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

66
web/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.5.1",
"markdown-it": "^14.1.1",
"pg": "^8.13.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
@@ -1144,6 +1145,12 @@
"node": ">=0.4.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/c12": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz",
@@ -1428,6 +1435,15 @@
"license": "MIT",
"optional": true
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1437,6 +1453,41 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.1",
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -1729,6 +1780,15 @@
"node": ">=12.11.0"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -1869,6 +1929,12 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"license": "0BSD"
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",

View File

@@ -14,6 +14,7 @@
"@vitejs/plugin-vue": "^5.2.4",
"@vueuse/motion": "^3.0.3",
"chart.js": "^4.5.1",
"markdown-it": "^14.1.1",
"pg": "^8.13.1",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",

View File

@@ -27,7 +27,7 @@
.talk-content header strong { color: #334155; font-size: 14px; font-weight: 800; }
.talk-content header time { color: #94a3b8; font-size: 12px; }
.user-question { display: inline-block; margin: 0; padding: 9px 16px; border-radius: 8px; background: #e8f5ef; color: #334155; font-size: 14px; line-height: 1.5; }
.answer-card, .agent-answer { max-width: 760px; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; color: #334155; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.answer-card, .agent-answer-markdown { max-width: 760px; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; color: #334155; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.answer-card { display: grid; gap: 10px; padding: 13px 18px; }
.answer-card.compact { gap: 10px; }
.answer-card h4 { margin: 0 0 5px; color: #10a272; font-size: 13px; font-weight: 850; }
@@ -36,14 +36,41 @@
.answer-card footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: #64748b; font-size: 12px; }
.answer-card footer button { width: 28px; height: 28px; display: grid; place-items: center; border: 0; border-radius: 6px; background: transparent; color: #64748b; }
.answer-card footer button:hover { background: #f1f5f9; color: #0f9f78; }
.agent-answer { margin: 0; padding: 12px 16px; font-size: 14px; line-height: 1.65; }
.agent-answer-content { max-width: 760px; display: grid; gap: 10px; }
.agent-answer-table-wrap { overflow-x: auto; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); }
.agent-answer-table { width: 100%; min-width: 360px; border-collapse: collapse; font-size: 13px; }
.agent-answer-table th, .agent-answer-table td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; text-align: left; }
.agent-answer-table th { background: #eef7f2; color: #0f172a; font-weight: 800; }
.agent-answer-table td { color: #334155; font-weight: 650; }
.agent-answer-table tbody tr:last-child td { border-bottom: 0; }
.agent-answer-markdown { padding: 12px 16px; overflow-x: auto; font-size: 14px; }
.agent-answer-markdown :deep(h1),
.agent-answer-markdown :deep(h2),
.agent-answer-markdown :deep(h3),
.agent-answer-markdown :deep(h4),
.agent-answer-markdown :deep(p),
.agent-answer-markdown :deep(ul),
.agent-answer-markdown :deep(ol),
.agent-answer-markdown :deep(blockquote),
.agent-answer-markdown :deep(pre) { margin: 0; }
.agent-answer-markdown :deep(h1) { font-size: 18px; }
.agent-answer-markdown :deep(h2) { font-size: 16px; }
.agent-answer-markdown :deep(h3),
.agent-answer-markdown :deep(h4) { font-size: 14px; }
.agent-answer-markdown :deep(h1),
.agent-answer-markdown :deep(h2),
.agent-answer-markdown :deep(h3),
.agent-answer-markdown :deep(h4) { color: #0f172a; line-height: 1.35; }
.agent-answer-markdown :deep(p),
.agent-answer-markdown :deep(li) { line-height: 1.65; }
.agent-answer-markdown :deep(ul),
.agent-answer-markdown :deep(ol) { padding-left: 22px; }
.agent-answer-markdown :deep(blockquote) { padding: 10px 12px; border-left: 4px solid #86efac; border-radius: 0 10px 10px 0; background: #f0fdf4; color: #475569; }
.agent-answer-markdown :deep(strong) { color: #0f172a; }
.agent-answer-markdown :deep(code) { padding: 2px 6px; border-radius: 6px; background: #e2e8f0; font-size: 12px; }
.agent-answer-markdown :deep(pre) { overflow-x: auto; padding: 12px; border-radius: 10px; background: #0f172a; color: #e2e8f0; }
.agent-answer-markdown :deep(pre code) { padding: 0; background: transparent; color: inherit; }
.agent-answer-markdown :deep(a) { color: #059669; text-decoration: underline; }
.agent-answer-markdown :deep(table) { width: auto; max-width: 100%; border: 1px solid #dce5ef; border-radius: 10px; border-collapse: collapse; background: #fff; box-shadow: 0 8px 24px rgba(15, 23, 42, .04); font-size: 13px; }
.agent-answer-markdown :deep(th),
.agent-answer-markdown :deep(td) { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; text-align: left; white-space: nowrap; }
.agent-answer-markdown :deep(th) { background: #eef7f2; color: #0f172a; font-weight: 800; }
.agent-answer-markdown :deep(td) { color: #334155; font-weight: 650; }
.agent-answer-markdown :deep(tbody tr:last-child td) { border-bottom: 0; }
.agent-meta-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; max-width: 760px; }
.agent-meta-chip { min-height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: #eef7f2; color: #0f766e; font-size: 12px; font-weight: 760; }
.agent-detail-block { max-width: 760px; margin-top: 10px; display: grid; gap: 8px; }

View File

@@ -98,11 +98,11 @@
margin-top: 16px;
}
.folder-rail {
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
.folder-rail {
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
border-right: 1px solid #edf2f7;
padding-right: 12px;
}
@@ -136,21 +136,26 @@
font-weight: 850;
}
.folder-tree b {
min-width: 24px;
height: 20px;
display: inline-flex;
.folder-tree b {
min-width: 24px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
font-size: 11px;
}
.new-folder-btn {
min-height: 36px;
display: inline-flex;
color: #64748b;
font-size: 11px;
}
.folder-sync-block {
display: grid;
gap: 8px;
}
.new-folder-btn {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
@@ -162,11 +167,27 @@
font-weight: 850;
}
.new-folder-btn.fixed {
border-color: rgba(148, 163, 184, 0.3);
background: #f8fafc;
color: #64748b;
}
.new-folder-btn.fixed {
border-color: rgba(148, 163, 184, 0.3);
background: #f8fafc;
color: #64748b;
}
.knowledge-sync-btn:not(:disabled) {
cursor: pointer;
}
.knowledge-sync-btn:not(:disabled):hover {
border-color: rgba(16, 185, 129, 0.38);
background: #ecfdf5;
color: #047857;
}
.folder-sync-meta {
color: #64748b;
font-size: 12px;
line-height: 1.6;
}
.document-area {
min-width: 0;
@@ -339,6 +360,19 @@ th {
background: #fee2e2;
color: #dc2626;
}
.state-cell {
display: grid;
justify-items: center;
gap: 6px;
}
.state-time {
color: #64748b;
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
}
.more-btn {
width: 32px;

View File

@@ -384,44 +384,118 @@
gap: 12px;
}
.message-answer-content p {
.message-answer-content :deep(p),
.message-answer-content :deep(ul),
.message-answer-content :deep(ol),
.message-answer-content :deep(blockquote),
.message-answer-content :deep(pre) {
margin: 0;
}
.message-answer-table-wrap {
overflow-x: auto;
border: 1px solid #dbe4ee;
border-radius: 16px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
.message-answer-markdown :deep(h1),
.message-answer-markdown :deep(h2),
.message-answer-markdown :deep(h3),
.message-answer-markdown :deep(h4) {
margin: 0;
color: #0f172a;
line-height: 1.35;
}
.message-answer-table {
width: 100%;
min-width: 360px;
.message-answer-markdown :deep(h1) {
font-size: 18px;
}
.message-answer-markdown :deep(h2) {
font-size: 16px;
}
.message-answer-markdown :deep(h3),
.message-answer-markdown :deep(h4) {
font-size: 14px;
}
.message-answer-markdown :deep(p),
.message-answer-markdown :deep(li) {
line-height: 1.7;
}
.message-answer-markdown :deep(ul),
.message-answer-markdown :deep(ol) {
padding-left: 22px;
}
.message-answer-markdown :deep(strong) {
color: #0f172a;
}
.message-answer-markdown :deep(blockquote) {
padding: 10px 12px;
border-left: 4px solid #93c5fd;
border-radius: 0 12px 12px 0;
background: #eff6ff;
color: #475569;
}
.message-answer-markdown :deep(code) {
padding: 2px 6px;
border-radius: 6px;
background: #e2e8f0;
font-size: 12px;
}
.message-answer-markdown :deep(pre) {
overflow-x: auto;
padding: 12px;
border-radius: 14px;
background: #0f172a;
color: #e2e8f0;
}
.message-answer-markdown :deep(pre code) {
padding: 0;
background: transparent;
color: inherit;
}
.message-answer-markdown :deep(a) {
color: #2563eb;
text-decoration: underline;
}
.message-answer-markdown {
overflow-x: auto;
}
.message-answer-markdown :deep(table) {
width: auto;
max-width: 100%;
border: 1px solid #dbe4ee;
border-radius: 16px;
border-collapse: collapse;
overflow: hidden;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
font-size: 13px;
}
.message-answer-table th,
.message-answer-table td {
.message-answer-markdown :deep(th),
.message-answer-markdown :deep(td) {
padding: 10px 12px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
white-space: nowrap;
}
.message-answer-table th {
.message-answer-markdown :deep(th) {
background: #eff6ff;
color: #0f172a;
font-weight: 850;
}
.message-answer-table td {
.message-answer-markdown :deep(td) {
color: #334155;
font-weight: 650;
}
.message-answer-table tbody tr:last-child td {
.message-answer-markdown :deep(tbody tr:last-child td) {
border-bottom: 0;
}

View File

@@ -226,9 +226,11 @@ export async function apiRequest(path, options = {}) {
contentType = 'application/json',
responseType = 'json',
headers: customHeaders,
...fetchOptions
} = options
timeoutMs = 0,
timeoutMessage = '',
...fetchOptions
} = options
const headers = sanitizeHeaders({
...readCurrentUserHeaders(),
...(customHeaders || {})
@@ -237,23 +239,44 @@ export async function apiRequest(path, options = {}) {
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
headers['Content-Type'] = contentType
}
let response
let response
let timeoutId = 0
let requestOptions = {
...fetchOptions,
headers
}
if (!fetchOptions.signal && Number(timeoutMs) > 0 && typeof AbortController !== 'undefined') {
const controller = new AbortController()
timeoutId = globalThis.setTimeout(() => controller.abort(), Number(timeoutMs))
requestOptions = {
...requestOptions,
signal: controller.signal
}
}
try {
response = await fetch(buildUrl(path), {
...fetchOptions,
headers
})
response = await fetch(buildUrl(path), requestOptions)
} catch (error) {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
if (error?.name === 'AbortError') {
throw new Error(String(timeoutMessage || '').trim() || '接口请求超时,请稍后重试。')
}
if (String(error?.message || '').includes('ByteString')) {
throw new Error('当前登录用户信息包含浏览器不支持的请求头字符,请重新登录后重试。')
}
throw new Error('无法连接 FastAPI 后端服务,请确认后端已启动且浏览器可访问后端端口。')
}
if (responseType === 'blob') {
if (timeoutId) {
globalThis.clearTimeout(timeoutId)
}
if (responseType === 'blob') {
if (!response.ok) {
let payload = null

View File

@@ -1,20 +1,9 @@
import { apiRequest } from './api.js'
import { apiRequest } from './api.js'
export function fetchKnowledgeLibrary() {
return apiRequest('/knowledge/library')
}
export function fetchLlmWikiDocumentDetail(documentId) {
return apiRequest(`/knowledge/llm-wiki/documents/${documentId}`)
}
export function updateLlmWikiDocumentSummary(documentId, payload) {
return apiRequest(`/knowledge/llm-wiki/documents/${documentId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function fetchKnowledgeDocument(documentId) {
return apiRequest(`/knowledge/documents/${documentId}`)
}
@@ -40,12 +29,12 @@ export function deleteKnowledgeDocument(documentId) {
})
}
export function syncKnowledgeDocumentToLlmWiki({ folder, documentId, force = false }) {
return apiRequest('/knowledge/llm-wiki/sync', {
export function syncKnowledgeLibrary({ folder, documentIds = [], force = false }) {
return apiRequest('/knowledge/sync', {
method: 'POST',
body: JSON.stringify({
folder,
document_ids: documentId ? [documentId] : [],
document_ids: Array.isArray(documentIds) ? documentIds : [],
force
})
})

View File

@@ -1,9 +1,10 @@
import { apiRequest } from './api.js'
export function runOrchestrator(payload) {
export function runOrchestrator(payload, options = {}) {
return apiRequest('/orchestrator/run', {
method: 'POST',
body: JSON.stringify(payload)
body: JSON.stringify(payload),
...options
})
}

12
web/src/utils/markdown.js Normal file
View File

@@ -0,0 +1,12 @@
import MarkdownIt from 'markdown-it'
const markdown = new MarkdownIt({
html: false,
linkify: true,
breaks: true
})
export function renderMarkdown(text = '') {
const normalized = String(text || '').trim()
return normalized ? markdown.render(normalized) : ''
}

View File

@@ -109,25 +109,11 @@
<time>刚刚</time>
</header>
<p v-if="message.role === 'user'" class="user-question">{{ message.text }}</p>
<div v-else class="agent-answer-content">
<template v-for="(block, blockIndex) in buildAnswerBlocks(message.text)" :key="`${message.id}-block-${blockIndex}`">
<p v-if="block.type === 'paragraph'" class="agent-answer">{{ block.text }}</p>
<div v-else-if="block.type === 'table'" class="agent-answer-table-wrap">
<table class="agent-answer-table">
<thead>
<tr>
<th v-for="header in block.headers" :key="`${message.id}-head-${header}`">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in block.rows" :key="`${message.id}-row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`${message.id}-cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div
v-else
class="agent-answer-content agent-answer-markdown"
v-html="renderMarkdown(message.text)"
></div>
<div v-if="message.role !== 'user' && message.meta?.length" class="agent-meta-row">
<span v-for="item in message.meta" :key="item" class="agent-meta-chip">{{ item }}</span>
</div>

View File

@@ -1,363 +1,363 @@
<template>
<section class="log-detail-page">
<div class="detail-scroll">
<article v-if="loading" class="panel detail-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载日志详情</strong>
<p>系统正在读取当前记录的结构化信息</p>
</article>
<article v-else-if="error" class="panel detail-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>日志详情加载失败</strong>
<p>{{ error }}</p>
<button type="button" @click="loadDetail">重新加载</button>
</article>
<template v-else-if="isHermes && hermesRun">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
{{ resolveRunLevel(hermesRun) }}
</span>
<span class="status-pill" :class="resolveStatusTone(hermesRun.status)">
{{ resolveStatusLabel(hermesRun.status) }}
</span>
</div>
<h2>{{ resolveRunTitle(hermesRun) }}</h2>
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
</div>
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
</article>
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>基本信息</h3>
<p>围绕当前 Hermes 任务查看关键字段</p>
</div>
<div class="info-grid">
<div><span>Trace ID</span><strong>{{ hermesRun.run_id }}</strong></div>
<div><span>开始时间</span><strong>{{ formatDateTime(hermesRun.started_at) }}</strong></div>
<div><span>结束时间</span><strong>{{ formatDateTime(hermesRun.finished_at) }}</strong></div>
<div><span>来源</span><strong>{{ resolveRunSourceLabel(hermesRun.source) }}</strong></div>
<div><span>模块</span><strong>{{ resolveRunModuleLabel(hermesRun) }}</strong></div>
<div><span>当前进度</span><strong>{{ resolveRunProgress(hermesRun) }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p>
</div>
<div v-if="(hermesRun.tool_calls || []).length" class="trace-steps">
<button
v-for="(toolCall, index) in hermesRun.tool_calls || []"
:key="toolCall.id"
type="button"
class="trace-step"
:class="{ active: selectedToolCall?.id === toolCall.id }"
@click="selectedToolCallId = toolCall.id"
>
<span class="step-index">{{ index + 1 }}</span>
<div class="step-copy">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type }} · {{ toolCall.duration_ms }}ms</span>
</div>
<span class="status-pill" :class="resolveToolStatusTone(toolCall.status)">
{{ toolCall.status }}
</span>
</button>
</div>
<div v-else class="inline-empty">当前运行暂无 ToolCall 明细</div>
</article>
<article v-if="selectedToolCall" class="panel detail-card">
<div class="card-head">
<h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p>
</div>
<div class="payload-grid">
<div>
<h4>请求参数</h4>
<pre class="code-block">{{ formatJson(selectedToolCall.request_json) }}</pre>
</div>
<div>
<h4>返回结果</h4>
<pre class="code-block">{{ formatJson(selectedToolCall.response_json) }}</pre>
</div>
</div>
</article>
<article class="panel detail-card wide">
<div class="card-head">
<h3>路由上下文</h3>
<p>保留 Hermes 路由与进度原文便于管理员核查</p>
</div>
<pre class="code-block large">{{ formatJson(hermesRun.route_json) }}</pre>
</article>
</div>
</template>
<template v-else-if="isSystem && systemEntry">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveSystemLevelTone(systemEntry.level)">
{{ systemEntry.level }}
</span>
<span class="status-pill" :class="resolveSystemOutcomeTone(systemEntry.outcome)">
{{ systemEntry.outcome }}
</span>
</div>
<h2>{{ systemEntry.summary || systemEntry.message }}</h2>
<p>{{ systemEntry.event_type }} · {{ systemEntry.logger || '未标记模块' }}</p>
</div>
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
</article>
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>解析结果</h3>
<p>按单条系统日志提取出的结构化字段</p>
</div>
<div class="info-grid">
<div><span>发生时间</span><strong>{{ formatDateTime(systemEntry.timestamp) }}</strong></div>
<div><span>日志来源</span><strong>{{ systemEntry.source_file }} #{{ systemEntry.line_number }}</strong></div>
<div><span>模块</span><strong>{{ systemEntry.logger || '未标记' }}</strong></div>
<div><span>Request ID</span><strong>{{ systemEntry.request_id || '—' }}</strong></div>
<div><span>请求方法</span><strong>{{ systemEntry.method || '—' }}</strong></div>
<div><span>响应状态</span><strong>{{ systemEntry.status_code ?? '—' }}</strong></div>
<div><span>请求路径</span><strong>{{ systemEntry.path || '—' }}</strong></div>
<div><span>处理耗时</span><strong>{{ systemEntry.duration_ms == null ? '—' : `${systemEntry.duration_ms}ms` }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>解析反馈</h3>
<p>系统对当前日志的归类与处理建议</p>
</div>
<div class="feedback-grid">
<div><span>事件类型</span><strong>{{ systemEntry.event_type }}</strong></div>
<div><span>解析状态</span><strong>{{ resolveSystemParseLabel(systemEntry.parse_status) }}</strong></div>
<div><span>处理结果</span><strong>{{ systemEntry.outcome }}</strong></div>
<div><span>建议动作</span><strong>{{ resolveSystemRecommendation(systemEntry) }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>原始日志</h3>
<p>保留该条记录的完整原文便于排障核对</p>
</div>
<pre class="code-block large">{{ systemEntry.raw }}</pre>
</article>
</div>
</template>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="backToLogs">
<i class="mdi mdi-arrow-left"></i>
<span>返回日志列表</span>
</button>
</footer>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchAgentRunDetail } from '../services/agentAssets.js'
import { fetchSystemLogEntry } from '../services/systemLogs.js'
const SOURCE_LABELS = {
schedule: '定时任务',
system_event: '系统事件',
user_message: '用户触发'
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const error = ref('')
const hermesRun = ref(null)
const systemEntry = ref(null)
const selectedToolCallId = ref('')
const isHermes = computed(() => route.params.logKind === 'hermes')
const isSystem = computed(() => route.params.logKind === 'system')
const selectedToolCall = computed(() =>
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
)
function formatDateTime(value) {
if (!value) return '未结束'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
}
function formatJson(value) {
try {
return JSON.stringify(value || {}, null, 2)
} catch {
return String(value || '')
}
}
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'
return 'muted'
}
function resolveToolStatusTone(status) {
return status === 'succeeded' ? 'success' : status === 'failed' ? 'danger' : 'warning'
}
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'
return 'INFO'
}
function resolveLevelTone(level) {
if (level === 'ERROR') return 'danger'
if (level === 'WARN') return 'warning'
if (level === 'INFO') return 'info'
return 'muted'
}
function resolveRunProgress(run) {
const progress = run?.route_json?.progress || {}
const percent = Number(progress.percent || 0)
const completed = Number(progress.completed_documents || 0)
const total = Number(progress.total_documents || 0)
const failed = Number(progress.failed_documents || 0)
const stage = String(progress.current_stage || '').trim()
const stageLabelMap = {
document_started: '文档启动',
text_extracted: '文本已提取',
candidate_chunks_selected: '已筛正文',
extracting_candidates: '候选提炼中',
candidate_extraction_completed: '候选提炼完成',
document_completed: '文档完成',
skipped: '跳过'
}
const stageLabel = stageLabelMap[stage] || (stage || '等待中')
return total > 0
? `${percent}% · ${completed}/${total} 文档 · ${stageLabel}${failed > 0 ? ` · 失败 ${failed}` : ''}`
: `${percent}% · ${stageLabel}`
}
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 resolveSystemParseLabel(status) {
return status === 'parsed' ? '已结构化' : '待人工核查'
}
function resolveSystemRecommendation(entry) {
if (!entry) return '暂无'
if (entry.outcome === '失败') return '优先排查并关联上下游日志'
if (entry.outcome === '异常' || entry.outcome === '告警') return '建议继续关注同模块后续记录'
if (entry.parse_status !== 'parsed') return '建议人工核对原始日志'
return '无需额外处理'
}
function syncSelectedToolCall() {
const calls = hermesRun.value?.tool_calls || []
if (!calls.length) {
selectedToolCallId.value = ''
return
}
if (!calls.some((item) => item.id === selectedToolCallId.value)) {
selectedToolCallId.value = calls[0].id
}
}
async function loadDetail() {
loading.value = true
error.value = ''
try {
const id = String(route.params.logId || '')
if (isHermes.value) {
hermesRun.value = await fetchAgentRunDetail(id)
systemEntry.value = null
syncSelectedToolCall()
return
}
if (isSystem.value) {
systemEntry.value = await fetchSystemLogEntry(id)
hermesRun.value = null
return
}
throw new Error('不支持的日志类型。')
} catch (nextError) {
error.value = nextError?.message || '日志详情加载失败。'
} finally {
loading.value = false
}
}
function backToLogs() {
router.push({ name: 'app-logs' })
}
watch(() => [route.params.logKind, route.params.logId], loadDetail)
onMounted(loadDetail)
</script>
<style scoped src="../assets/styles/views/log-detail-view.css"></style>
<template>
<section class="log-detail-page">
<div class="detail-scroll">
<article v-if="loading" class="panel detail-state">
<i class="mdi mdi-loading mdi-spin"></i>
<strong>正在加载日志详情</strong>
<p>系统正在读取当前记录的结构化信息</p>
</article>
<article v-else-if="error" class="panel detail-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>日志详情加载失败</strong>
<p>{{ error }}</p>
<button type="button" @click="loadDetail">重新加载</button>
</article>
<template v-else-if="isHermes && hermesRun">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveLevelTone(resolveRunLevel(hermesRun))">
{{ resolveRunLevel(hermesRun) }}
</span>
<span class="status-pill" :class="resolveStatusTone(hermesRun.status)">
{{ resolveStatusLabel(hermesRun.status) }}
</span>
</div>
<h2>{{ resolveRunTitle(hermesRun) }}</h2>
<p>{{ hermesRun.result_summary || '暂无运行摘要。' }}</p>
</div>
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
</article>
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>基本信息</h3>
<p>围绕当前 Hermes 任务查看关键字段</p>
</div>
<div class="info-grid">
<div><span>Trace ID</span><strong>{{ hermesRun.run_id }}</strong></div>
<div><span>开始时间</span><strong>{{ formatDateTime(hermesRun.started_at) }}</strong></div>
<div><span>结束时间</span><strong>{{ formatDateTime(hermesRun.finished_at) }}</strong></div>
<div><span>来源</span><strong>{{ resolveRunSourceLabel(hermesRun.source) }}</strong></div>
<div><span>模块</span><strong>{{ resolveRunModuleLabel(hermesRun) }}</strong></div>
<div><span>当前进度</span><strong>{{ resolveRunProgress(hermesRun) }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>处理链路</h3>
<p>按工具调用顺序查看执行链</p>
</div>
<div v-if="(hermesRun.tool_calls || []).length" class="trace-steps">
<button
v-for="(toolCall, index) in hermesRun.tool_calls || []"
:key="toolCall.id"
type="button"
class="trace-step"
:class="{ active: selectedToolCall?.id === toolCall.id }"
@click="selectedToolCallId = toolCall.id"
>
<span class="step-index">{{ index + 1 }}</span>
<div class="step-copy">
<strong>{{ toolCall.tool_name }}</strong>
<span>{{ toolCall.tool_type }} · {{ toolCall.duration_ms }}ms</span>
</div>
<span class="status-pill" :class="resolveToolStatusTone(toolCall.status)">
{{ toolCall.status }}
</span>
</button>
</div>
<div v-else class="inline-empty">当前运行暂无 ToolCall 明细</div>
</article>
<article v-if="selectedToolCall" class="panel detail-card">
<div class="card-head">
<h3>当前 ToolCall</h3>
<p>查看当前工具调用的请求与返回</p>
</div>
<div class="payload-grid">
<div>
<h4>请求参数</h4>
<pre class="code-block">{{ formatJson(selectedToolCall.request_json) }}</pre>
</div>
<div>
<h4>返回结果</h4>
<pre class="code-block">{{ formatJson(selectedToolCall.response_json) }}</pre>
</div>
</div>
</article>
<article class="panel detail-card wide">
<div class="card-head">
<h3>路由上下文</h3>
<p>保留 Hermes 路由与进度原文便于管理员核查</p>
</div>
<pre class="code-block large">{{ formatJson(hermesRun.route_json) }}</pre>
</article>
</div>
</template>
<template v-else-if="isSystem && systemEntry">
<article class="detail-hero panel">
<div class="hero-copy">
<div class="hero-tags">
<span class="level-pill" :class="resolveSystemLevelTone(systemEntry.level)">
{{ systemEntry.level }}
</span>
<span class="status-pill" :class="resolveSystemOutcomeTone(systemEntry.outcome)">
{{ systemEntry.outcome }}
</span>
</div>
<h2>{{ systemEntry.summary || systemEntry.message }}</h2>
<p>{{ systemEntry.event_type }} · {{ systemEntry.logger || '未标记模块' }}</p>
</div>
<button class="refresh-btn" type="button" :disabled="loading" @click="loadDetail">
<i class="mdi mdi-refresh"></i>
<span>刷新详情</span>
</button>
</article>
<div class="detail-grid">
<article class="panel detail-card wide">
<div class="card-head">
<h3>解析结果</h3>
<p>按单条系统日志提取出的结构化字段</p>
</div>
<div class="info-grid">
<div><span>发生时间</span><strong>{{ formatDateTime(systemEntry.timestamp) }}</strong></div>
<div><span>日志来源</span><strong>{{ systemEntry.source_file }} #{{ systemEntry.line_number }}</strong></div>
<div><span>模块</span><strong>{{ systemEntry.logger || '未标记' }}</strong></div>
<div><span>Request ID</span><strong>{{ systemEntry.request_id || '—' }}</strong></div>
<div><span>请求方法</span><strong>{{ systemEntry.method || '—' }}</strong></div>
<div><span>响应状态</span><strong>{{ systemEntry.status_code ?? '—' }}</strong></div>
<div><span>请求路径</span><strong>{{ systemEntry.path || '—' }}</strong></div>
<div><span>处理耗时</span><strong>{{ systemEntry.duration_ms == null ? '—' : `${systemEntry.duration_ms}ms` }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>解析反馈</h3>
<p>系统对当前日志的归类与处理建议</p>
</div>
<div class="feedback-grid">
<div><span>事件类型</span><strong>{{ systemEntry.event_type }}</strong></div>
<div><span>解析状态</span><strong>{{ resolveSystemParseLabel(systemEntry.parse_status) }}</strong></div>
<div><span>处理结果</span><strong>{{ systemEntry.outcome }}</strong></div>
<div><span>建议动作</span><strong>{{ resolveSystemRecommendation(systemEntry) }}</strong></div>
</div>
</article>
<article class="panel detail-card">
<div class="card-head">
<h3>原始日志</h3>
<p>保留该条记录的完整原文便于排障核对</p>
</div>
<pre class="code-block large">{{ systemEntry.raw }}</pre>
</article>
</div>
</template>
</div>
<footer class="detail-actions">
<button class="back-action" type="button" @click="backToLogs">
<i class="mdi mdi-arrow-left"></i>
<span>返回日志列表</span>
</button>
</footer>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchAgentRunDetail } from '../services/agentAssets.js'
import { fetchSystemLogEntry } from '../services/systemLogs.js'
const SOURCE_LABELS = {
schedule: '定时任务',
system_event: '系统事件',
user_message: '用户触发'
}
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const error = ref('')
const hermesRun = ref(null)
const systemEntry = ref(null)
const selectedToolCallId = ref('')
const isHermes = computed(() => route.params.logKind === 'hermes')
const isSystem = computed(() => route.params.logKind === 'system')
const selectedToolCall = computed(() =>
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
)
function formatDateTime(value) {
if (!value) return '未结束'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
}
function formatJson(value) {
try {
return JSON.stringify(value || {}, null, 2)
} catch {
return String(value || '')
}
}
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'
return 'muted'
}
function resolveToolStatusTone(status) {
return status === 'succeeded' ? 'success' : status === 'failed' ? 'danger' : 'warning'
}
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'
return 'INFO'
}
function resolveLevelTone(level) {
if (level === 'ERROR') return 'danger'
if (level === 'WARN') return 'warning'
if (level === 'INFO') return 'info'
return 'muted'
}
function resolveRunProgress(run) {
const progress = run?.route_json?.progress || {}
const percent = Number(progress.percent || 0)
const completed = Number(progress.completed_documents || 0)
const total = Number(progress.total_documents || 0)
const failed = Number(progress.failed_documents || 0)
const stage = String(progress.current_stage || '').trim()
const stageLabelMap = {
document_started: '文档启动',
text_extracted: '文本已提取',
candidate_chunks_selected: '已筛正文',
extracting_candidates: '候选提炼中',
candidate_extraction_completed: '候选提炼完成',
document_completed: '文档完成',
skipped: '跳过'
}
const stageLabel = stageLabelMap[stage] || (stage || '等待中')
return total > 0
? `${percent}% · ${completed}/${total} 文档 · ${stageLabel}${failed > 0 ? ` · 失败 ${failed}` : ''}`
: `${percent}% · ${stageLabel}`
}
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 resolveSystemParseLabel(status) {
return status === 'parsed' ? '已结构化' : '待人工核查'
}
function resolveSystemRecommendation(entry) {
if (!entry) return '暂无'
if (entry.outcome === '失败') return '优先排查并关联上下游日志'
if (entry.outcome === '异常' || entry.outcome === '告警') return '建议继续关注同模块后续记录'
if (entry.parse_status !== 'parsed') return '建议人工核对原始日志'
return '无需额外处理'
}
function syncSelectedToolCall() {
const calls = hermesRun.value?.tool_calls || []
if (!calls.length) {
selectedToolCallId.value = ''
return
}
if (!calls.some((item) => item.id === selectedToolCallId.value)) {
selectedToolCallId.value = calls[0].id
}
}
async function loadDetail() {
loading.value = true
error.value = ''
try {
const id = String(route.params.logId || '')
if (isHermes.value) {
hermesRun.value = await fetchAgentRunDetail(id)
systemEntry.value = null
syncSelectedToolCall()
return
}
if (isSystem.value) {
systemEntry.value = await fetchSystemLogEntry(id)
hermesRun.value = null
return
}
throw new Error('不支持的日志类型。')
} catch (nextError) {
error.value = nextError?.message || '日志详情加载失败。'
} finally {
loading.value = false
}
}
function backToLogs() {
router.push({ name: 'app-logs' })
}
watch(() => [route.params.logKind, route.params.logId], loadDetail)
onMounted(loadDetail)
</script>
<style scoped src="../assets/styles/views/log-detail-view.css"></style>

View File

@@ -1,15 +1,15 @@
<template>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': previewLayoutState.usesSplitLayout }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后以弹窗方式展开预览</p>
</div>
<label class="file-search">
<i class="mdi mdi-magnify"></i>
<section class="knowledge-page">
<div class="knowledge-grid" :class="{ 'has-preview': previewLayoutState.usesSplitLayout }">
<section class="knowledge-main">
<article class="library-panel panel">
<header class="panel-title">
<div>
<h2>文档库 / 文件夹</h2>
<p>默认展示文件列表点击具体文件后以弹窗方式展开预览</p>
</div>
<label class="file-search">
<i class="mdi mdi-magnify"></i>
<input v-model="documentSearch" type="search" placeholder="搜索当前文件夹内文件" />
</label>
</header>
@@ -30,35 +30,43 @@
</button>
</nav>
<button class="new-folder-btn fixed" type="button" disabled>
<i class="mdi mdi-lock-outline"></i>
<span>固定文件夹</span>
</button>
<div class="folder-sync-block">
<button
class="new-folder-btn fixed knowledge-sync-btn"
type="button"
:disabled="!canTriggerKnowledgeSync"
@click="handleKnowledgeSync"
>
<i :class="syncingFolder ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-book-sync-outline'"></i>
<span>{{ knowledgeSyncButtonLabel }}</span>
</button>
<p class="folder-sync-meta">{{ knowledgeSyncHint }}</p>
</div>
</aside>
<section class="document-area" :class="{ 'read-only': !isAdmin }">
<div
v-if="isAdmin"
class="upload-zone"
:class="{ busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
<section class="document-area" :class="{ 'read-only': !isAdmin }">
<div
v-if="isAdmin"
class="upload-zone"
:class="{ busy: uploading }"
@click="triggerUpload"
@dragover.prevent
@drop.prevent="handleDrop"
>
<input
ref="uploadInput"
class="upload-input"
type="file"
multiple
@change="handleFileInput"
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<table class="knowledge-document-table">
/>
<i class="mdi mdi-cloud-upload"></i>
<strong>{{ uploading ? '正在上传文件...' : '拖拽文档到此处,或点击上传' }}</strong>
<span>{{ uploadHint }}</span>
</div>
<div class="doc-table-wrap">
<table class="knowledge-document-table">
<thead>
<tr>
<th>文件名称</th>
@@ -89,53 +97,35 @@
</td>
<td>{{ doc.time }}</td>
<td>{{ doc.version }}</td>
<td><span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span></td>
<td>
<div class="state-cell">
<span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span>
<span v-if="doc.ingestTime" class="state-time">归纳时间{{ doc.ingestTime }}</span>
</div>
</td>
<td>{{ doc.owner }}</td>
<td>
<div class="row-actions" @click.stop>
<button
v-if="isAdmin"
class="more-btn ingest"
type="button"
:disabled="Boolean(ingestingId) || deletingId === doc.id || Number(doc.stateCode || 0) === 2"
:aria-label="resolveIngestActionTitle(doc)"
:title="resolveIngestActionTitle(doc)"
@click="handleManualIngest(doc)"
>
<i class="mdi mdi-book-sync-outline"></i>
</button>
<button
v-if="isAdmin"
class="more-btn llm-wiki-view"
:class="{ 'is-disabled': !canViewLlmWiki(doc) }"
type="button"
:aria-label="resolveViewLlmWikiTitle(doc)"
:aria-disabled="!canViewLlmWiki(doc)"
:title="resolveViewLlmWikiTitle(doc)"
@click="openLlmWikiSummary(doc)"
>
<i class="mdi mdi-eye-outline"></i>
</button>
<button
class="more-btn"
type="button"
aria-label="下载文件"
title="下载当前文件"
@click="handleDownload(doc)"
>
<i class="mdi mdi-download"></i>
</button>
<button
v-if="isAdmin"
class="more-btn danger"
type="button"
:disabled="deletingId === doc.id || ingestingId === doc.id"
aria-label="删除文件"
title="删除当前文件"
@click="handleDelete(doc)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
<td>
<div class="row-actions" @click.stop>
<button
class="more-btn"
type="button"
aria-label="下载文件"
title="归纳时间:?"
@click="handleDownload(doc)"
>
<i class="mdi mdi-download"></i>
</button>
<button
v-if="isAdmin"
class="more-btn danger"
type="button"
:disabled="deletingId === doc.id || Number(doc.stateCode || 0) === 2"
aria-label="删除文件"
title="归纳时间:?"
@click="handleDelete(doc)"
>
<i class="mdi mdi-delete-outline"></i>
</button>
</div>
</td>
</tr>
@@ -190,29 +180,29 @@
</footer>
</section>
</div>
</article>
</section>
<Teleport to="body">
<Transition name="preview-modal">
<div
v-if="previewLayoutState.isPreviewModalOpen"
class="preview-modal-overlay"
role="presentation"
@click.self="closePreview"
>
<aside class="preview-modal-shell" role="dialog" aria-modal="true" aria-labelledby="knowledge-preview-title">
<article
ref="previewDialogPanel"
class="preview-panel preview-modal-panel panel"
tabindex="-1"
@click.stop
>
<header class="preview-head">
<div class="preview-copy">
<h2 id="knowledge-preview-title">{{ selectedDocument.name }}</h2>
<p class="preview-summary-line">
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
</p>
</article>
</section>
<Teleport to="body">
<Transition name="preview-modal">
<div
v-if="previewLayoutState.isPreviewModalOpen"
class="preview-modal-overlay"
role="presentation"
@click.self="closePreview"
>
<aside class="preview-modal-shell" role="dialog" aria-modal="true" aria-labelledby="knowledge-preview-title">
<article
ref="previewDialogPanel"
class="preview-panel preview-modal-panel panel"
tabindex="-1"
@click.stop
>
<header class="preview-head">
<div class="preview-copy">
<h2 id="knowledge-preview-title">{{ selectedDocument.name }}</h2>
<p class="preview-summary-line">
<span v-for="part in previewMetaLine" :key="part">{{ part }}</span>
</p>
<div v-if="previewSecondaryMetaLine.length" class="preview-secondary-line">
<span v-for="part in previewSecondaryMetaLine" :key="part">{{ part }}</span>
</div>
@@ -227,34 +217,34 @@
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="preview-viewer">
<div v-if="shouldRenderOnlyOffice" class="onlyoffice-preview-wrap">
<div
v-if="shouldRenderOnlyOfficeHostNode"
:id="onlyOfficeHostId"
class="onlyoffice-preview-host"
></div>
<div v-if="onlyOfficeLoading" class="preview-status preview-status-overlay">
正在加载 ONLYOFFICE 预览...
</div>
<div v-else-if="onlyOfficeError" class="preview-status error preview-status-overlay">
{{ onlyOfficeError }}
</div>
</div>
<div v-else-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="previewMode === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
</div>
<div v-else-if="previewMode === 'image' && previewBlobUrl" class="preview-image-wrap">
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
</div>
<div v-else-if="previewMode === 'table'" class="excel-preview-wrap">
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
<button
v-for="(page, index) in selectedDocument.previewPages"
</header>
<div class="preview-viewer">
<div v-if="shouldRenderOnlyOffice" class="onlyoffice-preview-wrap">
<div
v-if="shouldRenderOnlyOfficeHostNode"
:id="onlyOfficeHostId"
class="onlyoffice-preview-host"
></div>
<div v-if="onlyOfficeLoading" class="preview-status preview-status-overlay">
正在加载 ONLYOFFICE 预览...
</div>
<div v-else-if="onlyOfficeError" class="preview-status error preview-status-overlay">
{{ onlyOfficeError }}
</div>
</div>
<div v-else-if="previewLoading" class="preview-status">正在加载预览...</div>
<div v-else-if="previewError" class="preview-status error">{{ previewError }}</div>
<div v-else-if="previewMode === 'pdf' && previewBlobUrl" class="preview-embed-wrap">
<iframe :src="previewBlobUrl" class="preview-embed" title="PDF 预览"></iframe>
</div>
<div v-else-if="previewMode === 'image' && previewBlobUrl" class="preview-image-wrap">
<img :src="previewBlobUrl" :alt="selectedDocument.name" class="preview-image" />
</div>
<div v-else-if="previewMode === 'table'" class="excel-preview-wrap">
<div v-if="selectedDocument.previewPages.length > 1" class="excel-sheet-tabs" role="tablist" aria-label="Excel 工作表页签">
<button
v-for="(page, index) in selectedDocument.previewPages"
:key="`${selectedDocument.id}-sheet-${index}`"
type="button"
class="excel-sheet-tab"
@@ -299,163 +289,33 @@
</article>
<div v-if="!selectedDocument.previewPages.length" class="preview-status">
当前文件暂未生成结构化预览请下载后查看
</div>
</div>
</div>
</article>
</aside>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<Transition name="preview-modal">
<div
v-if="llmWikiDialogOpen"
class="preview-modal-overlay llm-wiki-overlay"
role="presentation"
@click.self="closeLlmWikiSummary"
>
<aside class="preview-modal-shell llm-wiki-shell" role="dialog" aria-modal="true" aria-labelledby="llm-wiki-title">
<article
ref="llmWikiDialogPanel"
class="preview-panel preview-modal-panel llm-wiki-panel panel"
tabindex="-1"
@click.stop
>
<header class="preview-head llm-wiki-head">
<div class="preview-copy">
<h2 id="llm-wiki-title">LLM Wiki 归纳内容</h2>
<p>{{ llmWikiDocument ? llmWikiDocument.document_name : '正在读取当前文档的 LLM Wiki 知识总结。' }}</p>
<div v-if="llmWikiDocument" class="llm-wiki-meta">
<span>版本 {{ llmWikiDocument.document_version }}</span>
<span>{{ llmWikiDocument.chunk_count }} 个分块</span>
<span>{{ llmWikiDocument.knowledge_candidate_count }} 条知识</span>
<span>{{ llmWikiDocument.rule_candidate_count }} 条规则草稿</span>
</div>
</div>
<div class="preview-actions">
<button
v-if="isAdmin"
type="button"
class="mini-action"
:disabled="llmWikiLoading || llmWikiSaving || !llmWikiDocument"
@click="saveLlmWikiSummary"
>
<i class="mdi mdi-content-save-outline"></i>
<span>{{ llmWikiSaving ? '保存中...' : '保存总结' }}</span>
</button>
<button type="button" class="icon-action" aria-label="关闭归纳内容" @click="closeLlmWikiSummary">
<i class="mdi mdi-close"></i>
</button>
</div>
</header>
<div class="llm-wiki-body">
<div v-if="llmWikiLoading" class="preview-status">正在加载 LLM Wiki 归纳内容...</div>
<div v-else-if="llmWikiError" class="preview-status error">{{ llmWikiError }}</div>
<div v-else-if="llmWikiDocument" class="llm-wiki-grid">
<section class="llm-wiki-section llm-wiki-summary-section">
<div
v-if="llmWikiDocument.quality_status !== 'formal'"
class="llm-wiki-alert"
:class="resolveLlmWikiQualityTone(llmWikiDocument)"
>
<strong>{{ resolveLlmWikiQualityLabel(llmWikiDocument) }}</strong>
<p>{{ llmWikiDocument.quality_note || '当前展示内容不是正式 Hermes 归纳,请人工复核后再使用。' }}</p>
</div>
<div
v-else-if="llmWikiDocument.quality_note"
class="llm-wiki-alert info"
>
<strong>{{ resolveLlmWikiQualityLabel(llmWikiDocument) }}</strong>
<p>{{ llmWikiDocument.quality_note }}</p>
</div>
<div class="llm-wiki-section-head">
<div>
<h3>知识总结</h3>
<p>管理员可在审核后修订这份归纳总结作为知识预览内容保留</p>
</div>
<span class="llm-wiki-count">{{ llmWikiDocument.knowledge_candidate_count }} 条知识</span>
</div>
<div class="llm-wiki-stat-grid">
<span>正文分块 {{ llmWikiDocument.candidate_chunk_count }}</span>
<span>过滤分块 {{ llmWikiDocument.filtered_chunk_count }}</span>
<span>成功分组 {{ llmWikiDocument.successful_group_count }}/{{ llmWikiDocument.group_count }}</span>
<span>正式知识 {{ llmWikiDocument.formal_knowledge_candidate_count }}</span>
</div>
<textarea
v-model="llmWikiSummaryDraft"
class="llm-wiki-editor"
spellcheck="false"
placeholder="Hermes 归纳后的知识总结会显示在这里。"
></textarea>
</section>
<section class="llm-wiki-section llm-wiki-candidates-section">
<div class="llm-wiki-section-head">
<div>
<h3>知识条目预览</h3>
<p>展示 Hermes 已提炼出的知识点方便与总结内容逐项比对</p>
</div>
</div>
<div v-if="llmWikiDocument.knowledge_candidates.length" class="llm-wiki-candidate-list">
<article
v-for="candidate in llmWikiDocument.knowledge_candidates"
:key="candidate.candidate_id"
class="llm-wiki-candidate-card"
>
<header>
<strong>{{ candidate.title }}</strong>
<span>{{ candidate.scenario }}</span>
</header>
<p>{{ candidate.content }}</p>
<div v-if="candidate.tags.length" class="llm-wiki-chip-list">
<span
v-for="tag in candidate.tags"
:key="`${candidate.candidate_id}-${tag}`"
>
{{ tag }}
</span>
</div>
<ul v-if="candidate.evidence.length" class="llm-wiki-evidence">
<li
v-for="(evidence, index) in candidate.evidence.slice(0, 3)"
:key="`${candidate.candidate_id}-evidence-${index}`"
>
{{ evidence }}
</li>
</ul>
</article>
</div>
<div v-else class="preview-status">当前文档暂无可展示的知识条目</div>
</section>
</div>
</div>
</article>
</aside>
</div>
</Transition>
</Teleport>
</div>
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除文件"
badge-tone="danger"
:title="`确认删除文件“${deleteTargetDocument?.name || ''}”吗?`"
description="删除后该知识库文件及其当前版本记录将不可恢复,请确认本次操作。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
:busy="Boolean(deletingId)"
@close="closeDeleteDialog"
@confirm="confirmDeleteDocument"
/>
</section>
</template>
</div>
</div>
</div>
</article>
</aside>
</div>
</Transition>
</Teleport>
</div>
<ConfirmDialog
:open="deleteDialogOpen"
badge="删除文件"
badge-tone="danger"
:title="`确认删除文件“${deleteTargetDocument?.name || ''}”吗?`"
description="删除后该知识库文件及其当前版本记录将不可恢复,请确认本次操作。"
cancel-text="取消"
confirm-text="确认删除"
busy-text="删除中..."
confirm-tone="danger"
confirm-icon="mdi mdi-delete-outline"
:busy="Boolean(deletingId)"
@close="closeDeleteDialog"
@confirm="confirmDeleteDocument"
/>
</section>
</template>
<script src="./scripts/PoliciesView.js"></script>

View File

@@ -357,117 +357,121 @@
<i :class="getModelTestState('backup').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('backup').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('backup').message }}</span>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>VLM 模型</h4>
<p>用于票据图像等多模态识别场景的视觉语言模型</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('vlm')" @click="testModelConnection('vlm')">
<i :class="isModelTesting('vlm') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('vlm') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.vlmProvider" @change="applyProviderPreset('vlm')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.vlmModel" type="text" placeholder="请输入 VLM 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.vlmEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.vlmApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('vlm')"
:placeholder="pageState.llmForm.vlmApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.vlmApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div v-if="getModelTestState('vlm').message" class="test-feedback" :class="`is-${getModelTestState('vlm').status}`">
<i :class="getModelTestState('vlm').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('vlm').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('vlm').message }}</span>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>Embedding 模型配置</h4>
<p>用于向量检索知识库召回和语义匹配的嵌入模型设置</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('embedding')" @click="testModelConnection('embedding')">
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.embeddingProvider" @change="applyProviderPreset('embedding')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.embeddingApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('embedding').message"
class="test-feedback"
:class="`is-${getModelTestState('embedding').status}`"
>
<i :class="getModelTestState('embedding').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('embedding').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('embedding').message }}</span>
</div>
</section>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>Embedding 模型</h4>
<p>用于向量检索知识库召回和语义匹配的嵌入模型</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('embedding')" @click="testModelConnection('embedding')">
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.embeddingProvider" @change="applyProviderPreset('embedding')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.embeddingApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('embedding')"
:placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('embedding').message"
class="test-feedback"
:class="`is-${getModelTestState('embedding').status}`"
>
<i :class="getModelTestState('embedding').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('embedding').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('embedding').message }}</span>
</div>
</section>
<section class="settings-card">
<div class="card-head">
<div>
<h4>Reranker 模型配置</h4>
<p>用于检索结果重排和语义精排的 Reranker 模型设置</p>
</div>
<div class="card-head-actions">
<button class="test-button" type="button" :disabled="isModelTesting('reranker')" @click="testModelConnection('reranker')">
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
</button>
</div>
</div>
<div class="form-grid">
<label class="field">
<span><em>*</em> 供应商</span>
<select v-model="pageState.llmForm.rerankerProvider" @change="applyProviderPreset('reranker')">
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
</select>
</label>
<label class="field">
<span><em>*</em> 模型名称</span>
<input v-model="pageState.llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
</label>
<label class="field field-full">
<span><em>*</em> 接口地址</span>
<input v-model="pageState.llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
</label>
<label class="field field-full">
<span>API Key</span>
<input
v-model="pageState.llmForm.rerankerApiKey"
type="password"
autocomplete="off"
@focus="clearModelSecretMask('reranker')"
:placeholder="pageState.llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载测试会使用已保存密钥</span>
</small>
</label>
</div>
<div
v-if="getModelTestState('reranker').message"
class="test-feedback"
:class="`is-${getModelTestState('reranker').status}`"
>
<i :class="getModelTestState('reranker').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('reranker').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
<span>{{ getModelTestState('reranker').message }}</span>
</div>
</section>
</div>
</template>

View File

@@ -89,28 +89,11 @@
{{ message.text }}
</p>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content"
>
<template v-for="(block, blockIndex) in buildAnswerBlocks(message.text)" :key="`${message.id}-block-${blockIndex}`">
<p v-if="block.type === 'paragraph'">{{ block.text }}</p>
<div v-else-if="block.type === 'table'" class="message-answer-table-wrap">
<table class="message-answer-table">
<thead>
<tr>
<th v-for="header in block.headers" :key="`${message.id}-head-${header}`">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in block.rows" :key="`${message.id}-row-${rowIndex}`">
<td v-for="(cell, cellIndex) in row" :key="`${message.id}-cell-${rowIndex}-${cellIndex}`">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
></div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>

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,

View File

@@ -48,13 +48,13 @@ async function testSupportsBlobResponses() {
async function testInjectsAuthenticatedUserHeaders() {
const sessionStorage = new Map([
[
'x-financial-auth-user',
JSON.stringify({
username: 'admin',
name: '系统管理员',
roleCodes: ['manager'],
isAdmin: true
})
'x-financial-auth-user',
JSON.stringify({
username: 'admin',
name: 'Admin User',
roleCodes: ['manager'],
isAdmin: true
})
]
])
@@ -79,9 +79,9 @@ async function testInjectsAuthenticatedUserHeaders() {
}
await apiRequest('/knowledge/library')
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
assert.equal(capturedOptions.headers['x-auth-name'], '系统管理员')
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User')
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
}
@@ -117,11 +117,35 @@ async function testFormatsValidationErrors() {
)
}
async function testRejectsWithCustomTimeoutMessage() {
global.fetch = async (_url, options) =>
new Promise((_, reject) => {
options.signal.addEventListener('abort', () => {
const error = new Error('aborted')
error.name = 'AbortError'
reject(error)
})
})
await assert.rejects(
() =>
apiRequest('/knowledge/library', {
timeoutMs: 1,
timeoutMessage: '知识问答整理超时,已停止等待。'
}),
(error) => {
assert.equal(error.message, '知识问答整理超时,已停止等待。')
return true
}
)
}
async function run() {
await testUsesCustomContentTypeHeader()
await testSupportsBlobResponses()
await testInjectsAuthenticatedUserHeaders()
await testFormatsValidationErrors()
await testRejectsWithCustomTimeoutMessage()
console.log('api-request tests passed')
}

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
function testLlmSectionReplacesVlmWithReranker() {
assert.doesNotMatch(settingsView, /VLM 模型/)
assert.match(settingsView, /Reranker 模型配置/)
assert.match(settingsScript, /rerankerProvider/)
}
function testRerankerCardRendersAfterEmbeddingCard() {
assert.match(settingsView, /Embedding 模型配置[\s\S]*Reranker 模型配置/)
}
function run() {
testLlmSectionReplacesVlmWithReranker()
testRerankerCardRendersAfterEmbeddingCard()
console.log('settings llm section tests passed')
}
run()