@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.
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +66 -0
- package/package.json +3 -3
- package/public/app.js +36 -13
package/docs/ARCHITECTURE.md
CHANGED
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.
|
|
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.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
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 === "/")) {
|