@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 +170 -1
- package/package.json +4 -4
- package/public/app.js +287 -38
- package/public/icon.svg +11 -0
- package/public/index.html +6 -1
- package/public/manifest.webmanifest +17 -0
- package/public/style.css +68 -0
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.
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
2051
|
-
tc.
|
|
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 (
|
|
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;
|
package/public/icon.svg
ADDED
|
@@ -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"
|
|
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;
|