Files
mai-bot/src/A_memorix/web/tuning.html
A-Dawn 15d436b3a1 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 及相关历史维护文档
2026-04-03 08:08:24 +08:00

723 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>