@liguoshuai/pi-web-chat 2.16.5 → 2.18.13
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 +3 -3
- package/package.json +5 -5
- package/public/app.js +241 -68
- package/public/index.html +1 -1
- package/public/style.css +40 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@liguoshuai/pi-web-chat)
|
|
4
4
|
[](LICENSE)
|
|
5
|
-
[](https://nodejs.org/)
|
|
6
6
|
|
|
7
7
|
一个 [pi](https://pi.dev) 编程代理的 Web 界面,风格参考 ChatGPT / Gemini ——
|
|
8
8
|
左侧历史会话侧边栏 + 右侧对话区 + 底部输入框。底层通过 pi 的 **RPC 模式**
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
## 🚀 快速开始
|
|
36
36
|
|
|
37
37
|
### 1. 环境准备
|
|
38
|
-
- **Node.js**: `>=
|
|
38
|
+
- **Node.js**: `>= 20.0.0`
|
|
39
39
|
- **操作系统**: Linux / macOS / WSL (Windows)
|
|
40
40
|
|
|
41
41
|
---
|
|
@@ -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.18.13",
|
|
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",
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
|
-
"node": ">=
|
|
40
|
+
"node": ">=20"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"express": "^4.21.2",
|
|
44
44
|
"ws": "^8.18.0",
|
|
45
|
-
"@liguoshuai/pi-chat-
|
|
46
|
-
"@liguoshuai/pi-chat-
|
|
45
|
+
"@liguoshuai/pi-chat-server": "2.18.13",
|
|
46
|
+
"@liguoshuai/pi-chat-protocol": "2.18.13"
|
|
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,21 +363,31 @@ 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 = "";
|
|
370
373
|
}
|
|
371
374
|
});
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
375
|
+
|
|
376
|
+
// Live update for the thinking placeholder spinner label before first delta arrives
|
|
377
|
+
const placeholder = document.querySelector(".thinking-placeholder");
|
|
378
|
+
if (placeholder && state.turnStartedAt) {
|
|
379
|
+
const elapsed = Math.max(0, now - state.turnStartedAt);
|
|
380
|
+
const labelEl = placeholder.querySelector(".thinking-label");
|
|
381
|
+
if (labelEl) {
|
|
382
|
+
const durStr = formatDuration(elapsed);
|
|
383
|
+
if (elapsed > 2500) {
|
|
384
|
+
labelEl.textContent = `正在深度推理中… (${durStr})`;
|
|
385
|
+
} else {
|
|
386
|
+
labelEl.textContent = `正在思考中… (${durStr})`;
|
|
387
|
+
}
|
|
377
388
|
}
|
|
378
|
-
}
|
|
379
|
-
},
|
|
389
|
+
}
|
|
390
|
+
}, 200);
|
|
380
391
|
}
|
|
381
392
|
|
|
382
393
|
function stopStreamingTimer() {
|
|
@@ -386,6 +397,20 @@ function stopStreamingTimer() {
|
|
|
386
397
|
}
|
|
387
398
|
}
|
|
388
399
|
|
|
400
|
+
function updateUrlSession(sessionFile) {
|
|
401
|
+
if (!sessionFile) return;
|
|
402
|
+
try {
|
|
403
|
+
const params = new URLSearchParams(window.location.search);
|
|
404
|
+
params.set("session", sessionFile);
|
|
405
|
+
if (state.cwd && !params.has("cwd")) {
|
|
406
|
+
params.set("cwd", state.cwd);
|
|
407
|
+
}
|
|
408
|
+
const query = params.toString();
|
|
409
|
+
const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
|
|
410
|
+
window.history.replaceState({ session: sessionFile }, "", newUrl);
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
|
|
389
414
|
function sameSession(a, b) {
|
|
390
415
|
if (!a || !b) return false;
|
|
391
416
|
if (a === b) return true;
|
|
@@ -531,7 +556,12 @@ function startNewSession() {
|
|
|
531
556
|
state.activeToolCalls.clear();
|
|
532
557
|
setComposerStreaming(false);
|
|
533
558
|
try {
|
|
534
|
-
|
|
559
|
+
const params = new URLSearchParams(window.location.search);
|
|
560
|
+
params.delete("session");
|
|
561
|
+
params.delete("file");
|
|
562
|
+
const query = params.toString();
|
|
563
|
+
const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
|
|
564
|
+
window.history.replaceState({}, "", newUrl);
|
|
535
565
|
} catch {}
|
|
536
566
|
$("#topSessionName").textContent = "新对话";
|
|
537
567
|
updatePageTitle(null);
|
|
@@ -659,10 +689,7 @@ async function loadSession(file) {
|
|
|
659
689
|
state.streamingMsg = null;
|
|
660
690
|
state.activeToolCalls.clear();
|
|
661
691
|
setComposerStreaming(false);
|
|
662
|
-
|
|
663
|
-
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
|
|
664
|
-
window.history.replaceState({ session: file }, "", newUrl);
|
|
665
|
-
} catch {}
|
|
692
|
+
updateUrlSession(file);
|
|
666
693
|
// Mobile: close sidebar on selection
|
|
667
694
|
if (window.innerWidth <= 768) {
|
|
668
695
|
closeSidebar();
|
|
@@ -826,6 +853,7 @@ function clearChat() {
|
|
|
826
853
|
chatInner.innerHTML = "";
|
|
827
854
|
state.streamingMsg = null;
|
|
828
855
|
state.streamingItems = [];
|
|
856
|
+
lastRenderedItemCount = 0;
|
|
829
857
|
state.thinkingOpen = true;
|
|
830
858
|
state.thinkingUserToggled = false;
|
|
831
859
|
state.activeToolCalls.clear();
|
|
@@ -1336,6 +1364,79 @@ function updateToolBlockCopyBtn(tc, call) {
|
|
|
1336
1364
|
tc.head._cmdToCopy = cmd;
|
|
1337
1365
|
}
|
|
1338
1366
|
|
|
1367
|
+
function formatToolCommandText(name, args) {
|
|
1368
|
+
if (!args) return "";
|
|
1369
|
+
let obj = args;
|
|
1370
|
+
if (typeof args === "string") {
|
|
1371
|
+
try { obj = JSON.parse(args); } catch { return args; }
|
|
1372
|
+
}
|
|
1373
|
+
if (!obj || typeof obj !== "object") return String(args);
|
|
1374
|
+
if (name === "bash" && obj.command) {
|
|
1375
|
+
return `$ ${obj.command}`;
|
|
1376
|
+
}
|
|
1377
|
+
if (name === "read" && obj.path) {
|
|
1378
|
+
let s = `read ${obj.path}`;
|
|
1379
|
+
if (obj.offset != null) s += ` (offset: ${obj.offset})`;
|
|
1380
|
+
if (obj.limit != null) s += ` (limit: ${obj.limit})`;
|
|
1381
|
+
return s;
|
|
1382
|
+
}
|
|
1383
|
+
if (name === "write" && obj.path) {
|
|
1384
|
+
let s = `write ${obj.path}`;
|
|
1385
|
+
if (obj.content) {
|
|
1386
|
+
const preview = obj.content.length > 500 ? obj.content.slice(0, 500) + "\n... (truncated)" : obj.content;
|
|
1387
|
+
s += `\n\n--- 写入内容预览 ---\n${preview}`;
|
|
1388
|
+
}
|
|
1389
|
+
return s;
|
|
1390
|
+
}
|
|
1391
|
+
if (name === "edit" && obj.path) {
|
|
1392
|
+
return `edit ${obj.path}`;
|
|
1393
|
+
}
|
|
1394
|
+
if (name === "ls" && obj.path) {
|
|
1395
|
+
return `ls ${obj.path}`;
|
|
1396
|
+
}
|
|
1397
|
+
if (name === "grep") {
|
|
1398
|
+
return `grep "${obj.pattern || ""}" ${obj.path || ""}`;
|
|
1399
|
+
}
|
|
1400
|
+
if (name === "find") {
|
|
1401
|
+
return `find ${obj.path || "."} -name "${obj.pattern || ""}"`;
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
return JSON.stringify(obj, null, 2);
|
|
1405
|
+
} catch {
|
|
1406
|
+
return String(args);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function renderToolBlockContent(bodyEl, call, resultText, isRunning, isError) {
|
|
1411
|
+
if (!bodyEl) return;
|
|
1412
|
+
bodyEl.innerHTML = "";
|
|
1413
|
+
const cmdText = formatToolCommandText(call?.name, call?.arguments ?? call?.args);
|
|
1414
|
+
|
|
1415
|
+
// 1. 指令 / 参数区域 (有命令就立即提前显示,无需等待输出)
|
|
1416
|
+
if (cmdText) {
|
|
1417
|
+
const cmdSec = el("div", { class: "tool-section tool-cmd-section" }, [
|
|
1418
|
+
el("div", { class: "tool-section-header" }, [
|
|
1419
|
+
el("span", { class: "tool-section-title", text: "执行指令" }),
|
|
1420
|
+
]),
|
|
1421
|
+
el("pre", { class: "tool-code-block", text: cmdText }),
|
|
1422
|
+
]);
|
|
1423
|
+
bodyEl.appendChild(cmdSec);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// 2. 输出区域 (执行中显示实时状态/输出,执行完显示最终结果)
|
|
1427
|
+
const outSec = el("div", { class: "tool-section tool-out-section" });
|
|
1428
|
+
const outHeader = el("div", { class: "tool-section-header" }, [
|
|
1429
|
+
el("span", { class: "tool-section-title", text: isRunning ? "实时状态" : "输出结果" }),
|
|
1430
|
+
isRunning ? el("span", { class: "tool-running-pulse", text: "正在执行…" }) : null,
|
|
1431
|
+
]);
|
|
1432
|
+
outSec.appendChild(outHeader);
|
|
1433
|
+
|
|
1434
|
+
const displayOut = resultText || (isRunning ? "(命令正在执行中,请稍候…)" : "(无输出)");
|
|
1435
|
+
const outPre = el("pre", { class: "tool-output-block" + (isError ? " is-error" : ""), text: displayOut });
|
|
1436
|
+
outSec.appendChild(outPre);
|
|
1437
|
+
bodyEl.appendChild(outSec);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1339
1440
|
function makeToolBlockFromCall(call, ts = null) {
|
|
1340
1441
|
const block = el("div", { class: "tool-block" });
|
|
1341
1442
|
const hasResult = Boolean(call.result);
|
|
@@ -1399,9 +1500,9 @@ function makeToolBlockFromCall(call, ts = null) {
|
|
|
1399
1500
|
]);
|
|
1400
1501
|
head._cmdToCopy = cmdToCopy;
|
|
1401
1502
|
|
|
1402
|
-
const
|
|
1403
|
-
const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
|
|
1503
|
+
const body = el("div", { class: "tool-body" });
|
|
1404
1504
|
body.style.display = "none";
|
|
1505
|
+
renderToolBlockContent(body, call, resultText, !hasResult, isError);
|
|
1405
1506
|
|
|
1406
1507
|
head.addEventListener("click", () => {
|
|
1407
1508
|
body.style.display = body.style.display === "none" ? "block" : "none";
|
|
@@ -1415,7 +1516,7 @@ function makeToolBlockFromCall(call, ts = null) {
|
|
|
1415
1516
|
block._durationEl = durationEl;
|
|
1416
1517
|
|
|
1417
1518
|
if (!hasResult && call.id) {
|
|
1418
|
-
state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
|
|
1519
|
+
state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now(), name: call.name, args: call.arguments });
|
|
1419
1520
|
}
|
|
1420
1521
|
|
|
1421
1522
|
return block;
|
|
@@ -1433,13 +1534,14 @@ function summaryArgs(name, args) {
|
|
|
1433
1534
|
if (name === "write" && obj.path) return obj.path;
|
|
1434
1535
|
if (name === "edit" && obj.path) return obj.path;
|
|
1435
1536
|
if (name === "ls" && obj.path) return obj.path;
|
|
1436
|
-
if (name === "grep") return obj.pattern || "";
|
|
1437
|
-
if (name === "find") return obj.pattern
|
|
1537
|
+
if (name === "grep") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
|
|
1538
|
+
if (name === "find") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
|
|
1438
1539
|
if (typeof obj === "object" && obj !== null) {
|
|
1439
1540
|
const keys = Object.keys(obj);
|
|
1440
1541
|
if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
|
|
1542
|
+
return JSON.stringify(obj);
|
|
1441
1543
|
}
|
|
1442
|
-
return
|
|
1544
|
+
return String(obj);
|
|
1443
1545
|
} catch { return ""; }
|
|
1444
1546
|
}
|
|
1445
1547
|
|
|
@@ -1476,6 +1578,7 @@ function getStreamingFullText() {
|
|
|
1476
1578
|
function ensureStreamingMsg(ts = Date.now()) {
|
|
1477
1579
|
if (state.streamingMsg) return state.streamingMsg;
|
|
1478
1580
|
showEmptyState(false);
|
|
1581
|
+
lastRenderedItemCount = 0;
|
|
1479
1582
|
state.turnStartedAt = ts || Date.now();
|
|
1480
1583
|
startStreamingTimer();
|
|
1481
1584
|
const timeStr = formatMessageTime(ts);
|
|
@@ -1533,26 +1636,47 @@ function refreshStreamingContentDebounced() {
|
|
|
1533
1636
|
});
|
|
1534
1637
|
}
|
|
1535
1638
|
|
|
1639
|
+
let lastRenderedItemCount = 0;
|
|
1536
1640
|
function refreshStreamingContent() {
|
|
1537
1641
|
const node = state.streamingMsg;
|
|
1538
1642
|
if (!node) return;
|
|
1539
1643
|
const content = node.querySelector(".content");
|
|
1540
|
-
|
|
1644
|
+
const items = state.streamingItems;
|
|
1541
1645
|
|
|
1542
|
-
if (
|
|
1543
|
-
if (
|
|
1544
|
-
content.
|
|
1545
|
-
|
|
1546
|
-
el("
|
|
1547
|
-
|
|
1646
|
+
if (items.length === 0) {
|
|
1647
|
+
if (content.children.length === 0 || !content.querySelector(".thinking-placeholder")) {
|
|
1648
|
+
content.innerHTML = "";
|
|
1649
|
+
if (state.streaming) {
|
|
1650
|
+
content.appendChild(el("div", { class: "thinking-placeholder" }, [
|
|
1651
|
+
el("span", { class: "thinking-spinner" }),
|
|
1652
|
+
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
1653
|
+
]));
|
|
1654
|
+
}
|
|
1548
1655
|
}
|
|
1549
1656
|
scrollBottom();
|
|
1550
1657
|
return;
|
|
1551
1658
|
}
|
|
1552
1659
|
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1660
|
+
// Fast path: if item count unchanged and last item is text, only update its innerHTML.
|
|
1661
|
+
// This avoids O(n²) rebuilding on every text delta for long messages with many tool calls.
|
|
1662
|
+
if (items.length === lastRenderedItemCount && items.length > 0) {
|
|
1663
|
+
const lastItem = items[items.length - 1];
|
|
1664
|
+
if (lastItem.type === "text") {
|
|
1665
|
+
const lastEl = content.lastElementChild;
|
|
1666
|
+
if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block")) {
|
|
1667
|
+
const showCursor = state.streaming;
|
|
1668
|
+
lastEl.innerHTML = renderMarkdown(lastItem.text) + (showCursor ? '<span class="typing-cursor"></span>' : "");
|
|
1669
|
+
scrollBottom();
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// Full rebuild (new items added or structure changed)
|
|
1676
|
+
content.innerHTML = "";
|
|
1677
|
+
const lastIdx = items.length - 1;
|
|
1678
|
+
for (let i = 0; i < items.length; i++) {
|
|
1679
|
+
const item = items[i];
|
|
1556
1680
|
const isLast = (i === lastIdx);
|
|
1557
1681
|
if (item.type === "thinking") {
|
|
1558
1682
|
const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
|
|
@@ -1564,11 +1688,14 @@ function refreshStreamingContent() {
|
|
|
1564
1688
|
content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
|
|
1565
1689
|
}
|
|
1566
1690
|
}
|
|
1691
|
+
lastRenderedItemCount = items.length;
|
|
1567
1692
|
scrollBottom();
|
|
1568
1693
|
}
|
|
1569
1694
|
|
|
1570
1695
|
function finalizeStreamingMsg() {
|
|
1571
1696
|
state.streaming = false;
|
|
1697
|
+
if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
|
|
1698
|
+
lastRenderedItemCount = 0;
|
|
1572
1699
|
stopStreamingTimer();
|
|
1573
1700
|
if (state.streamingMsg) {
|
|
1574
1701
|
for (const item of state.streamingItems) {
|
|
@@ -1841,15 +1968,13 @@ function sendWs(obj) {
|
|
|
1841
1968
|
function handlePiMessage(obj) {
|
|
1842
1969
|
// Automatically bind to the session file as soon as pi allocates it on disk
|
|
1843
1970
|
const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
|
|
1844
|
-
if (sessionFile
|
|
1971
|
+
if (sessionFile) {
|
|
1972
|
+
const isDifferent = !sameSession(sessionFile, state.currentSessionFile);
|
|
1845
1973
|
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();
|
|
1974
|
+
if (state.streaming || isDifferent) {
|
|
1975
|
+
updateUrlSession(sessionFile);
|
|
1976
|
+
refreshSessions();
|
|
1977
|
+
}
|
|
1853
1978
|
}
|
|
1854
1979
|
|
|
1855
1980
|
// Backfill markers emitted by the server when it replays buffered events
|
|
@@ -2104,27 +2229,36 @@ function handlePiMessage(obj) {
|
|
|
2104
2229
|
activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
|
|
2105
2230
|
}
|
|
2106
2231
|
ensureStreamingMsg();
|
|
2107
|
-
const
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2232
|
+
const callId = ev.toolCall?.id || ev.id || obj.toolCallId;
|
|
2233
|
+
const callName = ev.toolCall?.name || ev.toolName || obj.toolName;
|
|
2234
|
+
const callArgs = ev.toolCall?.arguments || obj.args;
|
|
2235
|
+
if (callId) {
|
|
2236
|
+
ensureToolBlock(callId, callName, callArgs, Date.now());
|
|
2237
|
+
}
|
|
2111
2238
|
} 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());
|
|
2239
|
+
// Streaming function-call argument JSON. We do not create dummy tool blocks
|
|
2240
|
+
// with undefined ID here to prevent empty tasks from appearing.
|
|
2117
2241
|
} 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
|
-
|
|
2242
|
+
// Authoritative final toolCall object (with full arguments).
|
|
2243
|
+
// Update the block head so the displayed args and copy button are final,
|
|
2244
|
+
// and pre-render the command in the block body immediately!
|
|
2245
|
+
const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName || ev.toolName, arguments: obj.args };
|
|
2246
|
+
const id = call.id || obj.toolCallId || ev.id;
|
|
2247
|
+
if (id) {
|
|
2248
|
+
let tc = state.activeToolCalls.get(id);
|
|
2249
|
+
if (!tc) {
|
|
2250
|
+
tc = ensureToolBlock(id, call.name, call.arguments, Date.now());
|
|
2251
|
+
}
|
|
2252
|
+
if (tc) {
|
|
2253
|
+
tc.name = call.name || tc.name;
|
|
2254
|
+
tc.args = call.arguments || tc.args;
|
|
2255
|
+
const nameEl = tc.head.querySelector(".name");
|
|
2256
|
+
if (nameEl && tc.name) nameEl.textContent = tc.name;
|
|
2257
|
+
const argsEl = tc.head.querySelector(".args");
|
|
2258
|
+
if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
|
|
2259
|
+
updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
|
|
2260
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
|
|
2261
|
+
}
|
|
2128
2262
|
}
|
|
2129
2263
|
}
|
|
2130
2264
|
break;
|
|
@@ -2140,18 +2274,26 @@ function handlePiMessage(obj) {
|
|
|
2140
2274
|
const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
|
|
2141
2275
|
if (tc) {
|
|
2142
2276
|
tc.startedAt = Date.now();
|
|
2277
|
+
tc.name = obj.toolName || tc.name;
|
|
2278
|
+
tc.args = obj.args || tc.args;
|
|
2143
2279
|
if (tc.durationEl) {
|
|
2144
2280
|
tc.durationEl.className = "tool-duration live";
|
|
2145
2281
|
tc.durationEl._startedAt = tc.startedAt;
|
|
2146
2282
|
tc.durationEl.textContent = "0.0s";
|
|
2147
2283
|
tc.durationEl.style.display = "";
|
|
2148
2284
|
}
|
|
2285
|
+
const nameEl = tc.head.querySelector(".name");
|
|
2286
|
+
if (nameEl && tc.name) nameEl.textContent = tc.name;
|
|
2287
|
+
const argsEl = tc.head.querySelector(".args");
|
|
2288
|
+
if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
|
|
2149
2289
|
const stateEl = tc.head.querySelector(".state");
|
|
2150
2290
|
if (stateEl) {
|
|
2151
2291
|
stateEl.className = "state running";
|
|
2152
2292
|
stateEl.textContent = "执行中…";
|
|
2153
2293
|
}
|
|
2154
|
-
updateToolBlockCopyBtn(tc, { name:
|
|
2294
|
+
updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
|
|
2295
|
+
// Immediately render the command code block in body so user sees it right away!
|
|
2296
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
|
|
2155
2297
|
}
|
|
2156
2298
|
break;
|
|
2157
2299
|
}
|
|
@@ -2159,7 +2301,12 @@ function handlePiMessage(obj) {
|
|
|
2159
2301
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
2160
2302
|
if (tc) {
|
|
2161
2303
|
const text = extractContentText(obj.partialResult?.content);
|
|
2162
|
-
tc.body.
|
|
2304
|
+
const outPre = tc.body.querySelector(".tool-output-block");
|
|
2305
|
+
if (outPre) {
|
|
2306
|
+
outPre.textContent = text || "(执行中…)";
|
|
2307
|
+
} else {
|
|
2308
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, true, false);
|
|
2309
|
+
}
|
|
2163
2310
|
}
|
|
2164
2311
|
break;
|
|
2165
2312
|
}
|
|
@@ -2167,7 +2314,6 @@ function handlePiMessage(obj) {
|
|
|
2167
2314
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
2168
2315
|
if (tc) {
|
|
2169
2316
|
const text = extractContentText(obj.result?.content);
|
|
2170
|
-
tc.body.innerHTML = escapeHtml(text) || "(无输出)";
|
|
2171
2317
|
tc.endedAt = Date.now();
|
|
2172
2318
|
const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
|
|
2173
2319
|
tc.durationMs = dur;
|
|
@@ -2182,9 +2328,8 @@ function handlePiMessage(obj) {
|
|
|
2182
2328
|
stateEl.textContent = obj.isError ? "错误" : "完成";
|
|
2183
2329
|
stateEl.className = "state" + (obj.isError ? " error" : "");
|
|
2184
2330
|
}
|
|
2331
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, false, Boolean(obj.isError));
|
|
2185
2332
|
// 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
2333
|
state.activeToolCalls.delete(obj.toolCallId);
|
|
2189
2334
|
}
|
|
2190
2335
|
break;
|
|
@@ -2257,9 +2402,17 @@ function handleExtensionUiRequest(req) {
|
|
|
2257
2402
|
}
|
|
2258
2403
|
|
|
2259
2404
|
function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
|
|
2260
|
-
if (
|
|
2405
|
+
if (!toolCallId) return null;
|
|
2406
|
+
if (state.activeToolCalls.has(toolCallId)) {
|
|
2407
|
+
const existing = state.activeToolCalls.get(toolCallId);
|
|
2408
|
+
if (name && !existing.name) existing.name = name;
|
|
2409
|
+
if (args && (!existing.args || existing.args === "{}")) existing.args = args;
|
|
2410
|
+
return existing;
|
|
2411
|
+
}
|
|
2261
2412
|
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 };
|
|
2413
|
+
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts, name, args };
|
|
2414
|
+
entry.name = name;
|
|
2415
|
+
entry.args = args;
|
|
2263
2416
|
state.activeToolCalls.set(toolCallId, entry);
|
|
2264
2417
|
state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
|
|
2265
2418
|
refreshStreamingContentDebounced();
|
|
@@ -2981,8 +3134,23 @@ function submitPrompt() {
|
|
|
2981
3134
|
ensureStreamingMsg(now);
|
|
2982
3135
|
refreshStreamingContent();
|
|
2983
3136
|
|
|
3137
|
+
// Streaming watchdog: if no agent_end in 5 minutes, auto-finalize
|
|
3138
|
+
if (state.streamingWatchdog) clearTimeout(state.streamingWatchdog);
|
|
3139
|
+
state.streamingWatchdog = setTimeout(() => {
|
|
3140
|
+
if (state.streaming) {
|
|
3141
|
+
showToast("生成超时(5 分钟无响应),已自动结束。");
|
|
3142
|
+
finalizeStreamingMsg();
|
|
3143
|
+
}
|
|
3144
|
+
}, 5 * 60 * 1000);
|
|
3145
|
+
|
|
3146
|
+
// If sessionFile is already known, immediately anchor it to browser URL so page reloads don't lose the active conversation
|
|
3147
|
+
if (state.currentSessionFile) {
|
|
3148
|
+
updateUrlSession(state.currentSessionFile);
|
|
3149
|
+
}
|
|
3150
|
+
|
|
2984
3151
|
// Set session name from the first prompt of a brand-new session.
|
|
2985
|
-
|
|
3152
|
+
const isFirstPrompt = ($("#chat-inner")?.querySelectorAll(".msg").length || 0) <= 2;
|
|
3153
|
+
if (isFirstPrompt && text) {
|
|
2986
3154
|
const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
|
|
2987
3155
|
$("#topSessionName").textContent = promptTitle;
|
|
2988
3156
|
updatePageTitle(promptTitle);
|
|
@@ -3013,6 +3181,7 @@ function submitPrompt() {
|
|
|
3013
3181
|
state.aborting = false;
|
|
3014
3182
|
setComposerStreaming(false);
|
|
3015
3183
|
stopStreamingTimer();
|
|
3184
|
+
if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
|
|
3016
3185
|
state.turnStartedAt = null;
|
|
3017
3186
|
state.streamingMsgDurationEl = null;
|
|
3018
3187
|
if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
|
|
@@ -3454,12 +3623,16 @@ async function init() {
|
|
|
3454
3623
|
}
|
|
3455
3624
|
});
|
|
3456
3625
|
|
|
3457
|
-
// Background session status polling
|
|
3626
|
+
// Background session status polling — refresh every 15s when page is visible.
|
|
3627
|
+
// Skip when sidebar is collapsed on mobile to avoid unnecessary API calls.
|
|
3458
3628
|
setInterval(() => {
|
|
3459
3629
|
if (document.visibilityState === "visible") {
|
|
3460
|
-
|
|
3630
|
+
const isSidebarVisible = window.innerWidth > 768 || $(".app").classList.contains("sidebar-open");
|
|
3631
|
+
if (isSidebarVisible || state.streaming) {
|
|
3632
|
+
refreshSessions();
|
|
3633
|
+
}
|
|
3461
3634
|
}
|
|
3462
|
-
},
|
|
3635
|
+
}, 15000);
|
|
3463
3636
|
|
|
3464
3637
|
// Global event delegation for code block copy buttons
|
|
3465
3638
|
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 {
|