@liguoshuai/pi-web-chat 2.14.0 → 2.14.12
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 +65 -0
- package/package.json +3 -3
- package/public/app.js +72 -13
package/docs/ARCHITECTURE.md
CHANGED
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
## [2.14.12] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **修复 HarmonyOS `versionCode` 位数与数值回退**:将 `clients/harmony/AppScope/app.json5` 的 `versionCode` 从错误的三位缩短格式(`21411`)恢复为标准的七位格式(`2141200`),并统一全仓版本号至 2.14.12。修正自 2.14.7 起 `versionCode` 由 `2140600` 误缩为 `21407` 造成的数值回退,避免 HarmonyOS 应用商店因版本号非单调递增而拒绝上架。
|
|
14
|
+
|
|
15
|
+
## [2.14.11] - 2026-09-05
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- **固化服务端与测试进程生命周期回收**:在 `createServer().close()` 中增加 `httpServer.closeAllConnections()` 及 `wss.clients` 强制 terminate 机制,杜绝单元测试及异常退出时因 TCP Keep-Alive 或 WebSocket 连接残留导致 `server.close()` 阻塞挂死。
|
|
19
|
+
|
|
20
|
+
## [2.14.10] - 2026-09-05
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **兼容 Node 18+ 原生测试参数**:移除 `node --test` 中 Node 18 不支持的 `--test-timeout` 选项,统一在异步测试内部通过标准 `setTimeout` + `try ... finally` 进行跨版本超时控制与资源清理。
|
|
24
|
+
|
|
25
|
+
## [2.14.9] - 2026-09-05
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **修复 CI 依赖安装与 Lockfile 同步**:补全 `clients/web/package.json` 中的依赖声明并同步 `pnpm-lock.yaml`,修复 GitHub Actions 流水线中 `pnpm install --frozen-lockfile` 报错。
|
|
29
|
+
|
|
30
|
+
## [2.14.8] - 2026-09-05
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **修复移动端(Android & HarmonyOS)新会话无法正常拉起且卡在“思考中…”的关键 Bug**:
|
|
34
|
+
- **反序列化强类型报错丢包修复**:`GenericServerMessage` 中 `message`、`code`、`id`、`content` 等多态字段由 `String?` 调整为 `JsonElement?`。修复 Pi 发送 `message_start` / `message_end`(`message` 为 JSON 对象)及 `pi_exit`(`code` 为数字)时抛出 `JsonDecodingException: Expected string, but had BEGIN_OBJECT/0` 导致事件被静默丢弃、会话状态无法流转的问题。
|
|
35
|
+
- **Prompt 失败响应与错误透传闭环**:服务端在 `activeAgent.send(prompt)` 发生预检失败或 Agent 进程死亡时立即回传 `success: false` 响应;移动端(Android `ChatRepository` 与鸿蒙 `ChatViewModel`)补充 `response.command === "prompt"` 失败处理分支,立即重置 `isStreaming = false` 并展示错误卡片,杜绝无响应时永久卡在“思考中…”。
|
|
36
|
+
- **新建会话状态全链路同步**:移动端在触发 `new_session` 成功后,自动重置流式状态并向服务端重新请求 `get_state` 与 `get_available_models`,同时将当前已选模型及思考级别重新应用到新会话 Agent 实例中。
|
|
37
|
+
- **模型报错与异常 UI 渲染完善**:补充 `message_end` 中 `stopReason === "error"` 及 `MessageStatus.ERROR` 的多端错误占位与提示渲染,避免异常时产生空白或假死气泡。
|
|
38
|
+
- **单元测试防挂死与超时保护**:为全仓 Node 测试命令增加 `--test-timeout=30000` 超时保护,网关 WebSocket 单元测试增加显式超时 reject 与 `try ... finally` 资源安全释放,防止异常未捕获导致测试进程永久挂起阻塞流水线。
|
|
39
|
+
|
|
40
|
+
## [2.14.7] - 2026-09-05
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
- **修复僵死任务问题**:服务端 `send()` 对 `prompt`/`steer`/`client_send` 等长运行命令增加可配置超时(默认 10 分钟,环境变量 `LONG_RUNNING_TIMEOUT_MS`),超时后清理 pending entry、重置 streaming 状态并广播 error 事件,防止 pi 子进程挂起时 agent 成为不可回收的僵尸。
|
|
44
|
+
- **前端 `tool_execution_end` 清理 activeToolCalls**:工具执行完成时从 `activeToolCalls` Map 中删除该 entry,避免 stale 条目累积。此前仅在 `finalizeStreamingMsg`/`clearChat` 等批量清空,若 `tool_execution_end` 事件丢失则工具永远卡在"执行中…"。
|
|
45
|
+
- **WebSocket 断线时标记 stale 工具为"已中断"**:`ws.onclose` 中遍历 `activeToolCalls`,将仍为"执行中…"状态的 tool block 标记为"已中断"并停止计时,防止断线后残留僵尸工具块。
|
|
46
|
+
- **`backfill_end` 后清理 stale 工具**:重连回放结束后,若服务端表示 `!streaming`,将所有仍为"执行中…"的 tool block 标记为"已中断",处理 buffer 溢出丢失 `tool_execution_end` 事件的场景。
|
|
47
|
+
|
|
48
|
+
## [2.14.6] - 2026-09-05
|
|
49
|
+
|
|
50
|
+
### Docs
|
|
51
|
+
- 在 `AGENTS.md` 新增「⚡ 效率、专注与职业化准则」章节,从效率(并行执行、合并命令、禁止冗余读取、最小路径)、专注(单一目标、范围控制、先澄清后动手、一次到位)、职业化(诚实不作假、先诊断后修复、最小改动、透明收尾)三个维度规范 AI 智能体行为,提升交付效率与专业度。
|
|
52
|
+
|
|
53
|
+
## [2.14.5] - 2026-09-05
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
- 修复 CI `publish-npm` 与 `publish-release` 的依赖判定:将 `!cancelled()` 改为 `success()`,测试失败时不会发布 NPM 包与 GitHub Release。
|
|
57
|
+
- 修正 README、各客户端 README、UED 原型、架构文档中残留的旧版本号(2.12.7 / 2.12.9 / 2.13.0),并同步全仓版本至 2.14.5。
|
|
58
|
+
|
|
59
|
+
### Docs
|
|
60
|
+
- 精简 `CLAUDE.md`,明确以 `AGENTS.md` 为唯一权威来源,补齐铁律四(CI 绿勾闭环)。
|
|
61
|
+
|
|
62
|
+
## [2.14.4] - 2026-09-05
|
|
63
|
+
|
|
64
|
+
### Performance
|
|
65
|
+
- **添加 HTTP gzip 压缩中间件**:使用 Node.js 内置 `zlib` 实现(零外部依赖),对 `app.js` (123KB→~30KB)、`style.css` (46KB→~10KB) 等文本资源自动 gzip,传输体积减少约 70%。
|
|
66
|
+
- **添加静态资源 Cache-Control 头**:常规资源缓存 1 天,带哈希文件名的资源缓存 1 年(immutable),减少浏览器重复拉取。
|
|
67
|
+
|
|
68
|
+
### Refactor
|
|
69
|
+
- **提取路径遍历安全校验为 `validateSessionFile()` helper**:`routes.js` 中 GET /api/session 和 DELETE /api/session 的重复安全校验逻辑(~30 行 × 2)合并为一处,降低维护风险。
|
|
70
|
+
- **提取环境变量数值解析为 `parseEnvNum()` helper**:`config.js` 中 4 处重复的 IIFE 模式合并为一个带 `{ integer }` 选项的 helper 函数。
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
- 修复 `agent.js` `saveTimingData()` 中注释与代码不一致:注释写 "Keep last 200 turns" 但实际保留 1000 条。
|
|
74
|
+
|
|
10
75
|
## [2.14.0] - 2026-09-04
|
|
11
76
|
|
|
12
77
|
### 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.12",
|
|
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.12",
|
|
46
|
+
"@liguoshuai/pi-chat-server": "2.14.12"
|
|
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
|
}
|
|
@@ -1912,6 +1932,22 @@ function connectWs(opts = {}) {
|
|
|
1912
1932
|
isConnecting = false;
|
|
1913
1933
|
if (ws._suppressOnclose) return;
|
|
1914
1934
|
|
|
1935
|
+
// Mark any tool blocks still in "执行中…" as interrupted — otherwise they
|
|
1936
|
+
// stay as zombie "执行中…" blocks forever after a disconnect mid-execution.
|
|
1937
|
+
for (const [, tc] of state.activeToolCalls) {
|
|
1938
|
+
const stateEl = tc.head?.querySelector(".state");
|
|
1939
|
+
if (stateEl && stateEl.classList.contains("running")) {
|
|
1940
|
+
stateEl.className = "state error";
|
|
1941
|
+
stateEl.textContent = "已中断";
|
|
1942
|
+
}
|
|
1943
|
+
if (tc.durationEl && tc.durationEl._startedAt) {
|
|
1944
|
+
const dur = Math.max(0, Date.now() - tc.durationEl._startedAt);
|
|
1945
|
+
tc.durationEl.className = "tool-duration";
|
|
1946
|
+
tc.durationEl._startedAt = null;
|
|
1947
|
+
tc.durationEl.textContent = `耗时 ${formatDuration(dur)}`;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1915
1951
|
wasDisconnected = true;
|
|
1916
1952
|
setConnStatus("disconnected");
|
|
1917
1953
|
scheduleReconnect();
|
|
@@ -1959,9 +1995,8 @@ function handlePiMessage(obj) {
|
|
|
1959
1995
|
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
|
|
1960
1996
|
window.history.replaceState({ session: sessionFile }, "", newUrl);
|
|
1961
1997
|
} catch {}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
}
|
|
1998
|
+
// Do not set baseName(sessionFile) as title — it's a session ID, not a user-friendly summary.
|
|
1999
|
+
// The proper title will be set by syncSessionHistory (via firstUser) or by the user prompt.
|
|
1965
2000
|
refreshSessions();
|
|
1966
2001
|
}
|
|
1967
2002
|
|
|
@@ -1987,6 +2022,22 @@ function handlePiMessage(obj) {
|
|
|
1987
2022
|
syncSessionHistory(state.currentSessionFile, false);
|
|
1988
2023
|
}
|
|
1989
2024
|
} else {
|
|
2025
|
+
// Server says not streaming — finalize any stale tool blocks that were
|
|
2026
|
+
// left in "执行中…" by a partial backfill (e.g. buffer overflow lost
|
|
2027
|
+
// the matching tool_execution_end event).
|
|
2028
|
+
for (const [, tc] of state.activeToolCalls) {
|
|
2029
|
+
const stateEl = tc.head?.querySelector(".state");
|
|
2030
|
+
if (stateEl && stateEl.classList.contains("running")) {
|
|
2031
|
+
stateEl.className = "state error";
|
|
2032
|
+
stateEl.textContent = "已中断";
|
|
2033
|
+
}
|
|
2034
|
+
if (tc.durationEl && tc.durationEl._startedAt) {
|
|
2035
|
+
const dur = Math.max(0, Date.now() - tc.durationEl._startedAt);
|
|
2036
|
+
tc.durationEl.className = "tool-duration";
|
|
2037
|
+
tc.durationEl._startedAt = null;
|
|
2038
|
+
tc.durationEl.textContent = `耗时 ${formatDuration(dur)}`;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
1990
2041
|
finalizeStreamingMsg();
|
|
1991
2042
|
state.streaming = false;
|
|
1992
2043
|
state.aborting = false;
|
|
@@ -2266,6 +2317,10 @@ function handlePiMessage(obj) {
|
|
|
2266
2317
|
stateEl.textContent = obj.isError ? "错误" : "完成";
|
|
2267
2318
|
stateEl.className = "state" + (obj.isError ? " error" : "");
|
|
2268
2319
|
}
|
|
2320
|
+
// Remove from activeToolCalls so stale entries don't accumulate.
|
|
2321
|
+
// Previously only cleared wholesale in finalizeStreamingMsg/clearChat/etc,
|
|
2322
|
+
// which meant a missed tool_execution_end left tools stuck in "执行中…" forever.
|
|
2323
|
+
state.activeToolCalls.delete(obj.toolCallId);
|
|
2269
2324
|
}
|
|
2270
2325
|
break;
|
|
2271
2326
|
}
|
|
@@ -2365,7 +2420,11 @@ function updateState(d) {
|
|
|
2365
2420
|
renderThinkingPill();
|
|
2366
2421
|
}
|
|
2367
2422
|
updateEmptyStateModelInfo();
|
|
2368
|
-
|
|
2423
|
+
// Only update top bar title from state if sessionName is explicitly available;
|
|
2424
|
+
// otherwise keep current title (set by syncSessionHistory or user prompt) to avoid showing session file name.
|
|
2425
|
+
if (d?.sessionName) {
|
|
2426
|
+
$("#topSessionName").textContent = d.sessionName;
|
|
2427
|
+
}
|
|
2369
2428
|
|
|
2370
2429
|
// Sync streaming state upon state updates (e.g. after reconnect)
|
|
2371
2430
|
if (d && typeof d.isStreaming === "boolean") {
|
|
@@ -3173,7 +3232,7 @@ async function init() {
|
|
|
3173
3232
|
await loadServerConfig();
|
|
3174
3233
|
|
|
3175
3234
|
// event listeners
|
|
3176
|
-
$("#btnNew").addEventListener("click", () => startNewSession(
|
|
3235
|
+
$("#btnNew").addEventListener("click", () => startNewSession());
|
|
3177
3236
|
|
|
3178
3237
|
$("#sendBtn").addEventListener("click", () => {
|
|
3179
3238
|
if (state.streaming) {
|
|
@@ -3246,7 +3305,7 @@ async function init() {
|
|
|
3246
3305
|
|
|
3247
3306
|
if (isCmdOrCtrl && e.shiftKey && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "o")) {
|
|
3248
3307
|
e.preventDefault();
|
|
3249
|
-
startNewSession(
|
|
3308
|
+
startNewSession();
|
|
3250
3309
|
return;
|
|
3251
3310
|
}
|
|
3252
3311
|
if ((isCmdOrCtrl && e.key.toLowerCase() === "k") || (e.ctrlKey && e.key === "/")) {
|