@liguoshuai/pi-web-chat 2.14.0 → 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
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
 
9
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
+
10
32
  ## [2.14.0] - 2026-09-04
11
33
 
12
34
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.14.0",
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.14.0",
46
- "@liguoshuai/pi-chat-server": "2.14.0"
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 === "/")) {