@liguoshuai/pi-web-chat 1.8.3 → 1.8.4

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