@liguoshuai/pi-web-chat 1.8.3 → 1.8.4
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/docs/ARCHITECTURE.md +2 -1
- package/docs/CHANGELOG.md +23 -0
- package/docs/ISSUES.md +57 -5
- package/package.json +2 -1
- package/public/app.js +122 -53
- package/public/style.css +19 -1
- package/scripts/install-service.sh +84 -0
- package/scripts/pi-web-chat.service +41 -0
- package/scripts/uninstall-service.sh +25 -0
- package/server.js +21 -14
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构设计文档
|
|
2
2
|
|
|
3
|
-
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.
|
|
3
|
+
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.8.4 版本)。
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -176,5 +176,6 @@ pi-web-chat/
|
|
|
176
176
|
│ └── CHANGELOG.md # 版本变更日志
|
|
177
177
|
└── scripts/ # 服务安装/卸载脚本
|
|
178
178
|
├── install-service.sh
|
|
179
|
+
├── uninstall-service.sh
|
|
179
180
|
└── pi-web-chat.service
|
|
180
181
|
```
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.8.4] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Markdown 渲染能力增强 (Markdown Enhancements: HR, Strikethrough & Task Checkboxes)**:
|
|
14
|
+
- 支持标准 Markdown 水平分割线(`---`、`***`、`___`)渲染为 `<hr>` 标签。
|
|
15
|
+
- 支持删除线语法(`~~strikethrough~~`)渲染为 `<del>` 标签。
|
|
16
|
+
- 支持任务列表复选框(`- [ ]` 与 `- [x]`)及加号无序列表(`+ item`)。
|
|
17
|
+
- 为 `h4`、`h5`、`h6`、`hr`、`del` 及任务复选框补充了精细的暗色主题 CSS 样式。
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **移动端息屏/切出恢复后历史消息自动同步 (Mobile Reconnect Session Auto-Sync)**:
|
|
21
|
+
- 修复手机端在后台挂起或息屏后切回页面时,未能及时同步后台已生成完毕的最新回复内容的问题。
|
|
22
|
+
- 增加 `syncSessionHistory` 历史同步机制,并在 `ws.onopen`、`visibilitychange` 与 `pageshow` 事件中自动拉取磁盘最新消息渲染至对话区。
|
|
23
|
+
- 优化 `backfill_start` / `backfill_end` 流式状态重置,保障后台生成途中切回时无缝追平实时生成内容。
|
|
24
|
+
- **未绑定会话孤儿子进程即时回收 (Immediate Cleanup of Unkeyed Idle Agents)**:当客户端打开新建会话页面但未发送消息即关闭或切换时,后端未分配 Session Key 的孤立 `PiAgent` 进程在连接断开后立即释放,避免占用 5 分钟闲置内存。
|
|
25
|
+
- **切换与加载历史会话时工作目录自动同步 (Session CWD Auto-Sync)**:点击侧边栏或通过 URL 参数(`?session=...`)打开会话时,自动将 `state.cwd` 同步为会话真实的 `data.header.cwd`,确保 Agent 执行环境与会话一致。
|
|
26
|
+
- **工作目录变更后项目级默认配置实时重载 (Project Settings Live Reload on CWD Change)**:切换工作目录时重新获取 `/api/config?cwd=...`,确保项目根目录下的 `.pi/settings.json` 模型配置即时生效。
|
|
27
|
+
- **波浪号路径解析规范化 (Tilde `~` Path Resolution Normalization)**:服务端引入统一的 `normalizePath`,确保会话参数与路径校验中包含的 `~` 家目录前缀能被正确展开并绝对化。
|
|
28
|
+
- **历史错误消息展示保护 (Error Notice on Historical Failed Messages)**:修复历史会话中若生成中途报错中断时未能渲染失败提示条的问题。
|
|
29
|
+
- **Package.json 发布字段补充 (Scripts Directory Inclusion)**:在 `package.json` 的 `files` 字段中加入 `scripts/` 目录,确保 Systemd 服务脚本正常打包发布。
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
10
33
|
## [1.8.3] - 2026-07-26
|
|
11
34
|
|
|
12
35
|
### Fixed
|
package/docs/ISSUES.md
CHANGED
|
@@ -190,6 +190,19 @@
|
|
|
190
190
|
|
|
191
191
|
---
|
|
192
192
|
|
|
193
|
+
## 18. 移动端顶栏空间挤压与思考胶囊截断
|
|
194
|
+
|
|
195
|
+
**症状**:在手机端竖屏打开页面时,顶栏右侧的“深度思考 / 推理级别”(`🧠 High`)胶囊被挤压,只能看到一半的脑花图标,文本完全不可见。
|
|
196
|
+
|
|
197
|
+
**根因**:移动端媒体查询中为 `.model-pill-container` 设置了 `max-width: 130px` 的硬限制。由于模型胶囊占据了大部分宽度,容器剩余宽度不足以完整容纳思考胶囊,导致其被裁剪。
|
|
198
|
+
|
|
199
|
+
**修复**:
|
|
200
|
+
- 移除移动端 `.model-pill-container` 的 `max-width: 130px` 限制,改为弹性无约束容器。
|
|
201
|
+
- 优化顶栏各按钮尺寸与间距:缩小菜单按钮为 36px,工作目录胶囊限制最大 75px,模型胶囊适度缩略模型名并隐藏手机端冗余的“默认”徽章,为思考胶囊留出充足显示空间。
|
|
202
|
+
- 追加 `<= 360px` 极窄屏响应式规则,确保在 iPhone SE 等小屏设备上也能完整显示。
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
193
206
|
## 19. 移动端切换工作目录弹窗按钮超出边框
|
|
194
207
|
|
|
195
208
|
**症状**:在手机端点击顶栏切换工作目录(CWD Modal)时,右侧的“确定切换”按钮超出了弹窗白色/暗色卡片右边框。
|
|
@@ -203,14 +216,53 @@
|
|
|
203
216
|
- 在移动端媒体查询中调整弹窗内边距为 16px,优化输入框与按钮的 padding。
|
|
204
217
|
- 在 `<= 380px` 超窄屏设备上自适应将输入框与操作按钮上下垂直排列(`flex-direction: column`),确保彻底杜绝横向溢出。
|
|
205
218
|
|
|
206
|
-
|
|
219
|
+
---
|
|
207
220
|
|
|
208
|
-
|
|
221
|
+
## 20. 未绑定会话孤儿子进程即时回收
|
|
222
|
+
|
|
223
|
+
**症状**:客户端打开新建会话页面但未发送任何 Prompt 就关闭页面或切换至其他会话时,后端生成的未分配 Session Key 的孤立 `PiAgent` 子进程会持续占用内存 5 分钟后才被回收。
|
|
224
|
+
|
|
225
|
+
**根因**:原先 `detachWs` 统一对所有断连进程开启 5 分钟 `IDLE_TIMEOUT_MS` 倒计时。然而未绑定会话 Key 的进程由于没有 Session Key,后续没有任何客户端可以重新挂载接入该进程。
|
|
226
|
+
|
|
227
|
+
**修复**:在 `detachWs` 中增加判断,若 Agent 未分配 Session Key(`!this.sessionKey`)且处于完全空闲状态(`!this.isBusy`),则在最后一个连接断开时立即调用 `stop()` 释放进程资源,避免无用进程驻留内存。
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## 21. 切换与直接加载历史会话时工作目录 (CWD) 自动同步
|
|
232
|
+
|
|
233
|
+
**症状**:通过 URL 参数(如 `?session=...`)或点击侧边栏加载属于其他工作目录的历史会话时,前端工作目录(CWD)未能联动更新,导致后续发送消息时 Agent 在错误的工作目录下执行命令与操作文件。
|
|
234
|
+
|
|
235
|
+
**根因**:`loadSession` 在从 `/api/session` 接口获取会话详情后,仅解析了 `model` 和消息历史,未读取 `header.cwd` 同步更新 `state.cwd`。
|
|
236
|
+
|
|
237
|
+
**修复**:在 `loadSession` 中自动比对并应用 `data.header.cwd`,同步更新 `state.cwd`、`localStorage` 及顶栏工作目录显示,确保后续 WebSocket 连接与 Agent 子进程的工作目录与会话原始目录精确一致。
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## 22. 波浪号 (`~`) 路径解析规范化与 Markdown 渲染增强
|
|
242
|
+
|
|
243
|
+
**症状**:
|
|
244
|
+
1. 若 WebSocket 会话参数或切换会话传入带有 `~` 的路径,Node 原生 `path.resolve` 无法自动展开用户家目录,导致路径解析不一致。
|
|
245
|
+
2. 模型输出的 Markdown 水平分割线(`---` / `***`)、删除线(`~~strikethrough~~`)、任务复选框(`- [ ]` / `- [x]`)未能正确渲染。
|
|
209
246
|
|
|
210
247
|
**修复**:
|
|
211
|
-
-
|
|
212
|
-
-
|
|
213
|
-
|
|
248
|
+
- 服务端增加 `normalizePath` 统一处理 `~` 家目录展开与绝对路径规范化。
|
|
249
|
+
- 前端 Markdown 渲染器增加对水平分割线 `<hr>`、删除线 `<del>`、任务列表复选框以及加号无序列表(`+ item`)的完整语法支持与对应样式。
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## 23. 移动端切出/息屏恢复后会话未同步后台最新对话内容
|
|
254
|
+
|
|
255
|
+
**症状**:在手机端发送消息或查看对话时,若切出应用或息屏锁定,等待一段时间后再返回页面(WebSocket 提示“网络连接已恢复”),但聊天窗口中仍停留在息屏前的旧状态,未能展示后台已生成完毕的完整回复。
|
|
256
|
+
|
|
257
|
+
**根因**:
|
|
258
|
+
1. 手机端在息屏或后台挂起时,浏览器暂停 JS 执行并断开 WebSocket。
|
|
259
|
+
2. 任务在服务端后台运行完毕后,pi 将完整消息写入磁盘 `.jsonl` 并触发 `agent_settled`,随后服务端清空了针对该轮次的事件环形 Buffer。
|
|
260
|
+
3. 手机端唤醒并重新建立 WebSocket 连接后,前端仅请求了 `get_state`,未主动从服务端 `/api/session` 重新拉取最新的会话历史消息,导致 DOM 中残留断线前的未完成状态。
|
|
261
|
+
|
|
262
|
+
**修复**:
|
|
263
|
+
- 抽取通用的 `syncSessionHistory(file)` 历史同步机制。
|
|
264
|
+
- 在 `ws.onopen`(重连成功时)、`visibilitychange`(页面可见性恢复时)以及 `pageshow`(移动端切回页面时)自动触发 `syncSessionHistory`,重新拉取并渲染磁盘最新消息。
|
|
265
|
+
- 完善 `backfill_start` / `backfill_end` 流式状态重置,确保如果在后台仍在流式生成途中切回,能清空旧数据并无缝追平实时流式内容。
|
|
214
266
|
|
|
215
267
|
|
|
216
268
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
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",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"bin/",
|
|
13
13
|
"public/",
|
|
14
14
|
"docs/",
|
|
15
|
+
"scripts/",
|
|
15
16
|
"README.md",
|
|
16
17
|
"LICENSE"
|
|
17
18
|
],
|
package/public/app.js
CHANGED
|
@@ -71,9 +71,10 @@ function updateCwdDisplay() {
|
|
|
71
71
|
if (wrap) wrap.title = `工作目录: ${state.cwd || "~"}`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function setCwd(newCwd) {
|
|
74
|
+
async function setCwd(newCwd) {
|
|
75
75
|
state.cwd = newCwd;
|
|
76
76
|
localStorage.setItem("pi_cwd", newCwd);
|
|
77
|
+
await loadServerConfig();
|
|
77
78
|
updateCwdDisplay();
|
|
78
79
|
clearChat();
|
|
79
80
|
showEmptyState(true);
|
|
@@ -112,12 +113,18 @@ function saveRecentModel(model) {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
async function loadServerConfig() {
|
|
116
|
+
if (!state.cwd) {
|
|
117
|
+
state.cwd = localStorage.getItem("pi_cwd") || document.body.dataset.cwd || "";
|
|
118
|
+
}
|
|
115
119
|
try {
|
|
116
120
|
const cwdParam = state.cwd ? `?cwd=${encodeURIComponent(state.cwd)}` : "";
|
|
117
121
|
const res = await fetch(`${API}/api/config${cwdParam}`);
|
|
118
122
|
const data = await res.json();
|
|
119
123
|
if (data.home) state.homeDir = data.home;
|
|
120
124
|
if (data.serverCwd) state.serverCwd = data.serverCwd;
|
|
125
|
+
if (!state.cwd) {
|
|
126
|
+
state.cwd = state.serverCwd || state.homeDir || "";
|
|
127
|
+
}
|
|
121
128
|
if (data.version) {
|
|
122
129
|
state.version = data.version;
|
|
123
130
|
const verEl = $("#appVersion");
|
|
@@ -131,7 +138,7 @@ async function loadServerConfig() {
|
|
|
131
138
|
}
|
|
132
139
|
} catch {}
|
|
133
140
|
if (!state.cwd) {
|
|
134
|
-
state.cwd =
|
|
141
|
+
state.cwd = state.serverCwd || state.homeDir || "";
|
|
135
142
|
}
|
|
136
143
|
loadRecentModels();
|
|
137
144
|
updateCwdDisplay();
|
|
@@ -358,9 +365,14 @@ function renderInlineMd(text) {
|
|
|
358
365
|
flushList();
|
|
359
366
|
outHtml += mdTable(seg.lines);
|
|
360
367
|
} else if (seg.kind === "line") {
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
368
|
+
// horizontal rule
|
|
369
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
370
|
+
flushPara();
|
|
371
|
+
flushList();
|
|
372
|
+
outHtml += "<hr>";
|
|
373
|
+
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
374
|
+
// headings
|
|
375
|
+
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
364
376
|
flushPara();
|
|
365
377
|
flushList();
|
|
366
378
|
const level = m[1].length;
|
|
@@ -373,7 +385,7 @@ function renderInlineMd(text) {
|
|
|
373
385
|
flushPara();
|
|
374
386
|
flushList();
|
|
375
387
|
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
376
|
-
} else if (/^\s*[
|
|
388
|
+
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
377
389
|
// list item — group consecutive into ul/ol
|
|
378
390
|
// simple inline handling: wrap each list item line.
|
|
379
391
|
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
@@ -384,7 +396,16 @@ function renderInlineMd(text) {
|
|
|
384
396
|
linkListOrdered = isOrdered;
|
|
385
397
|
outHtml += "<" + linkListOpen + ">";
|
|
386
398
|
}
|
|
387
|
-
|
|
399
|
+
let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
400
|
+
let taskPrefix = "";
|
|
401
|
+
if (/^\[ \]\s+/.test(itemText)) {
|
|
402
|
+
taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
|
|
403
|
+
itemText = itemText.replace(/^\[ \]\s+/, "");
|
|
404
|
+
} else if (/^\[[xX]\]\s+/.test(itemText)) {
|
|
405
|
+
taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
|
|
406
|
+
itemText = itemText.replace(/^\[[xX]\]\s+/, "");
|
|
407
|
+
}
|
|
408
|
+
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
388
409
|
} else {
|
|
389
410
|
flushList();
|
|
390
411
|
para.push(seg.text);
|
|
@@ -405,6 +426,8 @@ function mdInlineBlock(text) {
|
|
|
405
426
|
// bold
|
|
406
427
|
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
407
428
|
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
429
|
+
// strikethrough
|
|
430
|
+
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
408
431
|
// italic
|
|
409
432
|
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
|
|
410
433
|
s = s.replace(/(^|[^_])_([^_]+)_/g, "$1<em>$2</em>");
|
|
@@ -514,6 +537,44 @@ function initMobileToolbarFab() {
|
|
|
514
537
|
});
|
|
515
538
|
}
|
|
516
539
|
|
|
540
|
+
async function syncSessionHistory(file, force = false) {
|
|
541
|
+
if (!file) return;
|
|
542
|
+
try {
|
|
543
|
+
const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
|
|
544
|
+
if (!res.ok) return;
|
|
545
|
+
const data = await res.json();
|
|
546
|
+
if (!data || data.error) return;
|
|
547
|
+
|
|
548
|
+
if (data.header?.cwd && data.header.cwd !== state.cwd) {
|
|
549
|
+
state.cwd = data.header.cwd;
|
|
550
|
+
localStorage.setItem("pi_cwd", state.cwd);
|
|
551
|
+
updateCwdDisplay();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (data.model) {
|
|
555
|
+
state.currentModel = data.model;
|
|
556
|
+
renderModelPill();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
|
|
560
|
+
$("#topSessionName").textContent = topName;
|
|
561
|
+
|
|
562
|
+
// Only overwrite chat if force is true or we're not actively streaming mid-turn live
|
|
563
|
+
if (force || !state.streaming) {
|
|
564
|
+
clearChat();
|
|
565
|
+
const msgs = reconstructFromEntries(data.entries || []);
|
|
566
|
+
showEmptyState(msgs.length === 0);
|
|
567
|
+
for (const m of msgs) {
|
|
568
|
+
appendMessageNode(m.role, m);
|
|
569
|
+
}
|
|
570
|
+
scrollBottom();
|
|
571
|
+
refreshSessions();
|
|
572
|
+
}
|
|
573
|
+
} catch (e) {
|
|
574
|
+
console.warn("syncSessionHistory error:", e);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
517
578
|
async function loadSession(file) {
|
|
518
579
|
state.currentSessionFile = file;
|
|
519
580
|
state.streaming = false;
|
|
@@ -529,38 +590,9 @@ async function loadSession(file) {
|
|
|
529
590
|
if (window.innerWidth <= 768) {
|
|
530
591
|
closeSidebar();
|
|
531
592
|
}
|
|
532
|
-
|
|
533
|
-
const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
|
|
534
|
-
const data = await res.json();
|
|
535
|
-
|
|
536
|
-
if (!res.ok || data.error) {
|
|
537
|
-
showToast(`加载会话失败: ${data.error || "无法读取会话文件"}`);
|
|
538
|
-
showEmptyState(true);
|
|
539
|
-
state.currentSessionFile = null;
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
if (data.model) {
|
|
544
|
-
state.currentModel = data.model;
|
|
545
|
-
renderModelPill();
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
|
|
549
|
-
$("#topSessionName").textContent = topName;
|
|
550
|
-
|
|
551
|
-
clearChat();
|
|
552
|
-
document.querySelector("#emptyState").style.display = "none";
|
|
553
|
-
const chat = $("#chat-inner");
|
|
554
|
-
// Walk through path entries to render messages in order.
|
|
555
|
-
// We reconstruct assistant/user/toolResult blocks.
|
|
556
|
-
const msgs = reconstructFromEntries(data.entries || []);
|
|
557
|
-
for (const m of msgs) {
|
|
558
|
-
appendMessageNode(m.role, m);
|
|
559
|
-
}
|
|
593
|
+
await syncSessionHistory(file, true);
|
|
560
594
|
// Reconnect websocket pointed at this session so new prompts continue history.
|
|
561
595
|
connectWs({ session: file });
|
|
562
|
-
// Update sidebar active highlight
|
|
563
|
-
refreshSessions();
|
|
564
596
|
}
|
|
565
597
|
|
|
566
598
|
function reconstructFromEntries(entries) {
|
|
@@ -592,7 +624,7 @@ function reconstructFromEntries(entries) {
|
|
|
592
624
|
}
|
|
593
625
|
return part;
|
|
594
626
|
});
|
|
595
|
-
if (m.stopReason === "error"
|
|
627
|
+
if (m.stopReason === "error") {
|
|
596
628
|
let errMsg = m.errorMessage || "生成失败(模型返回错误)";
|
|
597
629
|
try {
|
|
598
630
|
const parsed = JSON.parse(errMsg);
|
|
@@ -613,7 +645,8 @@ function extractContentText(content) {
|
|
|
613
645
|
return content
|
|
614
646
|
.filter(c => c && (c.type === "text" || typeof c === "string"))
|
|
615
647
|
.map(c => typeof c === "string" ? c : (c.text || ""))
|
|
616
|
-
.
|
|
648
|
+
.filter(Boolean)
|
|
649
|
+
.join("\n\n");
|
|
617
650
|
}
|
|
618
651
|
|
|
619
652
|
function appendSystemNotice(text) {
|
|
@@ -846,14 +879,22 @@ function makeToolBlockFromCall(call) {
|
|
|
846
879
|
|
|
847
880
|
function summaryArgs(name, args) {
|
|
848
881
|
if (!args) return "";
|
|
882
|
+
let obj = args;
|
|
883
|
+
if (typeof args === "string") {
|
|
884
|
+
try { obj = JSON.parse(args); } catch { return args; }
|
|
885
|
+
}
|
|
849
886
|
try {
|
|
850
|
-
if (name === "bash" &&
|
|
851
|
-
if (name === "read" &&
|
|
852
|
-
if (name === "write" &&
|
|
853
|
-
if (name === "edit" &&
|
|
854
|
-
if (name === "ls" &&
|
|
855
|
-
if (name === "grep") return
|
|
856
|
-
if (name === "find") return
|
|
887
|
+
if (name === "bash" && obj.command) return obj.command;
|
|
888
|
+
if (name === "read" && obj.path) return obj.path;
|
|
889
|
+
if (name === "write" && obj.path) return obj.path;
|
|
890
|
+
if (name === "edit" && obj.path) return obj.path;
|
|
891
|
+
if (name === "ls" && obj.path) return obj.path;
|
|
892
|
+
if (name === "grep") return obj.pattern || "";
|
|
893
|
+
if (name === "find") return obj.pattern || obj.path || "";
|
|
894
|
+
if (typeof obj === "object" && obj !== null) {
|
|
895
|
+
const keys = Object.keys(obj);
|
|
896
|
+
if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
|
|
897
|
+
}
|
|
857
898
|
return "";
|
|
858
899
|
} catch { return ""; }
|
|
859
900
|
}
|
|
@@ -1121,14 +1162,18 @@ function connectWs(opts = {}) {
|
|
|
1121
1162
|
state.ws = ws;
|
|
1122
1163
|
ws._gen = myGen;
|
|
1123
1164
|
|
|
1124
|
-
ws.onopen = () => {
|
|
1165
|
+
ws.onopen = async () => {
|
|
1125
1166
|
if (ws._gen !== wsGen) return;
|
|
1126
1167
|
isConnecting = false;
|
|
1127
1168
|
setConnStatus("connected");
|
|
1128
1169
|
startPingInterval();
|
|
1129
1170
|
|
|
1130
|
-
|
|
1171
|
+
const isReconnecting = wasDisconnected || reconnectAttempts > 0 || opts.isReconnect;
|
|
1172
|
+
if (isReconnecting) {
|
|
1131
1173
|
showToast("网络连接已恢复");
|
|
1174
|
+
if (targetSession) {
|
|
1175
|
+
await syncSessionHistory(targetSession, true);
|
|
1176
|
+
}
|
|
1132
1177
|
}
|
|
1133
1178
|
wasDisconnected = false;
|
|
1134
1179
|
reconnectAttempts = 0;
|
|
@@ -1189,6 +1234,11 @@ function handlePiMessage(obj) {
|
|
|
1189
1234
|
// that happened in the background while no browser was attached.
|
|
1190
1235
|
if (obj.type === "backfill_start") {
|
|
1191
1236
|
state.isBackfilling = true;
|
|
1237
|
+
state.streaming = true;
|
|
1238
|
+
setComposerAborting(true);
|
|
1239
|
+
state.streamingItems = [];
|
|
1240
|
+
state.streamingMsg = null;
|
|
1241
|
+
state.activeToolCalls.clear();
|
|
1192
1242
|
return;
|
|
1193
1243
|
}
|
|
1194
1244
|
if (obj.type === "backfill_end") {
|
|
@@ -1199,6 +1249,11 @@ function handlePiMessage(obj) {
|
|
|
1199
1249
|
setComposerAborting(true);
|
|
1200
1250
|
ensureStreamingMsg();
|
|
1201
1251
|
refreshStreamingContent();
|
|
1252
|
+
} else {
|
|
1253
|
+
finalizeStreamingMsg();
|
|
1254
|
+
state.streaming = false;
|
|
1255
|
+
setComposerAborting(false);
|
|
1256
|
+
refreshSessions();
|
|
1202
1257
|
}
|
|
1203
1258
|
// jump to the latest content once the replay is done
|
|
1204
1259
|
requestAnimationFrame(scrollBottom);
|
|
@@ -1389,8 +1444,7 @@ function handlePiMessage(obj) {
|
|
|
1389
1444
|
case "tool_execution_update": {
|
|
1390
1445
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1391
1446
|
if (tc) {
|
|
1392
|
-
const
|
|
1393
|
-
const text = pr && pr.content ? (Array.isArray(pr.content) ? pr.content.map(c => c.text || "").join("") : "") : "";
|
|
1447
|
+
const text = extractContentText(obj.partialResult?.content);
|
|
1394
1448
|
tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
|
|
1395
1449
|
}
|
|
1396
1450
|
break;
|
|
@@ -1398,8 +1452,7 @@ function handlePiMessage(obj) {
|
|
|
1398
1452
|
case "tool_execution_end": {
|
|
1399
1453
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1400
1454
|
if (tc) {
|
|
1401
|
-
const
|
|
1402
|
-
const text = res && res.content ? (Array.isArray(res.content) ? res.content.map(c => c.text || "").join("") : "") : "";
|
|
1455
|
+
const text = extractContentText(obj.result?.content);
|
|
1403
1456
|
tc.body.innerHTML = escapeHtml(text) || "(无输出)";
|
|
1404
1457
|
tc.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
|
|
1405
1458
|
tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
|
|
@@ -1508,7 +1561,11 @@ function updateState(d) {
|
|
|
1508
1561
|
finalizeStreamingMsg();
|
|
1509
1562
|
state.streaming = false;
|
|
1510
1563
|
setComposerAborting(false);
|
|
1511
|
-
|
|
1564
|
+
if (state.currentSessionFile) {
|
|
1565
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
1566
|
+
} else {
|
|
1567
|
+
refreshSessions();
|
|
1568
|
+
}
|
|
1512
1569
|
}
|
|
1513
1570
|
}
|
|
1514
1571
|
}
|
|
@@ -2323,10 +2380,22 @@ async function init() {
|
|
|
2323
2380
|
scheduleReconnect(0);
|
|
2324
2381
|
} else {
|
|
2325
2382
|
try { state.ws.send(JSON.stringify({ type: "ping" })); } catch {}
|
|
2383
|
+
if (state.currentSessionFile && !state.streaming) {
|
|
2384
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
2385
|
+
}
|
|
2326
2386
|
}
|
|
2327
2387
|
}
|
|
2328
2388
|
});
|
|
2329
2389
|
|
|
2390
|
+
window.addEventListener("pageshow", () => {
|
|
2391
|
+
if (!state.wsConnected) {
|
|
2392
|
+
reconnectAttempts = 0;
|
|
2393
|
+
scheduleReconnect(0);
|
|
2394
|
+
} else if (state.currentSessionFile && !state.streaming) {
|
|
2395
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2330
2399
|
// Global event delegation for code block copy buttons
|
|
2331
2400
|
document.addEventListener("click", async (e) => {
|
|
2332
2401
|
const btn = e.target.closest(".btn-copy-code");
|
package/public/style.css
CHANGED
|
@@ -439,12 +439,30 @@ body {
|
|
|
439
439
|
}
|
|
440
440
|
.msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
|
|
441
441
|
.msg.assistant .content li { margin: 4px 0; }
|
|
442
|
-
.msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3
|
|
442
|
+
.msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3,
|
|
443
|
+
.msg.assistant .content h4, .msg.assistant .content h5, .msg.assistant .content h6 {
|
|
443
444
|
margin: 16px 0 10px; line-height: 1.3;
|
|
444
445
|
}
|
|
445
446
|
.msg.assistant .content h1 { font-size: 20px; }
|
|
446
447
|
.msg.assistant .content h2 { font-size: 18px; }
|
|
447
448
|
.msg.assistant .content h3 { font-size: 16px; }
|
|
449
|
+
.msg.assistant .content h4 { font-size: 14.5px; }
|
|
450
|
+
.msg.assistant .content h5 { font-size: 13.5px; }
|
|
451
|
+
.msg.assistant .content h6 { font-size: 12.5px; }
|
|
452
|
+
.msg.assistant .content hr {
|
|
453
|
+
border: none;
|
|
454
|
+
border-top: 1px solid var(--border);
|
|
455
|
+
margin: 16px 0;
|
|
456
|
+
}
|
|
457
|
+
.msg.assistant .content del {
|
|
458
|
+
text-decoration: line-through;
|
|
459
|
+
opacity: 0.7;
|
|
460
|
+
}
|
|
461
|
+
.msg.assistant .content .task-list-item-checkbox {
|
|
462
|
+
margin-right: 6px;
|
|
463
|
+
vertical-align: -2px;
|
|
464
|
+
accent-color: var(--accent);
|
|
465
|
+
}
|
|
448
466
|
.msg.assistant .content a { color: #4ea1ff; text-decoration: none; }
|
|
449
467
|
.msg.assistant .content a:hover { text-decoration: underline; }
|
|
450
468
|
.msg.assistant .content table {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install pi-web-chat as a user-level systemd service.
|
|
3
|
+
#
|
|
4
|
+
# Why user-level? pi reads/writes ~/.pi and cwd files under $HOME, and it
|
|
5
|
+
# launches `pi` from ~/.npm-global/bin (or wherever $PI_BIN points). Running
|
|
6
|
+
# as a system unit would either need root or your home dir in a bind mount;
|
|
7
|
+
# user-level systemd (systemd --user) is the obvious fit and needs no sudo.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# ./scripts/install-service.sh [PORT] [--restart]
|
|
11
|
+
#
|
|
12
|
+
# After install:
|
|
13
|
+
# journalctl --user -u pi-web-chat -f
|
|
14
|
+
# systemctl --user status pi-web-chat
|
|
15
|
+
# systemctl --user restart pi-web-chat
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
ROOT="$(cd "$HERE/.." && pwd)"
|
|
21
|
+
SRC="$HERE/pi-web-chat.service"
|
|
22
|
+
DEST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
23
|
+
DEST="$DEST_DIR/pi-web-chat.service"
|
|
24
|
+
|
|
25
|
+
PORT="${1:-3000}"
|
|
26
|
+
RESTART_NOW="no"
|
|
27
|
+
for arg in "$@"; do
|
|
28
|
+
case "$arg" in
|
|
29
|
+
--restart|--now) RESTART_NOW="yes" ;;
|
|
30
|
+
esac
|
|
31
|
+
done
|
|
32
|
+
|
|
33
|
+
# Sanity checks.
|
|
34
|
+
if [[ ! -f "$SRC" ]]; then
|
|
35
|
+
echo "ERR: unit file not found at $SRC" >&2; exit 1
|
|
36
|
+
fi
|
|
37
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
38
|
+
echo "ERR: 'node' not on PATH" >&2; exit 1
|
|
39
|
+
fi
|
|
40
|
+
if ! command -v systemctl >/dev/null 2>&1; then
|
|
41
|
+
echo "ERR: 'systemctl' not on PATH — this installer only supports systemd" >&2; exit 1
|
|
42
|
+
fi
|
|
43
|
+
# User systemd needs linger for services to outlive the login session.
|
|
44
|
+
if ! loginctl show-user "$USER" 2>/dev/null | grep -q '^Linger=yes'; then
|
|
45
|
+
echo "⚠ Linger is not enabled for $USER — service will stop at logout."
|
|
46
|
+
echo " To keep it running after logout, run (one-off, requires sudo):"
|
|
47
|
+
echo " sudo loginctl enable-linger $USER"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
mkdir -p "$DEST_DIR"
|
|
51
|
+
|
|
52
|
+
# Concrete paths. The shipped unit uses %h for portability, but %h is only
|
|
53
|
+
# resolved when the user systemd instance starts — and we want a file that
|
|
54
|
+
# 'systemctl --user status' shows an exact path on, so substitute now.
|
|
55
|
+
USER_HOME="$HOME"
|
|
56
|
+
PROJECT_DIR="$ROOT"
|
|
57
|
+
NODE_BIN="$(command -v node)"
|
|
58
|
+
|
|
59
|
+
# Read the template and substitute.
|
|
60
|
+
sed \
|
|
61
|
+
-e "s|^WorkingDirectory=.*|WorkingDirectory=$PROJECT_DIR|" \
|
|
62
|
+
-e "s|^ExecStart=.*|ExecStart=$NODE_BIN $PROJECT_DIR/server.js|" \
|
|
63
|
+
-e "s|^Environment=PORT=.*|Environment=PORT=$PORT|" \
|
|
64
|
+
-e "s|%h/projects/pi-web-chat|$PROJECT_DIR|g" \
|
|
65
|
+
-e "s|%h/|$USER_HOME/|g" \
|
|
66
|
+
"$SRC" > "$DEST"
|
|
67
|
+
|
|
68
|
+
echo "✔ wrote $DEST"
|
|
69
|
+
|
|
70
|
+
# Reload + (re)enable + start.
|
|
71
|
+
systemctl --user daemon-reload
|
|
72
|
+
systemctl --user enable pi-web-chat.service
|
|
73
|
+
if [[ "$RESTART_NOW" == "yes" ]] || systemctl --user is-active --quiet pi-web-chat.service; then
|
|
74
|
+
systemctl --user restart pi-web-chat.service
|
|
75
|
+
else
|
|
76
|
+
systemctl --user start pi-web-chat.service || true
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
echo
|
|
80
|
+
echo "Status:"
|
|
81
|
+
systemctl --user --no-pager status pi-web-chat.service || true
|
|
82
|
+
echo
|
|
83
|
+
echo "Logs:"
|
|
84
|
+
echo " journalctl --user -u pi-web-chat -f"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=pi-web-chat (pi RPC web UI)
|
|
3
|
+
Documentation=https://github.com/liguoshuai-1990/pi-web-chat
|
|
4
|
+
# Run after the user session / network is available (no-op for user systemd
|
|
5
|
+
# units that run after the user logs in).
|
|
6
|
+
After=network-online.target
|
|
7
|
+
Wants=network-online.target
|
|
8
|
+
|
|
9
|
+
[Service]
|
|
10
|
+
Type=simple
|
|
11
|
+
# Project lives in $HOME/projects/pi-web-chat — override with `WorkingDirectory=`
|
|
12
|
+
# in an override unit or in install-service.sh if you keep it elsewhere.
|
|
13
|
+
# We don't hardcode the path here so the file is portable across machines /
|
|
14
|
+
# forks; the install script substitutes it at install time.
|
|
15
|
+
WorkingDirectory=%h/projects/pi-web-chat
|
|
16
|
+
|
|
17
|
+
# Same reasoning for the Node binary — prefer what Node.js EnvFile says if
|
|
18
|
+
# the user has nvm/fnm; otherwise rely on /usr/bin/node being on PATH.
|
|
19
|
+
ExecStart=/usr/bin/env node server.js
|
|
20
|
+
|
|
21
|
+
# Allow overriding via environment files / drop-ins without editing this
|
|
22
|
+
# unit. PORT, PI_BIN, PI_SESSIONS_DIR etc. are honored by server.js.
|
|
23
|
+
Environment=PORT=3000
|
|
24
|
+
Environment=NODE_ENV=production
|
|
25
|
+
Environment=PI_SKIP_VERSION_CHECK=1
|
|
26
|
+
|
|
27
|
+
# Logging: stdout/stderr go to journald. `journalctl --user -u pi-web-chat -f`.
|
|
28
|
+
StandardOutput=journal
|
|
29
|
+
StandardError=journal
|
|
30
|
+
SyslogIdentifier=pi-web-chat
|
|
31
|
+
|
|
32
|
+
# Restart on any failure; back off so we don't tight-loop if node is broken.
|
|
33
|
+
Restart=on-failure
|
|
34
|
+
RestartSec=3
|
|
35
|
+
|
|
36
|
+
# Hardening.
|
|
37
|
+
NoNewPrivileges=true
|
|
38
|
+
PrivateTmp=true
|
|
39
|
+
|
|
40
|
+
[Install]
|
|
41
|
+
WantedBy=default.target
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Remove the pi-web-chat user systemd service.
|
|
3
|
+
# Does NOT delete the project directory or sessions.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
UNIT_NAME="pi-web-chat.service"
|
|
8
|
+
DEST="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$UNIT_NAME"
|
|
9
|
+
|
|
10
|
+
if ! command -v systemctl >/dev/null 2>&1; then
|
|
11
|
+
echo "ERR: 'systemctl' not on PATH" >&2; exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
if systemctl --user list-unit-files "$UNIT_NAME" 2>/dev/null | grep -q "$UNIT_NAME"; then
|
|
15
|
+
systemctl --user disable --now "$UNIT_NAME" || true
|
|
16
|
+
echo "✔ disabled $UNIT_NAME"
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if [[ -f "$DEST" ]]; then
|
|
20
|
+
rm "$DEST"
|
|
21
|
+
systemctl --user daemon-reload
|
|
22
|
+
echo "✔ removed $DEST"
|
|
23
|
+
else
|
|
24
|
+
echo "no unit file at $DEST"
|
|
25
|
+
fi
|
package/server.js
CHANGED
|
@@ -36,15 +36,20 @@ function resolvePiBin() {
|
|
|
36
36
|
const PI_BIN = resolvePiBin();
|
|
37
37
|
function home() { return os.homedir(); }
|
|
38
38
|
|
|
39
|
-
function
|
|
40
|
-
if (!
|
|
41
|
-
let resolved =
|
|
42
|
-
if (
|
|
43
|
-
resolved = path.join(home(),
|
|
39
|
+
function normalizePath(p) {
|
|
40
|
+
if (!p) return "";
|
|
41
|
+
let resolved = p;
|
|
42
|
+
if (p.startsWith("~")) {
|
|
43
|
+
resolved = path.join(home(), p.slice(1));
|
|
44
44
|
}
|
|
45
45
|
return path.resolve(resolved);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function normalizeCwd(dir) {
|
|
49
|
+
if (!dir) return process.cwd() || home();
|
|
50
|
+
return normalizePath(dir);
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
// Where pi stores sessions, organized by cwd-encoded subdirectory.
|
|
49
54
|
const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(home(), ".pi", "agent", "sessions");
|
|
50
55
|
const PORT = process.env.PORT || 3000;
|
|
@@ -172,13 +177,19 @@ class PiAgent {
|
|
|
172
177
|
if (IDLE_DROP_HEAP) {
|
|
173
178
|
try { if (typeof global.gc === "function") global.gc(); } catch {}
|
|
174
179
|
}
|
|
180
|
+
// If unkeyed AND truly idle (never had a session assigned and not busy),
|
|
181
|
+
// no client can ever re-attach to it. Stop immediately to reclaim memory.
|
|
182
|
+
if (!this.sessionKey && !this.isBusy) {
|
|
183
|
+
this.stop();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
175
186
|
this.maybeScheduleIdleKill();
|
|
176
187
|
}
|
|
177
188
|
}
|
|
178
189
|
|
|
179
190
|
setSessionKey(cwd, sessionPath) {
|
|
180
191
|
if (!sessionPath) return;
|
|
181
|
-
const resolved =
|
|
192
|
+
const resolved = normalizePath(sessionPath);
|
|
182
193
|
const key = `${cwd}:${resolved}`;
|
|
183
194
|
if (this.sessionKey && this.sessionKey !== key) {
|
|
184
195
|
activeAgents.delete(this.sessionKey);
|
|
@@ -623,7 +634,7 @@ async function listAllSessionFiles() {
|
|
|
623
634
|
}
|
|
624
635
|
|
|
625
636
|
// Subdirectory .jsonl files (created by pi itself when cwd is encoded).
|
|
626
|
-
const subdirs = top.filter(d => d.isDirectory() || d.isSymbolicLink());
|
|
637
|
+
const subdirs = top.filter(d => d.isDirectory() || (d.isSymbolicLink() && !d.name.endsWith(".jsonl")));
|
|
627
638
|
for (const d of subdirs) {
|
|
628
639
|
const dp = path.join(SESSIONS_DIR, d.name);
|
|
629
640
|
const names = (await readdir(dp).catch(() => [])).filter(f => f.endsWith(".jsonl"));
|
|
@@ -717,12 +728,8 @@ app.get("/api/session", async (req, res) => {
|
|
|
717
728
|
if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
|
|
718
729
|
|
|
719
730
|
// Security check: ensure the file path is within SESSIONS_DIR
|
|
720
|
-
let resolvedFile = file;
|
|
721
|
-
|
|
722
|
-
resolvedFile = path.join(home(), resolvedFile.slice(1));
|
|
723
|
-
}
|
|
724
|
-
resolvedFile = path.resolve(resolvedFile);
|
|
725
|
-
const resolvedSessionsDir = path.resolve(SESSIONS_DIR);
|
|
731
|
+
let resolvedFile = normalizePath(file);
|
|
732
|
+
const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
|
|
726
733
|
const relPath = path.relative(resolvedSessionsDir, resolvedFile);
|
|
727
734
|
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
|
|
728
735
|
return res.status(403).json({ error: "Access denied" });
|
|
@@ -834,7 +841,7 @@ wss.on("connection", (ws, req) => {
|
|
|
834
841
|
const url = new URL(req.url, "http://x");
|
|
835
842
|
const cwd = normalizeCwd(url.searchParams.get("cwd"));
|
|
836
843
|
const session = url.searchParams.get("session") || null;
|
|
837
|
-
const key = session ? `${cwd}:${
|
|
844
|
+
const key = session ? `${cwd}:${normalizePath(session)}` : null;
|
|
838
845
|
|
|
839
846
|
let agent = null;
|
|
840
847
|
if (key && activeAgents.has(key) && activeAgents.get(key).alive) {
|