@liguoshuai/pi-web-chat 2.16.5 → 2.17.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,7 +181,7 @@ Web 服务启动后,在浏览器中访问:
181
181
  | `PORT` | `3000` | Web 服务监听端口 |
182
182
  | `PI_BIN` | 自动探测(`~/.npm-global/bin/pi`、`/usr/local/bin/pi` 或 `PATH`) | 显式指定 pi 可执行文件绝对路径 |
183
183
  | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
184
- | `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的进程回收超时(`0` 为禁用回收) |
184
+ | `IDLE_TIMEOUT_MS` | `1800000` (30分钟) | 真正空闲(无连接+非流式)后的进程回收超时(`0` 为禁用回收) |
185
185
  | `MAX_AGENT_LIFETIME_MS` | `0` (无上限) | 单个 Agent 进程后台生存硬上限(`0` 为禁用) |
186
186
  | `EVENT_BUFFER_SIZE` | `5000` | 离线环形 Buffer 允许缓存的最大事件条数 |
187
187
  | `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.16.5",
3
+ "version": "2.17.8",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -42,13 +42,13 @@
42
42
  "dependencies": {
43
43
  "express": "^4.21.2",
44
44
  "ws": "^8.18.0",
45
- "@liguoshuai/pi-chat-protocol": "2.16.5",
46
- "@liguoshuai/pi-chat-server": "2.16.5"
45
+ "@liguoshuai/pi-chat-protocol": "2.17.8",
46
+ "@liguoshuai/pi-chat-server": "2.17.8"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/markdown.js && node --check public/app.js",
50
50
  "start": "node server.js",
51
51
  "dev": "node --watch server.js",
52
- "test": "node --test"
52
+ "test": "node --test --test-timeout=8000"
53
53
  }
54
54
  }
package/public/app.js CHANGED
@@ -81,6 +81,7 @@ const state = {
81
81
  attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
82
82
  turnStartedAt: null,
83
83
  streamingMsgDurationEl: null,
84
+ streamingWatchdog: null, // 5-min timeout for stuck streaming
84
85
  lastSessions: [],
85
86
  };
86
87
 
@@ -362,8 +363,10 @@ function startStreamingTimer() {
362
363
  if (state.turnStartedAt && state.streamingMsgDurationEl) {
363
364
  state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
364
365
  }
365
- const liveThinking = document.querySelectorAll(".thinking-duration.live");
366
- liveThinking.forEach((el) => {
366
+ // Single combined query instead of two separate querySelectorAll calls.
367
+ // Interval at 200ms (5 scans/sec) is still smooth for duration display.
368
+ const liveEls = document.querySelectorAll(".thinking-duration.live, .tool-duration.live");
369
+ liveEls.forEach((el) => {
367
370
  if (el._startedAt) {
368
371
  el.textContent = formatDuration(now - el._startedAt);
369
372
  el.style.display = "";
@@ -376,6 +379,20 @@ function startStreamingTimer() {
376
379
  el.style.display = "";
377
380
  }
378
381
  });
382
+ // Live update for the thinking placeholder spinner label before first delta arrives
383
+ const placeholder = document.querySelector(".thinking-placeholder");
384
+ if (placeholder && state.turnStartedAt) {
385
+ const elapsed = Math.max(0, now - state.turnStartedAt);
386
+ const labelEl = placeholder.querySelector(".thinking-label");
387
+ if (labelEl) {
388
+ const durStr = formatDuration(elapsed);
389
+ if (elapsed > 2500) {
390
+ labelEl.textContent = `正在深度推理中… (${durStr})`;
391
+ } else {
392
+ labelEl.textContent = `正在思考中… (${durStr})`;
393
+ }
394
+ }
395
+ }
379
396
  }, 100);
380
397
  }
381
398
 
@@ -386,6 +403,20 @@ function stopStreamingTimer() {
386
403
  }
387
404
  }
388
405
 
406
+ function updateUrlSession(sessionFile) {
407
+ if (!sessionFile) return;
408
+ try {
409
+ const params = new URLSearchParams(window.location.search);
410
+ params.set("session", sessionFile);
411
+ if (state.cwd && !params.has("cwd")) {
412
+ params.set("cwd", state.cwd);
413
+ }
414
+ const query = params.toString();
415
+ const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
416
+ window.history.replaceState({ session: sessionFile }, "", newUrl);
417
+ } catch {}
418
+ }
419
+
389
420
  function sameSession(a, b) {
390
421
  if (!a || !b) return false;
391
422
  if (a === b) return true;
@@ -531,7 +562,12 @@ function startNewSession() {
531
562
  state.activeToolCalls.clear();
532
563
  setComposerStreaming(false);
533
564
  try {
534
- window.history.replaceState({}, "", window.location.pathname);
565
+ const params = new URLSearchParams(window.location.search);
566
+ params.delete("session");
567
+ params.delete("file");
568
+ const query = params.toString();
569
+ const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
570
+ window.history.replaceState({}, "", newUrl);
535
571
  } catch {}
536
572
  $("#topSessionName").textContent = "新对话";
537
573
  updatePageTitle(null);
@@ -659,10 +695,7 @@ async function loadSession(file) {
659
695
  state.streamingMsg = null;
660
696
  state.activeToolCalls.clear();
661
697
  setComposerStreaming(false);
662
- try {
663
- const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
664
- window.history.replaceState({ session: file }, "", newUrl);
665
- } catch {}
698
+ updateUrlSession(file);
666
699
  // Mobile: close sidebar on selection
667
700
  if (window.innerWidth <= 768) {
668
701
  closeSidebar();
@@ -826,6 +859,7 @@ function clearChat() {
826
859
  chatInner.innerHTML = "";
827
860
  state.streamingMsg = null;
828
861
  state.streamingItems = [];
862
+ lastRenderedItemCount = 0;
829
863
  state.thinkingOpen = true;
830
864
  state.thinkingUserToggled = false;
831
865
  state.activeToolCalls.clear();
@@ -1336,6 +1370,79 @@ function updateToolBlockCopyBtn(tc, call) {
1336
1370
  tc.head._cmdToCopy = cmd;
1337
1371
  }
1338
1372
 
1373
+ function formatToolCommandText(name, args) {
1374
+ if (!args) return "";
1375
+ let obj = args;
1376
+ if (typeof args === "string") {
1377
+ try { obj = JSON.parse(args); } catch { return args; }
1378
+ }
1379
+ if (!obj || typeof obj !== "object") return String(args);
1380
+ if (name === "bash" && obj.command) {
1381
+ return `$ ${obj.command}`;
1382
+ }
1383
+ if (name === "read" && obj.path) {
1384
+ let s = `read ${obj.path}`;
1385
+ if (obj.offset != null) s += ` (offset: ${obj.offset})`;
1386
+ if (obj.limit != null) s += ` (limit: ${obj.limit})`;
1387
+ return s;
1388
+ }
1389
+ if (name === "write" && obj.path) {
1390
+ let s = `write ${obj.path}`;
1391
+ if (obj.content) {
1392
+ const preview = obj.content.length > 500 ? obj.content.slice(0, 500) + "\n... (truncated)" : obj.content;
1393
+ s += `\n\n--- 写入内容预览 ---\n${preview}`;
1394
+ }
1395
+ return s;
1396
+ }
1397
+ if (name === "edit" && obj.path) {
1398
+ return `edit ${obj.path}`;
1399
+ }
1400
+ if (name === "ls" && obj.path) {
1401
+ return `ls ${obj.path}`;
1402
+ }
1403
+ if (name === "grep") {
1404
+ return `grep "${obj.pattern || ""}" ${obj.path || ""}`;
1405
+ }
1406
+ if (name === "find") {
1407
+ return `find ${obj.path || "."} -name "${obj.pattern || ""}"`;
1408
+ }
1409
+ try {
1410
+ return JSON.stringify(obj, null, 2);
1411
+ } catch {
1412
+ return String(args);
1413
+ }
1414
+ }
1415
+
1416
+ function renderToolBlockContent(bodyEl, call, resultText, isRunning, isError) {
1417
+ if (!bodyEl) return;
1418
+ bodyEl.innerHTML = "";
1419
+ const cmdText = formatToolCommandText(call?.name, call?.arguments ?? call?.args);
1420
+
1421
+ // 1. 指令 / 参数区域 (有命令就立即提前显示,无需等待输出)
1422
+ if (cmdText) {
1423
+ const cmdSec = el("div", { class: "tool-section tool-cmd-section" }, [
1424
+ el("div", { class: "tool-section-header" }, [
1425
+ el("span", { class: "tool-section-title", text: "执行指令" }),
1426
+ ]),
1427
+ el("pre", { class: "tool-code-block", text: cmdText }),
1428
+ ]);
1429
+ bodyEl.appendChild(cmdSec);
1430
+ }
1431
+
1432
+ // 2. 输出区域 (执行中显示实时状态/输出,执行完显示最终结果)
1433
+ const outSec = el("div", { class: "tool-section tool-out-section" });
1434
+ const outHeader = el("div", { class: "tool-section-header" }, [
1435
+ el("span", { class: "tool-section-title", text: isRunning ? "实时状态" : "输出结果" }),
1436
+ isRunning ? el("span", { class: "tool-running-pulse", text: "正在执行…" }) : null,
1437
+ ]);
1438
+ outSec.appendChild(outHeader);
1439
+
1440
+ const displayOut = resultText || (isRunning ? "(命令正在执行中,请稍候…)" : "(无输出)");
1441
+ const outPre = el("pre", { class: "tool-output-block" + (isError ? " is-error" : ""), text: displayOut });
1442
+ outSec.appendChild(outPre);
1443
+ bodyEl.appendChild(outSec);
1444
+ }
1445
+
1339
1446
  function makeToolBlockFromCall(call, ts = null) {
1340
1447
  const block = el("div", { class: "tool-block" });
1341
1448
  const hasResult = Boolean(call.result);
@@ -1399,9 +1506,9 @@ function makeToolBlockFromCall(call, ts = null) {
1399
1506
  ]);
1400
1507
  head._cmdToCopy = cmdToCopy;
1401
1508
 
1402
- const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
1403
- const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
1509
+ const body = el("div", { class: "tool-body" });
1404
1510
  body.style.display = "none";
1511
+ renderToolBlockContent(body, call, resultText, !hasResult, isError);
1405
1512
 
1406
1513
  head.addEventListener("click", () => {
1407
1514
  body.style.display = body.style.display === "none" ? "block" : "none";
@@ -1415,7 +1522,7 @@ function makeToolBlockFromCall(call, ts = null) {
1415
1522
  block._durationEl = durationEl;
1416
1523
 
1417
1524
  if (!hasResult && call.id) {
1418
- state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
1525
+ state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now(), name: call.name, args: call.arguments });
1419
1526
  }
1420
1527
 
1421
1528
  return block;
@@ -1433,13 +1540,14 @@ function summaryArgs(name, args) {
1433
1540
  if (name === "write" && obj.path) return obj.path;
1434
1541
  if (name === "edit" && obj.path) return obj.path;
1435
1542
  if (name === "ls" && obj.path) return obj.path;
1436
- if (name === "grep") return obj.pattern || "";
1437
- if (name === "find") return obj.pattern || obj.path || "";
1543
+ if (name === "grep") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
1544
+ if (name === "find") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
1438
1545
  if (typeof obj === "object" && obj !== null) {
1439
1546
  const keys = Object.keys(obj);
1440
1547
  if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
1548
+ return JSON.stringify(obj);
1441
1549
  }
1442
- return "";
1550
+ return String(obj);
1443
1551
  } catch { return ""; }
1444
1552
  }
1445
1553
 
@@ -1476,6 +1584,7 @@ function getStreamingFullText() {
1476
1584
  function ensureStreamingMsg(ts = Date.now()) {
1477
1585
  if (state.streamingMsg) return state.streamingMsg;
1478
1586
  showEmptyState(false);
1587
+ lastRenderedItemCount = 0;
1479
1588
  state.turnStartedAt = ts || Date.now();
1480
1589
  startStreamingTimer();
1481
1590
  const timeStr = formatMessageTime(ts);
@@ -1533,26 +1642,47 @@ function refreshStreamingContentDebounced() {
1533
1642
  });
1534
1643
  }
1535
1644
 
1645
+ let lastRenderedItemCount = 0;
1536
1646
  function refreshStreamingContent() {
1537
1647
  const node = state.streamingMsg;
1538
1648
  if (!node) return;
1539
1649
  const content = node.querySelector(".content");
1540
- content.innerHTML = "";
1650
+ const items = state.streamingItems;
1541
1651
 
1542
- if (state.streamingItems.length === 0) {
1543
- if (state.streaming) {
1544
- content.appendChild(el("div", { class: "thinking-placeholder" }, [
1545
- el("span", { class: "thinking-spinner" }),
1546
- el("span", { class: "thinking-label", text: "正在思考中…" })
1547
- ]));
1652
+ if (items.length === 0) {
1653
+ if (content.children.length === 0 || !content.querySelector(".thinking-placeholder")) {
1654
+ content.innerHTML = "";
1655
+ if (state.streaming) {
1656
+ content.appendChild(el("div", { class: "thinking-placeholder" }, [
1657
+ el("span", { class: "thinking-spinner" }),
1658
+ el("span", { class: "thinking-label", text: "正在思考中…" })
1659
+ ]));
1660
+ }
1548
1661
  }
1549
1662
  scrollBottom();
1550
1663
  return;
1551
1664
  }
1552
1665
 
1553
- const lastIdx = state.streamingItems.length - 1;
1554
- for (let i = 0; i < state.streamingItems.length; i++) {
1555
- const item = state.streamingItems[i];
1666
+ // Fast path: if item count unchanged and last item is text, only update its innerHTML.
1667
+ // This avoids O(n²) rebuilding on every text delta for long messages with many tool calls.
1668
+ if (items.length === lastRenderedItemCount && items.length > 0) {
1669
+ const lastItem = items[items.length - 1];
1670
+ if (lastItem.type === "text") {
1671
+ const lastEl = content.lastElementChild;
1672
+ if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block")) {
1673
+ const showCursor = state.streaming;
1674
+ lastEl.innerHTML = renderMarkdown(lastItem.text) + (showCursor ? '<span class="typing-cursor"></span>' : "");
1675
+ scrollBottom();
1676
+ return;
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ // Full rebuild (new items added or structure changed)
1682
+ content.innerHTML = "";
1683
+ const lastIdx = items.length - 1;
1684
+ for (let i = 0; i < items.length; i++) {
1685
+ const item = items[i];
1556
1686
  const isLast = (i === lastIdx);
1557
1687
  if (item.type === "thinking") {
1558
1688
  const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
@@ -1564,11 +1694,14 @@ function refreshStreamingContent() {
1564
1694
  content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
1565
1695
  }
1566
1696
  }
1697
+ lastRenderedItemCount = items.length;
1567
1698
  scrollBottom();
1568
1699
  }
1569
1700
 
1570
1701
  function finalizeStreamingMsg() {
1571
1702
  state.streaming = false;
1703
+ if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
1704
+ lastRenderedItemCount = 0;
1572
1705
  stopStreamingTimer();
1573
1706
  if (state.streamingMsg) {
1574
1707
  for (const item of state.streamingItems) {
@@ -1841,15 +1974,13 @@ function sendWs(obj) {
1841
1974
  function handlePiMessage(obj) {
1842
1975
  // Automatically bind to the session file as soon as pi allocates it on disk
1843
1976
  const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
1844
- if (sessionFile && !sameSession(sessionFile, state.currentSessionFile)) {
1977
+ if (sessionFile) {
1978
+ const isDifferent = !sameSession(sessionFile, state.currentSessionFile);
1845
1979
  state.currentSessionFile = sessionFile;
1846
- try {
1847
- const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
1848
- window.history.replaceState({ session: sessionFile }, "", newUrl);
1849
- } catch {}
1850
- // Do not set baseName(sessionFile) as title — it's a session ID, not a user-friendly summary.
1851
- // The proper title will be set by syncSessionHistory (via firstUser) or by the user prompt.
1852
- refreshSessions();
1980
+ if (state.streaming || isDifferent) {
1981
+ updateUrlSession(sessionFile);
1982
+ refreshSessions();
1983
+ }
1853
1984
  }
1854
1985
 
1855
1986
  // Backfill markers emitted by the server when it replays buffered events
@@ -2104,27 +2235,36 @@ function handlePiMessage(obj) {
2104
2235
  activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2105
2236
  }
2106
2237
  ensureStreamingMsg();
2107
- const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
2108
- // args may be incomplete until toolcall_end; we fill what we have now
2109
- // and patch the head display on toolcall_end.
2110
- ensureToolBlock(call.id, call.name, call.arguments, Date.now());
2238
+ const callId = ev.toolCall?.id || ev.id || obj.toolCallId;
2239
+ const callName = ev.toolCall?.name || ev.toolName || obj.toolName;
2240
+ const callArgs = ev.toolCall?.arguments || obj.args;
2241
+ if (callId) {
2242
+ ensureToolBlock(callId, callName, callArgs, Date.now());
2243
+ }
2111
2244
  } else if (ev.type === "toolcall_delta") {
2112
- // Streaming function-call argument JSON. We don't render it live
2113
- // (JSON fragments are not useful UX), but make sure the tool block
2114
- // exists so toolcall_end has somewhere to write into.
2115
- const id = obj.toolCallId || ev.id;
2116
- ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args, Date.now());
2245
+ // Streaming function-call argument JSON. We do not create dummy tool blocks
2246
+ // with undefined ID here to prevent empty tasks from appearing.
2117
2247
  } else if (ev.type === "toolcall_end") {
2118
- // Authoritative final toolCall object (with full arguments). Patch
2119
- // the block head so the displayed args are the final ones, not the
2120
- // partial ones we got at toolcall_start.
2121
- const call = ev.toolCall || { id: obj.toolCallId, name: obj.toolName, arguments: obj.args };
2122
- const id = call.id || obj.toolCallId;
2123
- const tc = state.activeToolCalls.get(id);
2124
- if (tc) {
2125
- const argsEl = tc.head.querySelector(".args");
2126
- if (argsEl) argsEl.textContent = summaryArgs(call.name, call.arguments);
2127
- updateToolBlockCopyBtn(tc, call);
2248
+ // Authoritative final toolCall object (with full arguments).
2249
+ // Update the block head so the displayed args and copy button are final,
2250
+ // and pre-render the command in the block body immediately!
2251
+ const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName || ev.toolName, arguments: obj.args };
2252
+ const id = call.id || obj.toolCallId || ev.id;
2253
+ if (id) {
2254
+ let tc = state.activeToolCalls.get(id);
2255
+ if (!tc) {
2256
+ tc = ensureToolBlock(id, call.name, call.arguments, Date.now());
2257
+ }
2258
+ if (tc) {
2259
+ tc.name = call.name || tc.name;
2260
+ tc.args = call.arguments || tc.args;
2261
+ const nameEl = tc.head.querySelector(".name");
2262
+ if (nameEl && tc.name) nameEl.textContent = tc.name;
2263
+ const argsEl = tc.head.querySelector(".args");
2264
+ if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
2265
+ updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
2266
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
2267
+ }
2128
2268
  }
2129
2269
  }
2130
2270
  break;
@@ -2140,18 +2280,26 @@ function handlePiMessage(obj) {
2140
2280
  const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
2141
2281
  if (tc) {
2142
2282
  tc.startedAt = Date.now();
2283
+ tc.name = obj.toolName || tc.name;
2284
+ tc.args = obj.args || tc.args;
2143
2285
  if (tc.durationEl) {
2144
2286
  tc.durationEl.className = "tool-duration live";
2145
2287
  tc.durationEl._startedAt = tc.startedAt;
2146
2288
  tc.durationEl.textContent = "0.0s";
2147
2289
  tc.durationEl.style.display = "";
2148
2290
  }
2291
+ const nameEl = tc.head.querySelector(".name");
2292
+ if (nameEl && tc.name) nameEl.textContent = tc.name;
2293
+ const argsEl = tc.head.querySelector(".args");
2294
+ if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
2149
2295
  const stateEl = tc.head.querySelector(".state");
2150
2296
  if (stateEl) {
2151
2297
  stateEl.className = "state running";
2152
2298
  stateEl.textContent = "执行中…";
2153
2299
  }
2154
- updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
2300
+ updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
2301
+ // Immediately render the command code block in body so user sees it right away!
2302
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
2155
2303
  }
2156
2304
  break;
2157
2305
  }
@@ -2159,7 +2307,12 @@ function handlePiMessage(obj) {
2159
2307
  const tc = state.activeToolCalls.get(obj.toolCallId);
2160
2308
  if (tc) {
2161
2309
  const text = extractContentText(obj.partialResult?.content);
2162
- tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
2310
+ const outPre = tc.body.querySelector(".tool-output-block");
2311
+ if (outPre) {
2312
+ outPre.textContent = text || "(执行中…)";
2313
+ } else {
2314
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, true, false);
2315
+ }
2163
2316
  }
2164
2317
  break;
2165
2318
  }
@@ -2167,7 +2320,6 @@ function handlePiMessage(obj) {
2167
2320
  const tc = state.activeToolCalls.get(obj.toolCallId);
2168
2321
  if (tc) {
2169
2322
  const text = extractContentText(obj.result?.content);
2170
- tc.body.innerHTML = escapeHtml(text) || "(无输出)";
2171
2323
  tc.endedAt = Date.now();
2172
2324
  const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
2173
2325
  tc.durationMs = dur;
@@ -2182,9 +2334,8 @@ function handlePiMessage(obj) {
2182
2334
  stateEl.textContent = obj.isError ? "错误" : "完成";
2183
2335
  stateEl.className = "state" + (obj.isError ? " error" : "");
2184
2336
  }
2337
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, false, Boolean(obj.isError));
2185
2338
  // Remove from activeToolCalls so stale entries don't accumulate.
2186
- // Previously only cleared wholesale in finalizeStreamingMsg/clearChat/etc,
2187
- // which meant a missed tool_execution_end left tools stuck in "执行中…" forever.
2188
2339
  state.activeToolCalls.delete(obj.toolCallId);
2189
2340
  }
2190
2341
  break;
@@ -2257,9 +2408,17 @@ function handleExtensionUiRequest(req) {
2257
2408
  }
2258
2409
 
2259
2410
  function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
2260
- if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
2411
+ if (!toolCallId) return null;
2412
+ if (state.activeToolCalls.has(toolCallId)) {
2413
+ const existing = state.activeToolCalls.get(toolCallId);
2414
+ if (name && !existing.name) existing.name = name;
2415
+ if (args && (!existing.args || existing.args === "{}")) existing.args = args;
2416
+ return existing;
2417
+ }
2261
2418
  const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args, startedAt: ts }, ts);
2262
- const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts };
2419
+ const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts, name, args };
2420
+ entry.name = name;
2421
+ entry.args = args;
2263
2422
  state.activeToolCalls.set(toolCallId, entry);
2264
2423
  state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
2265
2424
  refreshStreamingContentDebounced();
@@ -2981,8 +3140,23 @@ function submitPrompt() {
2981
3140
  ensureStreamingMsg(now);
2982
3141
  refreshStreamingContent();
2983
3142
 
3143
+ // Streaming watchdog: if no agent_end in 5 minutes, auto-finalize
3144
+ if (state.streamingWatchdog) clearTimeout(state.streamingWatchdog);
3145
+ state.streamingWatchdog = setTimeout(() => {
3146
+ if (state.streaming) {
3147
+ toast("生成超时(5 分钟无响应),已自动结束。", "warn");
3148
+ finalizeStreamingMsg();
3149
+ }
3150
+ }, 5 * 60 * 1000);
3151
+
3152
+ // If sessionFile is already known, immediately anchor it to browser URL so page reloads don't lose the active conversation
3153
+ if (state.currentSessionFile) {
3154
+ updateUrlSession(state.currentSessionFile);
3155
+ }
3156
+
2984
3157
  // Set session name from the first prompt of a brand-new session.
2985
- if (state.currentSessionFile == null && text) {
3158
+ const isFirstPrompt = ($("#chat-inner")?.querySelectorAll(".msg").length || 0) <= 2;
3159
+ if (isFirstPrompt && text) {
2986
3160
  const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
2987
3161
  $("#topSessionName").textContent = promptTitle;
2988
3162
  updatePageTitle(promptTitle);
@@ -3013,6 +3187,7 @@ function submitPrompt() {
3013
3187
  state.aborting = false;
3014
3188
  setComposerStreaming(false);
3015
3189
  stopStreamingTimer();
3190
+ if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
3016
3191
  state.turnStartedAt = null;
3017
3192
  state.streamingMsgDurationEl = null;
3018
3193
  if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
@@ -3454,12 +3629,16 @@ async function init() {
3454
3629
  }
3455
3630
  });
3456
3631
 
3457
- // Background session status polling
3632
+ // Background session status polling — refresh every 15s when page is visible.
3633
+ // Skip when sidebar is collapsed on mobile to avoid unnecessary API calls.
3458
3634
  setInterval(() => {
3459
3635
  if (document.visibilityState === "visible") {
3460
- refreshSessions();
3636
+ const isSidebarVisible = window.innerWidth > 768 || $(".app").classList.contains("sidebar-open");
3637
+ if (isSidebarVisible || state.streaming) {
3638
+ refreshSessions();
3639
+ }
3461
3640
  }
3462
- }, 10000);
3641
+ }, 15000);
3463
3642
 
3464
3643
  // Global event delegation for code block copy buttons
3465
3644
  document.addEventListener("click", async (e) => {
package/public/index.html CHANGED
@@ -119,7 +119,7 @@
119
119
  <div class="composer">
120
120
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
121
121
  <div class="composer-inner" id="composerInner">
122
- <input type="file" id="imageFileInput" class="sr-only-file-input" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.heic,.heif,.ico,.avif,text/*,.txt,.md,.json,.js,.ts,.py,.sh,.html,.css,.yaml,.yml,.toml,.xml,.sql,.log,.csv" multiple>
122
+ <input type="file" id="imageFileInput" class="sr-only-file-input" multiple>
123
123
  <label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
124
124
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
125
125
  <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
package/public/style.css CHANGED
@@ -804,10 +804,48 @@ body {
804
804
  .tool-head .state.error { color: var(--danger); background: #3a1a1a; }
805
805
  .tool-body {
806
806
  padding: 12px 14px; border-top: 1px solid var(--tool-border);
807
- max-height: 280px; overflow-y: auto;
808
- white-space: pre-wrap; word-break: break-word;
807
+ max-height: 380px; overflow-y: auto;
809
808
  font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; color: #cfcfcf;
810
809
  }
810
+ .tool-section {
811
+ margin-bottom: 12px;
812
+ }
813
+ .tool-section:last-child {
814
+ margin-bottom: 0;
815
+ }
816
+ .tool-section-header {
817
+ display: flex; align-items: center; justify-content: space-between;
818
+ margin-bottom: 6px;
819
+ }
820
+ .tool-section-title {
821
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
822
+ letter-spacing: 0.5px; color: var(--text-muted);
823
+ }
824
+ .tool-running-pulse {
825
+ font-size: 11px; color: var(--accent);
826
+ animation: pulse-op 1.4s infinite ease-in-out;
827
+ }
828
+ @keyframes pulse-op {
829
+ 0%, 100% { opacity: 0.5; }
830
+ 50% { opacity: 1; }
831
+ }
832
+ .tool-code-block, .tool-output-block {
833
+ margin: 0; padding: 8px 12px;
834
+ background: rgba(0, 0, 0, 0.35);
835
+ border: 1px solid rgba(255, 255, 255, 0.08);
836
+ border-radius: 6px;
837
+ white-space: pre-wrap; word-break: break-word;
838
+ font-family: inherit; font-size: 12px; line-height: 1.45;
839
+ color: #e5e5e5;
840
+ }
841
+ .tool-code-block {
842
+ color: #93c5fd;
843
+ border-left: 2px solid var(--accent);
844
+ }
845
+ .tool-output-block.is-error {
846
+ color: var(--danger);
847
+ border-left: 2px solid var(--danger);
848
+ }
811
849
 
812
850
  /* Thinking blocks */
813
851
  .thinking-block {