- 将 A_Memorix 从旧 submodule / 插件形态迁入主线源码,主体落到 src/A_memorix - 调整主程序接入方式,使 A_Memorix 作为源码内长期记忆子系统运行 - 回收父项目插件体系中针对 A_Memorix 的特判,减少对 plugin 通用层的侵入 - 将长期记忆配置、运行时、自检、导入、调优等能力收口到 memory 路由与主线服务层 - 重做长期记忆控制台与图谱页面,按 MaiBot 现有 dashboard 风格接入 - 补充实体关系图与证据视图双视图能力,支持查看节点、关系、段落及其证据链路 - 新增长期记忆配置编辑器与 memory-api,支持主线内配置管理 - 补齐删除管理能力:删除预览、混合删除、来源批量删除、删除操作恢复 - 优化删除预览与删除操作详情的前端展示,支持分页、检索,并以实体名/关系内容/段落摘要替代单纯 hash 展示 - 修复图谱与控制台相关前端问题,包括证据视图切换、查询触发时机、删除弹层空值保护等 - 新增或更新 A_Memorix 相关测试、WebUI 路由测试、前端 vitest 测试与辅助验证脚本 - 移除旧 plugins/A_memorix、.gitmodules 及相关历史维护文档
723 lines
28 KiB
HTML
723 lines
28 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>A_Memorix 检索调优中心</title>
|
||
<style>
|
||
:root {
|
||
--bg: #08101f;
|
||
--panel: #0f1a2f;
|
||
--border: #28415f;
|
||
--text: #dbeafe;
|
||
--muted: #93c5fd;
|
||
--pri: #22d3ee;
|
||
--ok: #34d399;
|
||
--warn: #fbbf24;
|
||
--err: #f87171;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||
background: radial-gradient(circle at 0 0, #12335d, transparent 34%), var(--bg);
|
||
color: var(--text);
|
||
}
|
||
.page { width: min(1500px, 96vw); margin: 18px auto 28px; }
|
||
.top { display: flex; justify-content: space-between; gap: 12px; align-items: end; margin-bottom: 12px; }
|
||
.title { font-size: 24px; font-weight: 700; }
|
||
.sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||
.btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.grid { display: grid; grid-template-columns: 460px 1fr; gap: 12px; }
|
||
.card { background: rgba(15, 26, 47, 0.94); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||
.hd { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--pri); font-size: 13px; }
|
||
.bd { padding: 12px; }
|
||
label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 4px; }
|
||
input, select, textarea, button { font: inherit; }
|
||
input, select, textarea {
|
||
width: 100%;
|
||
background: #0a1425;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 8px 10px;
|
||
}
|
||
textarea { min-height: 120px; resize: vertical; }
|
||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||
.btn {
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
background: #152841;
|
||
color: var(--text);
|
||
padding: 8px 10px;
|
||
cursor: pointer;
|
||
}
|
||
.btn.pri {
|
||
border: none;
|
||
color: #022f4d;
|
||
font-weight: 700;
|
||
background: linear-gradient(120deg, #22d3ee, #67e8f9);
|
||
}
|
||
.btn.warn { color: var(--warn); border-color: #854d0e; }
|
||
.btn.err { color: var(--err); border-color: #7f1d1d; }
|
||
.tiny { color: var(--muted); font-size: 12px; }
|
||
.mono {
|
||
font-family: Consolas, Menlo, Monaco, monospace;
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
.task-list { max-height: 280px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 6px; }
|
||
.task { border: 1px solid var(--border); background: #0a1425; border-radius: 8px; padding: 8px; margin-bottom: 7px; cursor: pointer; }
|
||
.task:last-child { margin-bottom: 0; }
|
||
.task.active { border-color: var(--pri); }
|
||
.task .line { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||
.badge { border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 11px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
th, td { text-align: left; border-bottom: 1px solid var(--border); padding: 7px 4px; vertical-align: top; }
|
||
#toast { position: fixed; top: 10px; left: 50%; transform: translateX(-50%); z-index: 100; display: none; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: #0f172a; }
|
||
.cmp-modal-mask {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(2, 6, 23, 0.72);
|
||
z-index: 120;
|
||
padding: 16px;
|
||
}
|
||
.cmp-modal-mask.show { display: flex; }
|
||
.cmp-modal {
|
||
width: min(1080px, 96vw);
|
||
max-height: 90vh;
|
||
background: #0a1425;
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.cmp-hd {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--pri);
|
||
font-size: 14px;
|
||
}
|
||
.cmp-bd {
|
||
padding: 12px;
|
||
overflow: auto;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.cmp-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
.cmp-metric {
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
background: #0f1a2f;
|
||
}
|
||
.cmp-metric .k {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
}
|
||
.cmp-metric .v {
|
||
margin-top: 4px;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
}
|
||
.cmp-delta-pos { color: var(--ok); }
|
||
.cmp-delta-neg { color: var(--err); }
|
||
.cmp-bars {
|
||
margin-top: 6px;
|
||
display: grid;
|
||
gap: 4px;
|
||
}
|
||
.cmp-bar-line {
|
||
display: grid;
|
||
grid-template-columns: 54px 1fr;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
.cmp-bar-wrap {
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
height: 8px;
|
||
overflow: hidden;
|
||
background: #08101f;
|
||
}
|
||
.cmp-bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(120deg, #22d3ee, #67e8f9);
|
||
}
|
||
.cmp-subcard {
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 8px;
|
||
background: #0f1a2f;
|
||
}
|
||
@media (max-width: 1150px) {
|
||
.grid { grid-template-columns: 1fr; }
|
||
.split { grid-template-columns: 1fr; }
|
||
.row, .row3 { grid-template-columns: 1fr; }
|
||
.cmp-grid { grid-template-columns: 1fr; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="toast"></div>
|
||
<div id="cmp-modal-mask" class="cmp-modal-mask">
|
||
<div class="cmp-modal">
|
||
<div class="cmp-hd">
|
||
<span>调优完成对比:Baseline vs Best</span>
|
||
<button class="btn" id="cmp-close-btn">关闭</button>
|
||
</div>
|
||
<div class="cmp-bd">
|
||
<div class="tiny" id="cmp-summary"></div>
|
||
<div class="cmp-grid" id="cmp-metrics"></div>
|
||
<div class="cmp-subcard">
|
||
<div style="font-weight: 700; margin-bottom: 6px;">分类召回/精度对比</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>分类</th>
|
||
<th>Baseline 召回</th>
|
||
<th>Best 召回</th>
|
||
<th>Δ召回</th>
|
||
<th>Baseline P@1</th>
|
||
<th>Best P@1</th>
|
||
<th>ΔP@1</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="cmp-category-table"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="page">
|
||
<div class="top">
|
||
<div>
|
||
<div class="title">A_Memorix 检索调优中心</div>
|
||
<div class="sub">LLM 辅助 + 多轮调查 + 运行时参数应用(不自动写 config.toml)</div>
|
||
</div>
|
||
<div class="btns">
|
||
<button class="btn" onclick="window.open('/', '_blank')">打开主面板</button>
|
||
<button class="btn" onclick="window.open('/import', '_blank')">打开导入中心</button>
|
||
<button class="btn pri" id="refresh-all">刷新</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div>
|
||
<div class="card">
|
||
<div class="hd">当前运行时参数</div>
|
||
<div class="bd">
|
||
<div class="tiny" id="settings-tip"></div>
|
||
<pre id="current-profile" class="mono"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top: 10px;">
|
||
<div class="hd">手动调参与应用</div>
|
||
<div class="bd">
|
||
<label>参数 JSON(支持局部字段)</label>
|
||
<textarea id="manual-profile" class="mono"></textarea>
|
||
<div class="btns" style="margin-top: 8px;">
|
||
<button class="btn pri" id="btn-apply">应用到运行时</button>
|
||
<button class="btn warn" id="btn-rollback">回滚上次应用</button>
|
||
<button class="btn" id="btn-export">导出 TOML 片段</button>
|
||
</div>
|
||
<label style="margin-top: 10px;">TOML 导出</label>
|
||
<textarea id="toml-snippet" class="mono" style="min-height: 90px;"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top: 10px;">
|
||
<div class="hd">创建自动调优任务</div>
|
||
<div class="bd">
|
||
<div class="row">
|
||
<div>
|
||
<label>目标函数</label>
|
||
<select id="objective">
|
||
<option value="precision_priority">precision_priority</option>
|
||
<option value="balanced">balanced</option>
|
||
<option value="recall_priority">recall_priority</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>强度</label>
|
||
<select id="intensity">
|
||
<option value="standard">standard</option>
|
||
<option value="quick">quick</option>
|
||
<option value="deep">deep</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="row3">
|
||
<div>
|
||
<label>轮次(可选)</label>
|
||
<input id="rounds" type="number" min="1" max="200" placeholder="留空走强度默认" />
|
||
</div>
|
||
<div>
|
||
<label>样本数</label>
|
||
<input id="sample-size" type="number" min="4" max="500" value="24" />
|
||
</div>
|
||
<div>
|
||
<label>评估 top_k</label>
|
||
<input id="top-k-eval" type="number" min="5" max="100" value="20" />
|
||
</div>
|
||
</div>
|
||
<label style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||
<input id="llm-enabled" type="checkbox" checked style="width: 16px; height: 16px;" />
|
||
<span>启用 LLM 问题生成/失败模式建议(不可用自动退化)</span>
|
||
</label>
|
||
<div class="btns">
|
||
<button class="btn pri" id="btn-create-task">创建任务</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="card">
|
||
<div class="hd">
|
||
<span>任务队列</span>
|
||
<span class="tiny" id="task-meta"></span>
|
||
</div>
|
||
<div class="bd split">
|
||
<div>
|
||
<div class="task-list" id="task-list"></div>
|
||
</div>
|
||
<div>
|
||
<label>任务详情</label>
|
||
<pre id="task-detail" class="mono" style="min-height: 160px; max-height: 250px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px;"></pre>
|
||
<div class="btns" style="margin-top: 8px;">
|
||
<button class="btn warn" id="btn-cancel-task">取消任务</button>
|
||
<button class="btn pri" id="btn-apply-best">应用最优参数</button>
|
||
<button class="btn" id="btn-load-report">加载报告</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top: 10px;">
|
||
<div class="hd">
|
||
<span>轮次明细</span>
|
||
<span class="tiny" id="round-meta"></span>
|
||
</div>
|
||
<div class="bd">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Round</th>
|
||
<th>Score</th>
|
||
<th>P@1</th>
|
||
<th>P@3</th>
|
||
<th>MRR</th>
|
||
<th>Recall@K</th>
|
||
<th>SPO hit</th>
|
||
<th>Empty</th>
|
||
<th>Latency(ms)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="round-table"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top: 10px;">
|
||
<div class="hd">报告预览</div>
|
||
<div class="bd">
|
||
<textarea id="report-content" class="mono" style="min-height: 240px;"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const state = {
|
||
settings: null,
|
||
tasks: [],
|
||
selectedTaskId: null,
|
||
pollTimer: null,
|
||
taskStatusMap: {},
|
||
watchTaskIds: new Set(),
|
||
completionPopupShown: new Set(),
|
||
};
|
||
|
||
function _num(v, fallback = 0) {
|
||
const x = Number(v);
|
||
return Number.isFinite(x) ? x : fallback;
|
||
}
|
||
|
||
function _pct(v) {
|
||
return `${(_num(v, 0) * 100).toFixed(2)}%`;
|
||
}
|
||
|
||
function _fmt(v, digits = 4) {
|
||
return _num(v, 0).toFixed(digits);
|
||
}
|
||
|
||
function _deltaClass(delta, reverse = false) {
|
||
const d = _num(delta, 0);
|
||
if (Math.abs(d) < 1e-12) return "";
|
||
const positive = reverse ? d < 0 : d > 0;
|
||
return positive ? "cmp-delta-pos" : "cmp-delta-neg";
|
||
}
|
||
|
||
function _renderMetricCard({ name, base, best, percent = true, reverse = false }) {
|
||
const b = _num(base, 0);
|
||
const n = _num(best, 0);
|
||
const d = n - b;
|
||
const bTxt = percent ? _pct(b) : _fmt(b, 3);
|
||
const nTxt = percent ? _pct(n) : _fmt(n, 3);
|
||
const dTxt = `${d >= 0 ? "+" : ""}${percent ? _pct(d) : _fmt(d, 3)}`;
|
||
const dClass = _deltaClass(d, reverse);
|
||
const bWidth = percent ? Math.max(0, Math.min(100, b * 100)) : 100;
|
||
const nWidth = percent ? Math.max(0, Math.min(100, n * 100)) : 100;
|
||
return `
|
||
<div class="cmp-metric">
|
||
<div class="k">${name}</div>
|
||
<div class="v">${bTxt} -> ${nTxt}</div>
|
||
<div class="${dClass}" style="font-size:12px;">Δ ${dTxt}</div>
|
||
<div class="cmp-bars">
|
||
<div class="cmp-bar-line">
|
||
<div class="tiny">Base</div>
|
||
<div class="cmp-bar-wrap"><div class="cmp-bar-fill" style="width:${bWidth}%;opacity:0.75;"></div></div>
|
||
</div>
|
||
<div class="cmp-bar-line">
|
||
<div class="tiny">Best</div>
|
||
<div class="cmp-bar-wrap"><div class="cmp-bar-fill" style="width:${nWidth}%;"></div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function hideCompletionPopup() {
|
||
document.getElementById("cmp-modal-mask").classList.remove("show");
|
||
}
|
||
|
||
function showCompletionPopupContent(task) {
|
||
const baseline = task.baseline_metrics || {};
|
||
const best = task.best_metrics || {};
|
||
const roundsDone = Number(task.rounds_done || 0);
|
||
const roundsTotal = Number(task.rounds_total || 0);
|
||
const summary = `任务 ${String(task.task_id || "").slice(0, 8)} 已完成,目标=${task.objective || "-"},轮次=${roundsDone}/${roundsTotal},best_score=${_fmt(task.best_score || 0, 6)}`;
|
||
document.getElementById("cmp-summary").textContent = summary;
|
||
|
||
const metricCards = [
|
||
{ name: "Precision@1", base: baseline.precision_at_1, best: best.precision_at_1, percent: true },
|
||
{ name: "Precision@3", base: baseline.precision_at_3, best: best.precision_at_3, percent: true },
|
||
{ name: "Recall@K", base: baseline.recall_at_k, best: best.recall_at_k, percent: true },
|
||
{ name: "MRR", base: baseline.mrr, best: best.mrr, percent: true },
|
||
{ name: "SPO Relation Hit", base: baseline.spo_relation_hit_rate, best: best.spo_relation_hit_rate, percent: true },
|
||
{ name: "Empty Rate", base: baseline.empty_rate, best: best.empty_rate, percent: true, reverse: true },
|
||
];
|
||
document.getElementById("cmp-metrics").innerHTML = metricCards.map(_renderMetricCard).join("");
|
||
|
||
const baseCat = baseline.category || {};
|
||
const bestCat = best.category || {};
|
||
const keys = Array.from(new Set([...Object.keys(baseCat), ...Object.keys(bestCat)])).sort();
|
||
const rows = [];
|
||
for (const k of keys) {
|
||
const b = baseCat[k] || {};
|
||
const n = bestCat[k] || {};
|
||
const bTot = Math.max(1, Number(b.total || 0));
|
||
const nTot = Math.max(1, Number(n.total || 0));
|
||
const bRecall = Number(b.hit || 0) / bTot;
|
||
const nRecall = Number(n.hit || 0) / nTot;
|
||
const bP1 = Number(b.hit_at_1 || 0) / bTot;
|
||
const nP1 = Number(n.hit_at_1 || 0) / nTot;
|
||
const dRecall = nRecall - bRecall;
|
||
const dP1 = nP1 - bP1;
|
||
rows.push(`
|
||
<tr>
|
||
<td>${k}</td>
|
||
<td>${_pct(bRecall)} (${Number(b.hit || 0)}/${Number(b.total || 0)})</td>
|
||
<td>${_pct(nRecall)} (${Number(n.hit || 0)}/${Number(n.total || 0)})</td>
|
||
<td class="${_deltaClass(dRecall)}">${dRecall >= 0 ? "+" : ""}${_pct(dRecall)}</td>
|
||
<td>${_pct(bP1)}</td>
|
||
<td>${_pct(nP1)}</td>
|
||
<td class="${_deltaClass(dP1)}">${dP1 >= 0 ? "+" : ""}${_pct(dP1)}</td>
|
||
</tr>
|
||
`);
|
||
}
|
||
document.getElementById("cmp-category-table").innerHTML = rows.join("") || '<tr><td colspan="7" class="tiny">无分类指标</td></tr>';
|
||
document.getElementById("cmp-modal-mask").classList.add("show");
|
||
}
|
||
|
||
async function tryShowCompletionPopup(taskId) {
|
||
if (!taskId || state.completionPopupShown.has(taskId)) return;
|
||
const body = await req(`/api/retrieval_tuning/tasks/${taskId}`);
|
||
const task = body.task || {};
|
||
if (task.status !== "completed") return;
|
||
showCompletionPopupContent(task);
|
||
state.completionPopupShown.add(taskId);
|
||
}
|
||
|
||
function toast(msg, level = "info") {
|
||
const el = document.getElementById("toast");
|
||
el.style.display = "block";
|
||
el.style.borderColor = level === "error" ? "#7f1d1d" : level === "warn" ? "#854d0e" : "#28415f";
|
||
el.textContent = msg;
|
||
setTimeout(() => { el.style.display = "none"; }, 2200);
|
||
}
|
||
|
||
async function req(url, options = {}) {
|
||
const resp = await fetch(url, options);
|
||
const body = await resp.json().catch(() => ({}));
|
||
if (!resp.ok || body.success === false) {
|
||
throw new Error(body.detail || body.error || body.message || `HTTP ${resp.status}`);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
function pretty(obj) {
|
||
return JSON.stringify(obj, null, 2);
|
||
}
|
||
|
||
async function loadProfile() {
|
||
const body = await req("/api/retrieval_tuning/profile");
|
||
state.settings = body.settings || {};
|
||
document.getElementById("settings-tip").textContent = `默认目标=${state.settings.default_objective},默认强度=${state.settings.default_intensity},轮询=${state.settings.poll_interval_ms}ms`;
|
||
document.getElementById("current-profile").textContent = pretty(body.profile || {});
|
||
document.getElementById("manual-profile").value = pretty(body.profile || {});
|
||
|
||
if (state.settings.default_objective) document.getElementById("objective").value = state.settings.default_objective;
|
||
if (state.settings.default_intensity) document.getElementById("intensity").value = state.settings.default_intensity;
|
||
if (state.settings.default_sample_size) document.getElementById("sample-size").value = state.settings.default_sample_size;
|
||
if (state.settings.default_top_k_eval) document.getElementById("top-k-eval").value = state.settings.default_top_k_eval;
|
||
}
|
||
|
||
function renderTaskList() {
|
||
const list = document.getElementById("task-list");
|
||
list.innerHTML = "";
|
||
for (const task of state.tasks) {
|
||
const div = document.createElement("div");
|
||
div.className = `task${task.task_id === state.selectedTaskId ? " active" : ""}`;
|
||
div.onclick = () => {
|
||
state.selectedTaskId = task.task_id;
|
||
renderTaskList();
|
||
loadTaskDetail();
|
||
};
|
||
div.innerHTML = `
|
||
<div class="line"><span>${task.task_id.slice(0, 8)}</span><span class="badge">${task.status}</span></div>
|
||
<div class="line tiny"><span>${task.objective}</span><span>${Math.round((task.progress || 0) * 100)}%</span></div>
|
||
<div class="tiny">round ${task.rounds_done || 0}/${task.rounds_total || 0}, best=${(task.best_score || 0).toFixed(4)}</div>
|
||
`;
|
||
list.appendChild(div);
|
||
}
|
||
document.getElementById("task-meta").textContent = `共 ${state.tasks.length} 个任务`;
|
||
}
|
||
|
||
async function loadTasks() {
|
||
const body = await req("/api/retrieval_tuning/tasks?limit=100");
|
||
state.tasks = body.items || [];
|
||
const prevMap = { ...state.taskStatusMap };
|
||
state.taskStatusMap = {};
|
||
let toPopupTaskId = null;
|
||
for (const t of state.tasks) {
|
||
state.taskStatusMap[t.task_id] = t.status;
|
||
if (
|
||
state.watchTaskIds.has(t.task_id) &&
|
||
t.status === "completed" &&
|
||
prevMap[t.task_id] &&
|
||
prevMap[t.task_id] !== "completed" &&
|
||
!state.completionPopupShown.has(t.task_id)
|
||
) {
|
||
toPopupTaskId = t.task_id;
|
||
}
|
||
}
|
||
if (!state.selectedTaskId && state.tasks.length) {
|
||
state.selectedTaskId = state.tasks[0].task_id;
|
||
} else if (state.selectedTaskId && !state.tasks.find(x => x.task_id === state.selectedTaskId)) {
|
||
state.selectedTaskId = state.tasks.length ? state.tasks[0].task_id : null;
|
||
}
|
||
renderTaskList();
|
||
if (toPopupTaskId) {
|
||
await tryShowCompletionPopup(toPopupTaskId);
|
||
}
|
||
}
|
||
|
||
async function loadTaskDetail() {
|
||
const taskId = state.selectedTaskId;
|
||
if (!taskId) {
|
||
document.getElementById("task-detail").textContent = "";
|
||
document.getElementById("round-table").innerHTML = "";
|
||
return;
|
||
}
|
||
const body = await req(`/api/retrieval_tuning/tasks/${taskId}`);
|
||
const task = body.task || {};
|
||
document.getElementById("task-detail").textContent = pretty(task);
|
||
|
||
const rounds = await req(`/api/retrieval_tuning/tasks/${taskId}/rounds?offset=0&limit=400`);
|
||
const tb = document.getElementById("round-table");
|
||
tb.innerHTML = "";
|
||
for (const row of rounds.items || []) {
|
||
const m = row.metrics || {};
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML = `
|
||
<td>${row.round_index}</td>
|
||
<td>${Number(row.score || 0).toFixed(4)}</td>
|
||
<td>${Number(m.precision_at_1 || 0).toFixed(4)}</td>
|
||
<td>${Number(m.precision_at_3 || 0).toFixed(4)}</td>
|
||
<td>${Number(m.mrr || 0).toFixed(4)}</td>
|
||
<td>${Number(m.recall_at_k || 0).toFixed(4)}</td>
|
||
<td>${Number(m.spo_relation_hit_rate || 0).toFixed(4)}</td>
|
||
<td>${Number(m.empty_rate || 0).toFixed(4)}</td>
|
||
<td>${Number(row.latency_ms || 0).toFixed(2)}</td>
|
||
`;
|
||
tb.appendChild(tr);
|
||
}
|
||
document.getElementById("round-meta").textContent = `total ${rounds.total || 0}`;
|
||
}
|
||
|
||
async function loadReport() {
|
||
if (!state.selectedTaskId) return;
|
||
const body = await req(`/api/retrieval_tuning/tasks/${state.selectedTaskId}/report?format=md`);
|
||
document.getElementById("report-content").value = body.content || "";
|
||
}
|
||
|
||
async function refreshAll() {
|
||
try {
|
||
await loadProfile();
|
||
await loadTasks();
|
||
await loadTaskDetail();
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
}
|
||
|
||
document.getElementById("refresh-all").onclick = refreshAll;
|
||
|
||
document.getElementById("btn-apply").onclick = async () => {
|
||
try {
|
||
const profile = JSON.parse(document.getElementById("manual-profile").value || "{}");
|
||
await req("/api/retrieval_tuning/profile/apply", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ profile, reason: "web_manual_apply" }),
|
||
});
|
||
toast("参数已应用");
|
||
await refreshAll();
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("btn-rollback").onclick = async () => {
|
||
try {
|
||
await req("/api/retrieval_tuning/profile/rollback", { method: "POST" });
|
||
toast("已回滚");
|
||
await refreshAll();
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("btn-export").onclick = async () => {
|
||
try {
|
||
const body = await req("/api/retrieval_tuning/profile/export_toml");
|
||
document.getElementById("toml-snippet").value = body.toml || "";
|
||
toast("已导出 TOML");
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("btn-create-task").onclick = async () => {
|
||
try {
|
||
const rounds = document.getElementById("rounds").value.trim();
|
||
const payload = {
|
||
objective: document.getElementById("objective").value,
|
||
intensity: document.getElementById("intensity").value,
|
||
sample_size: Number(document.getElementById("sample-size").value || 24),
|
||
top_k_eval: Number(document.getElementById("top-k-eval").value || 20),
|
||
llm_enabled: document.getElementById("llm-enabled").checked,
|
||
};
|
||
if (rounds) payload.rounds = Number(rounds);
|
||
const body = await req("/api/retrieval_tuning/tasks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
toast("任务已创建");
|
||
const newTaskId = body.task?.task_id || "";
|
||
if (newTaskId) {
|
||
state.watchTaskIds.add(newTaskId);
|
||
state.selectedTaskId = newTaskId;
|
||
}
|
||
await refreshAll();
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("btn-cancel-task").onclick = async () => {
|
||
if (!state.selectedTaskId) return;
|
||
try {
|
||
await req(`/api/retrieval_tuning/tasks/${state.selectedTaskId}/cancel`, { method: "POST" });
|
||
toast("已发送取消请求", "warn");
|
||
await refreshAll();
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("btn-apply-best").onclick = async () => {
|
||
if (!state.selectedTaskId) return;
|
||
try {
|
||
await req(`/api/retrieval_tuning/tasks/${state.selectedTaskId}/apply_best`, { method: "POST" });
|
||
toast("最优参数已应用");
|
||
await refreshAll();
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("btn-load-report").onclick = async () => {
|
||
try {
|
||
await loadReport();
|
||
toast("报告已加载");
|
||
} catch (e) {
|
||
toast(e.message || String(e), "error");
|
||
}
|
||
};
|
||
|
||
document.getElementById("cmp-close-btn").onclick = hideCompletionPopup;
|
||
document.getElementById("cmp-modal-mask").onclick = (e) => {
|
||
if (e.target && e.target.id === "cmp-modal-mask") {
|
||
hideCompletionPopup();
|
||
}
|
||
};
|
||
|
||
function startPolling() {
|
||
const ms = Number(state.settings?.poll_interval_ms || 1200);
|
||
if (state.pollTimer) clearInterval(state.pollTimer);
|
||
state.pollTimer = setInterval(async () => {
|
||
try {
|
||
await loadTasks();
|
||
await loadTaskDetail();
|
||
} catch (_) {}
|
||
}, Math.max(400, ms));
|
||
}
|
||
|
||
(async () => {
|
||
await refreshAll();
|
||
startPolling();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|