@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 +1 -1
- package/package.json +4 -4
- package/public/app.js +240 -61
- package/public/index.html +1 -1
- package/public/style.css +40 -2
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` | `
|
|
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.
|
|
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.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1650
|
+
const items = state.streamingItems;
|
|
1541
1651
|
|
|
1542
|
-
if (
|
|
1543
|
-
if (
|
|
1544
|
-
content.
|
|
1545
|
-
|
|
1546
|
-
el("
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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
|
|
1977
|
+
if (sessionFile) {
|
|
1978
|
+
const isDifferent = !sameSession(sessionFile, state.currentSessionFile);
|
|
1845
1979
|
state.currentSessionFile = sessionFile;
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
}
|
|
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
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
|
2113
|
-
//
|
|
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).
|
|
2119
|
-
// the block head so the displayed args are
|
|
2120
|
-
//
|
|
2121
|
-
const call = ev.toolCall || { id: obj.toolCallId, name: obj.toolName, arguments: obj.args };
|
|
2122
|
-
const id = call.id || obj.toolCallId;
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
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:
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3636
|
+
const isSidebarVisible = window.innerWidth > 768 || $(".app").classList.contains("sidebar-open");
|
|
3637
|
+
if (isSidebarVisible || state.streaming) {
|
|
3638
|
+
refreshSessions();
|
|
3639
|
+
}
|
|
3461
3640
|
}
|
|
3462
|
-
},
|
|
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"
|
|
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:
|
|
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 {
|