@liguoshuai/pi-web-chat 1.7.0 → 1.7.2
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 +209 -82
- package/public/index.html +4 -1
- package/public/style.css +11 -0
- package/server.js +70 -15
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 }
|
|
@@ -88,6 +87,11 @@ async function loadServerConfig() {
|
|
|
88
87
|
const data = await res.json();
|
|
89
88
|
if (data.home) state.homeDir = data.home;
|
|
90
89
|
if (data.serverCwd) state.serverCwd = data.serverCwd;
|
|
90
|
+
if (data.version) {
|
|
91
|
+
state.version = data.version;
|
|
92
|
+
const verEl = $("#appVersion");
|
|
93
|
+
if (verEl) verEl.textContent = `v${data.version}`;
|
|
94
|
+
}
|
|
91
95
|
} catch {}
|
|
92
96
|
if (!state.cwd) {
|
|
93
97
|
state.cwd = localStorage.getItem("pi_cwd") || state.serverCwd || state.homeDir || "";
|
|
@@ -205,6 +209,8 @@ async function copyToClipboard(text) {
|
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
function renderMarkdown(md) {
|
|
212
|
+
if (!md) return "";
|
|
213
|
+
if (typeof md !== "string") md = String(md);
|
|
208
214
|
// Strip headings of # etc. and convert to proper elements with escaping.
|
|
209
215
|
// We do a fenced-code-first approach so we don't process markdown inside code.
|
|
210
216
|
const parts = [];
|
|
@@ -273,11 +279,8 @@ function renderInlineMd(text) {
|
|
|
273
279
|
if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i+1].includes("-")) {
|
|
274
280
|
// collect table block
|
|
275
281
|
const header = lines[i];
|
|
276
|
-
let rows = [];
|
|
277
|
-
let j = i;
|
|
278
|
-
out.push({ kind: "blockskip", range: [i, j] });
|
|
279
282
|
const tblLines = [header, lines[i + 1]];
|
|
280
|
-
j = i + 2;
|
|
283
|
+
let j = i + 2;
|
|
281
284
|
while (j < lines.length && lines[j].includes("|")) { tblLines.push(lines[j]); j++; }
|
|
282
285
|
out.push({ kind: "table", lines: tblLines });
|
|
283
286
|
i = j;
|
|
@@ -288,6 +291,17 @@ function renderInlineMd(text) {
|
|
|
288
291
|
}
|
|
289
292
|
let outHtml = "";
|
|
290
293
|
let para = [];
|
|
294
|
+
let linkListOpen = null;
|
|
295
|
+
let linkListOrdered = null;
|
|
296
|
+
|
|
297
|
+
function flushList() {
|
|
298
|
+
if (linkListOpen) {
|
|
299
|
+
outHtml += linkListOpen === "ol" ? "</ol>" : "</ul>";
|
|
300
|
+
linkListOpen = null;
|
|
301
|
+
linkListOrdered = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
291
305
|
function flushPara() {
|
|
292
306
|
if (para.length === 0) return;
|
|
293
307
|
const block = para.join("\n").trim();
|
|
@@ -297,39 +311,43 @@ function renderInlineMd(text) {
|
|
|
297
311
|
for (const seg of out) {
|
|
298
312
|
if (seg.kind === "table") {
|
|
299
313
|
flushPara();
|
|
314
|
+
flushList();
|
|
300
315
|
outHtml += mdTable(seg.lines);
|
|
301
316
|
} else if (seg.kind === "line") {
|
|
302
317
|
// headings
|
|
303
318
|
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
304
319
|
if (m) {
|
|
305
320
|
flushPara();
|
|
321
|
+
flushList();
|
|
306
322
|
const level = m[1].length;
|
|
307
323
|
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
308
324
|
} else if (/^\s*$/.test(seg.text)) {
|
|
309
325
|
flushPara();
|
|
326
|
+
flushList();
|
|
310
327
|
} else if (/^>\s?/.test(seg.text)) {
|
|
311
328
|
// blockquote line — group simple consecutive ones
|
|
312
329
|
flushPara();
|
|
330
|
+
flushList();
|
|
313
331
|
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
314
332
|
} else if (/^\s*[-*]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
315
333
|
// list item — group consecutive into ul/ol
|
|
316
334
|
// simple inline handling: wrap each list item line.
|
|
317
335
|
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
318
|
-
if (!
|
|
336
|
+
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
319
337
|
flushPara();
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
outHtml += "<" +
|
|
338
|
+
flushList();
|
|
339
|
+
linkListOpen = isOrdered ? "ol" : "ul";
|
|
340
|
+
linkListOrdered = isOrdered;
|
|
341
|
+
outHtml += "<" + linkListOpen + ">";
|
|
324
342
|
}
|
|
325
343
|
outHtml += `<li>${mdInlineBlock(seg.text.replace(/^\s*([-*]|\d+\.)\s+/, ""))}</li>`;
|
|
326
344
|
} else {
|
|
327
|
-
|
|
345
|
+
flushList();
|
|
328
346
|
para.push(seg.text);
|
|
329
347
|
}
|
|
330
348
|
}
|
|
331
349
|
}
|
|
332
|
-
|
|
350
|
+
flushList();
|
|
333
351
|
flushPara();
|
|
334
352
|
// restore inline code
|
|
335
353
|
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => `<code>${escapeHtml(codeChunks[+n].slice(1, -1))}</code>`);
|
|
@@ -454,6 +472,11 @@ function initMobileToolbarFab() {
|
|
|
454
472
|
|
|
455
473
|
async function loadSession(file) {
|
|
456
474
|
state.currentSessionFile = file;
|
|
475
|
+
state.streaming = false;
|
|
476
|
+
state.streamingItems = [];
|
|
477
|
+
state.streamingMsg = null;
|
|
478
|
+
state.activeToolCalls.clear();
|
|
479
|
+
setComposerAborting(false);
|
|
457
480
|
try {
|
|
458
481
|
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
|
|
459
482
|
window.history.replaceState({ session: file }, "", newUrl);
|
|
@@ -515,6 +538,14 @@ function reconstructFromEntries(entries) {
|
|
|
515
538
|
}
|
|
516
539
|
return part;
|
|
517
540
|
});
|
|
541
|
+
if (m.stopReason === "error" && content.length === 0) {
|
|
542
|
+
let errMsg = m.errorMessage || "生成失败(模型返回错误)";
|
|
543
|
+
try {
|
|
544
|
+
const parsed = JSON.parse(errMsg);
|
|
545
|
+
if (parsed.error?.message) errMsg = parsed.error.message;
|
|
546
|
+
} catch {}
|
|
547
|
+
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
548
|
+
}
|
|
518
549
|
out.push({ role: "assistant", content, ts: m.timestamp, usage: m.usage });
|
|
519
550
|
}
|
|
520
551
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
@@ -536,8 +567,7 @@ function clearChat() {
|
|
|
536
567
|
const chatInner = $("#chat-inner");
|
|
537
568
|
chatInner.innerHTML = "";
|
|
538
569
|
state.streamingMsg = null;
|
|
539
|
-
state.
|
|
540
|
-
state.streamingThinking = "";
|
|
570
|
+
state.streamingItems = [];
|
|
541
571
|
state.thinkingOpen = true;
|
|
542
572
|
state.thinkingUserToggled = false;
|
|
543
573
|
state.activeToolCalls.clear();
|
|
@@ -650,6 +680,39 @@ function getToolCommandToCopy(call) {
|
|
|
650
680
|
return summaryArgs(call.name, call.arguments);
|
|
651
681
|
}
|
|
652
682
|
|
|
683
|
+
function updateToolBlockCopyBtn(tc, call) {
|
|
684
|
+
if (!tc || !tc.head) return;
|
|
685
|
+
const cmd = getToolCommandToCopy(call);
|
|
686
|
+
if (!cmd) return;
|
|
687
|
+
let btn = tc.head.querySelector(".btn-copy-tool");
|
|
688
|
+
if (!btn) {
|
|
689
|
+
btn = el("button", {
|
|
690
|
+
class: "btn-copy-tool",
|
|
691
|
+
type: "button",
|
|
692
|
+
title: "复制指令/参数",
|
|
693
|
+
onclick: async (e) => {
|
|
694
|
+
e.stopPropagation();
|
|
695
|
+
const curCmd = tc.head._cmdToCopy || cmd;
|
|
696
|
+
if (await copyToClipboard(curCmd)) {
|
|
697
|
+
btn.classList.add("copied");
|
|
698
|
+
const span = btn.querySelector("span");
|
|
699
|
+
if (span) span.textContent = "已复制";
|
|
700
|
+
setTimeout(() => {
|
|
701
|
+
btn.classList.remove("copied");
|
|
702
|
+
if (span) span.textContent = "复制";
|
|
703
|
+
}, 1500);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}, [
|
|
707
|
+
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>' }),
|
|
708
|
+
el("span", { text: "复制" })
|
|
709
|
+
]);
|
|
710
|
+
const stateEl = tc.head.querySelector(".state");
|
|
711
|
+
tc.head.insertBefore(btn, stateEl);
|
|
712
|
+
}
|
|
713
|
+
tc.head._cmdToCopy = cmd;
|
|
714
|
+
}
|
|
715
|
+
|
|
653
716
|
function makeToolBlockFromCall(call) {
|
|
654
717
|
const block = el("div", { class: "tool-block" });
|
|
655
718
|
const hasResult = Boolean(call.result);
|
|
@@ -670,7 +733,8 @@ function makeToolBlockFromCall(call) {
|
|
|
670
733
|
title: "复制指令/参数",
|
|
671
734
|
onclick: async (e) => {
|
|
672
735
|
e.stopPropagation();
|
|
673
|
-
|
|
736
|
+
const curCmd = head._cmdToCopy || cmdToCopy;
|
|
737
|
+
if (await copyToClipboard(curCmd)) {
|
|
674
738
|
copyBtn.classList.add("copied");
|
|
675
739
|
const span = copyBtn.querySelector("span");
|
|
676
740
|
if (span) span.textContent = "已复制";
|
|
@@ -692,6 +756,7 @@ function makeToolBlockFromCall(call) {
|
|
|
692
756
|
copyBtn,
|
|
693
757
|
el("span", { class: stateClass, text: stateText }),
|
|
694
758
|
]);
|
|
759
|
+
head._cmdToCopy = cmdToCopy;
|
|
695
760
|
|
|
696
761
|
const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
|
|
697
762
|
const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
|
|
@@ -735,6 +800,13 @@ function scrollBottom() {
|
|
|
735
800
|
chat.scrollTop = chat.scrollHeight;
|
|
736
801
|
}
|
|
737
802
|
|
|
803
|
+
function getStreamingFullText() {
|
|
804
|
+
return state.streamingItems
|
|
805
|
+
.filter(it => it.type === "text" && it.text)
|
|
806
|
+
.map(it => it.text)
|
|
807
|
+
.join("\n\n");
|
|
808
|
+
}
|
|
809
|
+
|
|
738
810
|
// ---- Streaming: handle live assistant message ----
|
|
739
811
|
function ensureStreamingMsg() {
|
|
740
812
|
if (state.streamingMsg) return state.streamingMsg;
|
|
@@ -748,9 +820,7 @@ function ensureStreamingMsg() {
|
|
|
748
820
|
title: "复制回答全文",
|
|
749
821
|
onclick: async (e) => {
|
|
750
822
|
e.stopPropagation();
|
|
751
|
-
const
|
|
752
|
-
if (!contentEl) return;
|
|
753
|
-
const text = contentEl.textContent || "";
|
|
823
|
+
const text = getStreamingFullText();
|
|
754
824
|
if (await copyToClipboard(text)) {
|
|
755
825
|
showToast("已复制回答全文");
|
|
756
826
|
}
|
|
@@ -763,8 +833,7 @@ function ensureStreamingMsg() {
|
|
|
763
833
|
el("div", { class: "content" }),
|
|
764
834
|
]);
|
|
765
835
|
state.streamingMsg = node;
|
|
766
|
-
state.
|
|
767
|
-
state.streamingThinking = "";
|
|
836
|
+
state.streamingItems = [];
|
|
768
837
|
state.thinkingOpen = true;
|
|
769
838
|
state.thinkingUserToggled = false;
|
|
770
839
|
state.activeToolCalls.clear();
|
|
@@ -789,29 +858,30 @@ function refreshStreamingContent() {
|
|
|
789
858
|
const content = node.querySelector(".content");
|
|
790
859
|
content.innerHTML = "";
|
|
791
860
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
el("span", { class: "thinking-spinner" }),
|
|
800
|
-
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
801
|
-
]));
|
|
861
|
+
if (state.streamingItems.length === 0) {
|
|
862
|
+
if (state.streaming) {
|
|
863
|
+
content.appendChild(el("div", { class: "thinking-placeholder" }, [
|
|
864
|
+
el("span", { class: "thinking-spinner" }),
|
|
865
|
+
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
866
|
+
]));
|
|
867
|
+
}
|
|
802
868
|
scrollBottom();
|
|
803
869
|
return;
|
|
804
870
|
}
|
|
805
871
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
872
|
+
const lastIdx = state.streamingItems.length - 1;
|
|
873
|
+
for (let i = 0; i < state.streamingItems.length; i++) {
|
|
874
|
+
const item = state.streamingItems[i];
|
|
875
|
+
const isLast = (i === lastIdx);
|
|
876
|
+
if (item.type === "thinking") {
|
|
877
|
+
const isActivelyThinking = state.streaming && isLast;
|
|
878
|
+
content.appendChild(makeThinkingBlock(item.text, isActivelyThinking));
|
|
879
|
+
} else if (item.type === "tool") {
|
|
880
|
+
if (item.tc?.block) content.appendChild(item.tc.block);
|
|
881
|
+
} else if (item.type === "text") {
|
|
882
|
+
const showCursor = state.streaming && isLast;
|
|
883
|
+
content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
|
|
884
|
+
}
|
|
815
885
|
}
|
|
816
886
|
scrollBottom();
|
|
817
887
|
}
|
|
@@ -820,10 +890,19 @@ function finalizeStreamingMsg() {
|
|
|
820
890
|
state.streaming = false;
|
|
821
891
|
if (state.streamingMsg) {
|
|
822
892
|
refreshStreamingContent();
|
|
893
|
+
const finalFullText = getStreamingFullText();
|
|
894
|
+
const btn = state.streamingMsg.querySelector(".btn-copy-msg");
|
|
895
|
+
if (btn) {
|
|
896
|
+
btn.onclick = async (e) => {
|
|
897
|
+
e.stopPropagation();
|
|
898
|
+
if (await copyToClipboard(finalFullText)) {
|
|
899
|
+
showToast("已复制回答全文");
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
823
903
|
}
|
|
824
904
|
state.streamingMsg = null;
|
|
825
|
-
state.
|
|
826
|
-
state.streamingThinking = "";
|
|
905
|
+
state.streamingItems = [];
|
|
827
906
|
state.thinkingOpen = true;
|
|
828
907
|
state.thinkingUserToggled = false;
|
|
829
908
|
state.activeToolCalls.clear();
|
|
@@ -967,8 +1046,7 @@ function connectWs(opts = {}) {
|
|
|
967
1046
|
// from the previous connection so the new connection starts clean.
|
|
968
1047
|
if (opts.explicitNewSession) {
|
|
969
1048
|
state.streaming = false;
|
|
970
|
-
state.
|
|
971
|
-
state.streamingThinking = "";
|
|
1049
|
+
state.streamingItems = [];
|
|
972
1050
|
state.streamingMsg = null;
|
|
973
1051
|
state.activeToolCalls.clear();
|
|
974
1052
|
}
|
|
@@ -1119,18 +1197,6 @@ function handlePiMessage(obj) {
|
|
|
1119
1197
|
const m = obj.message;
|
|
1120
1198
|
if (m && m.role !== "assistant") break;
|
|
1121
1199
|
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
1200
|
break;
|
|
1135
1201
|
}
|
|
1136
1202
|
case "message_end": {
|
|
@@ -1141,8 +1207,13 @@ function handlePiMessage(obj) {
|
|
|
1141
1207
|
// those failures explicitly so the user isn't left staring at
|
|
1142
1208
|
// an empty reply.
|
|
1143
1209
|
const m = obj.message;
|
|
1144
|
-
if (m && m.role === "assistant" && m.stopReason === "error"
|
|
1145
|
-
|
|
1210
|
+
if (m && m.role === "assistant" && m.stopReason === "error") {
|
|
1211
|
+
let errMsg = m.errorMessage || "生成失败(模型返回错误)。可能是当前模型不可用,请从右上角切换一个模型后重试。";
|
|
1212
|
+
try {
|
|
1213
|
+
const parsed = JSON.parse(errMsg);
|
|
1214
|
+
if (parsed.error?.message) errMsg = parsed.error.message;
|
|
1215
|
+
} catch {}
|
|
1216
|
+
state.streamingItems.push({ type: "text", text: `⚠️ **${escapeHtml(errMsg)}**` });
|
|
1146
1217
|
refreshStreamingContent();
|
|
1147
1218
|
}
|
|
1148
1219
|
break;
|
|
@@ -1151,22 +1222,35 @@ function handlePiMessage(obj) {
|
|
|
1151
1222
|
const ev = obj.assistantMessageEvent;
|
|
1152
1223
|
if (!ev) break;
|
|
1153
1224
|
if (ev.type === "text_delta") {
|
|
1154
|
-
state.
|
|
1225
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1226
|
+
if (last && last.type === "text") {
|
|
1227
|
+
last.text += ev.delta;
|
|
1228
|
+
} else {
|
|
1229
|
+
state.streamingItems.push({ type: "text", text: ev.delta });
|
|
1230
|
+
}
|
|
1155
1231
|
refreshStreamingContentDebounced();
|
|
1156
1232
|
} else if (ev.type === "text_end") {
|
|
1157
|
-
// Authoritative final text for this content slot.
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1233
|
+
// Authoritative final text for this content slot.
|
|
1234
|
+
if (typeof ev.content === "string") {
|
|
1235
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1236
|
+
if (last && last.type === "text") {
|
|
1237
|
+
last.text = ev.content;
|
|
1238
|
+
} else if (ev.content) {
|
|
1239
|
+
state.streamingItems.push({ type: "text", text: ev.content });
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1163
1242
|
refreshStreamingContent();
|
|
1164
1243
|
} else if (ev.type === "thinking_delta" || ev.type === "thinking_start" || ev.type === "thinking_end") {
|
|
1165
1244
|
// For thinking we accumulate deltas; thinking_delta carries .delta
|
|
1166
|
-
if (ev.type === "thinking_delta") {
|
|
1167
|
-
|
|
1245
|
+
if (ev.type === "thinking_delta" && ev.delta) {
|
|
1246
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1247
|
+
if (last && last.type === "thinking") {
|
|
1248
|
+
last.text += ev.delta;
|
|
1249
|
+
} else {
|
|
1250
|
+
state.streamingItems.push({ type: "thinking", text: ev.delta });
|
|
1251
|
+
}
|
|
1252
|
+
refreshStreamingContentDebounced();
|
|
1168
1253
|
}
|
|
1169
|
-
refreshStreamingContentDebounced();
|
|
1170
1254
|
} else if (ev.type === "toolcall_start") {
|
|
1171
1255
|
ensureStreamingMsg();
|
|
1172
1256
|
const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
|
|
@@ -1189,14 +1273,17 @@ function handlePiMessage(obj) {
|
|
|
1189
1273
|
if (tc) {
|
|
1190
1274
|
const argsEl = tc.head.querySelector(".args");
|
|
1191
1275
|
if (argsEl) argsEl.textContent = summaryArgs(call.name, call.arguments);
|
|
1276
|
+
updateToolBlockCopyBtn(tc, call);
|
|
1192
1277
|
}
|
|
1193
1278
|
}
|
|
1194
1279
|
break;
|
|
1195
1280
|
}
|
|
1196
|
-
case "tool_execution_start":
|
|
1281
|
+
case "tool_execution_start": {
|
|
1197
1282
|
ensureStreamingMsg();
|
|
1198
|
-
ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
|
|
1283
|
+
const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
|
|
1284
|
+
if (tc) updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
|
|
1199
1285
|
break;
|
|
1286
|
+
}
|
|
1200
1287
|
case "tool_execution_update": {
|
|
1201
1288
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1202
1289
|
if (tc) {
|
|
@@ -1217,6 +1304,9 @@ function handlePiMessage(obj) {
|
|
|
1217
1304
|
}
|
|
1218
1305
|
break;
|
|
1219
1306
|
}
|
|
1307
|
+
case "extension_ui_request":
|
|
1308
|
+
handleExtensionUiRequest(obj);
|
|
1309
|
+
break;
|
|
1220
1310
|
case "pi_exit":
|
|
1221
1311
|
finalizeStreamingMsg();
|
|
1222
1312
|
state.streaming = false;
|
|
@@ -1229,11 +1319,58 @@ function handlePiMessage(obj) {
|
|
|
1229
1319
|
}
|
|
1230
1320
|
}
|
|
1231
1321
|
|
|
1322
|
+
function handleExtensionUiRequest(req) {
|
|
1323
|
+
const { id, method, title, message, options, placeholder, prefill, notifyType } = req;
|
|
1324
|
+
if (method === "notify") {
|
|
1325
|
+
showToast((notifyType === "warning" ? "⚠️ " : notifyType === "error" ? "❌ " : "ℹ️ ") + (message || ""));
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
if (method === "confirm") {
|
|
1329
|
+
const text = (title ? title + "\n" : "") + (message || "");
|
|
1330
|
+
const confirmed = window.confirm(text || "是否确认?");
|
|
1331
|
+
sendWs({ type: "extension_ui_response", id, confirmed });
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
if (method === "select") {
|
|
1335
|
+
const promptText = (title ? title + "\n" : "") + (options || []).map((o, idx) => `${idx + 1}. ${o}`).join("\n");
|
|
1336
|
+
const res = window.prompt(promptText, "1");
|
|
1337
|
+
if (res === null) {
|
|
1338
|
+
sendWs({ type: "extension_ui_response", id, cancelled: true });
|
|
1339
|
+
} else {
|
|
1340
|
+
const idx = parseInt(res.trim(), 10) - 1;
|
|
1341
|
+
const val = (options && options[idx]) ? options[idx] : res.trim();
|
|
1342
|
+
sendWs({ type: "extension_ui_response", id, value: val });
|
|
1343
|
+
}
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (method === "input") {
|
|
1347
|
+
const res = window.prompt(title || "请输入:", placeholder || "");
|
|
1348
|
+
if (res === null) {
|
|
1349
|
+
sendWs({ type: "extension_ui_response", id, cancelled: true });
|
|
1350
|
+
} else {
|
|
1351
|
+
sendWs({ type: "extension_ui_response", id, value: res });
|
|
1352
|
+
}
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (method === "editor") {
|
|
1356
|
+
const res = window.prompt((title || "编辑内容") + " (多行内容可用 \\n 分隔):", prefill || "");
|
|
1357
|
+
if (res === null) {
|
|
1358
|
+
sendWs({ type: "extension_ui_response", id, cancelled: true });
|
|
1359
|
+
} else {
|
|
1360
|
+
sendWs({ type: "extension_ui_response", id, value: res });
|
|
1361
|
+
}
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1232
1366
|
function ensureToolBlock(toolCallId, name, args) {
|
|
1233
1367
|
if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
|
|
1234
|
-
makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
|
|
1368
|
+
const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
|
|
1369
|
+
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head };
|
|
1370
|
+
state.activeToolCalls.set(toolCallId, entry);
|
|
1371
|
+
state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
|
|
1235
1372
|
refreshStreamingContentDebounced();
|
|
1236
|
-
return
|
|
1373
|
+
return entry;
|
|
1237
1374
|
}
|
|
1238
1375
|
|
|
1239
1376
|
// We render incoming session entries (for live new messages we use streaming
|
|
@@ -1261,9 +1398,6 @@ function updateState(d) {
|
|
|
1261
1398
|
finalizeStreamingMsg();
|
|
1262
1399
|
state.streaming = false;
|
|
1263
1400
|
setComposerAborting(false);
|
|
1264
|
-
if (state.currentSessionFile) {
|
|
1265
|
-
loadSession(state.currentSessionFile);
|
|
1266
|
-
}
|
|
1267
1401
|
refreshSessions();
|
|
1268
1402
|
}
|
|
1269
1403
|
}
|
|
@@ -1664,13 +1798,6 @@ function init() {
|
|
|
1664
1798
|
connectWs({});
|
|
1665
1799
|
showEmptyState(true);
|
|
1666
1800
|
}
|
|
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
1801
|
}
|
|
1675
1802
|
|
|
1676
1803
|
document.addEventListener("DOMContentLoaded", init);
|
package/public/index.html
CHANGED
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
<div class="session-list" id="sessionList"></div>
|
|
24
24
|
<div class="sidebar-bottom">
|
|
25
25
|
<div id="connStatus" class="conn-status" title="点击可重新连接"><span id="connDot" style="color: var(--danger);">●</span> <span id="connLabel">连接中…</span></div>
|
|
26
|
-
<div
|
|
26
|
+
<div class="sidebar-bottom-meta">
|
|
27
|
+
<a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a>
|
|
28
|
+
<span id="appVersion" class="app-version" title="pi-web-chat 版本"></span>
|
|
29
|
+
</div>
|
|
27
30
|
</div>
|
|
28
31
|
</aside>
|
|
29
32
|
|
package/public/style.css
CHANGED
|
@@ -141,6 +141,17 @@ body {
|
|
|
141
141
|
}
|
|
142
142
|
.sidebar-bottom a { color: var(--text-muted); text-decoration: none; }
|
|
143
143
|
.sidebar-bottom a:hover { color: var(--text); }
|
|
144
|
+
.sidebar-bottom-meta {
|
|
145
|
+
margin-top: 6px;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: space-between;
|
|
149
|
+
}
|
|
150
|
+
.app-version {
|
|
151
|
+
font-size: 11px;
|
|
152
|
+
color: var(--text-dim);
|
|
153
|
+
user-select: none;
|
|
154
|
+
}
|
|
144
155
|
.conn-status {
|
|
145
156
|
display: inline-flex;
|
|
146
157
|
align-items: center;
|
package/server.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
6
|
import { readFile, readdir, stat } from "fs/promises";
|
|
7
|
-
import { existsSync } from "fs";
|
|
7
|
+
import { readFileSync, 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";
|
|
@@ -14,6 +15,12 @@ import { dirname } from "path";
|
|
|
14
15
|
|
|
15
16
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
17
|
|
|
18
|
+
let PKG_VERSION = "1.0.0";
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(readFileSync(path.join(__dirname, "package.json"), "utf8"));
|
|
21
|
+
if (pkg.version) PKG_VERSION = pkg.version;
|
|
22
|
+
} catch {}
|
|
23
|
+
|
|
17
24
|
// Resolve pi binary: prefer PI_BIN env, else search PATH, else fall back to ~/.npm-global/bin/pi
|
|
18
25
|
function resolvePiBin() {
|
|
19
26
|
if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) return process.env.PI_BIN;
|
|
@@ -109,6 +116,8 @@ const nowMs = () => Date.now();
|
|
|
109
116
|
|
|
110
117
|
// Active pi RPC processes pooled by session key (`${cwd}:${resolvedSessionPath}`)
|
|
111
118
|
const activeAgents = new Map();
|
|
119
|
+
// Master set of all live PiAgent instances (both keyed and unkeyed)
|
|
120
|
+
const allAgents = new Set();
|
|
112
121
|
|
|
113
122
|
class PiAgent {
|
|
114
123
|
constructor(cwd) {
|
|
@@ -119,6 +128,7 @@ class PiAgent {
|
|
|
119
128
|
this.pending = new Map(); // reqId -> resolve()
|
|
120
129
|
this.proc = null;
|
|
121
130
|
this.buffer = "";
|
|
131
|
+
this.decoder = new StringDecoder("utf8");
|
|
122
132
|
this.alive = false;
|
|
123
133
|
// lifecycle / background-task state
|
|
124
134
|
this.state = "idle"; // "idle" | "streaming"
|
|
@@ -149,6 +159,9 @@ class PiAgent {
|
|
|
149
159
|
detachWs(ws) {
|
|
150
160
|
this.sockets.delete(ws);
|
|
151
161
|
if (this.sockets.size === 0) {
|
|
162
|
+
// Reset event buffer so we only capture events that happen while nobody is connected
|
|
163
|
+
this.eventBuffer = [];
|
|
164
|
+
this.bufferHead = 0;
|
|
152
165
|
// Browser closed. We do NOT kill the subprocess here: a background task
|
|
153
166
|
// keeps running. We only arm the idle-kill, which fires once the agent
|
|
154
167
|
// is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
|
|
@@ -228,6 +241,7 @@ class PiAgent {
|
|
|
228
241
|
env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
|
|
229
242
|
});
|
|
230
243
|
this.alive = true;
|
|
244
|
+
allAgents.add(this);
|
|
231
245
|
this.startedAt = nowMs();
|
|
232
246
|
this.lastActivityAt = this.startedAt;
|
|
233
247
|
if (MAX_AGENT_LIFETIME_MS > 0) {
|
|
@@ -238,17 +252,27 @@ class PiAgent {
|
|
|
238
252
|
}
|
|
239
253
|
this.proc.on("error", (err) => {
|
|
240
254
|
this.alive = false;
|
|
255
|
+
allAgents.delete(this);
|
|
256
|
+
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
257
|
+
this.cancelIdleKill();
|
|
258
|
+
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
241
259
|
console.error(`[pi spawn error]`, err);
|
|
242
260
|
this.wsSend({ type: "pi_exit", error: err.message });
|
|
243
261
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
244
262
|
this.sockets.clear();
|
|
245
263
|
});
|
|
264
|
+
if (this.proc.stdin) {
|
|
265
|
+
this.proc.stdin.on("error", (err) => {
|
|
266
|
+
console.warn(`[pi stdin error]`, err.message);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
246
269
|
this.proc.stdout.on("data", (d) => this.onStdout(d));
|
|
247
270
|
this.proc.stderr.on("data", (d) => {
|
|
248
271
|
process.stderr.write(`[pi stderr] ${d}`);
|
|
249
272
|
});
|
|
250
273
|
this.proc.on("exit", (code) => {
|
|
251
274
|
this.alive = false;
|
|
275
|
+
allAgents.delete(this);
|
|
252
276
|
console.log(`pi exited (code=${code})`);
|
|
253
277
|
this.wsSend({ type: "pi_exit", code });
|
|
254
278
|
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
@@ -260,7 +284,7 @@ class PiAgent {
|
|
|
260
284
|
}
|
|
261
285
|
|
|
262
286
|
onStdout(chunk) {
|
|
263
|
-
this.buffer +=
|
|
287
|
+
this.buffer += this.decoder.write(chunk);
|
|
264
288
|
while (true) {
|
|
265
289
|
const nl = this.buffer.indexOf("\n");
|
|
266
290
|
if (nl === -1) break;
|
|
@@ -283,7 +307,11 @@ class PiAgent {
|
|
|
283
307
|
switch (obj.type) {
|
|
284
308
|
case "agent_start": this.setStreaming(true); break;
|
|
285
309
|
case "agent_end": this.setStreaming(false); break;
|
|
286
|
-
case "agent_settled":
|
|
310
|
+
case "agent_settled":
|
|
311
|
+
this.setStreaming(false);
|
|
312
|
+
this.eventBuffer = [];
|
|
313
|
+
this.bufferHead = 0;
|
|
314
|
+
break;
|
|
287
315
|
case "pi_exit": this.state = "idle"; break;
|
|
288
316
|
}
|
|
289
317
|
// Capture the most recent user prompt for the background-task dashboard.
|
|
@@ -325,16 +353,26 @@ class PiAgent {
|
|
|
325
353
|
try { ws.send(JSON.stringify(ev)); } catch {}
|
|
326
354
|
}
|
|
327
355
|
try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
|
|
356
|
+
// Clear buffer once consumed by the reconnecting client
|
|
357
|
+
this.eventBuffer = [];
|
|
358
|
+
this.bufferHead = 0;
|
|
328
359
|
}
|
|
329
360
|
|
|
330
361
|
send(cmd) {
|
|
331
|
-
return new Promise((resolve
|
|
332
|
-
if (!this.alive
|
|
362
|
+
return new Promise((resolve) => {
|
|
363
|
+
if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) {
|
|
364
|
+
return resolve({ type: "response", id: cmd.id || "0", success: false, error: "pi process not alive" });
|
|
365
|
+
}
|
|
333
366
|
const id = String(++this.reqId);
|
|
334
367
|
const payload = { ...cmd, id };
|
|
335
368
|
this.pending.set(id, resolve);
|
|
336
369
|
this.markActivity();
|
|
337
|
-
|
|
370
|
+
try {
|
|
371
|
+
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
|
372
|
+
} catch (err) {
|
|
373
|
+
this.pending.delete(id);
|
|
374
|
+
return resolve({ type: "response", id, success: false, error: err.message });
|
|
375
|
+
}
|
|
338
376
|
// Safety: timeout so a dropped response doesn't leak the promise.
|
|
339
377
|
setTimeout(() => {
|
|
340
378
|
if (this.pending.has(id)) {
|
|
@@ -347,9 +385,13 @@ class PiAgent {
|
|
|
347
385
|
}
|
|
348
386
|
|
|
349
387
|
sendNoReply(cmd) {
|
|
350
|
-
if (!this.alive
|
|
388
|
+
if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
|
|
351
389
|
this.markActivity();
|
|
352
|
-
|
|
390
|
+
try {
|
|
391
|
+
this.proc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
392
|
+
} catch (e) {
|
|
393
|
+
console.error("[pi sendNoReply error]", e);
|
|
394
|
+
}
|
|
353
395
|
}
|
|
354
396
|
|
|
355
397
|
wsSend(obj, excludeSocket = null) {
|
|
@@ -390,6 +432,7 @@ class PiAgent {
|
|
|
390
432
|
|
|
391
433
|
stop() {
|
|
392
434
|
this.alive = false;
|
|
435
|
+
allAgents.delete(this);
|
|
393
436
|
this.cancelIdleKill();
|
|
394
437
|
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
395
438
|
if (this.sessionKey) {
|
|
@@ -419,6 +462,7 @@ app.get("/api/config", (req, res) => {
|
|
|
419
462
|
res.json({
|
|
420
463
|
home: home(),
|
|
421
464
|
serverCwd: process.cwd(),
|
|
465
|
+
version: PKG_VERSION,
|
|
422
466
|
});
|
|
423
467
|
});
|
|
424
468
|
|
|
@@ -441,9 +485,9 @@ app.get("/api/validate-dir", async (req, res) => {
|
|
|
441
485
|
// Shows which sessions are still running headlessly after the browser closed.
|
|
442
486
|
app.get("/api/agents", (req, res) => {
|
|
443
487
|
const agents = [];
|
|
444
|
-
for (const
|
|
488
|
+
for (const a of allAgents) {
|
|
445
489
|
if (!a.alive) continue;
|
|
446
|
-
agents.push({ key, ...a.status() });
|
|
490
|
+
agents.push({ key: a.sessionKey || "unkeyed", ...a.status() });
|
|
447
491
|
}
|
|
448
492
|
res.json({ count: agents.length, idleTimeoutMs: IDLE_TIMEOUT_MS, maxLifetimeMs: MAX_AGENT_LIFETIME_MS, agents });
|
|
449
493
|
});
|
|
@@ -554,7 +598,8 @@ app.get("/api/session", async (req, res) => {
|
|
|
554
598
|
// Security check: ensure the file path is within SESSIONS_DIR
|
|
555
599
|
const resolvedFile = path.resolve(file);
|
|
556
600
|
const resolvedSessionsDir = path.resolve(SESSIONS_DIR);
|
|
557
|
-
|
|
601
|
+
const relPath = path.relative(resolvedSessionsDir, resolvedFile);
|
|
602
|
+
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
|
|
558
603
|
return res.status(403).json({ error: "Access denied" });
|
|
559
604
|
}
|
|
560
605
|
|
|
@@ -673,7 +718,7 @@ wss.on("connection", (ws, req) => {
|
|
|
673
718
|
// memory. The cap protects small-memory hosts from runaway browser tabs.
|
|
674
719
|
if (MAX_CONCURRENT_AGENTS > 0) {
|
|
675
720
|
let live = 0;
|
|
676
|
-
for (const a of
|
|
721
|
+
for (const a of allAgents) if (a.alive) live++;
|
|
677
722
|
if (live >= MAX_CONCURRENT_AGENTS) {
|
|
678
723
|
const msg = `Server is at capacity (${live}/${MAX_CONCURRENT_AGENTS} pi agents). ` +
|
|
679
724
|
`Close another tab or raise MAX_CONCURRENT_AGENTS.`;
|
|
@@ -714,6 +759,13 @@ wss.on("connection", (ws, req) => {
|
|
|
714
759
|
agent.sendNoReply({ type: "abort" });
|
|
715
760
|
break;
|
|
716
761
|
case "new_session":
|
|
762
|
+
if (agent.sessionKey) {
|
|
763
|
+
activeAgents.delete(agent.sessionKey);
|
|
764
|
+
agent.sessionKey = null;
|
|
765
|
+
}
|
|
766
|
+
agent.lastUserPrompt = null;
|
|
767
|
+
agent.eventBuffer = [];
|
|
768
|
+
agent.bufferHead = 0;
|
|
717
769
|
agent.send({ type: "new_session" });
|
|
718
770
|
break;
|
|
719
771
|
case "switch_session":
|
|
@@ -741,6 +793,9 @@ wss.on("connection", (ws, req) => {
|
|
|
741
793
|
case "set_model":
|
|
742
794
|
agent.send({ type: "set_model", provider: msg.provider, modelId: msg.modelId });
|
|
743
795
|
break;
|
|
796
|
+
case "extension_ui_response":
|
|
797
|
+
agent.sendNoReply({ type: "extension_ui_response", ...msg });
|
|
798
|
+
break;
|
|
744
799
|
default:
|
|
745
800
|
// Unknown — just forward, might be a raw RPC command.
|
|
746
801
|
agent.send(msg);
|
|
@@ -762,8 +817,8 @@ wss.on("connection", (ws, req) => {
|
|
|
762
817
|
|
|
763
818
|
// ---- Graceful shutdown: stop all background pi agents on exit ----
|
|
764
819
|
function shutdownAllAgents(reason) {
|
|
765
|
-
console.log(`\n[pi-web-chat] ${reason}: stopping ${
|
|
766
|
-
for (const a of [...
|
|
820
|
+
console.log(`\n[pi-web-chat] ${reason}: stopping ${allAgents.size} background pi agent(s)…`);
|
|
821
|
+
for (const a of [...allAgents]) {
|
|
767
822
|
try { a.stop(); } catch {}
|
|
768
823
|
}
|
|
769
824
|
clearInterval(heartbeatInterval);
|
|
@@ -774,7 +829,7 @@ process.on("SIGINT", () => { shutdownAllAgents("SIGINT"); process.exit(0); });
|
|
|
774
829
|
process.on("SIGTERM", () => { shutdownAllAgents("SIGTERM"); process.exit(0); });
|
|
775
830
|
process.on("exit", () => {
|
|
776
831
|
// best-effort: kill any still-living children synchronously on hard exit
|
|
777
|
-
for (const a of
|
|
832
|
+
for (const a of allAgents) {
|
|
778
833
|
try { a.proc && a.proc.kill("SIGKILL"); } catch {}
|
|
779
834
|
}
|
|
780
835
|
});
|