ask.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  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. function fmtDuration(ms) { return ms == null || ms === '' ? '' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : `${ms}ms`); }
  43. function fmtBytes(n) { return n == null ? '' : (n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : `${n} B`); }
  44. function fmtTokens(n) { return n == null ? '—' : (n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)); }
  45. function fmtClock(iso) {
  46. const d = iso ? new Date(iso) : null;
  47. return d ? `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` : '';
  48. }
  49. function entityName(key) {
  50. const e = state.entities.find((x) => x.key === key);
  51. return e ? (e.name || e.key) : (key || '');
  52. }
  53. // ---------- 初始化 ----------
  54. window.shanAskInit = async function () {
  55. if (!state.inited) {
  56. state.inited = true;
  57. bindEvents();
  58. }
  59. if (!state.columns.length) {
  60. try {
  61. const data = await jsonFetch('/api/reports/columns');
  62. state.columns = data.columns || [];
  63. } catch { state.columns = []; }
  64. }
  65. await loadSkills();
  66. await loadEntities();
  67. await loadSessions();
  68. await openLatestSession();
  69. };
  70. async function loadSkills() {
  71. const sel = $('askSkill');
  72. try {
  73. const data = await jsonFetch('/api/agent/skills');
  74. state.skills = data.skills || [];
  75. } catch { state.skills = []; }
  76. sel.innerHTML = '<option value="">技能:自动</option>';
  77. for (const s of state.skills) {
  78. const opt = document.createElement('option');
  79. opt.value = s.key;
  80. opt.textContent = `技能:${s.name}`;
  81. opt.title = s.desc || '';
  82. sel.appendChild(opt);
  83. }
  84. sel.value = state.skill || '';
  85. }
  86. async function loadEntities() {
  87. try {
  88. const data = await jsonFetch('/api/reports/entities');
  89. state.entities = data.entities || [];
  90. } catch { state.entities = []; }
  91. const sel = $('askEntity');
  92. sel.innerHTML = '';
  93. if (!state.entities.length) {
  94. const opt = document.createElement('option');
  95. opt.value = '';
  96. opt.textContent = '暂无主体,请先同步数据';
  97. sel.appendChild(opt);
  98. sel.disabled = true;
  99. state.entity = null;
  100. renderStageHead();
  101. return;
  102. }
  103. sel.disabled = false;
  104. for (const e of state.entities) {
  105. const opt = document.createElement('option');
  106. opt.value = e.key;
  107. opt.textContent = e.name || e.key;
  108. sel.appendChild(opt);
  109. }
  110. if (!state.entity || !state.entities.some((e) => e.key === state.entity)) {
  111. state.entity = state.entities[0].key;
  112. }
  113. sel.value = state.entity;
  114. renderStageHead();
  115. }
  116. // ---------- 左侧会话栏 ----------
  117. async function loadSessions() {
  118. if (!state.entity) { state.sessions = []; renderSessions(); return; }
  119. try {
  120. const data = await jsonFetch(`/api/agent/sessions?entity=${encodeURIComponent(state.entity)}`);
  121. state.sessions = data.sessions || [];
  122. } catch { state.sessions = []; }
  123. renderSessions();
  124. }
  125. function renderSessions() {
  126. const wrap = $('askSessions');
  127. if (!state.entity) { wrap.innerHTML = '<div class="ask-sessions-empty">暂无主体</div>'; return; }
  128. const kw = state.search.trim().toLowerCase();
  129. const list = state.sessions.filter((s) => !kw || String(s.title || '').toLowerCase().includes(kw));
  130. if (!list.length) {
  131. wrap.innerHTML = `<div class="ask-sessions-empty">${kw ? '无匹配会话' : '暂无会话'}</div>`;
  132. return;
  133. }
  134. wrap.innerHTML = '';
  135. for (const s of list) {
  136. const item = document.createElement('div');
  137. item.className = 'ask-session-item' + (s.sessionId === state.sessionId ? ' active' : '');
  138. item.innerHTML = `
  139. <span class="ask-session-title"></span>
  140. <span class="ask-session-preview"></span>
  141. <button type="button" class="ask-session-del" title="删除会话">
  142. <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>
  143. </button>`;
  144. item.querySelector('.ask-session-title').textContent = s.title || '新会话';
  145. const preview = s.lastQuestion && s.lastQuestion !== s.title ? s.lastQuestion : (s.lastQuestion || '');
  146. item.querySelector('.ask-session-preview').textContent = preview;
  147. item.addEventListener('click', () => openSession(s.sessionId));
  148. item.querySelector('.ask-session-del').addEventListener('click', (event) => {
  149. event.stopPropagation();
  150. removeSession(s.sessionId);
  151. });
  152. wrap.appendChild(item);
  153. }
  154. }
  155. async function removeSession(sessionId) {
  156. try {
  157. await jsonFetch(`/api/agent/sessions/${encodeURIComponent(sessionId)}?entity=${encodeURIComponent(state.entity)}`, { method: 'DELETE' });
  158. } catch { /* 删除失败不打扰 */ }
  159. if (sessionId === state.sessionId) await newSession();
  160. else await loadSessions();
  161. }
  162. // ---------- 会话与舞台 ----------
  163. function renderStageHead() {
  164. const session = state.sessions.find((s) => s.sessionId === state.sessionId);
  165. $('askStageTitle').textContent = state.sessionId && session ? (session.title || '新对话') : '新对话';
  166. $('askStageBadge').textContent = state.entity ? entityName(state.entity) : '未选择主体';
  167. }
  168. async function openLatestSession() {
  169. await loadSessions();
  170. if (state.sessions.length) await openSession(state.sessions[0].sessionId);
  171. else await openSession(null);
  172. }
  173. async function openSession(sessionId) {
  174. stopPoll();
  175. state.sessionId = sessionId;
  176. state.activeRunId = null;
  177. if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
  178. renderStageHead();
  179. renderSessions();
  180. const stream = $('askStream');
  181. [...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
  182. if (!sessionId || !state.entity) { showHero(true); return; }
  183. showHero(false);
  184. try {
  185. const data = await jsonFetch(`/api/agent/runs?entity=${encodeURIComponent(state.entity)}&sessionId=${encodeURIComponent(sessionId)}`);
  186. const runs = (data.runs || []).slice(0, 10);
  187. let runningRun = null;
  188. for (const r of runs.reverse()) {
  189. try {
  190. const detail = await jsonFetch(`/api/agent/runs/${encodeURIComponent(r.runId)}?entity=${encodeURIComponent(state.entity)}`);
  191. appendRun(detail.run);
  192. if (detail.run.status === 'running') runningRun = detail.run;
  193. } catch { /* 单条历史加载失败跳过 */ }
  194. }
  195. // 回到页面时若有运行仍在进行(切页不中断),续接轮询
  196. if (runningRun) {
  197. state.activeRunId = runningRun.runId;
  198. state.busy = true;
  199. setComposerBusy(true);
  200. startPoll(runningRun.runId);
  201. }
  202. } catch { /* 历史加载失败不阻塞新提问 */ }
  203. }
  204. async function newSession() {
  205. stopPoll();
  206. state.sessionId = null;
  207. state.activeRunId = null;
  208. if (state.busy) { state.busy = false; setComposerBusy(false); } // 切走时运行仍在服务端继续
  209. const stream = $('askStream');
  210. [...stream.querySelectorAll('.ask-run')].forEach((n) => n.remove());
  211. renderStageHead();
  212. renderSessions();
  213. showHero(true);
  214. $('askInput').focus();
  215. }
  216. function showHero(show) {
  217. const hero = $('askEmpty');
  218. if (hero) hero.hidden = !show;
  219. }
  220. // ---------- 提问 ----------
  221. async function ask(text) {
  222. const q = String(text || $('askInput').value || '').trim();
  223. if (!q || state.busy || !state.entity) return;
  224. state.busy = true;
  225. $('askInput').value = '';
  226. autoresize();
  227. setComposerBusy(true);
  228. showHero(false);
  229. try {
  230. const data = await jsonFetch('/api/agent/runs', {
  231. method: 'POST',
  232. headers: { 'Content-Type': 'application/json' },
  233. body: JSON.stringify({
  234. entity: state.entity, question: q, mode: state.mode,
  235. skill: state.skill || undefined, sessionId: state.sessionId || undefined,
  236. }),
  237. });
  238. state.sessionId = data.run.sessionId;
  239. state.activeRunId = data.run.runId;
  240. renderStageHead();
  241. appendRun(data.run);
  242. loadSessions(); // 侧栏出现新会话/新预览
  243. startPoll(data.run.runId);
  244. } catch (e) {
  245. state.busy = false;
  246. setComposerBusy(false);
  247. appendLocalError(q, e.message);
  248. }
  249. }
  250. async function cancel() {
  251. if (!state.activeRunId) return;
  252. try {
  253. await jsonFetch(`/api/agent/runs/${encodeURIComponent(state.activeRunId)}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
  254. } catch { /* 取消失败不打扰 */ }
  255. }
  256. function startPoll(runId) {
  257. stopPoll();
  258. state.pollTimer = setInterval(async () => {
  259. if (!$('viewAsk').classList.contains('active')) return; // 不在问数页时暂停拉取
  260. try {
  261. const data = await jsonFetch(`/api/agent/runs/${encodeURIComponent(runId)}?entity=${encodeURIComponent(state.entity)}`);
  262. const runEl = document.getElementById(`ask-run-${runId}`);
  263. if (runEl) renderRunInto(runEl, data.run);
  264. if (data.run.status !== 'running') {
  265. stopPoll();
  266. state.busy = false;
  267. setComposerBusy(false);
  268. loadSessions();
  269. }
  270. } catch { /* 单次轮询失败下轮再试 */ }
  271. }, 1000);
  272. }
  273. function stopPoll() {
  274. if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
  275. }
  276. function setComposerBusy(busy) {
  277. $('btnAskSend').hidden = busy;
  278. $('btnAskStop').hidden = !busy;
  279. }
  280. function autoresize() {
  281. const el = $('askInput');
  282. el.style.height = 'auto';
  283. el.style.height = Math.min(el.scrollHeight, 120) + 'px';
  284. }
  285. // ---------- 渲染 ----------
  286. function appendLocalError(q, message) {
  287. const stream = $('askStream');
  288. const wrap = document.createElement('div');
  289. wrap.className = 'ask-run';
  290. wrap.innerHTML = `
  291. <div class="ask-q">${esc(q)}</div>
  292. <div class="ask-card ask-card-error">${esc(message)}</div>`;
  293. stream.appendChild(wrap);
  294. stream.scrollTop = stream.scrollHeight;
  295. }
  296. function appendRun(rec) {
  297. const stream = $('askStream');
  298. const wrap = document.createElement('div');
  299. wrap.className = 'ask-run';
  300. wrap.id = `ask-run-${rec.runId}`;
  301. stream.appendChild(wrap);
  302. renderRunInto(wrap, rec);
  303. stream.scrollTop = stream.scrollHeight;
  304. }
  305. /** 把一次运行渲染进容器(轮询时整卡重绘,数据量小无压力)。 */
  306. function renderRunInto(wrap, rec) {
  307. const TOOL_LABEL = {
  308. lake_overview: '湖概览', query_report: '查询报表', compare_periods: '同环比对比',
  309. data_quality: '数据体检', cross_check: '层间核对', raw_inspect: '源侧字段', peer_benchmark: '同行对标',
  310. cross_entity: '跨主体对比', aitable_verify: '湖云比对',
  311. };
  312. const VERDICT_WORD = { ok: '通过', warn: '警告', fail: '不一致' };
  313. const skillName = rec.skill ? ((state.skills.find((s) => s.key === rec.skill) || {}).name || rec.skill) : '';
  314. // 头部:时间 + 「状态 · 总耗时 · N 个工具」
  315. const totalMs = rec.finishedAt && rec.startedAt ? new Date(rec.finishedAt) - new Date(rec.startedAt) : null;
  316. const toolCount = (rec.steps || []).filter((s) => s.type === 'tool').length;
  317. const dotCls = rec.status === 'running' ? 'running' : (rec.status === 'failed' ? 'failed' : 'done');
  318. const head = [];
  319. head.push(`<div class="ask-meta">问数 · ${esc(fmtClock(rec.startedAt))}</div>`);
  320. head.push(`<div class="ask-worked"><span class="ask-dot ${dotCls}"></span><span>${STATUS_WORD[rec.status] || esc(rec.status)}`
  321. + `${totalMs != null ? ` · ${fmtDuration(totalMs)}` : ''} · ${toolCount} 个工具`
  322. + `${rec.autoPicked ? ` · 自动→${rec.mode === 'quick' ? '快问' : '深度分析'}` : ''}`
  323. + `${skillName ? ` · 技能「${esc(skillName)}」` : ''}</span></div>`);
  324. // 步骤时间线:思考 · Turn N(耗时/tokens)、工具(耗时/载荷/行数)
  325. const steps = [];
  326. for (const step of rec.steps || []) {
  327. if (step.type === 'thought') {
  328. const bits = [fmtDuration(step.ms)];
  329. if (step.tokensIn != null) bits.push(`${fmtTokens(step.tokensIn)} in / ${fmtTokens(step.tokensOut)} out`);
  330. steps.push(`<div class="ask-step">
  331. <div class="ask-step-head"><span class="ask-dot done"></span><b>思考 · Turn ${step.turn || ''}</b></div>
  332. <div class="ask-step-meta">${bits.filter(Boolean).join(' · ')}</div>
  333. ${step.text ? `<div class="ask-step-body">${esc(step.text)}</div>` : ''}
  334. </div>`);
  335. } else if (step.type === 'tool') {
  336. const label = TOOL_LABEL[step.tool] || step.tool;
  337. const bits = [fmtDuration(step.ms), fmtBytes(step.bytes)];
  338. if (step.rows != null) bits.push(`${step.rows} 行`);
  339. steps.push(`<div class="ask-step">
  340. <div class="ask-step-head"><span class="ask-dot ${step.ok ? 'done' : 'failed'}"></span><b>${esc(label)}</b></div>
  341. <div class="ask-step-meta">${bits.filter(Boolean).join(' · ')}</div>
  342. <details class="ask-tool"><summary>参数与结果</summary>
  343. <pre class="ask-pre">参数:${esc(JSON.stringify(step.args || {}))}
  344. ${esc(step.summary || step.error || '')}</pre>
  345. </details>
  346. </div>`);
  347. }
  348. }
  349. const parts = [];
  350. parts.push(`<div class="ask-q">${esc(rec.question)}</div>`);
  351. parts.push('<div class="ask-card">');
  352. parts.push(head.join(''));
  353. if (steps.length) parts.push(`<div class="ask-steps">${steps.join('')}</div>`);
  354. // verdict 卡:数据体检/层间核对/恒等式/完整性等校验结论
  355. const verdicts = rec.verdicts || [];
  356. if (verdicts.length) {
  357. parts.push(`<div class="ask-verdicts">${verdicts.map((v) => `
  358. <div class="ask-verdict ${v.status}">
  359. <span class="ask-verdict-status">${VERDICT_WORD[v.status] || esc(v.status)}</span>
  360. <span class="ask-verdict-check">${esc(v.check)}</span>
  361. <span class="ask-verdict-detail">${esc(v.detail || '')}</span>
  362. </div>`).join('')}</div>`);
  363. }
  364. // 数字溯源警示(不阻断,仅提示)
  365. const prov = rec.provenance;
  366. if (prov && prov.untracedCount > 0) {
  367. parts.push(`<div class="ask-provenance">⚠ ${prov.untracedCount}/${prov.total} 个数字未能在工具结果中溯源(可能为换算或表述),请谨慎采信:${esc(prov.untraced.join('、'))}</div>`);
  368. }
  369. if (rec.status === 'running') parts.push('<div class="ask-typing"><span></span><span></span><span></span></div>');
  370. if (rec.status === 'failed') parts.push(`<div class="ask-error">${esc(rec.error || '运行失败')}</div>`);
  371. if (rec.status === 'cancelled') parts.push('<div class="ask-note">已取消</div>');
  372. const result = rec.result || null;
  373. if (result && result.answer) {
  374. parts.push(`<div class="ask-answer">${esc(result.answer)}</div>`);
  375. const chart = result.chart;
  376. if (chart && chart.type && chart.type !== 'none') {
  377. parts.push(`<div class="ask-chart" data-run="${esc(rec.runId)}"></div>`);
  378. if (chart.title) parts.push(`<div class="ask-chart-title">${esc(chart.title)}</div>`);
  379. }
  380. if (result.appliedFrom) {
  381. parts.push('<div class="ask-actions"><button type="button" class="btn btn-ghost btn-small" data-apply="' + esc(rec.runId) + '">应用到报表</button></div>');
  382. }
  383. if (result.reportUrl) {
  384. parts.push(`<div class="ask-actions"><a class="btn btn-ghost btn-small" href="${esc(result.reportUrl)}" target="_blank" rel="noopener">查看分析报告</a></div>`);
  385. }
  386. }
  387. parts.push('</div>');
  388. wrap.innerHTML = parts.join('');
  389. // 图表:从产物中找与 chart 维度匹配的行(最后一条优先)
  390. if (result && result.chart && result.chart.type && result.chart.type !== 'none') {
  391. const wrapEl = wrap.querySelector('.ask-chart');
  392. const rows = findChartRows(rec, result.chart);
  393. if (wrapEl && rows && rows.length) renderChart(wrapEl, result.chart, rows);
  394. else if (wrapEl) wrapEl.parentNode.removeChild(wrapEl);
  395. }
  396. const applyBtn = wrap.querySelector('[data-apply]');
  397. if (applyBtn) {
  398. applyBtn.addEventListener('click', () => {
  399. const applied = rec.result && rec.result.appliedFrom;
  400. if (applied && window.shanReportsApplyQuery) window.shanReportsApplyQuery(applied);
  401. });
  402. }
  403. const stream = $('askStream');
  404. stream.scrollTop = stream.scrollHeight;
  405. }
  406. function findChartRows(rec, chart) {
  407. if (rec.result && Array.isArray(rec.result.chartRows) && rec.result.chartRows.length) {
  408. return filterRowsByDimension(rec.result.chartRows, chart);
  409. }
  410. const steps = (rec.steps || []).filter((s) => s.type === 'tool' && s.ok && s.data);
  411. for (const step of steps.reverse()) {
  412. const d = step.data;
  413. const candidates = [d.rows, chart.x === 'date' ? d.curByDate : d.curByShop];
  414. for (const rows of candidates) {
  415. if (Array.isArray(rows) && rows.length) {
  416. const filtered = filterRowsByDimension(rows, chart);
  417. if (filtered.length) return filtered;
  418. }
  419. }
  420. }
  421. return null;
  422. }
  423. function filterRowsByDimension(rows, chart) {
  424. const key = chart.x === 'date' ? 'date' : 'shopName';
  425. const has = rows.filter((r) => r && r[key] != null && chart.y.some((m) => r[m] != null));
  426. if (chart.x === 'date') has.sort((a, b) => (String(a.date) < String(b.date) ? -1 : 1));
  427. return has;
  428. }
  429. function renderChart(wrapEl, chart, rows) {
  430. const prev = echarts.getInstanceByDom(wrapEl);
  431. if (prev) prev.dispose();
  432. const instance = echarts.init(wrapEl);
  433. state.charts.push(instance);
  434. const meta = new Map(state.columns.map((c) => [c.key, c]));
  435. const colOf = (key) => meta.get(key) || { label: key, type: 'num' };
  436. instance.setOption({
  437. tooltip: { trigger: 'axis' },
  438. grid: { left: 56, right: 16, top: 20, bottom: chart.type === 'bar' && chart.x !== 'date' ? 52 : 24 },
  439. xAxis: {
  440. type: 'category',
  441. data: rows.map((r) => (chart.x === 'date' ? ymdToDash(r.date) : (r.shopName || r.shopId || ''))),
  442. axisLabel: { color: '#6b6560', interval: 0, rotate: chart.type === 'bar' && chart.x !== 'date' ? 30 : 0, width: 80, overflow: 'truncate' },
  443. axisLine: { lineStyle: { color: '#e8e4df' } },
  444. },
  445. yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { lineStyle: { color: '#f0ede8' } } },
  446. series: (chart.y || []).map((key, yi) => {
  447. const c = colOf(key);
  448. return {
  449. name: c.label || key,
  450. type: chart.type === 'line' ? 'line' : 'bar',
  451. smooth: true, barMaxWidth: 26,
  452. data: rows.map((r) => {
  453. const n = Number(r[key]);
  454. if (!Number.isFinite(n)) return 0;
  455. return c.type === 'pct' ? +(n * 100).toFixed(2) : n;
  456. }),
  457. itemStyle: { color: yi === 0 ? ACCENT : BLUE },
  458. };
  459. }),
  460. });
  461. setTimeout(() => instance.resize(), 40);
  462. }
  463. // ---------- 事件 ----------
  464. function bindEvents() {
  465. $('askEntity').addEventListener('change', () => {
  466. state.entity = $('askEntity').value || null;
  467. state.sessionId = null;
  468. state.sessions = [];
  469. renderStageHead();
  470. openLatestSession();
  471. });
  472. $('askMode').addEventListener('click', (event) => {
  473. const btn = event.target.closest('button[data-mode]');
  474. if (!btn) return;
  475. state.mode = ['auto', 'agent', 'quick'].includes(btn.dataset.mode) ? btn.dataset.mode : 'auto';
  476. $('askMode').querySelectorAll('button').forEach((b) => b.classList.toggle('active', b === btn));
  477. });
  478. $('askSkill').addEventListener('change', () => {
  479. state.skill = $('askSkill').value || '';
  480. });
  481. $('btnAskNewSession').addEventListener('click', newSession);
  482. $('askSearch').addEventListener('input', () => {
  483. state.search = $('askSearch').value;
  484. renderSessions();
  485. });
  486. $('btnAskSend').addEventListener('click', () => ask());
  487. $('btnAskStop').addEventListener('click', cancel);
  488. const input = $('askInput');
  489. input.addEventListener('keydown', (event) => {
  490. if (event.key === 'Enter' && !event.shiftKey) {
  491. event.preventDefault();
  492. ask();
  493. }
  494. });
  495. input.addEventListener('input', autoresize);
  496. $('askChips').addEventListener('click', (event) => {
  497. const chip = event.target.closest('[data-q]');
  498. if (chip) ask(chip.dataset.q);
  499. });
  500. window.addEventListener('resize', () => {
  501. state.charts.forEach((c) => { try { c.resize(); } catch { /* ignore */ } });
  502. });
  503. }
  504. })();