@liguoshuai/pi-web-chat 1.7.4 → 1.7.6
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 +8 -0
- package/bin/pi-web-chat.js +34 -6
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +27 -0
- package/docs/ISSUES.md +48 -0
- package/package.json +1 -1
- package/public/app.js +11 -8
- package/server.js +19 -9
package/README.md
CHANGED
|
@@ -154,6 +154,10 @@ pi-web-chat/
|
|
|
154
154
|
│ ├── index.html 单页 UI
|
|
155
155
|
│ ├── app.js 前端逻辑与状态机
|
|
156
156
|
│ └── style.css ChatGPT/Gemini 风格样式
|
|
157
|
+
├── scripts/ Systemd 服务安装与管理脚本
|
|
158
|
+
│ ├── pi-web-chat.service Systemd user unit 模板
|
|
159
|
+
│ ├── install-service.sh 一键安装与启动脚本
|
|
160
|
+
│ └── uninstall-service.sh 一键卸载脚本
|
|
157
161
|
└── docs/ 项目文档库
|
|
158
162
|
├── ARCHITECTURE.md 架构设计文档
|
|
159
163
|
├── DESIGN.md 详细设计与决策文档
|
|
@@ -210,6 +214,10 @@ journalctl --user -u pi-web-chat -f
|
|
|
210
214
|
|
|
211
215
|
卸载服务:
|
|
212
216
|
```bash
|
|
217
|
+
# 使用一键卸载脚本
|
|
218
|
+
./scripts/uninstall-service.sh
|
|
219
|
+
|
|
220
|
+
# 或手动清理:
|
|
213
221
|
systemctl --user disable --now pi-web-chat
|
|
214
222
|
rm ~/.config/systemd/user/pi-web-chat.service
|
|
215
223
|
systemctl --user daemon-reload
|
package/bin/pi-web-chat.js
CHANGED
|
@@ -37,15 +37,43 @@ Examples:
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function parseArgs(argv) {
|
|
40
|
-
const opts = { port: process.env.PORT || 3000, cwd: process.env.HOME };
|
|
40
|
+
const opts = { port: process.env.PORT || 3000, cwd: process.env.HOME || os.homedir() };
|
|
41
41
|
for (let i = 0; i < argv.length; i++) {
|
|
42
42
|
const a = argv[i];
|
|
43
43
|
if (a === "-h" || a === "--help") { printHelp(); process.exit(0); }
|
|
44
|
-
if (a === "-p" || a === "--port")
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
if (a === "-p" || a === "--port") {
|
|
45
|
+
const val = argv[++i];
|
|
46
|
+
if (!val || isNaN(Number(val))) {
|
|
47
|
+
console.error(`Error: --port requires a valid number`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
opts.port = Number(val);
|
|
51
|
+
} else if (a.startsWith("--port=")) {
|
|
52
|
+
const val = a.split("=")[1];
|
|
53
|
+
if (!val || isNaN(Number(val))) {
|
|
54
|
+
console.error(`Error: --port requires a valid number`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
opts.port = Number(val);
|
|
58
|
+
} else if (a === "-c" || a === "--cwd") {
|
|
59
|
+
const val = argv[++i];
|
|
60
|
+
if (!val) {
|
|
61
|
+
console.error(`Error: --cwd requires a path`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
opts.cwd = val;
|
|
65
|
+
} else if (a.startsWith("--cwd=")) {
|
|
66
|
+
const val = a.split("=")[1];
|
|
67
|
+
if (!val) {
|
|
68
|
+
console.error(`Error: --cwd requires a path`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
opts.cwd = val;
|
|
72
|
+
} else {
|
|
73
|
+
console.error(`Unknown option: ${a}`);
|
|
74
|
+
printHelp();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
49
77
|
}
|
|
50
78
|
return opts;
|
|
51
79
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.7.6] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Markdown 渲染 HTML 属性逃逸注入安全漏洞修复 (HTML Attribute Breakout XSS Vulnerability)**:升级 `public/app.js` 中的 `escapeHtml` 严密性,将原有对 `&`、`<`、`>` 的处理,扩展至双引号 `"` (`"`) 与单引号 `'` (`'`),阻断由于模型生成恶意链接等引起的 `href` 属性逃逸与任意 HTML 属性/事件(XSS)注入风险。
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **多设备与多标签页流式事件同步及中途连入追平优化 (Multi-device/Multi-tab Live Event Streaming Sync)**:
|
|
17
|
+
- 优化 `PiAgent` 事件录制范围:只要处于流式生成(Busy/Streaming)状态,即便当前已有在线浏览器连接,也会向 `eventBuffer` 持续录制。
|
|
18
|
+
- 优化缓存释放策略:若 Agent 仍处于 busy 生成状态,新连入的客户端消费完 `eventBuffer` 后不再立即清空 Buffer。
|
|
19
|
+
- 优化重连/中途断开录制:若最后一个客户端在 streaming 途中断开连接,只要生成任务仍在运行中,即不销毁当前的缓存。
|
|
20
|
+
- 以上多项优化完美实现了多台设备(例如手机、电脑)、多个浏览器 Tab 随时刷新或中途连入正在运行中的流式会话时,能够无缝重播已生成的上半段内容并追平后续实时流,极大地增强了多端多 Tab 的协同可靠性。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## [1.7.5] - 2026-07-26
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **初始化 CWD 配置加载与 WebSocket 建立竞态修复 (Async CWD Init Race Condition)**:
|
|
28
|
+
- 前端 `init()` 声明为异步函数,优先 `await loadServerConfig()` 完成服务器配置(`serverCwd` / `homeDir`)与本地缓存读取后,再发起 WebSocket 连接或加载会话,彻底消除首次加载时 agent 子进程工作目录与前端显示不一致的问题。
|
|
29
|
+
- 服务端 `normalizeCwd(dir)` 在 `dir` 为空时默认使用 `process.cwd()`(服务启动目录),保证前后端默认工作目录始终精确统一。
|
|
30
|
+
- **错误信息二次 HTML 转义修复 (Double Escape on Error Messages)**:移除 `message_end` 中冗余的 `escapeHtml()` 调用,避免错误信息包含特殊符号时被 Markdown 渲染器二次转义显示为实体编码字符。
|
|
31
|
+
- **CLI 参数解析健壮性增强 (CLI Arguments Validation)**:`bin/pi-web-chat.js` 增加对 `-p/--port` 与 `-c/--cwd` 选项值的合法性检验与防越界保护,避免 `NaN` 或 `undefined`。
|
|
32
|
+
- **输入法合成事件优化 (IME Keycode Handling)**:输入框 `keydown` 事件追加 `e.keyCode !== 229` 判断,进一步增强各平台中文输入法选词回车时的兼容性。
|
|
33
|
+
- **Systemd 安装脚本优化 (Systemd Absolute ExecStart Path)**:`scripts/install-service.sh` 生成 unit 文件时将 `ExecStart` 明确为绝对路径 `$NODE_BIN "$PROJECT_DIR/server.js"`。
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
10
37
|
## [1.7.4] - 2026-07-26
|
|
11
38
|
|
|
12
39
|
### Added
|
package/docs/ISSUES.md
CHANGED
|
@@ -99,4 +99,52 @@
|
|
|
99
99
|
- 给 `.session-list` 增加 `min-height: 0`,给 `.sidebar-bottom` 增加 `flex-shrink: 0`。
|
|
100
100
|
- 引入 `safe-area-inset-bottom` 底部安全区自适应内边距,并将 z-index 提升至 105,确保手机端完整可见。
|
|
101
101
|
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 10. 页面初次加载时服务端配置加载与 WebSocket 连接的竞态
|
|
105
|
+
|
|
106
|
+
**症状**:在未设置 localStorage CWD 时打开页面,前端顶栏胶囊显示为当前服务启动目录(`serverCwd`),但后端生成的 agent 子进程的工作目录实际上是用户的 home 目录(`~`),导致执行文件操作或命令时位置不一致。
|
|
107
|
+
|
|
108
|
+
**根因**:前端 `init()` 中 `loadServerConfig()` 异步发起 `/api/config` 请求,但没有等待返回就同步调用了 `connectWs({})`。此时 `state.cwd` 尚为空字符串 `""`,WebSocket URL 为 `/ws?cwd=`,后端 fallback 成了 `home()`。等到 fetch 返回后 `state.cwd` 虽被赋予 `serverCwd`,但 WebSocket 连接和 `PiAgent` 已经按 `home()` 创建。
|
|
109
|
+
|
|
110
|
+
**修复**:
|
|
111
|
+
- 前端 `init()` 改为异步函数,显式 `await loadServerConfig()` 保证配置解析完毕后再建立 WebSocket 连接或加载会话。
|
|
112
|
+
- 后端 `normalizeCwd(dir)` 在 `dir` 为空时优先返回 `process.cwd()`,其次回退到 `home()`,确保前后端默认工作目录始终精确一致。
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 11. 模型异常响应错误信息二次转义 (Double Escape)
|
|
117
|
+
|
|
118
|
+
**症状**:当模型返回错误(如 stopReason="error")且错误信息中含有 `<`、`&` 等字符时,聊天框中显示为 `<`、`&` 字面量。
|
|
119
|
+
|
|
120
|
+
**根因**:`public/app.js` 在 `message_end` 处理中对 `errMsg` 调用了一次 `escapeHtml()`,随后 `refreshStreamingContent()` 调用 `renderMarkdown()` 时又内部进行了一次 `escapeHtml()`。
|
|
121
|
+
|
|
122
|
+
**修复**:移除 `message_end` 中冗余的 `escapeHtml()`,统一由 Markdown 渲染器进行安全转义。
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 12. Markdown 渲染中潜在的 XSS 注入安全漏洞 (HTML Attribute Breakout)
|
|
127
|
+
|
|
128
|
+
**症状**:若模型返回了特制格式的 Markdown 链接(例如 `[点击](http://abc.com" style="..." onmouseover="alert(1))`),该链接渲染为 HTML 时能逃逸 `href="..."` 属性,注入任意 HTML 属性与恶意 JavaScript。
|
|
129
|
+
|
|
130
|
+
**根因**:原先 `escapeHtml()` 仅转义了 `&`、`<`、`>`,未能转义双引号 `"` 与单引号 `'`,使得属性逃逸攻击成为可能。
|
|
131
|
+
|
|
132
|
+
**修复**:修改 `public/app.js` 中的 `escapeHtml()` 实现,额外对 `"` (`"`) 与 `'` (`'`) 进行了严格转义,阻止任何 HTML 属性级别的注入攻击。
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 13. 多设备 / 多 Tab 流式对话时新连入客户端无法追平进度
|
|
137
|
+
|
|
138
|
+
**症状**:在某一会话处于流式文本生成(Streaming)状态时,若用户在另一台设备或新的浏览器标签页中打开相同会话,由于 `PiAgent` 检测到已连接客户端而不将流式事件写入 `eventBuffer`,新连入的客户端无法回放当前的流式消息,导致其显示为空白、缺页或卡在加载中。
|
|
139
|
+
|
|
140
|
+
**根因**:
|
|
141
|
+
- 原逻辑在 `onPiMessage` 中判断只有在无客户端连接时才缓存事件(`if (!this.hasWs) this.bufferEvent(obj)`)。
|
|
142
|
+
- 离线回放完毕后,会强制清空环形 Buffer,导致后续其他客户端连入时无内容可播。
|
|
143
|
+
|
|
144
|
+
**修复**:
|
|
145
|
+
- 在 `server.js` 的 `onPiMessage` 逻辑中,当 `PiAgent` 处于繁忙或流式对话过程中(`this.isBusy` 为真)即便有客户端在线也开启缓存。
|
|
146
|
+
- 修改 `detachWs`,当所有连接断开但 Agent 仍在生成中时,保留 `eventBuffer` 保证继续录制。
|
|
147
|
+
- 修改 `replayBuffered`,在 Agent 仍处于 busy 状态时消费后不清空缓存,使多个设备/多个 Tab 可多次或同时连入并安全回溯追平全部生成细节,并在流式终止时通过 `agent_settled` 进行最终的统一清理。
|
|
148
|
+
|
|
149
|
+
|
|
102
150
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -177,7 +177,11 @@ async function confirmCwdChange() {
|
|
|
177
177
|
|
|
178
178
|
// ---- Markdown render (small, safe renderer) ----
|
|
179
179
|
function escapeHtml(s) {
|
|
180
|
-
return s.replace(/&/g, "&")
|
|
180
|
+
return s.replace(/&/g, "&")
|
|
181
|
+
.replace(/</g, "<")
|
|
182
|
+
.replace(/>/g, ">")
|
|
183
|
+
.replace(/"/g, """)
|
|
184
|
+
.replace(/'/g, "'");
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
async function copyToClipboard(text) {
|
|
@@ -1226,7 +1230,7 @@ function handlePiMessage(obj) {
|
|
|
1226
1230
|
const parsed = JSON.parse(errMsg);
|
|
1227
1231
|
if (parsed.error?.message) errMsg = parsed.error.message;
|
|
1228
1232
|
} catch {}
|
|
1229
|
-
state.streamingItems.push({ type: "text", text: `⚠️ **${
|
|
1233
|
+
state.streamingItems.push({ type: "text", text: `⚠️ **${errMsg}**` });
|
|
1230
1234
|
refreshStreamingContent();
|
|
1231
1235
|
}
|
|
1232
1236
|
break;
|
|
@@ -1668,11 +1672,13 @@ function autoResize() {
|
|
|
1668
1672
|
}
|
|
1669
1673
|
|
|
1670
1674
|
// ---- Init ----
|
|
1671
|
-
function init() {
|
|
1675
|
+
async function init() {
|
|
1672
1676
|
// Default cwd to home (server uses home default too).
|
|
1673
1677
|
state.cwd = document.body.dataset.cwd || "";
|
|
1674
1678
|
|
|
1675
|
-
//
|
|
1679
|
+
// Load server config & restore saved CWD before connecting WebSocket
|
|
1680
|
+
await loadServerConfig();
|
|
1681
|
+
|
|
1676
1682
|
// event listeners
|
|
1677
1683
|
$("#btnNew").addEventListener("click", () => {
|
|
1678
1684
|
if (state.streaming) {
|
|
@@ -1716,7 +1722,7 @@ function init() {
|
|
|
1716
1722
|
updateComposerUI();
|
|
1717
1723
|
});
|
|
1718
1724
|
ta.addEventListener("keydown", (e) => {
|
|
1719
|
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
1725
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
|
1720
1726
|
e.preventDefault();
|
|
1721
1727
|
if (state.streaming) {
|
|
1722
1728
|
if (ta.value.trim()) {
|
|
@@ -1826,9 +1832,6 @@ function init() {
|
|
|
1826
1832
|
});
|
|
1827
1833
|
}
|
|
1828
1834
|
|
|
1829
|
-
// Load server config & restore saved CWD
|
|
1830
|
-
loadServerConfig();
|
|
1831
|
-
|
|
1832
1835
|
// Status badge click to reconnect
|
|
1833
1836
|
const connStatusEl = $("#connStatus");
|
|
1834
1837
|
if (connStatusEl) {
|
package/server.js
CHANGED
|
@@ -37,7 +37,7 @@ const PI_BIN = resolvePiBin();
|
|
|
37
37
|
function home() { return os.homedir(); }
|
|
38
38
|
|
|
39
39
|
function normalizeCwd(dir) {
|
|
40
|
-
if (!dir) return home();
|
|
40
|
+
if (!dir) return process.cwd() || home();
|
|
41
41
|
let resolved = dir;
|
|
42
42
|
if (dir.startsWith("~")) {
|
|
43
43
|
resolved = path.join(home(), dir.slice(1));
|
|
@@ -159,9 +159,13 @@ class PiAgent {
|
|
|
159
159
|
detachWs(ws) {
|
|
160
160
|
this.sockets.delete(ws);
|
|
161
161
|
if (this.sockets.size === 0) {
|
|
162
|
-
// Reset event buffer
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
// Reset event buffer only if NOT busy.
|
|
163
|
+
// If we are currently streaming/busy, we must retain the buffer so a reconnecting client
|
|
164
|
+
// can replay the full stream from the beginning of the active generation.
|
|
165
|
+
if (!this.isBusy) {
|
|
166
|
+
this.eventBuffer = [];
|
|
167
|
+
this.bufferHead = 0;
|
|
168
|
+
}
|
|
165
169
|
// Browser closed. We do NOT kill the subprocess here: a background task
|
|
166
170
|
// keeps running. We only arm the idle-kill, which fires once the agent
|
|
167
171
|
// is truly idle (no streaming, no pending requests) for IDLE_TIMEOUT_MS.
|
|
@@ -326,8 +330,11 @@ class PiAgent {
|
|
|
326
330
|
}
|
|
327
331
|
// Forward every event / response to connected browsers as-is.
|
|
328
332
|
this.wsSend(obj);
|
|
329
|
-
// If nobody is listening,
|
|
330
|
-
|
|
333
|
+
// If nobody is listening, or if the agent is currently busy (streaming), remember it
|
|
334
|
+
// so any reconnecting or newly connecting clients can replay and catch up.
|
|
335
|
+
if (!this.hasWs || this.isBusy) {
|
|
336
|
+
this.bufferEvent(obj);
|
|
337
|
+
}
|
|
331
338
|
}
|
|
332
339
|
|
|
333
340
|
bufferEvent(obj) {
|
|
@@ -353,9 +360,12 @@ class PiAgent {
|
|
|
353
360
|
try { ws.send(JSON.stringify(ev)); } catch {}
|
|
354
361
|
}
|
|
355
362
|
try { ws.send(JSON.stringify({ type: "backfill_end", streaming: this.isBusy, state: this.state })); } catch {}
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
this.
|
|
363
|
+
// Only clear the buffer if the agent is not busy.
|
|
364
|
+
// If the agent is still busy, keep the buffer so that other clients or future reconnects can still catch up.
|
|
365
|
+
if (!this.isBusy) {
|
|
366
|
+
this.eventBuffer = [];
|
|
367
|
+
this.bufferHead = 0;
|
|
368
|
+
}
|
|
359
369
|
}
|
|
360
370
|
|
|
361
371
|
send(cmd) {
|