@liguoshuai/pi-web-chat 1.8.0 → 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 +55 -0
- package/docs/ISSUES.md +118 -0
- package/package.json +2 -1
- package/public/app.js +140 -48
- package/public/style.css +135 -18
- 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 +60 -23
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构设计文档
|
|
2
2
|
|
|
3
|
-
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.
|
|
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,61 @@ 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
|
+
|
|
33
|
+
## [1.8.3] - 2026-07-26
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- **移动端工作目录切换弹窗按钮溢出修复 (CWD Modal Mobile Overflow & Input Sizing)**:
|
|
37
|
+
- 修复手机端切换工作目录弹窗中“确定切换”按钮超出卡片右侧边框的问题。
|
|
38
|
+
- 为 `.input-group input` 添加 `min-width: 0` 和 `box-sizing: border-box`,消除原生 input 元素的内在最小宽度导致的 Flexbox 溢出。
|
|
39
|
+
- 适配移动端弹窗内边距与间距(padding 降至 16px),并在 `<= 380px` 窄屏设备上自适应将按钮折行全宽呈现,防止水平溢出。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## [1.8.2] - 2026-07-26
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
- **移动端顶栏空间挤压与思考胶囊截断修复 (Mobile Topbar Layout & Thinking Pill Overflow)**:
|
|
47
|
+
- 移除移动端 `.model-pill-container` 的固定 `max-width: 130px` 宽度限制,修复深度思考胶囊被挤压截断、只显示一半图标的问题。
|
|
48
|
+
- 优化移动端顶栏各元素间距与自适应弹性缩放:侧边栏菜单按钮调整为 36px,工作目录胶囊最大宽度限制为 75px,模型胶囊适度收敛文字并隐藏默认徽章,思考胶囊保留完整图标与级别文本。
|
|
49
|
+
- 增加对 `<= 360px` 极窄小屏设备的精细化响应式适配规则,保障全尺寸移动端设备顶部操作栏整齐美观且不换行、不溢出。
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## [1.8.1] - 2026-07-26
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- **模型切换系统通知未定义函数报错修复 (Missing appendSystemNotice ReferenceError)**:补充前端 `appendSystemNotice` 函数实现,使用 `.system-notice-divider` 与 `.system-notice-text` 渲染模型变更系统提示,解决切换模型时触发 `ReferenceError` 的问题。
|
|
57
|
+
- **思考深度循环切换响应数据字段适配 (cycle_thinking_level Response Handling)**:兼容 Pi RPC 响应中返回的 `data.level` 字段,避免因字段名不匹配导致思考深度未能及时更新。
|
|
58
|
+
- **会话自定义命名解析与侧边栏即时呈现 (Session Name & session_info Extraction)**:服务端 `/api/sessions` 与 `/api/session` 增加对 `session_info` 记录的解析,支持读取用户通过 `set_session_name` 设置的会话名称并在侧边栏和顶栏直观显示。
|
|
59
|
+
- **默认模型设为已启用模型列表防回退保护 (enabledModels Sync on Set Default)**:在写入 `defaultModel` 时同步检查并加入 `enabledModels` 列表,防止 Pi core 启动时回退至 `enabledModels[0]`。
|
|
60
|
+
- **会话历史加载异常容错与提示 (Session Load Error Handling)**:`loadSession` 增加网络及文件读取异常判断与 Toast 友好提示,避免读取异常会话文件时界面卡死。
|
|
61
|
+
- **Systemd User 单元文件权限沙箱配置优化 (Systemd Unit Workspace Access)**:移除 `ProtectHome=read-only`,确保作为用户级服务启动时 Pi Agent 可以正常读写和编辑工作区项目文件。
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
10
65
|
## [1.8.0] - 2026-07-26
|
|
11
66
|
|
|
12
67
|
### Added
|
package/docs/ISSUES.md
CHANGED
|
@@ -146,5 +146,123 @@
|
|
|
146
146
|
- 修改 `detachWs`,当所有连接断开但 Agent 仍在生成中时,保留 `eventBuffer` 保证继续录制。
|
|
147
147
|
- 修改 `replayBuffered`,在 Agent 仍处于 busy 状态时消费后不清空缓存,使多个设备/多个 Tab 可多次或同时连入并安全回溯追平全部生成细节,并在流式终止时通过 `agent_settled` 进行最终的统一清理。
|
|
148
148
|
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 14. 切换模型时触发 `appendSystemNotice is not defined` 报错
|
|
152
|
+
|
|
153
|
+
**症状**:在顶部模型下拉菜单中切换模型后,控制台报错 `ReferenceError: appendSystemNotice is not defined`,并在 `/api/log-error` 中记录错误日志。
|
|
154
|
+
|
|
155
|
+
**根因**:前端 `app.js` 在处理模型切换和接收 `set_model` 响应时调用了 `appendSystemNotice` 函数在消息流中插入切换提醒,但在重构时该辅助函数未被声明。
|
|
156
|
+
|
|
157
|
+
**修复**:在 `app.js` 中补充 `appendSystemNotice(text)` 实现,利用 CSS 中已定义好的 `.system-notice-divider` 与 `.system-notice-text` 类在聊天区尾部安全插入居中分割标签。
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 15. `cycle_thinking_level` 响应字段未正确更新思考深度
|
|
162
|
+
|
|
163
|
+
**症状**:通过 RPC 调用循环切换思考级别后,前端状态与顶栏思考胶囊未实时更新。
|
|
164
|
+
|
|
165
|
+
**根因**:Pi RPC 模式下 `cycle_thinking_level` 返回的响应数据为 `{ level: ThinkingLevel }`,而前端代码按 `obj.data?.thinkingLevel` 读取导致取得 `undefined`。
|
|
166
|
+
|
|
167
|
+
**修复**:在 `app.js` 中改为 `const newLevel = obj.data?.level || obj.data?.thinkingLevel;`,兼容标准 RPC 返回格式。
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 16. 自定义会话名称 (`session_info`) 无法在侧边栏与顶栏显示
|
|
172
|
+
|
|
173
|
+
**症状**:通过 `set_session_name` 重命名会话后,侧边栏列表中依然只展示第一条用户 prompt 提取出的简略标题。
|
|
174
|
+
|
|
175
|
+
**根因**:服务端 `getSessionMetadata` 和 `/api/session` 在逐行扫描 `.jsonl` 时只查找了 `type === "session"` 和 `type === "message"`,忽略了 `type === "session_info"` 记录。
|
|
176
|
+
|
|
177
|
+
**修复**:
|
|
178
|
+
- 服务端解析 `.jsonl` 时提取最新的 `session_info.name` 作为 `sessionName` 返回。
|
|
179
|
+
- 前端 `renderSidebar` 优先使用 `s.sessionName || s.firstUser || "新对话"` 进行渲染。
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 17. Systemd 用户服务中 `ProtectHome=read-only` 导致 Agent 读写工作区报错
|
|
184
|
+
|
|
185
|
+
**症状**:通过 `install-service.sh` 将服务安装至 systemd user 后,Pi 编程代理在用户项目目录中执行 `write` 或 `edit` 工具时触发 `EACCES` 权限拒绝错误。
|
|
186
|
+
|
|
187
|
+
**根因**:服务单元模板设置了 `ProtectHome=read-only` 并仅将 `~/.pi` 和 `~/.npm-global` 列入白名单,导致 Agent 无法在 `$HOME/projects` 等工作区目录下创建或修改文件。
|
|
188
|
+
|
|
189
|
+
**修复**:移除该限制,让 User-level 服务在常规用户权限上下文下直接操作工作区。
|
|
190
|
+
|
|
191
|
+
---
|
|
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
|
+
|
|
206
|
+
## 19. 移动端切换工作目录弹窗按钮超出边框
|
|
207
|
+
|
|
208
|
+
**症状**:在手机端点击顶栏切换工作目录(CWD Modal)时,右侧的“确定切换”按钮超出了弹窗白色/暗色卡片右边框。
|
|
209
|
+
|
|
210
|
+
**根因**:
|
|
211
|
+
1. 原生 `<input>` 元素具有默认的内在 `size`(`min-width: auto` 相当于约 180px~200px)。在小屏手机上,input 的内在最小宽度加上“确定切换”按钮宽度和弹窗内边距超出了视口总可用宽度。
|
|
212
|
+
2. 移动端没有针对 `.modal` 和 `.input-group` 进行专门的响应式约束和内边距收敛。
|
|
213
|
+
|
|
214
|
+
**修复**:
|
|
215
|
+
- 给 `.input-group input` 添加 `min-width: 0; box-sizing: border-box;`,确保其在 flex 容器中可以自由收缩。
|
|
216
|
+
- 在移动端媒体查询中调整弹窗内边距为 16px,优化输入框与按钮的 padding。
|
|
217
|
+
- 在 `<= 380px` 超窄屏设备上自适应将输入框与操作按钮上下垂直排列(`flex-direction: column`),确保彻底杜绝横向溢出。
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
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]`)未能正确渲染。
|
|
246
|
+
|
|
247
|
+
**修复**:
|
|
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` 流式状态重置,确保如果在后台仍在流式生成途中切回,能清空旧数据并无缝追平实时流式内容。
|
|
266
|
+
|
|
149
267
|
|
|
150
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>");
|
|
@@ -465,7 +488,7 @@ function renderSidebar(sessions) {
|
|
|
465
488
|
return;
|
|
466
489
|
}
|
|
467
490
|
sessions.forEach((s) => {
|
|
468
|
-
const title = s.firstUser || "新对话";
|
|
491
|
+
const title = s.sessionName || s.firstUser || "新对话";
|
|
469
492
|
const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
|
|
470
493
|
const item = el("div", {
|
|
471
494
|
class: "session-item" + (s.file === state.currentSessionFile ? " active" : ""),
|
|
@@ -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,28 +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 (data.model) {
|
|
537
|
-
state.currentModel = data.model;
|
|
538
|
-
renderModelPill();
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
clearChat();
|
|
542
|
-
document.querySelector("#emptyState").style.display = "none";
|
|
543
|
-
const chat = $("#chat-inner");
|
|
544
|
-
// Walk through path entries to render messages in order.
|
|
545
|
-
// We reconstruct assistant/user/toolResult blocks.
|
|
546
|
-
const msgs = reconstructFromEntries(data.entries || []);
|
|
547
|
-
for (const m of msgs) {
|
|
548
|
-
appendMessageNode(m.role, m);
|
|
549
|
-
}
|
|
593
|
+
await syncSessionHistory(file, true);
|
|
550
594
|
// Reconnect websocket pointed at this session so new prompts continue history.
|
|
551
595
|
connectWs({ session: file });
|
|
552
|
-
// Update sidebar active highlight
|
|
553
|
-
refreshSessions();
|
|
554
596
|
}
|
|
555
597
|
|
|
556
598
|
function reconstructFromEntries(entries) {
|
|
@@ -582,7 +624,7 @@ function reconstructFromEntries(entries) {
|
|
|
582
624
|
}
|
|
583
625
|
return part;
|
|
584
626
|
});
|
|
585
|
-
if (m.stopReason === "error"
|
|
627
|
+
if (m.stopReason === "error") {
|
|
586
628
|
let errMsg = m.errorMessage || "生成失败(模型返回错误)";
|
|
587
629
|
try {
|
|
588
630
|
const parsed = JSON.parse(errMsg);
|
|
@@ -601,9 +643,21 @@ function extractContentText(content) {
|
|
|
601
643
|
if (typeof content === "string") return content;
|
|
602
644
|
if (!Array.isArray(content)) return "";
|
|
603
645
|
return content
|
|
604
|
-
.filter(c => c.type === "text" || typeof c === "string")
|
|
605
|
-
.map(c => typeof c === "string" ? c : c.text)
|
|
606
|
-
.
|
|
646
|
+
.filter(c => c && (c.type === "text" || typeof c === "string"))
|
|
647
|
+
.map(c => typeof c === "string" ? c : (c.text || ""))
|
|
648
|
+
.filter(Boolean)
|
|
649
|
+
.join("\n\n");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function appendSystemNotice(text) {
|
|
653
|
+
if (!text) return;
|
|
654
|
+
const chatInner = $("#chat-inner");
|
|
655
|
+
if (!chatInner) return;
|
|
656
|
+
const node = el("div", { class: "system-notice-divider" }, [
|
|
657
|
+
el("span", { class: "system-notice-text", text })
|
|
658
|
+
]);
|
|
659
|
+
chatInner.appendChild(node);
|
|
660
|
+
scrollBottom();
|
|
607
661
|
}
|
|
608
662
|
|
|
609
663
|
// ---- Chat rendering ----
|
|
@@ -825,14 +879,22 @@ function makeToolBlockFromCall(call) {
|
|
|
825
879
|
|
|
826
880
|
function summaryArgs(name, args) {
|
|
827
881
|
if (!args) return "";
|
|
882
|
+
let obj = args;
|
|
883
|
+
if (typeof args === "string") {
|
|
884
|
+
try { obj = JSON.parse(args); } catch { return args; }
|
|
885
|
+
}
|
|
828
886
|
try {
|
|
829
|
-
if (name === "bash" &&
|
|
830
|
-
if (name === "read" &&
|
|
831
|
-
if (name === "write" &&
|
|
832
|
-
if (name === "edit" &&
|
|
833
|
-
if (name === "ls" &&
|
|
834
|
-
if (name === "grep") return
|
|
835
|
-
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
|
+
}
|
|
836
898
|
return "";
|
|
837
899
|
} catch { return ""; }
|
|
838
900
|
}
|
|
@@ -1100,14 +1162,18 @@ function connectWs(opts = {}) {
|
|
|
1100
1162
|
state.ws = ws;
|
|
1101
1163
|
ws._gen = myGen;
|
|
1102
1164
|
|
|
1103
|
-
ws.onopen = () => {
|
|
1165
|
+
ws.onopen = async () => {
|
|
1104
1166
|
if (ws._gen !== wsGen) return;
|
|
1105
1167
|
isConnecting = false;
|
|
1106
1168
|
setConnStatus("connected");
|
|
1107
1169
|
startPingInterval();
|
|
1108
1170
|
|
|
1109
|
-
|
|
1171
|
+
const isReconnecting = wasDisconnected || reconnectAttempts > 0 || opts.isReconnect;
|
|
1172
|
+
if (isReconnecting) {
|
|
1110
1173
|
showToast("网络连接已恢复");
|
|
1174
|
+
if (targetSession) {
|
|
1175
|
+
await syncSessionHistory(targetSession, true);
|
|
1176
|
+
}
|
|
1111
1177
|
}
|
|
1112
1178
|
wasDisconnected = false;
|
|
1113
1179
|
reconnectAttempts = 0;
|
|
@@ -1168,6 +1234,11 @@ function handlePiMessage(obj) {
|
|
|
1168
1234
|
// that happened in the background while no browser was attached.
|
|
1169
1235
|
if (obj.type === "backfill_start") {
|
|
1170
1236
|
state.isBackfilling = true;
|
|
1237
|
+
state.streaming = true;
|
|
1238
|
+
setComposerAborting(true);
|
|
1239
|
+
state.streamingItems = [];
|
|
1240
|
+
state.streamingMsg = null;
|
|
1241
|
+
state.activeToolCalls.clear();
|
|
1171
1242
|
return;
|
|
1172
1243
|
}
|
|
1173
1244
|
if (obj.type === "backfill_end") {
|
|
@@ -1178,6 +1249,11 @@ function handlePiMessage(obj) {
|
|
|
1178
1249
|
setComposerAborting(true);
|
|
1179
1250
|
ensureStreamingMsg();
|
|
1180
1251
|
refreshStreamingContent();
|
|
1252
|
+
} else {
|
|
1253
|
+
finalizeStreamingMsg();
|
|
1254
|
+
state.streaming = false;
|
|
1255
|
+
setComposerAborting(false);
|
|
1256
|
+
refreshSessions();
|
|
1181
1257
|
}
|
|
1182
1258
|
// jump to the latest content once the replay is done
|
|
1183
1259
|
requestAnimationFrame(scrollBottom);
|
|
@@ -1213,8 +1289,9 @@ function handlePiMessage(obj) {
|
|
|
1213
1289
|
}
|
|
1214
1290
|
}
|
|
1215
1291
|
else if (obj.command === "cycle_thinking_level" && obj.success) {
|
|
1216
|
-
|
|
1217
|
-
|
|
1292
|
+
const newLevel = obj.data?.level || obj.data?.thinkingLevel;
|
|
1293
|
+
if (newLevel) {
|
|
1294
|
+
state.thinkingLevel = newLevel;
|
|
1218
1295
|
renderThinkingPill();
|
|
1219
1296
|
updateEmptyStateModelInfo();
|
|
1220
1297
|
}
|
|
@@ -1264,6 +1341,7 @@ function handlePiMessage(obj) {
|
|
|
1264
1341
|
finalizeStreamingMsg();
|
|
1265
1342
|
state.streaming = false;
|
|
1266
1343
|
setComposerAborting(false);
|
|
1344
|
+
sendWs({ type: "get_state" });
|
|
1267
1345
|
refreshSessions(); // titles may have changed
|
|
1268
1346
|
break;
|
|
1269
1347
|
case "message_start": {
|
|
@@ -1366,8 +1444,7 @@ function handlePiMessage(obj) {
|
|
|
1366
1444
|
case "tool_execution_update": {
|
|
1367
1445
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1368
1446
|
if (tc) {
|
|
1369
|
-
const
|
|
1370
|
-
const text = pr && pr.content ? (Array.isArray(pr.content) ? pr.content.map(c => c.text || "").join("") : "") : "";
|
|
1447
|
+
const text = extractContentText(obj.partialResult?.content);
|
|
1371
1448
|
tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
|
|
1372
1449
|
}
|
|
1373
1450
|
break;
|
|
@@ -1375,8 +1452,7 @@ function handlePiMessage(obj) {
|
|
|
1375
1452
|
case "tool_execution_end": {
|
|
1376
1453
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
1377
1454
|
if (tc) {
|
|
1378
|
-
const
|
|
1379
|
-
const text = res && res.content ? (Array.isArray(res.content) ? res.content.map(c => c.text || "").join("") : "") : "";
|
|
1455
|
+
const text = extractContentText(obj.result?.content);
|
|
1380
1456
|
tc.body.innerHTML = escapeHtml(text) || "(无输出)";
|
|
1381
1457
|
tc.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
|
|
1382
1458
|
tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
|
|
@@ -1485,7 +1561,11 @@ function updateState(d) {
|
|
|
1485
1561
|
finalizeStreamingMsg();
|
|
1486
1562
|
state.streaming = false;
|
|
1487
1563
|
setComposerAborting(false);
|
|
1488
|
-
|
|
1564
|
+
if (state.currentSessionFile) {
|
|
1565
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
1566
|
+
} else {
|
|
1567
|
+
refreshSessions();
|
|
1568
|
+
}
|
|
1489
1569
|
}
|
|
1490
1570
|
}
|
|
1491
1571
|
}
|
|
@@ -2300,10 +2380,22 @@ async function init() {
|
|
|
2300
2380
|
scheduleReconnect(0);
|
|
2301
2381
|
} else {
|
|
2302
2382
|
try { state.ws.send(JSON.stringify({ type: "ping" })); } catch {}
|
|
2383
|
+
if (state.currentSessionFile && !state.streaming) {
|
|
2384
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
2385
|
+
}
|
|
2303
2386
|
}
|
|
2304
2387
|
}
|
|
2305
2388
|
});
|
|
2306
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
|
+
|
|
2307
2399
|
// Global event delegation for code block copy buttons
|
|
2308
2400
|
document.addEventListener("click", async (e) => {
|
|
2309
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 {
|
|
@@ -811,9 +829,12 @@ body {
|
|
|
811
829
|
.input-group {
|
|
812
830
|
display: flex;
|
|
813
831
|
gap: 8px;
|
|
832
|
+
width: 100%;
|
|
833
|
+
box-sizing: border-box;
|
|
814
834
|
}
|
|
815
835
|
.input-group input {
|
|
816
836
|
flex: 1;
|
|
837
|
+
min-width: 0;
|
|
817
838
|
background: var(--bg-input);
|
|
818
839
|
border: 1px solid var(--border);
|
|
819
840
|
border-radius: 10px;
|
|
@@ -822,6 +843,7 @@ body {
|
|
|
822
843
|
font-size: 13px;
|
|
823
844
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
|
824
845
|
outline: none;
|
|
846
|
+
box-sizing: border-box;
|
|
825
847
|
}
|
|
826
848
|
.input-group input:focus {
|
|
827
849
|
border-color: var(--text-dim);
|
|
@@ -837,6 +859,8 @@ body {
|
|
|
837
859
|
cursor: pointer;
|
|
838
860
|
white-space: nowrap;
|
|
839
861
|
transition: background 0.15s;
|
|
862
|
+
flex-shrink: 0;
|
|
863
|
+
box-sizing: border-box;
|
|
840
864
|
}
|
|
841
865
|
.btn-primary:hover { background: var(--accent-hover); }
|
|
842
866
|
|
|
@@ -1205,9 +1229,9 @@ body {
|
|
|
1205
1229
|
|
|
1206
1230
|
/* Topbar Mobile Adjustments — fixed, taller, shadowed so it is always findable */
|
|
1207
1231
|
.topbar {
|
|
1208
|
-
height:
|
|
1209
|
-
padding: 0
|
|
1210
|
-
gap:
|
|
1232
|
+
height: 52px;
|
|
1233
|
+
padding: 0 8px;
|
|
1234
|
+
gap: 6px;
|
|
1211
1235
|
position: fixed;
|
|
1212
1236
|
top: 0;
|
|
1213
1237
|
left: 0;
|
|
@@ -1219,36 +1243,37 @@ body {
|
|
|
1219
1243
|
}
|
|
1220
1244
|
|
|
1221
1245
|
.main {
|
|
1222
|
-
padding-top:
|
|
1246
|
+
padding-top: 52px;
|
|
1223
1247
|
}
|
|
1224
1248
|
|
|
1225
1249
|
.topbar .session-name {
|
|
1226
|
-
font-size:
|
|
1250
|
+
font-size: 13px;
|
|
1227
1251
|
font-weight: 500;
|
|
1228
1252
|
min-width: 0;
|
|
1229
1253
|
flex: 1;
|
|
1230
1254
|
overflow: hidden;
|
|
1231
1255
|
text-overflow: ellipsis;
|
|
1232
1256
|
white-space: nowrap;
|
|
1233
|
-
padding: 0
|
|
1257
|
+
padding: 0 2px;
|
|
1234
1258
|
}
|
|
1235
1259
|
|
|
1236
1260
|
.btn-toggle-sidebar {
|
|
1237
|
-
width:
|
|
1238
|
-
height:
|
|
1261
|
+
width: 36px;
|
|
1262
|
+
height: 36px;
|
|
1239
1263
|
padding: 0;
|
|
1240
1264
|
color: var(--text);
|
|
1241
1265
|
background: var(--bg-hover);
|
|
1266
|
+
flex-shrink: 0;
|
|
1242
1267
|
}
|
|
1243
1268
|
.btn-toggle-sidebar svg {
|
|
1244
|
-
width:
|
|
1245
|
-
height:
|
|
1269
|
+
width: 20px;
|
|
1270
|
+
height: 20px;
|
|
1246
1271
|
}
|
|
1247
1272
|
|
|
1248
1273
|
#cwdPillWrap {
|
|
1249
|
-
padding:
|
|
1274
|
+
padding: 4px 7px;
|
|
1250
1275
|
font-size: 11px;
|
|
1251
|
-
max-width:
|
|
1276
|
+
max-width: 75px;
|
|
1252
1277
|
overflow: hidden;
|
|
1253
1278
|
text-overflow: ellipsis;
|
|
1254
1279
|
white-space: nowrap;
|
|
@@ -1256,25 +1281,65 @@ body {
|
|
|
1256
1281
|
}
|
|
1257
1282
|
|
|
1258
1283
|
.model-pill-container {
|
|
1259
|
-
|
|
1284
|
+
display: flex;
|
|
1285
|
+
align-items: center;
|
|
1286
|
+
gap: 4px;
|
|
1287
|
+
flex-shrink: 0;
|
|
1288
|
+
max-width: none;
|
|
1260
1289
|
}
|
|
1261
1290
|
|
|
1262
1291
|
.model-pill {
|
|
1263
|
-
padding:
|
|
1292
|
+
padding: 4px 8px;
|
|
1264
1293
|
font-size: 11px;
|
|
1265
|
-
max-width:
|
|
1294
|
+
max-width: 105px;
|
|
1266
1295
|
overflow: hidden;
|
|
1267
1296
|
text-overflow: ellipsis;
|
|
1268
1297
|
white-space: nowrap;
|
|
1269
1298
|
flex-shrink: 0;
|
|
1299
|
+
gap: 4px;
|
|
1270
1300
|
}
|
|
1271
1301
|
.model-pill-name {
|
|
1272
|
-
max-width:
|
|
1302
|
+
max-width: 65px;
|
|
1303
|
+
}
|
|
1304
|
+
.model-pill-badge {
|
|
1305
|
+
display: none !important;
|
|
1273
1306
|
}
|
|
1274
1307
|
|
|
1275
1308
|
.thinking-pill {
|
|
1276
|
-
padding:
|
|
1309
|
+
padding: 4px 7px;
|
|
1310
|
+
font-size: 11px;
|
|
1311
|
+
flex-shrink: 0;
|
|
1312
|
+
white-space: nowrap;
|
|
1313
|
+
gap: 3px;
|
|
1314
|
+
display: inline-flex;
|
|
1315
|
+
align-items: center;
|
|
1316
|
+
}
|
|
1317
|
+
.thinking-pill .thinking-icon {
|
|
1318
|
+
font-size: 12px;
|
|
1319
|
+
}
|
|
1320
|
+
.thinking-pill .thinking-label {
|
|
1277
1321
|
font-size: 11px;
|
|
1322
|
+
font-weight: 500;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
@media (max-width: 360px) {
|
|
1326
|
+
.topbar {
|
|
1327
|
+
padding: 0 4px;
|
|
1328
|
+
gap: 4px;
|
|
1329
|
+
}
|
|
1330
|
+
#cwdPillWrap {
|
|
1331
|
+
max-width: 50px;
|
|
1332
|
+
padding: 4px 5px;
|
|
1333
|
+
}
|
|
1334
|
+
.model-pill {
|
|
1335
|
+
max-width: 90px;
|
|
1336
|
+
}
|
|
1337
|
+
.model-pill-name {
|
|
1338
|
+
max-width: 50px;
|
|
1339
|
+
}
|
|
1340
|
+
.thinking-pill {
|
|
1341
|
+
padding: 4px 5px;
|
|
1342
|
+
}
|
|
1278
1343
|
}
|
|
1279
1344
|
|
|
1280
1345
|
/* Model & Thinking Dropdown Menu Mobile Adjustments */
|
|
@@ -1395,6 +1460,58 @@ body {
|
|
|
1395
1460
|
height: 32px;
|
|
1396
1461
|
}
|
|
1397
1462
|
|
|
1463
|
+
/* CWD Modal Mobile Adjustments */
|
|
1464
|
+
.modal {
|
|
1465
|
+
width: 92%;
|
|
1466
|
+
max-width: 440px;
|
|
1467
|
+
padding: 16px;
|
|
1468
|
+
border-radius: 14px;
|
|
1469
|
+
gap: 12px;
|
|
1470
|
+
box-sizing: border-box;
|
|
1471
|
+
}
|
|
1472
|
+
.modal-header h3 {
|
|
1473
|
+
font-size: 15px;
|
|
1474
|
+
}
|
|
1475
|
+
.modal-desc {
|
|
1476
|
+
font-size: 12px;
|
|
1477
|
+
}
|
|
1478
|
+
.input-group {
|
|
1479
|
+
display: flex;
|
|
1480
|
+
flex-direction: row;
|
|
1481
|
+
gap: 6px;
|
|
1482
|
+
width: 100%;
|
|
1483
|
+
box-sizing: border-box;
|
|
1484
|
+
}
|
|
1485
|
+
.input-group input {
|
|
1486
|
+
font-size: 13px;
|
|
1487
|
+
padding: 9px 10px;
|
|
1488
|
+
min-width: 0;
|
|
1489
|
+
}
|
|
1490
|
+
.btn-primary {
|
|
1491
|
+
padding: 0 14px;
|
|
1492
|
+
font-size: 12px;
|
|
1493
|
+
height: 38px;
|
|
1494
|
+
display: inline-flex;
|
|
1495
|
+
align-items: center;
|
|
1496
|
+
justify-content: center;
|
|
1497
|
+
flex-shrink: 0;
|
|
1498
|
+
}
|
|
1499
|
+
.quick-dirs .chips .chip {
|
|
1500
|
+
font-size: 11px;
|
|
1501
|
+
padding: 5px 8px;
|
|
1502
|
+
word-break: break-all;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
@media (max-width: 380px) {
|
|
1506
|
+
.input-group {
|
|
1507
|
+
flex-direction: column;
|
|
1508
|
+
gap: 8px;
|
|
1509
|
+
}
|
|
1510
|
+
.btn-primary {
|
|
1511
|
+
width: 100%;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1398
1515
|
.composer-hint {
|
|
1399
1516
|
font-size: 10px;
|
|
1400
1517
|
margin-top: 6px;
|
|
@@ -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);
|
|
@@ -551,6 +562,19 @@ app.post("/api/set-default-model", async (req, res) => {
|
|
|
551
562
|
current.defaultThinkingLevel = thinkingLevel;
|
|
552
563
|
}
|
|
553
564
|
|
|
565
|
+
// If enabledModels is configured in settings.json, ensure the new default model
|
|
566
|
+
// is in the enabledModels list, otherwise Pi core startup logic will fall back
|
|
567
|
+
// to enabledModels[0] instead of using defaultModel.
|
|
568
|
+
if (Array.isArray(current.enabledModels) && current.enabledModels.length > 0) {
|
|
569
|
+
const fullId = `${provider}/${modelId}`;
|
|
570
|
+
const inList = current.enabledModels.some(item =>
|
|
571
|
+
item === fullId || item === modelId || item === `${provider}/*` || item === "*"
|
|
572
|
+
);
|
|
573
|
+
if (!inList) {
|
|
574
|
+
current.enabledModels.unshift(fullId);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
554
578
|
await writeFile(settingsPath, JSON.stringify(current, null, 2), "utf8");
|
|
555
579
|
|
|
556
580
|
res.json({
|
|
@@ -604,11 +628,13 @@ async function listAllSessionFiles() {
|
|
|
604
628
|
// Root-level .jsonl files (created when we pass --session-dir to pi).
|
|
605
629
|
const top = await readdir(SESSIONS_DIR, { withFileTypes: true }).catch(() => []);
|
|
606
630
|
for (const e of top) {
|
|
607
|
-
if (e.isFile() && e.name.endsWith(".jsonl"))
|
|
631
|
+
if ((e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".jsonl")) {
|
|
632
|
+
files.push(path.join(SESSIONS_DIR, e.name));
|
|
633
|
+
}
|
|
608
634
|
}
|
|
609
635
|
|
|
610
636
|
// Subdirectory .jsonl files (created by pi itself when cwd is encoded).
|
|
611
|
-
const subdirs = top.filter(d => d.isDirectory());
|
|
637
|
+
const subdirs = top.filter(d => d.isDirectory() || (d.isSymbolicLink() && !d.name.endsWith(".jsonl")));
|
|
612
638
|
for (const d of subdirs) {
|
|
613
639
|
const dp = path.join(SESSIONS_DIR, d.name);
|
|
614
640
|
const names = (await readdir(dp).catch(() => [])).filter(f => f.endsWith(".jsonl"));
|
|
@@ -629,11 +655,14 @@ async function getSessionMetadata(file) {
|
|
|
629
655
|
|
|
630
656
|
const content = await readFile(file, "utf8");
|
|
631
657
|
const lines = content.split("\n").filter(Boolean);
|
|
632
|
-
let header = null, title = null, msgCount = 0;
|
|
658
|
+
let header = null, title = null, sessionName = null, msgCount = 0;
|
|
633
659
|
for (const line of lines) {
|
|
634
660
|
let o;
|
|
635
661
|
try { o = JSON.parse(line); } catch { continue; }
|
|
636
662
|
if (o.type === "session") header = o;
|
|
663
|
+
if (o.type === "session_info" && o.name) {
|
|
664
|
+
sessionName = o.name.trim();
|
|
665
|
+
}
|
|
637
666
|
if (o.type === "message" && o.message && o.message.role === "user" && !title) {
|
|
638
667
|
title = extractText(o.message.content).slice(0, 80);
|
|
639
668
|
}
|
|
@@ -649,6 +678,7 @@ async function getSessionMetadata(file) {
|
|
|
649
678
|
file,
|
|
650
679
|
name: path.basename(file),
|
|
651
680
|
id: header.id,
|
|
681
|
+
sessionName: sessionName || null,
|
|
652
682
|
timestamp: header.timestamp,
|
|
653
683
|
firstUser: title,
|
|
654
684
|
messageCount: msgCount,
|
|
@@ -662,15 +692,18 @@ app.get("/api/sessions", async (req, res) => {
|
|
|
662
692
|
try {
|
|
663
693
|
const cwd = normalizeCwd(req.query.cwd);
|
|
664
694
|
const all = await listAllSessionFiles();
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
695
|
+
const results = await Promise.all(
|
|
696
|
+
all.map(async (full) => {
|
|
697
|
+
try {
|
|
698
|
+
const meta = await getSessionMetadata(full);
|
|
699
|
+
if (meta && meta.cwd === cwd) {
|
|
700
|
+
return meta.sessionInfo;
|
|
701
|
+
}
|
|
702
|
+
} catch {}
|
|
703
|
+
return null;
|
|
704
|
+
})
|
|
705
|
+
);
|
|
706
|
+
const sessions = results.filter(Boolean);
|
|
674
707
|
sessions.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
|
|
675
708
|
res.json({ cwd, sessions });
|
|
676
709
|
} catch (e) {
|
|
@@ -684,7 +717,7 @@ function extractText(content) {
|
|
|
684
717
|
if (!Array.isArray(content)) return "";
|
|
685
718
|
return content
|
|
686
719
|
.filter(c => c && (c.type === "text" || typeof c === "string"))
|
|
687
|
-
.map(c => typeof c === "string" ? c : c.text)
|
|
720
|
+
.map(c => typeof c === "string" ? c : (c.text || ""))
|
|
688
721
|
.join("");
|
|
689
722
|
}
|
|
690
723
|
|
|
@@ -695,8 +728,8 @@ app.get("/api/session", async (req, res) => {
|
|
|
695
728
|
if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
|
|
696
729
|
|
|
697
730
|
// Security check: ensure the file path is within SESSIONS_DIR
|
|
698
|
-
|
|
699
|
-
const resolvedSessionsDir =
|
|
731
|
+
let resolvedFile = normalizePath(file);
|
|
732
|
+
const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
|
|
700
733
|
const relPath = path.relative(resolvedSessionsDir, resolvedFile);
|
|
701
734
|
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
|
|
702
735
|
return res.status(403).json({ error: "Access denied" });
|
|
@@ -706,9 +739,13 @@ app.get("/api/session", async (req, res) => {
|
|
|
706
739
|
const lines = content.split("\n").filter(Boolean);
|
|
707
740
|
const entries = [];
|
|
708
741
|
let header = null;
|
|
742
|
+
let sessionName = null;
|
|
709
743
|
for (const line of lines) {
|
|
710
744
|
let o; try { o = JSON.parse(line); } catch { continue; }
|
|
711
745
|
if (o.type === "session") header = o;
|
|
746
|
+
if (o.type === "session_info" && o.name) {
|
|
747
|
+
sessionName = o.name.trim();
|
|
748
|
+
}
|
|
712
749
|
entries.push(o);
|
|
713
750
|
}
|
|
714
751
|
// Build a map and reconstruct the active path from root -> leaf.
|
|
@@ -762,7 +799,7 @@ app.get("/api/session", async (req, res) => {
|
|
|
762
799
|
}
|
|
763
800
|
}
|
|
764
801
|
|
|
765
|
-
res.json({ header, entries: entryChain, model: sessionModel });
|
|
802
|
+
res.json({ header, entries: entryChain, model: sessionModel, sessionName });
|
|
766
803
|
} catch (e) {
|
|
767
804
|
console.error(e);
|
|
768
805
|
res.status(500).json({ error: String(e) });
|
|
@@ -804,7 +841,7 @@ wss.on("connection", (ws, req) => {
|
|
|
804
841
|
const url = new URL(req.url, "http://x");
|
|
805
842
|
const cwd = normalizeCwd(url.searchParams.get("cwd"));
|
|
806
843
|
const session = url.searchParams.get("session") || null;
|
|
807
|
-
const key = session ? `${cwd}:${
|
|
844
|
+
const key = session ? `${cwd}:${normalizePath(session)}` : null;
|
|
808
845
|
|
|
809
846
|
let agent = null;
|
|
810
847
|
if (key && activeAgents.has(key) && activeAgents.get(key).alive) {
|