@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 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.2",
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 }
@@ -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 (!out.linkListOpen || out.linkListOrdered !== isOrdered) {
336
+ if (!linkListOpen || linkListOrdered !== isOrdered) {
319
337
  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 + ">";
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
- if (out.linkListOpen) { outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>"; out.linkListOpen = null; }
345
+ flushList();
328
346
  para.push(seg.text);
329
347
  }
330
348
  }
331
349
  }
332
- if (out.linkListOpen) { outHtml += out.linkListOpen === "ol" ? "</ol>" : "</ul>"; out.linkListOpen = null; }
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.streamingText = "";
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
- if (await copyToClipboard(cmdToCopy)) {
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 contentEl = node.querySelector(".content");
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.streamingText = "";
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
- 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
- ]));
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
- 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>' : "") }));
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.streamingText = "";
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.streamingText = "";
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" && !state.streamingText) {
1145
- state.streamingText = "⚠️ 生成失败(模型返回错误)。可能是当前模型不可用,请从右上角切换一个模型后重试。";
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.streamingText += ev.delta;
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. 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;
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
- state.streamingThinking += ev.delta || "";
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 state.activeToolCalls.get(toolCallId);
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>&nbsp;<span id="connLabel">连接中…</span></div>
26
- <div style="margin-top:4px;"><a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a></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 += chunk.toString("utf8");
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": this.setStreaming(false); break;
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, reject) => {
332
- if (!this.alive) return reject(new Error("pi process not 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
- this.proc.stdin.write(JSON.stringify(payload) + "\n");
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) throw new Error("pi process not alive");
388
+ if (!this.alive || !this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
351
389
  this.markActivity();
352
- this.proc.stdin.write(JSON.stringify(cmd) + "\n");
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 [key, a] of activeAgents.entries()) {
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
- if (!resolvedFile.startsWith(resolvedSessionsDir)) {
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 activeAgents.values()) if (a.alive) live++;
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 ${activeAgents.size} background pi agent(s)…`);
766
- for (const a of [...activeAgents.values()]) {
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 activeAgents.values()) {
832
+ for (const a of allAgents) {
778
833
  try { a.proc && a.proc.kill("SIGKILL"); } catch {}
779
834
  }
780
835
  });