ask.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. 'use strict';
  2. /**
  3. * 智能问数 · 独立页面(副驾式布局:左会话栏 + 右舞台 + 底部大输入卡)
  4. * ----------------------------------------------------------------------------
  5. * 发起运行(POST /api/agent/runs)→ 轮询进度 → 渲染思考 / 工具步骤 / 结论 / 图表。
  6. * 运行异步执行在服务端,本页只做编排与展示;切换页面运行继续,回来自动续显。
  7. * 「应用到报表」经 window.shanReportsApplyQuery 落到数据中台明细 tab(reports.js 提供)。
  8. */
  9. (function () {
  10. const ACCENT = '#ff6b35';
  11. const BLUE = '#2563eb';
  12. const STATUS_WORD = { running: '运行中', done: '已完成', failed: '失败', cancelled: '已取消' };
  13. const state = {
  14. inited: false,
  15. entities: [],
  16. entity: null,
  17. sessions: [],
  18. sessionId: null,
  19. search: '',
  20. mode: 'auto',
  21. skill: '',
  22. skills: [],
  23. busy: false,
  24. pollTimer: null,
  25. activeRunId: null,
  26. charts: [],
  27. columns: [],
  28. };
  29. const $ = (id) => document.getElementById(id);
  30. function esc(value) {
  31. const div = document.createElement('div');
  32. div.textContent = String(value ?? '');
  33. return div.innerHTML;
  34. }
  35. async function jsonFetch(url, opts) {
  36. const r = await fetch(url, opts);
  37. const data = await r.json().catch(() => ({}));
  38. if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
  39. return data;
  40. }
  41. function ymdToDash(s) { return /^\d{8}$/.test(String(s)) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : String(s || ''); }
  42. /** 极简安全 Markdown:先整体 HTML 转义,再做受限转换(标题/列表/表格/代码块/引用/加粗/链接)。
  43. * 不引入外部依赖;链接仅放行 http(s)。 */
  44. function renderMarkdown(src) {
  45. const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
  46. const inline = (s) => esc(s)
  47. .replace(/`([^`]+)`/g, '<code>$1</code>')
  48. .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
  49. .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
  50. const out = [];
  51. let list = null;
  52. let para = [];
  53. const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
  54. const closePara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
  55. let i = 0;
  56. while (i < lines.length) {
  57. const line = lines[i];
  58. if (/^```/.test(line)) {
  59. closePara(); closeList();
  60. const buf = [];
  61. i += 1;
  62. while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i += 1; }
  63. i += 1;
  64. out.push(`<pre class="md-code">${esc(buf.join('\n'))}</pre>`);
  65. continue;
  66. }
  67. if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
  68. closePara(); closeList();
  69. const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
  70. const head = cells(line);
  71. i += 2;
  72. const rows = [];
  73. while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) { rows.push(cells(lines[i])); i += 1; }
  74. out.push(`<table class="md-table"><thead><tr>${head.map((h) => `<th>${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table>`);
  75. continue;
  76. }
  77. const h = line.match(/^(#{1,4})\s+(.*)$/);
  78. if (h) { closePara(); closeList(); const lv = h[1].length + 2; out.push(`<h${lv}>${inline(h[2])}</h${lv}>`); i += 1; continue; }
  79. if (/^\s*-{3,}\s*$/.test(line)) { closePara(); closeList(); out.push('<hr>'); i += 1; continue; }
  80. const q = line.match(/^\s*>\s?(.*)$/);
  81. if (q) { closePara(); closeList(); out.push(`<blockquote>${inline(q[1])}</blockquote>`); i += 1; continue; }
  82. const ul = line.match(/^\s*[-*]\s+(.*)$/);
  83. const ol = line.match(/^\s*\d+[.、]\s+(.*)$/);
  84. if (ul || ol) {
  85. closePara();
  86. const want = ul ? 'ul' : 'ol';
  87. if (list !== want) { closeList(); out.push(`<${want} class="md-list">`); list = want; }
  88. out.push(`<li>${inline((ul || ol)[1])}</li>`);
  89. i += 1;
  90. continue;
  91. }
  92. if (!line.trim()) { closePara(); closeList(); i += 1; continue; }
  93. para.push(line);
  94. i += 1;
  95. }
  96. closePara(); closeList();
  97. return out.join('');
  98. }
  99. function fmtDuration(ms) { return ms == null || ms === '' ? '' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : `${ms}ms`); }
  100. function fmtBytes(n) { return n == null ? '' : (n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : `${n} B`); }
  101. function fmtTokens(n) { return n == null ? '—' : (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }
  102. function fmtClock(iso) {
  103. const d = iso ? new Date(iso) : null;
  104. return d ? `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` : '';
  105. }
  106. function entityName(key) {
  107. const e = state.entities.find((x) => x.key === key);
  108. return e ? (e.name || e.key) : (key || '');
  109. }
  110. // ---------- 初始化 ----------
  111. window.shanAskInit = async function () {
  112. if (!state.inited) {
  113. state.inited = true;
  114. bindEvents();
  115. }
  116. if (!state.columns.length) {
  117. try {
  118. const data = await jsonFetch('/api/reports/columns');
  119. state.columns = data.columns || [];
  120. } catch { state.columns = []; }
  121. }
  122. await loadSkills();
  123. await loadEntities();
  124. await loadSessions();
  125. await openLatestSession();
  126. await loadDigest();
  127. };
  128. /** 晨报横幅:最近一次确定性体检+环比结论(每日自动同步后生成,可手动重建) */
  129. async function loadDigest() {
  130. const wrap = $('askDigest');
  131. wrap.hidden = true;
  132. wrap.innerHTML = '';
  133. if (!state.entity) return;
  134. let latest = null;
  135. try {
  136. const data = await jsonFetch(`/api/agent/digests?entity=${encodeURIComponent(state.entity)}`);
  137. latest = (data.digests || [])[0] || null;
  138. } catch { return; }
  139. if (!latest) return;
  140. const at = new Date(latest.at);
  141. const stamp = `${at.getMonth() + 1}/${at.getDate()} ${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
  142. const dotCls = latest.status === 'fail' ? 'failed' : (latest.status === 'warn' ? 'running' : 'done');
  143. wrap.hidden = false;
  144. wrap.innerHTML = `
  145. <div class="ask-digest-bar" role="button" tabindex="0">
  146. <span class="ask-dot ${dotCls}"></span>
  147. <span class="ask-digest-label">晨报 · ${esc(stamp)}</span>
  148. <span class="ask-digest-headline">${esc(latest.headline)}</span>
  149. <span class="ask-digest-toggle">详情 ▾</span>
  150. </div>
  151. <div class="ask-digest-body" hidden>
  152. <pre class="ask-pre">${esc((latest.sections || []).map((s) => `[${s.ok ? 'ok' : '失败'}] ${s.summary}`).join('\n\n'))}</pre>
  153. ${(latest.verdicts || []).length ? `<div class="ask-verdicts">${latest.verdicts.map((v) => `
  154. <div class="ask-verdict ${v.status}">
  155. <span class="ask-verdict-status">${{ ok: '通过', warn: '警告', fail: '不一致' }[v.status] || esc(v.status)}</span>
  156. <span class="ask-verdict-check">${esc(v.check)}</span>
  157. <span class="ask-verdict-detail">${esc(v.detail || '')}</span>
  158. </div>`).join('')}</div>` : ''}
  159. ${latest.reportUrl ? `<div class="ask-actions"><a class="btn btn-ghost btn-small" href="${esc(latest.reportUrl)}" target="_blank" rel="noopener">查看分析报告</a></div>` : ''}
  160. </div>`;
  161. wrap.querySelector('.ask-digest-bar').addEventListener('click', () => {
  162. const body = wrap.querySelector('.ask-digest-body');
  163. body.hidden = !body.hidden;
  164. wrap.querySelector('.ask-digest-toggle').textContent = body.hidden ? '详情 ▾' : '收起 ▴';
  165. });
  166. }
  167. async function loadSkills() {
  168. const sel = $('askSkill');
  169. try {
  170. const data = await jsonFetch('/api/agent/skills');
  171. state.skills = data.skills || [];
  172. } catch { state.skills = []; }
  173. sel.innerHTML = '<option value="">技能:自动</option>';
  174. for (const s of state.skills) {
  175. const opt = document.createElement('option');
  176. opt.value = s.key;
  177. opt.textContent = `技能:${s.name}`;
  178. opt.title = s.desc || '';
  179. sel.appendChild(opt);
  180. }
  181. sel.value = state.skill || '';
  182. }
  183. async function loadEntities() {
  184. try {
  185. const data = await jsonFetch('/api/reports/entities');
  186. state.entities = data.entities || [];
  187. } catch { state.entities = []; }
  188. const sel = $('askEntity');
  189. sel.innerHTML = '';
  190. if (!state.entities.length) {
  191. const opt = document.createElement('option');
  192. opt.value = '';
  193. opt.textContent = '暂无主体,请先同步数据';
  194. sel.appendChild(opt);
  195. sel.disabled = true;
  196. state.entity = null;
  197. renderStageHead();
  198. return;
  199. }
  200. sel.disabled = false;
  201. for (const e of state.entities) {
  202. const opt = document.createElement('option');
  203. opt.value = e.key;
  204. opt.textContent = e.name || e.key;
  205. sel.appendChild(opt);
  206. }
  207. if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
  208. state.entity = state.entities[0].key;
  209. }
  210. sel.value = state.entity;
  211. renderStageHead();
  212. }
  213. // ---------- 左侧会话栏 ----------
  214. async function loadSessions() {
  215. if (!state.entity) { state.sessions = []; renderSessions(); return; }
  216. try {
  217. const data = await jsonFetch(`/api/agent/sessions?entity=${encodeURIComponent(state.entity)}`);
  218. state.sessions = data.sessions || [];
  219. } catch { state.sessions = []; }
  220. renderSessions();
  221. }
  222. function renderSessions() {
  223. const wrap = $('askSessions');
  224. if (!state.entity) { wrap.innerHTML = '<div class="ask-sessions-empty">暂无主体</div>'; return; }
  225. const kw = state.search.trim().toLowerCase();
  226. const list = state.sessions.filter((s) => !kw || String(s.title || '').toLowerCase().includes(kw));
  227. if (!list.length) {
  228. wrap.innerHTML = `<div class="ask-sessions-empty">${kw ? '无匹配会话' : '暂无会话'}</div>`;
  229. return;
  230. }
  231. wrap.innerHTML = '';
  232. for (const s of list) {
  233. const item = document.createElement('div');
  234. item.className = 'ask-session-item' + (s.sessionId === state.sessionId ? ' active' : '');
  235. item.innerHTML = `
  236. <span class="ask-session-title"></span>
  237. <span class="ask-session-preview"></span>
  238. <button type="button" class="ask-session-del" title="删除会话">
  239. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/></svg>
  240. </button>`;
  241. item.querySelector('.ask-session-title').textContent = s.title || '新会话';
  242. const preview = s.lastQuestion && s.lastQuestion !== s.title ? s.lastQuestion : (s.lastQuestion || '');
  243. item.querySelector('.ask-session-preview').textContent = preview;
  244. item.addEventListener('click', () => openSession(s.sessionId));
  245. item.querySelector('.ask-session-del').addEventListener('click', (event) => {
  246. event.stopPropagation();
  247. removeSession(s.sessionId);
  248. });
  249. wrap.appendChild(item);
  250. }
  251. }
  252. async function removeSession(sessionId) {
  253. const meta = state.sessions.find((s) => s.sessionId === sessionId);
  254. const label = String((meta && (meta.title || meta.lastQuestion)) || '该会话').slice(0, 24);
  255. if (!window.confirm(`删除会话「${label}」?其全部运行记录将一并删除,不可恢复。`)) return;
  256. try {
  257. await jsonFetch(`/api/agent/sessions/${encodeURIComponent(sessionId)}?entity=${encodeURIComponent(state.entity)}`, { method: 'DELETE' });
  258. } catch { /* 删除失败不打扰 */ }
  259. if (sessionId === state.sessionId) await newSession();
  260. else await loadSessions();
  261. }
  262. // ---------- 会话与舞台 ----------
  263. function renderStageHead() {
  264. const session = state.sessions.find((s) => s.sessionId === state.sessionId);
  265. $('askStageTitle').textContent = state.sessionId && session ? (session.title || '新对话') : '新对话';
  266. $('askStageBadge').textContent = state.entity ? entityName(state.entity) : '未选择主体';
  267. }
  268. async function openLatestSession() {
  269. await loadSessions();
  270. if (state.sessions.length) await openSession(state.sessions[0].sessionId);
  271. else await openSession(null);
  272. }
  273. async function openSession(sessionId) {
  274. stopPoll();
  275. state.sessionId = sessionId;
  276. state.activeRunId = null;
  277. if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
  278. renderStageHead();
  279. renderSessions();
  280. const stream = $('askStream');
  281. [...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
  282. if (!sessionId || !state.entity) { showHero(true); return; }
  283. showHero(false);
  284. try {
  285. const data = await jsonFetch(`/api/agent/runs?entity=${encodeURIComponent(state.entity)}&sessionId=${encodeURIComponent(sessionId)}`);
  286. const runs = (data.runs || []).slice(0, 10);
  287. let runningRun = null;
  288. for (const r of runs.reverse()) {
  289. try {
  290. const detail = await jsonFetch(`/api/agent/runs/${encodeURIComponent(r.runId)}?entity=${encodeURIComponent(state.entity)}`);
  291. appendRun(detail.run);
  292. if (detail.run.status === 'running') runningRun = detail.run;
  293. } catch { /* 单条历史加载失败跳过 */ }
  294. }
  295. // 回到页面时若有运行仍在进行(切页不中断),续接轮询
  296. if (runningRun) {
  297. state.activeRunId = runningRun.runId;
  298. state.busy = true;
  299. setComposerBusy(true);
  300. startPoll(runningRun.runId);
  301. }
  302. } catch { /* 历史加载失败不阻塞新提问 */ }
  303. }
  304. async function newSession() {
  305. stopPoll();
  306. state.sessionId = null;
  307. state.activeRunId = null;
  308. if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
  309. const stream = $('askStream');
  310. [...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
  311. renderStageHead();
  312. renderSessions();
  313. showHero(true);
  314. $('askInput').focus();
  315. }
  316. function showHero(show) {
  317. const hero = $('askEmpty');
  318. if (hero) hero.hidden = !show;
  319. }
  320. // ---------- 提问 ----------
  321. async function ask(text) {
  322. const q = String(text || $('askInput').value || '').trim();
  323. if (!q || state.busy || !state.entity) return;
  324. state.busy = true;
  325. $('askInput').value = '';
  326. autoresize();
  327. setComposerBusy(true);
  328. showHero(false);
  329. try {
  330. const data = await jsonFetch('/api/agent/runs', {
  331. method: 'POST',
  332. headers: { 'Content-Type': 'application/json' },
  333. body: JSON.stringify({
  334. entity: state.entity, question: q, mode: state.mode,
  335. skill: state.skill || undefined, sessionId: state.sessionId || undefined,
  336. }),
  337. });
  338. state.sessionId = data.run.sessionId;
  339. state.activeRunId = data.run.runId;
  340. renderStageHead();
  341. appendRun(data.run);
  342. loadSessions(); // 侧栏出现新会话/新预览
  343. startPoll(data.run.runId);
  344. } catch (e) {
  345. state.busy = false;
  346. setComposerBusy(false);
  347. appendLocalError(q, e.message);
  348. }
  349. }
  350. async function cancel() {
  351. if (!state.activeRunId) return;
  352. try {
  353. await jsonFetch(`/api/agent/runs/${encodeURIComponent(state.activeRunId)}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
  354. } catch { /* 取消失败不打扰 */ }
  355. }
  356. function startPoll(runId) {
  357. stopPoll();
  358. state.pollTimer = setInterval(async () => {
  359. if (!$('viewAsk').classList.contains('active')) return; // 不在问数页时暂停拉取
  360. try {
  361. const data = await jsonFetch(`/api/agent/runs/${encodeURIComponent(runId)}?entity=${encodeURIComponent(state.entity)}`);
  362. const runEl = document.getElementById(`ask-run-${runId}`);
  363. if (runEl) renderRunInto(runEl, data.run);
  364. if (data.run.status !== 'running') {
  365. stopPoll();
  366. state.busy = false;
  367. setComposerBusy(false);
  368. loadSessions();
  369. }
  370. } catch { /* 单次轮询失败下轮再试 */ }
  371. }, 1000);
  372. }
  373. function stopPoll() {
  374. if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
  375. }
  376. function setComposerBusy(busy) {
  377. $('btnAskSend').hidden = busy;
  378. $('btnAskStop').hidden = !busy;
  379. }
  380. function autoresize() {
  381. const el = $('askInput');
  382. el.style.height = 'auto';
  383. el.style.height = Math.min(el.scrollHeight, 120) + 'px';
  384. }
  385. // ---------- 渲染 ----------
  386. function appendLocalError(q, message) {
  387. const stream = $('askStream');
  388. const wrap = document.createElement('div');
  389. wrap.className = 'ask-run';
  390. wrap.innerHTML = `
  391. <div class="ask-q">${esc(q)}</div>
  392. <div class="ask-card ask-card-error">${esc(message)}</div>`;
  393. stream.appendChild(wrap);
  394. stream.scrollTop = stream.scrollHeight;
  395. }
  396. function appendRun(rec) {
  397. const stream = $('askStream');
  398. const wrap = document.createElement('div');
  399. wrap.className = 'ask-run';
  400. wrap.id = `ask-run-${rec.runId}`;
  401. stream.appendChild(wrap);
  402. renderRunInto(wrap, rec);
  403. stream.scrollTop = stream.scrollHeight;
  404. }
  405. /** 把一次问数导出为自包含 HTML 报告(问题/元信息 + markdown 正文),纯前端生成。 */
  406. function exportRunHtml(rec) {
  407. const result = rec.result || {};
  408. const entity = (state.entities.find((e) => e.key === (rec.entity || state.entity)) || {});
  409. const styles = [
  410. 'body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#2b2a28;margin:0;background:#f4f2ef;}',
  411. '.wrap{max-width:860px;margin:0 auto;padding:40px 32px 56px;}',
  412. 'h1{font-size:22px;margin:0 0 8px;line-height:1.4;}',
  413. '.meta{color:#6b6560;font-size:12.5px;margin-bottom:24px;}',
  414. '.card{background:#fff;border:1px solid #e8e4df;border-radius:14px;padding:26px 30px;}',
  415. '.answer{font-size:14px;line-height:1.8;}',
  416. '.answer>*:first-child{margin-top:0}.answer>*:last-child{margin-bottom:0}',
  417. '.answer p{margin:10px 0;}',
  418. '.answer h3,.answer h4,.answer h5,.answer h6{margin:18px 0 8px;font-size:14px;font-weight:700;}',
  419. '.answer h3{font-size:16px;}',
  420. '.answer ul,.answer ol{margin:10px 0;padding-left:22px;display:grid;gap:5px;}',
  421. '.answer blockquote{margin:10px 0;padding:8px 14px;border-left:3px solid #e8e4df;color:#6b6560;background:#f4f2ef;border-radius:0 8px 8px 0;}',
  422. '.answer code{padding:1px 5px;border-radius:5px;background:#f0ede8;font-size:12.5px;font-family:ui-monospace,Menlo,Consolas,monospace;}',
  423. '.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;}',
  424. '.answer pre.md-code code{background:transparent;color:inherit;padding:0;}',
  425. '.answer table{margin:12px 0;border-collapse:collapse;font-size:13px;width:100%;}',
  426. '.answer th,.answer td{border:1px solid #e8e4df;padding:7px 11px;text-align:left;vertical-align:top;}',
  427. '.answer th{background:#f0ede8;font-weight:650;white-space:nowrap;}',
  428. '.answer hr{border:0;border-top:1px solid #e8e4df;margin:14px 0;}',
  429. '.answer a{color:#ff6b35;}',
  430. 'footer{margin-top:22px;color:#a5a09a;font-size:12px;text-align:center;}',
  431. '@media print{body{background:#fff;}.card{border:none;padding:0;}}',
  432. ].join('\n');
  433. const doc = '<!doctype html>\n<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n'
  434. + '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
  435. + `<title>${esc(rec.question || '问数报告')}</title>\n<style>${styles}</style>\n</head>\n<body>\n<div class="wrap">\n`
  436. + `<h1>${esc(rec.question || '')}</h1>\n`
  437. + `<div class="meta">智能问数 · ${esc(entity.name || rec.entity || state.entity || '—')} · ${esc(fmtClock(rec.startedAt))}${rec.skill ? ` · 技能「${esc(rec.skill)}」` : ''}</div>\n`
  438. + `<div class="card"><div class="answer">${renderMarkdown(result.answer || '')}</div></div>\n`
  439. + '<footer>由 炘和数智智慧平台 · 智能问数 生成</footer>\n</div>\n</body>\n</html>';
  440. const blob = new Blob([doc], { type: 'text/html;charset=utf-8' });
  441. const a = document.createElement('a');
  442. a.href = URL.createObjectURL(blob);
  443. a.download = `问数报告-${String(rec.question || '报告').slice(0, 16).replace(/[\\/:*?"<>|\s]+/g, '-')}-${Date.now()}.html`;
  444. a.click();
  445. URL.revokeObjectURL(a.href);
  446. showToast('报告已导出(.html,可直接打印为 PDF)', 'success');
  447. }
  448. /** 把一次运行渲染进容器(轮询时整卡重绘,数据量小无压力)。 */
  449. function renderRunInto(wrap, rec) {
  450. const TOOL_LABEL = {
  451. lake_overview: '湖概览', query_report: '查询报表', compare_periods: '同环比对比',
  452. data_quality: '数据体检', cross_check: '层间核对', raw_inspect: '源侧字段', peer_benchmark: '同行对标',
  453. cross_entity: '跨主体对比', aitable_verify: '湖云比对', build_report: '分析报告', metric_query: '指标查询',
  454. detect_anomalies: '异常检测', attribution: '归因分析',
  455. };
  456. // 阶段徽标:让「数据查询 → 提取 → 计算」的推进在时间线上一眼可见
  457. const TOOL_PHASE = {
  458. lake_overview: '查询', query_report: '查询', compare_periods: '查询', metric_query: '查询', cross_entity: '查询', peer_benchmark: '查询',
  459. raw_inspect: '提取', aitable_verify: '提取',
  460. data_quality: '计算', cross_check: '计算', detect_anomalies: '计算', attribution: '计算', build_report: '计算',
  461. };
  462. const VERDICT_WORD = { ok: '通过', warn: '警告', fail: '不一致' };
  463. const skillName = rec.skill ? ((state.skills.find((s) => s.key === rec.skill) || {}).name || rec.skill) : '';
  464. // 头部:时间 + 「状态 · 总耗时 · N 个工具」
  465. const totalMs = rec.finishedAt && rec.startedAt ? new Date(rec.finishedAt) - new Date(rec.startedAt) : null;
  466. const toolCount = (rec.steps || []).filter((s) => s.type === 'tool').length;
  467. const dotCls = rec.status === 'running' ? 'running' : (rec.status === 'failed' ? 'failed' : 'done');
  468. const head = [];
  469. head.push(`<div class="ask-meta">问数 · ${esc(fmtClock(rec.startedAt))}</div>`);
  470. const usage = rec.usage || {};
  471. const costBits = [];
  472. if (usage.tokensIn) costBits.push(`${fmtTokens(usage.tokensIn)} in / ${fmtTokens(usage.tokensOut)} out`);
  473. head.push(`<div class="ask-worked"><span class="ask-dot ${dotCls}"></span><span>${STATUS_WORD[rec.status] || esc(rec.status)}`
  474. + `${totalMs != null ? ` · ${fmtDuration(totalMs)}` : ''} · ${toolCount} 个工具`
  475. + (costBits.length ? ` · ${costBits.join(' · ')}` : '')
  476. + `${rec.autoPicked ? ` · 自动→${rec.mode === 'quick' ? '快问' : '深度分析'}` : ''}`
  477. + `${rec.replayFrom ? ' · 复用打法' : ''}`
  478. + `${skillName ? ` · 技能「${esc(skillName)}」` : ''}</span></div>`);
  479. // 步骤时间线:思考 · Turn N(耗时/tokens)、工具(耗时/载荷/行数)
  480. const steps = [];
  481. for (const step of rec.steps || []) {
  482. if (step.type === 'thought') {
  483. const bits = [fmtDuration(step.ms)];
  484. if (step.tokensIn != null) bits.push(`${fmtTokens(step.tokensIn)} in / ${fmtTokens(step.tokensOut)} out`);
  485. steps.push(`<div class="ask-step">
  486. <div class="ask-step-head"><span class="ask-dot done"></span><b>思考${step.turn ? ` · Turn ${step.turn}` : ''}</b></div>
  487. <div class="ask-step-meta">${bits.filter(Boolean).map((b) => `<span class="ask-chip">${esc(String(b))}</span>`).join('')}</div>
  488. ${step.text ? `<div class="ask-step-body">${esc(step.text)}</div>` : ''}
  489. </div>`);
  490. } else if (step.type === 'tool') {
  491. const label = TOOL_LABEL[step.tool] || step.tool;
  492. const bits = [fmtDuration(step.ms), fmtBytes(step.bytes)];
  493. if (step.rows != null) bits.push(`${step.rows} 行`);
  494. steps.push(`<div class="ask-step">
  495. <div class="ask-step-head"><span class="ask-dot ${step.ok ? 'done' : 'failed'}"></span><span class="ask-phase phase-${TOOL_PHASE[step.tool] || 'query'}">${esc(TOOL_PHASE[step.tool] || '查询')}</span><b>${esc(label)}</b></div>
  496. <div class="ask-step-meta">${bits.filter(Boolean).map((b) => `<span class="ask-chip">${esc(String(b))}</span>`).join('')}</div>
  497. <details class="ask-tool"><summary>参数与结果</summary>
  498. <pre class="ask-pre">参数:${esc(JSON.stringify(step.args || {}))}
  499. ${esc(step.summary || step.error || '')}</pre>
  500. </details>
  501. </div>`);
  502. }
  503. }
  504. const parts = [];
  505. parts.push(`<div class="ask-q">${esc(rec.question)}</div>`);
  506. parts.push('<div class="ask-card">');
  507. parts.push(head.join(''));
  508. // 思考过程:Codex 式一行摘要,运行中自动展开、完成后收起,点开才看逐步明细
  509. if (steps.length) {
  510. const running = rec.status === 'running';
  511. const label = running
  512. ? `思考中 · 已 ${steps.length} 步 · ${toolCount} 个工具`
  513. : `思考过程 · ${steps.length} 步${totalMs != null ? ` · ${fmtDuration(totalMs)}` : ''} · ${toolCount} 个工具`;
  514. parts.push(`<details class="ask-loop"${running ? ' open' : ''}><summary><span class="ask-dot ${running ? 'running' : 'done'}"></span>${esc(label)}</summary><div class="ask-steps">${steps.join('')}</div></details>`);
  515. }
  516. // verdict 卡:数据体检/层间核对/恒等式/完整性等校验结论
  517. const verdicts = rec.verdicts || [];
  518. if (verdicts.length) {
  519. parts.push(`<div class="ask-verdicts">${verdicts.map((v) => `
  520. <div class="ask-verdict ${v.status}">
  521. <span class="ask-verdict-status">${VERDICT_WORD[v.status] || esc(v.status)}</span>
  522. <span class="ask-verdict-check">${esc(v.check)}</span>
  523. <span class="ask-verdict-detail">${esc(v.detail || '')}</span>
  524. </div>`).join('')}</div>`);
  525. }
  526. // 数字溯源警示(不阻断,仅提示)
  527. const prov = rec.provenance;
  528. if (prov && prov.untracedCount > 0) {
  529. parts.push(`<div class="ask-provenance">⚠ ${prov.untracedCount}/${prov.total} 个数字未能在工具结果中溯源(可能为换算或表述),请谨慎采信:${esc(prov.untraced.join('、'))}</div>`);
  530. }
  531. if (rec.status === 'running') parts.push('<div class="ask-typing"><span></span><span></span><span></span></div>');
  532. if (rec.status === 'failed') parts.push(`<div class="ask-error">${esc(rec.error || '运行失败')}</div>`);
  533. if (rec.status === 'cancelled') parts.push('<div class="ask-note">已取消</div>');
  534. const result = rec.result || null;
  535. if (result && result.answer) {
  536. parts.push(`<div class="ask-answer">${renderMarkdown(result.answer)}</div>`);
  537. const chart = result.chart;
  538. if (chart && chart.type && chart.type !== 'none') {
  539. parts.push(`<div class="ask-chart" data-run="${esc(rec.runId)}"></div>`);
  540. if (chart.title) parts.push(`<div class="ask-chart-title">${esc(chart.title)}</div>`);
  541. }
  542. parts.push('<div class="ask-actions"><button type="button" class="btn btn-ghost btn-small" data-export="' + esc(rec.runId) + '">导出为报告(HTML)</button></div>');
  543. if (result.reportUrl) {
  544. parts.push(`<div class="ask-actions"><a class="btn btn-ghost btn-small" href="${esc(result.reportUrl)}" target="_blank" rel="noopener">查看分析报告</a></div>`);
  545. }
  546. // 反馈闭环:采纳 → 打法入语料;纠错 → 反馈日志供评测提炼
  547. if (rec.status === 'done') {
  548. const fb = rec.feedback;
  549. if (fb && fb.kind) {
  550. parts.push(`<div class="ask-note">已反馈${fb.kind === 'up' ? '有帮助' : '纠错'}${fb.note ? ':' + esc(fb.note) : ''}</div>`);
  551. } else {
  552. parts.push(`<div class="ask-actions ask-fb"><button type="button" class="btn btn-ghost btn-small" data-fb="up" data-run="${esc(rec.runId)}">👍 有帮助</button>`
  553. + `<button type="button" class="btn btn-ghost btn-small" data-fb="down" data-run="${esc(rec.runId)}">✍️ 纠错</button></div>`);
  554. }
  555. }
  556. }
  557. parts.push('</div>');
  558. wrap.innerHTML = parts.join('');
  559. // 图表:从产物中找与 chart 维度匹配的行(最后一条优先)
  560. if (result && result.chart && result.chart.type && result.chart.type !== 'none') {
  561. const wrapEl = wrap.querySelector('.ask-chart');
  562. const rows = findChartRows(rec, result.chart);
  563. if (wrapEl && rows && rows.length) renderChart(wrapEl, result.chart, rows);
  564. else if (wrapEl) wrapEl.parentNode.removeChild(wrapEl);
  565. }
  566. const exportBtn = wrap.querySelector('[data-export]');
  567. if (exportBtn) exportBtn.addEventListener('click', () => exportRunHtml(rec));
  568. wrap.querySelectorAll('[data-fb]').forEach((btn) => {
  569. btn.addEventListener('click', async () => {
  570. const kind = btn.dataset.fb;
  571. let note = '';
  572. if (kind === 'down') {
  573. note = window.prompt('请描述哪里不对(会进入改进清单,可留空)');
  574. if (note === null) return;
  575. }
  576. try {
  577. const data = await jsonFetch('/api/agent/feedback', {
  578. method: 'POST',
  579. headers: { 'Content-Type': 'application/json' },
  580. body: JSON.stringify({ entity: state.entity, runId: rec.runId, kind, note }),
  581. });
  582. Object.assign(rec, { feedback: data.run.feedback });
  583. renderRunInto(wrap, rec);
  584. } catch { /* 反馈失败不打扰 */ }
  585. });
  586. });
  587. const stream = $('askStream');
  588. stream.scrollTop = stream.scrollHeight;
  589. }
  590. function findChartRows(rec, chart) {
  591. if (rec.result && Array.isArray(rec.result.chartRows) && rec.result.chartRows.length) {
  592. return filterRowsByDimension(rec.result.chartRows, chart);
  593. }
  594. const steps = (rec.steps || []).filter((s) => s.type === 'tool' && s.ok && s.data);
  595. for (const step of steps.reverse()) {
  596. const d = step.data;
  597. const candidates = [d.rows, chart.x === 'date' ? d.curByDate : d.curByShop];
  598. for (const rows of candidates) {
  599. if (Array.isArray(rows) && rows.length) {
  600. const filtered = filterRowsByDimension(rows, chart);
  601. if (filtered.length) return filtered;
  602. }
  603. }
  604. }
  605. return null;
  606. }
  607. function filterRowsByDimension(rows, chart) {
  608. const key = chart.x === 'date' ? 'date' : 'shopName';
  609. const has = rows.filter((r) => r && r[key] != null && chart.y.some((m) => r[m] != null));
  610. if (chart.x === 'date') has.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
  611. return has;
  612. }
  613. function renderChart(wrapEl, chart, rows) {
  614. const prev = echarts.getInstanceByDom(wrapEl);
  615. if (prev) prev.dispose();
  616. const instance = echarts.init(wrapEl);
  617. state.charts.push(instance);
  618. const meta = new Map(state.columns.map((c) => [c.key, c]));
  619. const colOf = (key) => meta.get(key) || { label: key, type: 'num' };
  620. instance.setOption({
  621. tooltip: { trigger: 'axis' },
  622. grid: { left: 56, right: 16, top: 20, bottom: chart.type === 'bar' && chart.x !== 'date' ? 52 : 24 },
  623. xAxis: {
  624. type: 'category',
  625. data: rows.map((r) => (chart.x === 'date' ? ymdToDash(r.date) : (r.shopName || r.shopId || ''))),
  626. axisLabel: { color: '#6b6560', interval: 0, rotate: chart.type === 'bar' && chart.x !== 'date' ? 30 : 0, width: 80, overflow: 'truncate' },
  627. axisLine: { lineStyle: { color: '#e8e4df' } },
  628. },
  629. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
  630. series: (chart.y || []).map((key, yi) => {
  631. const c = colOf(key);
  632. return {
  633. name: c.label || key,
  634. type: chart.type === 'line' ? 'line' : 'bar',
  635. smooth: true, barMaxWidth: 26,
  636. data: rows.map((r) => {
  637. const n = Number(r[key]);
  638. if (!Number.isFinite(n)) return 0;
  639. return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
  640. }),
  641. itemStyle: { color: yi === 0 ? ACCENT : BLUE },
  642. };
  643. }),
  644. });
  645. setTimeout(() => instance.resize(), 40);
  646. }
  647. // ---------- 事件 ----------
  648. function bindEvents() {
  649. $('askEntity').addEventListener('change', () => {
  650. state.entity = $('askEntity').value || null;
  651. state.sessionId = null;
  652. state.sessions = [];
  653. renderStageHead();
  654. loadDigest();
  655. openLatestSession();
  656. });
  657. $('askMode').addEventListener('click', (event) => {
  658. const btn = event.target.closest('button[data-mode]');
  659. if (!btn) return;
  660. state.mode = ['auto', 'agent', 'quick'].includes(btn.dataset.mode) ? btn.dataset.mode : 'auto';
  661. $('askMode').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  662. });
  663. $('askSkill').addEventListener('change', () => {
  664. state.skill = $('askSkill').value || '';
  665. });
  666. $('btnAskNewSession').addEventListener('click', newSession);
  667. $('askSearch').addEventListener('input', () => {
  668. state.search = $('askSearch').value;
  669. renderSessions();
  670. });
  671. $('btnAskSend').addEventListener('click', () => ask());
  672. $('btnAskStop').addEventListener('click', cancel);
  673. const input = $('askInput');
  674. input.addEventListener('keydown', (event) => {
  675. if (event.key === 'Enter' && !event.shiftKey) {
  676. event.preventDefault();
  677. ask();
  678. }
  679. });
  680. input.addEventListener('input', autoresize);
  681. $('askChips').addEventListener('click', (event) => {
  682. const chip = event.target.closest('[data-q]');
  683. if (chip) ask(chip.dataset.q);
  684. });
  685. window.addEventListener('resize', () => {
  686. state.charts.forEach((c) => { try { c.resize(); } catch { /* ignore */ } });
  687. });
  688. }
  689. })();