'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 = 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) => ``).join('')}${rows.map((r) => `${r.map((c) => ``).join('')}`).join('')}
${inline(h)}
${inline(c)}
`); 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)} 详情 ▾
    `; 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) { 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; } /** 把一次运行渲染进容器(轮询时整卡重绘,数据量小无压力)。 */ 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 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(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('')); if (steps.length) parts.push(`
    ${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)}
    `); } if (result.appliedFrom) { parts.push('
    '); } 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 applyBtn = wrap.querySelector('[data-apply]'); if (applyBtn) { applyBtn.addEventListener('click', () => { const applied = rec.result && rec.result.appliedFrom; if (applied && window.shanReportsApplyQuery) window.shanReportsApplyQuery(applied); }); } 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 */ } }); }); } })();