@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 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
- # 确保 pi 已安装并已配置好至少一个 provider:
33
- # pi (交互模式 → 运行 /login 选择 provider,或设置 API key)
101
+ #### 启动 Web 服务
102
+
103
+ ```bash
34
104
  npm start
35
- # → http://localhost:3000
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` 等) | 显式指定 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 为无上限) |
@@ -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).
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.6.0 版本)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
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",
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
- streamingText: "", // accumulated text deltas
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 (!out.linkListOpen || out.linkListOrdered !== isOrdered) {
331
+ if (!linkListOpen || linkListOrdered !== isOrdered) {
319
332
  flushPara();
320
- if (out.linkListOpen) outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>";
321
- out.linkListOpen = isOrdered ? "ol" : "ul";
322
- out.linkListOrdered = isOrdered;
323
- outHtml += "<" + out.linkListOpen + ">";
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
- if (out.linkListOpen) { outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>"; out.linkListOpen = null; }
340
+ flushList();
328
341
  para.push(seg.text);
329
342
  }
330
343
  }
331
344
  }
332
- if (out.linkListOpen) { outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>"; out.linkListOpen = null; }
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.streamingText = "";
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
- if (await copyToClipboard(cmdToCopy)) {
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 contentEl = node.querySelector(".content");
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.streamingText = "";
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
- const hasThinking = Boolean(state.streamingThinking);
793
- const hasText = Boolean(state.streamingText);
794
- const hasTools = state.activeToolCalls.size > 0;
795
-
796
- // Immediate visual feedback placeholder while waiting for LLM first token
797
- if (state.streaming && !hasThinking && !hasText && !hasTools) {
798
- content.appendChild(el("div", { class: "thinking-placeholder" }, [
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
- if (state.streamingThinking) {
807
- const isActivelyThinking = state.streaming && !hasText && !hasTools;
808
- content.appendChild(makeThinkingBlock(state.streamingThinking, isActivelyThinking));
809
- }
810
- for (const v of state.activeToolCalls.values()) {
811
- content.appendChild(v.block);
812
- }
813
- if (state.streamingText) {
814
- content.appendChild(el("div", { html: renderMarkdown(state.streamingText) + (state.streaming ? '<span class="typing-cursor"></span>' : "") }));
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.streamingText = "";
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.streamingText = "";
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" && !state.streamingText) {
1145
- state.streamingText = "⚠️ 生成失败(模型返回错误)。可能是当前模型不可用,请从右上角切换一个模型后重试。";
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.streamingText += ev.delta;
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. Overwrite any
1158
- // accumulated/delta text so we display exactly what the model
1159
- // produced (handles non-streamed replies where deltas never come,
1160
- // and avoids duplicates when both message_start.content and deltas
1161
- // carried the same string).
1162
- if (typeof ev.content === "string") state.streamingText = ev.content;
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
- state.streamingThinking += ev.delta || "";
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 state.activeToolCalls.get(toolCallId);
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 += chunk.toString("utf8");
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": this.setStreaming(false); break;
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, reject) => {
332
- if (!this.alive) return reject(new Error("pi process not 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
- this.proc.stdin.write(JSON.stringify(payload) + "\n");
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) throw new Error("pi process not alive");
382
+ if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
351
383
  this.markActivity();
352
- this.proc.stdin.write(JSON.stringify(cmd) + "\n");
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 [key, a] of activeAgents.entries()) {
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
- if (!resolvedFile.startsWith(resolvedSessionsDir)) {
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 activeAgents.values()) if (a.alive) live++;
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 ${activeAgents.size} background pi agent(s)…`);
766
- for (const a of [...activeAgents.values()]) {
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 activeAgents.values()) {
825
+ for (const a of allAgents) {
778
826
  try { a.proc && a.proc.kill("SIGKILL"); } catch {}
779
827
  }
780
828
  });