feat: 重构知识库系统,移除Hermes集成,增强RAG和同步功能

主要变更:
- 移除Hermes智能体及相关回调服务
- 新增知识库RAG、同步、调度、规范化和索引任务服务
- 重构orchestrator服务,增强运行时聊天功能
- 更新前端聊天、政策制度、设置等页面样式和逻辑
- 更新expense_claims和document_intelligence服务
- 删除llm_wiki相关服务和测试文件
- 更新docker-compose配置和启动脚本
This commit is contained in:
caoxiaozhu
2026-05-17 08:38:41 +00:00
parent 212c935308
commit 68f663f2f4
308 changed files with 83729 additions and 13588 deletions

View File

@@ -1,287 +1,225 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchOntologyParse } from '../../services/ontology.js'
function isMarkdownTableDivider(line = '') {
const value = String(line || '').trim()
if (!value.includes('|')) return false
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value)
}
function splitMarkdownTableRow(line = '') {
return String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function buildAnswerBlocks(text = '') {
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index].trim()
if (!line) {
index += 1
continue
}
if (
line.includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
) {
const headers = splitMarkdownTableRow(line)
const rows = []
index += 2
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
rows.push(splitMarkdownTableRow(lines[index]))
index += 1
}
blocks.push({ type: 'table', headers, rows })
continue
}
const paragraphLines = [line]
index += 1
while (
index < lines.length &&
lines[index].trim() &&
!(
lines[index].includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
)
) {
paragraphLines.push(lines[index].trim())
index += 1
}
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') })
}
return blocks
}
export default {
name: 'ChatView',
props: {
documents: { type: Array, required: true },
docSearch: { type: String, default: '' },
messages: { type: Array, required: true },
uploadedFiles: { type: Array, required: true },
activeCase: { type: Object, default: null },
quickPrompts: { type: Array, required: true },
draft: { type: String, default: '' },
sending: { type: Boolean, default: false },
messageList: { type: Object, default: null }
},
emits: ['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'],
setup(props, { emit }) {
const { currentUser } = useSystemState()
const localMessageList = ref(null)
const promptPage = ref(0)
const semanticDraft = ref('查一下本周报销超标风险')
const semanticLoading = ref(false)
const semanticError = ref('')
const semanticResult = ref(null)
const sessions = [
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
{ title: '发票抬头不一致怎么办', time: '09:48' },
{ title: '借款冲销流程', time: '昨天' },
{ title: '预算占用失败处理', time: '昨天' },
{ title: '招待费报销范围', time: '05-11' },
{ title: '差旅住宿标准如何匹配城市级别?', time: '05-10' },
{ title: '电子发票验真失败如何处理?', time: '05-09' },
{ title: '跨部门项目费用怎么归集?', time: '05-08' },
{ title: '会议费和招待费如何区分?', time: '05-07' },
{ title: '超预算申请需要哪些审批节点?', time: '05-06' },
{ title: '海外差旅汇率按哪天计算?', time: '05-05' },
{ title: '员工退票手续费是否可报销?', time: '05-04' }
]
const prompts = [
{ icon: 'mdi mdi-bed-outline', short: '差旅标准', text: '差旅报销特殊标准是什么?' },
{ icon: 'mdi mdi-receipt-text-outline', short: '发票规范', text: '发票丢失如何处理?' },
{ icon: 'mdi mdi-cash-refund', short: '借款冲销', text: '借款多久内需要冲销?' },
{ icon: 'mdi mdi-file-chart-outline', short: '预算冲突', text: '预算不足如何申请?' },
{ icon: 'mdi mdi-shield-check-outline', short: '审批要求', text: '酒店超标后如何申请例外报销?' },
{ icon: 'mdi mdi-office-building-marker', short: '住宿标准', text: '差旅住宿标准按什么规则执行?' },
{ icon: 'mdi mdi-file-question-outline', short: '材料补齐', text: '报销附件缺失怎么补交?' },
{ icon: 'mdi mdi-alert-circle-outline', short: '风险等级', text: '哪些情况会触发中风险?' }
]
const visiblePrompts = computed(() => prompts.slice((promptPage.value % 2) * 4, (promptPage.value % 2) * 4 + 4))
const hotQuestions = [
'差旅报销标准是什么?',
'酒店超标后如何申请例外报销?',
'发票丢失如何处理?',
'借款多久内需要冲销?',
'预算超支如何申请?',
'招待费报销需要哪些凭证?',
'发票抬头不一致是否允许报销?',
'报销附件缺失怎么补交?',
'差旅住宿标准按什么规则执行?',
'电子发票验真失败如何处理?'
]
const similarQuestions = [
{ text: '酒店超标后如何申请例外报销?', score: '96%' },
{ text: '发票抬头不一致是否允许报销?', score: '92%' },
{ text: '差旅住宿标准按什么规则执行?', score: '89%' },
{ text: '预算不足时能否先提交报销?', score: '86%' },
{ text: '电子发票验真失败是否可以先报销?', score: '84%' },
{ text: '跨部门项目费用如何归集?', score: '81%' },
{ text: '招待费报销需要哪些凭证?', score: '78%' },
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
]
const semanticExamples = [
'查一下本周报销超标风险',
'客户 A 这个月还有多少应收',
'帮我直接付款给供应商B'
]
const semanticConfidenceLabel = computed(() =>
semanticResult.value ? `${Math.round((semanticResult.value.confidence || 0) * 100)}%` : '未解析'
)
const semanticEntitiesText = computed(() => {
const items = semanticResult.value?.entities || []
return items.length
? items.map((item) => `${item.type}:${item.normalized_value}`).join(' / ')
: '未识别'
})
const semanticTimeRangeText = computed(() => {
const value = semanticResult.value?.time_range
if (!value?.start_date || !value?.end_date) {
return '未识别'
}
return `${value.start_date} ~ ${value.end_date}${value.granularity ? ` · ${value.granularity}` : ''}`
})
const semanticMetricsText = computed(() => {
const items = semanticResult.value?.metrics || []
return items.length
? items
.map((item) => {
const suffix = item.top_n ? ` top_${item.top_n}` : ''
return `${item.name}${suffix}`
})
.join(' / ')
: '未识别'
})
const semanticConstraintsText = computed(() => {
const items = semanticResult.value?.constraints || []
return items.length
? items.map((item) => `${item.field}${item.operator}${item.value}`).join(' / ')
: '未识别'
})
const semanticRiskFlagsText = computed(() => {
const items = semanticResult.value?.risk_flags || []
return items.length ? items.join(' / ') : '未识别'
})
const semanticClarificationText = computed(() => {
if (!semanticResult.value) {
return '未解析'
}
if (!semanticResult.value.clarification_required) {
return '无需澄清'
}
return semanticResult.value.clarification_question || '需要补充更多上下文'
})
const semanticResultJson = computed(() =>
semanticResult.value ? JSON.stringify(semanticResult.value, null, 2) : ''
)
function rotatePrompts() {
promptPage.value += 1
}
function applyPrompt(text) {
emit('draft', text)
semanticDraft.value = text
}
function applySemanticExample(text) {
semanticDraft.value = text
}
function useDraftAsSemanticInput() {
semanticDraft.value = props.draft || semanticDraft.value
}
async function parseSemanticQuery() {
const query = String(semanticDraft.value || '').trim()
if (!query) {
semanticError.value = '请输入要解析的问题。'
semanticResult.value = null
return
}
semanticLoading.value = true
semanticError.value = ''
try {
semanticResult.value = await fetchOntologyParse({
query,
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
context_json: {
role_codes: currentUser.value?.roleCodes || [],
is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '',
role: currentUser.value?.role || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || ''
}
})
} catch (error) {
semanticResult.value = null
semanticError.value = error.message || '语义解析失败,请稍后重试。'
} finally {
semanticLoading.value = false
}
}
watch(
() => props.messages.length,
() => {
nextTick(() => localMessageList.value?.scrollTo({ top: localMessageList.value.scrollHeight, behavior: 'smooth' }))
}
)
return {
emit,
localMessageList,
promptPage,
sessions,
prompts,
visiblePrompts,
hotQuestions,
similarQuestions,
rotatePrompts,
applyPrompt,
semanticDraft,
semanticLoading,
semanticError,
semanticResult,
semanticExamples,
semanticConfidenceLabel,
semanticEntitiesText,
semanticTimeRangeText,
semanticMetricsText,
semanticConstraintsText,
semanticRiskFlagsText,
semanticClarificationText,
semanticResultJson,
buildAnswerBlocks,
applySemanticExample,
useDraftAsSemanticInput,
parseSemanticQuery
}
}
}
import { computed, nextTick, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { renderMarkdown } from '../../utils/markdown.js'
export default {
name: 'ChatView',
props: {
documents: { type: Array, required: true },
docSearch: { type: String, default: '' },
messages: { type: Array, required: true },
uploadedFiles: { type: Array, required: true },
activeCase: { type: Object, default: null },
quickPrompts: { type: Array, required: true },
draft: { type: String, default: '' },
sending: { type: Boolean, default: false },
messageList: { type: Object, default: null }
},
emits: ['send', 'upload', 'draft', 'approveCase', 'rejectCase', 'selectCase'],
setup(props, { emit }) {
const { currentUser } = useSystemState()
const localMessageList = ref(null)
const promptPage = ref(0)
const semanticDraft = ref('查一下本周报销超标风险')
const semanticLoading = ref(false)
const semanticError = ref('')
const semanticResult = ref(null)
const sessions = [
{ title: '北京出差,酒店超标报销怎么处理?', time: '10:32', active: true },
{ title: '发票抬头不一致怎么办', time: '09:48' },
{ title: '借款冲销流程', time: '昨天' },
{ title: '预算占用失败处理', time: '昨天' },
{ title: '招待费报销范围', time: '05-11' },
{ title: '差旅住宿标准如何匹配城市级别?', time: '05-10' },
{ title: '电子发票验真失败如何处理?', time: '05-09' },
{ title: '跨部门项目费用怎么归集?', time: '05-08' },
{ title: '会议费和招待费如何区分?', time: '05-07' },
{ title: '超预算申请需要哪些审批节点?', time: '05-06' },
{ title: '海外差旅汇率按哪天计算?', time: '05-05' },
{ title: '员工退票手续费是否可报销?', time: '05-04' }
]
const prompts = [
{ icon: 'mdi mdi-bed-outline', short: '差旅标准', text: '差旅报销特殊标准是什么?' },
{ icon: 'mdi mdi-receipt-text-outline', short: '发票规范', text: '发票丢失如何处理?' },
{ icon: 'mdi mdi-cash-refund', short: '借款冲销', text: '借款多久内需要冲销?' },
{ icon: 'mdi mdi-file-chart-outline', short: '预算冲突', text: '预算不足如何申请?' },
{ icon: 'mdi mdi-shield-check-outline', short: '审批要求', text: '酒店超标后如何申请例外报销?' },
{ icon: 'mdi mdi-office-building-marker', short: '住宿标准', text: '差旅住宿标准按什么规则执行?' },
{ icon: 'mdi mdi-file-question-outline', short: '材料补齐', text: '报销附件缺失怎么补交?' },
{ icon: 'mdi mdi-alert-circle-outline', short: '风险等级', text: '哪些情况会触发中风险?' }
]
const visiblePrompts = computed(() => prompts.slice((promptPage.value % 2) * 4, (promptPage.value % 2) * 4 + 4))
const hotQuestions = [
'差旅报销标准是什么?',
'酒店超标后如何申请例外报销?',
'发票丢失如何处理?',
'借款多久内需要冲销?',
'预算超支如何申请?',
'招待费报销需要哪些凭证?',
'发票抬头不一致是否允许报销?',
'报销附件缺失怎么补交?',
'差旅住宿标准按什么规则执行?',
'电子发票验真失败如何处理?'
]
const similarQuestions = [
{ text: '酒店超标后如何申请例外报销?', score: '96%' },
{ text: '发票抬头不一致是否允许报销?', score: '92%' },
{ text: '差旅住宿标准按什么规则执行?', score: '89%' },
{ text: '预算不足时能否先提交报销?', score: '86%' },
{ text: '电子发票验真失败是否可以先报销?', score: '84%' },
{ text: '跨部门项目费用如何归集?', score: '81%' },
{ text: '招待费报销需要哪些凭证?', score: '78%' },
{ text: '借款冲销逾期会影响报销吗?', score: '76%' }
]
const semanticExamples = [
'查一下本周报销超标风险',
'客户 A 这个月还有多少应收',
'帮我直接付款给供应商B'
]
const semanticConfidenceLabel = computed(() =>
semanticResult.value ? `${Math.round((semanticResult.value.confidence || 0) * 100)}%` : '未解析'
)
const semanticEntitiesText = computed(() => {
const items = semanticResult.value?.entities || []
return items.length
? items.map((item) => `${item.type}:${item.normalized_value}`).join(' / ')
: '未识别'
})
const semanticTimeRangeText = computed(() => {
const value = semanticResult.value?.time_range
if (!value?.start_date || !value?.end_date) {
return '未识别'
}
return `${value.start_date} ~ ${value.end_date}${value.granularity ? ` · ${value.granularity}` : ''}`
})
const semanticMetricsText = computed(() => {
const items = semanticResult.value?.metrics || []
return items.length
? items
.map((item) => {
const suffix = item.top_n ? ` top_${item.top_n}` : ''
return `${item.name}${suffix}`
})
.join(' / ')
: '未识别'
})
const semanticConstraintsText = computed(() => {
const items = semanticResult.value?.constraints || []
return items.length
? items.map((item) => `${item.field}${item.operator}${item.value}`).join(' / ')
: '未识别'
})
const semanticRiskFlagsText = computed(() => {
const items = semanticResult.value?.risk_flags || []
return items.length ? items.join(' / ') : '未识别'
})
const semanticClarificationText = computed(() => {
if (!semanticResult.value) {
return '未解析'
}
if (!semanticResult.value.clarification_required) {
return '无需澄清'
}
return semanticResult.value.clarification_question || '需要补充更多上下文'
})
const semanticResultJson = computed(() =>
semanticResult.value ? JSON.stringify(semanticResult.value, null, 2) : ''
)
function rotatePrompts() {
promptPage.value += 1
}
function applyPrompt(text) {
emit('draft', text)
semanticDraft.value = text
}
function applySemanticExample(text) {
semanticDraft.value = text
}
function useDraftAsSemanticInput() {
semanticDraft.value = props.draft || semanticDraft.value
}
async function parseSemanticQuery() {
const query = String(semanticDraft.value || '').trim()
if (!query) {
semanticError.value = '请输入要解析的问题。'
semanticResult.value = null
return
}
semanticLoading.value = true
semanticError.value = ''
try {
semanticResult.value = await fetchOntologyParse({
query,
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
context_json: {
role_codes: currentUser.value?.roleCodes || [],
is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '',
role: currentUser.value?.role || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || ''
}
})
} catch (error) {
semanticResult.value = null
semanticError.value = error.message || '语义解析失败,请稍后重试。'
} finally {
semanticLoading.value = false
}
}
watch(
() => props.messages.length,
() => {
nextTick(() => localMessageList.value?.scrollTo({ top: localMessageList.value.scrollHeight, behavior: 'smooth' }))
}
)
return {
emit,
localMessageList,
promptPage,
sessions,
prompts,
visiblePrompts,
hotQuestions,
similarQuestions,
rotatePrompts,
applyPrompt,
semanticDraft,
semanticLoading,
semanticError,
semanticResult,
semanticExamples,
semanticConfidenceLabel,
semanticEntitiesText,
semanticTimeRangeText,
semanticMetricsText,
semanticConstraintsText,
semanticRiskFlagsText,
semanticClarificationText,
semanticResultJson,
renderMarkdown,
applySemanticExample,
useDraftAsSemanticInput,
parseSemanticQuery
}
}
}

View File

@@ -1,470 +1,470 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
import DonutChart from '../../components/charts/DonutChart.vue'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { isManagerUser } from '../../utils/accessControl.js'
const POLL_INTERVAL_MS = 5000
const SOURCE_LABELS = {
schedule: '定时任务',
system_event: '系统事件',
user_message: '用户触发'
}
function formatDateTime(value) {
if (!value) {
return '未结束'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value)
}
return date.toLocaleString('zh-CN', { hour12: false })
}
function resolveStatusLabel(status) {
if (status === 'running') {
return '运行中'
}
if (status === 'succeeded') {
return '已完成'
}
if (status === 'failed') {
return '失败'
}
if (status === 'blocked') {
return '待确认'
}
return status || '未知'
}
function resolveStatusTone(status) {
if (status === 'running') {
return 'warning'
}
if (status === 'succeeded') {
return 'success'
}
if (status === 'failed') {
return 'danger'
}
if (status === 'blocked') {
return 'muted'
}
return 'muted'
}
function resolveRunSourceLabel(source) {
return SOURCE_LABELS[source] || source || '未标记'
}
function resolveRunModuleLabel(run) {
const routeJson = run?.route_json || {}
if (routeJson.job_type === 'llm_wiki_sync') {
return '知识归纳'
}
if (routeJson.selected_agent) {
return String(routeJson.selected_agent)
}
if (routeJson.folder) {
return String(routeJson.folder)
}
return resolveRunSourceLabel(run?.source)
}
function resolveRunTitle(run) {
const routeJson = run?.route_json || {}
if (routeJson.job_type === 'llm_wiki_sync') {
return `LLM Wiki 归纳 · ${routeJson.folder || '未指定目录'}`
}
return `Hermes 调用 · ${resolveRunModuleLabel(run)}`
}
function resolveRunLevel(run) {
const progress = run?.route_json?.progress || {}
if (run?.status === 'failed' || run?.error_message) {
return 'ERROR'
}
if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) {
return 'WARN'
}
if (run?.status === 'running') {
return 'INFO'
}
return 'INFO'
}
function resolveLevelTone(level) {
if (level === 'ERROR') {
return 'danger'
}
if (level === 'WARN') {
return 'warning'
}
if (level === 'INFO') {
return 'info'
}
return 'muted'
}
function formatSummary(summary) {
const text = String(summary || '').trim()
if (!text) {
return '暂无摘要。'
}
if (text.length <= 64) {
return text
}
return `${text.slice(0, 64)}...`
}
function resolveSystemLevelTone(level) {
if (level === 'ERROR' || level === 'CRITICAL') {
return 'danger'
}
if (level === 'WARNING' || level === 'WARN') {
return 'warning'
}
if (level === 'INFO') {
return 'info'
}
return 'muted'
}
function resolveSystemOutcomeTone(outcome) {
if (outcome === '失败') {
return 'danger'
}
if (outcome === '异常' || outcome === '告警') {
return 'warning'
}
if (outcome === '成功') {
return 'success'
}
return 'muted'
}
function formatHourBucketLabel(date) {
return `${String(date.getHours()).padStart(2, '0')}:00`
}
function buildTrendSeries(runs) {
const parsedTimes = runs
.map((run) => new Date(run?.started_at))
.filter((date) => !Number.isNaN(date.getTime()))
const latest = parsedTimes.length ? new Date(Math.max(...parsedTimes.map((date) => date.getTime()))) : new Date()
latest.setMinutes(0, 0, 0)
const buckets = Array.from({ length: 8 }, (_, index) => {
const date = new Date(latest)
date.setHours(latest.getHours() - (7 - index))
return {
key: date.toISOString(),
label: formatHourBucketLabel(date),
total: 0,
failed: 0
}
})
const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket]))
for (const run of runs) {
const date = new Date(run?.started_at)
if (Number.isNaN(date.getTime())) {
continue
}
date.setMinutes(0, 0, 0)
const bucket = bucketMap.get(date.toISOString())
if (!bucket) {
continue
}
bucket.total += 1
if (run.status === 'failed') {
bucket.failed += 1
}
}
return {
buckets,
labels: buckets.map((bucket) => bucket.label),
totals: buckets.map((bucket) => bucket.total),
failures: buckets.map((bucket) => bucket.failed)
}
}
export default {
name: 'LogsView',
components: {
LogTrendChart,
DonutChart
},
emits: ['summary-change'],
setup(_, { emit }) {
const router = useRouter()
const { currentUser } = useSystemState()
const { toast } = useToast()
const activeTab = ref('hermes')
const hermesLoading = ref(false)
const systemLogLoading = ref(false)
const hermesRuns = ref([])
const systemSearchKeyword = ref('')
const systemLevelFilter = ref('')
const systemEventTypeFilter = ref('')
const systemLogEntries = ref([])
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
let pollTimer = 0
const isAdmin = computed(() => isManagerUser(currentUser.value))
const filteredHermesRuns = computed(() => hermesRuns.value)
const systemLevelOptions = computed(() =>
Array.from(new Set(systemLogEntries.value.map((entry) => entry.level).filter(Boolean)))
)
const systemEventTypeOptions = computed(() =>
Array.from(new Set(systemLogEntries.value.map((entry) => entry.event_type).filter(Boolean)))
)
const filteredSystemLogEntries = computed(() => {
const keyword = systemSearchKeyword.value.trim().toLowerCase()
return systemLogEntries.value.filter((entry) => {
if (systemLevelFilter.value && entry.level !== systemLevelFilter.value) {
return false
}
if (systemEventTypeFilter.value && entry.event_type !== systemEventTypeFilter.value) {
return false
}
if (!keyword) {
return true
}
const haystack = [
entry.summary,
entry.message,
entry.logger,
entry.request_id,
entry.path,
entry.event_type,
entry.outcome,
entry.source_file
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
})
const hermesRunCount = computed(() => hermesRuns.value.length)
const runningRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'running').length)
const completedRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'succeeded').length)
const failedRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'failed').length)
const trendSeries = computed(() => buildTrendSeries(filteredHermesRuns.value))
const levelDistribution = computed(() => {
const items = [
{ level: 'INFO', count: 0, color: '#3b82f6' },
{ level: 'WARN', count: 0, color: '#f59e0b' },
{ level: 'ERROR', count: 0, color: '#ef4444' }
]
for (const run of filteredHermesRuns.value) {
const item = items.find((candidate) => candidate.level === resolveRunLevel(run))
if (item) {
item.count += 1
}
}
const total = items.reduce((sum, item) => sum + item.count, 0)
return {
items: items.map((item) => ({
name: item.level,
value: item.count,
color: item.color,
display: total ? `${item.count} (${Math.round((item.count / total) * 100)}%)` : '0'
})),
total
}
})
const activeRows = computed(() =>
activeTab.value === 'hermes' ? filteredHermesRuns.value : filteredSystemLogEntries.value
)
const totalCount = computed(() => activeRows.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visiblePageItems = computed(() => {
if (totalPages.value <= 6) {
return Array.from({ length: totalPages.value }, (_, index) => index + 1)
}
return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value]
})
const visibleHermesRuns = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredHermesRuns.value.slice(start, start + pageSize.value)
})
const visibleSystemLogEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
})
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
async function loadHermesRuns() {
if (!isAdmin.value) {
return
}
hermesLoading.value = true
try {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
hermesRuns.value = Array.isArray(payload) ? payload : []
} catch (error) {
toast(error.message || 'Hermes 日志加载失败。')
} finally {
hermesLoading.value = false
}
}
function selectRun(runId) {
router.push({
name: 'app-log-detail',
params: { logKind: 'hermes', logId: runId }
})
}
async function loadSystemLogs() {
if (!isAdmin.value) {
return
}
systemLogLoading.value = true
try {
const payload = await fetchSystemLogEntries(300)
systemLogEntries.value = Array.isArray(payload) ? payload : []
} catch (error) {
toast(error.message || '系统日志加载失败。')
} finally {
systemLogLoading.value = false
}
}
function selectSystemLog(entryId) {
router.push({
name: 'app-log-detail',
params: { logKind: 'system', logId: entryId }
})
}
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => {
loadHermesRuns()
loadSystemLogs()
}, POLL_INTERVAL_MS)
}
function stopPolling() {
if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = 0
}
}
watch(
[
activeTab,
systemSearchKeyword,
systemLevelFilter,
systemEventTypeFilter
],
() => {
currentPage.value = 1
}
)
watch(totalPages, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
watch(
() => [
hermesRunCount.value,
runningRunCount.value,
completedRunCount.value,
failedRunCount.value
],
([total, running, completed, failed]) => {
emit('summary-change', { total, running, completed, failed })
},
{ immediate: true }
)
onMounted(async () => {
await loadHermesRuns()
await loadSystemLogs()
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
return {
activeTab,
changePageSize,
completedRunCount,
failedRunCount,
filteredHermesRuns,
filteredSystemLogEntries,
formatDateTime,
formatSummary,
hermesLoading,
hermesRunCount,
hermesRuns,
isAdmin,
levelDistribution,
loadHermesRuns,
loadSystemLogs,
currentPage,
pageSize,
pageSizeOpen,
pageSizes,
resolveLevelTone,
resolveRunLevel,
resolveRunModuleLabel,
resolveRunSourceLabel,
resolveRunTitle,
resolveStatusLabel,
resolveStatusTone,
resolveSystemLevelTone,
resolveSystemOutcomeTone,
runningRunCount,
selectRun,
selectSystemLog,
systemEventTypeFilter,
systemEventTypeOptions,
systemLevelFilter,
systemLevelOptions,
systemLogEntries,
systemLogLoading,
systemSearchKeyword,
totalCount,
totalPages,
trendSeries,
visiblePageItems,
visibleHermesRuns,
visibleSystemLogEntries
}
}
}
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import LogTrendChart from '../../components/charts/LogTrendChart.vue'
import DonutChart from '../../components/charts/DonutChart.vue'
import { fetchAgentRuns } from '../../services/agentAssets.js'
import { fetchSystemLogEntries } from '../../services/systemLogs.js'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { isManagerUser } from '../../utils/accessControl.js'
const POLL_INTERVAL_MS = 5000
const SOURCE_LABELS = {
schedule: '定时任务',
system_event: '系统事件',
user_message: '用户触发'
}
function formatDateTime(value) {
if (!value) {
return '未结束'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value)
}
return date.toLocaleString('zh-CN', { hour12: false })
}
function resolveStatusLabel(status) {
if (status === 'running') {
return '运行中'
}
if (status === 'succeeded') {
return '已完成'
}
if (status === 'failed') {
return '失败'
}
if (status === 'blocked') {
return '待确认'
}
return status || '未知'
}
function resolveStatusTone(status) {
if (status === 'running') {
return 'warning'
}
if (status === 'succeeded') {
return 'success'
}
if (status === 'failed') {
return 'danger'
}
if (status === 'blocked') {
return 'muted'
}
return 'muted'
}
function resolveRunSourceLabel(source) {
return SOURCE_LABELS[source] || source || '未标记'
}
function resolveRunModuleLabel(run) {
const routeJson = run?.route_json || {}
if (routeJson.job_type === 'knowledge_index_sync' || routeJson.job_type === 'llm_wiki_sync') {
return '\u77e5\u8bc6\u5f52\u7eb3'
}
if (routeJson.selected_agent) {
return String(routeJson.selected_agent)
}
if (routeJson.folder) {
return String(routeJson.folder)
}
return resolveRunSourceLabel(run?.source)
}
function resolveRunTitle(run) {
const routeJson = run?.route_json || {}
if (routeJson.job_type === 'knowledge_index_sync' || routeJson.job_type === 'llm_wiki_sync') {
return `\u77e5\u8bc6\u5f52\u7eb3 \u00b7 ${routeJson.folder || '\u672a\u6307\u5b9a\u76ee\u5f55'}`
}
return `Hermes 调用 · ${resolveRunModuleLabel(run)}`
}
function resolveRunLevel(run) {
const progress = run?.route_json?.progress || {}
if (run?.status === 'failed' || run?.error_message) {
return 'ERROR'
}
if (run?.status === 'blocked' || Number(progress.failed_documents || 0) > 0) {
return 'WARN'
}
if (run?.status === 'running') {
return 'INFO'
}
return 'INFO'
}
function resolveLevelTone(level) {
if (level === 'ERROR') {
return 'danger'
}
if (level === 'WARN') {
return 'warning'
}
if (level === 'INFO') {
return 'info'
}
return 'muted'
}
function formatSummary(summary) {
const text = String(summary || '').trim()
if (!text) {
return '暂无摘要。'
}
if (text.length <= 64) {
return text
}
return `${text.slice(0, 64)}...`
}
function resolveSystemLevelTone(level) {
if (level === 'ERROR' || level === 'CRITICAL') {
return 'danger'
}
if (level === 'WARNING' || level === 'WARN') {
return 'warning'
}
if (level === 'INFO') {
return 'info'
}
return 'muted'
}
function resolveSystemOutcomeTone(outcome) {
if (outcome === '失败') {
return 'danger'
}
if (outcome === '异常' || outcome === '告警') {
return 'warning'
}
if (outcome === '成功') {
return 'success'
}
return 'muted'
}
function formatHourBucketLabel(date) {
return `${String(date.getHours()).padStart(2, '0')}:00`
}
function buildTrendSeries(runs) {
const parsedTimes = runs
.map((run) => new Date(run?.started_at))
.filter((date) => !Number.isNaN(date.getTime()))
const latest = parsedTimes.length ? new Date(Math.max(...parsedTimes.map((date) => date.getTime()))) : new Date()
latest.setMinutes(0, 0, 0)
const buckets = Array.from({ length: 8 }, (_, index) => {
const date = new Date(latest)
date.setHours(latest.getHours() - (7 - index))
return {
key: date.toISOString(),
label: formatHourBucketLabel(date),
total: 0,
failed: 0
}
})
const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket]))
for (const run of runs) {
const date = new Date(run?.started_at)
if (Number.isNaN(date.getTime())) {
continue
}
date.setMinutes(0, 0, 0)
const bucket = bucketMap.get(date.toISOString())
if (!bucket) {
continue
}
bucket.total += 1
if (run.status === 'failed') {
bucket.failed += 1
}
}
return {
buckets,
labels: buckets.map((bucket) => bucket.label),
totals: buckets.map((bucket) => bucket.total),
failures: buckets.map((bucket) => bucket.failed)
}
}
export default {
name: 'LogsView',
components: {
LogTrendChart,
DonutChart
},
emits: ['summary-change'],
setup(_, { emit }) {
const router = useRouter()
const { currentUser } = useSystemState()
const { toast } = useToast()
const activeTab = ref('hermes')
const hermesLoading = ref(false)
const systemLogLoading = ref(false)
const hermesRuns = ref([])
const systemSearchKeyword = ref('')
const systemLevelFilter = ref('')
const systemEventTypeFilter = ref('')
const systemLogEntries = ref([])
const currentPage = ref(1)
const pageSize = ref(10)
const pageSizes = [10, 20, 50]
const pageSizeOpen = ref(false)
let pollTimer = 0
const isAdmin = computed(() => isManagerUser(currentUser.value))
const filteredHermesRuns = computed(() => hermesRuns.value)
const systemLevelOptions = computed(() =>
Array.from(new Set(systemLogEntries.value.map((entry) => entry.level).filter(Boolean)))
)
const systemEventTypeOptions = computed(() =>
Array.from(new Set(systemLogEntries.value.map((entry) => entry.event_type).filter(Boolean)))
)
const filteredSystemLogEntries = computed(() => {
const keyword = systemSearchKeyword.value.trim().toLowerCase()
return systemLogEntries.value.filter((entry) => {
if (systemLevelFilter.value && entry.level !== systemLevelFilter.value) {
return false
}
if (systemEventTypeFilter.value && entry.event_type !== systemEventTypeFilter.value) {
return false
}
if (!keyword) {
return true
}
const haystack = [
entry.summary,
entry.message,
entry.logger,
entry.request_id,
entry.path,
entry.event_type,
entry.outcome,
entry.source_file
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(keyword)
})
})
const hermesRunCount = computed(() => hermesRuns.value.length)
const runningRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'running').length)
const completedRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'succeeded').length)
const failedRunCount = computed(() => hermesRuns.value.filter((run) => run.status === 'failed').length)
const trendSeries = computed(() => buildTrendSeries(filteredHermesRuns.value))
const levelDistribution = computed(() => {
const items = [
{ level: 'INFO', count: 0, color: '#3b82f6' },
{ level: 'WARN', count: 0, color: '#f59e0b' },
{ level: 'ERROR', count: 0, color: '#ef4444' }
]
for (const run of filteredHermesRuns.value) {
const item = items.find((candidate) => candidate.level === resolveRunLevel(run))
if (item) {
item.count += 1
}
}
const total = items.reduce((sum, item) => sum + item.count, 0)
return {
items: items.map((item) => ({
name: item.level,
value: item.count,
color: item.color,
display: total ? `${item.count} (${Math.round((item.count / total) * 100)}%)` : '0'
})),
total
}
})
const activeRows = computed(() =>
activeTab.value === 'hermes' ? filteredHermesRuns.value : filteredSystemLogEntries.value
)
const totalCount = computed(() => activeRows.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visiblePageItems = computed(() => {
if (totalPages.value <= 6) {
return Array.from({ length: totalPages.value }, (_, index) => index + 1)
}
return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value]
})
const visibleHermesRuns = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredHermesRuns.value.slice(start, start + pageSize.value)
})
const visibleSystemLogEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
})
function changePageSize(size) {
pageSize.value = size
pageSizeOpen.value = false
currentPage.value = 1
}
async function loadHermesRuns() {
if (!isAdmin.value) {
return
}
hermesLoading.value = true
try {
const payload = await fetchAgentRuns({ agent: 'hermes', limit: 100 })
hermesRuns.value = Array.isArray(payload) ? payload : []
} catch (error) {
toast(error.message || 'Hermes 日志加载失败。')
} finally {
hermesLoading.value = false
}
}
function selectRun(runId) {
router.push({
name: 'app-log-detail',
params: { logKind: 'hermes', logId: runId }
})
}
async function loadSystemLogs() {
if (!isAdmin.value) {
return
}
systemLogLoading.value = true
try {
const payload = await fetchSystemLogEntries(300)
systemLogEntries.value = Array.isArray(payload) ? payload : []
} catch (error) {
toast(error.message || '系统日志加载失败。')
} finally {
systemLogLoading.value = false
}
}
function selectSystemLog(entryId) {
router.push({
name: 'app-log-detail',
params: { logKind: 'system', logId: entryId }
})
}
function startPolling() {
stopPolling()
pollTimer = window.setInterval(() => {
loadHermesRuns()
loadSystemLogs()
}, POLL_INTERVAL_MS)
}
function stopPolling() {
if (pollTimer) {
window.clearInterval(pollTimer)
pollTimer = 0
}
}
watch(
[
activeTab,
systemSearchKeyword,
systemLevelFilter,
systemEventTypeFilter
],
() => {
currentPage.value = 1
}
)
watch(totalPages, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
watch(
() => [
hermesRunCount.value,
runningRunCount.value,
completedRunCount.value,
failedRunCount.value
],
([total, running, completed, failed]) => {
emit('summary-change', { total, running, completed, failed })
},
{ immediate: true }
)
onMounted(async () => {
await loadHermesRuns()
await loadSystemLogs()
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
return {
activeTab,
changePageSize,
completedRunCount,
failedRunCount,
filteredHermesRuns,
filteredSystemLogEntries,
formatDateTime,
formatSummary,
hermesLoading,
hermesRunCount,
hermesRuns,
isAdmin,
levelDistribution,
loadHermesRuns,
loadSystemLogs,
currentPage,
pageSize,
pageSizeOpen,
pageSizes,
resolveLevelTone,
resolveRunLevel,
resolveRunModuleLabel,
resolveRunSourceLabel,
resolveRunTitle,
resolveStatusLabel,
resolveStatusTone,
resolveSystemLevelTone,
resolveSystemOutcomeTone,
runningRunCount,
selectRun,
selectSystemLog,
systemEventTypeFilter,
systemEventTypeOptions,
systemLevelFilter,
systemLevelOptions,
systemLogEntries,
systemLogLoading,
systemSearchKeyword,
totalCount,
totalPages,
trendSeries,
visiblePageItems,
visibleHermesRuns,
visibleSystemLogEntries
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,8 +39,8 @@ const SECTION_DEFINITIONS = [
id: 'llm',
label: '大语言模型',
title: '模型接入配置',
desc: '主模型、备份模型与多模态模型',
longDesc: '集中维护主模型、备份模型、VLM 模型和 Embedding 模型的接入参数,供 AI 助手和识别链路调用。',
desc: '主模型、备份模型与检索模型',
longDesc: '集中维护主模型、备份模型、Embedding 模型和 Reranker 模型的接入参数,供 AI 助手和检索链路调用。',
actionLabel: '保存模型配置'
},
{
@@ -82,7 +82,7 @@ const PROVIDER_OPTIONS = [
CUSTOM_OPENAI_PROVIDER
]
const PROVIDER_ENDPOINTS = {
const PROVIDER_ENDPOINTS = {
MiniMax: 'https://api.minimaxi.com/v1',
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
Kimi: 'https://api.moonshot.ai/v1',
@@ -90,8 +90,13 @@ const PROVIDER_ENDPOINTS = {
Codex: 'https://api.openai.com/v1',
Claude: 'https://api.anthropic.com/v1/',
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
[CUSTOM_OPENAI_PROVIDER]: ''
}
[CUSTOM_OPENAI_PROVIDER]: ''
}
const RERANKER_PROVIDER_ENDPOINTS = {
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
[CUSTOM_OPENAI_PROVIDER]: ''
}
const LEGACY_PROVIDER_MAP = {
'OpenAI Compatible': 'Codex',
@@ -117,23 +122,23 @@ const MODEL_TEST_CONFIGS = {
apiKeyKey: 'backupApiKey',
capability: 'chat'
},
vlm: {
label: 'VLM 模型',
providerKey: 'vlmProvider',
modelKey: 'vlmModel',
endpointKey: 'vlmEndpoint',
apiKeyKey: 'vlmApiKey',
capability: 'chat'
},
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
}
}
embedding: {
label: 'Embedding 模型',
providerKey: 'embeddingProvider',
modelKey: 'embeddingModel',
endpointKey: 'embeddingEndpoint',
apiKeyKey: 'embeddingApiKey',
capability: 'embedding'
},
reranker: {
label: 'Reranker 模型',
providerKey: 'rerankerProvider',
modelKey: 'rerankerModel',
endpointKey: 'rerankerEndpoint',
apiKeyKey: 'rerankerApiKey',
capability: 'reranker'
}
}
const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
@@ -159,9 +164,13 @@ function normalizeProviderValue(value, fallback = 'Codex') {
return fallback
}
function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
function getProviderEndpoint(provider) {
return PROVIDER_ENDPOINTS[provider] ?? ''
}
function getRerankerEndpoint(provider) {
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
}
function buildDefaultState(companyProfile, currentUser) {
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
@@ -201,21 +210,21 @@ function buildDefaultState(companyProfile, currentUser) {
mainEndpoint: getProviderEndpoint('Codex'),
mainApiKey: '',
mainApiKeyConfigured: false,
backupProvider: 'GLM',
backupModel: 'glm-5.1',
backupEndpoint: getProviderEndpoint('GLM'),
backupApiKey: '',
backupApiKeyConfigured: false,
vlmProvider: 'Gemini',
vlmModel: 'gemini-2.5-flash',
vlmEndpoint: getProviderEndpoint('Gemini'),
vlmApiKey: '',
vlmApiKeyConfigured: false,
embeddingProvider: 'GLM',
embeddingModel: 'Embedding-3',
backupProvider: 'GLM',
backupModel: 'glm-5.1',
backupEndpoint: getProviderEndpoint('GLM'),
backupApiKey: '',
backupApiKeyConfigured: false,
embeddingProvider: 'GLM',
embeddingModel: 'Embedding-3',
embeddingEndpoint: getProviderEndpoint('GLM'),
embeddingApiKey: '',
embeddingApiKeyConfigured: false
embeddingApiKeyConfigured: false,
rerankerProvider: 'Ali',
rerankerModel: 'gte-rerank-v2',
rerankerEndpoint: getRerankerEndpoint('Ali'),
rerankerApiKey: '',
rerankerApiKeyConfigured: false
},
renderForm: {
enabled: false,
@@ -268,15 +277,18 @@ function readStoredSettings() {
}
function mergeState(baseState, overrideState) {
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
mergedLlmForm.vlmProvider = normalizeProviderValue(mergedLlmForm.vlmProvider, baseState.llmForm.vlmProvider)
mergedLlmForm.embeddingProvider = normalizeProviderValue(
mergedLlmForm.embeddingProvider,
baseState.llmForm.embeddingProvider
)
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
mergedLlmForm.embeddingProvider = normalizeProviderValue(
mergedLlmForm.embeddingProvider,
baseState.llmForm.embeddingProvider
)
mergedLlmForm.rerankerProvider = normalizeProviderValue(
mergedLlmForm.rerankerProvider,
baseState.llmForm.rerankerProvider
)
return {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
@@ -302,8 +314,8 @@ function sanitizeForStorage(state) {
...state.llmForm,
mainApiKey: '',
backupApiKey: '',
vlmApiKey: '',
embeddingApiKey: ''
embeddingApiKey: '',
rerankerApiKey: ''
},
renderForm: {
...state.renderForm,
@@ -402,11 +414,15 @@ function computeSectionStatus(state) {
llm: Boolean(
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
isModelConfigReady(state.llmForm.vlmProvider, state.llmForm.vlmModel, state.llmForm.vlmEndpoint) &&
isModelConfigReady(
state.llmForm.embeddingProvider,
state.llmForm.embeddingModel,
state.llmForm.embeddingEndpoint
) &&
isModelConfigReady(
state.llmForm.rerankerProvider,
state.llmForm.rerankerModel,
state.llmForm.rerankerEndpoint
)
),
rendering: Boolean(
@@ -440,11 +456,11 @@ export default {
const sessionRetentionPickerOpen = ref(false)
const sessionRetentionPickerRef = ref(null)
const modelTestState = ref({
main: { status: 'idle', message: '' },
backup: { status: 'idle', message: '' },
vlm: { status: 'idle', message: '' },
embedding: { status: 'idle', message: '' }
})
main: { status: 'idle', message: '' },
backup: { status: 'idle', message: '' },
embedding: { status: 'idle', message: '' },
reranker: { status: 'idle', message: '' }
})
const sections = SECTION_DEFINITIONS
const logLevels = LOG_LEVELS
@@ -484,8 +500,8 @@ export default {
if (preserveModelApiKeys) {
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
nextState.llmForm.vlmApiKey = currentState.llmForm.vlmApiKey
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
}
if (preserveAdminPasswords) {
@@ -582,7 +598,8 @@ export default {
const provider = normalizeProviderValue(llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
llmForm[config.providerKey] = provider
llmForm[config.endpointKey] = getProviderEndpoint(provider)
llmForm[config.endpointKey] =
slot === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
}
function getModelTestState(testKey) {
@@ -735,12 +752,12 @@ export default {
async function saveLlmSection() {
const llmForm = pageState.value.llmForm
const modelConfigs = [
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
['VLM 模型', llmForm.vlmProvider, llmForm.vlmModel, llmForm.vlmEndpoint],
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint]
]
const modelConfigs = [
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
]
for (const [label, provider, model, endpoint] of modelConfigs) {
if (!isModelConfigReady(provider, model, endpoint)) {

View File

@@ -6,6 +6,7 @@ import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { recognizeOcrFiles } from '../../services/ocr.js'
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js'
import {
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
@@ -228,69 +229,6 @@ function createMessage(role, text, attachments = [], extras = {}) {
}
}
function isMarkdownTableDivider(line = '') {
const value = String(line || '').trim()
if (!value.includes('|')) return false
return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(value)
}
function splitMarkdownTableRow(line = '') {
return String(line || '')
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function buildAnswerBlocks(text = '') {
const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index].trim()
if (!line) {
index += 1
continue
}
if (
line.includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
) {
const headers = splitMarkdownTableRow(line)
const rows = []
index += 2
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
rows.push(splitMarkdownTableRow(lines[index]))
index += 1
}
blocks.push({ type: 'table', headers, rows })
continue
}
const paragraphLines = [line]
index += 1
while (
index < lines.length &&
lines[index].trim() &&
!(
lines[index].includes('|') &&
index + 1 < lines.length &&
isMarkdownTableDivider(lines[index + 1])
)
) {
paragraphLines.push(lines[index].trim())
index += 1
}
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') })
}
return blocks
}
function formatMessageTime(value) {
if (!value) {
return nowTime()
@@ -3424,31 +3362,39 @@ export default {
}
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const payload = await runOrchestrator({
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
conversation_id: conversationId.value || null,
message: backendMessage,
context_json: {
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
position: user.position || '',
grade: user.grade || '',
...buildClientTimeContext(),
session_type: activeSessionType.value,
entry_source: props.entrySource,
user_input_text: systemGenerated ? '' : rawText,
attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
ocr_summary: effectiveOcrSummary,
ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
...extraContext
}
})
const payload = await runOrchestrator(
{
source: 'user_message',
user_id: user.username || user.name || 'anonymous',
conversation_id: conversationId.value || null,
message: backendMessage,
context_json: {
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
is_admin: Boolean(user.isAdmin),
name: user.name || '',
role: user.role || '',
position: user.position || '',
grade: user.grade || '',
...buildClientTimeContext(),
session_type: activeSessionType.value,
entry_source: props.entrySource,
user_input_text: systemGenerated ? '' : rawText,
attachment_names: effectiveFileNames,
attachment_count: effectiveFileNames.length,
draft_claim_id: isKnowledgeSession.value ? undefined : draftClaimId.value || undefined,
ocr_summary: effectiveOcrSummary,
ocr_documents: effectiveOcrDocuments,
...(linkedRequest.value && !isKnowledgeSession.value ? { request_context: linkedRequest.value } : {}),
...extraContext
}
},
isKnowledgeSession.value
? {
timeoutMs: 18000,
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
}
: {}
)
responsePayload = payload
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
@@ -3852,7 +3798,7 @@ export default {
buildReviewRiskHint,
buildReviewActionHint,
buildReviewStatusTag,
buildAnswerBlocks,
renderMarkdown,
buildExpenseQueryWindowLabel,
buildExpenseQueryHint,
getExpenseQueryActivePage,