@liguoshuai/pi-web-chat 2.5.1 → 2.11.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,175 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [2.11.2] - 2026-09-03
11
+
12
+ ### Fixed
13
+ - **Web 端 `submitSteer` 发送失败静默丢弃 (`clients/web/public/app.js`)**:`sendWs` 返回值未检查,WebSocket 在检查与发送之间断开时,指导指令被静默丢弃但 UI 已显示"已插入指导指令"。现先检查 `sendWs` 返回值,失败时提示用户并触发重连,不再乐观更新 UI。
14
+ - **Web 端 `abortGeneration` 发送失败导致 UI 锁死 (`clients/web/public/app.js`)**:`sendWs({ type: "abort" })` 返回值未检查,发送失败时 `state.aborting` 永久为 `true`,UI 锁定在中止状态。现检查返回值,失败时回退 `state.aborting` 并提示用户。
15
+ - **Web 端 `submitPrompt` 失败回退遗漏定时器清理 (`clients/web/public/app.js`)**:发送失败回退时未调用 `stopStreamingTimer()` 且未重置 `turnStartedAt` / `streamingMsgDurationEl`,导致 100ms 定时器持续向已分离 DOM 节点写入。现已补充清理。
16
+ - **Android `sendSteer` / `abort` / `switchSession` 发送返回值未检查 (`clients/android/.../ChatRepository.kt`)**:`sendRaw` 返回的 `Boolean` 被忽略。`abort` 无条件设置 `_isStreaming = false` 即使中止未送达服务端;`switchSession` 在发送前清空消息列表,发送失败时导致状态不同步。现均检查返回值并在失败时回退。
17
+ - **HarmonyOS `abort` / `newSession` / `switchSession` 发送返回值未检查 (`clients/harmony/.../ChatViewModel.ets`)**:与 Android 同类问题。`abort` 无条件重置 `isStreaming`;`newSession` / `switchSession` 在发送前清空消息,发送失败时导致状态不同步。现均检查 `wsManager.send()` 返回值并在失败时回退。
18
+ - **HarmonyOS `remote_user_prompt` 消息重复 (`clients/harmony/.../ChatViewModel.ets`)**:本地已发送的用户消息与服务端 `remote_user_prompt` 回声重复添加。现增加去重检查,若倒数第二条用户消息内容相同则跳过。
19
+ - **HarmonyOS `init()` 未重置流式状态 (`clients/harmony/.../ChatViewModel.ets`)**:切换服务器 URL 重新初始化时,残留的 `isStreaming = true` 导致孤立流式气泡。现 `init()` 中重置 `isStreaming = false`。
20
+ - **Android 编译失败修复 (`clients/android/.../ChatScreen.kt`)**:补充 `SmartToy` 与 `Psychology` 图标的 `import` 声明,修复替顶部胶囊换用 Material 图标后因缺失导入导致的 `Unresolved reference`,`assembleDebug` 可正常产出 APK。
21
+ - **服务端单元测试 CI 挂起修复 (`server/test/server.test.js` + `server/fixtures/pi-stub.mjs`)**:CI runner 未安装 `pi` CLI,`PiAgent.start()` 执行 `spawn("pi")` 时报 `ENOENT`,导致 `WebSocket gateway NEW_SESSION` 断言失败、HTTP 服务未关闭,最终使 `node --test` 进程长时间挂起。现引入无操作长驻 stub 并重写 `config.piBin`,使网关单测自包含、不再依赖外部 `pi` 二进制。
22
+
23
+ ### Changed
24
+ - 全端版本号统一递增至 2.11.2(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
25
+
26
+ ---
27
+
28
+ ## [2.11.1] - 2026-09-03
29
+
30
+ ### Fixed
31
+ - **Android 构建失败修复 (`clients/android/.../ChatRepository.kt`)**:修复 `connect` 函数声明被写坏导致的语法错误(`fun connectcwd: String = ...` → `fun connect(cwd: String = ..., sessionPath: String? = null)`),该错误导致 `compileDebugKotlin` 失败、APK 无法产出,并连带触发 `ChatViewModel` 中多处 `Unresolved reference 'connect'`。修复后 Android Debug APK 可正常构建。
32
+
33
+ ### Changed
34
+ - 全端版本号统一递增至 2.11.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
35
+
36
+ ---
37
+
38
+ ## [2.11.0] - 2026-09-03
39
+
40
+ ### Changed & Beautified (Android)
41
+ - **应用图标焕新 (`clients/android`)**:弃用系统默认图标,新增基于 π 品牌图形与翡翠绿渐变的 **自适应启动图标 (Adaptive Icon)**,包含背景渐变、白色圆角 π 字形与点缀色,支持普通与圆形图标形态。
42
+ - **顶部胶囊图标美化 (TopBar Pills)**:将模型选择 (🤖) 与思考级别 (🧠) 的简单 Emoji 替换为 **彩色圆形徽章 + Material 图标**(`SmartToy` 机器人 / `Psychology` 大脑),并同步美化工作目录胶囊图标,视觉更加精致统一。
43
+ - **对话框气泡美化 (Message Bubbles)**:
44
+ - 用户消息气泡升级为 **翡翠绿渐变背景 + 护色描边**,圆角更柔和、内边距更舒适。
45
+ - 助手头像由扁平半透明圆形升级为 **彩色渐变圆形徽章**(白色 π),辨识度与质感更佳。
46
+ - **样例提示词间距优化 (Suggestion Chips)**:解决提示词芯片过于紧凑的问题,引入图标 + 标签的卡片式芯片,并增大水平与垂直间距、内边距,视觉更舒展。
47
+
48
+ ### Changed
49
+ - 全端版本号统一递增至 2.11.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
50
+
51
+ ---
52
+
53
+ ## [2.10.1] - 2026-09-03
54
+
55
+ ### Fixed
56
+ - **HarmonyOS 监听器泄漏修复 (`clients/harmony/.../ChatViewModel.ets`)**:`init()` 每次调用都新增 WebSocket 状态与消息监听器但不移除旧的,导致重复调用时监听器堆积、消息被重复处理。现已保存监听器引用并在重新初始化时先移除旧监听器。
57
+ - **HarmonyOS WebSocket 重连竞争修复 (`clients/harmony/.../WebSocketManager.ets`)**:`connect()` 替换已有连接时,旧 socket 的异步 `on('close')` 回调仍会触发 `scheduleReconnect()`,导致连接抖动。现已通过捕获 ws 实例引用,在陈旧回调中跳过重连逻辑。
58
+ - **Android WebSocket 连接泄漏修复 (`clients/android/.../WebSocketClient.kt`)**:`connect()` 未关闭已有 WebSocket 连接,在重连场景下旧连接资源泄漏。现已在 `connect()` 开头主动关闭并清理旧连接。
59
+ - **Android 协程作用域泄漏修复 (`clients/android/.../ChatRepository.kt` / `ChatViewModel.kt`)**:`reconnect()` 创建新 `ChatRepository` 但未取消旧实例的协程作用域,导致协程泄漏。现已新增 `close()` 方法并在重连时调用。
60
+ - **协议层冗余代码清理 (`packages/protocol/src/index.js`)**:`formatDuration` 函数前两个条件分支完全相同,移除冗余的 `ms < 1000` 分支。
61
+ - **HarmonyOS 冗余代码清理 (`clients/harmony/.../Index.ets`)**:同步移除 `formatDuration` 中相同的冗余分支。
62
+ - **Web 端 Token 泄漏防护 (`clients/web/public/app.js`)**:从 URL 提取 auth token 后立即使用 `history.replaceState` 清除 URL 中的 token 参数,防止通过 Referer 头、截图或分享链接泄漏。
63
+ - **Web 端代码围栏匹配修复 (`clients/web/public/app.js`)**:`renderMarkdown` 中 ```` 围栏匹配改为仅在行首匹配,避免将行内三反引号误判为代码围栏。
64
+ - **Web 端 SVG 元素命名空间修复 (`clients/web/public/app.js`)**:DOM 辅助函数 `el()` 对 SVG 标签使用 `createElementNS` 创建,修复 SVG 图标渲染问题。
65
+ - **Web 端发送失败处理增强 (`clients/web/public/app.js`)**:`sendWs` 返回布尔值,`submitPrompt` 在 WebSocket 断开时回退流式状态并提示用户,`setComposerAborting(true)` 正确设置 `state.aborting`。
66
+ - **Android API 基址规范化 (`clients/android/.../ApiService.kt`)**:构造函数中自动去除 `baseUrl` 尾部斜杠,避免拼接出双斜杠 URL。
67
+
68
+ ### Changed
69
+ - 全端版本号统一递增至 2.10.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
70
+ ---
71
+
72
+ ## [2.10.0] - 2026-09-02
73
+
74
+ ### Added & Aligned
75
+ - **Android 原生客户端与移动 Web 端全要素对齐 (`clients/android`)**:
76
+ - **顶部栏元素完整对齐 (TopBar Alignment)**:
77
+ - 新增 **工作目录胶囊按钮 (CWD Pill)**:展示当前工作目录,点击弹出工作目录切换对话框(包含路径输入与 `~`, `~/.pi`, `/tmp` 快捷芯片)。
78
+ - 新增 **模型选择胶囊按钮 (Model Pill)**:展示当前模型名称与 `★ 默认` 徽章,点击弹出支持搜索、厂商归类、能力标签(🧠 推理、👁️ 视觉、🛠️ 工具)的模型选择对话框。
79
+ - 新增 **深度思考级别胶囊按钮 (Thinking Pill)**:展示当前思考深度,点击弹出 `Off`、`Minimal`、`Low`、`Medium`、`High`、`Max` 等推理级别选择对话框。
80
+ - 新增 **会话导出按钮 (Export Chat)**:一键将完整对话导出为标准 Markdown 格式并唤起系统分享。
81
+ - **设置入口单点统一**:移除侧边抽屉顶部多余的重复设置按钮,保留顶部栏统一单一设置入口,避免操作歧义。
82
+ - **空白引导页全要素对齐 (Empty State Alignment)**:
83
+ - 引入 `π` 经典头像与提示文案。
84
+ - 引入 **当前模型信息横幅 (Empty Model Banner)**:展示当前模型名称、默认徽章、🧠 思考 / 👁️ 视觉 / 🛠️ 工具能力标签及“切换模型”快捷按钮。
85
+ - 引入 **快捷指令芯片 (Suggestion Chips)**:提供“列出当前目录文件”、“总结这个项目”、“代码审查”等一键触发指令。
86
+ - **底部输入区域与富文本展示对齐 (Composer & Message Bubbles)**:
87
+ - 增加待发送附件预览栏(带缩略图与删除 `✕` 按钮)。
88
+ - 增加图片全屏查看画廊模式 (Image Lightbox Modal)。
89
+ - 助手回复内容 Markdown 解析优化,代码块增加语言标签与“复制代码”独立按钮。
90
+ - 增加底部操作提示文案:“pi 会执行命令与读写你的文件 —— 请注意操作内容。”
91
+
92
+ ### Changed
93
+ - 全端版本号统一递增至 2.10.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
94
+
95
+ ---
96
+
97
+ ## [2.9.1] - 2026-09-02
98
+
99
+ ### Fixed & Optimized
100
+ - **网关路由健壮性增强 (`server/src/routes.js`)**:修复 `/api/sessions` 历史会话列表中若存在数值格式时间戳导致 `localeCompare` 抛错的问题,优化为安全兼容数字与 ISO 字符串的多格式时间戳倒序排列。
101
+ - **Web 前端异常流式保护 (`clients/web/public/app.js`)**:在 WebSocket 消息处理中补充对 `error` 与 `pi_exit` 事件的完备响应,遇到异常时自动终结流式状态、解除输入框锁定并提示用户。
102
+ - **Android 原生端异常退出流式重置 (`clients/android`)**:`ChatRepository.kt` 补充对 `error` 和 `pi_exit` 事件的处理,避免在子进程异常退出时状态滞留在流式中。
103
+ - **HarmonyOS 原生端异常流式重置 (`clients/harmony`)**:`ChatViewModel.ets` 补充对 `error` 和 `pi_exit` 事件的处理,确保鸿蒙界面在异常中断时能正常完成并释放输入。
104
+ - **跨端通信协议 Schema 完备性更新 (`packages/protocol/src/schema.json`)**:补充 `remote_user_steer` 与 `extension_ui_request` 到服务端消息类型枚举定义。
105
+
106
+ ### Changed
107
+ - 全端版本号统一递增至 2.9.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
108
+
109
+ ---
110
+
111
+ ## [2.9.0] - 2026-09-02
112
+
113
+ ### Added
114
+ - **思考耗时与执行耗时多端展示特性 (Thinking & Execution Duration Display)**:
115
+ - **Web 端 (`clients/web`)**:
116
+ - 思考过程块 (`.thinking-block`):流式思考进行中实时显示动态耗时(例如 `3.2s`),思考完成显示完整用时徽章(例如 `用时 3.2s`)。
117
+ - 工具执行块 (`.tool-block`):执行中显示动态计时(例如 `执行中 · 1.2s`),执行完毕显示最终耗时(例如 `完成 · 1.2s` 或 `错误 · 0.8s`)。
118
+ - 助手回复消息头部 (`.role-tag`):实时显示本次回答生成总耗时,并在生成完成后展示总耗时(例如 `耗时 4.5s`)。
119
+ - 历史会话加载 (`reconstructFromEntries`):支持自动从历史日志计算各工具调用耗时与助手生成总耗时并渲染。
120
+ - **Android 原生端 (`clients/android`)**:
121
+ - `ChatMessage` 与 `ToolCall` 模型扩充思考用时 (`thinkingDurationMs`) 与执行耗时 (`durationMs`, `turnDurationMs`) 属性。
122
+ - Compose 界面中的 `ThinkingBlock`、`ToolCallBlock` 以及 `MessageBubble` 均支持清晰展示思考耗时、工具执行耗时与回答生成总用时。
123
+ - 历史会话加载支持还原思考与工具执行耗时。
124
+ - **HarmonyOS 鸿蒙原生端 (`clients/harmony`)**:
125
+ - `ChatMessage` 增加 `thinkingDuration` 与 `turnDuration` 属性支持。
126
+ - `Index.ets` 在思考过程与助手回复头部优雅展示思考耗时与执行总耗时。
127
+ - **跨端协议与公共库 (`@liguoshuai/pi-chat-protocol`)**:
128
+ - 新增跨端标准耗时格式化函数 `formatDuration(ms)` 与单元测试。
129
+
130
+ ### Changed
131
+ - 全端版本号统一升级至 2.9.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
132
+
133
+ ---
134
+
135
+ ## [2.8.0] - 2026-09-02
136
+
137
+ ### Added
138
+ - 前端思考过程与工具调用块时间戳显示:在思考过程块和工具调用块中添加时间戳,格式为当天 HH:mm,跨天 MM-dd HH:mm。
139
+
140
+ ## [2.7.0] - 2026-09-01
141
+
142
+ ### Added
143
+ - **网关 REST CORS 跨域支持与预检支持 (`server`)**:
144
+ - 在 Express 网关服务中增加标准 CORS 拦截器,支持自定义 `ALLOWED_ORIGINS` 配置与通配放行,支持 `OPTIONS` preflight 预检请求与相关请求头(`Authorization`, `Content-Type`, `x-api-token`)。
145
+ - **服务端环境变量自动检测与多宿主兼容 (`server`)**:
146
+ - 服务端配置模块增加自动检测并读取根目录或包内 `.env` 文件的能力。
147
+ - 完善 Windows 宿主机上的 `pi` 执行文件解析(`.cmd` / `.exe`)与 `spawn` 平台兼容性。
148
+ - **Web 端 PWA (Progressive Web App) 支持 (`clients/web`)**:
149
+ - 新增 `manifest.webmanifest` 与高清矢量 `icon.svg`,支持在手机与桌面浏览器中一键“添加到主屏幕”,享受沉浸式应用体验。
150
+ - **移动端指数退避断线重连 (`clients/android` & `clients/harmony`)**:
151
+ - Android 原生端 (`WebSocketClient.kt`) 与 HarmonyOS 鸿蒙原生端 (`WebSocketManager.ets`) 统一引入 Exponential Backoff + Jitter(指数退避与随机抖动重连算法),有效防止服务端重启或网络抖动时的瞬时并发重连风暴。
152
+ - **CI 工作流强化 (`.github/workflows/ci.yml`)**:
153
+ - 在 GitHub Actions 中增加 Android 原生端自动化构建与依赖检查(`gradlew assembleDebug`)。
154
+
155
+ ### Changed
156
+ - 全端版本号统一升级至 2.7.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
157
+
158
+ ---
159
+
160
+ ## [2.6.0] - 2026-09-01
161
+
162
+ ### Added
163
+ - **跨端协议增强与强类型声明 (`@liguoshuai/pi-chat-protocol`)**:
164
+ - 新增 `createBackfillStartMessage` 与 `createBackfillEndMessage` 协议消息构造器,支持在断线重连回放中透传 `overflowed` 标志。
165
+ - 新增 `ToolCallPayload` 与 `ToolResultPayload` 强类型接口,增强跨端工具调用的类型安全性。
166
+ - 在 Android (`PiChatProtocol.kt`) 与 HarmonyOS (`PiProtocol.ets`) 客户端协议模型中同步补齐 `overflowed` 等字段。
167
+ - **网关断线溢出检测与客户端自愈补偿 (`server` & `clients/web`)**:
168
+ - 网关 `PiAgent` 增加事件环形缓冲区溢出状态跟踪(`hasBufferOverflowed`),在客户端重连事件回放结束(`backfill_end`)时明确通知客户端。
169
+ - Web 客户端侦测到 `overflowed: true` 时,自动在后台触发增量/完整历史记录无感同步(`syncSessionHistory`),彻底杜绝网络严重抖动或长时间离线导致的消息截断。
170
+ - **Web 前端流式渲染性能与快捷键体验优化 (`clients/web`)**:
171
+ - 流式更新渲染全面统一采用 `requestAnimationFrame` 防抖批量刷新(`refreshStreamingContentDebounced`),消除高频 token 吐出时的页面重排重绘瓶颈。
172
+ - 新增 `Cmd/Ctrl + Shift + O` 快捷新建会话别名,适配主流 AI 交互习惯。
173
+
174
+ ### Changed
175
+ - 全端版本号统一升级至 2.6.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
176
+
177
+ ---
178
+
10
179
  ## [2.5.1] - 2026-09-01
11
180
 
12
181
  ### Added
@@ -556,4 +725,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
556
725
  | **Deprecated** | 即将移除 |
557
726
  | **Removed** | 已移除 |
558
727
  | **Fixed** | Bug 修复 |
559
- | **Security** | 安全相关修复 |
728
+ | **Security** | 安全相关修复 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.5.1",
3
+ "version": "2.11.2",
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",
@@ -40,10 +40,10 @@
40
40
  "node": ">=18"
41
41
  },
42
42
  "dependencies": {
43
- "@liguoshuai/pi-chat-protocol": "2.5.1",
44
- "@liguoshuai/pi-chat-server": "2.5.1",
45
43
  "express": "^4.21.2",
46
- "ws": "^8.18.0"
44
+ "ws": "^8.18.0",
45
+ "@liguoshuai/pi-chat-server": "2.11.2",
46
+ "@liguoshuai/pi-chat-protocol": "2.11.2"
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
@@ -26,6 +26,10 @@ function getAuthToken() {
26
26
  const urlToken = new URLSearchParams(window.location.search).get("token");
27
27
  if (urlToken) {
28
28
  localStorage.setItem("pi_auth_token", urlToken);
29
+ // Strip token from URL to prevent leakage via Referer, screenshots, or shared links
30
+ const cleanUrl = new URL(window.location.href);
31
+ cleanUrl.searchParams.delete("token");
32
+ window.history.replaceState({}, "", cleanUrl.toString());
29
33
  return urlToken;
30
34
  }
31
35
  return localStorage.getItem("pi_auth_token") || "";
@@ -75,6 +79,8 @@ const state = {
75
79
  isBackfilling: false,
76
80
  aborting: false,
77
81
  attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
82
+ turnStartedAt: null,
83
+ streamingMsgDurationEl: null,
78
84
  };
79
85
 
80
86
  let toastTimer = null;
@@ -300,11 +306,14 @@ function renderMarkdown(md) {
300
306
  const parts = [];
301
307
  let rest = md;
302
308
  while (rest.length) {
303
- const fenceIdx = rest.search(/```/);
304
- if (fenceIdx === -1) {
309
+ // Only match ``` at the beginning of a line (or start of string) to avoid
310
+ // misinterpreting inline triple-backticks as code fences.
311
+ const fenceMatch = rest.match(/(?:^|\n)```/);
312
+ if (!fenceMatch) {
305
313
  parts.push({ kind: "md", text: rest });
306
314
  rest = "";
307
315
  } else {
316
+ const fenceIdx = fenceMatch.index + (fenceMatch[0].length - 3);
308
317
  if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
309
318
  rest = rest.slice(fenceIdx + 3);
310
319
  // optional language on this line
@@ -503,7 +512,8 @@ function mdTable(lines) {
503
512
  // ---- DOM helpers ----
504
513
  const $ = (sel, root = document) => root.querySelector(sel);
505
514
  const el = (tag, props = {}, children = []) => {
506
- const n = document.createElement(tag);
515
+ const SVG_TAGS = new Set(["svg","rect","path","circle","line","polyline","polygon","ellipse","g","defs","use","text","tspan","linearGradient","radialGradient","stop","clipPath","mask","pattern","filter","feGaussianBlur","feOffset","feMerge","feMergeNode","animate","animateTransform","animateMotion"]);
516
+ const n = SVG_TAGS.has(tag) ? document.createElementNS("http://www.w3.org/2000/svg", tag) : document.createElement(tag);
507
517
  for (const [k, v] of Object.entries(props)) {
508
518
  if (k === "class") n.className = v;
509
519
  else if (k === "html") n.innerHTML = v;
@@ -547,6 +557,52 @@ function formatMessageTime(ts) {
547
557
  return `${year}-${month}-${date} ${timeStr}`;
548
558
  }
549
559
 
560
+ // ---- Duration Formatter ----
561
+ function formatDuration(ms) {
562
+ if (ms == null || isNaN(ms) || ms < 0) return "";
563
+ if (ms < 1000) {
564
+ return `${(ms / 1000).toFixed(1)}s`;
565
+ }
566
+ if (ms < 60000) {
567
+ return `${(ms / 1000).toFixed(1)}s`;
568
+ }
569
+ const mins = Math.floor(ms / 60000);
570
+ const secs = Math.round((ms % 60000) / 1000);
571
+ return `${mins}m ${secs}s`;
572
+ }
573
+
574
+ let liveTimerInterval = null;
575
+ function startStreamingTimer() {
576
+ if (liveTimerInterval) return;
577
+ liveTimerInterval = setInterval(() => {
578
+ const now = Date.now();
579
+ if (state.turnStartedAt && state.streamingMsgDurationEl) {
580
+ state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
581
+ }
582
+ const liveThinking = document.querySelectorAll(".thinking-duration.live");
583
+ liveThinking.forEach((el) => {
584
+ if (el._startedAt) {
585
+ el.textContent = formatDuration(now - el._startedAt);
586
+ el.style.display = "";
587
+ }
588
+ });
589
+ const liveTools = document.querySelectorAll(".tool-duration.live");
590
+ liveTools.forEach((el) => {
591
+ if (el._startedAt) {
592
+ el.textContent = formatDuration(now - el._startedAt);
593
+ el.style.display = "";
594
+ }
595
+ });
596
+ }, 100);
597
+ }
598
+
599
+ function stopStreamingTimer() {
600
+ if (liveTimerInterval) {
601
+ clearInterval(liveTimerInterval);
602
+ liveTimerInterval = null;
603
+ }
604
+ }
605
+
550
606
  function sameSession(a, b) {
551
607
  if (!a || !b) return false;
552
608
  if (a === b) return true;
@@ -822,6 +878,13 @@ async function loadSession(file) {
822
878
  }
823
879
  }
824
880
 
881
+ function parseEntryTimestamp(ts) {
882
+ if (!ts) return null;
883
+ if (typeof ts === "number") return ts;
884
+ const parsed = new Date(ts).getTime();
885
+ return isNaN(parsed) ? null : parsed;
886
+ }
887
+
825
888
  function reconstructFromEntries(entries) {
826
889
  // Map toolResults by toolCallId so we can attach them to their toolCall in the assistant message
827
890
  const toolResults = new Map();
@@ -829,17 +892,23 @@ function reconstructFromEntries(entries) {
829
892
  if (e.type === "message" && e.message?.role === "toolResult") {
830
893
  const m = e.message;
831
894
  if (m.toolCallId) {
832
- toolResults.set(m.toolCallId, m);
895
+ toolResults.set(m.toolCallId, {
896
+ ...m,
897
+ entryTs: parseEntryTimestamp(m.timestamp || e.timestamp)
898
+ });
833
899
  }
834
900
  }
835
901
  }
836
902
 
837
903
  const out = [];
904
+ let lastUserTs = null;
838
905
  for (const e of entries) {
839
906
  if (e.type !== "message") continue;
840
907
  const m = e.message;
841
908
  if (!m || m.role === "bashExecution") continue;
909
+ const msgTs = parseEntryTimestamp(m.timestamp || e.timestamp);
842
910
  if (m.role === "user") {
911
+ lastUserTs = msgTs;
843
912
  // Extract optional images from user message content array
844
913
  let images = [];
845
914
  if (Array.isArray(m.content)) {
@@ -851,13 +920,28 @@ function reconstructFromEntries(entries) {
851
920
  url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
852
921
  }));
853
922
  }
854
- out.push({ role: "user", text: extractContentText(m.content), images, ts: m.timestamp || e.timestamp });
923
+ out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
855
924
  } else if (m.role === "assistant") {
925
+ let turnDurationMs = null;
926
+ if (lastUserTs && msgTs && msgTs >= lastUserTs) {
927
+ const diff = msgTs - lastUserTs;
928
+ if (diff > 0 && diff < 15 * 60 * 1000) {
929
+ turnDurationMs = diff;
930
+ }
931
+ }
856
932
  const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
857
933
  const content = rawContent.map(part => {
858
934
  if (part && part.type === "toolCall") {
859
935
  const res = toolResults.get(part.id);
860
- return { ...part, result: res || null };
936
+ let durationMs = null;
937
+ if (res) {
938
+ const startTs = parseEntryTimestamp(part.timestamp || m.timestamp || e.timestamp);
939
+ const endTs = res.entryTs || parseEntryTimestamp(res.timestamp);
940
+ if (startTs && endTs && endTs >= startTs) {
941
+ durationMs = endTs - startTs;
942
+ }
943
+ }
944
+ return { ...part, result: res || null, durationMs };
861
945
  }
862
946
  return part;
863
947
  });
@@ -869,7 +953,7 @@ function reconstructFromEntries(entries) {
869
953
  } catch {}
870
954
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
871
955
  }
872
- out.push({ role: "assistant", content, ts: m.timestamp || e.timestamp, usage: m.usage });
956
+ out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
873
957
  }
874
958
  // toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
875
959
  }
@@ -1274,6 +1358,9 @@ function renderAssistantBlock(m) {
1274
1358
  if (timeStr) {
1275
1359
  roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
1276
1360
  }
1361
+ if (m.turnDurationMs != null && m.turnDurationMs > 0) {
1362
+ roleChildren.push(el("span", { class: "msg-duration", text: `耗时 ${formatDuration(m.turnDurationMs)}`, title: "生成总耗时" }));
1363
+ }
1277
1364
  roleChildren.push(copyMsgBtn);
1278
1365
 
1279
1366
  const node = el("div", { class: "msg assistant" }, [
@@ -1288,7 +1375,7 @@ function renderAssistantBlock(m) {
1288
1375
  const div = el("div", { html: renderMarkdown(c.text) });
1289
1376
  content.appendChild(div);
1290
1377
  } else if (c.type === "thinking") {
1291
- content.appendChild(makeThinkingBlock(c.thinking));
1378
+ content.appendChild(makeThinkingBlock(c.thinking, false, m.ts, c.durationMs));
1292
1379
  } else if (c.type === "toolCall") {
1293
1380
  content.appendChild(makeToolBlockFromCall(c));
1294
1381
  }
@@ -1300,8 +1387,24 @@ function renderAssistantBlock(m) {
1300
1387
  return node;
1301
1388
  }
1302
1389
 
1303
- function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
1390
+ function makeThinkingBlock(thinkingText, isActivelyThinking = false, ts = null, durationMs = null, startedAt = null) {
1304
1391
  const block = el("div", { class: "thinking-block" + (isActivelyThinking ? " active" : "") });
1392
+
1393
+ const curDuration = durationMs != null
1394
+ ? durationMs
1395
+ : (isActivelyThinking && startedAt ? Math.max(0, Date.now() - startedAt) : null);
1396
+
1397
+ const durationText = curDuration != null ? (isActivelyThinking ? formatDuration(curDuration) : `用时 ${formatDuration(curDuration)}`) : "";
1398
+
1399
+ const durationEl = el("span", {
1400
+ class: "thinking-duration" + (isActivelyThinking ? " live" : ""),
1401
+ text: durationText,
1402
+ style: durationText ? "" : "display: none;"
1403
+ });
1404
+ if (isActivelyThinking && startedAt) {
1405
+ durationEl._startedAt = startedAt;
1406
+ }
1407
+
1305
1408
  const head = el("div", {
1306
1409
  class: "thinking-head",
1307
1410
  onclick: () => {
@@ -1312,20 +1415,23 @@ function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
1312
1415
  }
1313
1416
  }, [
1314
1417
  el("span", { class: "thinking-title", text: isActivelyThinking ? "💭 正在思考中…" : "💭 思考过程" }),
1418
+ durationEl,
1419
+ ts ? el("span", { class: "thinking-time", text: formatMessageTime(ts) }) : null,
1315
1420
  isActivelyThinking ? el("span", { class: "thinking-pulse" }) : null,
1316
1421
  el("span", { class: "thinking-toggle-hint", text: "(点击展开/收起)" }),
1317
1422
  ]);
1318
-
1423
+
1319
1424
  const body = el("div", { class: "thinking-body", html: escapeHtml(thinkingText) });
1320
-
1425
+
1321
1426
  if (state.thinkingUserToggled) {
1322
1427
  body.style.display = state.thinkingOpen ? "block" : "none";
1323
1428
  } else {
1324
1429
  body.style.display = isActivelyThinking ? "block" : "none";
1325
1430
  }
1326
-
1431
+
1327
1432
  block.appendChild(head);
1328
1433
  block.appendChild(body);
1434
+ block._durationEl = durationEl;
1329
1435
  return block;
1330
1436
  }
1331
1437
 
@@ -1375,7 +1481,7 @@ function updateToolBlockCopyBtn(tc, call) {
1375
1481
  tc.head._cmdToCopy = cmd;
1376
1482
  }
1377
1483
 
1378
- function makeToolBlockFromCall(call) {
1484
+ function makeToolBlockFromCall(call, ts = null) {
1379
1485
  const block = el("div", { class: "tool-block" });
1380
1486
  const hasResult = Boolean(call.result);
1381
1487
  const isError = call.result ? Boolean(call.result.isError) : false;
@@ -1385,8 +1491,24 @@ function makeToolBlockFromCall(call) {
1385
1491
  resultText = extractContentText(call.result.content);
1386
1492
  }
1387
1493
 
1388
- const stateText = hasResult ? (isError ? "错误" : "完成") : "…";
1389
- const stateClass = "state" + (hasResult && isError ? " error" : "");
1494
+ const stateText = hasResult ? (isError ? "错误" : "完成") : "执行中…";
1495
+ const stateClass = "state" + (hasResult && isError ? " error" : "") + (!hasResult ? " running" : "");
1496
+
1497
+ const durationMs = call.durationMs != null
1498
+ ? call.durationMs
1499
+ : (call.startedAt && call.endedAt ? Math.max(0, call.endedAt - call.startedAt) : null);
1500
+ const durationText = durationMs != null
1501
+ ? `耗时 ${formatDuration(durationMs)}`
1502
+ : (!hasResult && call.startedAt ? formatDuration(Date.now() - call.startedAt) : "");
1503
+
1504
+ const durationEl = el("span", {
1505
+ class: "tool-duration" + (!hasResult ? " live" : ""),
1506
+ text: durationText,
1507
+ style: durationText ? "" : "display: none;"
1508
+ });
1509
+ if (!hasResult && call.startedAt) {
1510
+ durationEl._startedAt = call.startedAt;
1511
+ }
1390
1512
 
1391
1513
  const cmdToCopy = getToolCommandToCopy(call);
1392
1514
  const copyBtn = cmdToCopy ? el("button", {
@@ -1414,8 +1536,10 @@ function makeToolBlockFromCall(call) {
1414
1536
  const head = el("div", { class: "tool-head" }, [
1415
1537
  el("span", { class: "ic", text: "⚙" }),
1416
1538
  el("span", { class: "name", text: call.name }),
1539
+ ts ? el("span", { class: "tool-time", text: formatMessageTime(ts) }) : null,
1417
1540
  el("span", { class: "args", text: summaryArgs(call.name, call.arguments) }),
1418
1541
  copyBtn,
1542
+ durationEl,
1419
1543
  el("span", { class: stateClass, text: stateText }),
1420
1544
  ]);
1421
1545
  head._cmdToCopy = cmdToCopy;
@@ -1433,9 +1557,10 @@ function makeToolBlockFromCall(call) {
1433
1557
  block._head = head;
1434
1558
  block._body = body;
1435
1559
  block._callId = call.id;
1560
+ block._durationEl = durationEl;
1436
1561
 
1437
1562
  if (!hasResult && call.id) {
1438
- state.activeToolCalls.set(call.id, { block, body, head });
1563
+ state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
1439
1564
  }
1440
1565
 
1441
1566
  return block;
@@ -1481,6 +1606,8 @@ function getStreamingFullText() {
1481
1606
  function ensureStreamingMsg(ts = Date.now()) {
1482
1607
  if (state.streamingMsg) return state.streamingMsg;
1483
1608
  showEmptyState(false);
1609
+ state.turnStartedAt = ts || Date.now();
1610
+ startStreamingTimer();
1484
1611
  const timeStr = formatMessageTime(ts);
1485
1612
  const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
1486
1613
 
@@ -1500,12 +1627,15 @@ function ensureStreamingMsg(ts = Date.now()) {
1500
1627
  el("span", { text: "复制全文" })
1501
1628
  ]);
1502
1629
 
1630
+ const durationEl = el("span", { class: "msg-duration live", text: "0.0s", title: "生成耗时" });
1631
+
1503
1632
  const roleChildren = [
1504
1633
  el("span", { class: "role-name", text: "pi" }),
1505
1634
  ];
1506
1635
  if (timeStr) {
1507
1636
  roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
1508
1637
  }
1638
+ roleChildren.push(durationEl);
1509
1639
  roleChildren.push(copyBtn);
1510
1640
 
1511
1641
  const node = el("div", { class: "msg assistant" }, [
@@ -1513,6 +1643,7 @@ function ensureStreamingMsg(ts = Date.now()) {
1513
1643
  el("div", { class: "content" }),
1514
1644
  ]);
1515
1645
  state.streamingMsg = node;
1646
+ state.streamingMsgDurationEl = durationEl;
1516
1647
  state.streamingItems = [];
1517
1648
  state.thinkingOpen = true;
1518
1649
  state.thinkingUserToggled = false;
@@ -1554,8 +1685,8 @@ function refreshStreamingContent() {
1554
1685
  const item = state.streamingItems[i];
1555
1686
  const isLast = (i === lastIdx);
1556
1687
  if (item.type === "thinking") {
1557
- const isActivelyThinking = state.streaming && isLast;
1558
- content.appendChild(makeThinkingBlock(item.text, isActivelyThinking));
1688
+ const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
1689
+ content.appendChild(makeThinkingBlock(item.text, isActivelyThinking, item.ts, item.durationMs, item.startedAt));
1559
1690
  } else if (item.type === "tool") {
1560
1691
  if (item.tc?.block) content.appendChild(item.tc.block);
1561
1692
  } else if (item.type === "text") {
@@ -1568,8 +1699,25 @@ function refreshStreamingContent() {
1568
1699
 
1569
1700
  function finalizeStreamingMsg() {
1570
1701
  state.streaming = false;
1702
+ stopStreamingTimer();
1571
1703
  if (state.streamingMsg) {
1704
+ for (const item of state.streamingItems) {
1705
+ if (item.type === "thinking" && item.isStreaming) {
1706
+ item.isStreaming = false;
1707
+ item.endedAt = Date.now();
1708
+ item.durationMs = Math.max(0, item.endedAt - (item.startedAt || item.ts || Date.now()));
1709
+ }
1710
+ }
1572
1711
  refreshStreamingContent();
1712
+
1713
+ if (state.turnStartedAt) {
1714
+ const turnDuration = Math.max(0, Date.now() - state.turnStartedAt);
1715
+ if (state.streamingMsgDurationEl) {
1716
+ state.streamingMsgDurationEl.className = "msg-duration";
1717
+ state.streamingMsgDurationEl.textContent = `耗时 ${formatDuration(turnDuration)}`;
1718
+ }
1719
+ }
1720
+
1573
1721
  const finalFullText = getStreamingFullText();
1574
1722
  const btn = state.streamingMsg.querySelector(".btn-copy-msg");
1575
1723
  if (btn) {
@@ -1581,6 +1729,8 @@ function finalizeStreamingMsg() {
1581
1729
  };
1582
1730
  }
1583
1731
  }
1732
+ state.turnStartedAt = null;
1733
+ state.streamingMsgDurationEl = null;
1584
1734
  state.streamingMsg = null;
1585
1735
  state.streamingItems = [];
1586
1736
  state.thinkingOpen = true;
@@ -1797,8 +1947,9 @@ function connectWs(opts = {}) {
1797
1947
  }
1798
1948
 
1799
1949
  function sendWs(obj) {
1800
- if (!state.ws || state.ws.readyState !== 1) return;
1950
+ if (!state.ws || state.ws.readyState !== 1) return false;
1801
1951
  state.ws.send(JSON.stringify(obj));
1952
+ return true;
1802
1953
  }
1803
1954
 
1804
1955
  function handlePiMessage(obj) {
@@ -1833,6 +1984,10 @@ function handlePiMessage(obj) {
1833
1984
  setComposerAborting(true);
1834
1985
  ensureStreamingMsg();
1835
1986
  refreshStreamingContent();
1987
+ // If ring buffer overflowed while client was away, sync full session history in background
1988
+ if (obj.overflowed && state.currentSessionFile) {
1989
+ syncSessionHistory(state.currentSessionFile, false);
1990
+ }
1836
1991
  } else {
1837
1992
  finalizeStreamingMsg();
1838
1993
  state.streaming = false;
@@ -1972,6 +2127,13 @@ function handlePiMessage(obj) {
1972
2127
  const ev = obj.assistantMessageEvent;
1973
2128
  if (!ev) break;
1974
2129
  if (ev.type === "text_delta") {
2130
+ // Finalize active thinking duration if transitioning to text
2131
+ const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
2132
+ if (activeThinking) {
2133
+ activeThinking.isStreaming = false;
2134
+ activeThinking.endedAt = Date.now();
2135
+ activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2136
+ }
1975
2137
  const last = state.streamingItems[state.streamingItems.length - 1];
1976
2138
  if (last && last.type === "text") {
1977
2139
  last.text += ev.delta;
@@ -1981,6 +2143,12 @@ function handlePiMessage(obj) {
1981
2143
  refreshStreamingContentDebounced();
1982
2144
  } else if (ev.type === "text_end") {
1983
2145
  // Authoritative final text for this content slot.
2146
+ const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
2147
+ if (activeThinking) {
2148
+ activeThinking.isStreaming = false;
2149
+ activeThinking.endedAt = Date.now();
2150
+ activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2151
+ }
1984
2152
  if (typeof ev.content === "string") {
1985
2153
  const last = state.streamingItems[state.streamingItems.length - 1];
1986
2154
  if (last && last.type === "text") {
@@ -1989,30 +2157,49 @@ function handlePiMessage(obj) {
1989
2157
  state.streamingItems.push({ type: "text", text: ev.content });
1990
2158
  }
1991
2159
  }
1992
- refreshStreamingContent();
2160
+ refreshStreamingContentDebounced();
1993
2161
  } else if (ev.type === "thinking_delta" || ev.type === "thinking_start" || ev.type === "thinking_end") {
1994
2162
  // For thinking we accumulate deltas; thinking_delta carries .delta
1995
2163
  if (ev.type === "thinking_delta" && ev.delta) {
1996
- const last = state.streamingItems[state.streamingItems.length - 1];
1997
- if (last && last.type === "thinking") {
2164
+ let last = state.streamingItems[state.streamingItems.length - 1];
2165
+ if (last && last.type === "thinking" && last.isStreaming !== false) {
1998
2166
  last.text += ev.delta;
2167
+ if (!last.startedAt) last.startedAt = Date.now();
1999
2168
  } else {
2000
- state.streamingItems.push({ type: "thinking", text: ev.delta });
2169
+ last = { type: "thinking", text: ev.delta, ts: Date.now(), startedAt: Date.now(), isStreaming: true };
2170
+ state.streamingItems.push(last);
2171
+ }
2172
+ refreshStreamingContentDebounced();
2173
+ } else if (ev.type === "thinking_start") {
2174
+ state.streamingItems.push({ type: "thinking", text: "", ts: Date.now(), startedAt: Date.now(), isStreaming: true });
2175
+ refreshStreamingContentDebounced();
2176
+ } else if (ev.type === "thinking_end") {
2177
+ const last = state.streamingItems[state.streamingItems.length - 1];
2178
+ if (last && last.type === "thinking") {
2179
+ last.isStreaming = false;
2180
+ last.endedAt = Date.now();
2181
+ last.durationMs = Math.max(0, last.endedAt - (last.startedAt || last.ts || Date.now()));
2001
2182
  }
2002
2183
  refreshStreamingContentDebounced();
2003
2184
  }
2004
2185
  } else if (ev.type === "toolcall_start") {
2186
+ const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
2187
+ if (activeThinking) {
2188
+ activeThinking.isStreaming = false;
2189
+ activeThinking.endedAt = Date.now();
2190
+ activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2191
+ }
2005
2192
  ensureStreamingMsg();
2006
2193
  const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
2007
2194
  // args may be incomplete until toolcall_end; we fill what we have now
2008
2195
  // and patch the head display on toolcall_end.
2009
- ensureToolBlock(call.id, call.name, call.arguments);
2196
+ ensureToolBlock(call.id, call.name, call.arguments, Date.now());
2010
2197
  } else if (ev.type === "toolcall_delta") {
2011
2198
  // Streaming function-call argument JSON. We don't render it live
2012
2199
  // (JSON fragments are not useful UX), but make sure the tool block
2013
2200
  // exists so toolcall_end has somewhere to write into.
2014
2201
  const id = obj.toolCallId || ev.id;
2015
- ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args);
2202
+ ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args, Date.now());
2016
2203
  } else if (ev.type === "toolcall_end") {
2017
2204
  // Authoritative final toolCall object (with full arguments). Patch
2018
2205
  // the block head so the displayed args are the final ones, not the
@@ -2029,9 +2216,29 @@ function handlePiMessage(obj) {
2029
2216
  break;
2030
2217
  }
2031
2218
  case "tool_execution_start": {
2219
+ const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
2220
+ if (activeThinking) {
2221
+ activeThinking.isStreaming = false;
2222
+ activeThinking.endedAt = Date.now();
2223
+ activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2224
+ }
2032
2225
  ensureStreamingMsg();
2033
- const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
2034
- if (tc) updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
2226
+ const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
2227
+ if (tc) {
2228
+ tc.startedAt = Date.now();
2229
+ if (tc.durationEl) {
2230
+ tc.durationEl.className = "tool-duration live";
2231
+ tc.durationEl._startedAt = tc.startedAt;
2232
+ tc.durationEl.textContent = "0.0s";
2233
+ tc.durationEl.style.display = "";
2234
+ }
2235
+ const stateEl = tc.head.querySelector(".state");
2236
+ if (stateEl) {
2237
+ stateEl.className = "state running";
2238
+ stateEl.textContent = "执行中…";
2239
+ }
2240
+ updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
2241
+ }
2035
2242
  break;
2036
2243
  }
2037
2244
  case "tool_execution_update": {
@@ -2047,14 +2254,33 @@ function handlePiMessage(obj) {
2047
2254
  if (tc) {
2048
2255
  const text = extractContentText(obj.result?.content);
2049
2256
  tc.body.innerHTML = escapeHtml(text) || "(无输出)";
2050
- tc.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
2051
- tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
2257
+ tc.endedAt = Date.now();
2258
+ const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
2259
+ tc.durationMs = dur;
2260
+ if (tc.durationEl) {
2261
+ tc.durationEl.className = "tool-duration";
2262
+ tc.durationEl._startedAt = null;
2263
+ tc.durationEl.textContent = `耗时 ${formatDuration(dur)}`;
2264
+ tc.durationEl.style.display = "";
2265
+ }
2266
+ const stateEl = tc.head.querySelector(".state");
2267
+ if (stateEl) {
2268
+ stateEl.textContent = obj.isError ? "错误" : "完成";
2269
+ stateEl.className = "state" + (obj.isError ? " error" : "");
2270
+ }
2052
2271
  }
2053
2272
  break;
2054
2273
  }
2055
2274
  case "extension_ui_request":
2056
2275
  handleExtensionUiRequest(obj);
2057
2276
  break;
2277
+ case "error":
2278
+ finalizeStreamingMsg();
2279
+ state.streaming = false;
2280
+ state.aborting = false;
2281
+ setComposerAborting(false);
2282
+ showToast(`⚠️ ${obj.message || obj.code || "发生错误"}`);
2283
+ break;
2058
2284
  case "pi_exit":
2059
2285
  finalizeStreamingMsg();
2060
2286
  state.streaming = false;
@@ -2112,10 +2338,10 @@ function handleExtensionUiRequest(req) {
2112
2338
  }
2113
2339
  }
2114
2340
 
2115
- function ensureToolBlock(toolCallId, name, args) {
2341
+ function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
2116
2342
  if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
2117
- const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
2118
- const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head };
2343
+ const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args, startedAt: ts }, ts);
2344
+ const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts };
2119
2345
  state.activeToolCalls.set(toolCallId, entry);
2120
2346
  state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
2121
2347
  refreshStreamingContentDebounced();
@@ -2716,7 +2942,9 @@ function updateComposerUI() {
2716
2942
  }
2717
2943
 
2718
2944
  function setComposerAborting(yes) {
2719
- if (!yes) {
2945
+ if (yes) {
2946
+ state.aborting = true;
2947
+ } else {
2720
2948
  state.aborting = false;
2721
2949
  const inner = $("#composerInner");
2722
2950
  if (inner) inner.classList.remove("aborting");
@@ -2741,7 +2969,12 @@ function abortGeneration() {
2741
2969
  updateComposerUI();
2742
2970
  const hint = $(".composer-hint");
2743
2971
  if (hint) hint.textContent = "中止当前任务中…";
2744
- sendWs({ type: "abort" });
2972
+ if (!sendWs({ type: "abort" })) {
2973
+ state.aborting = false;
2974
+ updateComposerUI();
2975
+ if (hint) hint.textContent = "中止失败:WebSocket 连接已断开。正在尝试重连…";
2976
+ scheduleReconnect(0);
2977
+ }
2745
2978
  }
2746
2979
 
2747
2980
  function submitSteer() {
@@ -2756,6 +2989,12 @@ function submitSteer() {
2756
2989
  return;
2757
2990
  }
2758
2991
 
2992
+ if (!sendWs({ type: "steer", message: text })) {
2993
+ if (hint) hint.textContent = "发送失败:WebSocket 连接已断开。正在尝试重连…";
2994
+ scheduleReconnect(0);
2995
+ return;
2996
+ }
2997
+
2759
2998
  appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
2760
2999
  ta.value = "";
2761
3000
  autoResize();
@@ -2765,8 +3004,6 @@ function submitSteer() {
2765
3004
  setTimeout(() => {
2766
3005
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
2767
3006
  }, 4000);
2768
-
2769
- sendWs({ type: "steer", message: text });
2770
3007
  }
2771
3008
 
2772
3009
  function submitPrompt() {
@@ -2843,7 +3080,19 @@ function submitPrompt() {
2843
3080
  }
2844
3081
  }
2845
3082
  }
2846
- sendWs({ type: "prompt", message: text, images: imagesToSend });
3083
+ if (!sendWs({ type: "prompt", message: text, images: imagesToSend })) {
3084
+ state.streaming = false;
3085
+ state.aborting = false;
3086
+ setComposerAborting(false);
3087
+ stopStreamingTimer();
3088
+ state.turnStartedAt = null;
3089
+ state.streamingMsgDurationEl = null;
3090
+ if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
3091
+ state.streamingItems = [];
3092
+ const hint = $(".composer-hint");
3093
+ if (hint) hint.textContent = "发送失败:WebSocket 连接已断开。正在尝试重连…";
3094
+ scheduleReconnect(0);
3095
+ }
2847
3096
  }
2848
3097
 
2849
3098
  function autoResize() {
@@ -2996,7 +3245,7 @@ async function init() {
2996
3245
  const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
2997
3246
  const isCmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
2998
3247
 
2999
- if (isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === "n") {
3248
+ if (isCmdOrCtrl && e.shiftKey && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "o")) {
3000
3249
  e.preventDefault();
3001
3250
  startNewSession(true);
3002
3251
  return;
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
2
+ <defs>
3
+ <linearGradient id="piGrad" x1="0%" y1="0%" x2="100%" y2="100%">
4
+ <stop offset="0%" stop-color="#3b82f6"/>
5
+ <stop offset="100%" stop-color="#8b5cf6"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="512" height="512" rx="128" fill="#18181b"/>
9
+ <rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="url(#piGrad)" stroke-width="8" opacity="0.4"/>
10
+ <text x="256" y="340" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, sans-serif" font-size="280" font-weight="bold" fill="url(#piGrad)" text-anchor="middle">π</text>
11
+ </svg>
package/public/index.html CHANGED
@@ -3,9 +3,14 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="theme-color" content="#18181b">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
6
9
  <title>pi · Chat</title>
10
+ <link rel="manifest" href="/manifest.webmanifest">
11
+ <link rel="apple-touch-icon" href="/icon.svg">
7
12
  <link rel="stylesheet" href="/style.css">
8
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ctext y='20' font-size='20'%3Eπ%3C/text%3E%3C/svg%3E">
13
+ <link rel="icon" type="image/svg+xml" href="/icon.svg">
9
14
  </head>
10
15
  <body data-cwd="">
11
16
  <div class="app sidebar-open">
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "pi-chat",
3
+ "short_name": "pi-chat",
4
+ "description": "ChatGPT/Gemini style web UI for the pi coding agent",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#18181b",
8
+ "theme_color": "#18181b",
9
+ "icons": [
10
+ {
11
+ "src": "/icon.svg",
12
+ "sizes": "any",
13
+ "type": "image/svg+xml",
14
+ "purpose": "any maskable"
15
+ }
16
+ ]
17
+ }
package/public/style.css CHANGED
@@ -578,6 +578,74 @@ body {
578
578
  letter-spacing: 0;
579
579
  user-select: none;
580
580
  }
581
+ .msg-duration {
582
+ font-size: 11px;
583
+ font-weight: 400;
584
+ color: var(--text-muted);
585
+ background: rgba(255, 255, 255, 0.05);
586
+ border: 1px solid rgba(255, 255, 255, 0.08);
587
+ padding: 1px 7px;
588
+ border-radius: 999px;
589
+ user-select: none;
590
+ }
591
+ .msg-duration.live {
592
+ color: var(--accent);
593
+ background: rgba(16, 163, 127, 0.12);
594
+ border-color: rgba(16, 163, 127, 0.25);
595
+ animation: pulse-live-tag 1.5s infinite ease-in-out;
596
+ }
597
+ .thinking-time {
598
+ font-size: 11px;
599
+ color: var(--text-muted);
600
+ }
601
+ .thinking-duration {
602
+ font-size: 11px;
603
+ font-weight: 500;
604
+ color: #c4b5fd;
605
+ background: rgba(167, 139, 250, 0.12);
606
+ border: 1px solid rgba(167, 139, 250, 0.25);
607
+ padding: 1px 8px;
608
+ border-radius: 999px;
609
+ display: inline-flex;
610
+ align-items: center;
611
+ gap: 3px;
612
+ user-select: none;
613
+ }
614
+ .thinking-duration.live {
615
+ color: var(--accent);
616
+ background: rgba(16, 163, 127, 0.12);
617
+ border-color: rgba(16, 163, 127, 0.3);
618
+ animation: pulse-live-tag 1.5s infinite ease-in-out;
619
+ }
620
+ .tool-time {
621
+ font-size: 11px;
622
+ color: var(--text-muted);
623
+ }
624
+ .tool-duration {
625
+ font-size: 11px;
626
+ font-weight: 400;
627
+ color: var(--text-dim);
628
+ background: #252525;
629
+ border: 1px solid var(--border);
630
+ padding: 1px 7px;
631
+ border-radius: 999px;
632
+ display: inline-flex;
633
+ align-items: center;
634
+ gap: 3px;
635
+ user-select: none;
636
+ }
637
+ .tool-duration.live {
638
+ color: #fbbf24;
639
+ background: rgba(234, 179, 8, 0.12);
640
+ border-color: rgba(234, 179, 8, 0.25);
641
+ animation: pulse-live-tag 1.5s infinite ease-in-out;
642
+ }
643
+
644
+ @keyframes pulse-live-tag {
645
+ 0%, 100% { opacity: 0.85; }
646
+ 50% { opacity: 1; }
647
+ }
648
+
581
649
  .msg.assistant .content {
582
650
  font-size: 15px; line-height: 1.65; color: var(--text);
583
651
  max-width: 100%; min-width: 0;