@liguoshuai/pi-web-chat 2.9.0 → 2.12.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,168 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [2.12.2] - 2026-09-04
11
+
12
+ ### Fixed
13
+ - **Web 端发送消息后输入框锁死并误显“正在中止…” (`clients/web/public/app.js`)**:`submitPrompt` 发送成功后调用 `setComposerAborting(true)`(现已更名为 `setComposerStreaming`),其内部误将 `state.aborting` 置为 `true`,导致 `updateComposerUI` 进入“中止中”分支:输入框占位符显示“正在中止当前任务…”,发送按钮变成 `⏳` 并置为 `disabled`,UI 锁死直至任务结束。同时 `agent_start` / `backfill_end` / `updateState` 三处进入流式状态的路径也存在同样的误用。现将语义统一为 `setComposerStreaming`:进入流式状态时始终将 `state.aborting` 清理为 `false`,仅刷新 `updateComposerUI` 与 `renderModelPill`;而“中止”仅由用户点击中止时通过 `abortGeneration` 单独置位,彻底解决发送消息后对话框锁死、误显“中止…”的问题。
14
+
15
+ ### Changed
16
+ - 全端版本号统一递增至 2.12.2(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
17
+
18
+ ## [2.12.1] - 2026-09-04
19
+
20
+ ### Improved
21
+ - **耗时时长格式化进位与边界修复 (`packages/protocol`, `clients/web/public/app.js`)**:
22
+ - 重构 `formatDuration` 算法,避免毫秒数在接近整分边界(例如 `59950ms`、`119950ms`)时因浮点数取模四舍五入导致输出 `"0m 60s"` 或 `"1m 60s"` 异常格式,统一进位并补齐单元测试。
23
+ - **会话列表排序时间戳健壮性增强 (`server/src/routes.js`)**:
24
+ - 在 `/api/sessions` 排序中加入严格的 `Number.isFinite` 与 `Date.parse` 解析校验,防止无效或非标准日期字符串导致 `NaN` 比较破坏 JavaScript 引擎排序稳定性。
25
+ - **流式事件回放与请求 ID 边界保护 (`server/src/agent.js`)**:
26
+ - 优化 WebSocket / SSE 缓冲区回放遍历逻辑,精准依据缓冲区环形溢出状态重放事件,过滤潜在空槽位;
27
+ - 改进 `PiAgent.send` 在子进程异常终止时对于数字 `0` 作为请求 ID 的正确保留与响应返回。
28
+ - **Android 客户端协程生命周期防御 (`clients/android/.../ChatRepository.kt`)**:
29
+ - 仓库层使用 `SupervisorJob()` 配合 `Dispatchers.IO`,避免子协程失败导致整个仓库层作用域被级联取消。
30
+
31
+ ### Changed
32
+ - 全端版本号统一递增至 2.12.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
33
+
34
+ ## [2.12.0] - 2026-09-04
35
+
36
+ ### Added
37
+ - **Android 构建产物自定义重命名与 Release 加固配置 (`clients/android/app/build.gradle.kts`, `proguard-rules.pro`)**:
38
+ - 构建产物根据版本号和构建类型重命名为语义化规范格式:`pi-chat-v{versionName}-debug.apk` 与 `pi-chat-v{versionName}-release.apk`。
39
+ - 启用 Release 版本的混淆加固与代码资源缩减(`isMinifyEnabled = true`, `isShrinkResources = true`),配置完整的 ProGuard / R8 保护规则(数据模型、Kotlinx Serialization、OkHttp、Compose 与协程)。
40
+ - 配置默认签名回退支持,使 Release APK 可开箱即用构建。
41
+ - **Android 会话删除功能 (`clients/android/.../ApiService.kt`, `ChatRepository.kt`, `ChatViewModel.kt`, `ChatScreen.kt`)**:
42
+ - 新增 `DELETE /api/session?file=...` 网络请求与仓库层方法,并在侧边栏每个会话项右侧提供删除按钮及二次确认弹窗。
43
+
44
+ ### Improved
45
+ - **Android 侧边栏抽屉界面优化与对齐 Web 体验 (`clients/android/.../ChatScreen.kt`)**:
46
+ - 抽屉宽度适配调整为 `280dp`,改善手机端视觉比例;
47
+ - 会话项展示信息全面对齐 Web:新增格式化时间戳(如 `9月4日 15:30`)、消息条数展示(`· N 条`)与流式任务运行中微动徽标(`运行中`);
48
+ - 选中会话样式重构:高亮背景(`BgHover`)+ 左侧强调色指示条(`Accent`),会话行间距与内边距紧凑精致化;
49
+ - 搜索框高度重构为紧凑式 `34dp`,包含搜索图标与一键清空按钮;
50
+ - 侧边栏底部补齐 `pi.dev` 与 `pi-web-chat` 外部链接跳转,统一展示版本信息。
51
+ - **Android 顶栏排版与信息层级重构 (`clients/android/.../ChatScreen.kt`)**:
52
+ - 顶部导航栏高度规整至 `56dp`,居中展示当前对话主标题,并在下方紧凑排列工作目录(CWD)、模型名称(含 `★ 默认` 徽标)和思考推理等级胶囊;
53
+ - 左右两侧合理布局抽屉按钮、分享导出按钮及设置按钮,排版清晰美观不拥挤,彻底解决信息遮挡与丢失问题。
54
+ - **Android 底部输入框体验与样式重构 (`clients/android/.../ChatScreen.kt`)**:
55
+ - 输入框整体采用 Web 端 ChatGPT 风格圆角胶囊卡片(`RoundedCornerShape(22.dp)`),内边距与高度严格贴合文本行高;
56
+ - 采用 `BasicTextField` 配合优雅的占位符提示,支持多行平滑自适应扩展;
57
+ - 发送按钮、指导指令按钮及中止按钮尺寸规整(32dp 圆形/胶囊),并对齐底部安全区。
58
+
59
+ ### Changed
60
+ - 全端版本号统一递增至 2.12.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
61
+
62
+ ## [2.11.3] - 2026-09-03
63
+
64
+ ### Fixed
65
+ - **HarmonyOS 端配置持久化与初始加载缺失 (`clients/harmony/...`)**:`PreferencesHelper` 虽然实现了服务端 URL 的存储与获取,但未在 App 启动/初始化流程中实际调用。现已在 `Index.ets` 的 `aboutToAppear()` 中调用 `PreferencesHelper.getServerUrl()` 动态读取已保存的 URL 进行初始化,并在“保存并重连”时调用 `PreferencesHelper.setServerUrl()` 写入持久化存储,解决应用重启后配置丢失问题。
66
+ - **HarmonyOS 端 UI 界面版本号硬编码滞后 (`clients/harmony/.../Index.ets`)**:侧边栏抽屉底部硬编码展示旧版本号 `v2.10.1`,现已更新为当前全仓最新的 `v2.11.3`,与 Monorepo 版本数严格对齐。
67
+
68
+ ### Changed
69
+ - 全端版本号统一递增至 2.11.3(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
70
+
71
+ ## [2.11.2] - 2026-09-03
72
+
73
+ ### Fixed
74
+ - **Web 端 `submitSteer` 发送失败静默丢弃 (`clients/web/public/app.js`)**:`sendWs` 返回值未检查,WebSocket 在检查与发送之间断开时,指导指令被静默丢弃但 UI 已显示"已插入指导指令"。现先检查 `sendWs` 返回值,失败时提示用户并触发重连,不再乐观更新 UI。
75
+ - **Web 端 `abortGeneration` 发送失败导致 UI 锁死 (`clients/web/public/app.js`)**:`sendWs({ type: "abort" })` 返回值未检查,发送失败时 `state.aborting` 永久为 `true`,UI 锁定在中止状态。现检查返回值,失败时回退 `state.aborting` 并提示用户。
76
+ - **Web 端 `submitPrompt` 失败回退遗漏定时器清理 (`clients/web/public/app.js`)**:发送失败回退时未调用 `stopStreamingTimer()` 且未重置 `turnStartedAt` / `streamingMsgDurationEl`,导致 100ms 定时器持续向已分离 DOM 节点写入。现已补充清理。
77
+ - **Android `sendSteer` / `abort` / `switchSession` 发送返回值未检查 (`clients/android/.../ChatRepository.kt`)**:`sendRaw` 返回的 `Boolean` 被忽略。`abort` 无条件设置 `_isStreaming = false` 即使中止未送达服务端;`switchSession` 在发送前清空消息列表,发送失败时导致状态不同步。现均检查返回值并在失败时回退。
78
+ - **HarmonyOS `abort` / `newSession` / `switchSession` 发送返回值未检查 (`clients/harmony/.../ChatViewModel.ets`)**:与 Android 同类问题。`abort` 无条件重置 `isStreaming`;`newSession` / `switchSession` 在发送前清空消息,发送失败时导致状态不同步。现均检查 `wsManager.send()` 返回值并在失败时回退。
79
+ - **HarmonyOS `remote_user_prompt` 消息重复 (`clients/harmony/.../ChatViewModel.ets`)**:本地已发送的用户消息与服务端 `remote_user_prompt` 回声重复添加。现增加去重检查,若倒数第二条用户消息内容相同则跳过。
80
+ - **HarmonyOS `init()` 未重置流式状态 (`clients/harmony/.../ChatViewModel.ets`)**:切换服务器 URL 重新初始化时,残留的 `isStreaming = true` 导致孤立流式气泡。现 `init()` 中重置 `isStreaming = false`。
81
+ - **Android 编译失败修复 (`clients/android/.../ChatScreen.kt`)**:补充 `SmartToy` 与 `Psychology` 图标的 `import` 声明,修复替顶部胶囊换用 Material 图标后因缺失导入导致的 `Unresolved reference`,`assembleDebug` 可正常产出 APK。
82
+ - **服务端单元测试 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` 二进制。
83
+
84
+ ### Changed
85
+ - 全端版本号统一递增至 2.11.2(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
86
+
87
+ ---
88
+
89
+ ## [2.11.1] - 2026-09-03
90
+
91
+ ### Fixed
92
+ - **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 可正常构建。
93
+
94
+ ### Changed
95
+ - 全端版本号统一递增至 2.11.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
96
+
97
+ ---
98
+
99
+ ## [2.11.0] - 2026-09-03
100
+
101
+ ### Changed & Beautified (Android)
102
+ - **应用图标焕新 (`clients/android`)**:弃用系统默认图标,新增基于 π 品牌图形与翡翠绿渐变的 **自适应启动图标 (Adaptive Icon)**,包含背景渐变、白色圆角 π 字形与点缀色,支持普通与圆形图标形态。
103
+ - **顶部胶囊图标美化 (TopBar Pills)**:将模型选择 (🤖) 与思考级别 (🧠) 的简单 Emoji 替换为 **彩色圆形徽章 + Material 图标**(`SmartToy` 机器人 / `Psychology` 大脑),并同步美化工作目录胶囊图标,视觉更加精致统一。
104
+ - **对话框气泡美化 (Message Bubbles)**:
105
+ - 用户消息气泡升级为 **翡翠绿渐变背景 + 护色描边**,圆角更柔和、内边距更舒适。
106
+ - 助手头像由扁平半透明圆形升级为 **彩色渐变圆形徽章**(白色 π),辨识度与质感更佳。
107
+ - **样例提示词间距优化 (Suggestion Chips)**:解决提示词芯片过于紧凑的问题,引入图标 + 标签的卡片式芯片,并增大水平与垂直间距、内边距,视觉更舒展。
108
+
109
+ ### Changed
110
+ - 全端版本号统一递增至 2.11.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
111
+
112
+ ---
113
+
114
+ ## [2.10.1] - 2026-09-03
115
+
116
+ ### Fixed
117
+ - **HarmonyOS 监听器泄漏修复 (`clients/harmony/.../ChatViewModel.ets`)**:`init()` 每次调用都新增 WebSocket 状态与消息监听器但不移除旧的,导致重复调用时监听器堆积、消息被重复处理。现已保存监听器引用并在重新初始化时先移除旧监听器。
118
+ - **HarmonyOS WebSocket 重连竞争修复 (`clients/harmony/.../WebSocketManager.ets`)**:`connect()` 替换已有连接时,旧 socket 的异步 `on('close')` 回调仍会触发 `scheduleReconnect()`,导致连接抖动。现已通过捕获 ws 实例引用,在陈旧回调中跳过重连逻辑。
119
+ - **Android WebSocket 连接泄漏修复 (`clients/android/.../WebSocketClient.kt`)**:`connect()` 未关闭已有 WebSocket 连接,在重连场景下旧连接资源泄漏。现已在 `connect()` 开头主动关闭并清理旧连接。
120
+ - **Android 协程作用域泄漏修复 (`clients/android/.../ChatRepository.kt` / `ChatViewModel.kt`)**:`reconnect()` 创建新 `ChatRepository` 但未取消旧实例的协程作用域,导致协程泄漏。现已新增 `close()` 方法并在重连时调用。
121
+ - **协议层冗余代码清理 (`packages/protocol/src/index.js`)**:`formatDuration` 函数前两个条件分支完全相同,移除冗余的 `ms < 1000` 分支。
122
+ - **HarmonyOS 冗余代码清理 (`clients/harmony/.../Index.ets`)**:同步移除 `formatDuration` 中相同的冗余分支。
123
+ - **Web 端 Token 泄漏防护 (`clients/web/public/app.js`)**:从 URL 提取 auth token 后立即使用 `history.replaceState` 清除 URL 中的 token 参数,防止通过 Referer 头、截图或分享链接泄漏。
124
+ - **Web 端代码围栏匹配修复 (`clients/web/public/app.js`)**:`renderMarkdown` 中 ```` 围栏匹配改为仅在行首匹配,避免将行内三反引号误判为代码围栏。
125
+ - **Web 端 SVG 元素命名空间修复 (`clients/web/public/app.js`)**:DOM 辅助函数 `el()` 对 SVG 标签使用 `createElementNS` 创建,修复 SVG 图标渲染问题。
126
+ - **Web 端发送失败处理增强 (`clients/web/public/app.js`)**:`sendWs` 返回布尔值,`submitPrompt` 在 WebSocket 断开时回退流式状态并提示用户,`setComposerAborting(true)` 正确设置 `state.aborting`。
127
+ - **Android API 基址规范化 (`clients/android/.../ApiService.kt`)**:构造函数中自动去除 `baseUrl` 尾部斜杠,避免拼接出双斜杠 URL。
128
+
129
+ ### Changed
130
+ - 全端版本号统一递增至 2.10.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
131
+ ---
132
+
133
+ ## [2.10.0] - 2026-09-02
134
+
135
+ ### Added & Aligned
136
+ - **Android 原生客户端与移动 Web 端全要素对齐 (`clients/android`)**:
137
+ - **顶部栏元素完整对齐 (TopBar Alignment)**:
138
+ - 新增 **工作目录胶囊按钮 (CWD Pill)**:展示当前工作目录,点击弹出工作目录切换对话框(包含路径输入与 `~`, `~/.pi`, `/tmp` 快捷芯片)。
139
+ - 新增 **模型选择胶囊按钮 (Model Pill)**:展示当前模型名称与 `★ 默认` 徽章,点击弹出支持搜索、厂商归类、能力标签(🧠 推理、👁️ 视觉、🛠️ 工具)的模型选择对话框。
140
+ - 新增 **深度思考级别胶囊按钮 (Thinking Pill)**:展示当前思考深度,点击弹出 `Off`、`Minimal`、`Low`、`Medium`、`High`、`Max` 等推理级别选择对话框。
141
+ - 新增 **会话导出按钮 (Export Chat)**:一键将完整对话导出为标准 Markdown 格式并唤起系统分享。
142
+ - **设置入口单点统一**:移除侧边抽屉顶部多余的重复设置按钮,保留顶部栏统一单一设置入口,避免操作歧义。
143
+ - **空白引导页全要素对齐 (Empty State Alignment)**:
144
+ - 引入 `π` 经典头像与提示文案。
145
+ - 引入 **当前模型信息横幅 (Empty Model Banner)**:展示当前模型名称、默认徽章、🧠 思考 / 👁️ 视觉 / 🛠️ 工具能力标签及“切换模型”快捷按钮。
146
+ - 引入 **快捷指令芯片 (Suggestion Chips)**:提供“列出当前目录文件”、“总结这个项目”、“代码审查”等一键触发指令。
147
+ - **底部输入区域与富文本展示对齐 (Composer & Message Bubbles)**:
148
+ - 增加待发送附件预览栏(带缩略图与删除 `✕` 按钮)。
149
+ - 增加图片全屏查看画廊模式 (Image Lightbox Modal)。
150
+ - 助手回复内容 Markdown 解析优化,代码块增加语言标签与“复制代码”独立按钮。
151
+ - 增加底部操作提示文案:“pi 会执行命令与读写你的文件 —— 请注意操作内容。”
152
+
153
+ ### Changed
154
+ - 全端版本号统一递增至 2.10.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
155
+
156
+ ---
157
+
158
+ ## [2.9.1] - 2026-09-02
159
+
160
+ ### Fixed & Optimized
161
+ - **网关路由健壮性增强 (`server/src/routes.js`)**:修复 `/api/sessions` 历史会话列表中若存在数值格式时间戳导致 `localeCompare` 抛错的问题,优化为安全兼容数字与 ISO 字符串的多格式时间戳倒序排列。
162
+ - **Web 前端异常流式保护 (`clients/web/public/app.js`)**:在 WebSocket 消息处理中补充对 `error` 与 `pi_exit` 事件的完备响应,遇到异常时自动终结流式状态、解除输入框锁定并提示用户。
163
+ - **Android 原生端异常退出流式重置 (`clients/android`)**:`ChatRepository.kt` 补充对 `error` 和 `pi_exit` 事件的处理,避免在子进程异常退出时状态滞留在流式中。
164
+ - **HarmonyOS 原生端异常流式重置 (`clients/harmony`)**:`ChatViewModel.ets` 补充对 `error` 和 `pi_exit` 事件的处理,确保鸿蒙界面在异常中断时能正常完成并释放输入。
165
+ - **跨端通信协议 Schema 完备性更新 (`packages/protocol/src/schema.json`)**:补充 `remote_user_steer` 与 `extension_ui_request` 到服务端消息类型枚举定义。
166
+
167
+ ### Changed
168
+ - 全端版本号统一递增至 2.9.1(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
169
+
170
+ ---
171
+
10
172
  ## [2.9.0] - 2026-09-02
11
173
 
12
174
  ### Added
@@ -624,4 +786,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
624
786
  | **Deprecated** | 即将移除 |
625
787
  | **Removed** | 已移除 |
626
788
  | **Fixed** | Bug 修复 |
627
- | **Security** | 安全相关修复 |
789
+ | **Security** | 安全相关修复 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.9.0",
3
+ "version": "2.12.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",
@@ -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.9.0",
46
- "@liguoshuai/pi-chat-server": "2.9.0"
45
+ "@liguoshuai/pi-chat-server": "2.12.2",
46
+ "@liguoshuai/pi-chat-protocol": "2.12.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") || "";
@@ -302,11 +306,14 @@ function renderMarkdown(md) {
302
306
  const parts = [];
303
307
  let rest = md;
304
308
  while (rest.length) {
305
- const fenceIdx = rest.search(/```/);
306
- 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) {
307
313
  parts.push({ kind: "md", text: rest });
308
314
  rest = "";
309
315
  } else {
316
+ const fenceIdx = fenceMatch.index + (fenceMatch[0].length - 3);
310
317
  if (fenceIdx > 0) parts.push({ kind: "md", text: rest.slice(0, fenceIdx) });
311
318
  rest = rest.slice(fenceIdx + 3);
312
319
  // optional language on this line
@@ -505,7 +512,8 @@ function mdTable(lines) {
505
512
  // ---- DOM helpers ----
506
513
  const $ = (sel, root = document) => root.querySelector(sel);
507
514
  const el = (tag, props = {}, children = []) => {
508
- 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);
509
517
  for (const [k, v] of Object.entries(props)) {
510
518
  if (k === "class") n.className = v;
511
519
  else if (k === "html") n.innerHTML = v;
@@ -552,14 +560,12 @@ function formatMessageTime(ts) {
552
560
  // ---- Duration Formatter ----
553
561
  function formatDuration(ms) {
554
562
  if (ms == null || isNaN(ms) || ms < 0) return "";
555
- if (ms < 1000) {
563
+ const totalSecs = Math.round(ms / 1000);
564
+ if (totalSecs < 60) {
556
565
  return `${(ms / 1000).toFixed(1)}s`;
557
566
  }
558
- if (ms < 60000) {
559
- return `${(ms / 1000).toFixed(1)}s`;
560
- }
561
- const mins = Math.floor(ms / 60000);
562
- const secs = Math.round((ms % 60000) / 1000);
567
+ const mins = Math.floor(totalSecs / 60);
568
+ const secs = totalSecs % 60;
563
569
  return `${mins}m ${secs}s`;
564
570
  }
565
571
 
@@ -733,7 +739,7 @@ function startNewSession() {
733
739
  state.streamingItems = [];
734
740
  state.streamingMsg = null;
735
741
  state.activeToolCalls.clear();
736
- setComposerAborting(false);
742
+ setComposerStreaming(false);
737
743
  try {
738
744
  window.history.replaceState({}, "", window.location.pathname);
739
745
  } catch {}
@@ -853,7 +859,7 @@ async function loadSession(file) {
853
859
  state.streamingItems = [];
854
860
  state.streamingMsg = null;
855
861
  state.activeToolCalls.clear();
856
- setComposerAborting(false);
862
+ setComposerStreaming(false);
857
863
  try {
858
864
  const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
859
865
  window.history.replaceState({ session: file }, "", newUrl);
@@ -1939,8 +1945,9 @@ function connectWs(opts = {}) {
1939
1945
  }
1940
1946
 
1941
1947
  function sendWs(obj) {
1942
- if (!state.ws || state.ws.readyState !== 1) return;
1948
+ if (!state.ws || state.ws.readyState !== 1) return false;
1943
1949
  state.ws.send(JSON.stringify(obj));
1950
+ return true;
1944
1951
  }
1945
1952
 
1946
1953
  function handlePiMessage(obj) {
@@ -1972,7 +1979,7 @@ function handlePiMessage(obj) {
1972
1979
  // After replay, sync the composer / streaming state to what the server thinks.
1973
1980
  if (obj.streaming) {
1974
1981
  state.streaming = true;
1975
- setComposerAborting(true);
1982
+ setComposerStreaming(true);
1976
1983
  ensureStreamingMsg();
1977
1984
  refreshStreamingContent();
1978
1985
  // If ring buffer overflowed while client was away, sync full session history in background
@@ -1983,7 +1990,7 @@ function handlePiMessage(obj) {
1983
1990
  finalizeStreamingMsg();
1984
1991
  state.streaming = false;
1985
1992
  state.aborting = false;
1986
- setComposerAborting(false);
1993
+ setComposerStreaming(false);
1987
1994
  if (state.currentSessionFile) {
1988
1995
  syncSessionHistory(state.currentSessionFile, true);
1989
1996
  }
@@ -2031,7 +2038,7 @@ function handlePiMessage(obj) {
2031
2038
  }
2032
2039
  } else if (obj.command === "abort") {
2033
2040
  state.aborting = false;
2034
- setComposerAborting(false);
2041
+ setComposerStreaming(false);
2035
2042
  }
2036
2043
  else if (obj.command === "switch_session" && obj.success) {
2037
2044
  // ask pi for current state so we can get session id, name
@@ -2067,7 +2074,7 @@ function handlePiMessage(obj) {
2067
2074
  case "agent_start":
2068
2075
  state.streaming = true;
2069
2076
  state.aborting = false;
2070
- setComposerAborting(true);
2077
+ setComposerStreaming(true);
2071
2078
  ensureStreamingMsg();
2072
2079
  refreshStreamingContent();
2073
2080
  refreshSessions();
@@ -2079,7 +2086,7 @@ function handlePiMessage(obj) {
2079
2086
  finalizeStreamingMsg();
2080
2087
  state.streaming = false;
2081
2088
  state.aborting = false;
2082
- setComposerAborting(false);
2089
+ setComposerStreaming(false);
2083
2090
  sendWs({ type: "get_state" });
2084
2091
  refreshSessions(); // titles may have changed
2085
2092
  break;
@@ -2265,11 +2272,18 @@ function handlePiMessage(obj) {
2265
2272
  case "extension_ui_request":
2266
2273
  handleExtensionUiRequest(obj);
2267
2274
  break;
2275
+ case "error":
2276
+ finalizeStreamingMsg();
2277
+ state.streaming = false;
2278
+ state.aborting = false;
2279
+ setComposerStreaming(false);
2280
+ showToast(`⚠️ ${obj.message || obj.code || "发生错误"}`);
2281
+ break;
2268
2282
  case "pi_exit":
2269
2283
  finalizeStreamingMsg();
2270
2284
  state.streaming = false;
2271
2285
  state.aborting = false;
2272
- setComposerAborting(false);
2286
+ setComposerStreaming(false);
2273
2287
  $("#connDot").style.color = "var(--danger)";
2274
2288
  break;
2275
2289
  default:
@@ -2358,14 +2372,14 @@ function updateState(d) {
2358
2372
  if (d.isStreaming) {
2359
2373
  if (!state.streaming) {
2360
2374
  state.streaming = true;
2361
- setComposerAborting(true);
2375
+ setComposerStreaming(true);
2362
2376
  ensureStreamingMsg();
2363
2377
  }
2364
2378
  } else if (state.streaming && !state.isBackfilling) {
2365
2379
  finalizeStreamingMsg();
2366
2380
  state.streaming = false;
2367
2381
  state.aborting = false;
2368
- setComposerAborting(false);
2382
+ setComposerStreaming(false);
2369
2383
  refreshSessions();
2370
2384
  }
2371
2385
  }
@@ -2925,9 +2939,12 @@ function updateComposerUI() {
2925
2939
  }
2926
2940
  }
2927
2941
 
2928
- function setComposerAborting(yes) {
2942
+ function setComposerStreaming(yes) {
2943
+ // Entering/exiting the streaming state must never turn on the "aborting"
2944
+ // flag — otherwise the composer would lock and show "正在中止…" while the
2945
+ // model is actually just running. Abort is a separate transient state.
2946
+ state.aborting = false;
2929
2947
  if (!yes) {
2930
- state.aborting = false;
2931
2948
  const inner = $("#composerInner");
2932
2949
  if (inner) inner.classList.remove("aborting");
2933
2950
  const hint = $(".composer-hint");
@@ -2951,7 +2968,12 @@ function abortGeneration() {
2951
2968
  updateComposerUI();
2952
2969
  const hint = $(".composer-hint");
2953
2970
  if (hint) hint.textContent = "中止当前任务中…";
2954
- sendWs({ type: "abort" });
2971
+ if (!sendWs({ type: "abort" })) {
2972
+ state.aborting = false;
2973
+ updateComposerUI();
2974
+ if (hint) hint.textContent = "中止失败:WebSocket 连接已断开。正在尝试重连…";
2975
+ scheduleReconnect(0);
2976
+ }
2955
2977
  }
2956
2978
 
2957
2979
  function submitSteer() {
@@ -2966,6 +2988,12 @@ function submitSteer() {
2966
2988
  return;
2967
2989
  }
2968
2990
 
2991
+ if (!sendWs({ type: "steer", message: text })) {
2992
+ if (hint) hint.textContent = "发送失败:WebSocket 连接已断开。正在尝试重连…";
2993
+ scheduleReconnect(0);
2994
+ return;
2995
+ }
2996
+
2969
2997
  appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
2970
2998
  ta.value = "";
2971
2999
  autoResize();
@@ -2975,8 +3003,6 @@ function submitSteer() {
2975
3003
  setTimeout(() => {
2976
3004
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。";
2977
3005
  }, 4000);
2978
-
2979
- sendWs({ type: "steer", message: text });
2980
3006
  }
2981
3007
 
2982
3008
  function submitPrompt() {
@@ -3023,7 +3049,7 @@ function submitPrompt() {
3023
3049
 
3024
3050
  state.streaming = true;
3025
3051
  state.aborting = false;
3026
- setComposerAborting(true);
3052
+ setComposerStreaming(true);
3027
3053
  ensureStreamingMsg(now);
3028
3054
  refreshStreamingContent();
3029
3055
 
@@ -3053,7 +3079,19 @@ function submitPrompt() {
3053
3079
  }
3054
3080
  }
3055
3081
  }
3056
- sendWs({ type: "prompt", message: text, images: imagesToSend });
3082
+ if (!sendWs({ type: "prompt", message: text, images: imagesToSend })) {
3083
+ state.streaming = false;
3084
+ state.aborting = false;
3085
+ setComposerStreaming(false);
3086
+ stopStreamingTimer();
3087
+ state.turnStartedAt = null;
3088
+ state.streamingMsgDurationEl = null;
3089
+ if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
3090
+ state.streamingItems = [];
3091
+ const hint = $(".composer-hint");
3092
+ if (hint) hint.textContent = "发送失败:WebSocket 连接已断开。正在尝试重连…";
3093
+ scheduleReconnect(0);
3094
+ }
3057
3095
  }
3058
3096
 
3059
3097
  function autoResize() {