@liguoshuai/pi-web-chat 1.7.0 → 1.7.1
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 +92 -4
- package/bin/pi-web-chat.js +10 -0
- package/docs/ARCHITECTURE.md +2 -1
- package/package.json +1 -1
- package/public/app.js +204 -82
- package/server.js +62 -14
package/README.md
CHANGED
|
@@ -25,14 +25,102 @@
|
|
|
25
25
|
|
|
26
26
|
## 🚀 快速开始
|
|
27
27
|
|
|
28
|
+
### 1. 环境准备
|
|
29
|
+
- **Node.js**: `>= 18.0.0`
|
|
30
|
+
- **操作系统**: Linux / macOS / WSL (Windows)
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
### 2. 安装 pi 编程代理 (pi agent)
|
|
35
|
+
|
|
36
|
+
`pi-web-chat` 通过 RPC 模式 (`pi --mode rpc`) 与底层 `pi` 命令行 Agent 子进程通信。若系统中尚未安装 `pi`,请选择以下任一方式进行全局安装:
|
|
37
|
+
|
|
38
|
+
#### 方式 A:通过 npm / pnpm 全局安装(推荐)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
|
42
|
+
```
|
|
43
|
+
> 💡 `--ignore-scripts` 可跳过依赖包中的生命周期脚本,安装更干净高效。
|
|
44
|
+
|
|
45
|
+
#### 方式 B:通过 Shell 官方安装脚本
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
curl -fsSL https://pi.dev/install.sh | sh
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
#### 验证安装
|
|
52
|
+
安装完成后,在终端运行以下命令验证:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pi --version
|
|
56
|
+
# 输出版本号(例如 0.82.1)即表示安装成功
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
### 3. 配置模型 Provider 与 API Key
|
|
62
|
+
|
|
63
|
+
`pi` 支持 Anthropic、OpenAI、OpenRouter、DeepSeek、SiliconFlow 等多种 LLM 模型 Provider。在使用前需配置好 API Key 或授权凭证:
|
|
64
|
+
|
|
65
|
+
#### 方法 A:设置环境变量(推荐)
|
|
66
|
+
在终端或 Shell 配置文件(如 `~/.bashrc` 或 `~/.zshrc`)中导出对应的 API Key:
|
|
67
|
+
|
|
28
68
|
```bash
|
|
69
|
+
# 使用 Anthropic (Claude)
|
|
70
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
71
|
+
|
|
72
|
+
# 使用 OpenRouter
|
|
73
|
+
export OPENROUTER_API_KEY="sk-or-v1-..."
|
|
74
|
+
|
|
75
|
+
# 使用 OpenAI / DeepSeek / 其它 OpenAI 兼容 API
|
|
76
|
+
export OPENAI_API_KEY="sk-..."
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### 方法 B:使用 pi CLI 交互式登录
|
|
80
|
+
在终端输入 `pi` 命令启动交互模式,然后输入 `/login` 按照提示选择 Provider 并绑定账号或密钥:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pi
|
|
84
|
+
# 进入交互界面后输入:
|
|
85
|
+
/login
|
|
86
|
+
# 按照提示选择 Provider 并输入 Key,配置完成后输入 /quit 退出
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### 4. 克隆与启动 pi-web-chat
|
|
92
|
+
|
|
93
|
+
#### 克隆仓库与安装依赖
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/liguoshuai-1990/pi-web-chat.git
|
|
29
97
|
cd pi-web-chat
|
|
30
98
|
npm install
|
|
99
|
+
```
|
|
31
100
|
|
|
32
|
-
|
|
33
|
-
|
|
101
|
+
#### 启动 Web 服务
|
|
102
|
+
|
|
103
|
+
```bash
|
|
34
104
|
npm start
|
|
35
|
-
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### 打开浏览器体验
|
|
108
|
+
在浏览器中访问:
|
|
109
|
+
👉 **http://localhost:3000**
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### 5. 命令行启动选项与全局 CLI(可选)
|
|
114
|
+
|
|
115
|
+
你也可以通过 `bin/pi-web-chat.js` 指定工作目录或监听端口:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# 指定端口和工作目录 (cwd)
|
|
119
|
+
node bin/pi-web-chat.js --port 8080 --cwd /path/to/your/project
|
|
120
|
+
|
|
121
|
+
# 或通过 npm 全局安装后在任意目录下启动
|
|
122
|
+
npm install -g .
|
|
123
|
+
pi-web-chat --port 8080
|
|
36
124
|
```
|
|
37
125
|
|
|
38
126
|
---
|
|
@@ -42,7 +130,7 @@ npm start
|
|
|
42
130
|
| 变量 | 默认值 | 说明 |
|
|
43
131
|
| ---- | ------ | ---- |
|
|
44
132
|
| `PORT` | `3000` | Web 服务监听端口 |
|
|
45
|
-
| `PI_BIN` | 自动探测(`~/.npm-global/bin/pi`
|
|
133
|
+
| `PI_BIN` | 自动探测(`~/.npm-global/bin/pi`、`/usr/local/bin/pi` 或 `PATH`) | 显式指定 pi 可执行文件绝对路径 |
|
|
46
134
|
| `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
|
|
47
135
|
| `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的进程回收超时(0 为禁用回收) |
|
|
48
136
|
| `MAX_AGENT_LIFETIME_MS` | `1800000` (30分钟) | 单个 Agent 进程后台生存硬上限(0 为无上限) |
|
package/bin/pi-web-chat.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { dirname, resolve } from "path";
|
|
7
7
|
import { spawn } from "child_process";
|
|
8
|
+
import os from "os";
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = dirname(__filename);
|
|
@@ -49,7 +50,16 @@ function parseArgs(argv) {
|
|
|
49
50
|
return opts;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
function normalizeCwd(dir) {
|
|
54
|
+
if (!dir) return os.homedir();
|
|
55
|
+
if (dir.startsWith("~")) {
|
|
56
|
+
return resolve(os.homedir(), dir.slice(1).replace(/^[/\\]/, ""));
|
|
57
|
+
}
|
|
58
|
+
return resolve(dir);
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
const opts = parseArgs(process.argv.slice(2));
|
|
62
|
+
opts.cwd = normalizeCwd(opts.cwd);
|
|
53
63
|
|
|
54
64
|
// Spawn server.js as a child so we can forward signals cleanly.
|
|
55
65
|
// Use --expose-gc so idle agents can call global.gc() to release heap (IDLE_DROP_HEAP=1).
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构设计文档
|
|
2
2
|
|
|
3
|
-
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.
|
|
3
|
+
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.7.0 版本)。
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
- **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认 2000 条)。客户端重连时通过 `backfill_start` → 离线事件 → `backfill_end` 进行增量补齐。
|
|
43
43
|
- **真正空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“进程彻底进入 idle(非 streaming、无挂起 RPC)”时,才启动 `IDLE_TIMEOUT_MS` 空闲回收倒计时。
|
|
44
44
|
- **磁盘 JSONL 为唯一真理(Single Source of Truth)**:会话历史与树状分支全由 pi 本身维护在磁盘 `.jsonl` 文件中,Web 端通过 REST 接口与 RPC 消息与文件保持同步。
|
|
45
|
+
- **`pi` 可执行文件自动探查与派生**:服务端通过 `resolvePiBin()` 自动定位全局安装的 `pi` 可执行文件(优先使用 `PI_BIN` 环境变量 -> 检查 `~/.npm-global/bin/pi` -> `/usr/local/bin/pi` -> `/usr/bin/pi` -> 系统 `PATH`),派生 `pi --mode rpc --session-dir ...` 子进程。
|
|
45
46
|
|
|
46
47
|
---
|
|
47
48
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -29,8 +29,7 @@ const state = {
|
|
|
29
29
|
currentSessionFile: null,
|
|
30
30
|
// entriesByCallId: for live assistant messages we accumulate tool calls + text
|
|
31
31
|
streamingMsg: null, // DOM node for the in-progress assistant message
|
|
32
|
-
|
|
33
|
-
streamingThinking: "",
|
|
32
|
+
streamingItems: [], // Array of { type: "thinking"|"text"|"tool", text?, tc? } in chronological sequence
|
|
34
33
|
thinkingOpen: true,
|
|
35
34
|
thinkingUserToggled: false,
|
|
36
35
|
activeToolCalls: new Map(), // toolCallId -> { node, body, state }
|
|
@@ -205,6 +204,8 @@ async function copyToClipboard(text) {
|
|
|
205
204
|
}
|
|
206
205
|
|
|
207
206
|
function renderMarkdown(md) {
|
|
207
|
+
if (!md) return "";
|
|
208
|
+
if (typeof md !== "string") md = String(md);
|
|
208
209
|
// Strip headings of # etc. and convert to proper elements with escaping.
|
|
209
210
|
// We do a fenced-code-first approach so we don't process markdown inside code.
|
|
210
211
|
const parts = [];
|
|
@@ -273,11 +274,8 @@ function renderInlineMd(text) {
|
|
|
273
274
|
if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i+1].includes("-")) {
|
|
274
275
|
// collect table block
|
|
275
276
|
const header = lines[i];
|
|
276
|
-
let rows = [];
|
|
277
|
-
let j = i;
|
|
278
|
-
out.push({ kind: "blockskip", range: [i, j] });
|
|
279
277
|
const tblLines = [header, lines[i + 1]];
|
|
280
|
-
j = i + 2;
|
|
278
|
+
let j = i + 2;
|
|
281
279
|
while (j < lines.length && lines[j].includes("|")) { tblLines.push(lines[j]); j++; }
|
|
282
280
|
out.push({ kind: "table", lines: tblLines });
|
|
283
281
|
i = j;
|
|
@@ -288,6 +286,17 @@ function renderInlineMd(text) {
|
|
|
288
286
|
}
|
|
289
287
|
let outHtml = "";
|
|
290
288
|
let para = [];
|
|
289
|
+
let linkListOpen = null;
|
|
290
|
+
let linkListOrdered = null;
|
|
291
|
+
|
|
292
|
+
function flushList() {
|
|
293
|
+
if (linkListOpen) {
|
|
294
|
+
outHtml += linkListOpen === "ol" ? "</ol>" : "</ul>";
|
|
295
|
+
linkListOpen = null;
|
|
296
|
+
linkListOrdered = null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
291
300
|
function flushPara() {
|
|
292
301
|
if (para.length === 0) return;
|
|
293
302
|
const block = para.join("\n").trim();
|
|
@@ -297,39 +306,43 @@ function renderInlineMd(text) {
|
|
|
297
306
|
for (const seg of out) {
|
|
298
307
|
if (seg.kind === "table") {
|
|
299
308
|
flushPara();
|
|
309
|
+
flushList();
|
|
300
310
|
outHtml += mdTable(seg.lines);
|
|
301
311
|
} else if (seg.kind === "line") {
|
|
302
312
|
// headings
|
|
303
313
|
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
304
314
|
if (m) {
|
|
305
315
|
flushPara();
|
|
316
|
+
flushList();
|
|
306
317
|
const level = m[1].length;
|
|
307
318
|
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
308
319
|
} else if (/^\s*$/.test(seg.text)) {
|
|
309
320
|
flushPara();
|
|
321
|
+
flushList();
|
|
310
322
|
} else if (/^>\s?/.test(seg.text)) {
|
|
311
323
|
// blockquote line — group simple consecutive ones
|
|
312
324
|
flushPara();
|
|
325
|
+
flushList();
|
|
313
326
|
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
314
327
|
} else if (/^\s*[-*]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
315
328
|
// list item — group consecutive into ul/ol
|
|
316
329
|
// simple inline handling: wrap each list item line.
|
|
317
330
|
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
318
|
-
if (!
|
|
331
|
+
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
319
332
|
flushPara();
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
outHtml += "<" +
|
|
333
|
+
flushList();
|
|
334
|
+
linkListOpen = isOrdered ? "ol" : "ul";
|
|
335
|
+
linkListOrdered = isOrdered;
|
|
336
|
+
outHtml += "<" + linkListOpen + ">";
|
|
324
337
|
}
|
|
325
338
|
outHtml += `<li>${mdInlineBlock(seg.text.replace(/^\s*([-*]|\d+\.)\s+/, ""))}</li>`;
|
|
326
339
|
} else {
|
|
327
|
-
|
|
340
|
+
flushList();
|
|
328
341
|
para.push(seg.text);
|
|
329
342
|
}
|
|
330
343
|
}
|
|
331
344
|
}
|
|
332
|
-
|
|
345
|
+
flushList();
|
|
333
346
|
flushPara();
|
|
334
347
|
// restore inline code
|
|
335
348
|
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => `<code>${escapeHtml(codeChunks[+n].slice(1, -1))}</code>`);
|
|
@@ -454,6 +467,11 @@ function initMobileToolbarFab() {
|
|
|
454
467
|
|
|
455
468
|
async function loadSession(file) {
|
|
456
469
|
state.currentSessionFile = file;
|
|
470
|
+
state.streaming = false;
|
|
471
|
+
state.streamingItems = [];
|
|
472
|
+
state.streamingMsg = null;
|
|
473
|
+
state.activeToolCalls.clear();
|
|
474
|
+
setComposerAborting(false);
|
|
457
475
|
try {
|
|
458
476
|
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
|
|
459
477
|
window.history.replaceState({ session: file }, "", newUrl);
|
|
@@ -515,6 +533,14 @@ function reconstructFromEntries(entries) {
|
|
|
515
533
|
}
|
|
516
534
|
return part;
|
|
517
535
|
});
|
|
536
|
+
if (m.stopReason === "error" && content.length === 0) {
|
|
537
|
+
let errMsg = m.errorMessage || "生成失败(模型返回错误)";
|
|
538
|
+
try {
|
|
539
|
+
const parsed = JSON.parse(errMsg);
|
|
540
|
+
if (parsed.error?.message) errMsg = parsed.error.message;
|
|
541
|
+
} catch {}
|
|
542
|
+
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
543
|
+
}
|
|
518
544
|
out.push({ role: "assistant", content, ts: m.timestamp, usage: m.usage });
|
|
519
545
|
}
|
|
520
546
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
@@ -536,8 +562,7 @@ function clearChat() {
|
|
|
536
562
|
const chatInner = $("#chat-inner");
|
|
537
563
|
chatInner.innerHTML = "";
|
|
538
564
|
state.streamingMsg = null;
|
|
539
|
-
state.
|
|
540
|
-
state.streamingThinking = "";
|
|
565
|
+
state.streamingItems = [];
|
|
541
566
|
state.thinkingOpen = true;
|
|
542
567
|
state.thinkingUserToggled = false;
|
|
543
568
|
state.activeToolCalls.clear();
|
|
@@ -650,6 +675,39 @@ function getToolCommandToCopy(call) {
|
|
|
650
675
|
return summaryArgs(call.name, call.arguments);
|
|
651
676
|
}
|
|
652
677
|
|
|
678
|
+
function updateToolBlockCopyBtn(tc, call) {
|
|
679
|
+
if (!tc || !tc.head) return;
|
|
680
|
+
const cmd = getToolCommandToCopy(call);
|
|
681
|
+
if (!cmd) return;
|
|
682
|
+
let btn = tc.head.querySelector(".btn-copy-tool");
|
|
683
|
+
if (!btn) {
|
|
684
|
+
btn = el("button", {
|
|
685
|
+
class: "btn-copy-tool",
|
|
686
|
+
type: "button",
|
|
687
|
+
title: "复制指令/参数",
|
|
688
|
+
onclick: async (e) => {
|
|
689
|
+
e.stopPropagation();
|
|
690
|
+
const curCmd = tc.head._cmdToCopy || cmd;
|
|
691
|
+
if (await copyToClipboard(curCmd)) {
|
|
692
|
+
btn.classList.add("copied");
|
|
693
|
+
const span = btn.querySelector("span");
|
|
694
|
+
if (span) span.textContent = "已复制";
|
|
695
|
+
setTimeout(() => {
|
|
696
|
+
btn.classList.remove("copied");
|
|
697
|
+
if (span) span.textContent = "复制";
|
|
698
|
+
}, 1500);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}, [
|
|
702
|
+
el("svg", { html: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' }),
|
|
703
|
+
el("span", { text: "复制" })
|
|
704
|
+
]);
|
|
705
|
+
const stateEl = tc.head.querySelector(".state");
|
|
706
|
+
tc.head.insertBefore(btn, stateEl);
|
|
707
|
+
}
|
|
708
|
+
tc.head._cmdToCopy = cmd;
|
|
709
|
+
}
|
|
710
|
+
|
|
653
711
|
function makeToolBlockFromCall(call) {
|
|
654
712
|
const block = el("div", { class: "tool-block" });
|
|
655
713
|
const hasResult = Boolean(call.result);
|
|
@@ -670,7 +728,8 @@ function makeToolBlockFromCall(call) {
|
|
|
670
728
|
title: "复制指令/参数",
|
|
671
729
|
onclick: async (e) => {
|
|
672
730
|
e.stopPropagation();
|
|
673
|
-
|
|
731
|
+
const curCmd = head._cmdToCopy || cmdToCopy;
|
|
732
|
+
if (await copyToClipboard(curCmd)) {
|
|
674
733
|
copyBtn.classList.add("copied");
|
|
675
734
|
const span = copyBtn.querySelector("span");
|
|
676
735
|
if (span) span.textContent = "已复制";
|
|
@@ -692,6 +751,7 @@ function makeToolBlockFromCall(call) {
|
|
|
692
751
|
copyBtn,
|
|
693
752
|
el("span", { class: stateClass, text: stateText }),
|
|
694
753
|
]);
|
|
754
|
+
head._cmdToCopy = cmdToCopy;
|
|
695
755
|
|
|
696
756
|
const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
|
|
697
757
|
const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
|
|
@@ -735,6 +795,13 @@ function scrollBottom() {
|
|
|
735
795
|
chat.scrollTop = chat.scrollHeight;
|
|
736
796
|
}
|
|
737
797
|
|
|
798
|
+
function getStreamingFullText() {
|
|
799
|
+
return state.streamingItems
|
|
800
|
+
.filter(it => it.type === "text" && it.text)
|
|
801
|
+
.map(it => it.text)
|
|
802
|
+
.join("\n\n");
|
|
803
|
+
}
|
|
804
|
+
|
|
738
805
|
// ---- Streaming: handle live assistant message ----
|
|
739
806
|
function ensureStreamingMsg() {
|
|
740
807
|
if (state.streamingMsg) return state.streamingMsg;
|
|
@@ -748,9 +815,7 @@ function ensureStreamingMsg() {
|
|
|
748
815
|
title: "复制回答全文",
|
|
749
816
|
onclick: async (e) => {
|
|
750
817
|
e.stopPropagation();
|
|
751
|
-
const
|
|
752
|
-
if (!contentEl) return;
|
|
753
|
-
const text = contentEl.textContent || "";
|
|
818
|
+
const text = getStreamingFullText();
|
|
754
819
|
if (await copyToClipboard(text)) {
|
|
755
820
|
showToast("已复制回答全文");
|
|
756
821
|
}
|
|
@@ -763,8 +828,7 @@ function ensureStreamingMsg() {
|
|
|
763
828
|
el("div", { class: "content" }),
|
|
764
829
|
]);
|
|
765
830
|
state.streamingMsg = node;
|
|
766
|
-
state.
|
|
767
|
-
state.streamingThinking = "";
|
|
831
|
+
state.streamingItems = [];
|
|
768
832
|
state.thinkingOpen = true;
|
|
769
833
|
state.thinkingUserToggled = false;
|
|
770
834
|
state.activeToolCalls.clear();
|
|
@@ -789,29 +853,30 @@ function refreshStreamingContent() {
|
|
|
789
853
|
const content = node.querySelector(".content");
|
|
790
854
|
content.innerHTML = "";
|
|
791
855
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
el("span", { class: "thinking-spinner" }),
|
|
800
|
-
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
801
|
-
]));
|
|
856
|
+
if (state.streamingItems.length === 0) {
|
|
857
|
+
if (state.streaming) {
|
|
858
|
+
content.appendChild(el("div", { class: "thinking-placeholder" }, [
|
|
859
|
+
el("span", { class: "thinking-spinner" }),
|
|
860
|
+
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
861
|
+
]));
|
|
862
|
+
}
|
|
802
863
|
scrollBottom();
|
|
803
864
|
return;
|
|
804
865
|
}
|
|
805
866
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
867
|
+
const lastIdx = state.streamingItems.length - 1;
|
|
868
|
+
for (let i = 0; i < state.streamingItems.length; i++) {
|
|
869
|
+
const item = state.streamingItems[i];
|
|
870
|
+
const isLast = (i === lastIdx);
|
|
871
|
+
if (item.type === "thinking") {
|
|
872
|
+
const isActivelyThinking = state.streaming && isLast;
|
|
873
|
+
content.appendChild(makeThinkingBlock(item.text, isActivelyThinking));
|
|
874
|
+
} else if (item.type === "tool") {
|
|
875
|
+
if (item.tc?.block) content.appendChild(item.tc.block);
|
|
876
|
+
} else if (item.type === "text") {
|
|
877
|
+
const showCursor = state.streaming && isLast;
|
|
878
|
+
content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
|
|
879
|
+
}
|
|
815
880
|
}
|
|
816
881
|
scrollBottom();
|
|
817
882
|
}
|
|
@@ -820,10 +885,19 @@ function finalizeStreamingMsg() {
|
|
|
820
885
|
state.streaming = false;
|
|
821
886
|
if (state.streamingMsg) {
|
|
822
887
|
refreshStreamingContent();
|
|
888
|
+
const finalFullText = getStreamingFullText();
|
|
889
|
+
const btn = state.streamingMsg.querySelector(".btn-copy-msg");
|
|
890
|
+
if (btn) {
|
|
891
|
+
btn.onclick = async (e) => {
|
|
892
|
+
e.stopPropagation();
|
|
893
|
+
if (await copyToClipboard(finalFullText)) {
|
|
894
|
+
showToast("已复制回答全文");
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
}
|
|
823
898
|
}
|
|
824
899
|
state.streamingMsg = null;
|
|
825
|
-
state.
|
|
826
|
-
state.streamingThinking = "";
|
|
900
|
+
state.streamingItems = [];
|
|
827
901
|
state.thinkingOpen = true;
|
|
828
902
|
state.thinkingUserToggled = false;
|
|
829
903
|
state.activeToolCalls.clear();
|
|
@@ -967,8 +1041,7 @@ function connectWs(opts = {}) {
|
|
|
967
1041
|
// from the previous connection so the new connection starts clean.
|
|
968
1042
|
if (opts.explicitNewSession) {
|
|
969
1043
|
state.streaming = false;
|
|
970
|
-
state.
|
|
971
|
-
state.streamingThinking = "";
|
|
1044
|
+
state.streamingItems = [];
|
|
972
1045
|
state.streamingMsg = null;
|
|
973
1046
|
state.activeToolCalls.clear();
|
|
974
1047
|
}
|
|
@@ -1119,18 +1192,6 @@ function handlePiMessage(obj) {
|
|
|
1119
1192
|
const m = obj.message;
|
|
1120
1193
|
if (m && m.role !== "assistant") break;
|
|
1121
1194
|
ensureStreamingMsg();
|
|
1122
|
-
// Each turn within one agent reply gets its own message_start, so reset
|
|
1123
|
-
// the text/thinking accumulators here so text_end's overwrite (and
|
|
1124
|
-
// text_delta accumulation) only reflect THIS message, not a stale
|
|
1125
|
-
// one from the previous turn. Tool-call blocks persist across the
|
|
1126
|
-
// whole reply (keyed by toolCallId) and stay visible.
|
|
1127
|
-
state.streamingText = "";
|
|
1128
|
-
state.streamingThinking = "";
|
|
1129
|
-
// NOTE: do NOT pre-fill streamingText from message.content here.
|
|
1130
|
-
// pi sends the full content on message_start for assistant turns but
|
|
1131
|
-
// then also streams the same text via text_delta → pre-filling would
|
|
1132
|
-
// duplicate it ("WS_OKWS_OK"). We rely on text_delta for incremental
|
|
1133
|
-
// display and on text_end.content for the final, authoritative text.
|
|
1134
1195
|
break;
|
|
1135
1196
|
}
|
|
1136
1197
|
case "message_end": {
|
|
@@ -1141,8 +1202,13 @@ function handlePiMessage(obj) {
|
|
|
1141
1202
|
// those failures explicitly so the user isn't left staring at
|
|
1142
1203
|
// an empty reply.
|
|
1143
1204
|
const m = obj.message;
|
|
1144
|
-
if (m && m.role === "assistant" && m.stopReason === "error"
|
|
1145
|
-
|
|
1205
|
+
if (m && m.role === "assistant" && m.stopReason === "error") {
|
|
1206
|
+
let errMsg = m.errorMessage || "生成失败(模型返回错误)。可能是当前模型不可用,请从右上角切换一个模型后重试。";
|
|
1207
|
+
try {
|
|
1208
|
+
const parsed = JSON.parse(errMsg);
|
|
1209
|
+
if (parsed.error?.message) errMsg = parsed.error.message;
|
|
1210
|
+
} catch {}
|
|
1211
|
+
state.streamingItems.push({ type: "text", text: `⚠️ **${escapeHtml(errMsg)}**` });
|
|
1146
1212
|
refreshStreamingContent();
|
|
1147
1213
|
}
|
|
1148
1214
|
break;
|
|
@@ -1151,22 +1217,35 @@ function handlePiMessage(obj) {
|
|
|
1151
1217
|
const ev = obj.assistantMessageEvent;
|
|
1152
1218
|
if (!ev) break;
|
|
1153
1219
|
if (ev.type === "text_delta") {
|
|
1154
|
-
state.
|
|
1220
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1221
|
+
if (last && last.type === "text") {
|
|
1222
|
+
last.text += ev.delta;
|
|
1223
|
+
} else {
|
|
1224
|
+
state.streamingItems.push({ type: "text", text: ev.delta });
|
|
1225
|
+
}
|
|
1155
1226
|
refreshStreamingContentDebounced();
|
|
1156
1227
|
} else if (ev.type === "text_end") {
|
|
1157
|
-
// Authoritative final text for this content slot.
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1228
|
+
// Authoritative final text for this content slot.
|
|
1229
|
+
if (typeof ev.content === "string") {
|
|
1230
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1231
|
+
if (last && last.type === "text") {
|
|
1232
|
+
last.text = ev.content;
|
|
1233
|
+
} else if (ev.content) {
|
|
1234
|
+
state.streamingItems.push({ type: "text", text: ev.content });
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1163
1237
|
refreshStreamingContent();
|
|
1164
1238
|
} else if (ev.type === "thinking_delta" || ev.type === "thinking_start" || ev.type === "thinking_end") {
|
|
1165
1239
|
// For thinking we accumulate deltas; thinking_delta carries .delta
|
|
1166
|
-
if (ev.type === "thinking_delta") {
|
|
1167
|
-
|
|
1240
|
+
if (ev.type === "thinking_delta" && ev.delta) {
|
|
1241
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1242
|
+
if (last && last.type === "thinking") {
|
|
1243
|
+
last.text += ev.delta;
|
|
1244
|
+
} else {
|
|
1245
|
+
state.streamingItems.push({ type: "thinking", text: ev.delta });
|
|
1246
|
+
}
|
|
1247
|
+
refreshStreamingContentDebounced();
|
|
1168
1248
|
}
|
|
1169
|
-
refreshStreamingContentDebounced();
|
|
1170
1249
|
} else if (ev.type === "toolcall_start") {
|
|
1171
1250
|
ensureStreamingMsg();
|
|
1172
1251
|
const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
|
|
@@ -1189,14 +1268,17 @@ function handlePiMessage(obj) {
|
|
|
1189
1268
|
if (tc) {
|
|
1190
1269
|
const argsEl = tc.head.querySelector(".args");
|
|
1191
1270
|
if (argsEl) argsEl.textContent = summaryArgs(call.name, call.arguments);
|
|
1271
|
+
updateToolBlockCopyBtn(tc, call);
|
|
1192
1272
|
}
|
|
1193
1273
|
}
|
|
1194
1274
|
break;
|
|
1195
1275
|
}
|
|
1196
|
-
case "tool_execution_start":
|
|
1276
|
+
case "tool_execution_start": {
|
|
1197
1277
|
ensureStreamingMsg();
|
|
1198
|
-
ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
|
|
1278
|
+
const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
|
|
1279
|
+
if (tc) updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
|
|
1199
1280
|
break;
|
|
1281
|
+
}
|
|
1200
1282
|
case "tool_execution_update": {
|
|
1201
1283
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1202
1284
|
if (tc) {
|
|
@@ -1217,6 +1299,9 @@ function handlePiMessage(obj) {
|
|
|
1217
1299
|
}
|
|
1218
1300
|
break;
|
|
1219
1301
|
}
|
|
1302
|
+
case "extension_ui_request":
|
|
1303
|
+
handleExtensionUiRequest(obj);
|
|
1304
|
+
break;
|
|
1220
1305
|
case "pi_exit":
|
|
1221
1306
|
finalizeStreamingMsg();
|
|
1222
1307
|
state.streaming = false;
|
|
@@ -1229,11 +1314,58 @@ function handlePiMessage(obj) {
|
|
|
1229
1314
|
}
|
|
1230
1315
|
}
|
|
1231
1316
|
|
|
1317
|
+
function handleExtensionUiRequest(req) {
|
|
1318
|
+
const { id, method, title, message, options, placeholder, prefill, notifyType } = req;
|
|
1319
|
+
if (method === "notify") {
|
|
1320
|
+
showToast((notifyType === "warning" ? "⚠️ " : notifyType === "error" ? "❌ " : "ℹ️ ") + (message || ""));
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (method === "confirm") {
|
|
1324
|
+
const text = (title ? title + "\n" : "") + (message || "");
|
|
1325
|
+
const confirmed = window.confirm(text || "是否确认?");
|
|
1326
|
+
sendWs({ type: "extension_ui_response", id, confirmed });
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (method === "select") {
|
|
1330
|
+
const promptText = (title ? title + "\n" : "") + (options || []).map((o, idx) => `${idx + 1}. ${o}`).join("\n");
|
|
1331
|
+
const res = window.prompt(promptText, "1");
|
|
1332
|
+
if (res === null) {
|
|
1333
|
+
sendWs({ type: "extension_ui_response", id, cancelled: true });
|
|
1334
|
+
} else {
|
|
1335
|
+
const idx = parseInt(res.trim(), 10) - 1;
|
|
1336
|
+
const val = (options && options[idx]) ? options[idx] : res.trim();
|
|
1337
|
+
sendWs({ type: "extension_ui_response", id, value: val });
|
|
1338
|
+
}
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (method === "input") {
|
|
1342
|
+
const res = window.prompt(title || "请输入:", placeholder || "");
|
|
1343
|
+
if (res === null) {
|
|
1344
|
+
sendWs({ type: "extension_ui_response", id, cancelled: true });
|
|
1345
|
+
} else {
|
|
1346
|
+
sendWs({ type: "extension_ui_response", id, value: res });
|
|
1347
|
+
}
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (method === "editor") {
|
|
1351
|
+
const res = window.prompt((title || "编辑内容") + " (多行内容可用 \\n 分隔):", prefill || "");
|
|
1352
|
+
if (res === null) {
|
|
1353
|
+
sendWs({ type: "extension_ui_response", id, cancelled: true });
|
|
1354
|
+
} else {
|
|
1355
|
+
sendWs({ type: "extension_ui_response", id, value: res });
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1232
1361
|
function ensureToolBlock(toolCallId, name, args) {
|
|
1233
1362
|
if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
|
|
1234
|
-
makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
|
|
1363
|
+
const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
|
|
1364
|
+
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head };
|
|
1365
|
+
state.activeToolCalls.set(toolCallId, entry);
|
|
1366
|
+
state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
|
|
1235
1367
|
refreshStreamingContentDebounced();
|
|
1236
|
-
return
|
|
1368
|
+
return entry;
|
|
1237
1369
|
}
|
|
1238
1370
|
|
|
1239
1371
|
// We render incoming session entries (for live new messages we use streaming
|
|
@@ -1261,9 +1393,6 @@ function updateState(d) {
|
|
|
1261
1393
|
finalizeStreamingMsg();
|
|
1262
1394
|
state.streaming = false;
|
|
1263
1395
|
setComposerAborting(false);
|
|
1264
|
-
if (state.currentSessionFile) {
|
|
1265
|
-
loadSession(state.currentSessionFile);
|
|
1266
|
-
}
|
|
1267
1396
|
refreshSessions();
|
|
1268
1397
|
}
|
|
1269
1398
|
}
|
|
@@ -1664,13 +1793,6 @@ function init() {
|
|
|
1664
1793
|
connectWs({});
|
|
1665
1794
|
showEmptyState(true);
|
|
1666
1795
|
}
|
|
1667
|
-
// Pull the current pi state (model, session id, thinking level) once the
|
|
1668
|
-
// socket is open. connectWs() registers onopen asynchronously; defer long
|
|
1669
|
-
// enough that the writable is ready. (An earlier version wrote the `\n` as
|
|
1670
|
-
// literal backslash-n inside a single-line comment, so setTimeout never ran
|
|
1671
|
-
// and the model pill never populated.)
|
|
1672
|
-
setTimeout(() => sendWs({ type: "get_state" }), 400);
|
|
1673
|
-
setTimeout(() => sendWs({ type: "get_available_models" }), 600);
|
|
1674
1796
|
}
|
|
1675
1797
|
|
|
1676
1798
|
document.addEventListener("DOMContentLoaded", init);
|
package/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { spawn } from "child_process";
|
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
6
|
import { readFile, readdir, stat } from "fs/promises";
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
|
+
import { StringDecoder } from "string_decoder";
|
|
8
9
|
import express from "express";
|
|
9
10
|
import { WebSocketServer } from "ws";
|
|
10
11
|
import path from "path";
|
|
@@ -109,6 +110,8 @@ const nowMs = () => Date.now();
|
|
|
109
110
|
|
|
110
111
|
// Active pi RPC processes pooled by session key (`${cwd}:${resolvedSessionPath}`)
|
|
111
112
|
const activeAgents = new Map();
|
|
113
|
+
// Master set of all live PiAgent instances (both keyed and unkeyed)
|
|
114
|
+
const allAgents = new Set();
|
|
112
115
|
|
|
113
116
|
class PiAgent {
|
|
114
117
|
constructor(cwd) {
|
|
@@ -119,6 +122,7 @@ class PiAgent {
|
|
|
119
122
|
this.pending = new Map(); // reqId -> resolve()
|
|
120
123
|
this.proc = null;
|
|
121
124
|
this.buffer = "";
|
|
125
|
+
this.decoder = new StringDecoder("utf8");
|
|
122
126
|
this.alive = false;
|
|
123
127
|
// lifecycle / background-task state
|
|
124
128
|
this.state = "idle"; // "idle" | "streaming"
|
|
@@ -149,6 +153,9 @@ class PiAgent {
|
|
|
149
153
|
detachWs(ws) {
|
|
150
154
|
this.sockets.delete(ws);
|
|
151
155
|
if (this.sockets.size === 0) {
|
|
156
|
+
// Reset event buffer so we only capture events that happen while nobody is connected
|
|
157
|
+
this.eventBuffer = [];
|
|
158
|
+
this.bufferHead = 0;
|
|
152
159
|
// Browser closed. We do NOT kill the subprocess here: a background task
|
|
153
160
|
// keeps running. We only arm the idle-kill, which fires once the agent
|
|
154
161
|
// is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
|
|
@@ -228,6 +235,7 @@ class PiAgent {
|
|
|
228
235
|
env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
|
|
229
236
|
});
|
|
230
237
|
this.alive = true;
|
|
238
|
+
allAgents.add(this);
|
|
231
239
|
this.startedAt = nowMs();
|
|
232
240
|
this.lastActivityAt = this.startedAt;
|
|
233
241
|
if (MAX_AGENT_LIFETIME_MS > 0) {
|
|
@@ -238,17 +246,27 @@ class PiAgent {
|
|
|
238
246
|
}
|
|
239
247
|
this.proc.on("error", (err) => {
|
|
240
248
|
this.alive = false;
|
|
249
|
+
allAgents.delete(this);
|
|
250
|
+
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
251
|
+
this.cancelIdleKill();
|
|
252
|
+
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
241
253
|
console.error(`[pi spawn error]`, err);
|
|
242
254
|
this.wsSend({ type: "pi_exit", error: err.message });
|
|
243
255
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
244
256
|
this.sockets.clear();
|
|
245
257
|
});
|
|
258
|
+
if (this.proc.stdin) {
|
|
259
|
+
this.proc.stdin.on("error", (err) => {
|
|
260
|
+
console.warn(`[pi stdin error]`, err.message);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
246
263
|
this.proc.stdout.on("data", (d) => this.onStdout(d));
|
|
247
264
|
this.proc.stderr.on("data", (d) => {
|
|
248
265
|
process.stderr.write(`[pi stderr] ${d}`);
|
|
249
266
|
});
|
|
250
267
|
this.proc.on("exit", (code) => {
|
|
251
268
|
this.alive = false;
|
|
269
|
+
allAgents.delete(this);
|
|
252
270
|
console.log(`pi exited (code=${code})`);
|
|
253
271
|
this.wsSend({ type: "pi_exit", code });
|
|
254
272
|
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
@@ -260,7 +278,7 @@ class PiAgent {
|
|
|
260
278
|
}
|
|
261
279
|
|
|
262
280
|
onStdout(chunk) {
|
|
263
|
-
this.buffer +=
|
|
281
|
+
this.buffer += this.decoder.write(chunk);
|
|
264
282
|
while (true) {
|
|
265
283
|
const nl = this.buffer.indexOf("\n");
|
|
266
284
|
if (nl === -1) break;
|
|
@@ -283,7 +301,11 @@ class PiAgent {
|
|
|
283
301
|
switch (obj.type) {
|
|
284
302
|
case "agent_start": this.setStreaming(true); break;
|
|
285
303
|
case "agent_end": this.setStreaming(false); break;
|
|
286
|
-
case "agent_settled":
|
|
304
|
+
case "agent_settled":
|
|
305
|
+
this.setStreaming(false);
|
|
306
|
+
this.eventBuffer = [];
|
|
307
|
+
this.bufferHead = 0;
|
|
308
|
+
break;
|
|
287
309
|
case "pi_exit": this.state = "idle"; break;
|
|
288
310
|
}
|
|
289
311
|
// Capture the most recent user prompt for the background-task dashboard.
|
|
@@ -325,16 +347,26 @@ class PiAgent {
|
|
|
325
347
|
try { ws.send(JSON.stringify(ev)); } catch {}
|
|
326
348
|
}
|
|
327
349
|
try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
|
|
350
|
+
// Clear buffer once consumed by the reconnecting client
|
|
351
|
+
this.eventBuffer = [];
|
|
352
|
+
this.bufferHead = 0;
|
|
328
353
|
}
|
|
329
354
|
|
|
330
355
|
send(cmd) {
|
|
331
|
-
return new Promise((resolve
|
|
332
|
-
if (!this.alive
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) {
|
|
358
|
+
return resolve({ type: "response", id: cmd.id || "0", success: false, error: "pi process not alive" });
|
|
359
|
+
}
|
|
333
360
|
const id = String(++this.reqId);
|
|
334
361
|
const payload = { ...cmd, id };
|
|
335
362
|
this.pending.set(id, resolve);
|
|
336
363
|
this.markActivity();
|
|
337
|
-
|
|
364
|
+
try {
|
|
365
|
+
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
|
366
|
+
} catch (err) {
|
|
367
|
+
this.pending.delete(id);
|
|
368
|
+
return resolve({ type: "response", id, success: false, error: err.message });
|
|
369
|
+
}
|
|
338
370
|
// Safety: timeout so a dropped response doesn't leak the promise.
|
|
339
371
|
setTimeout(() => {
|
|
340
372
|
if (this.pending.has(id)) {
|
|
@@ -347,9 +379,13 @@ class PiAgent {
|
|
|
347
379
|
}
|
|
348
380
|
|
|
349
381
|
sendNoReply(cmd) {
|
|
350
|
-
if (!this.alive
|
|
382
|
+
if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
|
|
351
383
|
this.markActivity();
|
|
352
|
-
|
|
384
|
+
try {
|
|
385
|
+
this.proc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
386
|
+
} catch (e) {
|
|
387
|
+
console.error("[pi sendNoReply error]", e);
|
|
388
|
+
}
|
|
353
389
|
}
|
|
354
390
|
|
|
355
391
|
wsSend(obj, excludeSocket = null) {
|
|
@@ -390,6 +426,7 @@ class PiAgent {
|
|
|
390
426
|
|
|
391
427
|
stop() {
|
|
392
428
|
this.alive = false;
|
|
429
|
+
allAgents.delete(this);
|
|
393
430
|
this.cancelIdleKill();
|
|
394
431
|
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
395
432
|
if (this.sessionKey) {
|
|
@@ -441,9 +478,9 @@ app.get("/api/validate-dir", async (req, res) => {
|
|
|
441
478
|
// Shows which sessions are still running headlessly after the browser closed.
|
|
442
479
|
app.get("/api/agents", (req, res) => {
|
|
443
480
|
const agents = [];
|
|
444
|
-
for (const
|
|
481
|
+
for (const a of allAgents) {
|
|
445
482
|
if (!a.alive) continue;
|
|
446
|
-
agents.push({ key, ...a.status() });
|
|
483
|
+
agents.push({ key: a.sessionKey || "unkeyed", ...a.status() });
|
|
447
484
|
}
|
|
448
485
|
res.json({ count: agents.length, idleTimeoutMs: IDLE_TIMEOUT_MS, maxLifetimeMs: MAX_AGENT_LIFETIME_MS, agents });
|
|
449
486
|
});
|
|
@@ -554,7 +591,8 @@ app.get("/api/session", async (req, res) => {
|
|
|
554
591
|
// Security check: ensure the file path is within SESSIONS_DIR
|
|
555
592
|
const resolvedFile = path.resolve(file);
|
|
556
593
|
const resolvedSessionsDir = path.resolve(SESSIONS_DIR);
|
|
557
|
-
|
|
594
|
+
const relPath = path.relative(resolvedSessionsDir, resolvedFile);
|
|
595
|
+
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
|
|
558
596
|
return res.status(403).json({ error: "Access denied" });
|
|
559
597
|
}
|
|
560
598
|
|
|
@@ -673,7 +711,7 @@ wss.on("connection", (ws, req) => {
|
|
|
673
711
|
// memory. The cap protects small-memory hosts from runaway browser tabs.
|
|
674
712
|
if (MAX_CONCURRENT_AGENTS > 0) {
|
|
675
713
|
let live = 0;
|
|
676
|
-
for (const a of
|
|
714
|
+
for (const a of allAgents) if (a.alive) live++;
|
|
677
715
|
if (live >= MAX_CONCURRENT_AGENTS) {
|
|
678
716
|
const msg = `Server is at capacity (${live}/${MAX_CONCURRENT_AGENTS} pi agents). ` +
|
|
679
717
|
`Close another tab or raise MAX_CONCURRENT_AGENTS.`;
|
|
@@ -714,6 +752,13 @@ wss.on("connection", (ws, req) => {
|
|
|
714
752
|
agent.sendNoReply({ type: "abort" });
|
|
715
753
|
break;
|
|
716
754
|
case "new_session":
|
|
755
|
+
if (agent.sessionKey) {
|
|
756
|
+
activeAgents.delete(agent.sessionKey);
|
|
757
|
+
agent.sessionKey = null;
|
|
758
|
+
}
|
|
759
|
+
agent.lastUserPrompt = null;
|
|
760
|
+
agent.eventBuffer = [];
|
|
761
|
+
agent.bufferHead = 0;
|
|
717
762
|
agent.send({ type: "new_session" });
|
|
718
763
|
break;
|
|
719
764
|
case "switch_session":
|
|
@@ -741,6 +786,9 @@ wss.on("connection", (ws, req) => {
|
|
|
741
786
|
case "set_model":
|
|
742
787
|
agent.send({ type: "set_model", provider: msg.provider, modelId: msg.modelId });
|
|
743
788
|
break;
|
|
789
|
+
case "extension_ui_response":
|
|
790
|
+
agent.sendNoReply({ type: "extension_ui_response", ...msg });
|
|
791
|
+
break;
|
|
744
792
|
default:
|
|
745
793
|
// Unknown — just forward, might be a raw RPC command.
|
|
746
794
|
agent.send(msg);
|
|
@@ -762,8 +810,8 @@ wss.on("connection", (ws, req) => {
|
|
|
762
810
|
|
|
763
811
|
// ---- Graceful shutdown: stop all background pi agents on exit ----
|
|
764
812
|
function shutdownAllAgents(reason) {
|
|
765
|
-
console.log(`\n[pi-web-chat] ${reason}: stopping ${
|
|
766
|
-
for (const a of [...
|
|
813
|
+
console.log(`\n[pi-web-chat] ${reason}: stopping ${allAgents.size} background pi agent(s)…`);
|
|
814
|
+
for (const a of [...allAgents]) {
|
|
767
815
|
try { a.stop(); } catch {}
|
|
768
816
|
}
|
|
769
817
|
clearInterval(heartbeatInterval);
|
|
@@ -774,7 +822,7 @@ process.on("SIGINT", () => { shutdownAllAgents("SIGINT"); process.exit(0); });
|
|
|
774
822
|
process.on("SIGTERM", () => { shutdownAllAgents("SIGTERM"); process.exit(0); });
|
|
775
823
|
process.on("exit", () => {
|
|
776
824
|
// best-effort: kill any still-living children synchronously on hard exit
|
|
777
|
-
for (const a of
|
|
825
|
+
for (const a of allAgents) {
|
|
778
826
|
try { a.proc && a.proc.kill("SIGKILL"); } catch {}
|
|
779
827
|
}
|
|
780
828
|
});
|