@liguoshuai/pi-web-chat 1.8.3 → 1.8.5
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 +36 -0
- package/docs/ISSUES.md +57 -5
- package/package.json +2 -1
- package/public/app.js +198 -73
- package/public/style.css +26 -3
- 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.5 版本)。
|
|
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,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.8.5] - 2026-08-23
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **中止/终止生成按钮逻辑修复 (Abort Button Logic & UX Fix)**:
|
|
14
|
+
- 修复生成过程中点击终止按钮(`■`)时,因输入框为空触发提前返回(`if (!text) return`)导致无法发出 `abort` 信号的问题。
|
|
15
|
+
- 抽离独立的 `abortGeneration()` 统一处理中断生成流程,确保点击停止按钮无条件触发任务终止。
|
|
16
|
+
- 修复输入框有草稿时点击停止按钮误触发 `steer`(插入指令)的问题,明确区分停止按钮与插入指令按钮职责。
|
|
17
|
+
- 新增中止中即时视觉反馈(按钮变为等待状态 `⏳`,输入框边缘高亮变红,提示文字变更为“中止当前任务中…”),并在底层 `agent_settled` 或 `abort` 响应后安全恢复。
|
|
18
|
+
- 支持在生成过程中按 `Escape` 键快速中止当前任务(模态框或菜单打开时除外)。
|
|
19
|
+
- 在新建会话及切换会话时,若当前任务正在运行中,自动先行中断当前流式任务以避免后台状态错乱。
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## [1.8.4] - 2026-07-26
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **Markdown 渲染能力增强 (Markdown Enhancements: HR, Strikethrough & Task Checkboxes)**:
|
|
27
|
+
- 支持标准 Markdown 水平分割线(`---`、`***`、`___`)渲染为 `<hr>` 标签。
|
|
28
|
+
- 支持删除线语法(`~~strikethrough~~`)渲染为 `<del>` 标签。
|
|
29
|
+
- 支持任务列表复选框(`- [ ]` 与 `- [x]`)及加号无序列表(`+ item`)。
|
|
30
|
+
- 为 `h4`、`h5`、`h6`、`hr`、`del` 及任务复选框补充了精细的暗色主题 CSS 样式。
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **移动端息屏/切出恢复后历史消息自动同步 (Mobile Reconnect Session Auto-Sync)**:
|
|
34
|
+
- 修复手机端在后台挂起或息屏后切回页面时,未能及时同步后台已生成完毕的最新回复内容的问题。
|
|
35
|
+
- 增加 `syncSessionHistory` 历史同步机制,并在 `ws.onopen`、`visibilitychange` 与 `pageshow` 事件中自动拉取磁盘最新消息渲染至对话区。
|
|
36
|
+
- 优化 `backfill_start` / `backfill_end` 流式状态重置,保障后台生成途中切回时无缝追平实时生成内容。
|
|
37
|
+
- **未绑定会话孤儿子进程即时回收 (Immediate Cleanup of Unkeyed Idle Agents)**:当客户端打开新建会话页面但未发送消息即关闭或切换时,后端未分配 Session Key 的孤立 `PiAgent` 进程在连接断开后立即释放,避免占用 5 分钟闲置内存。
|
|
38
|
+
- **切换与加载历史会话时工作目录自动同步 (Session CWD Auto-Sync)**:点击侧边栏或通过 URL 参数(`?session=...`)打开会话时,自动将 `state.cwd` 同步为会话真实的 `data.header.cwd`,确保 Agent 执行环境与会话一致。
|
|
39
|
+
- **工作目录变更后项目级默认配置实时重载 (Project Settings Live Reload on CWD Change)**:切换工作目录时重新获取 `/api/config?cwd=...`,确保项目根目录下的 `.pi/settings.json` 模型配置即时生效。
|
|
40
|
+
- **波浪号路径解析规范化 (Tilde `~` Path Resolution Normalization)**:服务端引入统一的 `normalizePath`,确保会话参数与路径校验中包含的 `~` 家目录前缀能被正确展开并绝对化。
|
|
41
|
+
- **历史错误消息展示保护 (Error Notice on Historical Failed Messages)**:修复历史会话中若生成中途报错中断时未能渲染失败提示条的问题。
|
|
42
|
+
- **Package.json 发布字段补充 (Scripts Directory Inclusion)**:在 `package.json` 的 `files` 字段中加入 `scripts/` 目录,确保 Systemd 服务脚本正常打包发布。
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
10
46
|
## [1.8.3] - 2026-07-26
|
|
11
47
|
|
|
12
48
|
### 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.5",
|
|
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
|
@@ -42,6 +42,7 @@ const state = {
|
|
|
42
42
|
thinkingLevel: "medium",
|
|
43
43
|
sessionId: null,
|
|
44
44
|
isBackfilling: false,
|
|
45
|
+
aborting: false,
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
let toastTimer = null;
|
|
@@ -71,9 +72,10 @@ function updateCwdDisplay() {
|
|
|
71
72
|
if (wrap) wrap.title = `工作目录: ${state.cwd || "~"}`;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function setCwd(newCwd) {
|
|
75
|
+
async function setCwd(newCwd) {
|
|
75
76
|
state.cwd = newCwd;
|
|
76
77
|
localStorage.setItem("pi_cwd", newCwd);
|
|
78
|
+
await loadServerConfig();
|
|
77
79
|
updateCwdDisplay();
|
|
78
80
|
clearChat();
|
|
79
81
|
showEmptyState(true);
|
|
@@ -112,12 +114,18 @@ function saveRecentModel(model) {
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
async function loadServerConfig() {
|
|
117
|
+
if (!state.cwd) {
|
|
118
|
+
state.cwd = localStorage.getItem("pi_cwd") || document.body.dataset.cwd || "";
|
|
119
|
+
}
|
|
115
120
|
try {
|
|
116
121
|
const cwdParam = state.cwd ? `?cwd=${encodeURIComponent(state.cwd)}` : "";
|
|
117
122
|
const res = await fetch(`${API}/api/config${cwdParam}`);
|
|
118
123
|
const data = await res.json();
|
|
119
124
|
if (data.home) state.homeDir = data.home;
|
|
120
125
|
if (data.serverCwd) state.serverCwd = data.serverCwd;
|
|
126
|
+
if (!state.cwd) {
|
|
127
|
+
state.cwd = state.serverCwd || state.homeDir || "";
|
|
128
|
+
}
|
|
121
129
|
if (data.version) {
|
|
122
130
|
state.version = data.version;
|
|
123
131
|
const verEl = $("#appVersion");
|
|
@@ -131,7 +139,7 @@ async function loadServerConfig() {
|
|
|
131
139
|
}
|
|
132
140
|
} catch {}
|
|
133
141
|
if (!state.cwd) {
|
|
134
|
-
state.cwd =
|
|
142
|
+
state.cwd = state.serverCwd || state.homeDir || "";
|
|
135
143
|
}
|
|
136
144
|
loadRecentModels();
|
|
137
145
|
updateCwdDisplay();
|
|
@@ -358,9 +366,14 @@ function renderInlineMd(text) {
|
|
|
358
366
|
flushList();
|
|
359
367
|
outHtml += mdTable(seg.lines);
|
|
360
368
|
} else if (seg.kind === "line") {
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
369
|
+
// horizontal rule
|
|
370
|
+
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
371
|
+
flushPara();
|
|
372
|
+
flushList();
|
|
373
|
+
outHtml += "<hr>";
|
|
374
|
+
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
375
|
+
// headings
|
|
376
|
+
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
364
377
|
flushPara();
|
|
365
378
|
flushList();
|
|
366
379
|
const level = m[1].length;
|
|
@@ -373,7 +386,7 @@ function renderInlineMd(text) {
|
|
|
373
386
|
flushPara();
|
|
374
387
|
flushList();
|
|
375
388
|
outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
|
|
376
|
-
} else if (/^\s*[
|
|
389
|
+
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
377
390
|
// list item — group consecutive into ul/ol
|
|
378
391
|
// simple inline handling: wrap each list item line.
|
|
379
392
|
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
@@ -384,7 +397,16 @@ function renderInlineMd(text) {
|
|
|
384
397
|
linkListOrdered = isOrdered;
|
|
385
398
|
outHtml += "<" + linkListOpen + ">";
|
|
386
399
|
}
|
|
387
|
-
|
|
400
|
+
let itemText = seg.text.replace(/^\s*([-*+]|\d+\.)\s+/, "");
|
|
401
|
+
let taskPrefix = "";
|
|
402
|
+
if (/^\[ \]\s+/.test(itemText)) {
|
|
403
|
+
taskPrefix = '<input type="checkbox" disabled class="task-list-item-checkbox"> ';
|
|
404
|
+
itemText = itemText.replace(/^\[ \]\s+/, "");
|
|
405
|
+
} else if (/^\[[xX]\]\s+/.test(itemText)) {
|
|
406
|
+
taskPrefix = '<input type="checkbox" checked disabled class="task-list-item-checkbox"> ';
|
|
407
|
+
itemText = itemText.replace(/^\[[xX]\]\s+/, "");
|
|
408
|
+
}
|
|
409
|
+
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
388
410
|
} else {
|
|
389
411
|
flushList();
|
|
390
412
|
para.push(seg.text);
|
|
@@ -405,6 +427,8 @@ function mdInlineBlock(text) {
|
|
|
405
427
|
// bold
|
|
406
428
|
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
407
429
|
s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
430
|
+
// strikethrough
|
|
431
|
+
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
408
432
|
// italic
|
|
409
433
|
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
|
|
410
434
|
s = s.replace(/(^|[^_])_([^_]+)_/g, "$1<em>$2</em>");
|
|
@@ -514,9 +538,51 @@ function initMobileToolbarFab() {
|
|
|
514
538
|
});
|
|
515
539
|
}
|
|
516
540
|
|
|
541
|
+
async function syncSessionHistory(file, force = false) {
|
|
542
|
+
if (!file) return;
|
|
543
|
+
try {
|
|
544
|
+
const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
|
|
545
|
+
if (!res.ok) return;
|
|
546
|
+
const data = await res.json();
|
|
547
|
+
if (!data || data.error) return;
|
|
548
|
+
|
|
549
|
+
if (data.header?.cwd && data.header.cwd !== state.cwd) {
|
|
550
|
+
state.cwd = data.header.cwd;
|
|
551
|
+
localStorage.setItem("pi_cwd", state.cwd);
|
|
552
|
+
updateCwdDisplay();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (data.model) {
|
|
556
|
+
state.currentModel = data.model;
|
|
557
|
+
renderModelPill();
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
|
|
561
|
+
$("#topSessionName").textContent = topName;
|
|
562
|
+
|
|
563
|
+
// Only overwrite chat if force is true or we're not actively streaming mid-turn live
|
|
564
|
+
if (force || !state.streaming) {
|
|
565
|
+
clearChat();
|
|
566
|
+
const msgs = reconstructFromEntries(data.entries || []);
|
|
567
|
+
showEmptyState(msgs.length === 0);
|
|
568
|
+
for (const m of msgs) {
|
|
569
|
+
appendMessageNode(m.role, m);
|
|
570
|
+
}
|
|
571
|
+
scrollBottom();
|
|
572
|
+
refreshSessions();
|
|
573
|
+
}
|
|
574
|
+
} catch (e) {
|
|
575
|
+
console.warn("syncSessionHistory error:", e);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
517
579
|
async function loadSession(file) {
|
|
580
|
+
if (state.streaming) {
|
|
581
|
+
abortGeneration();
|
|
582
|
+
}
|
|
518
583
|
state.currentSessionFile = file;
|
|
519
584
|
state.streaming = false;
|
|
585
|
+
state.aborting = false;
|
|
520
586
|
state.streamingItems = [];
|
|
521
587
|
state.streamingMsg = null;
|
|
522
588
|
state.activeToolCalls.clear();
|
|
@@ -529,38 +595,9 @@ async function loadSession(file) {
|
|
|
529
595
|
if (window.innerWidth <= 768) {
|
|
530
596
|
closeSidebar();
|
|
531
597
|
}
|
|
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
|
-
}
|
|
598
|
+
await syncSessionHistory(file, true);
|
|
560
599
|
// Reconnect websocket pointed at this session so new prompts continue history.
|
|
561
600
|
connectWs({ session: file });
|
|
562
|
-
// Update sidebar active highlight
|
|
563
|
-
refreshSessions();
|
|
564
601
|
}
|
|
565
602
|
|
|
566
603
|
function reconstructFromEntries(entries) {
|
|
@@ -592,7 +629,7 @@ function reconstructFromEntries(entries) {
|
|
|
592
629
|
}
|
|
593
630
|
return part;
|
|
594
631
|
});
|
|
595
|
-
if (m.stopReason === "error"
|
|
632
|
+
if (m.stopReason === "error") {
|
|
596
633
|
let errMsg = m.errorMessage || "生成失败(模型返回错误)";
|
|
597
634
|
try {
|
|
598
635
|
const parsed = JSON.parse(errMsg);
|
|
@@ -613,7 +650,8 @@ function extractContentText(content) {
|
|
|
613
650
|
return content
|
|
614
651
|
.filter(c => c && (c.type === "text" || typeof c === "string"))
|
|
615
652
|
.map(c => typeof c === "string" ? c : (c.text || ""))
|
|
616
|
-
.
|
|
653
|
+
.filter(Boolean)
|
|
654
|
+
.join("\n\n");
|
|
617
655
|
}
|
|
618
656
|
|
|
619
657
|
function appendSystemNotice(text) {
|
|
@@ -846,14 +884,22 @@ function makeToolBlockFromCall(call) {
|
|
|
846
884
|
|
|
847
885
|
function summaryArgs(name, args) {
|
|
848
886
|
if (!args) return "";
|
|
887
|
+
let obj = args;
|
|
888
|
+
if (typeof args === "string") {
|
|
889
|
+
try { obj = JSON.parse(args); } catch { return args; }
|
|
890
|
+
}
|
|
849
891
|
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
|
|
892
|
+
if (name === "bash" && obj.command) return obj.command;
|
|
893
|
+
if (name === "read" && obj.path) return obj.path;
|
|
894
|
+
if (name === "write" && obj.path) return obj.path;
|
|
895
|
+
if (name === "edit" && obj.path) return obj.path;
|
|
896
|
+
if (name === "ls" && obj.path) return obj.path;
|
|
897
|
+
if (name === "grep") return obj.pattern || "";
|
|
898
|
+
if (name === "find") return obj.pattern || obj.path || "";
|
|
899
|
+
if (typeof obj === "object" && obj !== null) {
|
|
900
|
+
const keys = Object.keys(obj);
|
|
901
|
+
if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
|
|
902
|
+
}
|
|
857
903
|
return "";
|
|
858
904
|
} catch { return ""; }
|
|
859
905
|
}
|
|
@@ -1121,14 +1167,18 @@ function connectWs(opts = {}) {
|
|
|
1121
1167
|
state.ws = ws;
|
|
1122
1168
|
ws._gen = myGen;
|
|
1123
1169
|
|
|
1124
|
-
ws.onopen = () => {
|
|
1170
|
+
ws.onopen = async () => {
|
|
1125
1171
|
if (ws._gen !== wsGen) return;
|
|
1126
1172
|
isConnecting = false;
|
|
1127
1173
|
setConnStatus("connected");
|
|
1128
1174
|
startPingInterval();
|
|
1129
1175
|
|
|
1130
|
-
|
|
1176
|
+
const isReconnecting = wasDisconnected || reconnectAttempts > 0 || opts.isReconnect;
|
|
1177
|
+
if (isReconnecting) {
|
|
1131
1178
|
showToast("网络连接已恢复");
|
|
1179
|
+
if (targetSession) {
|
|
1180
|
+
await syncSessionHistory(targetSession, true);
|
|
1181
|
+
}
|
|
1132
1182
|
}
|
|
1133
1183
|
wasDisconnected = false;
|
|
1134
1184
|
reconnectAttempts = 0;
|
|
@@ -1189,6 +1239,11 @@ function handlePiMessage(obj) {
|
|
|
1189
1239
|
// that happened in the background while no browser was attached.
|
|
1190
1240
|
if (obj.type === "backfill_start") {
|
|
1191
1241
|
state.isBackfilling = true;
|
|
1242
|
+
state.streaming = true;
|
|
1243
|
+
setComposerAborting(true);
|
|
1244
|
+
state.streamingItems = [];
|
|
1245
|
+
state.streamingMsg = null;
|
|
1246
|
+
state.activeToolCalls.clear();
|
|
1192
1247
|
return;
|
|
1193
1248
|
}
|
|
1194
1249
|
if (obj.type === "backfill_end") {
|
|
@@ -1199,6 +1254,11 @@ function handlePiMessage(obj) {
|
|
|
1199
1254
|
setComposerAborting(true);
|
|
1200
1255
|
ensureStreamingMsg();
|
|
1201
1256
|
refreshStreamingContent();
|
|
1257
|
+
} else {
|
|
1258
|
+
finalizeStreamingMsg();
|
|
1259
|
+
state.streaming = false;
|
|
1260
|
+
setComposerAborting(false);
|
|
1261
|
+
refreshSessions();
|
|
1202
1262
|
}
|
|
1203
1263
|
// jump to the latest content once the replay is done
|
|
1204
1264
|
requestAnimationFrame(scrollBottom);
|
|
@@ -1240,6 +1300,9 @@ function handlePiMessage(obj) {
|
|
|
1240
1300
|
renderThinkingPill();
|
|
1241
1301
|
updateEmptyStateModelInfo();
|
|
1242
1302
|
}
|
|
1303
|
+
} else if (obj.command === "abort") {
|
|
1304
|
+
state.aborting = false;
|
|
1305
|
+
setComposerAborting(false);
|
|
1243
1306
|
}
|
|
1244
1307
|
else if (obj.command === "switch_session" && obj.success) {
|
|
1245
1308
|
// ask pi for current state so we can get session id, name
|
|
@@ -1274,6 +1337,7 @@ function handlePiMessage(obj) {
|
|
|
1274
1337
|
break;
|
|
1275
1338
|
case "agent_start":
|
|
1276
1339
|
state.streaming = true;
|
|
1340
|
+
state.aborting = false;
|
|
1277
1341
|
setComposerAborting(true);
|
|
1278
1342
|
ensureStreamingMsg();
|
|
1279
1343
|
refreshStreamingContent();
|
|
@@ -1285,6 +1349,7 @@ function handlePiMessage(obj) {
|
|
|
1285
1349
|
case "agent_settled":
|
|
1286
1350
|
finalizeStreamingMsg();
|
|
1287
1351
|
state.streaming = false;
|
|
1352
|
+
state.aborting = false;
|
|
1288
1353
|
setComposerAborting(false);
|
|
1289
1354
|
sendWs({ type: "get_state" });
|
|
1290
1355
|
refreshSessions(); // titles may have changed
|
|
@@ -1389,8 +1454,7 @@ function handlePiMessage(obj) {
|
|
|
1389
1454
|
case "tool_execution_update": {
|
|
1390
1455
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1391
1456
|
if (tc) {
|
|
1392
|
-
const
|
|
1393
|
-
const text = pr && pr.content ? (Array.isArray(pr.content) ? pr.content.map(c => c.text || "").join("") : "") : "";
|
|
1457
|
+
const text = extractContentText(obj.partialResult?.content);
|
|
1394
1458
|
tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
|
|
1395
1459
|
}
|
|
1396
1460
|
break;
|
|
@@ -1398,8 +1462,7 @@ function handlePiMessage(obj) {
|
|
|
1398
1462
|
case "tool_execution_end": {
|
|
1399
1463
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1400
1464
|
if (tc) {
|
|
1401
|
-
const
|
|
1402
|
-
const text = res && res.content ? (Array.isArray(res.content) ? res.content.map(c => c.text || "").join("") : "") : "";
|
|
1465
|
+
const text = extractContentText(obj.result?.content);
|
|
1403
1466
|
tc.body.innerHTML = escapeHtml(text) || "(无输出)";
|
|
1404
1467
|
tc.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
|
|
1405
1468
|
tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
|
|
@@ -1412,6 +1475,7 @@ function handlePiMessage(obj) {
|
|
|
1412
1475
|
case "pi_exit":
|
|
1413
1476
|
finalizeStreamingMsg();
|
|
1414
1477
|
state.streaming = false;
|
|
1478
|
+
state.aborting = false;
|
|
1415
1479
|
setComposerAborting(false);
|
|
1416
1480
|
$("#connDot").style.color = "var(--danger)";
|
|
1417
1481
|
break;
|
|
@@ -1507,8 +1571,13 @@ function updateState(d) {
|
|
|
1507
1571
|
} else if (state.streaming) {
|
|
1508
1572
|
finalizeStreamingMsg();
|
|
1509
1573
|
state.streaming = false;
|
|
1574
|
+
state.aborting = false;
|
|
1510
1575
|
setComposerAborting(false);
|
|
1511
|
-
|
|
1576
|
+
if (state.currentSessionFile) {
|
|
1577
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
1578
|
+
} else {
|
|
1579
|
+
refreshSessions();
|
|
1580
|
+
}
|
|
1512
1581
|
}
|
|
1513
1582
|
}
|
|
1514
1583
|
}
|
|
@@ -2007,8 +2076,24 @@ function updateComposerUI() {
|
|
|
2007
2076
|
const text = ta ? ta.value.trim() : "";
|
|
2008
2077
|
const sendBtn = $("#sendBtn");
|
|
2009
2078
|
const steerBtn = $("#steerBtn");
|
|
2079
|
+
const inner = $("#composerInner");
|
|
2010
2080
|
|
|
2011
|
-
if (
|
|
2081
|
+
if (inner) {
|
|
2082
|
+
inner.classList.toggle("aborting", !!state.aborting);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
if (state.aborting) {
|
|
2086
|
+
if (sendBtn) {
|
|
2087
|
+
sendBtn.classList.add("stop");
|
|
2088
|
+
sendBtn.disabled = true;
|
|
2089
|
+
sendBtn.textContent = "⏳";
|
|
2090
|
+
sendBtn.title = "中止中…";
|
|
2091
|
+
}
|
|
2092
|
+
if (steerBtn) {
|
|
2093
|
+
steerBtn.style.display = "none";
|
|
2094
|
+
}
|
|
2095
|
+
if (ta) ta.placeholder = "正在中止当前任务…";
|
|
2096
|
+
} else if (state.streaming) {
|
|
2012
2097
|
if (sendBtn) {
|
|
2013
2098
|
sendBtn.classList.add("stop");
|
|
2014
2099
|
sendBtn.disabled = !state.wsConnected;
|
|
@@ -2039,10 +2124,34 @@ function updateComposerUI() {
|
|
|
2039
2124
|
}
|
|
2040
2125
|
|
|
2041
2126
|
function setComposerAborting(yes) {
|
|
2127
|
+
if (!yes) {
|
|
2128
|
+
state.aborting = false;
|
|
2129
|
+
const inner = $("#composerInner");
|
|
2130
|
+
if (inner) inner.classList.remove("aborting");
|
|
2131
|
+
const hint = $(".composer-hint");
|
|
2132
|
+
if (hint && hint.textContent.includes("中止")) {
|
|
2133
|
+
hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2042
2136
|
updateComposerUI();
|
|
2043
2137
|
renderModelPill();
|
|
2044
2138
|
}
|
|
2045
2139
|
|
|
2140
|
+
function abortGeneration() {
|
|
2141
|
+
if (!state.streaming && !state.aborting) return;
|
|
2142
|
+
if (!state.wsConnected) {
|
|
2143
|
+
const hint = $(".composer-hint");
|
|
2144
|
+
if (hint) hint.textContent = "操作失败:WebSocket 未连接。正在尝试重连…";
|
|
2145
|
+
scheduleReconnect(0);
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
state.aborting = true;
|
|
2149
|
+
updateComposerUI();
|
|
2150
|
+
const hint = $(".composer-hint");
|
|
2151
|
+
if (hint) hint.textContent = "中止当前任务中…";
|
|
2152
|
+
sendWs({ type: "abort" });
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2046
2155
|
function submitSteer() {
|
|
2047
2156
|
const ta = $("#composer");
|
|
2048
2157
|
const text = ta.value.trim();
|
|
@@ -2069,8 +2178,19 @@ function submitSteer() {
|
|
|
2069
2178
|
}
|
|
2070
2179
|
|
|
2071
2180
|
function submitPrompt() {
|
|
2181
|
+
if (state.streaming) {
|
|
2182
|
+
const ta = $("#composer");
|
|
2183
|
+
const text = ta ? ta.value.trim() : "";
|
|
2184
|
+
if (text) {
|
|
2185
|
+
submitSteer();
|
|
2186
|
+
} else {
|
|
2187
|
+
abortGeneration();
|
|
2188
|
+
}
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2072
2192
|
const ta = $("#composer");
|
|
2073
|
-
const text = ta.value.trim();
|
|
2193
|
+
const text = ta ? ta.value.trim() : "";
|
|
2074
2194
|
const hint = $(".composer-hint");
|
|
2075
2195
|
if (!text) return;
|
|
2076
2196
|
if (!state.wsConnected) {
|
|
@@ -2080,16 +2200,7 @@ function submitPrompt() {
|
|
|
2080
2200
|
if (box) { box.style.boxShadow = "0 0 0 2px var(--danger)"; setTimeout(() => { box.style.boxShadow = ""; }, 350); }
|
|
2081
2201
|
return;
|
|
2082
2202
|
}
|
|
2083
|
-
|
|
2084
|
-
if (text) {
|
|
2085
|
-
submitSteer();
|
|
2086
|
-
return;
|
|
2087
|
-
}
|
|
2088
|
-
if (hint) hint.textContent = "中止当前生成中…";
|
|
2089
|
-
sendWs({ type: "abort" });
|
|
2090
|
-
return;
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2203
|
+
|
|
2093
2204
|
if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
|
|
2094
2205
|
// Render the user's message locally for instant feedback.
|
|
2095
2206
|
appendMessageNode("user", { text });
|
|
@@ -2097,6 +2208,7 @@ function submitPrompt() {
|
|
|
2097
2208
|
autoResize();
|
|
2098
2209
|
|
|
2099
2210
|
state.streaming = true;
|
|
2211
|
+
state.aborting = false;
|
|
2100
2212
|
setComposerAborting(true);
|
|
2101
2213
|
ensureStreamingMsg();
|
|
2102
2214
|
refreshStreamingContent();
|
|
@@ -2126,7 +2238,7 @@ async function init() {
|
|
|
2126
2238
|
$("#btnNew").addEventListener("click", () => {
|
|
2127
2239
|
if (state.streaming) {
|
|
2128
2240
|
if (!confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
|
|
2129
|
-
|
|
2241
|
+
abortGeneration();
|
|
2130
2242
|
}
|
|
2131
2243
|
clearChat();
|
|
2132
2244
|
showEmptyState(true);
|
|
@@ -2143,12 +2255,7 @@ async function init() {
|
|
|
2143
2255
|
|
|
2144
2256
|
$("#sendBtn").addEventListener("click", () => {
|
|
2145
2257
|
if (state.streaming) {
|
|
2146
|
-
|
|
2147
|
-
if (ta && ta.value.trim()) {
|
|
2148
|
-
submitSteer();
|
|
2149
|
-
} else {
|
|
2150
|
-
submitPrompt();
|
|
2151
|
-
}
|
|
2258
|
+
abortGeneration();
|
|
2152
2259
|
} else {
|
|
2153
2260
|
submitPrompt();
|
|
2154
2261
|
}
|
|
@@ -2202,11 +2309,17 @@ async function init() {
|
|
|
2202
2309
|
});
|
|
2203
2310
|
}
|
|
2204
2311
|
|
|
2205
|
-
// Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector
|
|
2312
|
+
// Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
|
|
2206
2313
|
window.addEventListener("keydown", (e) => {
|
|
2207
2314
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
|
|
2208
2315
|
e.preventDefault();
|
|
2209
2316
|
toggleModelMenu();
|
|
2317
|
+
} else if (e.key === "Escape" && state.streaming) {
|
|
2318
|
+
const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
|
|
2319
|
+
const isModalOpen = $("#cwdModal")?.style.display === "flex";
|
|
2320
|
+
if (!isMenuOpen && !isModalOpen) {
|
|
2321
|
+
abortGeneration();
|
|
2322
|
+
}
|
|
2210
2323
|
}
|
|
2211
2324
|
});
|
|
2212
2325
|
|
|
@@ -2323,10 +2436,22 @@ async function init() {
|
|
|
2323
2436
|
scheduleReconnect(0);
|
|
2324
2437
|
} else {
|
|
2325
2438
|
try { state.ws.send(JSON.stringify({ type: "ping" })); } catch {}
|
|
2439
|
+
if (state.currentSessionFile && !state.streaming) {
|
|
2440
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
2441
|
+
}
|
|
2326
2442
|
}
|
|
2327
2443
|
}
|
|
2328
2444
|
});
|
|
2329
2445
|
|
|
2446
|
+
window.addEventListener("pageshow", () => {
|
|
2447
|
+
if (!state.wsConnected) {
|
|
2448
|
+
reconnectAttempts = 0;
|
|
2449
|
+
scheduleReconnect(0);
|
|
2450
|
+
} else if (state.currentSessionFile && !state.streaming) {
|
|
2451
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
2452
|
+
}
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2330
2455
|
// Global event delegation for code block copy buttons
|
|
2331
2456
|
document.addEventListener("click", async (e) => {
|
|
2332
2457
|
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 {
|
|
@@ -690,7 +708,10 @@ body {
|
|
|
690
708
|
transition: border-color .15s;
|
|
691
709
|
}
|
|
692
710
|
.composer-inner:focus-within { border-color: var(--text-dim); }
|
|
693
|
-
.composer-inner.aborting {
|
|
711
|
+
.composer-inner.aborting {
|
|
712
|
+
border-color: var(--danger);
|
|
713
|
+
box-shadow: 0 0 0 1px var(--danger);
|
|
714
|
+
}
|
|
694
715
|
.composer textarea {
|
|
695
716
|
flex: 1; background: transparent; border: none; outline: none; resize: none;
|
|
696
717
|
color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
|
|
@@ -699,10 +720,12 @@ body {
|
|
|
699
720
|
.composer .send-btn {
|
|
700
721
|
width: 36px; height: 32px; border-radius: 18px; border: none; cursor: pointer;
|
|
701
722
|
background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
|
|
702
|
-
flex-shrink: 0; transition: background .15s, opacity .15s;
|
|
723
|
+
flex-shrink: 0; transition: background .15s, opacity .15s, transform .1s;
|
|
703
724
|
}
|
|
704
725
|
.composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
|
|
705
726
|
.composer .send-btn.stop { background: var(--danger); }
|
|
727
|
+
.composer .send-btn.stop:hover:not(:disabled) { background: #f87171; }
|
|
728
|
+
.composer .send-btn.stop:active:not(:disabled) { transform: scale(0.95); }
|
|
706
729
|
.composer .steer-btn {
|
|
707
730
|
display: inline-flex;
|
|
708
731
|
align-items: center;
|
|
@@ -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) {
|