@liguoshuai/pi-web-chat 1.6.0 → 1.7.0

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
@@ -8,17 +8,18 @@
8
8
 
9
9
  ---
10
10
 
11
- ## 📸 功能
12
-
13
- - 💬 **流式对话** —— 文本逐字流式渲染,带打字光标
14
- - 🗂️ **会话历史** —— 自动读取 `~/.pi/agent/sessions/` 下所有历史会话,按首条用户消息做标题
15
- - 🆕 **新建 / 切换会话** —— 通过左侧栏点击或生成中新建
16
- - ⚙️ **工具调用折叠块** —— bash / read / write / edit 等以可折叠卡片显示,实时输出
17
- - 💭 **思考过程折叠块** —— 模型 thinking 以独立可折叠卡片显示
18
- - 🧩 **模型切换** —— 顶栏模型 pill,覆盖所有 pi 已配置的模型
19
- - ⏹️ **中止生成** —— 生成中点击发送按钮即可中断
20
- - ⌨️ **Markdown 渲染** —— 代码块、表格、列表、链接、引用、标题
21
- - 🖥️ **工作目录绑定** —— 每个 ws 连接由 cwd 决定会话范围(默认 `~`)
11
+ ## 📸 功能特性
12
+
13
+ - 💬 **流式对话** —— 文本逐字流式渲染,带打字光标与 Markdown 高亮
14
+ - **后台任务持久化** —— 关闭网页/标签页不中断正在生成的任务,重新进入自动回放(Backfill)追平进度
15
+ - 🌐 **多端/多设备同步协同** —— 多个浏览器标签页或多设备(手机/电脑)可同时连入同一会话,实时双向同步
16
+ - 🧭 **运行时实时插入指令 (Steer)** —— 生成过程中可随时插入转向指令指导 AI 调整后续行动
17
+ - 🗂️ **会话历史与管理** —— 自动读取并显示 `~/.pi/agent/sessions/` 下所有历史会话,实时自动落盘
18
+ - ⚙️ **工具与思考过程折叠** —— bash / read / write / edit 等工具调用与 thinking 思考卡片实时展开/收起
19
+ - 🧩 **动态模型切换** —— 顶栏模型胶囊,零延迟覆盖所有 pi 已配置的模型
20
+ - 🔄 **断线自动重连与心跳** —— 带指数退避自动重连、双向心跳 Ping/Pong 检测
21
+ - 📋 **快捷复制** —— 支持代码块、工具指令、回答全文一键复制
22
+ - 📱 **移动端响应式** —— Viewport `100dvh` 优化、顶栏常驻固定、滚动置顶/置底快捷悬浮按钮(FAB)
22
23
 
23
24
  ---
24
25
 
@@ -28,19 +29,26 @@
28
29
  cd pi-web-chat
29
30
  npm install
30
31
 
31
- # 确保 pi 已安装并已配置好至少一个 provider/model
32
+ # 确保 pi 已安装并已配置好至少一个 provider:
32
33
  # pi (交互模式 → 运行 /login 选择 provider,或设置 API key)
33
34
  npm start
34
35
  # → http://localhost:3000
35
36
  ```
36
37
 
37
- ### 环境变量
38
+ ---
39
+
40
+ ## ⚙️ 环境变量配置
38
41
 
39
- | 变量 | 默认值 | 说明 |
40
- | -------------------- | --------------------------------------- | --------------------------- |
41
- | `PORT` | `3000` | Web 服务监听端口 |
42
- | `PI_BIN` | 自动探测(`~/.npm-global/bin/pi` 等) | 显式指定 pi 可执行文件路径 |
43
- | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
42
+ | 变量 | 默认值 | 说明 |
43
+ | ---- | ------ | ---- |
44
+ | `PORT` | `3000` | Web 服务监听端口 |
45
+ | `PI_BIN` | 自动探测(`~/.npm-global/bin/pi` 等) | 显式指定 pi 可执行文件绝对路径 |
46
+ | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
47
+ | `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的进程回收超时(0 为禁用回收) |
48
+ | `MAX_AGENT_LIFETIME_MS` | `1800000` (30分钟) | 单个 Agent 进程后台生存硬上限(0 为无上限) |
49
+ | `EVENT_BUFFER_SIZE` | `2000` | 离线环形 Buffer 允许缓存的最大事件条数 |
50
+ | `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
51
+ | `IDLE_DROP_HEAP` | `false` | 进入空闲时是否给 V8 引擎 GC 提示(需 `--expose-gc`) |
44
52
 
45
53
  ---
46
54
 
@@ -49,31 +57,38 @@ npm start
49
57
  ```
50
58
  pi-web-chat/
51
59
  ├── README.md 本文件
52
- ├── DESIGN.md 设计文档(架构、数据流、决策)
53
- ├── ISSUES.md 历次问题排查与修复
54
- ├── CHANGELOG.md 版本变更日志
55
60
  ├── package.json
56
61
  ├── package-lock.json
57
- ├── server.js Express + WebSocket,桥接 pi RPC
58
- ├── .gitignore
59
- └── public/
60
- ├── index.html 单页 UI
61
- ├── app.js 前端逻辑
62
- └── style.css ChatGPT/Gemini 风格样式
62
+ ├── server.js Express + WebSocket,桥接 pi RPC 与进程池管理
63
+ ├── bin/
64
+ └── pi-web-chat.js CLI 可执行入口
65
+ ├── public/
66
+ ├── index.html 单页 UI
67
+ │ ├── app.js 前端逻辑与状态机
68
+ │ └── style.css ChatGPT/Gemini 风格样式
69
+ └── docs/ 项目文档库
70
+ ├── ARCHITECTURE.md 架构设计文档
71
+ ├── DESIGN.md 详细设计与决策文档
72
+ ├── ISSUES.md 历次问题排查与修补记录
73
+ └── CHANGELOG.md 版本变更日志
63
74
  ```
64
75
 
65
76
  ---
66
77
 
67
- ## 🏗️ 架构
78
+ ## 🏗️ 架构概览
68
79
 
69
80
  ```
70
- 浏览器 ──WebSocket(/ws?cwd=...&session=...)──► Node server.js ──stdin/stdout(JSONL)──► pi --mode rpc
71
- (一个 ws 连接 spawn 一个 pi 子进程)
72
-
73
- ├──REST /api/sessions ──► 直读 JSONL 列历史
74
- └──REST /api/session?file=... ─► 重建根→叶路径
81
+ 浏览器 (多 Tab / 多设备) ──WebSocket(/ws?cwd=...&session=...)──► Node server.js
82
+ (Session Key 进程池 activeAgents)
83
+
84
+ ├──► pi --mode rpc (后台持久化 Worker)
85
+ ├──► REST /api/sessions ──► 列会话历史
86
+ ├──► REST /api/session ────► 构建对话链
87
+ └──► REST /api/agents ─────► 实时监控看板
75
88
  ```
76
89
 
90
+ 详细架构说明与设计决策请参阅 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) 与 [docs/DESIGN.md](docs/DESIGN.md)。
91
+
77
92
  ---
78
93
 
79
94
  ## 📝 License
@@ -94,11 +109,6 @@ MIT
94
109
  ./scripts/install-service.sh 8080
95
110
  ```
96
111
 
97
- 脚本会:
98
- 1. 在 `~/.config/systemd/user/pi-web-chat.service` 生成 unit
99
- 2. `systemctl --user daemon-reload && systemctl --user enable --now pi-web-chat`
100
- 3. 设置 `Restart=on-failure` 自动重启
101
-
102
112
  查看状态 / 日志:
103
113
  ```bash
104
114
  systemctl --user status pi-web-chat
@@ -110,7 +120,7 @@ journalctl --user -u pi-web-chat -f
110
120
  > sudo loginctl enable-linger $USER
111
121
  > ```
112
122
 
113
- 卸载:
123
+ 卸载服务:
114
124
  ```bash
115
125
  systemctl --user disable --now pi-web-chat
116
126
  rm ~/.config/systemd/user/pi-web-chat.service
@@ -0,0 +1,179 @@
1
+ # 架构设计文档
2
+
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.6.0 版本)。
4
+
5
+ ---
6
+
7
+ ## 1. 高层架构
8
+
9
+ ```
10
+ ┌─────────────────┐ WebSocket (JSON) ┌──────────────────────┐
11
+ │ Browser Tab A │ ◄──────────────────────────────────────► │ │
12
+ └─────────────────┘ │ │
13
+ ┌─────────────────┐ │ Node.js server │
14
+ │ Browser Tab B │ ◄──────────────────────────────────────► │ (server.js) │
15
+ └─────────────────┘ │ │
16
+ │ ┌────────────────┐ │
17
+ │ │ PiAgent Pool │ │
18
+ │ │ (activeAgents) │ │
19
+ │ └───────┬────────┘ │
20
+ └──────────┼───────────┘
21
+
22
+ spawn child_process │ (1 agent per session key)
23
+
24
+ ┌──────────────────────┐
25
+ │ pi --mode rpc │
26
+ │ (后台 Agent 子进程) │
27
+ │ stdin/stdout │
28
+ └──────────┬───────────┘
29
+
30
+ ┌──────────────────┼──────────────────┐
31
+ ▼ ▼ ▼
32
+ ~/.pi/agent/sessions/ ~/.pi/agent/ 配置/扩展/
33
+ --home-zrlgs--/ extensions/ skills/
34
+
35
+
36
+ *.jsonl (append-only tree)
37
+ ```
38
+
39
+ **核心设计原则**:
40
+ - **按 Session Key 池化进程**:以 `${cwd}:${resolvedSessionPath}` 作为唯一 Key,多标签页或多设备连接同一个 Session 时共享同一个后台 `PiAgent` 进程。
41
+ - **后台持久化运行(Headless Persistence)**:网页断开(如关闭标签页)不会立即终止进程。只要 Agent 处于流式生成或有未响应请求状态,后台进程会坚持跑完当前任务。
42
+ - **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认 2000 条)。客户端重连时通过 `backfill_start` → 离线事件 → `backfill_end` 进行增量补齐。
43
+ - **真正空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“进程彻底进入 idle(非 streaming、无挂起 RPC)”时,才启动 `IDLE_TIMEOUT_MS` 空闲回收倒计时。
44
+ - **磁盘 JSONL 为唯一真理(Single Source of Truth)**:会话历史与树状分支全由 pi 本身维护在磁盘 `.jsonl` 文件中,Web 端通过 REST 接口与 RPC 消息与文件保持同步。
45
+
46
+ ---
47
+
48
+ ## 2. 关键数据流
49
+
50
+ ### 2.1 打开已有会话与增量回放(Backfill)
51
+
52
+ ```
53
+ Browser server.js PiAgent (RPC)
54
+ │ │ │
55
+ ├─ GET /api/session?file=X ──────►│ 读 JSONL 主干消息 │
56
+ │◄─── { header, entries[] }──────┤ │
57
+ │ │ │
58
+ ├─ 渲染历史消息 │ │
59
+ │ │ │
60
+ ├─ WS /ws?cwd=...&session=X ─────►│ 查找/创建 activeAgents[Key] │
61
+ │ ├─► attachWs(ws) │
62
+ │ ├─► 发现有离线缓存事件 │
63
+ │◄─── { type: "backfill_start" }─┤ │
64
+ │◄─── N 条离线事件 (text_delta…) │ (将离线事件批量回放给当前 ws) │
65
+ │◄─── { type: "backfill_end" }───┤ │
66
+ │ │ │
67
+ ├─ 矫正当前 streaming 与 Composer 状态 │
68
+ ```
69
+
70
+ ### 2.2 新建会话与发送消息
71
+
72
+ ```
73
+ Browser server.js pi RPC 子进程
74
+ │ │ │
75
+ ├─ WS /ws?cwd=... (无 session) │ spawn pi --mode rpc │
76
+ │ │◄─ pi 创建新 session jsonl │
77
+ │◄─── ws open (状态记为已连接) │ │
78
+ │ │ │
79
+ ├─ sendWs({type:"prompt", ...})─►│────────────────────────────────►│
80
+ │ │ ├─ agent_start (state=streaming)
81
+ │◄─── message_update/text_delta ─┼◄────────────────────────────────┤
82
+ │◄─── agent_settled │◄────────────────────────────────┤ (state=idle)
83
+ │ │ ├─ session 文件实时追加
84
+ ```
85
+
86
+ ### 2.3 中途插话 / 转向(Mid-turn Steering)
87
+
88
+ ```
89
+ Browser server.js pi RPC 子进程
90
+ │ │ │
91
+ ├─ Agent 处于 streaming 状态 ────┼─────────────────────────────────┤ (正在执行长任务/工具)
92
+ ├─ sendWs({type:"steer", ...})──►│────────────────────────────────►│
93
+ │ │ ├─ 注入 steer 命令
94
+ │◄─── agent 收到并调整后续思考 ────┼◄────────────────────────────────┤
95
+ ```
96
+
97
+ ---
98
+
99
+ ## 3. 关键模块职责
100
+
101
+ ### 3.1 `server.js`
102
+
103
+ | 模块/函数 | 职责 |
104
+ |-----------|------|
105
+ | `PiAgent` 类 | 管理单个 `pi --mode rpc` 子进程:维护状态(`idle`/`streaming`)、离线事件 Buffer、挂起请求表、真·空闲回收定时器与硬生存上限定时器。 |
106
+ | `activeAgents` Map | 以 `${cwd}:${sessionFile}` 为 Key 的进程池,实现多端/多标签页共享。 |
107
+ | `GET /api/sessions` | 扫描 `~/.pi/agent/sessions/**/*.jsonl`,读取 header 做 cwd 过滤,按更新时间排序返回。 |
108
+ | `GET /api/session` | 读取单个 `.jsonl` 文件,按 parentId 关系重构叶子到根节点的标准对话链。 |
109
+ | `GET /api/agents` | 暴露后台活跃 Agent 的状态信息(运行状态、客户端数量、存活时长、离线 Buffer 消息数、最近提示词等)。 |
110
+ | `WebSocketServer` | 处理连接建立与断开,分发 `prompt`、`steer`、`abort`、`ping` 等指令,并在 `SIGINT`/`SIGTERM` 时进行优雅关闭。 |
111
+
112
+ ### 3.2 `public/app.js`
113
+
114
+ | 模块/函数 | 职责 |
115
+ |-----------|------|
116
+ | `state` | 全局状态对象:维护 `wsConnected`、`streaming`、`isBackfilling`、`activeToolCalls`、`sessionId`、`wsGen` 等。 |
117
+ | `connectWs(opts)` | 管理 WebSocket 生命周期:携带代次 `wsGen` 防止旧消息污染,处理重连逻辑与心跳。 |
118
+ | `handlePiMessage(obj)` | 核心事件分发器:处理 `backfill_start`/`backfill_end`、16 种 pi 事件以及状态同步。 |
119
+ | `ensureStreamingMsg / refreshStreamingContent` | 增量渲染/更新正在流式的文本、思考过程(Thinking)与工具卡片。 |
120
+ | `renderMarkdown` | 自研轻量 Markdown 渲染器(代码块抠出保护、Html Escape、行内语法转换)。 |
121
+
122
+ ### 3.3 `public/style.css`
123
+
124
+ - 暗色与明色响应式样式变量。
125
+ - 左侧 260px 侧边栏与右侧主聊天区自适应布局。
126
+ - 移动端 Fixed 顶栏、Viewport height 100dvh 适配与悬浮置顶按钮(FAB)。
127
+
128
+ ---
129
+
130
+ ## 4. 关键设计决策
131
+
132
+ | 决策点 | 选择 | 理由 |
133
+ |--------|------|------|
134
+ | **进程池粒度** | **Session Key 池化** | 允许同一会话在多设备/多 Tab 间同步,同时保证进程资源不重复浪费。 |
135
+ | **断连处理** | **后台继续跑 + 环形 Buffer 回放** | 解决关闭标签页导致未完成任务断掉的问题;用环形 Buffer 防止长时间离线膨胀内存。 |
136
+ | **回收策略** | **真·空闲倒计时(True-Idle Timeout)** | 区分“无连接”与“真正的空闲”,保障长时间后台生成任务不被中断。 |
137
+ | **通讯协议** | **直接透传 pi 原生 JSONL** | 不做二次包装,保持与 pi RPC 协议同频更新。 |
138
+ | **持久化** | **复用 pi 本身 .jsonl** | 零额外存储依赖,Web 端与终端命令完全同源共享会话。 |
139
+
140
+ ---
141
+
142
+ ## 5. 配置与环境变量
143
+
144
+ | 环境变量 | 默认值 | 说明 |
145
+ |----------|--------|------|
146
+ | `PORT` | `3000` | HTTP 与 WebSocket 监听端口 |
147
+ | `PI_BIN` | 自动探测 | pi 可执行文件绝对路径 |
148
+ | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | 会话 JSONL 文件存取目录 |
149
+ | `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的回收超时(0 为禁用回收) |
150
+ | `MAX_AGENT_LIFETIME_MS` | `1800000` (30分钟) | 单个 Agent 进程后台生存硬上限(0 为无上限) |
151
+ | `EVENT_BUFFER_SIZE` | `2000` | 离线环形 Buffer 允许缓存的最大事件条数 |
152
+ | `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
153
+ | `IDLE_DROP_HEAP` | `false` | 进入空闲时是否给 V8 引擎 GC 提示(需 `--expose-gc`) |
154
+
155
+ ---
156
+
157
+ ## 6. 目录结构
158
+
159
+ ```
160
+ pi-web-chat/
161
+ ├── README.md # 主说明文档
162
+ ├── package.json
163
+ ├── package-lock.json
164
+ ├── server.js # Express + WebSocket 服务器与 PiAgent 管理
165
+ ├── bin/
166
+ │ └── pi-web-chat.js # 可执行入口
167
+ ├── public/
168
+ │ ├── index.html # 单页 UI
169
+ │ ├── app.js # 前端逻辑与状态机
170
+ │ └── style.css # CSS 样式
171
+ ├── docs/ # 所有文档统一收纳
172
+ │ ├── ARCHITECTURE.md # 架构设计文档(本文件)
173
+ │ ├── DESIGN.md # 详细设计与决策文档
174
+ │ ├── ISSUES.md # 排查与修补记录
175
+ │ └── CHANGELOG.md # 版本变更日志
176
+ └── scripts/ # 服务安装/卸载脚本
177
+ ├── install-service.sh
178
+ └── pi-web-chat.service
179
+ ```
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.7.0] - 2026-07-26
11
+
12
+ ### Added
13
+ - **前端流式渲染防抖节流 (rAF UI Throttling)**:使用 `requestAnimationFrame` 节流高频 Token/Delta 事件,消除倒水式输出时的 DOM 频繁销毁与全量重塑,大幅提升高频打字时的流畅帧率,显著降低 CPU 和发热消耗。
14
+ - **后端 Session 列表 mtime 内存缓存 (Session Metadata Caching)**:`/api/sessions` 引入基于文件修改时间(`mtimeMs`)的元数据内存缓存。对无改动的历史 Session 文件跳过磁盘读取与全量 JSON 逐行解析,大幅提升侧边栏列表加载响应速度。
15
+ - **离线事件 Buffer 算法优化 (Ring Buffer O(1) Push)**:将 `PiAgent.bufferEvent` 从 $O(N)$ 复杂度的 `Array.shift()` 改为 $O(1)$ 的指针环形队列,消除无客户端连接时的数组平移消耗。
16
+ - **WebSocket 垃圾连接自动回收 (Dead Socket Cleanup)**:在 `wsSend` 广播消息时动态检测并自动剔除已处于关闭状态(`CLOSING`/`CLOSED`)的垃圾 Socket 引用,避免泄露。
17
+
18
+ ---
19
+
10
20
  ## [1.6.0] - 2026-07-26
11
21
 
12
22
  ### Added
@@ -153,14 +163,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
153
163
  - 多模型切换(pi 配置的所有 provider/model)
154
164
  - 响应式深色主题(ChatGPT/Gemini 风格)
155
165
 
156
- ### Technical Debt (已知)
157
- - 图片上传未接
158
- - 多 cwd / 项目切换器未做
159
- - fork / tree / clone 浏览未接
160
- - 浅/深主题切换未做
161
- - 双端实时同步未做
162
- - 鉴权未做
163
-
164
166
  ---
165
167
 
166
168
  ## Legend
package/docs/DESIGN.md ADDED
@@ -0,0 +1,88 @@
1
+ # 详细设计与决策文档
2
+
3
+ ## 目标
4
+
5
+ 将 [pi](https://pi.dev) 终端编码代理包装为一个**类 ChatGPT / Gemini 的 Web 界面**,使用户能够:
6
+
7
+ - 在浏览器中与 pi 展开对话,享受流式文本输出、思考过程卡片、Markdown 渲染与工具调用可视化;
8
+ - 与终端共享会话历史(一份存储、两端可见);
9
+ - 支持后台持久化运行:关闭标签页或切换设备不打断正在生成的任务,重新进入时自动追进度。
10
+
11
+ 非目标:
12
+ - 引入复杂的前端构建链(保持单页原生 JS/CSS,无打包工具);
13
+ - 替代 pi 本身的文件存储层或重新实现数据库。
14
+
15
+ ---
16
+
17
+ ## 核心设计与进程模型
18
+
19
+ ### 进程池化与后台持久化运行
20
+
21
+ ```
22
+ Browser Tab 1 ──┐
23
+ ├── WebSocket ──► server.js (activeAgents[Key]) ──stdio (JSONL)──► pi (rpc mode)
24
+ Browser Tab 2 ──┘
25
+ ```
26
+
27
+ 与早期“1 个 WebSocket = 1 个子进程”不同,当前设计以 **Session Key**(`${cwd}:${resolvedSessionPath}`)作为唯一标识:
28
+
29
+ 1. **多端/多标签页共享**:多个浏览器标签页或多个设备打开同一个 Session 时,附加到同一个 `PiAgent` 实例。
30
+ 2. **后台任务持久化**:关闭网页仅表示断开 WebSocket 连接,`PiAgent` 会继续运行直到当前的流式任务结束。
31
+ 3. **离线 Buffer 与回放**:在无客户端连接期间,pi 抛出的事件将缓存在 `eventBuffer` 环形队列中。新客户端连接后,服务端通过 `backfill_start` → 增量事件 → `backfill_end` 进行追回。
32
+ 4. **真·空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“真正 Idle”(非 streaming,无挂起 RPC 请求)时启动 `IDLE_TIMEOUT_MS`(默认 5 分钟)倒计时回收进程。
33
+
34
+ ---
35
+
36
+ ## 数据流设计
37
+
38
+ ### 1. 消息发送与流式渲染
39
+
40
+ ```
41
+ 1. 浏览器发送 JSON: {"type": "prompt", "message": "..."}
42
+ 2. 服务端转给对应 Session 的 PiAgent
43
+ 3. PiAgent 更新状态为 streaming,写入 pi 的 stdin
44
+ 4. pi 往 stdout 输出事件流 (agent_start / text_delta / toolcall_start / agent_end / agent_settled)
45
+ 5. 服务端广播给所有连入该 Session 的 WebSocket,并写入离线 Buffer(若无连接)
46
+ 6. 浏览器 handlePiMessage 增量更新 DOM(文本、思考块、工具卡片)
47
+ ```
48
+
49
+ ### 2. 中途中断与转向(Steering)
50
+
51
+ 在代理正在执行长任务或工具调用时,用户可以输入补充指令:
52
+ - **Abort**:发送 `{"type": "abort"}` 中断当前生成。
53
+ - **Steer**:发送 `{"type": "steer", "message": "..."}` 插入中途指令,指导代理调整后续行动。
54
+
55
+ ---
56
+
57
+ ## 关键设计决策与权衡
58
+
59
+ ### 1. 渲染策略:重构 vs 增量 DOM
60
+
61
+ 针对流式输出中出现的文本、思考块(Thinking)、工具卡片交错问题:
62
+ - 选择**每次更新时重构当前 assistant 消息的内容节点**。因为单次回复文本长度通常有限,原生 DOM 节点重写开销在 5ms 以内,极大地简化了复杂的节点插入与折叠逻辑。
63
+
64
+ ### 2. 代次标记(`wsGen`)
65
+
66
+ 解决浏览器在极短时间内快速切换 WebSocket 会话时的竞态:
67
+ - 全局分配单调递增的 `wsGen` 代次。每次建立新 WebSocket 时递增。在接收消息前比对 `ws._gen === wsGen`,避免旧连接迟到的消息破坏新会话的状态。
68
+
69
+ ### 3. Markdown 渲染:Escape-First 零依赖
70
+
71
+ 不使用第三方大型 Markdown 渲染库,采用原生轻量处理:
72
+ - 在渲染入口统一对文本做 HTML Escape 转义(防止 XSS 攻击);
73
+ - 先将围栏代码块(Fenced Code Blocks)抠出暂存,再做行内语法转换,最后还原代码块。
74
+
75
+ ---
76
+
77
+ ## 消息事件与 UI 控件对照表
78
+
79
+ | pi RPC 事件 | 触发动作 / 前端 UI 表现 |
80
+ |-------------|-------------------------|
81
+ | `agent_start` | 切换发送按钮为“停止/中止”状态,标志进入流式生成状态 |
82
+ | `message_start` | 在聊天区域末尾追加新的 Assistant 消息卡片 |
83
+ | `message_update` / `text_delta` | 累加到文本缓冲并重新渲染 Markdown |
84
+ | `message_update` / `thinking_delta` | 累加到 Thinking 折叠卡片并实时显示 |
85
+ | `message_update` / `toolcall_start` | 创建可视化工具调用卡片(可折叠) |
86
+ | `tool_execution_start / update / end` | 动态更新工具卡片的输入参数、标准输出与执行状态 |
87
+ | `agent_end` / `agent_settled` | 恢复发送按钮,标记流式生成结束,触发侧边栏会话列表刷新 |
88
+ | `backfill_start` / `backfill_end` | 前端标记处于历史回放状态,回放期间暂停自动滚动,回放结束后一次性同步状态并滚动到底部 |
package/docs/ISSUES.md ADDED
@@ -0,0 +1,66 @@
1
+ # 问题排查与修补记录
2
+
3
+ 项目开发与演进过程中遇到并修复的技术问题与设计优化记录。
4
+
5
+ ---
6
+
7
+ ## 1. 侧边栏 `cwd` 路径编码错误
8
+
9
+ **症状**:API `GET /api/sessions?cwd=/home/zrlgs` 拿不到任何结果。
10
+
11
+ **根因**:第一版用了 `cwd.replace(/\//g, "-")` 把 `/home/zrlgs` 编码成 `-home-zrlgs`,但 pi 实际上用的是 `--home-zrlgs--`(前后都带 `-`)。
12
+
13
+ **修复**:弃用编码猜测,扫描 sessions 根目录下所有子目录,读每个 jsonl 的 header 里 `cwd` 字段,再按 `cwd` 严格比对过滤。
14
+
15
+ ---
16
+
17
+ ## 2. 关闭网页标签页导致正在生成的 pi 任务中断
18
+
19
+ **症状**:在用户提交了一个复杂 Prompt(或正在跑 Bash 工具)后关闭网页,AI 回答直接中断,下次连入时拿不到完整结果。
20
+
21
+ **根因**:原先 WebSocket 断开会直接触发 5 分钟倒计时杀进程;即使倒计时未到,没有客户端连入时生成的事件也被直接抛弃,未在内存中保留。
22
+
23
+ **修复**:
24
+ - 引入 **PiAgent 进程池化** 与 **真·空闲回收(True-Idle Timeout)**:只有在“无 WS 连接”且“真正 Idle”(非 streaming、无挂起 RPC)时才倒计时。
25
+ - 引入 **离线事件环形 Buffer(Event Ring Buffer)**:无客户端时缓存在 Buffer,新连接重连时发送 `backfill_start` → 增量事件 → `backfill_end` 进行追回并一次性同步状态。
26
+
27
+ ---
28
+
29
+ ## 3. 局域网 / 移动端 Safari 100vh 动态导航栏遮挡底部输入框
30
+
31
+ **症状**:移动端 Safari 浏览器打开 Web 界面时,底部输入框被 iOS 动态地址栏遮挡,且顶栏在滚动时会跟着滑走。
32
+
33
+ **修复**:
34
+ - 在 CSS 中使用 `100dvh` (Dynamic Viewport Height) 替代 `100vh`。
35
+ - 将顶栏改为 `sticky` / `fixed` 布局,保障移动端交互体验。
36
+ - 增加悬浮置顶/置底快捷按钮(FAB)。
37
+
38
+ ---
39
+
40
+ ## 4. WebSocket 代次竞态 —— 旧 socket 残留消息污染新会话
41
+
42
+ **症状**:点“新对话”快速发消息时,前一次会话延迟到达的 `agent_end` / `agent_settled` 事件跑进新会话,导致新会话 `streaming` 状态被错误覆盖。
43
+
44
+ **修复**:
45
+ - 引入全局 `wsGen` 单调递增计数器,给每个 WebSocket 分配 `_gen` 编号。
46
+ - 接收消息前比对代次,丢弃非当前代次发来的旧事件。
47
+
48
+ ---
49
+
50
+ ## 5. `/api/session` 中局部变量遮蔽 Node `path` 模块
51
+
52
+ **症状**:在调用 `/api/session` 接口获取历史对话时,出现 `TypeError: path.basename is not a function` 报错。
53
+
54
+ **根因**:函数内部在处理 jsonl 包含的文件条目时,使用了 `const path = ...` 局部变量名,屏蔽了 Node.js 顶层的 `import path from "node:path"` 模块。
55
+
56
+ **修复**:重命名局部变量为 `filePath` / `resolvedPath`,消除变量同名遮蔽。
57
+
58
+ ---
59
+
60
+ ## 6. 多设备 / 多 Tab 共享会话不同步
61
+
62
+ **症状**:同一个 Session 在手机和电脑上同时打开时,手机发送的消息电脑看不到,状态互相覆盖。
63
+
64
+ **修复**:
65
+ - 以 Session Key 为粒度管理 `PiAgent` 实例。
66
+ - 任何一个客户端发送 `prompt` 或 `steer` 指令时,通过 `agent.wsSend(..., senderWs)` 广播同步给连入该 Session 的其他客户端。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
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",
@@ -11,8 +11,8 @@
11
11
  "server.js",
12
12
  "bin/",
13
13
  "public/",
14
+ "docs/",
14
15
  "README.md",
15
- "CHANGELOG.md",
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
package/public/app.js CHANGED
@@ -773,6 +773,16 @@ function ensureStreamingMsg() {
773
773
  return node;
774
774
  }
775
775
 
776
+ let isRefreshScheduled = false;
777
+ function refreshStreamingContentDebounced() {
778
+ if (isRefreshScheduled) return;
779
+ isRefreshScheduled = true;
780
+ requestAnimationFrame(() => {
781
+ isRefreshScheduled = false;
782
+ refreshStreamingContent();
783
+ });
784
+ }
785
+
776
786
  function refreshStreamingContent() {
777
787
  const node = state.streamingMsg;
778
788
  if (!node) return;
@@ -1142,7 +1152,7 @@ function handlePiMessage(obj) {
1142
1152
  if (!ev) break;
1143
1153
  if (ev.type === "text_delta") {
1144
1154
  state.streamingText += ev.delta;
1145
- refreshStreamingContent();
1155
+ refreshStreamingContentDebounced();
1146
1156
  } else if (ev.type === "text_end") {
1147
1157
  // Authoritative final text for this content slot. Overwrite any
1148
1158
  // accumulated/delta text so we display exactly what the model
@@ -1156,7 +1166,7 @@ function handlePiMessage(obj) {
1156
1166
  if (ev.type === "thinking_delta") {
1157
1167
  state.streamingThinking += ev.delta || "";
1158
1168
  }
1159
- refreshStreamingContent();
1169
+ refreshStreamingContentDebounced();
1160
1170
  } else if (ev.type === "toolcall_start") {
1161
1171
  ensureStreamingMsg();
1162
1172
  const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
@@ -1222,7 +1232,7 @@ function handlePiMessage(obj) {
1222
1232
  function ensureToolBlock(toolCallId, name, args) {
1223
1233
  if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
1224
1234
  makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
1225
- refreshStreamingContent();
1235
+ refreshStreamingContentDebounced();
1226
1236
  return state.activeToolCalls.get(toolCallId);
1227
1237
  }
1228
1238
 
package/server.js CHANGED
@@ -129,6 +129,7 @@ class PiAgent {
129
129
  // ring buffer of pi->browser events while no socket is attached, so a
130
130
  // reconnecting client can replay what happened in the background.
131
131
  this.eventBuffer = [];
132
+ this.bufferHead = 0;
132
133
  // lightweight summary of the task currently running in background, for
133
134
  // the /api/agents dashboard and reconnecting clients.
134
135
  this.lastUserPrompt = null;
@@ -303,18 +304,24 @@ class PiAgent {
303
304
 
304
305
  bufferEvent(obj) {
305
306
  if (EVENT_BUFFER_SIZE <= 0) return;
306
- this.eventBuffer.push(obj);
307
- if (this.eventBuffer.length > EVENT_BUFFER_SIZE) {
308
- this.eventBuffer.shift();
307
+ if (this.eventBuffer.length < EVENT_BUFFER_SIZE) {
308
+ this.eventBuffer.push(obj);
309
+ } else {
310
+ this.eventBuffer[this.bufferHead] = obj;
311
+ this.bufferHead = (this.bufferHead + 1) % EVENT_BUFFER_SIZE;
309
312
  }
310
313
  }
311
314
 
312
315
  replayBuffered(ws) {
313
316
  if (this.eventBuffer.length === 0) return;
314
317
  if (ws.readyState !== 1) return;
318
+ const count = this.eventBuffer.length;
315
319
  // Send a marker so the client knows the next burst is backfill, not live.
316
- try { ws.send(JSON.stringify({ type: "backfill_start", count: this.eventBuffer.length })); } catch {}
317
- for (const ev of this.eventBuffer) {
320
+ try { ws.send(JSON.stringify({ type: "backfill_start", count })); } catch {}
321
+ const start = count === EVENT_BUFFER_SIZE ? this.bufferHead : 0;
322
+ for (let i = 0; i < count; i++) {
323
+ const idx = (start + i) % EVENT_BUFFER_SIZE;
324
+ const ev = this.eventBuffer[idx];
318
325
  try { ws.send(JSON.stringify(ev)); } catch {}
319
326
  }
320
327
  try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
@@ -347,12 +354,17 @@ class PiAgent {
347
354
 
348
355
  wsSend(obj, excludeSocket = null) {
349
356
  const payload = typeof obj === "string" ? obj : JSON.stringify(obj);
350
- for (const ws of this.sockets) {
357
+ for (const ws of [...this.sockets]) {
358
+ if (ws.readyState > 1) { // CLOSING or CLOSED
359
+ this.sockets.delete(ws);
360
+ continue;
361
+ }
351
362
  if (ws !== excludeSocket && ws.readyState === 1) {
352
363
  try {
353
364
  ws.send(payload);
354
365
  } catch (e) {
355
366
  console.error("wsSend error", e);
367
+ this.sockets.delete(ws);
356
368
  }
357
369
  }
358
370
  }
@@ -462,6 +474,47 @@ async function listAllSessionFiles() {
462
474
  return files;
463
475
  }
464
476
 
477
+ // Memory metadata cache keyed by file path -> { mtimeMs, cwd, sessionInfo }
478
+ const sessionMetadataCache = new Map();
479
+
480
+ async function getSessionMetadata(file) {
481
+ const fileStat = await stat(file);
482
+ const cached = sessionMetadataCache.get(file);
483
+ if (cached && cached.mtimeMs === fileStat.mtimeMs) {
484
+ return cached;
485
+ }
486
+
487
+ const content = await readFile(file, "utf8");
488
+ const lines = content.split("\n").filter(Boolean);
489
+ let header = null, title = null, msgCount = 0;
490
+ for (const line of lines) {
491
+ let o;
492
+ try { o = JSON.parse(line); } catch { continue; }
493
+ if (o.type === "session") header = o;
494
+ if (o.type === "message" && o.message && o.message.role === "user" && !title) {
495
+ title = extractText(o.message.content).slice(0, 80);
496
+ }
497
+ if (o.type === "message") msgCount++;
498
+ }
499
+
500
+ if (!header) return null;
501
+
502
+ const result = {
503
+ mtimeMs: fileStat.mtimeMs,
504
+ cwd: normalizeCwd(header.cwd),
505
+ sessionInfo: {
506
+ file,
507
+ name: path.basename(file),
508
+ id: header.id,
509
+ timestamp: header.timestamp,
510
+ firstUser: title,
511
+ messageCount: msgCount,
512
+ },
513
+ };
514
+ sessionMetadataCache.set(file, result);
515
+ return result;
516
+ }
517
+
465
518
  app.get("/api/sessions", async (req, res) => {
466
519
  try {
467
520
  const cwd = normalizeCwd(req.query.cwd);
@@ -469,27 +522,10 @@ app.get("/api/sessions", async (req, res) => {
469
522
  const sessions = [];
470
523
  for (const full of all) {
471
524
  try {
472
- const content = await readFile(full, "utf8");
473
- const lines = content.split("\n").filter(Boolean);
474
- let header = null, title = null, msgCount = 0;
475
- for (const line of lines) {
476
- let o;
477
- try { o = JSON.parse(line); } catch { continue; }
478
- if (o.type === "session") header = o;
479
- if (o.type === "message" && o.message && o.message.role === "user" && !title) {
480
- title = extractText(o.message.content).slice(0, 80);
481
- }
482
- if (o.type === "message") msgCount++;
525
+ const meta = await getSessionMetadata(full);
526
+ if (meta && meta.cwd === cwd) {
527
+ sessions.push(meta.sessionInfo);
483
528
  }
484
- if (!header || normalizeCwd(header.cwd) !== cwd) continue;
485
- sessions.push({
486
- file: full,
487
- name: path.basename(full),
488
- id: header.id,
489
- timestamp: header.timestamp,
490
- firstUser: title,
491
- messageCount: msgCount,
492
- });
493
529
  } catch {}
494
530
  }
495
531
  sessions.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));