2000 lines
112 KiB
HTML
2000 lines
112 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Agent 控制台 · 萧康云医 TCM Agent</title>
|
||
<!-- 内联 SVG favicon:避免浏览器请求 /favicon.ico 被鉴权中间件拦成 401 噪音 -->
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%234361ee'/%3E%3Ctext x='16' y='22' font-size='16' text-anchor='middle' fill='white' font-family='sans-serif'%3E%E5%8C%BB%3C/text%3E%3C/svg%3E" />
|
||
|
||
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.6/dist/index.css" />
|
||
<script src="https://unpkg.com/vue@3.4.38/dist/vue.global.prod.js"></script>
|
||
<script src="https://unpkg.com/element-plus@2.7.6/dist/index.full.min.js"></script>
|
||
<script src="https://unpkg.com/element-plus@2.7.6/dist/locale/zh-cn.min.js"></script>
|
||
<script src="https://unpkg.com/@element-plus/icons-vue@2.3.1/dist/index.iife.min.js"></script>
|
||
<script src="https://unpkg.com/echarts@5.5.0/dist/echarts.min.js"></script>
|
||
<link rel="stylesheet" href="https://unpkg.com/element-plus@2.7.6/theme-chalk/dark/css-vars.css" />
|
||
|
||
<style>
|
||
/* ============================================================
|
||
* 主题系统:与 KB 页(/kb/view)共用同一套变量与 localStorage key(kb_theme)
|
||
* 亮色 = 冷蓝紫,暗色 = 琥珀橙,保持两个面板视觉一致
|
||
* ============================================================ */
|
||
:root,
|
||
[data-theme="light"] {
|
||
--bg-gradient: radial-gradient(ellipse at top left, #e8edff 0%, #f5f7ff 35%, #fafbff 100%);
|
||
--text-primary: #0f1729;
|
||
--text-secondary: #4e5969;
|
||
--text-tertiary: #86909c;
|
||
--glass-bg: rgba(255, 255, 255, 0.65);
|
||
--glass-bg-strong: rgba(255, 255, 255, 0.85);
|
||
--glass-border-subtle: rgba(64, 80, 180, 0.08);
|
||
--glass-blur: saturate(180%) blur(20px);
|
||
--glass-shadow: 0 4px 24px rgba(64, 80, 180, 0.06);
|
||
--accent: #4361ee;
|
||
--accent-soft: rgba(67, 97, 238, 0.12);
|
||
--accent-grad: linear-gradient(135deg, #4361ee 0%, #7209b7 100%);
|
||
--success: #009a55;
|
||
--warning: #d46b08;
|
||
--danger: #f5222d;
|
||
--border: rgba(64, 80, 180, 0.12);
|
||
--log-bg: rgba(15, 23, 41, 0.03);
|
||
}
|
||
[data-theme="dark"] {
|
||
--bg-gradient: radial-gradient(ellipse at top left, #2a1f15 0%, #181210 35%, #0e0a08 100%);
|
||
--text-primary: #fdf6e3;
|
||
--text-secondary: #d4c5a9;
|
||
--text-tertiary: #998871;
|
||
--glass-bg: rgba(36, 28, 22, 0.55);
|
||
--glass-bg-strong: rgba(52, 40, 30, 0.78);
|
||
--glass-border-subtle: rgba(255, 180, 100, 0.05);
|
||
--glass-blur: saturate(150%) blur(20px);
|
||
--glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||
--accent: #f59e0b;
|
||
--accent-soft: rgba(245, 158, 11, 0.18);
|
||
--accent-grad: linear-gradient(135deg, #f59e0b 0%, #dc2626 100%);
|
||
--success: #84cc16;
|
||
--warning: #fb923c;
|
||
--danger: #ef4444;
|
||
--border: rgba(255, 180, 100, 0.15);
|
||
--log-bg: rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body {
|
||
margin: 0; padding: 0; min-height: 100%;
|
||
color: var(--text-primary);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||
font-size: 14px;
|
||
transition: background 0.3s ease, color 0.3s ease;
|
||
color-scheme: light;
|
||
}
|
||
[data-theme="dark"] body { color-scheme: dark; }
|
||
body { background: var(--bg-gradient); background-attachment: fixed; }
|
||
#app { min-height: 100vh; }
|
||
[v-cloak] { display: none; }
|
||
|
||
/* ============================================================
|
||
* 后台布局:左侧菜单 + 顶栏 + 内容区
|
||
* ============================================================ */
|
||
.layout { display: flex; min-height: 100vh; }
|
||
|
||
/* 左侧菜单栏(毛玻璃) */
|
||
.sidebar {
|
||
width: 210px; flex-shrink: 0;
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border-right: 1px solid var(--glass-border-subtle);
|
||
display: flex; flex-direction: column;
|
||
position: sticky; top: 0; height: 100vh;
|
||
transition: width 0.25s ease;
|
||
}
|
||
.sidebar.collapsed { width: 64px; }
|
||
.sidebar-logo {
|
||
padding: 18px 16px 14px;
|
||
border-bottom: 1px solid var(--glass-border-subtle);
|
||
overflow: hidden; white-space: nowrap;
|
||
}
|
||
.sidebar-logo .logo-title {
|
||
font-size: 16px; font-weight: 700;
|
||
background: var(--accent-grad);
|
||
-webkit-background-clip: text; background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
.sidebar-logo .logo-sub { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }
|
||
.sidebar-menu { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||
.menu-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
margin: 2px 10px; padding: 10px 14px;
|
||
border-radius: 10px; cursor: pointer;
|
||
color: var(--text-secondary);
|
||
white-space: nowrap; overflow: hidden;
|
||
transition: background 0.15s ease, color 0.15s ease;
|
||
}
|
||
.menu-item:hover { background: var(--accent-soft); color: var(--text-primary); }
|
||
.menu-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||
.menu-item .el-icon { font-size: 17px; flex-shrink: 0; }
|
||
.sidebar.collapsed .menu-item { justify-content: center; padding: 10px 0; }
|
||
.sidebar.collapsed .menu-label, .sidebar.collapsed .logo-sub { display: none; }
|
||
.sidebar.collapsed .logo-title { font-size: 13px; }
|
||
.sidebar-footer {
|
||
padding: 10px; border-top: 1px solid var(--glass-border-subtle);
|
||
display: flex; justify-content: center;
|
||
}
|
||
|
||
/* 右侧内容 */
|
||
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||
.topbar {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border-bottom: 1px solid var(--glass-border-subtle);
|
||
padding: 0 28px; height: 58px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
position: sticky; top: 0; z-index: 100;
|
||
}
|
||
.topbar-title { font-size: 16px; font-weight: 600; }
|
||
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||
.main { padding: 22px 28px 48px; max-width: 1280px; width: 100%; margin: 0 auto; }
|
||
|
||
/* 玻璃卡片 */
|
||
.glass-card {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-radius: 16px;
|
||
box-shadow: var(--glass-shadow);
|
||
padding: 20px 24px;
|
||
}
|
||
|
||
/* 统计卡片 */
|
||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
||
.stat-card { text-align: center; padding: 24px 16px; }
|
||
.stat-value { font-size: 30px; font-weight: 700; color: var(--accent); }
|
||
.stat-value.ok { color: var(--success); }
|
||
.stat-value.bad { color: var(--danger); }
|
||
.stat-value.warn { color: var(--warning); }
|
||
.stat-label { margin-top: 6px; color: var(--text-tertiary); font-size: 13px; }
|
||
|
||
/* 首页状态横幅 */
|
||
.home-banner {
|
||
display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
|
||
margin-bottom: 16px; padding: 18px 24px;
|
||
}
|
||
.banner-item { display: flex; align-items: center; gap: 10px; }
|
||
.banner-item .el-icon { font-size: 22px; color: var(--accent); }
|
||
.banner-item .b-label { font-size: 12px; color: var(--text-tertiary); }
|
||
.banner-item .b-value { font-size: 15px; font-weight: 600; }
|
||
|
||
/* 图表容器 */
|
||
.chart-box { width: 100%; height: 280px; }
|
||
|
||
/* 步骤小圆点 */
|
||
.step-dots { display: inline-flex; gap: 4px; align-items: center; }
|
||
.step-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||
.step-dot.s1 { background: var(--success); }
|
||
.step-dot.s2 { background: var(--danger); }
|
||
.step-dot.s0 { background: var(--text-tertiary); }
|
||
|
||
/* 时间线 detail 块 */
|
||
.step-detail {
|
||
margin-top: 6px; padding: 10px 12px;
|
||
background: var(--glass-bg-strong);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
font-size: 12px; line-height: 1.7;
|
||
white-space: pre-wrap; word-break: break-all;
|
||
color: var(--text-secondary);
|
||
max-height: 320px; overflow: auto;
|
||
}
|
||
.step-meta { color: var(--text-tertiary); font-size: 12px; margin-top: 2px; }
|
||
.timeline-card-title { font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||
|
||
/* 配置项两列展示 */
|
||
.cfg-row { display: flex; padding: 9px 0; border-bottom: 1px dashed var(--border); }
|
||
.cfg-row:last-child { border-bottom: none; }
|
||
.cfg-key { width: 200px; flex-shrink: 0; color: var(--text-tertiary); }
|
||
.cfg-val { color: var(--text-primary); word-break: break-all; font-family: Consolas, Monaco, monospace; }
|
||
|
||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||
.muted { color: var(--text-tertiary); font-size: 12px; }
|
||
.section-title { font-weight: 600; margin-bottom: 12px; }
|
||
|
||
/* 实时日志滚动区 */
|
||
.log-viewer {
|
||
background: var(--log-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 12px 14px;
|
||
height: 560px; overflow-y: auto;
|
||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||
font-size: 12px; line-height: 1.8;
|
||
}
|
||
.log-line { white-space: pre-wrap; word-break: break-all; color: var(--text-secondary); }
|
||
.log-line .tag-http { color: var(--accent); font-weight: 600; }
|
||
.log-line .tag-err { color: var(--danger); font-weight: 600; }
|
||
.log-line .tag-warn { color: var(--warning); font-weight: 600; }
|
||
.log-line .tag-mod { color: var(--success); font-weight: 600; }
|
||
|
||
.kw-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||
|
||
.kb-doc {
|
||
padding: 12px 14px; margin-bottom: 10px;
|
||
background: var(--glass-bg-strong);
|
||
border: 1px solid var(--border); border-radius: 10px;
|
||
font-size: 13px; line-height: 1.7;
|
||
white-space: pre-wrap; word-break: break-all;
|
||
color: var(--text-secondary);
|
||
max-height: 220px; overflow: auto;
|
||
}
|
||
|
||
/* Element Plus 组件融入玻璃风格 */
|
||
.el-table { --el-table-bg-color: transparent; --el-table-tr-bg-color: transparent; background: transparent; }
|
||
.el-drawer__body { padding-top: 8px; }
|
||
.el-collapse { --el-collapse-header-bg-color: transparent; --el-collapse-content-bg-color: transparent; border: none; }
|
||
|
||
/* ============================================================
|
||
* 知识库模块样式(自 view/index.html 移植,保持视觉一致)
|
||
* ============================================================ */
|
||
.menu-divider {
|
||
margin: 8px 20px; border-top: 1px solid var(--glass-border-subtle);
|
||
}
|
||
.menu-group-title {
|
||
padding: 6px 24px 4px; font-size: 11px; color: var(--text-tertiary);
|
||
white-space: nowrap; overflow: hidden;
|
||
}
|
||
.sidebar.collapsed .menu-group-title { display: none; }
|
||
.lib-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
.lib-card {
|
||
background: var(--glass-bg);
|
||
backdrop-filter: var(--glass-blur);
|
||
-webkit-backdrop-filter: var(--glass-blur);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
box-shadow: var(--glass-shadow);
|
||
border-radius: 14px;
|
||
padding: 20px;
|
||
cursor: pointer;
|
||
transition: all 0.25s ease;
|
||
}
|
||
.lib-card:hover {
|
||
transform: translateY(-2px);
|
||
background: var(--glass-bg-strong);
|
||
border-color: var(--border);
|
||
}
|
||
.lib-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
||
.lib-icon-wrap {
|
||
width: 36px; height: 36px;
|
||
background: var(--accent-soft);
|
||
border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--accent);
|
||
}
|
||
.lib-title {
|
||
font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.lib-desc {
|
||
font-size: 12px; color: var(--text-tertiary); line-height: 1.5; height: 36px;
|
||
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||
}
|
||
.lib-stats {
|
||
display: flex; gap: 20px; margin-top: 14px; padding-top: 14px;
|
||
border-top: 1px solid var(--glass-border-subtle);
|
||
}
|
||
.lib-stat .num { font-size: 18px; font-weight: 600; color: var(--text-primary); line-height: 1; }
|
||
.lib-stat .lbl { font-size: 11px; color: var(--text-tertiary); margin-top: 3px; }
|
||
.lib-card-footer { margin-top: 12px; display: flex; justify-content: space-between; align-items: center; }
|
||
.source-pill {
|
||
font-size: 10px; padding: 2px 8px; border-radius: 4px;
|
||
background: var(--accent-soft); color: var(--text-secondary);
|
||
}
|
||
.empty-state { text-align: center; padding: 60px 20px; }
|
||
.empty-state .icon-wrap {
|
||
width: 72px; height: 72px; margin: 0 auto 16px;
|
||
background: var(--accent-soft); border-radius: 50%;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: var(--text-tertiary);
|
||
}
|
||
.empty-state h3 { margin: 0 0 6px; font-size: 15px; color: var(--text-primary); font-weight: 600; }
|
||
.empty-state p { margin: 0; color: var(--text-tertiary); font-size: 13px; }
|
||
.doc-list { display: flex; flex-direction: column; gap: 10px; }
|
||
.doc-item {
|
||
background: var(--glass-bg);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-radius: 11px;
|
||
padding: 14px 18px;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
transition: all 0.2s ease;
|
||
}
|
||
.doc-item:hover { background: var(--glass-bg-strong); border-color: var(--border); }
|
||
.doc-info { flex: 1; min-width: 0; }
|
||
.doc-title {
|
||
font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.doc-meta { font-size: 12px; color: var(--text-tertiary); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||
.drop-zone {
|
||
background: var(--glass-bg);
|
||
border: 1.5px dashed var(--border);
|
||
border-radius: 12px;
|
||
padding: 32px 20px;
|
||
text-align: center; cursor: pointer;
|
||
transition: all 0.25s ease;
|
||
margin-bottom: 16px;
|
||
}
|
||
.drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: var(--accent-soft); }
|
||
.drop-title { font-size: 14px; font-weight: 500; color: var(--text-primary); margin: 8px 0 4px; }
|
||
.drop-tip { font-size: 12px; color: var(--text-tertiary); }
|
||
.result-card {
|
||
background: var(--glass-bg);
|
||
border: 1px solid var(--glass-border-subtle);
|
||
border-left: 3px solid var(--accent);
|
||
border-radius: 11px;
|
||
padding: 16px 18px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.result-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 6px; }
|
||
.result-content {
|
||
font-size: 13px; color: var(--text-secondary); line-height: 1.65;
|
||
white-space: pre-wrap; word-break: break-word;
|
||
max-height: 180px; overflow: auto;
|
||
background: var(--log-bg);
|
||
padding: 10px 12px; border-radius: 7px;
|
||
margin-top: 8px;
|
||
}
|
||
.score-bar { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
|
||
.chunk-item { padding: 14px; background: var(--log-bg); border-radius: 9px; margin-bottom: 8px; }
|
||
.chunk-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.chunk-idx {
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
min-width: 22px; height: 22px;
|
||
background: var(--accent); color: white;
|
||
border-radius: 50%; font-size: 10px; font-weight: 600;
|
||
}
|
||
.chunk-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
|
||
.chunk-content {
|
||
font-size: 12px; color: var(--text-secondary); line-height: 1.65;
|
||
white-space: pre-wrap; word-break: break-word;
|
||
background: var(--glass-bg-strong);
|
||
padding: 8px 10px; border-radius: 6px;
|
||
}
|
||
.chunk-related {
|
||
margin-top: 6px; padding: 6px 10px;
|
||
background: var(--accent-soft); border-radius: 5px;
|
||
font-size: 11px; color: var(--accent);
|
||
}
|
||
.skeleton-card {
|
||
height: 180px; border-radius: 14px;
|
||
background: linear-gradient(90deg, var(--glass-bg) 25%, var(--glass-bg-strong) 37%, var(--glass-bg) 63%);
|
||
background-size: 400% 100%;
|
||
animation: skeleton-loading 1.4s ease infinite;
|
||
}
|
||
@keyframes skeleton-loading {
|
||
0% { background-position: 100% 50%; }
|
||
100% { background-position: 0 50%; }
|
||
}
|
||
|
||
/* 窄屏:侧栏自动收窄为纯图标 */
|
||
@media (max-width: 768px) {
|
||
.sidebar { width: 64px; }
|
||
.sidebar .menu-label, .sidebar .logo-sub { display: none; }
|
||
.sidebar .menu-item { justify-content: center; padding: 10px 0; }
|
||
.main { padding: 16px 12px 40px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app" v-cloak>
|
||
<!-- 口令弹窗(与 KB 页共用口令与 localStorage key,输一次两个面板通用) -->
|
||
<el-dialog v-model="pwdDialog.visible" title="管理口令" width="360px"
|
||
:close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
|
||
<el-input v-model="pwdInput" type="password" placeholder="请输入管理口令(与知识库后台相同)"
|
||
show-password @keyup.enter="submitPwd"></el-input>
|
||
<template #footer>
|
||
<el-button type="primary" :loading="pwdDialog.loading" @click="submitPwd" style="width: 100%">进入控制台</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<div class="layout">
|
||
<!-- ================= 左侧菜单 ================= -->
|
||
<div class="sidebar" :class="{ collapsed: menuCollapsed }">
|
||
<div class="sidebar-logo">
|
||
<div class="logo-title">TCM Agent</div>
|
||
<div class="logo-sub">萧康云医 · Agent 控制台</div>
|
||
</div>
|
||
<div class="sidebar-menu">
|
||
<template v-for="(m, mi) in menus" :key="m.key || 'sep' + mi">
|
||
<div v-if="m.divider" class="menu-divider"></div>
|
||
<div v-else-if="m.group" class="menu-group-title">{{ m.group }}</div>
|
||
<div v-else class="menu-item"
|
||
:class="{ active: activePage === m.key }" @click="switchPage(m.key)">
|
||
<el-icon><component :is="m.icon"></component></el-icon>
|
||
<span class="menu-label">{{ m.label }}</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="sidebar-footer">
|
||
<el-button text circle @click="toggleCollapse">
|
||
<!-- in-DOM 模板不能用自闭合组件标签(浏览器会把后续元素吞成子节点),必须显式闭合 -->
|
||
<el-icon><Expand v-if="menuCollapsed"></Expand><Fold v-else></Fold></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 右侧内容 ================= -->
|
||
<div class="content">
|
||
<div class="topbar">
|
||
<div class="topbar-title">{{ currentMenuLabel }}</div>
|
||
<div class="topbar-right">
|
||
<el-tooltip content="运行记录/统计/首页每 5 秒自动拉取" placement="bottom">
|
||
<el-switch v-model="autoRefresh" size="small" active-text="自动刷新"
|
||
@change="saveAutoRefresh"></el-switch>
|
||
</el-tooltip>
|
||
<el-tooltip content="切换亮/暗主题" placement="bottom">
|
||
<el-button size="small" circle @click="toggleTheme">
|
||
<el-icon><Moon v-if="!isDark"></Moon><Sunny v-else></Sunny></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
<el-tooltip content="退出登录(清除口令)" placement="bottom">
|
||
<el-button size="small" circle @click="logout">
|
||
<el-icon><SwitchButton></SwitchButton></el-icon>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main">
|
||
<!-- ================= 首页 ================= -->
|
||
<div v-show="activePage === 'home'">
|
||
<!-- 状态横幅:服务健康 + 运行时长 + 生效模型 -->
|
||
<div class="glass-card home-banner">
|
||
<div class="banner-item">
|
||
<el-icon :style="{ color: healthOK ? 'var(--success)' : 'var(--danger)' }"><CircleCheckFilled v-if="healthOK"></CircleCheckFilled><CircleCloseFilled v-else></CircleCloseFilled></el-icon>
|
||
<div><div class="b-label">服务状态</div><div class="b-value">{{ healthOK ? '运行中' : '异常' }}</div></div>
|
||
</div>
|
||
<div class="banner-item">
|
||
<el-icon><Timer></Timer></el-icon>
|
||
<div><div class="b-label">运行时长</div><div class="b-value">{{ fmtUptime(sys.uptime_seconds) }}</div></div>
|
||
</div>
|
||
<div class="banner-item">
|
||
<el-icon><Cpu></Cpu></el-icon>
|
||
<div><div class="b-label">生效模型</div>
|
||
<div class="b-value">{{ activeModelText }}</div></div>
|
||
</div>
|
||
<div class="banner-item">
|
||
<el-icon><Odometer></Odometer></el-icon>
|
||
<div><div class="b-label">enhance 并发</div>
|
||
<div class="b-value">{{ sys.enhance_concurrency ? sys.enhance_concurrency.used + ' / ' + sys.enhance_concurrency.capacity : '-' }}</div></div>
|
||
</div>
|
||
<div class="banner-item">
|
||
<el-icon><Umbrella></Umbrella></el-icon>
|
||
<div><div class="b-label">医疗守卫</div>
|
||
<div class="b-value">{{ agentCfg ? (agentCfg.medical_guard.enabled ? '开启' : '关闭') : '-' }}</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 核心指标四卡 -->
|
||
<div class="stat-grid" style="margin-bottom: 16px">
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ stats.total ?? 0 }}</div>
|
||
<div class="stat-label">总运行数(最近 {{ bufferSize }} 条内)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value ok">{{ successRate }}</div>
|
||
<div class="stat-label">成功率(失败 {{ stats.failed ?? 0 }} / 拦截 {{ stats.blocked ?? 0 }})</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value warn">{{ fmtMs(stats.avg_ms ?? 0) }}</div>
|
||
<div class="stat-label">平均耗时(成功请求)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ stats.total_tokens ?? 0 }}</div>
|
||
<div class="stat-label">Token 消耗合计</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 最近运行 -->
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="toolbar" style="margin-bottom: 8px">
|
||
<span class="section-title" style="margin: 0">最近运行</span>
|
||
<el-button size="small" text type="primary" style="margin-left: auto"
|
||
@click="switchPage('runs')">查看全部
|
||
<el-icon style="margin-left: 4px"><ArrowRight></ArrowRight></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<el-table :data="runs.slice(0, 5)" style="width: 100%" @row-click="openRunDetail"
|
||
:row-style="{ cursor: 'pointer' }" empty-text="暂无运行记录">
|
||
<el-table-column label="时间" width="150">
|
||
<template #default="{ row }">{{ fmtTime(row.started_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="scene" label="场景" width="140"></el-table-column>
|
||
<el-table-column label="模型" min-width="140">
|
||
<template #default="{ row }">
|
||
<span v-if="row.provider">{{ row.provider }} / {{ row.model }}</span>
|
||
<span v-else class="muted">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="耗时" width="90">
|
||
<template #default="{ row }">{{ fmtMs(row.total_ms) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.status === 1" type="success" size="small">成功</el-tag>
|
||
<el-tag v-else-if="row.status === 3" type="warning" size="small">拦截</el-tag>
|
||
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
|
||
<!-- 快捷入口 -->
|
||
<div class="glass-card">
|
||
<div class="section-title">快捷操作</div>
|
||
<div class="toolbar" style="margin: 0">
|
||
<el-button size="small" type="primary" plain @click="switchPage('debug')">
|
||
<el-icon style="margin-right: 4px"><Connection></Connection></el-icon>模型连通性测试
|
||
</el-button>
|
||
<el-button size="small" type="success" plain @click="switchPage('debug')">
|
||
<el-icon style="margin-right: 4px"><Umbrella></Umbrella></el-icon>守卫测试台
|
||
</el-button>
|
||
<el-button size="small" type="warning" plain @click="invalidateCache">
|
||
<el-icon style="margin-right: 4px"><Refresh></Refresh></el-icon>失效配置缓存
|
||
</el-button>
|
||
<el-button size="small" plain @click="switchPage('kb')">
|
||
<el-icon style="margin-right: 4px"><Collection></Collection></el-icon>知识库管理
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 运行记录 ================= -->
|
||
<div v-show="activePage === 'runs'">
|
||
<div class="glass-card">
|
||
<div class="toolbar">
|
||
<el-select v-model="filterScene" placeholder="全部场景" size="small" clearable
|
||
style="width: 160px" @change="loadRuns">
|
||
<el-option v-for="s in sceneOptions" :key="s" :label="s" :value="s"></el-option>
|
||
</el-select>
|
||
<el-select v-model="filterStatus" placeholder="全部状态" size="small" clearable
|
||
style="width: 130px" @change="loadRuns">
|
||
<el-option label="成功" :value="1"></el-option>
|
||
<el-option label="失败" :value="2"></el-option>
|
||
<el-option label="守卫拦截" :value="3"></el-option>
|
||
</el-select>
|
||
<el-button size="small" type="primary" plain @click="loadRuns" :loading="loadingRuns">刷新</el-button>
|
||
<span class="muted">内存环形缓冲,最多保留最近 {{ bufferSize }} 条;服务重启后清空(历史明细在 PHP xk_ai_generation_step 表)</span>
|
||
</div>
|
||
<el-table :data="runs" style="width: 100%" @row-click="openRunDetail"
|
||
:row-style="{ cursor: 'pointer' }" empty-text="暂无运行记录,跑一次开方/病历生成后刷新">
|
||
<el-table-column prop="id" label="#" width="60"></el-table-column>
|
||
<el-table-column label="时间" width="160">
|
||
<template #default="{ row }">{{ fmtTime(row.started_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="scene" label="场景" width="140"></el-table-column>
|
||
<el-table-column label="模型" min-width="150">
|
||
<template #default="{ row }">
|
||
<span v-if="row.provider">{{ row.provider }} / {{ row.model }}</span>
|
||
<span v-else class="muted">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="耗时" width="90">
|
||
<template #default="{ row }">{{ fmtMs(row.total_ms) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total_tokens" label="Token" width="90"></el-table-column>
|
||
<el-table-column label="步骤" width="120">
|
||
<template #default="{ row }">
|
||
<span class="step-dots">
|
||
<el-tooltip v-for="(b, i) in row.step_briefs" :key="i"
|
||
:content="b.step_type + ' · ' + fmtMs(b.duration_ms)" placement="top">
|
||
<span class="step-dot" :class="'s' + b.status"></span>
|
||
</el-tooltip>
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.status === 1" type="success" size="small">成功</el-tag>
|
||
<el-tag v-else-if="row.status === 3" type="warning" size="small">守卫拦截</el-tag>
|
||
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 统计概览 ================= -->
|
||
<div v-show="activePage === 'stats'">
|
||
<div class="stat-grid" style="margin-bottom: 16px">
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ stats.total ?? 0 }}</div>
|
||
<div class="stat-label">总运行数(最近 {{ bufferSize }} 条内)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value ok">{{ successRate }}</div>
|
||
<div class="stat-label">成功率(成功 {{ stats.success ?? 0 }} / 失败 {{ stats.failed ?? 0 }} / 拦截 {{ stats.blocked ?? 0 }})</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value warn">{{ fmtMs(stats.avg_ms ?? 0) }}</div>
|
||
<div class="stat-label">平均耗时(成功请求)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ stats.total_tokens ?? 0 }}</div>
|
||
<div class="stat-label">Token 消耗合计</div>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="section-title">耗时趋势(最近 {{ runs.length }} 次运行,红点为失败/拦截)</div>
|
||
<div class="chart-box" ref="durationChartEl"></div>
|
||
</div>
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="section-title">Token 消耗</div>
|
||
<div class="chart-box" ref="tokenChartEl"></div>
|
||
</div>
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="section-title">按场景分布</div>
|
||
<template v-if="sceneEntries.length">
|
||
<div class="cfg-row" v-for="[k, v] in sceneEntries" :key="k">
|
||
<div class="cfg-key">{{ k || '(未标注场景)' }}</div>
|
||
<div class="cfg-val">{{ v }} 次</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="muted">暂无数据</div>
|
||
</div>
|
||
<div class="glass-card" v-if="stats.last_error">
|
||
<div class="section-title" style="color: var(--danger)">最近一次失败</div>
|
||
<div class="muted" style="margin-bottom: 6px">{{ fmtTime(stats.last_error_at) }}</div>
|
||
<div class="step-detail">{{ stats.last_error }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 实时日志 ================= -->
|
||
<div v-show="activePage === 'logs'">
|
||
<div class="glass-card">
|
||
<div class="toolbar">
|
||
<el-input v-model="logFilter" placeholder="关键字过滤(前端过滤已拉取的行)" size="small"
|
||
clearable style="width: 280px"></el-input>
|
||
<el-switch v-model="logAutoScroll" size="small" active-text="自动滚动"></el-switch>
|
||
<el-button size="small" plain @click="clearLogView">清空视图</el-button>
|
||
<span class="muted">内存缓冲最近 500 行 · 2s 增量轮询 · debug 开关打开时日志含请求体(注意隐私)</span>
|
||
</div>
|
||
<div class="log-viewer" ref="logViewerEl">
|
||
<div v-for="l in filteredLogs" :key="l.id" class="log-line" v-html="colorizeLog(l.line)"></div>
|
||
<div v-if="!filteredLogs.length" class="muted">暂无日志(或全部被过滤)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 系统状态 ================= -->
|
||
<div v-show="activePage === 'system'">
|
||
<div class="stat-grid" style="margin-bottom: 16px">
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ fmtUptime(sys.uptime_seconds) }}</div>
|
||
<div class="stat-label">运行时长({{ fmtTime(sys.started_at) }} 启动)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value" :class="{ bad: sys.enhance_concurrency && sys.enhance_concurrency.used >= sys.enhance_concurrency.capacity }">
|
||
{{ sys.enhance_concurrency ? sys.enhance_concurrency.used + ' / ' + sys.enhance_concurrency.capacity : '-' }}
|
||
</div>
|
||
<div class="stat-label">enhance 当前并发 / 上限(满载时新请求 503)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value warn">{{ sys.heap_alloc_mb != null ? sys.heap_alloc_mb.toFixed(1) + ' MB' : '-' }}</div>
|
||
<div class="stat-label">堆内存占用(OS 共 {{ sys.sys_mb != null ? sys.sys_mb.toFixed(0) : '-' }} MB · GC {{ sys.num_gc ?? '-' }} 次)</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ sys.goroutines ?? '-' }}</div>
|
||
<div class="stat-label">Goroutine 数({{ sys.go_version || '' }})</div>
|
||
</div>
|
||
<div class="glass-card stat-card">
|
||
<div class="stat-value">{{ sys.runlog_usage ? sys.runlog_usage.used + ' / ' + sys.runlog_usage.capacity : '-' }}</div>
|
||
<div class="stat-label">RunLog 缓冲占用</div>
|
||
</div>
|
||
</div>
|
||
<div class="glass-card">
|
||
<div class="section-title">服务健康(/health)</div>
|
||
<template v-if="healthEntries.length">
|
||
<div class="cfg-row" v-for="[k, v] in healthEntries" :key="k">
|
||
<div class="cfg-key">{{ k }}</div>
|
||
<div class="cfg-val">{{ v }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="muted">加载中…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 调试工具 ================= -->
|
||
<div v-show="activePage === 'debug'">
|
||
<el-collapse v-model="debugPanels" style="border: none">
|
||
<el-collapse-item name="guard">
|
||
<template #title><span class="section-title" style="margin: 0">医疗守卫测试台</span></template>
|
||
<div class="glass-card">
|
||
<el-input v-model="guardText" type="textarea" :rows="4"
|
||
placeholder="贴一段 prompt / 用户输入,测试守卫会不会拦截、命中哪些关键词"></el-input>
|
||
<div style="margin-top: 12px">
|
||
<el-button type="primary" size="small" @click="runGuardTest" :loading="guardLoading">测试</el-button>
|
||
</div>
|
||
<template v-if="guardResult">
|
||
<div style="margin-top: 14px">
|
||
<el-tag :type="guardResult.passed ? 'success' : 'danger'" size="large">
|
||
{{ guardResult.passed ? '放行' : '拦截' }}
|
||
</el-tag>
|
||
<span v-if="guardResult.reason" class="muted" style="margin-left: 10px">{{ guardResult.reason }}</span>
|
||
</div>
|
||
<div class="kw-tags" v-if="(guardResult.hit_whitelist || []).length">
|
||
<span class="muted" style="align-self: center">命中白名单:</span>
|
||
<el-tag v-for="w in guardResult.hit_whitelist" :key="'w'+w" type="success" size="small">{{ w }}</el-tag>
|
||
</div>
|
||
<div class="kw-tags" v-if="(guardResult.hit_blacklist || []).length">
|
||
<span class="muted" style="align-self: center">命中黑名单:</span>
|
||
<el-tag v-for="b in guardResult.hit_blacklist" :key="'b'+b" type="danger" size="small">{{ b }}</el-tag>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</el-collapse-item>
|
||
|
||
<el-collapse-item name="model">
|
||
<template #title><span class="section-title" style="margin: 0">模型连通性测试</span></template>
|
||
<div class="glass-card">
|
||
<div class="toolbar">
|
||
<el-input v-model="modelTestProvider" placeholder="provider(留空 = 当前生效配置)"
|
||
size="small" clearable style="width: 240px"></el-input>
|
||
<el-input v-model="modelTestMessage" placeholder="测试消息(留空用默认)"
|
||
size="small" clearable style="width: 300px"></el-input>
|
||
<el-button type="primary" size="small" @click="runModelTest" :loading="modelTestLoading">发送测试</el-button>
|
||
<span class="muted">会真实调用一次 LLM(max_tokens 64,成本可忽略)</span>
|
||
</div>
|
||
<template v-if="modelTestResult">
|
||
<div class="cfg-row"><div class="cfg-key">结果</div>
|
||
<div class="cfg-val">
|
||
<el-tag :type="modelTestResult.ok ? 'success' : 'danger'" size="small">
|
||
{{ modelTestResult.ok ? '连通' : '失败' }}
|
||
</el-tag>
|
||
<span style="margin-left: 8px">{{ fmtMs(modelTestResult.duration_ms) }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="cfg-row"><div class="cfg-key">模型</div>
|
||
<div class="cfg-val">{{ modelTestResult.provider }} / {{ modelTestResult.model }}(来源: {{ modelTestResult.cfg_source }} · key#{{ modelTestResult.api_key_id }})</div></div>
|
||
<div class="cfg-row" v-if="modelTestResult.reply"><div class="cfg-key">回复</div>
|
||
<div class="cfg-val">{{ modelTestResult.reply }}</div></div>
|
||
<div class="cfg-row" v-if="modelTestResult.error"><div class="cfg-key">错误</div>
|
||
<div class="cfg-val" style="color: var(--danger)">{{ modelTestResult.error }}</div></div>
|
||
</template>
|
||
</div>
|
||
</el-collapse-item>
|
||
|
||
<el-collapse-item name="kb">
|
||
<template #title><span class="section-title" style="margin: 0">知识库检索测试</span></template>
|
||
<div class="glass-card">
|
||
<div class="toolbar">
|
||
<el-input v-model="kbTestQuery" placeholder="检索关键词,如「痰湿中阻 煎法」"
|
||
size="small" clearable style="width: 300px" @keyup.enter="runKBTest"></el-input>
|
||
<el-input-number v-model="kbTestTopK" :min="1" :max="20" size="small"></el-input-number>
|
||
<el-button type="primary" size="small" @click="runKBTest" :loading="kbTestLoading">检索</el-button>
|
||
<span class="muted">走 enhance 同款检索路径(含 ai_kb_source 分流),看 Agent 实际检索到什么</span>
|
||
</div>
|
||
<template v-if="kbTestResult">
|
||
<div class="muted" style="margin-bottom: 10px">
|
||
{{ kbTestResult.source }} · 耗时 {{ fmtMs(kbTestResult.duration_ms) }}
|
||
<span v-if="kbTestResult.error" style="color: var(--danger)"> · {{ kbTestResult.error }}</span>
|
||
</div>
|
||
<div v-for="(doc, i) in kbTestResult.docs || []" :key="i" class="kb-doc">{{ doc }}</div>
|
||
<div v-if="!kbTestResult.error && !(kbTestResult.docs || []).length" class="muted">未命中任何文档</div>
|
||
</template>
|
||
</div>
|
||
</el-collapse-item>
|
||
|
||
<el-collapse-item name="enhance">
|
||
<template #title><span class="section-title" style="margin: 0">Enhance 测试台(真实完整链路)</span></template>
|
||
<div class="glass-card">
|
||
<div class="toolbar">
|
||
<el-select v-model="enhScene" size="small" style="width: 180px">
|
||
<el-option label="medical_record" value="medical_record"></el-option>
|
||
<el-option label="prescription" value="prescription"></el-option>
|
||
</el-select>
|
||
<el-switch v-model="enhKBEnabled" size="small" active-text="启用知识库"></el-switch>
|
||
<el-button type="primary" size="small" @click="runEnhanceTest" :loading="enhLoading">发起请求</el-button>
|
||
<span class="muted">走真实 /agent/enhance:守卫 → 检索 → ReAct 完整链路(消耗真实 token)</span>
|
||
</div>
|
||
<el-input v-model="enhSystem" type="textarea" :rows="2" placeholder="system 消息"
|
||
style="margin-bottom: 8px"></el-input>
|
||
<el-input v-model="enhUser" type="textarea" :rows="4" placeholder="user 消息"></el-input>
|
||
<template v-if="enhResult">
|
||
<div style="margin: 14px 0 8px">
|
||
<el-tag :type="enhResult.ok ? 'success' : 'danger'" size="small">{{ enhResult.ok ? '成功' : '失败' }}</el-tag>
|
||
<span class="muted" style="margin-left: 8px">{{ enhResult.provider }} / {{ enhResult.model }} · {{ fmtMs(enhResult.total_ms) }}</span>
|
||
<el-button size="small" text type="primary" style="margin-left: 8px"
|
||
@click="showEnhanceTimeline">查看步骤时间线</el-button>
|
||
</div>
|
||
<div class="step-detail">{{ enhResult.content || enhResult.message || '(无输出)' }}</div>
|
||
</template>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
|
||
<!-- ================= 配置总览 ================= -->
|
||
<div v-show="activePage === 'config'">
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="toolbar">
|
||
<el-button size="small" type="primary" plain @click="loadConfig" :loading="loadingCfg">刷新</el-button>
|
||
<el-button size="small" type="warning" plain @click="invalidateCache">失效配置缓存</el-button>
|
||
<span class="muted">面板只读:开关修改统一在 PHP 后台(避免双写入口);「失效缓存」让 Go 立即重读 DB 配置</span>
|
||
</div>
|
||
<div class="section-title">生效模型(/models/active-config,DB 实时解析)</div>
|
||
<template v-if="cfgEntries.length">
|
||
<div class="cfg-row" v-for="[k, v] in cfgEntries" :key="k">
|
||
<div class="cfg-key">{{ k }}</div>
|
||
<div class="cfg-val">{{ v }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="muted">{{ cfgError || '加载中…' }}</div>
|
||
</div>
|
||
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="section-title">Agent 行为配置(xk_system_config 只读视图)</div>
|
||
<template v-if="agentCfg">
|
||
<div class="cfg-row"><div class="cfg-key">ReAct 循环</div>
|
||
<div class="cfg-val">{{ agentCfg.react.enabled ? '开启' : '关闭' }} · 最多 {{ agentCfg.react.max_iterations }} 轮 · Planning {{ agentCfg.react.planning_enabled ? '开' : '关' }} · Reflection {{ agentCfg.react.reflection_enabled ? '开' : '关' }} · JSON修复 {{ agentCfg.react.json_repair_enabled ? '开' : '关' }}</div></div>
|
||
<div class="cfg-row"><div class="cfg-key">Token 预算</div>
|
||
<div class="cfg-val">{{ agentCfg.token_budget.enabled ? '开启' : '关闭' }} · 单任务上限 {{ agentCfg.token_budget.per_request }} · 单次调用上限 {{ agentCfg.token_budget.max_tokens_per_call }}</div></div>
|
||
<div class="cfg-row"><div class="cfg-key">知识库</div>
|
||
<div class="cfg-val">source={{ agentCfg.kb.source }} · embedding={{ agentCfg.kb.embedding_provider }} · top_k={{ agentCfg.kb.top_k }} · mode={{ agentCfg.kb.search_mode }}</div></div>
|
||
<div class="cfg-row"><div class="cfg-key">医疗守卫</div>
|
||
<div class="cfg-val">{{ agentCfg.medical_guard.enabled ? '开启' : '关闭' }}(白名单 {{ agentCfg.medical_guard.whitelist.length }} 词 / 黑名单 {{ agentCfg.medical_guard.blacklist.length }} 词)</div></div>
|
||
<div class="cfg-row"><div class="cfg-key">调试日志</div>
|
||
<div class="cfg-val">{{ agentCfg.debug.log_request_body ? '开启(日志含请求体,注意隐私)' : '关闭' }}</div></div>
|
||
</template>
|
||
<div v-else class="muted">加载中…</div>
|
||
</div>
|
||
|
||
<div class="glass-card" style="margin-bottom: 16px" v-if="agentCfg">
|
||
<div class="section-title">守卫关键词表</div>
|
||
<div class="muted" style="margin-bottom: 6px">白名单(命中任一放行):</div>
|
||
<div class="kw-tags" style="margin-bottom: 12px">
|
||
<el-tag v-for="w in agentCfg.medical_guard.whitelist" :key="'cw'+w" type="success" size="small">{{ w }}</el-tag>
|
||
</div>
|
||
<div class="muted" style="margin-bottom: 6px">黑名单(白名单未命中且命中任一则拦截):</div>
|
||
<div class="kw-tags">
|
||
<el-tag v-for="b in agentCfg.medical_guard.blacklist" :key="'cb'+b" type="danger" size="small">{{ b }}</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="glass-card">
|
||
<div class="section-title">场景路由表(/models/routes)</div>
|
||
<template v-if="routeEntries.length">
|
||
<div class="cfg-row" v-for="[k, v] in routeEntries" :key="k">
|
||
<div class="cfg-key">{{ k }}</div>
|
||
<div class="cfg-val">{{ v }}</div>
|
||
</div>
|
||
</template>
|
||
<div v-else class="muted">加载中…</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 知识库管理(自 /kb/view 并入) ================= -->
|
||
<div v-show="activePage === 'kb'">
|
||
<!-- 库列表视图 -->
|
||
<div v-if="!currentLib">
|
||
<div class="toolbar">
|
||
<span class="muted">共 {{ libraries.length }} 个知识库 · V1 全文检索(MySQL ngram)</span>
|
||
<el-button size="small" type="primary" style="margin-left: auto" @click="openCreateLib">
|
||
<el-icon style="margin-right: 4px"><Plus></Plus></el-icon>新建知识库
|
||
</el-button>
|
||
</div>
|
||
<div v-if="kbLoading.libs" class="lib-grid">
|
||
<div v-for="i in 3" :key="i" class="skeleton-card"></div>
|
||
</div>
|
||
<div v-else-if="libraries.length === 0" class="glass-card empty-state">
|
||
<div class="icon-wrap"><el-icon size="32"><FolderAdd></FolderAdd></el-icon></div>
|
||
<h3>还没有任何知识库</h3>
|
||
<p>点击右上角「新建知识库」创建第一个库</p>
|
||
</div>
|
||
<div v-else class="lib-grid">
|
||
<div v-for="lib in libraries" :key="lib.id" class="lib-card" @click="openLibrary(lib)">
|
||
<div class="lib-card-header">
|
||
<div class="lib-icon-wrap"><el-icon size="20"><Folder></Folder></el-icon></div>
|
||
<el-dropdown trigger="click" @command="cmd => onLibCommand(cmd, lib)">
|
||
<el-button text circle @click.stop><el-icon><MoreFilled></MoreFilled></el-icon></el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="search"><el-icon><Search></Search></el-icon> 在此库检索</el-dropdown-item>
|
||
<el-dropdown-item command="delete" divided><el-icon><Delete></Delete></el-icon> 删除库</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
<div class="lib-title">{{ lib.name }}</div>
|
||
<div class="lib-desc">{{ lib.description || '暂无描述' }}</div>
|
||
<div class="lib-stats">
|
||
<div class="lib-stat"><div class="num">{{ lib.doc_count }}</div><div class="lbl">文档</div></div>
|
||
<div class="lib-stat"><div class="num">{{ lib.chunk_count }}</div><div class="lbl">分段</div></div>
|
||
<div class="lib-stat"><div class="num">{{ lib.vectorized_count }}</div><div class="lbl">已向量</div></div>
|
||
</div>
|
||
<div class="lib-card-footer">
|
||
<span class="source-pill">{{ lib.source === 'maxkb_export' ? 'MaxKB 导出' : '手动建立' }}</span>
|
||
<span class="muted">{{ fmtDate(lib.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 库详情视图(文档列表 + 上传) -->
|
||
<div v-else>
|
||
<div class="toolbar">
|
||
<el-button size="small" @click="exitLibrary">
|
||
<el-icon style="margin-right: 4px"><ArrowLeft></ArrowLeft></el-icon>返回列表
|
||
</el-button>
|
||
<span class="section-title" style="margin: 0">{{ currentLib.name }}</span>
|
||
<span class="muted">{{ currentLib.doc_count }} 篇文档 · {{ currentLib.chunk_count }} 个分段</span>
|
||
<el-button size="small" type="primary" plain style="margin-left: auto"
|
||
@click="jumpToKBSearch(currentLib.id)">
|
||
<el-icon style="margin-right: 4px"><Search></Search></el-icon>检索测试
|
||
</el-button>
|
||
</div>
|
||
<div class="drop-zone" :class="{ dragover: isDragOver }" @click="triggerFileInput"
|
||
@dragover.prevent="isDragOver = true" @dragleave.prevent="isDragOver = false"
|
||
@drop.prevent="handleDrop">
|
||
<input type="file" ref="fileInput" style="display:none" accept=".xlsx,.xls,.csv,.md,.txt,.pdf,.docx,.html,.htm"
|
||
@change="handleFileInput" />
|
||
<div v-if="kbLoading.import">
|
||
<el-icon class="is-loading" size="28" color="var(--accent)"><Loading></Loading></el-icon>
|
||
<div class="drop-title" style="color: var(--accent)">正在解析与入库…</div>
|
||
</div>
|
||
<template v-else>
|
||
<el-icon size="28" color="var(--accent)"><UploadFilled></UploadFilled></el-icon>
|
||
<div class="drop-title">点击上传或拖拽文件到此处</div>
|
||
<div class="drop-tip">支持 .xlsx / .csv / .md / .txt / .pdf / .docx / .html · 自动分段 · 单文件最大 50MB</div>
|
||
<div class="drop-tip">PDF 需为文字版(扫描版请先 OCR)· 老版 .doc/.xls 请先另存为 .docx/.xlsx</div>
|
||
</template>
|
||
</div>
|
||
<div class="doc-list" v-loading="kbLoading.docs">
|
||
<div v-for="doc in docs" :key="doc.id" class="doc-item">
|
||
<div class="doc-info">
|
||
<div class="doc-title">{{ doc.title }}</div>
|
||
<div class="doc-meta">
|
||
<span>{{ doc.source_file }}</span>
|
||
<el-tag size="small" effect="plain">{{ doc.source_type }}</el-tag>
|
||
<span>{{ doc.chunk_count }} 个分段</span>
|
||
<span>{{ fmtDate(doc.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex; gap:6px;">
|
||
<el-button size="small" @click="openChunksDrawer(doc)">
|
||
<el-icon style="margin-right: 4px"><View></View></el-icon>查看分段
|
||
</el-button>
|
||
<el-button size="small" type="danger" plain circle @click="deleteDoc(doc)">
|
||
<el-icon><Delete></Delete></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!kbLoading.docs && docs.length === 0" class="glass-card empty-state">
|
||
<div class="icon-wrap"><el-icon size="32"><Document></Document></el-icon></div>
|
||
<h3>该库还没有任何文档</h3>
|
||
<p>把文件拖到上面的上传区即可导入</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================= 知识库检索(自 /kb/view 并入) ================= -->
|
||
<div v-show="activePage === 'kbsearch'">
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<el-form label-position="top">
|
||
<el-form-item label="选择知识库">
|
||
<el-select v-model="searchForm.library_id" placeholder="请选择知识库" style="width:100%;">
|
||
<el-option v-for="lib in libraries" :key="lib.id"
|
||
:label="lib.name + '(' + lib.chunk_count + ' 个分段)'"
|
||
:value="lib.id"></el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="检索词">
|
||
<el-input v-model="searchForm.query" placeholder="例如:痰湿中阻 怎么治" size="large"
|
||
clearable @keyup.enter="runSearch">
|
||
<template #append>
|
||
<el-button :loading="kbLoading.search" @click="runSearch">
|
||
<el-icon style="margin-right: 4px"><Search></Search></el-icon>检索
|
||
</el-button>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
<el-form-item label="高级">
|
||
<div class="toolbar" style="margin: 0">
|
||
<span class="muted">返回条数:</span>
|
||
<el-input-number v-model="searchForm.top_k" :min="1" :max="20" size="small"></el-input-number>
|
||
<span class="muted">检索模式:</span>
|
||
<el-radio-group v-model="searchForm.mode" size="small">
|
||
<el-radio-button label="fulltext">全文</el-radio-button>
|
||
<el-radio-button label="vector" disabled>向量</el-radio-button>
|
||
<el-radio-button label="blend" disabled>混合</el-radio-button>
|
||
</el-radio-group>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
<div v-if="searchResults.length > 0">
|
||
<div class="toolbar" style="margin-bottom: 10px">
|
||
<span class="section-title" style="margin: 0">检索结果({{ searchResults.length }} 条)</span>
|
||
<span class="muted">耗时 {{ searchDurationMs }}ms · 排序按 BM25 相关度</span>
|
||
</div>
|
||
<div v-for="(r, idx) in searchResults" :key="r.chunk_id" class="result-card">
|
||
<div class="result-title">
|
||
<el-tag size="small" type="info" effect="plain">#{{ idx + 1 }}</el-tag>
|
||
{{ r.title || '(无标题)' }}
|
||
<!-- 精确匹配(boolean)不打标;自然语言回退命中时提示,便于判断召回质量 -->
|
||
<el-tag v-if="r.source_type === 'fulltext-natural'" size="small" type="warning" effect="plain">分词回退</el-tag>
|
||
</div>
|
||
<div class="result-content">{{ r.content }}</div>
|
||
<div class="score-bar">
|
||
<span class="muted">相关度</span>
|
||
<el-progress :percentage="scorePercent(r.score)" :stroke-width="5" :show-text="false"
|
||
style="flex:1; max-width:180px;" color="var(--accent)"></el-progress>
|
||
<el-tag size="small" type="success">{{ r.score.toFixed(3) }}</el-tag>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="searchedOnce" class="glass-card empty-state">
|
||
<div class="icon-wrap"><el-icon size="32"><Search></Search></el-icon></div>
|
||
<h3>没有找到相关内容</h3>
|
||
<p>换个关键词试试。短词走精确子串匹配;长句/整段会自动分词做相关度检索,仍无结果说明库内确实没有相关内容</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 运行详情抽屉:el-timeline 步骤时间线 -->
|
||
<el-drawer v-model="drawerVisible" :title="'运行 #' + (detail?.id ?? '') + ' · ' + (detail?.scene ?? '')"
|
||
size="620px" destroy-on-close>
|
||
<template v-if="detail">
|
||
<div class="glass-card" style="margin-bottom: 16px">
|
||
<div class="cfg-row"><div class="cfg-key">状态</div>
|
||
<div class="cfg-val">
|
||
<el-tag v-if="detail.status === 1" type="success" size="small">成功</el-tag>
|
||
<el-tag v-else-if="detail.status === 3" type="warning" size="small">守卫拦截</el-tag>
|
||
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="cfg-row"><div class="cfg-key">模型</div>
|
||
<div class="cfg-val">{{ detail.provider || '-' }} / {{ detail.model || '-' }}(来源: {{ detail.cfg_source || '-' }})</div></div>
|
||
<div class="cfg-row"><div class="cfg-key">时间</div>
|
||
<div class="cfg-val">{{ fmtTime(detail.started_at) }} → {{ fmtTime(detail.finished_at) }}({{ fmtMs(detail.total_ms) }})</div></div>
|
||
<div class="cfg-row"><div class="cfg-key">Token</div>
|
||
<div class="cfg-val">输入 {{ detail.prompt_tokens }} + 输出 {{ detail.completion_tokens }} = {{ detail.total_tokens }}</div></div>
|
||
<div class="cfg-row" v-if="detail.error"><div class="cfg-key">错误</div>
|
||
<div class="cfg-val" style="color: var(--danger)">{{ detail.error }}</div></div>
|
||
</div>
|
||
<el-timeline v-if="detail.steps && detail.steps.length">
|
||
<el-timeline-item v-for="(st, i) in detail.steps" :key="i"
|
||
:type="st.status === 2 ? 'danger' : (st.status === 1 ? 'success' : 'info')"
|
||
:timestamp="fmtTime(st.started_at) + ' · ' + fmtMs(st.duration_ms)" placement="top">
|
||
<div class="timeline-card-title">
|
||
{{ stepTypeName(st.step_type) }}
|
||
<el-tag size="small" :type="st.status === 2 ? 'danger' : 'success'">
|
||
{{ st.status === 2 ? '失败' : '完成' }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="step-meta" v-if="st.provider">
|
||
{{ st.provider }} / {{ st.model }}
|
||
<template v-if="st.total_tokens"> · token {{ st.prompt_tokens }}+{{ st.completion_tokens }}={{ st.total_tokens }}</template>
|
||
<template v-if="st.api_key_id"> · key#{{ st.api_key_id }}</template>
|
||
</div>
|
||
<div class="step-detail" v-if="st.detail">{{ st.detail }}</div>
|
||
</el-timeline-item>
|
||
</el-timeline>
|
||
<div v-else class="muted">本次运行无步骤明细</div>
|
||
</template>
|
||
</el-drawer>
|
||
|
||
<!-- 新建知识库对话框 -->
|
||
<el-dialog v-model="kbDialog.createLib" title="新建知识库" width="460px">
|
||
<el-form label-position="top">
|
||
<el-form-item label="库名" required>
|
||
<el-input v-model="createForm.name" placeholder="例如:中医诊疗资料 v1"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="描述">
|
||
<el-input v-model="createForm.description" type="textarea" :rows="2" placeholder="可选"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="来源">
|
||
<el-radio-group v-model="createForm.source">
|
||
<el-radio label="manual">手动建立</el-radio>
|
||
<el-radio label="maxkb_export">从 MaxKB 导出</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-alert type="info" :closable="false">
|
||
V1 默认不做向量化,检索走 MySQL FULLTEXT(ngram 2-gram 中文分词)。
|
||
</el-alert>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="kbDialog.createLib = false">取消</el-button>
|
||
<el-button type="primary" :loading="kbLoading.create" @click="submitCreateLib">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 分段查看抽屉 -->
|
||
<el-drawer v-model="chunksDrawer.visible" :title="chunksDrawer.title" direction="rtl" size="55%" destroy-on-close>
|
||
<div v-loading="kbLoading.chunks" style="padding: 0 4px">
|
||
<div v-for="chunk in chunks" :key="chunk.id" class="chunk-item">
|
||
<div class="chunk-head">
|
||
<span class="chunk-idx">{{ chunk.chunk_index + 1 }}</span>
|
||
<span class="chunk-title">{{ chunk.title || '(无标题)' }}</span>
|
||
<span style="flex:1;"></span>
|
||
<el-button size="small" plain @click="openEditChunk(chunk)">
|
||
<el-icon style="margin-right: 4px"><Edit></Edit></el-icon>编辑
|
||
</el-button>
|
||
</div>
|
||
<div class="chunk-content">{{ chunk.content }}</div>
|
||
<div v-if="getRelatedQuestions(chunk.meta_json)" class="chunk-related">
|
||
<strong>关联问题:</strong>{{ getRelatedQuestions(chunk.meta_json) }}
|
||
</div>
|
||
</div>
|
||
<div v-if="!kbLoading.chunks && chunks.length === 0" class="empty-state" style="padding: 40px">
|
||
<div class="icon-wrap" style="width: 56px; height: 56px">
|
||
<el-icon size="24"><Document></Document></el-icon>
|
||
</div>
|
||
<h3 style="font-size: 14px">无分段</h3>
|
||
</div>
|
||
</div>
|
||
</el-drawer>
|
||
|
||
<!-- 分段编辑对话框 -->
|
||
<el-dialog v-model="editChunkDialog.visible" title="编辑分段" width="640px" :close-on-click-modal="false">
|
||
<el-form label-position="top">
|
||
<el-form-item label="标题">
|
||
<el-input v-model="editChunkForm.title" placeholder="可空(如来自 md 的标题)"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="内容" required>
|
||
<el-input v-model="editChunkForm.content" type="textarea" :rows="10" placeholder="分段正文(必填)"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="关联问题">
|
||
<div style="width: 100%">
|
||
<div v-for="(q, idx) in editChunkForm.relatedQuestions" :key="idx"
|
||
style="display:flex; gap:8px; margin-bottom:8px;">
|
||
<el-input v-model="editChunkForm.relatedQuestions[idx]" placeholder="如:痰湿中阻怎么治"></el-input>
|
||
<el-button type="danger" plain circle @click="editChunkForm.relatedQuestions.splice(idx, 1)">
|
||
<el-icon><Delete></Delete></el-icon>
|
||
</el-button>
|
||
</div>
|
||
<el-button size="small" plain @click="editChunkForm.relatedQuestions.push('')">
|
||
<el-icon style="margin-right: 4px"><Plus></Plus></el-icon>添加关联问题
|
||
</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editChunkDialog.visible = false">取消</el-button>
|
||
<el-button type="primary" :loading="editChunkDialog.saving" @click="saveEditChunk">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
|
||
<script>
|
||
/* ============================================================
|
||
* TCM Agent 控制台(左侧菜单 + 首页仪表盘)
|
||
* ============================================================
|
||
* 页面:首页 / 运行记录 / 统计概览 / 实时日志 / 系统状态 / 调试工具 / 配置总览
|
||
*
|
||
* 鉴权:与 KB 页共用管理口令(X-KB-Admin-Password 头),
|
||
* localStorage 共用 kb_admin_pwd——KB 页输过口令这里免输,反之亦然。
|
||
* 401 时自动清缓存重新弹口令框。
|
||
*
|
||
* localStorage 记忆:
|
||
* - agent_view_tab 当前菜单页
|
||
* - agent_view_refresh 自动刷新开关
|
||
* - agent_view_menu_collapse 菜单折叠状态
|
||
* - kb_admin_pwd 管理口令(与 KB 页共用)
|
||
* - kb_theme 主题(与 KB 页共用,联动切换)
|
||
* ============================================================ */
|
||
const { createApp, ref, reactive, computed, onMounted, onUnmounted, nextTick } = Vue;
|
||
|
||
const TAB_KEY = 'agent_view_tab';
|
||
const REFRESH_KEY = 'agent_view_refresh';
|
||
const COLLAPSE_KEY = 'agent_view_menu_collapse';
|
||
const PWD_STORAGE_KEY = 'kb_admin_pwd'; // 与 KB 页共用
|
||
const THEME_KEY = 'kb_theme'; // 与 KB 页共用
|
||
|
||
const app = createApp({
|
||
setup() {
|
||
// ---------- 主题 ----------
|
||
const manualTheme = ref(localStorage.getItem(THEME_KEY) || '');
|
||
const isDark = computed(() => {
|
||
if (manualTheme.value) return manualTheme.value === 'dark';
|
||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
});
|
||
function applyTheme() {
|
||
const t = manualTheme.value || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
document.documentElement.classList.toggle('dark', t === 'dark');
|
||
}
|
||
function toggleTheme() {
|
||
manualTheme.value = isDark.value ? 'light' : 'dark';
|
||
localStorage.setItem(THEME_KEY, manualTheme.value);
|
||
applyTheme();
|
||
rebuildCharts();
|
||
}
|
||
|
||
// ---------- 口令管理(与 KB 页同款交互) ----------
|
||
const pwdDialog = reactive({ visible: false, loading: false, _resolve: null });
|
||
const pwdInput = ref('');
|
||
const cachedPwd = ref(localStorage.getItem(PWD_STORAGE_KEY) || '');
|
||
// requirePwd:没有缓存口令时弹框等用户输入,Promise 在验证成功后 resolve
|
||
function requirePwd() {
|
||
return new Promise((resolve) => {
|
||
if (cachedPwd.value) { resolve(cachedPwd.value); return; }
|
||
pwdInput.value = '';
|
||
pwdDialog.visible = true;
|
||
pwdDialog._resolve = resolve;
|
||
});
|
||
}
|
||
// submitPwd:用最轻量的 /api/v1/agent/stats 验证口令是否正确
|
||
async function submitPwd() {
|
||
if (!pwdInput.value.trim()) { ElementPlus.ElMessage.warning('请输入口令'); return; }
|
||
const pwd = pwdInput.value.trim();
|
||
pwdDialog.loading = true;
|
||
try {
|
||
const res = await fetch('/api/v1/agent/stats', {
|
||
headers: { 'X-KB-Admin-Password': pwd },
|
||
});
|
||
if (res.status === 401) { ElementPlus.ElMessage.error('口令错误'); return; }
|
||
cachedPwd.value = pwd;
|
||
localStorage.setItem(PWD_STORAGE_KEY, pwd);
|
||
pwdDialog.visible = false;
|
||
if (pwdDialog._resolve) pwdDialog._resolve(pwd);
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('验证失败: ' + e.message);
|
||
} finally {
|
||
pwdDialog.loading = false;
|
||
}
|
||
}
|
||
function logout() {
|
||
cachedPwd.value = '';
|
||
localStorage.removeItem(PWD_STORAGE_KEY);
|
||
location.reload();
|
||
}
|
||
|
||
// ---------- 统一请求封装(带口令头,401 自动重新要口令) ----------
|
||
async function apiFetch(url, options = {}) {
|
||
let pwd = cachedPwd.value || await requirePwd();
|
||
const opts = Object.assign({}, options);
|
||
opts.headers = Object.assign({}, options.headers || {}, { 'X-KB-Admin-Password': pwd });
|
||
let res = await fetch(url, opts);
|
||
if (res.status === 401) {
|
||
// 口令过期/被改:清缓存重新弹框再试一次
|
||
cachedPwd.value = '';
|
||
localStorage.removeItem(PWD_STORAGE_KEY);
|
||
pwd = await requirePwd();
|
||
opts.headers['X-KB-Admin-Password'] = pwd;
|
||
res = await fetch(url, opts);
|
||
}
|
||
return res.json();
|
||
}
|
||
function apiGet(url) { return apiFetch(url); }
|
||
function apiPost(url, body) {
|
||
return apiFetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body || {}),
|
||
});
|
||
}
|
||
|
||
// ---------- 菜单 ----------
|
||
// icon 名对应全局注册的 ElementPlusIconsVue 组件(挂载前循环注册,见文件末尾)
|
||
const menus = [
|
||
{ key: 'home', label: '首页', icon: 'HomeFilled' },
|
||
{ key: 'runs', label: '运行记录', icon: 'List' },
|
||
{ key: 'stats', label: '统计概览', icon: 'TrendCharts' },
|
||
{ key: 'logs', label: '实时日志', icon: 'Document' },
|
||
{ key: 'system', label: '系统状态', icon: 'Monitor' },
|
||
{ key: 'debug', label: '调试工具', icon: 'Tools' },
|
||
{ key: 'config', label: '配置总览', icon: 'Setting' },
|
||
{ divider: true },
|
||
{ group: '知识库' },
|
||
{ key: 'kb', label: '知识库管理', icon: 'Collection' },
|
||
{ key: 'kbsearch', label: '知识库检索', icon: 'Search' },
|
||
];
|
||
const activePage = ref(localStorage.getItem(TAB_KEY) || 'home');
|
||
// 老用户 localStorage 里可能存着不存在的旧 tab 名,兜底回首页
|
||
if (!menus.some(m => m.key === activePage.value)) activePage.value = 'home';
|
||
const currentMenuLabel = computed(() => (menus.find(m => m.key === activePage.value) || {}).label || '');
|
||
function switchPage(key) {
|
||
activePage.value = key;
|
||
localStorage.setItem(TAB_KEY, key);
|
||
if (key === 'stats') nextTick(() => rebuildCharts());
|
||
// 知识库模块懒加载:首次进入才拉库列表(避免面板启动多打一个请求)
|
||
if ((key === 'kb' || key === 'kbsearch') && !kbLoadedOnce) loadLibraries();
|
||
}
|
||
const menuCollapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1');
|
||
function toggleCollapse() {
|
||
menuCollapsed.value = !menuCollapsed.value;
|
||
localStorage.setItem(COLLAPSE_KEY, menuCollapsed.value ? '1' : '0');
|
||
}
|
||
|
||
// ---------- 偏好 ----------
|
||
const autoRefresh = ref(localStorage.getItem(REFRESH_KEY) !== '0'); // 默认开
|
||
function saveAutoRefresh(v) { localStorage.setItem(REFRESH_KEY, v ? '1' : '0'); }
|
||
|
||
// ---------- 运行记录 ----------
|
||
const runs = ref([]);
|
||
const loadingRuns = ref(false);
|
||
const filterScene = ref('');
|
||
const filterStatus = ref(null);
|
||
const bufferSize = ref(200);
|
||
const sceneOptions = computed(() => [...new Set(runs.value.map(r => r.scene).filter(Boolean))]);
|
||
|
||
async function loadRuns(silent) {
|
||
if (silent !== true) loadingRuns.value = true;
|
||
try {
|
||
const params = new URLSearchParams({ limit: '200' });
|
||
if (filterScene.value) params.set('scene', filterScene.value);
|
||
if (filterStatus.value) params.set('status', String(filterStatus.value));
|
||
const j = await apiGet('/api/v1/agent/runs?' + params.toString());
|
||
runs.value = j.data || [];
|
||
if (activePage.value === 'stats') nextTick(() => rebuildCharts());
|
||
} catch (e) {
|
||
if (silent !== true) ElementPlus.ElMessage.error('加载运行记录失败:' + e.message);
|
||
} finally {
|
||
loadingRuns.value = false;
|
||
}
|
||
}
|
||
|
||
const drawerVisible = ref(false);
|
||
const detail = ref(null);
|
||
async function openRunDetail(row) {
|
||
try {
|
||
const j = await apiGet('/api/v1/agent/runs/' + row.id);
|
||
// 环形缓冲只保留最近 N 条:老记录被覆盖后接口返回业务码 404,
|
||
// 必须检查 code,否则会打开一个空白抽屉
|
||
if (j.code !== 200 || !j.data) {
|
||
ElementPlus.ElMessage.warning(j.message || '记录已被环形缓冲覆盖,无法查看详情');
|
||
return;
|
||
}
|
||
detail.value = j.data;
|
||
drawerVisible.value = true;
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('加载详情失败:' + e.message);
|
||
}
|
||
}
|
||
|
||
// ---------- 统计 + 图表 ----------
|
||
const stats = ref({});
|
||
const successRate = computed(() => {
|
||
const t = stats.value.total || 0;
|
||
if (!t) return '-';
|
||
return Math.round((stats.value.success || 0) / t * 100) + '%';
|
||
});
|
||
const sceneEntries = computed(() => Object.entries(stats.value.scene_counts || {}));
|
||
async function loadStats(silent) {
|
||
try {
|
||
const j = await apiGet('/api/v1/agent/stats');
|
||
stats.value = j.data || {};
|
||
if (stats.value.buffer_size) bufferSize.value = stats.value.buffer_size;
|
||
} catch (e) {
|
||
if (silent !== true) ElementPlus.ElMessage.error('加载统计失败:' + e.message);
|
||
}
|
||
}
|
||
|
||
const durationChartEl = ref(null);
|
||
const tokenChartEl = ref(null);
|
||
let durationChart = null;
|
||
let tokenChart = null;
|
||
function cssVar(name) {
|
||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
}
|
||
function rebuildCharts() {
|
||
const ordered = [...runs.value].reverse();
|
||
const labels = ordered.map(r => '#' + r.id);
|
||
const textColor = cssVar('--text-tertiary');
|
||
const axisCommon = {
|
||
axisLabel: { color: textColor },
|
||
axisLine: { lineStyle: { color: cssVar('--border') } },
|
||
splitLine: { lineStyle: { color: cssVar('--border') } },
|
||
};
|
||
if (durationChart) { durationChart.dispose(); durationChart = null; }
|
||
if (durationChartEl.value) {
|
||
durationChart = echarts.init(durationChartEl.value);
|
||
durationChart.setOption({
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 50, right: 20, top: 20, bottom: 30 },
|
||
xAxis: { type: 'category', data: labels, ...axisCommon },
|
||
yAxis: { type: 'value', name: 'ms', ...axisCommon },
|
||
series: [{
|
||
type: 'line', smooth: true, showSymbol: true,
|
||
data: ordered.map(r => ({
|
||
value: r.total_ms,
|
||
itemStyle: r.status !== 1 ? { color: cssVar('--danger') } : { color: cssVar('--accent') },
|
||
})),
|
||
lineStyle: { color: cssVar('--accent') },
|
||
areaStyle: { opacity: 0.08, color: cssVar('--accent') },
|
||
}],
|
||
});
|
||
}
|
||
if (tokenChart) { tokenChart.dispose(); tokenChart = null; }
|
||
if (tokenChartEl.value) {
|
||
tokenChart = echarts.init(tokenChartEl.value);
|
||
tokenChart.setOption({
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 50, right: 20, top: 20, bottom: 30 },
|
||
xAxis: { type: 'category', data: labels, ...axisCommon },
|
||
yAxis: { type: 'value', name: 'token', ...axisCommon },
|
||
series: [
|
||
{ name: '总 token', type: 'bar', data: ordered.map(r => r.total_tokens), itemStyle: { color: cssVar('--warning') } },
|
||
],
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------- 实时日志 ----------
|
||
const logs = ref([]);
|
||
const logFilter = ref('');
|
||
const logAutoScroll = ref(true);
|
||
const logViewerEl = ref(null);
|
||
let logLastID = 0;
|
||
function clearLogView() { logs.value = []; }
|
||
const filteredLogs = computed(() => {
|
||
if (!logFilter.value) return logs.value;
|
||
const kw = logFilter.value.toLowerCase();
|
||
return logs.value.filter(l => l.line.toLowerCase().includes(kw));
|
||
});
|
||
function colorizeLog(line) {
|
||
const esc = line.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
return esc
|
||
.replace(/(失败|错误|致命|panic|⛔|❌)/g, '<span class="tag-err">$1</span>')
|
||
.replace(/(⚠️|警告|拦截)/g, '<span class="tag-warn">$1</span>')
|
||
.replace(/(\[HTTP\])/g, '<span class="tag-http">$1</span>')
|
||
.replace(/(\[[A-Za-z\u4e00-\u9fa5]+\])/g, '<span class="tag-mod">$1</span>');
|
||
}
|
||
async function pollLogs() {
|
||
try {
|
||
const j = await apiGet('/api/v1/agent/logs?since_id=' + logLastID + '&limit=500');
|
||
const d = j.data || {};
|
||
const entries = d.entries || [];
|
||
if (entries.length) {
|
||
logs.value.push(...entries);
|
||
if (logs.value.length > 1000) logs.value = logs.value.slice(-1000);
|
||
logLastID = d.last_id || logLastID;
|
||
if (logAutoScroll.value) {
|
||
nextTick(() => {
|
||
if (logViewerEl.value) logViewerEl.value.scrollTop = logViewerEl.value.scrollHeight;
|
||
});
|
||
}
|
||
}
|
||
} catch (e) { /* 日志轮询静默失败 */ }
|
||
}
|
||
|
||
// ---------- 系统状态 + 健康 ----------
|
||
const sys = ref({});
|
||
const healthEntries = ref([]);
|
||
const healthOK = ref(false);
|
||
async function loadSystem() {
|
||
try {
|
||
const j = await apiGet('/api/v1/agent/system');
|
||
sys.value = j.data || {};
|
||
} catch (e) { /* 静默 */ }
|
||
try {
|
||
// /health 无需鉴权,直接 fetch
|
||
const res = await fetch('/health');
|
||
const h = await res.json();
|
||
healthOK.value = h.status === 'ok';
|
||
healthEntries.value = Object.entries(h).map(([k, v]) => [k, String(v)]);
|
||
} catch (e) { healthOK.value = false; }
|
||
}
|
||
|
||
// ---------- 调试工具 ----------
|
||
const debugPanels = ref(['guard']);
|
||
|
||
const guardText = ref('');
|
||
const guardLoading = ref(false);
|
||
const guardResult = ref(null);
|
||
async function runGuardTest() {
|
||
if (!guardText.value.trim()) { ElementPlus.ElMessage.warning('请输入测试文本'); return; }
|
||
guardLoading.value = true;
|
||
try {
|
||
const j = await apiPost('/api/v1/agent/guard-test', { text: guardText.value });
|
||
guardResult.value = j.data;
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('测试失败:' + e.message);
|
||
} finally { guardLoading.value = false; }
|
||
}
|
||
|
||
const modelTestProvider = ref('');
|
||
const modelTestMessage = ref('');
|
||
const modelTestLoading = ref(false);
|
||
const modelTestResult = ref(null);
|
||
async function runModelTest() {
|
||
modelTestLoading.value = true;
|
||
try {
|
||
const j = await apiPost('/api/v1/models/test', {
|
||
provider: modelTestProvider.value, message: modelTestMessage.value,
|
||
});
|
||
modelTestResult.value = j.data;
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('测试失败:' + e.message);
|
||
} finally { modelTestLoading.value = false; }
|
||
}
|
||
|
||
const kbTestQuery = ref('');
|
||
const kbTestTopK = ref(5);
|
||
const kbTestLoading = ref(false);
|
||
const kbTestResult = ref(null);
|
||
async function runKBTest() {
|
||
if (!kbTestQuery.value.trim()) { ElementPlus.ElMessage.warning('请输入检索关键词'); return; }
|
||
kbTestLoading.value = true;
|
||
try {
|
||
const j = await apiPost('/api/v1/agent/kb-test', {
|
||
query: kbTestQuery.value, top_k: kbTestTopK.value,
|
||
});
|
||
kbTestResult.value = j.data;
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('检索失败:' + e.message);
|
||
} finally { kbTestLoading.value = false; }
|
||
}
|
||
|
||
const enhScene = ref('medical_record');
|
||
const enhKBEnabled = ref(false);
|
||
const enhSystem = ref('你是资深中医师,请根据患者信息给出简要辨证分析。');
|
||
const enhUser = ref('患者女,38岁,主诉失眠多梦两周,伴心悸健忘,舌淡苔薄白,脉细弱。');
|
||
const enhLoading = ref(false);
|
||
const enhResult = ref(null);
|
||
async function runEnhanceTest() {
|
||
if (!enhUser.value.trim()) { ElementPlus.ElMessage.warning('请输入 user 消息'); return; }
|
||
enhLoading.value = true;
|
||
enhResult.value = null;
|
||
try {
|
||
const messages = [];
|
||
if (enhSystem.value.trim()) messages.push({ role: 'system', content: enhSystem.value });
|
||
messages.push({ role: 'user', content: enhUser.value });
|
||
const body = { scene: enhScene.value, messages, kb_enabled: enhKBEnabled.value };
|
||
// Go 端检索条件是 kb_enabled && context 非空:开关打开时必须带 context,
|
||
// 否则永远不会触发知识库检索(用 user 消息作为检索关键词最贴近真实调用)
|
||
if (enhKBEnabled.value) {
|
||
body.context = enhUser.value;
|
||
body.top_k = 5;
|
||
}
|
||
const j = await apiPost('/api/v1/agent/enhance', body);
|
||
const d = j.data || {};
|
||
enhResult.value = {
|
||
ok: j.code === 200,
|
||
content: d.content,
|
||
message: j.message,
|
||
provider: d.provider || '-',
|
||
model: d.model || '-',
|
||
total_ms: d.total_ms || 0,
|
||
steps: d.steps || [],
|
||
};
|
||
loadRuns(true);
|
||
loadStats(true);
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('请求失败:' + e.message);
|
||
} finally { enhLoading.value = false; }
|
||
}
|
||
function showEnhanceTimeline() {
|
||
if (!enhResult.value) return;
|
||
let pt = 0, ct = 0, tt = 0;
|
||
for (const st of enhResult.value.steps) {
|
||
pt += st.prompt_tokens || 0; ct += st.completion_tokens || 0; tt += st.total_tokens || 0;
|
||
}
|
||
detail.value = {
|
||
id: '测试', scene: enhScene.value,
|
||
provider: enhResult.value.provider, model: enhResult.value.model,
|
||
cfg_source: '-', status: enhResult.value.ok ? 1 : 2,
|
||
error: enhResult.value.ok ? '' : (enhResult.value.message || ''),
|
||
total_ms: enhResult.value.total_ms,
|
||
started_at: 0, finished_at: 0,
|
||
prompt_tokens: pt, completion_tokens: ct, total_tokens: tt,
|
||
steps: enhResult.value.steps,
|
||
};
|
||
drawerVisible.value = true;
|
||
}
|
||
|
||
// ---------- 配置总览 ----------
|
||
const cfgEntries = ref([]);
|
||
const cfgError = ref('');
|
||
const loadingCfg = ref(false);
|
||
const agentCfg = ref(null);
|
||
const routeEntries = ref([]);
|
||
// 首页横幅用:生效模型简写
|
||
const activeModelText = computed(() => {
|
||
const m = Object.fromEntries(cfgEntries.value);
|
||
if (!m.provider) return '-';
|
||
return m.provider + ' / ' + m.model;
|
||
});
|
||
async function loadConfig() {
|
||
loadingCfg.value = true;
|
||
cfgError.value = '';
|
||
try {
|
||
const j = await apiGet('/api/v1/models/active-config');
|
||
// DB 解析失败时后端返回 HTTP 200 + 业务码 500:必须检查 code,
|
||
// 否则 cfgEntries 恒为空、cfgError 也为空,页面永远显示「加载中…」假死
|
||
if (j.code !== 200) throw new Error(j.message || '解析生效配置失败');
|
||
cfgEntries.value = Object.entries(j.data || {}).map(([k, v]) => [k, String(v)]);
|
||
} catch (e) {
|
||
cfgEntries.value = [];
|
||
cfgError.value = '加载失败:' + e.message;
|
||
} finally { loadingCfg.value = false; }
|
||
try {
|
||
const j = await apiGet('/api/v1/agent/config');
|
||
agentCfg.value = j.data;
|
||
} catch (e) { /* 静默 */ }
|
||
try {
|
||
const j = await apiGet('/api/v1/models/routes');
|
||
routeEntries.value = Object.entries(j.routes || {});
|
||
} catch (e) { /* 静默 */ }
|
||
}
|
||
function invalidateCache() {
|
||
ElementPlus.ElMessageBox.confirm(
|
||
'将清空 Go 端 LLM 配置缓存,下次请求立刻从数据库重新读取生效配置。确认执行?',
|
||
'失效配置缓存', { type: 'warning' },
|
||
).then(async () => {
|
||
try {
|
||
const j = await apiPost('/api/v1/models/invalidate-cache');
|
||
ElementPlus.ElMessage.success(j.message || '已失效');
|
||
loadConfig();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('操作失败:' + e.message);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// ============================================================
|
||
// 知识库模块(自 /kb/view 并入,接口不变:/api/v1/kb/admin/*)
|
||
// 请求复用本页 apiFetch(同一口令头 + 401 自动重弹口令框),
|
||
// 返回结构是 {code, message, data},这里再包一层业务码校验
|
||
// ============================================================
|
||
const KB_API = '/api/v1/kb/admin';
|
||
async function kbApi(path, options = {}) {
|
||
const json = await apiFetch(KB_API + path, options);
|
||
if (json.code !== 200) throw new Error(json.message || '请求失败');
|
||
return json.data;
|
||
}
|
||
|
||
let kbLoadedOnce = false;
|
||
const libraries = ref([]);
|
||
const kbLoading = reactive({ libs: false, docs: false, chunks: false, import: false, search: false, create: false });
|
||
const currentLib = ref(null);
|
||
const docs = ref([]);
|
||
const chunks = ref([]);
|
||
const chunksDrawer = reactive({ visible: false, title: '' });
|
||
const isDragOver = ref(false);
|
||
const fileInput = ref(null);
|
||
|
||
async function loadLibraries() {
|
||
kbLoading.libs = true;
|
||
kbLoadedOnce = true;
|
||
try {
|
||
libraries.value = await kbApi('/libraries');
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('加载库列表失败: ' + e.message);
|
||
} finally { kbLoading.libs = false; }
|
||
}
|
||
|
||
// ----- 库 CRUD -----
|
||
const kbDialog = reactive({ createLib: false });
|
||
const createForm = reactive({ name: '', description: '', source: 'manual' });
|
||
function openCreateLib() {
|
||
createForm.name = ''; createForm.description = ''; createForm.source = 'manual';
|
||
kbDialog.createLib = true;
|
||
}
|
||
async function submitCreateLib() {
|
||
if (!createForm.name.trim()) { ElementPlus.ElMessage.warning('请填库名'); return; }
|
||
kbLoading.create = true;
|
||
try {
|
||
await kbApi('/libraries', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(createForm),
|
||
});
|
||
ElementPlus.ElMessage.success('知识库已创建');
|
||
kbDialog.createLib = false;
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('创建失败: ' + e.message);
|
||
} finally { kbLoading.create = false; }
|
||
}
|
||
function onLibCommand(cmd, lib) {
|
||
if (cmd === 'search') { jumpToKBSearch(lib.id); }
|
||
else if (cmd === 'delete') { deleteLibrary(lib); }
|
||
}
|
||
// 从库卡片/库详情跳到检索页并预选该库
|
||
function jumpToKBSearch(libId) {
|
||
searchForm.library_id = libId;
|
||
switchPage('kbsearch');
|
||
}
|
||
async function deleteLibrary(lib) {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
`确定删除「${lib.name}」?该库下 ${lib.doc_count} 篇文档和 ${lib.chunk_count} 个分段都会被软删除。`,
|
||
'危险操作',
|
||
{ type: 'warning', confirmButtonText: '确认删除', confirmButtonClass: 'el-button--danger' },
|
||
);
|
||
} catch { return; }
|
||
try {
|
||
await kbApi('/libraries/' + lib.id, { method: 'DELETE' });
|
||
ElementPlus.ElMessage.success('已删除');
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ----- 进入/退出库详情 -----
|
||
async function openLibrary(lib) {
|
||
currentLib.value = lib;
|
||
await loadDocs(lib.id);
|
||
}
|
||
function exitLibrary() {
|
||
currentLib.value = null;
|
||
docs.value = [];
|
||
loadLibraries();
|
||
}
|
||
async function loadDocs(libId) {
|
||
kbLoading.docs = true;
|
||
try { docs.value = await kbApi('/libraries/' + libId + '/docs'); }
|
||
catch (e) { ElementPlus.ElMessage.error('加载文档失败: ' + e.message); }
|
||
finally { kbLoading.docs = false; }
|
||
}
|
||
|
||
// ----- 文件导入 -----
|
||
function triggerFileInput() {
|
||
if (!kbLoading.import && fileInput.value) fileInput.value.click();
|
||
}
|
||
function handleFileInput(e) {
|
||
const file = e.target.files[0];
|
||
if (file) doImport(file);
|
||
e.target.value = '';
|
||
}
|
||
function handleDrop(e) {
|
||
isDragOver.value = false;
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) doImport(file);
|
||
}
|
||
async function doImport(file) {
|
||
if (!currentLib.value) { ElementPlus.ElMessage.warning('请先选择库'); return; }
|
||
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||
// 与后端 kb.ParseFileFromBytes 支持的格式保持一致
|
||
if (!['.xlsx', '.xls', '.csv', '.md', '.txt', '.pdf', '.docx', '.html', '.htm'].includes(ext)) {
|
||
ElementPlus.ElMessage.error('不支持的文件类型(支持 xlsx/xls/csv/md/txt/pdf/docx/html)');
|
||
return;
|
||
}
|
||
if (file.size > 50 * 1024 * 1024) {
|
||
ElementPlus.ElMessage.error('文件过大(最大 50MB)');
|
||
return;
|
||
}
|
||
kbLoading.import = true;
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('library_id', String(currentLib.value.id));
|
||
fd.append('file', file);
|
||
// FormData 不能手动设 Content-Type(浏览器要自动带 multipart 边界),
|
||
// apiFetch 只加口令头,正好满足
|
||
const data = await kbApi('/docs/import', { method: 'POST', body: fd });
|
||
ElementPlus.ElMessage({
|
||
type: 'success',
|
||
message: `导入成功!「${file.name}」共切分为 ${data.chunk_count} 个分段`,
|
||
duration: 3500,
|
||
});
|
||
await loadDocs(currentLib.value.id);
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('导入失败: ' + e.message);
|
||
} finally { kbLoading.import = false; }
|
||
}
|
||
async function deleteDoc(doc) {
|
||
try {
|
||
await ElementPlus.ElMessageBox.confirm(
|
||
`确定删除文档「${doc.title}」?所有 ${doc.chunk_count} 个分段会一并软删除。`,
|
||
'危险操作',
|
||
{ type: 'warning', confirmButtonClass: 'el-button--danger' },
|
||
);
|
||
} catch { return; }
|
||
try {
|
||
await kbApi('/docs/' + doc.id, { method: 'DELETE' });
|
||
ElementPlus.ElMessage.success('已删除');
|
||
await loadDocs(currentLib.value.id);
|
||
await loadLibraries();
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ----- 分段查看/编辑 -----
|
||
async function openChunksDrawer(doc) {
|
||
chunksDrawer.title = `「${doc.title}」分段详情(共 ${doc.chunk_count} 个)`;
|
||
chunksDrawer.visible = true;
|
||
kbLoading.chunks = true;
|
||
chunks.value = [];
|
||
try {
|
||
chunks.value = await kbApi('/docs/' + doc.id + '/chunks');
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('加载分段失败: ' + e.message);
|
||
} finally { kbLoading.chunks = false; }
|
||
}
|
||
function getRelatedQuestions(meta) {
|
||
if (!meta) return '';
|
||
try {
|
||
const obj = typeof meta === 'string' ? JSON.parse(meta) : meta;
|
||
return obj.related_questions ? obj.related_questions.join(';') : '';
|
||
} catch { return ''; }
|
||
}
|
||
const editChunkDialog = reactive({ visible: false, saving: false, chunkId: null });
|
||
const editChunkForm = reactive({ title: '', content: '', relatedQuestions: [] });
|
||
function openEditChunk(chunk) {
|
||
editChunkDialog.chunkId = chunk.id;
|
||
editChunkForm.title = chunk.title || '';
|
||
editChunkForm.content = chunk.content || '';
|
||
let qs = [];
|
||
if (chunk.meta_json) {
|
||
try {
|
||
const obj = typeof chunk.meta_json === 'string' ? JSON.parse(chunk.meta_json) : chunk.meta_json;
|
||
qs = obj.related_questions ? [...obj.related_questions] : [];
|
||
} catch { qs = []; }
|
||
}
|
||
editChunkForm.relatedQuestions = qs;
|
||
editChunkDialog.visible = true;
|
||
}
|
||
async function saveEditChunk() {
|
||
if (!editChunkForm.content.trim()) { ElementPlus.ElMessage.warning('内容不能为空'); return; }
|
||
const cleanedQs = editChunkForm.relatedQuestions.map(s => (s || '').trim()).filter(s => s !== '');
|
||
editChunkDialog.saving = true;
|
||
try {
|
||
await kbApi('/chunks/' + editChunkDialog.chunkId, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: editChunkForm.title,
|
||
content: editChunkForm.content,
|
||
related_questions: cleanedQs,
|
||
}),
|
||
});
|
||
ElementPlus.ElMessage.success('已保存');
|
||
editChunkDialog.visible = false;
|
||
// 刷新分段列表让用户立即看到改动
|
||
if (chunksDrawer.visible && chunks.value.length > 0) {
|
||
const docId = chunks.value[0].doc_id;
|
||
chunks.value = await kbApi('/docs/' + docId + '/chunks');
|
||
}
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('保存失败: ' + e.message);
|
||
} finally { editChunkDialog.saving = false; }
|
||
}
|
||
|
||
// ----- 全文检索测试 -----
|
||
const searchForm = reactive({ library_id: null, query: '', top_k: 5, mode: 'fulltext' });
|
||
const searchResults = ref([]);
|
||
const searchedOnce = ref(false);
|
||
const searchDurationMs = ref(0);
|
||
async function runSearch() {
|
||
if (!searchForm.library_id) { ElementPlus.ElMessage.warning('请选择知识库'); return; }
|
||
if (!searchForm.query.trim()) { ElementPlus.ElMessage.warning('请输入检索词'); return; }
|
||
kbLoading.search = true;
|
||
const t0 = performance.now();
|
||
try {
|
||
searchResults.value = await kbApi('/search', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(searchForm),
|
||
});
|
||
searchDurationMs.value = Math.round(performance.now() - t0);
|
||
searchedOnce.value = true;
|
||
if (searchResults.value.length === 0) ElementPlus.ElMessage.info('没有命中相关内容');
|
||
} catch (e) {
|
||
ElementPlus.ElMessage.error('检索失败: ' + e.message);
|
||
} finally { kbLoading.search = false; }
|
||
}
|
||
function scorePercent(score) {
|
||
const max = Math.max(...searchResults.value.map(r => r.score), score, 0.001);
|
||
return Math.round((score / max) * 100);
|
||
}
|
||
function fmtDate(ts) {
|
||
if (!ts || ts <= 0) return '';
|
||
const d = new Date(ts * 1000);
|
||
const p = n => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||
}
|
||
|
||
// ---------- 轮询调度 ----------
|
||
let timer5s = null;
|
||
let timer2s = null;
|
||
function refreshAll(silent) {
|
||
loadRuns(silent);
|
||
loadStats(silent);
|
||
}
|
||
onMounted(async () => {
|
||
applyTheme();
|
||
// 先确保有口令(弹框直到输入正确),再开始拉数据
|
||
await requirePwd();
|
||
refreshAll();
|
||
loadConfig();
|
||
loadSystem();
|
||
pollLogs();
|
||
timer5s = setInterval(() => {
|
||
if (autoRefresh.value) refreshAll(true);
|
||
// 首页和系统页都依赖 system 数据
|
||
if (activePage.value === 'system' || activePage.value === 'home') loadSystem();
|
||
}, 5000);
|
||
timer2s = setInterval(() => {
|
||
if (activePage.value === 'logs') pollLogs();
|
||
}, 2000);
|
||
if (activePage.value === 'stats') nextTick(() => rebuildCharts());
|
||
// 刷新后直接停留在知识库页时,也要拉一次库列表
|
||
if (activePage.value === 'kb' || activePage.value === 'kbsearch') loadLibraries();
|
||
window.addEventListener('resize', onWinResize);
|
||
});
|
||
onUnmounted(() => {
|
||
if (timer5s) clearInterval(timer5s);
|
||
if (timer2s) clearInterval(timer2s);
|
||
window.removeEventListener('resize', onWinResize);
|
||
});
|
||
function onWinResize() {
|
||
if (durationChart) durationChart.resize();
|
||
if (tokenChart) tokenChart.resize();
|
||
}
|
||
|
||
// ---------- 展示辅助 ----------
|
||
function fmtTime(ts) {
|
||
if (!ts) return '-';
|
||
const d = new Date(ts * 1000);
|
||
const p = n => String(n).padStart(2, '0');
|
||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||
}
|
||
function fmtMs(ms) {
|
||
if (ms == null) return '-';
|
||
if (ms < 1000) return ms + 'ms';
|
||
return (ms / 1000).toFixed(1) + 's';
|
||
}
|
||
function fmtUptime(sec) {
|
||
if (sec == null) return '-';
|
||
if (sec < 3600) return Math.floor(sec / 60) + ' 分钟';
|
||
if (sec < 86400) return (sec / 3600).toFixed(1) + ' 小时';
|
||
return (sec / 86400).toFixed(1) + ' 天';
|
||
}
|
||
const STEP_NAMES = {
|
||
medical_guard: '医疗守卫',
|
||
kb_retrieval: '知识库检索',
|
||
plan: '计划(Planning)',
|
||
llm_call: 'LLM 调用',
|
||
tool_call: '工具调用',
|
||
reflection: '反思审核(Reflection)',
|
||
};
|
||
function stepTypeName(t) { return STEP_NAMES[t] || t; }
|
||
|
||
return {
|
||
isDark, toggleTheme,
|
||
pwdDialog, pwdInput, submitPwd, logout,
|
||
menus, activePage, currentMenuLabel, switchPage, menuCollapsed, toggleCollapse,
|
||
autoRefresh, saveAutoRefresh,
|
||
runs, loadingRuns, filterScene, filterStatus, sceneOptions, bufferSize, loadRuns,
|
||
drawerVisible, detail, openRunDetail,
|
||
stats, successRate, sceneEntries, durationChartEl, tokenChartEl,
|
||
logs, logFilter, logAutoScroll, logViewerEl, filteredLogs, colorizeLog, clearLogView,
|
||
sys, healthEntries, healthOK,
|
||
debugPanels,
|
||
guardText, guardLoading, guardResult, runGuardTest,
|
||
modelTestProvider, modelTestMessage, modelTestLoading, modelTestResult, runModelTest,
|
||
kbTestQuery, kbTestTopK, kbTestLoading, kbTestResult, runKBTest,
|
||
enhScene, enhKBEnabled, enhSystem, enhUser, enhLoading, enhResult, runEnhanceTest, showEnhanceTimeline,
|
||
cfgEntries, cfgError, loadingCfg, agentCfg, routeEntries, loadConfig, invalidateCache, activeModelText,
|
||
fmtTime, fmtMs, fmtUptime, stepTypeName,
|
||
// 知识库模块
|
||
libraries, kbLoading, currentLib, docs, chunks, chunksDrawer, isDragOver, fileInput,
|
||
kbDialog, createForm, openCreateLib, submitCreateLib, onLibCommand, jumpToKBSearch, deleteLibrary,
|
||
openLibrary, exitLibrary, triggerFileInput, handleFileInput, handleDrop, deleteDoc,
|
||
openChunksDrawer, getRelatedQuestions, editChunkDialog, editChunkForm, openEditChunk, saveEditChunk,
|
||
searchForm, searchResults, searchedOnce, searchDurationMs, runSearch, scorePercent, fmtDate,
|
||
};
|
||
},
|
||
});
|
||
|
||
// 全局注册 Element Plus 图标组件(不注册的话所有 <el-icon> 都是空白——上个版本踩的坑)
|
||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||
app.component(key, component);
|
||
}
|
||
app.use(ElementPlus, { locale: ElementPlusLocaleZhCn }).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|