@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.
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v1.7.5 版本)。
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.0",
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 = localStorage.getItem("pi_cwd") || state.serverCwd || state.homeDir || "";
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
- // headings
362
- const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
363
- if (m) {
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*[-*]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
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
- outHtml += `<li>${mdInlineBlock(seg.text.replace(/^\s*([-*]|\d+\.)\s+/, ""))}</li>`;
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
- // pull transcript from REST then connect a fresh WS pointed at this session
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" && content.length === 0) {
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
- .join("");
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" && args.command) return args.command;
830
- if (name === "read" && args.path) return args.path;
831
- if (name === "write" && args.path) return args.path;
832
- if (name === "edit" && args.path) return args.path;
833
- if (name === "ls" && args.path) return args.path;
834
- if (name === "grep") return args.pattern || "";
835
- if (name === "find") return args.pattern || args.path || "";
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
- if (wasDisconnected || reconnectAttempts > 0) {
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
- if (obj.data?.thinkingLevel) {
1217
- state.thinkingLevel = obj.data.thinkingLevel;
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 pr = obj.partialResult;
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 res = obj.result;
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
- refreshSessions();
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: 54px;
1209
- padding: 0 12px;
1210
- gap: 8px;
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: 54px;
1246
+ padding-top: 52px;
1223
1247
  }
1224
1248
 
1225
1249
  .topbar .session-name {
1226
- font-size: 14px;
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 4px;
1257
+ padding: 0 2px;
1234
1258
  }
1235
1259
 
1236
1260
  .btn-toggle-sidebar {
1237
- width: 40px;
1238
- height: 40px;
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: 22px;
1245
- height: 22px;
1269
+ width: 20px;
1270
+ height: 20px;
1246
1271
  }
1247
1272
 
1248
1273
  #cwdPillWrap {
1249
- padding: 5px 9px;
1274
+ padding: 4px 7px;
1250
1275
  font-size: 11px;
1251
- max-width: 90px;
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
- max-width: 130px;
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: 5px 8px;
1292
+ padding: 4px 8px;
1264
1293
  font-size: 11px;
1265
- max-width: 90px;
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: 50px;
1302
+ max-width: 65px;
1303
+ }
1304
+ .model-pill-badge {
1305
+ display: none !important;
1273
1306
  }
1274
1307
 
1275
1308
  .thinking-pill {
1276
- padding: 5px 7px;
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 normalizeCwd(dir) {
40
- if (!dir) return process.cwd() || home();
41
- let resolved = dir;
42
- if (dir.startsWith("~")) {
43
- resolved = path.join(home(), dir.slice(1));
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 = path.resolve(sessionPath);
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")) files.push(path.join(SESSIONS_DIR, e.name));
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 sessions = [];
666
- for (const full of all) {
667
- try {
668
- const meta = await getSessionMetadata(full);
669
- if (meta && meta.cwd === cwd) {
670
- sessions.push(meta.sessionInfo);
671
- }
672
- } catch {}
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
- const resolvedFile = path.resolve(file);
699
- const resolvedSessionsDir = path.resolve(SESSIONS_DIR);
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}:${path.resolve(session)}` : null;
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) {