@liguoshuai/pi-web-chat 2.12.10 → 2.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # 架构设计文档
2
2
 
3
- > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.12.7 版本)。
3
+ > pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.14.5 版本)。
4
4
 
5
5
  ---
6
6
 
package/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,72 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+
10
+ ## [2.14.5] - 2026-09-05
11
+
12
+ ### Fixed
13
+ - 修复 CI `publish-npm` 与 `publish-release` 的依赖判定:将 `!cancelled()` 改为 `success()`,测试失败时不会发布 NPM 包与 GitHub Release。
14
+ - 修正 README、各客户端 README、UED 原型、架构文档中残留的旧版本号(2.12.7 / 2.12.9 / 2.13.0),并同步全仓版本至 2.14.5。
15
+
16
+ ### Docs
17
+ - 精简 `CLAUDE.md`,明确以 `AGENTS.md` 为唯一权威来源,补齐铁律四(CI 绿勾闭环)。
18
+
19
+ ## [2.14.4] - 2026-09-05
20
+
21
+ ### Performance
22
+ - **添加 HTTP gzip 压缩中间件**:使用 Node.js 内置 `zlib` 实现(零外部依赖),对 `app.js` (123KB→~30KB)、`style.css` (46KB→~10KB) 等文本资源自动 gzip,传输体积减少约 70%。
23
+ - **添加静态资源 Cache-Control 头**:常规资源缓存 1 天,带哈希文件名的资源缓存 1 年(immutable),减少浏览器重复拉取。
24
+
25
+ ### Refactor
26
+ - **提取路径遍历安全校验为 `validateSessionFile()` helper**:`routes.js` 中 GET /api/session 和 DELETE /api/session 的重复安全校验逻辑(~30 行 × 2)合并为一处,降低维护风险。
27
+ - **提取环境变量数值解析为 `parseEnvNum()` helper**:`config.js` 中 4 处重复的 IIFE 模式合并为一个带 `{ integer }` 选项的 helper 函数。
28
+
29
+ ### Fixed
30
+ - 修复 `agent.js` `saveTimingData()` 中注释与代码不一致:注释写 "Keep last 200 turns" 但实际保留 1000 条。
31
+
32
+ ## [2.14.0] - 2026-09-04
33
+
34
+ ### Changed
35
+ - 全端版本号统一递增至 2.14.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
36
+ - 触发 CI 发布流程,产出全平台交付物(NPM 包 / HarmonyOS 包 / Android APK / GitHub Release)。
37
+
38
+ ## [2.13.1] - 2026-09-04
39
+
40
+ ### Fixed
41
+ - **修复 Android `ChatRepository.changeCwd()` 永久破坏 Repository 的关键 Bug**:
42
+ - `disconnect()` 原先调用 `wsClient.shutdown()` + `scope.cancel()`,会关闭 OkHttp 线程池并永久取消协程作用域;
43
+ - `changeCwd()` 调用 `disconnect()` 后再调用 `connect()`,但 OkHttp 已关闭无法创建新连接,`scope` 已取消导致 `init{}` 中的 `connectionState` 和 `incomingMessages` 两个 collector 永久死亡;
44
+ - 用户切换工作目录后,Repository 永久失聪 — WebSocket 消息无法处理,连接状态无法更新,所有 `scope.launch{}` 静默失效;
45
+ - 修复:`disconnect()` 改为仅调用 `wsClient.disconnect()`(只关闭当前 WebSocket),`close()` 改为 `wsClient.shutdown()` + `scope.cancel()`(完整清理),`onCleared()` 改为调用 `close()`。
46
+ - **修复 Android 真机无法连接局域网网关服务器的问题**:
47
+ - `network_security_config.xml` 原先仅允许 `10.0.2.2`(模拟器环回)、`localhost`、`127.0.0.1` 三个地址的明文 HTTP 流量;
48
+ - 真机连接局域网服务器(如 `http://192.168.1.x:3000`)时,Android 系统会静默拦截明文请求,导致 WebSocket 握手失败、App 显示"连接失败";
49
+ - 改为 `<base-config cleartextTrafficPermitted="true" />`,允许所有域名的明文 HTTP,因为 Pi Gateway 是局域网/本地开发工具,设计上只跑纯 HTTP(无 TLS),要求用户配置证书不现实。
50
+
51
+ ## [2.13.0] - 2026-09-04
52
+
53
+ ### Added
54
+ - **新增 UED 界面原型样例 (`docs/ued/`)**:
55
+ - 为网页 WEB、手机 WEB、手机 APP 三端创建 HTML 交互原型(代码级 Mockup),可浏览器直接打开预览;
56
+ - `index.html`:UED 总览页,含术语说明(UED/UX/UI/IXD)、业界原型绘制方式对比表、设计规范摘要;
57
+ - `desktop-web.html`:桌面网页端原型,双栏布局(侧边栏+主聊天区),含会话列表、消息流、代码块、输入栏;
58
+ - `mobile-web.html`:手机网页端原型,单栏布局+左滑抽屉,含 iPhone 设备框架、触控优化输入栏;
59
+ - `mobile-app.html`:手机 APP 端原型,含聊天页(气泡式消息)和设置页(模型/连接/界面配置),Android/HarmonyOS 通用风格;
60
+ - `README.md`:UED 设计说明文档,含三端布局差异对比表、配色规范、间距圆角规范。
61
+
62
+ ### Changed
63
+ - 全端版本号统一递增至 2.13.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
64
+
65
+ ## [2.12.11] - 2026-09-04
66
+
67
+ ### Added
68
+ - **AI Agent 规范升级:新增铁律四(GitHub Actions CI 全绿保障闭环)(`AGENTS.md`)**:
69
+ - 将《Pi-Chat AI Coding Agent 工作指南》核心原则升级为“四大强制原则”;
70
+ - 正式确立“铁律四:推送后必监控并保证 GitHub Actions CI 全绿”,要求智能体在每次 push 后通过 `gh run list` / `gh run watch` 跟踪 CI 流水线状态,若发生故障必须立即闭环修复至 100% 成功,保证主干代码时刻健康。
71
+
72
+ ### Changed
73
+ - 全端版本号统一递增至 2.12.11(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
74
+
9
75
  ## [2.12.10] - 2026-09-04
10
76
 
11
77
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.12.10",
3
+ "version": "2.14.5",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -42,8 +42,8 @@
42
42
  "dependencies": {
43
43
  "express": "^4.21.2",
44
44
  "ws": "^8.18.0",
45
- "@liguoshuai/pi-chat-protocol": "2.12.10",
46
- "@liguoshuai/pi-chat-server": "2.12.10"
45
+ "@liguoshuai/pi-chat-protocol": "2.14.5",
46
+ "@liguoshuai/pi-chat-server": "2.14.5"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/app.js",
package/public/app.js CHANGED
@@ -604,7 +604,8 @@ function stopStreamingTimer() {
604
604
  function sameSession(a, b) {
605
605
  if (!a || !b) return false;
606
606
  if (a === b) return true;
607
- return a.replace(/^[.~]\//, "") === b.replace(/^[.~]\//, "");
607
+ const norm = (s) => s.replace(/^(?:\.\.?|~)\//, "").replace(/^~$/, "").replace(/\/+$/, "");
608
+ return norm(a) === norm(b);
608
609
  }
609
610
 
610
611
  // ---- Sidebar / sessions ----
@@ -771,7 +772,7 @@ async function deleteSession(file, title) {
771
772
  }
772
773
  showToast("会话已删除");
773
774
  if (state.currentSessionFile === file) {
774
- startNewSession(false);
775
+ startNewSession();
775
776
  } else {
776
777
  await refreshSessions();
777
778
  }
@@ -831,13 +832,13 @@ async function syncSessionHistory(file, force = false) {
831
832
  renderModelPill();
832
833
  }
833
834
 
834
- const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
835
+ const topName = data.sessionName || data.firstUser || "新对话";
835
836
  $("#topSessionName").textContent = topName;
836
837
 
837
838
  // Only overwrite chat if not actively backfilling
838
839
  if (!state.isBackfilling && (force || !state.streaming)) {
839
840
  clearChat();
840
- const msgs = reconstructFromEntries(data.entries || []);
841
+ const msgs = reconstructFromEntries(data.entries || [], data.timing || null);
841
842
  showEmptyState(msgs.length === 0);
842
843
  for (const m of msgs) {
843
844
  appendMessageNode(m.role, m);
@@ -883,7 +884,7 @@ function parseEntryTimestamp(ts) {
883
884
  return isNaN(parsed) ? null : parsed;
884
885
  }
885
886
 
886
- function reconstructFromEntries(entries) {
887
+ function reconstructFromEntries(entries, timing = null) {
887
888
  // Map toolResults by toolCallId so we can attach them to their toolCall in the assistant message
888
889
  const toolResults = new Map();
889
890
  for (const e of entries) {
@@ -900,6 +901,7 @@ function reconstructFromEntries(entries) {
900
901
 
901
902
  const out = [];
902
903
  let lastUserTs = null;
904
+ let assistantMsgCount = 0;
903
905
  for (const e of entries) {
904
906
  if (e.type !== "message") continue;
905
907
  const m = e.message;
@@ -921,18 +923,35 @@ function reconstructFromEntries(entries) {
921
923
  out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
922
924
  } else if (m.role === "assistant") {
923
925
  let turnDurationMs = null;
924
- if (lastUserTs && msgTs && msgTs >= lastUserTs) {
926
+ // Try timing data first, then fall back to timestamp heuristic
927
+ const turnTiming = timing ? timing[assistantMsgCount] : null;
928
+ if (turnTiming?.turnDuration != null) {
929
+ turnDurationMs = turnTiming.turnDuration;
930
+ } else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
925
931
  const diff = msgTs - lastUserTs;
926
932
  if (diff > 0 && diff < 15 * 60 * 1000) {
927
933
  turnDurationMs = diff;
928
934
  }
929
935
  }
936
+ let thinkingIdx = 0;
930
937
  const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
931
938
  const content = rawContent.map(part => {
939
+ if (part && part.type === "thinking") {
940
+ // Apply persisted thinking duration from timing data
941
+ let thinkingDurationMs = null;
942
+ if (turnTiming?.thinkingDurations && thinkingIdx < turnTiming.thinkingDurations.length) {
943
+ thinkingDurationMs = turnTiming.thinkingDurations[thinkingIdx];
944
+ }
945
+ thinkingIdx++;
946
+ return { ...part, durationMs: thinkingDurationMs };
947
+ }
932
948
  if (part && part.type === "toolCall") {
933
949
  const res = toolResults.get(part.id);
934
950
  let durationMs = null;
935
- if (res) {
951
+ // Try timing data first
952
+ if (turnTiming?.toolDurations && part.id && turnTiming.toolDurations[part.id] != null) {
953
+ durationMs = turnTiming.toolDurations[part.id];
954
+ } else if (res) {
936
955
  const startTs = parseEntryTimestamp(part.timestamp || m.timestamp || e.timestamp);
937
956
  const endTs = res.entryTs || parseEntryTimestamp(res.timestamp);
938
957
  if (startTs && endTs && endTs >= startTs) {
@@ -952,6 +971,7 @@ function reconstructFromEntries(entries) {
952
971
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
953
972
  }
954
973
  out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
974
+ assistantMsgCount++;
955
975
  }
956
976
  // toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
957
977
  }
@@ -1959,9 +1979,8 @@ function handlePiMessage(obj) {
1959
1979
  const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
1960
1980
  window.history.replaceState({ session: sessionFile }, "", newUrl);
1961
1981
  } catch {}
1962
- if ($("#topSessionName").textContent === "新对话") {
1963
- $("#topSessionName").textContent = baseName(sessionFile);
1964
- }
1982
+ // Do not set baseName(sessionFile) as title — it's a session ID, not a user-friendly summary.
1983
+ // The proper title will be set by syncSessionHistory (via firstUser) or by the user prompt.
1965
1984
  refreshSessions();
1966
1985
  }
1967
1986
 
@@ -2365,7 +2384,11 @@ function updateState(d) {
2365
2384
  renderThinkingPill();
2366
2385
  }
2367
2386
  updateEmptyStateModelInfo();
2368
- $("#topSessionName").textContent = d?.sessionName || (d?.sessionFile ? baseName(d.sessionFile) : "新对话");
2387
+ // Only update top bar title from state if sessionName is explicitly available;
2388
+ // otherwise keep current title (set by syncSessionHistory or user prompt) to avoid showing session file name.
2389
+ if (d?.sessionName) {
2390
+ $("#topSessionName").textContent = d.sessionName;
2391
+ }
2369
2392
 
2370
2393
  // Sync streaming state upon state updates (e.g. after reconnect)
2371
2394
  if (d && typeof d.isStreaming === "boolean") {
@@ -3173,7 +3196,7 @@ async function init() {
3173
3196
  await loadServerConfig();
3174
3197
 
3175
3198
  // event listeners
3176
- $("#btnNew").addEventListener("click", () => startNewSession(true));
3199
+ $("#btnNew").addEventListener("click", () => startNewSession());
3177
3200
 
3178
3201
  $("#sendBtn").addEventListener("click", () => {
3179
3202
  if (state.streaming) {
@@ -3246,7 +3269,7 @@ async function init() {
3246
3269
 
3247
3270
  if (isCmdOrCtrl && e.shiftKey && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "o")) {
3248
3271
  e.preventDefault();
3249
- startNewSession(true);
3272
+ startNewSession();
3250
3273
  return;
3251
3274
  }
3252
3275
  if ((isCmdOrCtrl && e.key.toLowerCase() === "k") || (e.ctrlKey && e.key === "/")) {