app.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. const API = '/api';
  2. const USER = 'default'; // 2.0 无鉴权:这里自报身份
  3. let sessionId = null;
  4. let controller = null;
  5. let streaming = false;
  6. let resuming = false;
  7. let loadingHistory = false;
  8. let sessions = [];
  9. let pendingApproval = false;
  10. const el = (id) => document.getElementById(id);
  11. const log = el('log');
  12. function setStatus(text, state) {
  13. el('statusPill').textContent = text;
  14. el('statusPill').title = text;
  15. el('statusDot').className = 'status-dot ' + (state || (streaming ? 'busy' : 'ready'));
  16. }
  17. function scrollDown() { const area = el('conversationScroll'); area.scrollTop = area.scrollHeight; }
  18. function addNode(cls, html) {
  19. const wrap = document.createElement('div');
  20. wrap.className = 'msg ' + cls;
  21. wrap.innerHTML = html;
  22. el('welcome').hidden = true;
  23. log.appendChild(wrap);
  24. scrollDown();
  25. return wrap;
  26. }
  27. function addUserBubble(text) {
  28. const node = addNode('user', '');
  29. const b = document.createElement('div');
  30. b.className = 'bubble';
  31. b.textContent = text;
  32. node.appendChild(b);
  33. return node;
  34. }
  35. function newAssistant() {
  36. const node = addNode('assistant', '');
  37. const b = document.createElement('div');
  38. b.className = 'bubble';
  39. node.appendChild(b);
  40. return { node, bubble: b, tools: {}, thinking: null, segment: null };
  41. }
  42. // Keep Markdown source separate from the rendered DOM. Each text segment owns
  43. // its timer, so a tool card cannot steal a pending update from the previous one.
  44. const markdownSegments = new WeakMap();
  45. function renderMarkdownSegment(segment) {
  46. const state = markdownSegments.get(segment);
  47. if (!state) return;
  48. clearTimeout(state.timer);
  49. state.timer = null;
  50. if (!window.marked || !window.DOMPurify) {
  51. segment.classList.add('plain-text');
  52. segment.textContent = state.source;
  53. return;
  54. }
  55. segment.classList.remove('plain-text');
  56. const html = marked.parse(state.source, { gfm: true, breaks: true, async: false });
  57. // Model output and stored history are untrusted. Sanitize before insertion.
  58. const fragment = DOMPurify.sanitize(html, {
  59. RETURN_DOM_FRAGMENT: true,
  60. ALLOWED_TAGS: ['p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong',
  61. 'em', 'del', 's', 'blockquote', 'ul', 'ol', 'li', 'pre', 'code', 'hr',
  62. 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input'],
  63. ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'start', 'align', 'type', 'checked', 'disabled'],
  64. ALLOW_DATA_ATTR: false,
  65. ALLOW_ARIA_ATTR: false,
  66. });
  67. fragment.querySelectorAll('a').forEach((link) => {
  68. link.target = '_blank';
  69. link.rel = 'noopener noreferrer';
  70. });
  71. fragment.querySelectorAll('input').forEach((checkbox) => {
  72. checkbox.type = 'checkbox';
  73. checkbox.disabled = true;
  74. });
  75. fragment.querySelectorAll('table').forEach((table) => {
  76. const wrapper = document.createElement('div');
  77. wrapper.className = 'markdown-table';
  78. wrapper.tabIndex = 0;
  79. wrapper.setAttribute('role', 'region');
  80. wrapper.setAttribute('aria-label', '表格,可横向滚动');
  81. table.replaceWith(wrapper);
  82. wrapper.appendChild(table);
  83. });
  84. segment.replaceChildren(fragment);
  85. }
  86. function appendToken(ui, text) {
  87. if (!ui.segment) {
  88. ui.segment = document.createElement('div');
  89. ui.segment.className = 'segment';
  90. ui.bubble.appendChild(ui.segment);
  91. markdownSegments.set(ui.segment, { source: '', timer: null });
  92. }
  93. const segment = ui.segment;
  94. const state = markdownSegments.get(segment);
  95. state.source += text;
  96. // Batch bursts of tokens rather than parsing the entire reply per token.
  97. if (state.timer === null) {
  98. state.timer = setTimeout(() => {
  99. renderMarkdownSegment(segment);
  100. if (segment.isConnected) scrollDown();
  101. }, 50);
  102. }
  103. }
  104. // Flush before adding tool/status cards to preserve chronological ordering.
  105. function breakSegment(ui) {
  106. if (ui.segment) renderMarkdownSegment(ui.segment);
  107. ui.segment = null;
  108. }
  109. function addCard(bubble, cls, title, body) {
  110. const card = document.createElement('div');
  111. card.className = 'card ' + cls;
  112. const t = document.createElement('div');
  113. t.className = 'title';
  114. t.textContent = title;
  115. card.appendChild(t);
  116. if (body) {
  117. const pre = document.createElement('pre');
  118. pre.textContent = body;
  119. card.appendChild(pre);
  120. }
  121. bubble.appendChild(card);
  122. scrollDown();
  123. return card;
  124. }
  125. function addCardFor(ui, cls, title, body) {
  126. breakSegment(ui);
  127. return addCard(ui.bubble, cls, title, body);
  128. }
  129. // ---------- 发送 ----------
  130. async function send() {
  131. const input = el('input');
  132. const text = input.value.trim();
  133. if (!text || streaming || loadingHistory || pendingApproval) return;
  134. const form = buildForm(text);
  135. if (!sessionId) el('chatTitle').textContent = text.slice(0, 30);
  136. const attachmentName = el('file').files[0]?.name;
  137. input.value = '';
  138. input.style.height = 'auto';
  139. addUserBubble(text + (attachmentName ? '\n📎 ' + attachmentName : ''));
  140. clearAttachment();
  141. await stream(`${API}/chat`, 'POST', form);
  142. }
  143. function buildForm(text) {
  144. const fd = new FormData();
  145. fd.append('message', text);
  146. if (sessionId) fd.append('session_id', sessionId);
  147. fd.append('mode', el('mode').value);
  148. fd.append('user_name', USER);
  149. const f = el('file').files[0];
  150. if (f) fd.append('file', f);
  151. return fd;
  152. }
  153. function buildResumeBody(extra) {
  154. return JSON.stringify(Object.assign({
  155. session_id: sessionId, thread_id: sessionId, user_name: USER,
  156. }, extra));
  157. }
  158. // ---------- 流式 ----------
  159. async function stream(url, method, body, isJson) {
  160. const ui = newAssistant();
  161. controller = new AbortController();
  162. streaming = true;
  163. updateControls();
  164. setStatus('连接中...');
  165. let citations = [];
  166. try {
  167. const headers = {};
  168. if (isJson) headers['Content-Type'] = 'application/json';
  169. const res = await fetch(url, { method, body, headers, signal: controller.signal });
  170. if (!res.ok) {
  171. let detail = '';
  172. const raw = await res.text();
  173. try { const parsed = JSON.parse(raw); detail = parsed.detail?.message || JSON.stringify(parsed.detail || parsed); } catch (e) { detail = raw; }
  174. addCard(ui.bubble, 'error', `HTTP ${res.status}`, detail);
  175. setStatus('请求失败', 'error');
  176. return;
  177. }
  178. if (isJson) {
  179. pendingApproval = false;
  180. log.querySelectorAll('.approval .actions').forEach((row) => {
  181. row.closest('.msg').dataset.resolved = 'true';
  182. row.replaceWith(document.createTextNode('已提交确认'));
  183. });
  184. }
  185. const sid = res.headers.get('X-Session-Id');
  186. if (sid) { sessionId = sid; el('sessionPill').textContent = '会话:' + sid.slice(0, 8); }
  187. const reader = res.body.getReader();
  188. const decoder = new TextDecoder();
  189. let buffer = '';
  190. while (true) {
  191. const { done, value } = await reader.read();
  192. if (done) break;
  193. buffer += decoder.decode(value, { stream: true });
  194. const lines = buffer.split('\n');
  195. buffer = lines.pop() || '';
  196. for (const line of lines) {
  197. if (!line.startsWith('data: ')) continue;
  198. try { handle(JSON.parse(line.slice(6)), ui, citations); }
  199. catch (e) { /* 半截 JSON,忽略 */ }
  200. }
  201. }
  202. if (!ui.failed) setStatus(pendingApproval ? '等待你的确认' : '回复已完成', pendingApproval ? 'busy' : 'ready');
  203. } catch (e) {
  204. if (e.name === 'AbortError') {
  205. addCard(ui.bubble, '', '已停止生成', '');
  206. setStatus('已停止', 'ready');
  207. } else {
  208. addCard(ui.bubble, 'error', '连接中断,请重试', String(e));
  209. setStatus('连接失败', 'error');
  210. }
  211. } finally {
  212. breakSegment(ui);
  213. scrollDown();
  214. streaming = false;
  215. controller = null;
  216. updateControls();
  217. loadSessions();
  218. }
  219. }
  220. function handle(ev, ui, citations) {
  221. switch (ev.type) {
  222. case 'agent_start':
  223. setStatus(`${ev.cn_agent || ev.agent} 已启动`);
  224. break;
  225. case 'thinking':
  226. setStatus(ev.cn_agent ? `${ev.cn_agent}:${ev.message}` : ev.message);
  227. break;
  228. case 'thinking_token':
  229. if (!ui.thinking) {
  230. breakSegment(ui);
  231. ui.thinking = document.createElement('details');
  232. const s = document.createElement('summary');
  233. s.textContent = '思考过程';
  234. s.style.color = 'var(--fg-dim)';
  235. ui.thinking.appendChild(s);
  236. const p = document.createElement('pre');
  237. p.style.fontSize = '12px';
  238. p.style.color = 'var(--fg-dim)';
  239. ui.thinking.appendChild(p);
  240. ui.bubble.appendChild(ui.thinking);
  241. }
  242. ui.thinking.querySelector('pre').textContent += ev.content || '';
  243. break;
  244. case 'executing':
  245. setStatus(ev.message || '执行中...');
  246. if (ev.tools && ev.tools.length) {
  247. const i = ev.tools.length - 1;
  248. ui.tools[ev.tools[i]] = addCardFor(ui, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, '');
  249. }
  250. break;
  251. case 'tool_call':
  252. // 按 tool_call_id 索引:同一工具被调多次时按名字索引会把结果挂错卡
  253. ui.tools[ev.tool_call_id || ev.tool] = addCardFor(ui, 'pending', `工具 ${ev.cn_tool || ev.tool}`, JSON.stringify(ev.args, null, 1));
  254. break;
  255. case 'tool_result': {
  256. const key = ev.tool_call_id || ev.tool;
  257. const card = ui.tools[key] || ui.tools[ev.tool];
  258. const body = typeof ev.content === 'string' ? ev.content : JSON.stringify(ev.content, null, 1);
  259. if (card) {
  260. card.className = 'card ' + (ev.error ? 'error' : 'done');
  261. const pre = document.createElement('pre'); pre.textContent = body; card.appendChild(pre);
  262. } else {
  263. addCardFor(ui, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body);
  264. }
  265. break;
  266. }
  267. case 'updated_todo_list':
  268. renderTodos(ui, ev.todos || []);
  269. break;
  270. case 'token':
  271. appendToken(ui, ev.content || '');
  272. break;
  273. case 'citations':
  274. citations.length = 0;
  275. (ev.items || []).forEach((it) => citations.push(it));
  276. renderCitations(ui, citations, ev.model_only);
  277. break;
  278. case 'interrupt':
  279. pendingApproval = !!ev.tool_approval;
  280. setStatus('等待你的确认', 'busy');
  281. renderInterrupt(ui, ev);
  282. break;
  283. case 'agent_done':
  284. setStatus(`完成 ${ev.duration_ms ? ev.duration_ms + 'ms' : ''} 上下文 ${ev.context_tokens || 0}/${ev.context_window || 0}`);
  285. break;
  286. case 'agent_error':
  287. case 'error':
  288. ui.failed = true;
  289. setStatus('回复出错', 'error');
  290. addCardFor(ui, 'error', `错误 [${ev.code || '-'}]`, ev.message || '');
  291. break;
  292. case 'done':
  293. if (ev.usage) {
  294. addCardFor(ui, '', '用量',
  295. `输入 ${ev.usage.input_tokens} / 输出 ${ev.usage.output_tokens} / 合计 ${ev.usage.total_tokens}(${ev.usage.usage_source})`);
  296. }
  297. break;
  298. }
  299. }
  300. function renderTodos(ui, todos) {
  301. breakSegment(ui);
  302. let card = ui.node.querySelector('.todos');
  303. if (!card) {
  304. card = document.createElement('div');
  305. card.className = 'card todos';
  306. ui.bubble.appendChild(card);
  307. }
  308. const mark = { completed: '[x]', in_progress: '[~]', pending: '[ ]' };
  309. card.innerHTML = '<div class="title">待办</div>' +
  310. todos.map((t) => `${mark[t.status] || '[ ]'} ${escapeHtml(t.content || '')}`).join('<br>');
  311. }
  312. function renderCitations(ui, items, modelOnly) {
  313. breakSegment(ui);
  314. const box = document.createElement('div');
  315. box.className = 'card cite';
  316. if (!items.length) {
  317. box.textContent = modelOnly ? '本回答未引用外部数据源' : '无来源';
  318. } else {
  319. box.innerHTML = '<div class="title">来源(' + items.length + ')</div>' +
  320. items.map((it, i) => {
  321. const label = escapeHtml(it.title || it.url || '未命名');
  322. const link = /^https?:\/\//i.test(it.url || '') ? ` <a href="${escapeAttr(it.url)}" target="_blank" rel="noreferrer">链接</a>` : '';
  323. const score = (it.score !== undefined) ? ` <span style="color:var(--fg-dim)">score=${it.score}</span>` : '';
  324. return `${i + 1}. ${label}${link}${score}`;
  325. }).join('<br>');
  326. }
  327. ui.bubble.appendChild(box);
  328. scrollDown();
  329. }
  330. function renderInterrupt(ui, ev) {
  331. const ta = ev.tool_approval;
  332. if (!ta) {
  333. addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入');
  334. return;
  335. }
  336. const card = addCardFor(ui, 'approval',
  337. `需确认:${ta.tool}`,
  338. `${ta.reason || ''}\n${ta.side_effect || ''}\n\n参数:${JSON.stringify(ta.args, null, 1)}`);
  339. const row = document.createElement('div');
  340. row.className = 'actions';
  341. const ok = document.createElement('button'); ok.className = 'ok'; ok.textContent = '批准';
  342. const no = document.createElement('button'); no.className = 'no'; no.textContent = '拒绝';
  343. const decide = async (extra) => {
  344. if (streaming || resuming || ui.node.parentNode !== log) return;
  345. ok.disabled = no.disabled = true;
  346. await resume(extra, ui);
  347. // Failed requests keep their original approval controls available for retry.
  348. if (pendingApproval && !ui.node.dataset.resolved) ok.disabled = no.disabled = false;
  349. };
  350. ok.onclick = () => decide({ approved: true });
  351. no.onclick = () => decide({ approved: false, status: 'cancelled' });
  352. row.appendChild(ok); row.appendChild(no);
  353. card.appendChild(row);
  354. }
  355. async function resume(extra, ui) {
  356. // 双重保险:按钮点击后已经被 row.remove() 摘掉,这里再挡一次重复调用。
  357. // 恢复请求重复发出会让图在同一 thread 上被 resume 两次,第二次必然失败。
  358. if (streaming || resuming) return;
  359. resuming = true;
  360. setStatus('恢复中...');
  361. try {
  362. await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
  363. } finally {
  364. resuming = false;
  365. }
  366. }
  367. function escapeHtml(s) {
  368. return String(s).replace(/[&<>"']/g, (c) =>
  369. ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  370. }
  371. function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '&#96;'); }
  372. // ---------- 工作台交互 ----------
  373. function updateControls() {
  374. const busy = streaming || loadingHistory;
  375. el('sendBtn').disabled = busy || pendingApproval || !el('input').value.trim();
  376. el('sendBtn').hidden = streaming;
  377. el('stopBtn').disabled = !streaming;
  378. el('stopBtn').hidden = !streaming;
  379. el('newBtn').disabled = busy;
  380. el('mode').disabled = busy || pendingApproval;
  381. el('attachBtn').disabled = busy || pendingApproval;
  382. el('removeFileBtn').disabled = busy;
  383. document.querySelectorAll('.session-button').forEach((button) => { button.disabled = busy; });
  384. }
  385. function resizeInput() {
  386. const input = el('input');
  387. input.style.height = 'auto';
  388. input.style.height = Math.min(input.scrollHeight, 160) + 'px';
  389. updateControls();
  390. }
  391. function clearAttachment() {
  392. el('file').value = '';
  393. el('attachment').hidden = true;
  394. el('fileName').textContent = '';
  395. }
  396. function toggleSidebar(open) {
  397. el('sidebar').classList.toggle('open', open);
  398. el('sidebarBackdrop').hidden = !open;
  399. el('sidebarToggle').setAttribute('aria-expanded', String(open));
  400. el('sidebarToggle').setAttribute('aria-label', open ? '收起会话列表' : '展开会话列表');
  401. }
  402. function renderSessions() {
  403. const list = el('sessionList');
  404. list.replaceChildren();
  405. if (!sessions.length) {
  406. const empty = document.createElement('p');
  407. empty.className = 'list-empty';
  408. empty.textContent = '还没有对话。开启新对话,记录你的每一个想法。';
  409. list.appendChild(empty);
  410. }
  411. for (const session of sessions) {
  412. const button = document.createElement('button');
  413. button.className = 'session-button' + (session.id === sessionId ? ' active' : '');
  414. button.setAttribute('aria-current', session.id === sessionId ? 'page' : 'false');
  415. button.title = session.title || '未命名对话';
  416. button.innerHTML = '<svg aria-hidden="true"><use href="#i-chat"/></svg><span></span>';
  417. button.querySelector('span').textContent = (session.pinned ? '⌁ ' : '') + button.title;
  418. button.onclick = () => openSession(session);
  419. list.appendChild(button);
  420. }
  421. updateControls();
  422. }
  423. async function loadSessions() {
  424. el('refreshBtn').disabled = true;
  425. try {
  426. const response = await fetch(`${API}/sessions?user_name=${encodeURIComponent(USER)}`);
  427. if (!response.ok) throw new Error('会话列表加载失败');
  428. const data = await response.json();
  429. sessions = data.items || [];
  430. renderSessions();
  431. } catch (error) {
  432. if (!sessions.length) {
  433. const message = document.createElement('p');
  434. message.className = 'list-empty';
  435. message.textContent = '暂时无法加载对话,请点击刷新重试。';
  436. el('sessionList').replaceChildren(message);
  437. }
  438. } finally { el('refreshBtn').disabled = false; }
  439. }
  440. async function openSession(session) {
  441. if (streaming || loadingHistory || sessionId === session.id) return;
  442. loadingHistory = true;
  443. updateControls();
  444. setStatus('正在加载对话', 'busy');
  445. try {
  446. const response = await fetch(`${API}/chat/history/${encodeURIComponent(session.id)}?user_name=${encodeURIComponent(USER)}&limit=500`);
  447. if (!response.ok) throw new Error('无法加载这段对话,请重试');
  448. const data = await response.json();
  449. sessionId = session.id;
  450. pendingApproval = false;
  451. el('sessionPill').textContent = '会话:' + session.id.slice(0, 8);
  452. el('chatTitle').textContent = session.title || '未命名对话';
  453. el('mode').value = session.mode === 'plan' ? 'plan' : 'full';
  454. el('input').value = '';
  455. clearAttachment();
  456. log.replaceChildren();
  457. el('welcome').hidden = !!data.items.length;
  458. for (const message of data.items) {
  459. if (message.msg_role === 'user') addUserBubble(message.content);
  460. else if (message.msg_role === 'assistant') {
  461. const ui = newAssistant();
  462. appendToken(ui, message.content || '');
  463. breakSegment(ui);
  464. if (Array.isArray(message.citations) && message.citations.length) renderCitations(ui, message.citations, false);
  465. }
  466. }
  467. renderSessions();
  468. toggleSidebar(false);
  469. setStatus('对话已加载', 'ready');
  470. try {
  471. const response = await fetch(`${API}/chat/interrupt?session_id=${encodeURIComponent(sessionId)}&user_name=${encodeURIComponent(USER)}`);
  472. if (!response.ok) throw new Error('确认状态读取失败');
  473. if (response.status !== 204) {
  474. const data = await response.json();
  475. for (const event of data.interrupts || []) handle(event, newAssistant(), []);
  476. }
  477. } catch (error) { setStatus('对话已加载,确认状态获取失败', 'error'); }
  478. } catch (error) {
  479. setStatus(error.message, 'error');
  480. } finally {
  481. loadingHistory = false;
  482. resizeInput();
  483. scrollDown();
  484. }
  485. }
  486. el('sendBtn').onclick = send;
  487. el('stopBtn').onclick = () => { if (controller) controller.abort(); };
  488. el('newBtn').onclick = () => {
  489. if (streaming || loadingHistory) return;
  490. sessionId = null;
  491. pendingApproval = false;
  492. el('sessionPill').textContent = '新会话';
  493. el('chatTitle').textContent = '新对话';
  494. el('input').value = '';
  495. clearAttachment();
  496. log.replaceChildren();
  497. el('welcome').hidden = false;
  498. setStatus('就绪', 'ready');
  499. renderSessions();
  500. resizeInput();
  501. toggleSidebar(false);
  502. el('input').focus();
  503. };
  504. el('input').addEventListener('keydown', (event) => {
  505. if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
  506. event.preventDefault();
  507. send();
  508. }
  509. });
  510. el('input').addEventListener('input', resizeInput);
  511. el('attachBtn').onclick = () => el('file').click();
  512. el('removeFileBtn').onclick = clearAttachment;
  513. el('file').addEventListener('change', () => {
  514. const file = el('file').files[0];
  515. el('attachment').hidden = !file;
  516. el('fileName').textContent = file?.name || '';
  517. });
  518. el('refreshBtn').onclick = loadSessions;
  519. el('sidebarToggle').onclick = () => toggleSidebar(!el('sidebar').classList.contains('open'));
  520. el('sidebarBackdrop').onclick = () => { toggleSidebar(false); el('sidebarToggle').focus(); };
  521. document.addEventListener('keydown', (event) => {
  522. if (event.key === 'Escape' && el('sidebar').classList.contains('open')) {
  523. toggleSidebar(false); el('sidebarToggle').focus();
  524. }
  525. });
  526. fetch(`${API}/model`).then((response) => {
  527. if (!response.ok) throw new Error('服务不可达');
  528. return response.json();
  529. }).then((data) => {
  530. el('modelName').textContent = data.model || 'Deep Agent';
  531. if (!streaming && !loadingHistory) setStatus('服务已就绪', 'ready');
  532. }).catch(() => {
  533. el('modelName').textContent = '暂未连接模型';
  534. if (!streaming && !loadingHistory) setStatus('服务不可达', 'error');
  535. });
  536. loadSessions();