'use strict';
/**
* 智能问数 · 独立页面(副驾式布局:左会话栏 + 右舞台 + 底部大输入卡)
* ----------------------------------------------------------------------------
* 发起运行(POST /api/agent/runs)→ 轮询进度 → 渲染思考 / 工具步骤 / 结论 / 图表。
* 运行异步执行在服务端,本页只做编排与展示;切换页面运行继续,回来自动续显。
* 「应用到报表」经 window.shanReportsApplyQuery 落到数据中台明细 tab(reports.js 提供)。
*/
(function () {
const ACCENT = '#ff6b35';
const BLUE = '#2563eb';
const STATUS_WORD = { running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
const state = {
inited: false,
entities: [],
entity: null,
sessions: [],
sessionId: null,
search: '',
mode: 'auto',
skill: '',
skills: [],
busy: false,
pollTimer: null,
activeRunId: null,
charts: [],
columns: [],
};
const $ = (id) => document.getElementById(id);
function esc(value) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
}
async function jsonFetch(url, opts) {
const r = await fetch(url, opts);
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
/** 极简安全 Markdown:先整体 HTML 转义,再做受限转换(标题/列表/表格/代码块/引用/加粗/链接)。
* 不引入外部依赖;链接仅放行 http(s)。 */
function renderMarkdown(src) {
const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
const inline = (s) => esc(s)
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1 ')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1 ');
const out = [];
let list = null;
let para = [];
const closeList = () => { if (list) { out.push(`${list}>`); list = null; } };
const closePara = () => { if (para.length) { out.push(`
${para.map(inline).join(' ')}
`); para = []; } };
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) {
closePara(); closeList();
const buf = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i += 1; }
i += 1;
out.push(`${esc(buf.join('\n'))} `);
continue;
}
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
closePara(); closeList();
const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
const head = cells(line);
i += 2;
const rows = [];
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i += 1; }
out.push(`${head.map((h) => `${inline(h)} `).join('')} ${rows.map((r) => `${r.map((c) => `${inline(c)} `).join('')} `).join('')}
`);
continue;
}
const h = line.match(/^(#{1,4})\s+(.*)$/);
if (h) { closePara(); closeList(); const lv = h[1].length + 2; out.push(`${inline(h[2])} `); i += 1; continue; }
if (/^\s*-{3,}\s*$/.test(line)) { closePara(); closeList(); out.push(' '); i += 1; continue; }
const q = line.match(/^\s*>\s?(.*)$/);
if (q) { closePara(); closeList(); out.push(`${inline(q[1])} `); i += 1; continue; }
const ul = line.match(/^\s*[-*]\s+(.*)$/);
const ol = line.match(/^\s*\d+[.、]\s+(.*)$/);
if (ul || ol) {
closePara();
const want = ul ? 'ul' : 'ol';
if (list !== want) { closeList(); out.push(`<${want} class="md-list">`); list = want; }
out.push(`${inline((ul || ol)[1])} `);
i += 1;
continue;
}
if (!line.trim()) { closePara(); closeList(); i += 1; continue; }
para.push(line);
i += 1;
}
closePara(); closeList();
return out.join('');
}
function fmtDuration(ms) { return ms == null || ms === '' ? '' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : `${ms}ms`); }
function fmtBytes(n) { return n == null ? '' : (n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : `${n} B`); }
function fmtTokens(n) { return n == null ? '—' : (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }
function fmtClock(iso) {
const d = iso ? new Date(iso) : null;
return d ? `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` : '';
}
function entityName(key) {
const e = state.entities.find((x) => x.key === key);
return e ? (e.name || e.key) : (key || '');
}
// ---------- 初始化 ----------
window.shanAskInit = async function () {
if (!state.inited) {
state.inited = true;
bindEvents();
}
if (!state.columns.length) {
try {
const data = await jsonFetch('/api/reports/columns');
state.columns = data.columns || [];
} catch { state.columns = []; }
}
await loadSkills();
await loadEntities();
await loadSessions();
await openLatestSession();
await loadDigest();
};
/** 晨报横幅:最近一次确定性体检+环比结论(每日自动同步后生成,可手动重建) */
async function loadDigest() {
const wrap = $('askDigest');
wrap.hidden = true;
wrap.innerHTML = '';
if (!state.entity) return;
let latest = null;
try {
const data = await jsonFetch(`/api/agent/digests?entity=${encodeURIComponent(state.entity)}`);
latest = (data.digests || [])[0] || null;
} catch { return; }
if (!latest) return;
const at = new Date(latest.at);
const stamp = `${at.getMonth() + 1}/${at.getDate()} ${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
const dotCls = latest.status === 'fail' ? 'failed' : (latest.status === 'warn' ? 'running' : 'done');
wrap.hidden = false;
wrap.innerHTML = `
晨报 · ${esc(stamp)}
${esc(latest.headline)}
详情 ▾
${esc((latest.sections || []).map((s) => `[${s.ok ? 'ok' : '失败'}] ${s.summary}`).join('\n\n'))}
${(latest.verdicts || []).length ? `
${latest.verdicts.map((v) => `
${{ ok: '通过', warn: '警告', fail: '不一致' }[v.status] || esc(v.status)}
${esc(v.check)}
${esc(v.detail || '')}
`).join('')}
` : ''}
${latest.reportUrl ? `
` : ''}
`;
wrap.querySelector('.ask-digest-bar').addEventListener('click', () => {
const body = wrap.querySelector('.ask-digest-body');
body.hidden = !body.hidden;
wrap.querySelector('.ask-digest-toggle').textContent = body.hidden ? '详情 ▾' : '收起 ▴';
});
}
async function loadSkills() {
const sel = $('askSkill');
try {
const data = await jsonFetch('/api/agent/skills');
state.skills = data.skills || [];
} catch { state.skills = []; }
sel.innerHTML = '技能:自动 ';
for (const s of state.skills) {
const opt = document.createElement('option');
opt.value = s.key;
opt.textContent = `技能:${s.name}`;
opt.title = s.desc || '';
sel.appendChild(opt);
}
sel.value = state.skill || '';
}
async function loadEntities() {
try {
const data = await jsonFetch('/api/reports/entities');
state.entities = data.entities || [];
} catch { state.entities = []; }
const sel = $('askEntity');
sel.innerHTML = '';
if (!state.entities.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = '暂无主体,请先同步数据';
sel.appendChild(opt);
sel.disabled = true;
state.entity = null;
renderStageHead();
return;
}
sel.disabled = false;
for (const e of state.entities) {
const opt = document.createElement('option');
opt.value = e.key;
opt.textContent = e.name || e.key;
sel.appendChild(opt);
}
if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
state.entity = state.entities[0].key;
}
sel.value = state.entity;
renderStageHead();
}
// ---------- 左侧会话栏 ----------
async function loadSessions() {
if (!state.entity) { state.sessions = []; renderSessions(); return; }
try {
const data = await jsonFetch(`/api/agent/sessions?entity=${encodeURIComponent(state.entity)}`);
state.sessions = data.sessions || [];
} catch { state.sessions = []; }
renderSessions();
}
function renderSessions() {
const wrap = $('askSessions');
if (!state.entity) { wrap.innerHTML = '暂无主体
'; return; }
const kw = state.search.trim().toLowerCase();
const list = state.sessions.filter((s) => !kw || String(s.title || '').toLowerCase().includes(kw));
if (!list.length) {
wrap.innerHTML = `${kw ? '无匹配会话' : '暂无会话'}
`;
return;
}
wrap.innerHTML = '';
for (const s of list) {
const item = document.createElement('div');
item.className = 'ask-session-item' + (s.sessionId === state.sessionId ? ' active' : '');
item.innerHTML = `
`;
item.querySelector('.ask-session-title').textContent = s.title || '新会话';
const preview = s.lastQuestion && s.lastQuestion !== s.title ? s.lastQuestion : (s.lastQuestion || '');
item.querySelector('.ask-session-preview').textContent = preview;
item.addEventListener('click', () => openSession(s.sessionId));
item.querySelector('.ask-session-del').addEventListener('click', (event) => {
event.stopPropagation();
removeSession(s.sessionId);
});
wrap.appendChild(item);
}
}
async function removeSession(sessionId) {
const meta = state.sessions.find((s) => s.sessionId === sessionId);
const label = String((meta && (meta.title || meta.lastQuestion)) || '该会话').slice(0, 24);
if (!window.confirm(`删除会话「${label}」?其全部运行记录将一并删除,不可恢复。`)) return;
try {
await jsonFetch(`/api/agent/sessions/${encodeURIComponent(sessionId)}?entity=${encodeURIComponent(state.entity)}`, { method: 'DELETE' });
} catch { /* 删除失败不打扰 */ }
if (sessionId === state.sessionId) await newSession();
else await loadSessions();
}
// ---------- 会话与舞台 ----------
function renderStageHead() {
const session = state.sessions.find((s) => s.sessionId === state.sessionId);
$('askStageTitle').textContent = state.sessionId && session ? (session.title || '新对话') : '新对话';
$('askStageBadge').textContent = state.entity ? entityName(state.entity) : '未选择主体';
}
async function openLatestSession() {
await loadSessions();
if (state.sessions.length) await openSession(state.sessions[0].sessionId);
else await openSession(null);
}
async function openSession(sessionId) {
stopPoll();
state.sessionId = sessionId;
state.activeRunId = null;
if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
renderStageHead();
renderSessions();
const stream = $('askStream');
[...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
if (!sessionId || !state.entity) { showHero(true); return; }
showHero(false);
try {
const data = await jsonFetch(`/api/agent/runs?entity=${encodeURIComponent(state.entity)}&sessionId=${encodeURIComponent(sessionId)}`);
const runs = (data.runs || []).slice(0, 10);
let runningRun = null;
for (const r of runs.reverse()) {
try {
const detail = await jsonFetch(`/api/agent/runs/${encodeURIComponent(r.runId)}?entity=${encodeURIComponent(state.entity)}`);
appendRun(detail.run);
if (detail.run.status === 'running') runningRun = detail.run;
} catch { /* 单条历史加载失败跳过 */ }
}
// 回到页面时若有运行仍在进行(切页不中断),续接轮询
if (runningRun) {
state.activeRunId = runningRun.runId;
state.busy = true;
setComposerBusy(true);
startPoll(runningRun.runId);
}
} catch { /* 历史加载失败不阻塞新提问 */ }
}
async function newSession() {
stopPoll();
state.sessionId = null;
state.activeRunId = null;
if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
const stream = $('askStream');
[...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
renderStageHead();
renderSessions();
showHero(true);
$('askInput').focus();
}
function showHero(show) {
const hero = $('askEmpty');
if (hero) hero.hidden = !show;
}
// ---------- 提问 ----------
async function ask(text) {
const q = String(text || $('askInput').value || '').trim();
if (!q || state.busy || !state.entity) return;
state.busy = true;
$('askInput').value = '';
autoresize();
setComposerBusy(true);
showHero(false);
try {
const data = await jsonFetch('/api/agent/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity: state.entity, question: q, mode: state.mode,
skill: state.skill || undefined, sessionId: state.sessionId || undefined,
}),
});
state.sessionId = data.run.sessionId;
state.activeRunId = data.run.runId;
renderStageHead();
appendRun(data.run);
loadSessions(); // 侧栏出现新会话/新预览
startPoll(data.run.runId);
} catch (e) {
state.busy = false;
setComposerBusy(false);
appendLocalError(q, e.message);
}
}
async function cancel() {
if (!state.activeRunId) return;
try {
await jsonFetch(`/api/agent/runs/${encodeURIComponent(state.activeRunId)}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
} catch { /* 取消失败不打扰 */ }
}
function startPoll(runId) {
stopPoll();
state.pollTimer = setInterval(async () => {
if (!$('viewAsk').classList.contains('active')) return; // 不在问数页时暂停拉取
try {
const data = await jsonFetch(`/api/agent/runs/${encodeURIComponent(runId)}?entity=${encodeURIComponent(state.entity)}`);
const runEl = document.getElementById(`ask-run-${runId}`);
if (runEl) renderRunInto(runEl, data.run);
if (data.run.status !== 'running') {
stopPoll();
state.busy = false;
setComposerBusy(false);
loadSessions();
}
} catch { /* 单次轮询失败下轮再试 */ }
}, 1000);
}
function stopPoll() {
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
}
function setComposerBusy(busy) {
$('btnAskSend').hidden = busy;
$('btnAskStop').hidden = !busy;
}
function autoresize() {
const el = $('askInput');
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
// ---------- 渲染 ----------
function appendLocalError(q, message) {
const stream = $('askStream');
const wrap = document.createElement('div');
wrap.className = 'ask-run';
wrap.innerHTML = `
${esc(q)}
${esc(message)}
`;
stream.appendChild(wrap);
stream.scrollTop = stream.scrollHeight;
}
function appendRun(rec) {
const stream = $('askStream');
const wrap = document.createElement('div');
wrap.className = 'ask-run';
wrap.id = `ask-run-${rec.runId}`;
stream.appendChild(wrap);
renderRunInto(wrap, rec);
stream.scrollTop = stream.scrollHeight;
}
/** 把一次问数导出为自包含 HTML 报告(问题/元信息 + markdown 正文),纯前端生成。 */
function exportRunHtml(rec) {
const result = rec.result || {};
const entity = (state.entities.find((e) => e.key === (rec.entity || state.entity)) || {});
const styles = [
'body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#2b2a28;margin:0;background:#f4f2ef;}',
'.wrap{max-width:860px;margin:0 auto;padding:40px 32px 56px;}',
'h1{font-size:22px;margin:0 0 8px;line-height:1.4;}',
'.meta{color:#6b6560;font-size:12.5px;margin-bottom:24px;}',
'.card{background:#fff;border:1px solid #e8e4df;border-radius:14px;padding:26px 30px;}',
'.answer{font-size:14px;line-height:1.8;}',
'.answer>*:first-child{margin-top:0}.answer>*:last-child{margin-bottom:0}',
'.answer p{margin:10px 0;}',
'.answer h3,.answer h4,.answer h5,.answer h6{margin:18px 0 8px;font-size:14px;font-weight:700;}',
'.answer h3{font-size:16px;}',
'.answer ul,.answer ol{margin:10px 0;padding-left:22px;display:grid;gap:5px;}',
'.answer blockquote{margin:10px 0;padding:8px 14px;border-left:3px solid #e8e4df;color:#6b6560;background:#f4f2ef;border-radius:0 8px 8px 0;}',
'.answer code{padding:1px 5px;border-radius:5px;background:#f0ede8;font-size:12.5px;font-family:ui-monospace,Menlo,Consolas,monospace;}',
'.answer pre.md-code{margin:10px 0;padding:12px 14px;border-radius:8px;background:#1d1c1a;color:#f4f2ef;font-size:12.5px;overflow:auto;line-height:1.6;}',
'.answer pre.md-code code{background:transparent;color:inherit;padding:0;}',
'.answer table{margin:12px 0;border-collapse:collapse;font-size:13px;width:100%;}',
'.answer th,.answer td{border:1px solid #e8e4df;padding:7px 11px;text-align:left;vertical-align:top;}',
'.answer th{background:#f0ede8;font-weight:650;white-space:nowrap;}',
'.answer hr{border:0;border-top:1px solid #e8e4df;margin:14px 0;}',
'.answer a{color:#ff6b35;}',
'footer{margin-top:22px;color:#a5a09a;font-size:12px;text-align:center;}',
'@media print{body{background:#fff;}.card{border:none;padding:0;}}',
].join('\n');
const doc = '\n\n\n \n'
+ ' \n'
+ `${esc(rec.question || '问数报告')} \n\n\n\n\n`
+ `
${esc(rec.question || '')} \n`
+ `
智能问数 · ${esc(entity.name || rec.entity || state.entity || '—')} · ${esc(fmtClock(rec.startedAt))}${rec.skill ? ` · 技能「${esc(rec.skill)}」` : ''}
\n`
+ `
${renderMarkdown(result.answer || '')}
\n`
+ '
\n
\n\n';
const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `问数报告-${String(rec.question || '报告').slice(0, 16).replace(/[\\/:*?"<>|\s]+/g, '-')}-${Date.now()}.html`;
a.click();
URL.revokeObjectURL(a.href);
showToast('报告已导出(.html,可直接打印为 PDF)', 'success');
}
/** 把一次运行渲染进容器(轮询时整卡重绘,数据量小无压力)。 */
function renderRunInto(wrap, rec) {
const TOOL_LABEL = {
lake_overview: '湖概览', query_report: '查询报表', compare_periods: '同环比对比',
data_quality: '数据体检', cross_check: '层间核对', raw_inspect: '源侧字段', peer_benchmark: '同行对标',
cross_entity: '跨主体对比', aitable_verify: '湖云比对', build_report: '分析报告', metric_query: '指标查询',
detect_anomalies: '异常检测', attribution: '归因分析',
};
// 阶段徽标:让「数据查询 → 提取 → 计算」的推进在时间线上一眼可见
const TOOL_PHASE = {
lake_overview: '查询', query_report: '查询', compare_periods: '查询', metric_query: '查询', cross_entity: '查询', peer_benchmark: '查询',
raw_inspect: '提取', aitable_verify: '提取',
data_quality: '计算', cross_check: '计算', detect_anomalies: '计算', attribution: '计算', build_report: '计算',
};
const VERDICT_WORD = { ok: '通过', warn: '警告', fail: '不一致' };
const skillName = rec.skill ? ((state.skills.find((s) => s.key === rec.skill) || {}).name || rec.skill) : '';
// 头部:时间 + 「状态 · 总耗时 · N 个工具」
const totalMs = rec.finishedAt && rec.startedAt ? new Date(rec.finishedAt) - new Date(rec.startedAt) : null;
const toolCount = (rec.steps || []).filter((s) => s.type === 'tool').length;
const dotCls = rec.status === 'running' ? 'running' : (rec.status === 'failed' ? 'failed' : 'done');
const head = [];
head.push(`问数 · ${esc(fmtClock(rec.startedAt))}
`);
const usage = rec.usage || {};
const costBits = [];
if (usage.tokensIn) costBits.push(`${fmtTokens(usage.tokensIn)} in / ${fmtTokens(usage.tokensOut)} out`);
head.push(`${STATUS_WORD[rec.status] || esc(rec.status)}`
+ `${totalMs != null ? ` · ${fmtDuration(totalMs)}` : ''} · ${toolCount} 个工具`
+ (costBits.length ? ` · ${costBits.join(' · ')}` : '')
+ `${rec.autoPicked ? ` · 自动→${rec.mode === 'quick' ? '快问' : '深度分析'}` : ''}`
+ `${rec.replayFrom ? ' · 复用打法' : ''}`
+ `${skillName ? ` · 技能「${esc(skillName)}」` : ''}
`);
// 步骤时间线:思考 · Turn N(耗时/tokens)、工具(耗时/载荷/行数)
const steps = [];
for (const step of rec.steps || []) {
if (step.type === 'thought') {
const bits = [fmtDuration(step.ms)];
if (step.tokensIn != null) bits.push(`${fmtTokens(step.tokensIn)} in / ${fmtTokens(step.tokensOut)} out`);
steps.push(`
思考${step.turn ? ` · Turn ${step.turn}` : ''}
${bits.filter(Boolean).map((b) => `${esc(String(b))} `).join('')}
${step.text ? `
${esc(step.text)}
` : ''}
`);
} else if (step.type === 'tool') {
const label = TOOL_LABEL[step.tool] || step.tool;
const bits = [fmtDuration(step.ms), fmtBytes(step.bytes)];
if (step.rows != null) bits.push(`${step.rows} 行`);
steps.push(`
${esc(TOOL_PHASE[step.tool] || '查询')} ${esc(label)}
${bits.filter(Boolean).map((b) => `${esc(String(b))} `).join('')}
参数与结果
参数:${esc(JSON.stringify(step.args || {}))}
${esc(step.summary || step.error || '')}
`);
}
}
const parts = [];
parts.push(`${esc(rec.question)}
`);
parts.push('');
parts.push(head.join(''));
// 思考过程:Codex 式一行摘要,运行中自动展开、完成后收起,点开才看逐步明细
if (steps.length) {
const running = rec.status === 'running';
const label = running
? `思考中 · 已 ${steps.length} 步 · ${toolCount} 个工具`
: `思考过程 · ${steps.length} 步${totalMs != null ? ` · ${fmtDuration(totalMs)}` : ''} · ${toolCount} 个工具`;
parts.push(`
${esc(label)}${steps.join('')}
`);
}
// verdict 卡:数据体检/层间核对/恒等式/完整性等校验结论
const verdicts = rec.verdicts || [];
if (verdicts.length) {
parts.push(`
${verdicts.map((v) => `
${VERDICT_WORD[v.status] || esc(v.status)}
${esc(v.check)}
${esc(v.detail || '')}
`).join('')}
`);
}
// 数字溯源警示(不阻断,仅提示)
const prov = rec.provenance;
if (prov && prov.untracedCount > 0) {
parts.push(`
⚠ ${prov.untracedCount}/${prov.total} 个数字未能在工具结果中溯源(可能为换算或表述),请谨慎采信:${esc(prov.untraced.join('、'))}
`);
}
if (rec.status === 'running') parts.push('
');
if (rec.status === 'failed') parts.push(`
${esc(rec.error || '运行失败')}
`);
if (rec.status === 'cancelled') parts.push('
已取消
');
const result = rec.result || null;
if (result && result.answer) {
parts.push(`
${renderMarkdown(result.answer)}
`);
const chart = result.chart;
if (chart && chart.type && chart.type !== 'none') {
parts.push(`
`);
if (chart.title) parts.push(`
${esc(chart.title)}
`);
}
parts.push('
导出为报告(HTML)
');
if (result.reportUrl) {
parts.push(`
`);
}
// 反馈闭环:采纳 → 打法入语料;纠错 → 反馈日志供评测提炼
if (rec.status === 'done') {
const fb = rec.feedback;
if (fb && fb.kind) {
parts.push(`
已反馈${fb.kind === 'up' ? '有帮助' : '纠错'}${fb.note ? ':' + esc(fb.note) : ''}
`);
} else {
parts.push(`
👍 有帮助 `
+ `✍️ 纠错
`);
}
}
}
parts.push('
');
wrap.innerHTML = parts.join('');
// 图表:从产物中找与 chart 维度匹配的行(最后一条优先)
if (result && result.chart && result.chart.type && result.chart.type !== 'none') {
const wrapEl = wrap.querySelector('.ask-chart');
const rows = findChartRows(rec, result.chart);
if (wrapEl && rows && rows.length) renderChart(wrapEl, result.chart, rows);
else if (wrapEl) wrapEl.parentNode.removeChild(wrapEl);
}
const exportBtn = wrap.querySelector('[data-export]');
if (exportBtn) exportBtn.addEventListener('click', () => exportRunHtml(rec));
wrap.querySelectorAll('[data-fb]').forEach((btn) => {
btn.addEventListener('click', async () => {
const kind = btn.dataset.fb;
let note = '';
if (kind === 'down') {
note = window.prompt('请描述哪里不对(会进入改进清单,可留空)');
if (note === null) return;
}
try {
const data = await jsonFetch('/api/agent/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity: state.entity, runId: rec.runId, kind, note }),
});
Object.assign(rec, { feedback: data.run.feedback });
renderRunInto(wrap, rec);
} catch { /* 反馈失败不打扰 */ }
});
});
const stream = $('askStream');
stream.scrollTop = stream.scrollHeight;
}
function findChartRows(rec, chart) {
if (rec.result && Array.isArray(rec.result.chartRows) && rec.result.chartRows.length) {
return filterRowsByDimension(rec.result.chartRows, chart);
}
const steps = (rec.steps || []).filter((s) => s.type === 'tool' && s.ok && s.data);
for (const step of steps.reverse()) {
const d = step.data;
const candidates = [d.rows, chart.x === 'date' ? d.curByDate : d.curByShop];
for (const rows of candidates) {
if (Array.isArray(rows) && rows.length) {
const filtered = filterRowsByDimension(rows, chart);
if (filtered.length) return filtered;
}
}
}
return null;
}
function filterRowsByDimension(rows, chart) {
const key = chart.x === 'date' ? 'date' : 'shopName';
const has = rows.filter((r) => r && r[key] != null && chart.y.some((m) => r[m] != null));
if (chart.x === 'date') has.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
return has;
}
function renderChart(wrapEl, chart, rows) {
const prev = echarts.getInstanceByDom(wrapEl);
if (prev) prev.dispose();
const instance = echarts.init(wrapEl);
state.charts.push(instance);
const meta = new Map(state.columns.map((c) => [c.key, c]));
const colOf = (key) => meta.get(key) || { label: key, type: 'num' };
instance.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 56, right: 16, top: 20, bottom: chart.type === 'bar' && chart.x !== 'date' ? 52 : 24 },
xAxis: {
type: 'category',
data: rows.map((r) => (chart.x === 'date' ? ymdToDash(r.date) : (r.shopName || r.shopId || ''))),
axisLabel: { color: '#6b6560', interval: 0, rotate: chart.type === 'bar' && chart.x !== 'date' ? 30 : 0, width: 80, overflow: 'truncate' },
axisLine: { lineStyle: { color: '#e8e4df' } },
},
yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
series: (chart.y || []).map((key, yi) => {
const c = colOf(key);
return {
name: c.label || key,
type: chart.type === 'line' ? 'line' : 'bar',
smooth: true, barMaxWidth: 26,
data: rows.map((r) => {
const n = Number(r[key]);
if (!Number.isFinite(n)) return 0;
return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
}),
itemStyle: { color: yi === 0 ? ACCENT : BLUE },
};
}),
});
setTimeout(() => instance.resize(), 40);
}
// ---------- 事件 ----------
function bindEvents() {
$('askEntity').addEventListener('change', () => {
state.entity = $('askEntity').value || null;
state.sessionId = null;
state.sessions = [];
renderStageHead();
loadDigest();
openLatestSession();
});
$('askMode').addEventListener('click', (event) => {
const btn = event.target.closest('button[data-mode]');
if (!btn) return;
state.mode = ['auto', 'agent', 'quick'].includes(btn.dataset.mode) ? btn.dataset.mode : 'auto';
$('askMode').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
});
$('askSkill').addEventListener('change', () => {
state.skill = $('askSkill').value || '';
});
$('btnAskNewSession').addEventListener('click', newSession);
$('askSearch').addEventListener('input', () => {
state.search = $('askSearch').value;
renderSessions();
});
$('btnAskSend').addEventListener('click', () => ask());
$('btnAskStop').addEventListener('click', cancel);
const input = $('askInput');
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
ask();
}
});
input.addEventListener('input', autoresize);
$('askChips').addEventListener('click', (event) => {
const chip = event.target.closest('[data-q]');
if (chip) ask(chip.dataset.q);
});
window.addEventListener('resize', () => {
state.charts.forEach((c) => { try { c.resize(); } catch { /* ignore */ } });
});
}
})();