@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.
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +22 -0
- package/package.json +3 -3
- package/public/app.js +36 -13
package/docs/ARCHITECTURE.md
CHANGED
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.
|
|
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.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.14.
|
|
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
|
-
|
|
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(
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1963
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
3272
|
+
startNewSession();
|
|
3250
3273
|
return;
|
|
3251
3274
|
}
|
|
3252
3275
|
if ((isCmdOrCtrl && e.key.toLowerCase() === "k") || (e.ctrlKey && e.key === "/")) {
|