refactor: 将 A_Memorix 重构为主线长期记忆子系统并重建管理界面

- 将 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 及相关历史维护文档
This commit is contained in:
A-Dawn
2026-04-03 08:08:24 +08:00
parent bf5eb45709
commit 15d436b3a1
136 changed files with 52533 additions and 629 deletions

View File

@@ -0,0 +1,722 @@
<!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>