diff --git a/AGENTS.md b/AGENTS.md index 45ed82a9..8f17c27b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Use `uv` for the root project: For the console app, run `cd apps/undefined-console && npm ci && npm run check`. Use `npm run dev` for the Vite shell and `npm run tauri:dev` for the desktop shell. -For the native chat client, run `cd apps/undefined-chat && npm ci && npm run check` (Biome + TypeScript + Vitest unit/e2e + `cargo fmt --check`/`cargo check`/`cargo test`). Use `npm run tauri:dev` for the desktop shell. +For the native chat client, run `cd apps/undefined-chat && npm ci && npm run check` (Biome + TypeScript + Vitest unit/e2e + Node production-build regression + `cargo fmt --check`/`cargo check`/`cargo test`). Use `npm run tauri:dev` for the desktop shell. ## Coding Style & Naming Conventions Use 4-space indentation. Python code must be fully type-annotated and pass strict mypy checks. Disk I/O should go through `src/Undefined/utils/io.py` so writes stay async-safe and atomic. Follow `snake_case` for modules and functions, `PascalCase` for classes, and prefer extending existing services/helpers over introducing one-off abstractions. Skills handlers must not import repo-local modules outside `skills/` (only `Undefined.skills.*` and same-directory relative imports are allowed); pass dependencies through the execution context, share helpers via `src/Undefined/skills/shared.py`, and keep `tests/test_skills_import_boundary.py`'s ratchet baseline shrinking. WebUI JavaScript in `src/Undefined/webui/static/js/` and both native apps use Biome 2.5.10; keep the two package pins, lockfiles, and three configuration schemas in sync. Nested app configurations set `root: false` for checks launched from the repository root and use `extends: []` (explicit no-inherit) plus their own `formatter.indentStyle = "tab"`, so the root WebUI rules never leak into app checks. App changes must satisfy Biome, TypeScript, and Cargo checks. @@ -63,7 +63,7 @@ Both main prompt variants and `res/IMPORTANT/each.md` guide replies to add usefu `group_analysis.member_activity` distinguishes recency (`member_list`), retrieved window message counts (`history`), and weighted indicators (`hybrid`, default). History-mode timestamps must come from messages inside the selected window. History aggregation uses `try_parse_message_time` and skips invalid timestamps before counting or ranking; `parse_message_time` retains its current-time fallback for existing display callers. Missing/zero `last_sent_time` means unknown, not proof of inactivity or never speaking; last-message time does not indicate current online status. See [the toolset documentation](src/Undefined/skills/toolsets/group_analysis/README.md) for reporting limits. ## Testing Guidelines -Write tests as `tests/test_.py`. Async tests use `pytest-asyncio`. Add or update coverage for behavior changes in APIs, config loading/hot reload, cognitive memory, meme or knowledge flows, and WebUI/runtime routes. If you touch `apps/undefined-console/` or `src/Undefined/webui/static/js/`, run `npm run check` in `apps/undefined-console/` in addition to the Python checks; if you touch `apps/undefined-chat/`, run `npm run check` in `apps/undefined-chat/` (it bundles Vitest unit/e2e suites, so cover changed behavior there). No fixed coverage threshold is configured, so cover touched paths well. +Write tests as `tests/test_.py`. Async tests use `pytest-asyncio`. Add or update coverage for behavior changes in APIs, config loading/hot reload, cognitive memory, meme or knowledge flows, and WebUI/runtime routes. If you touch `apps/undefined-console/` or `src/Undefined/webui/static/js/`, run `npm run check` in `apps/undefined-console/` in addition to the Python checks; if you touch `apps/undefined-chat/`, run `npm run check` in `apps/undefined-chat/` (it bundles Vitest unit/e2e suites, so cover changed behavior there). CI runs `pytest --cov --cov-report=term` with the 65% minimum configured in `pyproject.toml`; cover touched paths well in addition to satisfying this overall gate. ## Commit & Pull Request Guidelines Recent history follows Conventional Commits with optional scopes, for example `fix(webui): refine launcher return flow` and `feat(commands): add /version (/v) slash command`. Keep commit subjects imperative and concise. Keep `code/NagaAgent/` syncs separate from local feature work when possible. If you are bumping release versions, prefer `uv run python scripts/bump_version.py ` so `pyproject.toml`, `src/Undefined/__init__.py`, and the `package.json` + Tauri config of both `apps/undefined-console/` and `apps/undefined-chat/` stay in sync. For pull requests, include a short impact summary, linked issues, and the commands you ran; attach screenshots for WebUI or Tauri UI changes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a095b02..0cd3a19d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -886,6 +886,8 @@ description: 从 PDF 文件中提取文本和表格,填写表单。当用户 微信接入由主进程独立管理,不依赖 OneBot 是否在线。`WeixinService` 先校验帐号与 peer,再把已绑定来源映射为逻辑 QQ 私聊交给 `MessageHandler`;未知来源在写历史或调用 AI 之前进入隔离存储。`DeliveryAddress` 将逻辑身份与物理通道分离:`qq:` 和 `wechat:` 可共享同一个用户历史、认知记忆与权限,但发送器、消息合并 scope 和 transport 元数据保持通道隔离。自动化同样持久化规范 `address`,旧 `target_type + target_id` 在加载时转换为 `group:` 或 `qq:` 地址。 +OneBot 消息处理任务由客户端跨连接跟踪。网络断线只结束接收循环、通知未完成的 API 请求连接失败,不等待媒体下载等处理任务即可重连;已有处理任务继续运行。主动断开、停止或取消连接任务时,会取消并等待这些处理任务收尾,连接建立或重连等待阶段收到取消也会执行同样的清理,避免后台任务遗留。 + ### "车站-列车" 队列模型 针对高并发消息处理,Undefined 实现了全新的 **ModelQueue** 调度机制: diff --git a/CHANGELOG.md b/CHANGELOG.md index defab789..680aa6da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## v3.18.5 会话恢复、消息投递与并发持久化修复 + +本版本集中修复聊天续接、消息发送和后台任务在切换会话、取消、断线、重启及写入失败时的状态错乱:保留已经完成的投递与提交,避免重复回复、旧响应覆盖新操作,以及界面显示成功但数据尚未保存的问题。 + +- 聊天恢复与历史一致性:WebChat 只有在任务收尾、历史落盘并释放会话占用后才确认完成,创建任务失败会撤销占用,删除会话与接收新任务按顺序处理。完整任务快照保留正文、工具调用与嵌套 Agent 时间线,Chat 客户端修复断线续接、旧版事件重放和分页恢复;离线期间已经完成的会话也会刷新,旧连接、旧会话和旧请求的迟到结果不会覆盖当前状态。 +- WebUI 与客户端交互:修复切换会话后发送归属错误、旧轮询重复追加正文、迟到历史清空刚发送的消息,以及发送失败覆盖后续草稿的问题。保存连接配置时暂停请求,将未完成上传标记失败并忽略迟到结果,旧缓存迁移失败保留原数据;日志、工作流、表情包编辑和微信登录弹窗隔离各自请求,刷新与翻页保留未保存的编辑。恢复配置失败会保留表单并显示错误,不再误报恢复成功;登录凭据刷新串行执行,避免并发刷新相互失效。 +- 消息投递不再被后续故障误判:微信按每次服务端确认及时记录已发送状态,支持展开嵌套转发;历史、附件登记或使用统计失败不会把已送达内容当作未发送。分片或批量发送已有部分成功、或投递结果无法确认时,微信、抖音、JM 等业务降级不再重发整条内容;仍保留对明确未投递内容的降级。令牌失效、会话暂停、配置错误及明确的连接前失败保留原异常,不再误标为可能送达;认证问题不触发拆分消息的结构降级。微信帐号变更与客户端启停串行,OneBot 重连不再等待消息处理任务阻塞;arXiv 去重按实际平台与投递地址隔离。 +- 配置保存与热更新:整体保存、增量修改、恢复、模板同步和密码更新共用原子写入顺序,同一时刻的备份不会互相覆盖;取消请求会等已开始的写入与必要更新完成。热更新失败可以重试,监听器重启会补读停止窗口内的修改,连续调整轮询间隔不会留下多个监听任务或继续使用旧间隔。内存构建配置不再意外读取本地文件,损坏的 TOML 即使使用 `strict=False` 也会明确报错。 +- 停止与取消:停止 Bot 先关闭新任务入口,等待已接收的回复、后台任务和持久化收尾,再关闭模型队列与消息通道。新增 `[webui].stop_timeout_seconds`(环境变量 `WEBUI_STOP_TIMEOUT_SECONDS`),**默认 `0` 表示等待自然结束,正整数才会在对应秒数后强制终止**;停止按钮、关闭 WebUI 和更新前停止共用此规则。取消模型请求会同步清理排队及重试任务,普通群聊队列裁剪保留已启动调用所依赖的子请求。新增 `[core].group_llm_queue_max_pending`(默认 `128`,支持热更新),每模型普通群聊 LLM 等待队列满时明确拒绝新请求并清理其等待状态,保留已接收任务与重试;这是排队准入限制,不限制在途调用或总内存,过载时普通自动回复可能结束而不发送消息。 +- 连续消息与工具状态:已经回复完成的投机批次及时结束,后续消息开启新批次,避免把旧输入再次回复;批次取消只作用于对应任务,不会误伤独立的 @ 或拍一拍回复。`task_progress` 在多轮工具调用间共享,技能热重载保留在途统计;多模态分析缓存使用完整媒体来源、类型与补充指令,避免长链接或 data URL 前缀相同而串用结果。 +- 记忆与知识库:长期记忆和会话数据在落盘成功后才发布新状态,取消会等待已开始的提交,写入失败不会让内存与磁盘各自保留不同版本。知识文件替换先完成新内容读取、嵌入和写入,再清理旧索引;读取、嵌入或新块写入失败时保留旧索引供重试。侧写昵称刷新与史官跳过合并时也会补齐检索索引,索引失败不再提前确认完成;记忆时间筛选统一比较含时区与不含时区的时间。 +- 表情包生命周期:重分析不会覆盖期间的人工编辑、恢复已删除内容或影响同 UID 重新入库的记录;停用、删除与附件缓存失效保持一致,搜索不会返回已停用条目。使用统计失败保留发送成功结果,容量清理跳过刚置顶或修改的候选,删除失败保留可重试记录;异常退出遗留的处理任务会在后续轮询中恢复,正在执行的任务不会被重复调度。 +- 自动化流程:持久化按顺序完成,取消不会让旧快照覆盖新状态;嵌套并行循环只合并本轮修改的变量,避免覆盖同级节点刚产生的输出。`loop.times.count=0` 正确跳过循环,旧存档的 `count:null` 兼容默认次数;新建、更新与启用任务仍严格拒绝负数、布尔值、字符串及 `null`。 +- 文件与附件:统一解析本地 `file://` URI,保留中文、空格及原始路径中的百分号;CQ 参数转义与解码保留链接里的逗号、方括号和实体文本。HTTP 压缩响应按传输字节校验完整性、按解压后大小执行容量限制。JM、Bilibili、arXiv 下载及 Python 执行目录在取消后等待后台文件操作结束再清理;JM 配置的会话目录生效,畸形输入和单章异常不再拖垮整次处理。侧写路径与归档解压目标限制在各自目录内,缓存清理拒绝符号链接目录,TAR 解压启用安全过滤。 +- 原生显示与连接:附件下载以独占方式创建文件,避免覆盖同名文件;图片仍显示时保留其 Blob,代码高亮失败回落纯文本,生产构建拆分体积并保留完整语言支持。Console 迁移旧连接时保留协议与 IPv6 地址,Android 生命周期监听即使延迟注册也会在退出后释放。恢复应用时保留刷新途中刚获确认的新任务,同一任务不会重复建立在途 SSE 订阅;迟到旧句柄会关闭,避免覆盖当前订阅或在取消后泄漏。 +- 部署与维护:部署向导中断不再继续写配置,备份名称不冲突,IPv6 服务地址正确加括号;容器模式为 Python 解释器映射宿主 Docker 可见的共享目录,LxMusic 配置变化会触发容器重建。`restore_profile.py restore` 现在同时恢复侧写并同步检索正文与向量,失败后可用 `sync` 补齐,**执行前须停止机器人及其他写入进程**。重嵌入的 `--dry-run` 在临时副本上读取,不修改源向量库,但仍会调用嵌入 API;侧写恢复的 `--dry-run` 不写盘、不调用模型。版本脚本支持失败后重跑,补齐全部版本文件与锁文件,并将此前已修改的版本目标纳入提交。 + +--- + ## v3.18.4 添加宣传片链接 在 README.md 中添加了宣传片链接。 diff --git a/apps/undefined-chat/README.md b/apps/undefined-chat/README.md index 21a152c6..3dc21abd 100644 --- a/apps/undefined-chat/README.md +++ b/apps/undefined-chat/README.md @@ -7,15 +7,19 @@ Undefined Chat 是 `apps/undefined-chat/` 下的 Tauri v2 + React 19 原生聊 ## 当前能力 - 会话管理:创建、删除、重命名、切换会话。当前没有置顶字段/API。 -- 消息历史:初始加载、cursor-based 加载更早消息、滚动锚点保持和长历史窗口化渲染。 -- 消息渲染:Markdown、表格、任务列表、引用块、代码高亮、代码块复制;正文 HTML 经 sanitize 内联渲染,HTML 代码块可在独立预览窗口中运行。 +- 消息历史:初始加载、cursor-based 加载更早消息、滚动锚点保持和长历史窗口化渲染。同一会话的重载与分页共用请求顺序保护;发送新消息、完成任务刷新或删除会话后,迟到的旧响应不会覆盖正文、游标或加载状态。 +- 消息渲染:Markdown、表格、任务列表、引用块、代码高亮、代码块复制;未知语言或高亮失败时保留为纯文本代码,代码中的 HTML 不会插入正文。正文 HTML 经 sanitize 内联渲染,HTML 代码块可在独立预览窗口中运行。 - 附件:系统文件选择器、上传状态队列、Tauri 流式上传、下载和预览。当前显示上传中/成功/失败状态,不显示百分比进度。 -- 图片:正文 `` 内联图、附件缩略图、blob 缓存、全屏查看、缩放、旋转和可滚动查看区域。 +- 保存连接配置会把尚未完成的附件上传转为失败状态,恢复连接后可移除并重新选择文件;旧上传结果不能恢复已移除条目。同一 Runtime 已上传成功的附件保留,切换 Runtime 清理旧附件。 +- 图片:正文 `` 内联图、附件缩略图、blob 缓存、全屏查看、缩放、旋转和可滚动查看区域。LRU 目标缓存 60 张;挂载中的图片和已打开的大图持有 blob 引用,超过目标时只淘汰无人使用的项,关闭或卸载消费者后再释放多余缓存。 - 命令:斜杠命令、子命令提示、方向键导航、Tab/Enter 补全;空态反馈(加载中/无匹配/不可用);窗口聚焦按 TTL 刷新命令列表。 - 引用:引用 bot 消息、划词引用选中文本、发送引用、引用芯片跳转当前已加载源消息;源消息不在当前页时会自动加载一页更早历史,仍未命中需手动继续加载更早历史。 - 工具调用块:层级展示、状态、运行中实时计时、阶段明细(stage detail)、与 WebUI 一致的输入/输出预览(JSON/Python 风格结构化、Markdown 输出、附件图片预览);历史回放支持 timeline/calls/events 多级回退。 - 自动滚动:智能跟随底部,可在设置面板开关,偏好持久化到 localStorage。 -- 事件:SSE 优先,断开时 JSON fallback(指数退避重连,活跃任务持续重试)。 +- 重命名只更新目标会话标题,保留当前会话、输入草稿与移动端面板;请求期间切换会话也不会被迟到响应切回。 +- 事件:SSE 优先,断开时 JSON fallback(指数退避重连,活跃任务持续重试);完整任务快照恢复消息与已结束调用,并按快照游标续接,旧 Runtime 的正文前缀也会保留。 +- 恢复连接时,已从 active 列表消失的本地任务会补刷所属会话的权威历史,包括非当前会话;仍在历史落盘的终态任务继续保留发送锁,已删除会话不再请求。刷新期间刚获发送确认的新任务不会被较早取得的空列表删除,也会继续订阅事件。 +- SSE 订阅按任务去重在途启动;恢复或结束任务后,迟到的旧订阅会被关闭,旧启动失败不会覆盖当前订阅或启动额外轮询。 - 快捷键:Ctrl/Cmd+N 新会话,Ctrl/Cmd+K 聚焦输入并打开命令模式,Ctrl/Cmd+/ 切换侧栏,Ctrl/Cmd+, 打开设置,Escape 关闭当前弹层/抽屉。 - i18n:中英双语运行时切换,提供语言切换 UI,并按系统语言自动检测默认语言。 @@ -30,7 +34,7 @@ Undefined Chat 是 `apps/undefined-chat/` 下的 Tauri v2 + React 19 原生聊 Android: - 文件选择器返回的 `content://` URI 通过 Tauri fs plugin 打开,上传链路仍需要真机/模拟器 smoke 覆盖。 -- 生命周期监听使用 Tauri `tauri://suspended` / `tauri://resumed` 事件;恢复前台时重新 bootstrap 并恢复 Runtime 订阅。逐 job 主动补齐仍依赖 store 的 SSE 断线 fallback 行为。 +- 生命周期监听使用 Tauri `tauri://suspended` / `tauri://resumed` 事件;恢复前台时重新 bootstrap,接受 Runtime 的完整任务时间线与对应游标后恢复订阅;快照内已有事件不会重复追加。卸载或重新注册时会清理尚在异步注册中的监听,已排队的旧恢复回调不会再发起请求。 - Android 通过生成工程注入的 `SecretPlugin` 使用 Android Keystore + AES-GCM 保存 API Key:密钥在 AndroidKeyStore 中生成且不可导出,加密后的密文存放在 `MODE_PRIVATE` 的 SharedPreferences 中。Android 生成插件已接入并有脚本/Rust 测试覆盖,真实设备仍需 smoke 验证;只有平台安全存储不可用且用户显式确认时,才允许不安全文件降级。 iOS: @@ -45,7 +49,9 @@ npm run check npm run tauri:dev ``` -`npm run check` 当前包含 Biome、TypeScript、unit/jsdom integration tests、cargo fmt、cargo check 和 cargo test。 +`npm run check` 当前包含 Biome、TypeScript、unit/jsdom integration tests、独立 Node production build 回归、cargo fmt、cargo check 和 cargo test。 + +`npm run build` 将应用、React、其余依赖与代码高亮模块分别打包。highlight.js 的全部语言定义按文件名区间拆成四组,仍同步注册并保留自动检测;分包控制单个 chunk 的体积并细化缓存粒度,全部语言支持保持一致。总下载体积基本不变,独立 gzip 压缩会因减少字典复用而增加压缩体积。`npm run test:build` 在临时目录构建,检查默认 500 kB 限制、chunk 导入图,并执行生产高亮模块,核对完整语言列表、各语言高亮和自动检测结果。 Biome 固定为 **2.5.10**,与 Console 和根目录 WebUI 配置保持一致;使用 `npm ci` 安装锁定版本。App 的 `biome.json` 使用独立嵌套配置(`root: false`),不继承 WebUI 格式规则,并显式启用 React 的 `noArrayIndexKey` 检查,避免依赖自动检测造成漏检;升级约定见 [构建文档](../../docs/build.md#6-git-hook-集成)。 diff --git a/apps/undefined-chat/biome.json b/apps/undefined-chat/biome.json index 6fa9115c..27a2485c 100644 --- a/apps/undefined-chat/biome.json +++ b/apps/undefined-chat/biome.json @@ -9,6 +9,7 @@ "includes": [ "**/src/**/*.ts", "**/src/**/*.tsx", + "**/tests/**/*.mjs", "**/package.json", "**/tsconfig.json", "**/vite.config.ts", diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json index af51a7c5..bf195ae2 100644 --- a/apps/undefined-chat/package-lock.json +++ b/apps/undefined-chat/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-chat", - "version": "3.18.4", + "version": "3.18.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-chat", - "version": "3.18.4", + "version": "3.18.5", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json index b5c63158..0b33cc87 100644 --- a/apps/undefined-chat/package.json +++ b/apps/undefined-chat/package.json @@ -1,17 +1,18 @@ { "name": "undefined-chat", "private": true, - "version": "3.18.4", + "version": "3.18.5", "type": "module", "scripts": { "tauri": "tauri", "dev": "vite --host 0.0.0.0 --port 1430", - "lint": "biome check src package.json tsconfig.json vite.config.ts src-tauri/tauri.conf.json src-tauri/capabilities/default.json", + "lint": "biome check src tests/build-chunks.node.mjs package.json tsconfig.json vite.config.ts src-tauri/tauri.conf.json src-tauri/capabilities/default.json", "typecheck": "tsc --noEmit", "test": "vitest run", "test:unit": "vitest run", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:all": "npm run test:unit && npm run test:e2e", + "test:build": "node --test tests/build-chunks.node.mjs", + "test:all": "npm run test:unit && npm run test:e2e && npm run test:build", "test:watch": "vitest watch", "test:e2e:watch": "vitest watch --config vitest.e2e.config.ts", "tauri:fmt:check": "cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock index 6f93c624..0ab76f08 100644 --- a/apps/undefined-chat/src-tauri/Cargo.lock +++ b/apps/undefined-chat/src-tauri/Cargo.lock @@ -5431,7 +5431,7 @@ dependencies = [ [[package]] name = "undefined_chat" -version = "3.18.4" +version = "3.18.5" dependencies = [ "futures-util", "keyring", diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml index 0869b1c3..70b73278 100644 --- a/apps/undefined-chat/src-tauri/Cargo.toml +++ b/apps/undefined-chat/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_chat" -version = "3.18.4" +version = "3.18.5" description = "Undefined native chat client" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-chat/src-tauri/src/download.rs b/apps/undefined-chat/src-tauri/src/download.rs index f8611267..57d66478 100644 --- a/apps/undefined-chat/src-tauri/src/download.rs +++ b/apps/undefined-chat/src-tauri/src/download.rs @@ -10,6 +10,7 @@ use tauri_plugin_http::reqwest::Response; use tokio::io::AsyncWriteExt; const MAX_PREVIEW_BYTES: usize = 10 * 1024 * 1024; +pub(crate) const MAX_DOWNLOAD_NAME_CANDIDATES: usize = 1000; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -153,12 +154,11 @@ pub async fn save_attachment( tokio::fs::create_dir_all(&download_dir) .await .map_err(|err| format!("download directory create failed: {err}"))?; - let mut path = download_dir.join(safe_file_name( + let path = download_dir.join(safe_file_name( input.file_name.as_deref(), &input.attachment_id, )); - path = unique_path(path).await; - let mut file = tokio::fs::File::create(&path) + let (path, mut file) = create_unique_file(path) .await .map_err(|err| format!("attachment output create failed: {err}"))?; let mut bytes_written = 0u64; @@ -224,11 +224,9 @@ pub async fn preview_attachment_bytes( }) } -async fn unique_path(path: PathBuf) -> PathBuf { - if tokio::fs::metadata(&path).await.is_err() { - return path; - } - +pub(crate) async fn create_unique_file( + path: PathBuf, +) -> std::io::Result<(PathBuf, tokio::fs::File)> { let parent = path .parent() .map(PathBuf::from) @@ -238,18 +236,27 @@ async fn unique_path(path: PathBuf) -> PathBuf { .and_then(|value| value.to_str()) .unwrap_or("attachment"); let extension = path.extension().and_then(|value| value.to_str()); - for index in 1..1000 { - let file_name = if let Some(extension) = extension { - format!("{stem} ({index}).{extension}") + for index in 0..MAX_DOWNLOAD_NAME_CANDIDATES { + let candidate = if index == 0 { + path.clone() } else { - format!("{stem} ({index})") + let file_name = if let Some(extension) = extension { + format!("{stem} ({index}).{extension}") + } else { + format!("{stem} ({index})") + }; + parent.join(file_name) }; - let candidate = parent.join(file_name); - if tokio::fs::metadata(&candidate).await.is_err() { - return candidate; + match tokio::fs::File::create_new(&candidate).await { + Ok(file) => return Ok((candidate, file)), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), } } - path + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "no unused attachment download file name is available", + )) } #[cfg(test)] diff --git a/apps/undefined-chat/src-tauri/src/native_tests.rs b/apps/undefined-chat/src-tauri/src/native_tests.rs index 9c81f7f4..0039b9a8 100644 --- a/apps/undefined-chat/src-tauri/src/native_tests.rs +++ b/apps/undefined-chat/src-tauri/src/native_tests.rs @@ -9,7 +9,7 @@ use crate::preview::{ }; use crate::runtime_client::{ build_runtime_url, job_events_url, parse_sse_chunks, runtime_health_from_body_result, - RuntimeRequestInput, StartJobEventStreamInput, Utf8ChunkDecoder, + ConversationHistoryInput, RuntimeRequestInput, StartJobEventStreamInput, Utf8ChunkDecoder, }; use crate::secret::{ api_key_status_from_storage, classify_secret_storage, derive_stronghold_key, @@ -20,13 +20,35 @@ use crate::state::{ AppRuntimeConfig, RuntimeConfigInput, RuntimeRequestPath, APP_CONFIG_FILE_NAME, }; use crate::{ - download::test_safe_file_name, + download::{create_unique_file, test_safe_file_name, MAX_DOWNLOAD_NAME_CANDIDATES}, upload::{ attachment_file_name, attachments_url, parse_file_path, requires_regular_file_check, upload_uses_streaming_body, UploadAttachmentInput, }, }; +#[test] +fn history_input_accepts_runtime_numeric_cursors() { + for before in [0, 70] { + let input: ConversationHistoryInput = serde_json::from_value(serde_json::json!({ + "conversationId": "default", + "limit": 50, + "before": before, + })) + .unwrap(); + assert_eq!(input.conversation_id, "default"); + assert_eq!(input.limit, Some(50)); + assert_eq!(input.before, Some(before)); + } + for input in [ + serde_json::json!({"conversationId": "default"}), + serde_json::json!({"conversationId": "default", "before": null}), + ] { + let input: ConversationHistoryInput = serde_json::from_value(input).unwrap(); + assert_eq!(input.before, None); + } +} + #[test] fn normalize_runtime_url_removes_trailing_slashes() { let value = normalize_runtime_url("http://127.0.0.1:8788///").unwrap(); @@ -276,6 +298,59 @@ fn attachment_save_file_name_sanitizes_path_like_input() { ); } +#[test] +fn concurrent_attachment_saves_preserve_existing_and_each_other() { + let root = std::env::temp_dir().join(format!("undefined-download-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("report.txt"); + std::fs::write(&path, b"existing").unwrap(); + tauri::async_runtime::block_on(async { + use tokio::io::AsyncWriteExt; + let (first, second) = futures_util::future::join( + create_unique_file(path.clone()), + create_unique_file(path.clone()), + ) + .await; + let (first_path, mut first_file) = first.unwrap(); + let (second_path, mut second_file) = second.unwrap(); + assert_ne!(first_path, second_path); + assert_ne!(first_path, path); + assert_ne!(second_path, path); + first_file.write_all(b"first attachment").await.unwrap(); + second_file.write_all(b"second attachment").await.unwrap(); + first_file.flush().await.unwrap(); + second_file.flush().await.unwrap(); + assert_eq!(tokio::fs::read(&path).await.unwrap(), b"existing"); + assert_eq!( + tokio::fs::read(first_path).await.unwrap(), + b"first attachment" + ); + assert_eq!( + tokio::fs::read(second_path).await.unwrap(), + b"second attachment" + ); + }); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn attachment_save_fails_when_all_file_names_are_occupied() { + let root = std::env::temp_dir().join(format!("undefined-download-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("report.txt"); + std::fs::write(&path, b"existing").unwrap(); + for index in 1..MAX_DOWNLOAD_NAME_CANDIDATES { + std::fs::write(root.join(format!("report ({index}).txt")), b"existing").unwrap(); + } + let error = tauri::async_runtime::block_on(create_unique_file(path.clone())).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert!(error + .to_string() + .contains("no unused attachment download file name")); + assert_eq!(std::fs::read(path).unwrap(), b"existing"); + std::fs::remove_dir_all(root).unwrap(); +} + #[test] fn upload_file_path_parser_keeps_android_content_uri_as_url() { let path = parse_file_path("content://media/external/images/media/42").unwrap(); diff --git a/apps/undefined-chat/src-tauri/src/runtime_client.rs b/apps/undefined-chat/src-tauri/src/runtime_client.rs index 920efcdb..5f129464 100644 --- a/apps/undefined-chat/src-tauri/src/runtime_client.rs +++ b/apps/undefined-chat/src-tauri/src/runtime_client.rs @@ -92,7 +92,7 @@ pub struct ConversationHistoryInput { #[serde(default)] pub limit: Option, #[serde(default)] - pub before: Option, + pub before: Option, } #[derive(Debug, Clone, Deserialize)] @@ -495,8 +495,7 @@ pub async fn get_history( path.push_str(&format!("&limit={limit}")); } if let Some(before) = input.before { - path.push_str("&before="); - path.push_str(&urlencoding::encode(&before)); + path.push_str(&format!("&before={before}")); } request_json_command( &app, diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json index e2e06711..230466d6 100644 --- a/apps/undefined-chat/src-tauri/tauri.conf.json +++ b/apps/undefined-chat/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Chat", - "version": "3.18.4", + "version": "3.18.5", "identifier": "com.undefined.chat", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-chat/src/App.test.tsx b/apps/undefined-chat/src/App.test.tsx index d0b519a9..069f88ab 100644 --- a/apps/undefined-chat/src/App.test.tsx +++ b/apps/undefined-chat/src/App.test.tsx @@ -1,5 +1,6 @@ import { open } from "@tauri-apps/plugin-dialog"; import { + act, fireEvent, render, screen, @@ -12,6 +13,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { App } from "./App"; import { LanguageProvider, LOCALE_STORAGE_KEY } from "./i18n"; import { createTauriRuntimeClient } from "./runtime-client/tauri"; +import type { + Attachment, + AttachmentPreviewResult, + RuntimeConfig, +} from "./runtime-client/types"; import { conversation, historyItem, runtimeClientStub } from "./test-fixtures"; vi.mock("./runtime-client/tauri", () => ({ @@ -30,6 +36,14 @@ function renderApp(ui: ReactElement = ) { return render({ui}); } +async function connectRuntime(runtimeUrl: string): Promise { + fireEvent.keyDown(window, { key: ",", ctrlKey: true }); + await userEvent.clear(screen.getByLabelText("Runtime URL")); + await userEvent.type(screen.getByLabelText("Runtime URL"), runtimeUrl); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await screen.findByText(runtimeUrl); +} + describe("App", () => { beforeEach(() => { vi.resetAllMocks(); @@ -152,12 +166,14 @@ describe("App", () => { test("saves initial Runtime config and API key before reconnecting", async () => { const client = runtimeClientStub({ getRuntimeConfig: vi - .fn() + .fn( + async (): Promise => ({ + runtimeUrl: "http://127.0.0.1:8788", + hasApiKey: true, + }), + ) .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - runtimeUrl: "http://127.0.0.1:8788", - hasApiKey: true, - }), + .mockResolvedValueOnce(null), }); vi.mocked(createTauriRuntimeClient).mockReturnValue(client); @@ -182,12 +198,14 @@ describe("App", () => { test("requires an explicit opt-in before confirming insecure API key storage fallback", async () => { const client = runtimeClientStub({ getRuntimeConfig: vi - .fn() + .fn( + async (): Promise => ({ + runtimeUrl: "http://127.0.0.1:8788", + hasApiKey: true, + }), + ) .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ - runtimeUrl: "http://127.0.0.1:8788", - hasApiKey: true, - }), + .mockResolvedValueOnce(null), }); vi.mocked(createTauriRuntimeClient).mockReturnValue(client); @@ -219,6 +237,137 @@ describe("App", () => { expect(await screen.findByRole("button", { name: /新会话/ })).toBeTruthy(); }); + test("restores the previous Native target and blocks sending after key storage fails", async () => { + let nativeConfig: RuntimeConfig = { + runtimeUrl: "http://runtime-a.test", + hasApiKey: true, + }; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => nativeConfig), + saveRuntimeConfig: vi.fn(async (runtimeUrl) => { + nativeConfig = { ...nativeConfig, runtimeUrl }; + return nativeConfig; + }), + saveApiKey: vi.fn(async () => { + throw new Error("keyring denied"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + renderApp(); + await screen.findByLabelText("消息输入"); + await userEvent.type(screen.getByLabelText("消息输入"), "A draft"); + fireEvent.keyDown(window, { key: ",", ctrlKey: true }); + await userEvent.type(screen.getByLabelText("API Key"), "B-key"); + await userEvent.clear(screen.getByLabelText("Runtime URL")); + await userEvent.type( + screen.getByLabelText("Runtime URL"), + "http://runtime-b.test", + ); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await screen.findByText(/keyring denied/); + expect(nativeConfig.runtimeUrl).toBe("http://runtime-a.test"); + expect(client.saveRuntimeConfig).toHaveBeenLastCalledWith( + "http://runtime-a.test", + ); + expect(screen.getByLabelText("Runtime URL")).toHaveValue( + "http://runtime-a.test", + ); + expect(screen.getByLabelText("消息输入")).toHaveValue("A draft"); + expect(screen.getByLabelText("消息输入")).toBeDisabled(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.getByLabelText("Runtime URL")).toBeInTheDocument(); + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(client.listConversations).toHaveBeenCalledTimes(1); + // 恢复界面不能留空沿用未知状态的密钥;明确重新保存后才解除暂停。 + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + expect(await screen.findByText("请输入 API Key")).toBeInTheDocument(); + client.saveApiKey = vi.fn(async () => client.loadApiKeyStatus()); + await userEvent.type(screen.getByLabelText("API Key"), "A-key"); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await waitFor(() => { + expect(screen.queryByLabelText("Runtime URL")).toBeNull(); + expect(screen.getByLabelText("消息输入")).toBeEnabled(); + }); + expect(screen.getByLabelText("消息输入")).toHaveValue("A draft"); + await userEvent.click(screen.getByRole("button", { name: "发送" })); + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { text: "A draft", attachmentIds: [], references: [] }, + }); + }); + + test("clears a first configuration after key storage fails", async () => { + let nativeConfig: RuntimeConfig | null = null; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => nativeConfig), + saveRuntimeConfig: vi.fn(async (runtimeUrl) => { + nativeConfig = { runtimeUrl, hasApiKey: false }; + return nativeConfig; + }), + clearRuntimeConfig: vi.fn(async () => { + nativeConfig = null; + }), + saveApiKey: vi.fn(async () => { + throw new Error("keyring denied"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + renderApp(); + await screen.findByLabelText("Runtime URL"); + await userEvent.type(screen.getByLabelText("API Key"), "B-key"); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await screen.findByText(/keyring denied/); + expect(nativeConfig).toBeNull(); + expect(client.clearRuntimeConfig).toHaveBeenCalledOnce(); + expect(client.listConversations).not.toHaveBeenCalled(); + expect(screen.getByLabelText("消息输入")).toBeDisabled(); + }); + + test("shows the actual Native target and requires recovery if URL rollback fails", async () => { + let nativeConfig: RuntimeConfig = { + runtimeUrl: "http://runtime-a.test", + hasApiKey: true, + }; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => nativeConfig), + saveRuntimeConfig: vi.fn(async (runtimeUrl) => { + if (runtimeUrl === "http://runtime-a.test") { + throw new Error("restore denied"); + } + nativeConfig = { ...nativeConfig, runtimeUrl }; + return nativeConfig; + }), + saveApiKey: vi.fn(async () => { + // 模拟底层已经更新密钥但回执失败;客户端不得推断旧密钥仍安全。 + nativeConfig = { ...nativeConfig, hasApiKey: true }; + throw new Error("key write receipt failed"); + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + renderApp(); + await screen.findByLabelText("消息输入"); + await userEvent.type(screen.getByLabelText("消息输入"), "private A draft"); + fireEvent.keyDown(window, { key: ",", ctrlKey: true }); + await userEvent.type(screen.getByLabelText("API Key"), "B-key"); + await userEvent.clear(screen.getByLabelText("Runtime URL")); + await userEvent.type( + screen.getByLabelText("Runtime URL"), + "http://runtime-b.test", + ); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await screen.findByText(/restore denied/); + expect(nativeConfig.runtimeUrl).toBe("http://runtime-b.test"); + expect(screen.getByLabelText("Runtime URL")).toHaveValue( + "http://runtime-b.test", + ); + expect(screen.getByLabelText("消息输入")).toHaveValue(""); + expect(screen.getByLabelText("消息输入")).toBeDisabled(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.getByLabelText("API Key")).toBeInTheDocument(); + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(client.listConversations).toHaveBeenCalledTimes(1); + }); + test("opens and closes the mobile conversation drawer with accessible state", async () => { const originalInnerWidth = window.innerWidth; Object.defineProperty(window, "innerWidth", { @@ -425,6 +574,184 @@ describe("App", () => { }); }); + test.each(["success", "failure"])( + "same-URL settings turn in-flight attachments into removable errors after late %s", + async (outcome) => { + let resolveUpload!: (value: Attachment) => void; + let rejectUpload!: (reason: Error) => void; + const pendingUpload = new Promise((resolve, reject) => { + resolveUpload = resolve; + rejectUpload = reject; + }); + let config: RuntimeConfig = { + runtimeUrl: "http://same-runtime.test", + hasApiKey: true, + }; + const attachment: Attachment = { + id: "file-A", + name: "A.txt", + size: 4, + kind: "file", + mediaType: "text/plain", + downloadUrl: "/attachment/file-A", + previewUrl: null, + discarded: false, + }; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => config), + saveRuntimeConfig: vi.fn( + async (runtimeUrl) => (config = { ...config, runtimeUrl }), + ), + uploadAttachment: vi + .fn() + .mockImplementationOnce(() => pendingUpload) + .mockResolvedValue({ ...attachment, id: "reselected" }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + vi.mocked(open).mockResolvedValue("/isolated/A.txt"); + renderApp(); + await screen.findByLabelText("消息输入"); + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + await waitFor(() => + expect(client.uploadAttachment).toHaveBeenCalledTimes(1), + ); + fireEvent.keyDown(window, { key: ",", ctrlKey: true }); + await screen.findByLabelText("Runtime URL"); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await waitFor(() => + expect(screen.queryByLabelText("Runtime URL")).toBeNull(), + ); + await act(async () => { + if (outcome === "success") resolveUpload(attachment); + else rejectUpload(new Error("obsolete upload failure")); + await Promise.resolve(); + }); + await userEvent.type(screen.getByLabelText("消息输入"), "after-settings"); + fireEvent.keyDown(screen.getByLabelText("消息输入"), { key: "Enter" }); + expect(await screen.findByText("请先移除上传失败的附件")).toBeTruthy(); + expect(client.sendMessage).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "移除 A.txt" })); + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + await waitFor(() => + expect(client.uploadAttachment).toHaveBeenCalledTimes(2), + ); + await waitFor(() => + expect( + screen.getByRole("list", { name: "附件队列" }).textContent, + ).toContain("4 B"), + ); + fireEvent.keyDown(screen.getByLabelText("消息输入"), { key: "Enter" }); + await waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "after-settings", + attachmentIds: ["reselected"], + references: [], + }, + }), + ); + }, + ); + + test("ignores a file picker opened on the previous Runtime", async () => { + let resolvePicker!: (path: string) => void; + vi.mocked(open).mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePicker = resolve; + }), + ); + let config: RuntimeConfig = { + runtimeUrl: "http://runtime-a.test", + hasApiKey: true, + }; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => config), + saveRuntimeConfig: vi.fn(async (runtimeUrl) => { + config = { ...config, runtimeUrl }; + return config; + }), + }); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + renderApp(); + await screen.findByLabelText("消息输入"); + await userEvent.click(screen.getByRole("button", { name: "添加附件" })); + await connectRuntime("http://runtime-b.test"); + await waitFor(() => { + expect(client.getHistory).toHaveBeenCalledTimes(2); + }); + resolvePicker("/tmp/runtime-a-private.txt"); + await waitFor(() => { + expect(screen.getByLabelText("消息输入")).toHaveValue(""); + }); + expect(client.uploadAttachment).not.toHaveBeenCalled(); + }); + + test("ignores a direct attachment preview returned from the previous Runtime", async () => { + let resolvePreview!: (result: AttachmentPreviewResult) => void; + let config: RuntimeConfig = { + runtimeUrl: "http://runtime-a.test", + hasApiKey: true, + }; + const client = runtimeClientStub(); + const getHistory = client.getHistory; + client.getRuntimeConfig = vi.fn(async () => config); + client.saveRuntimeConfig = vi.fn(async (runtimeUrl) => { + config = { ...config, runtimeUrl }; + return config; + }); + client.getHistory = vi.fn(async (request) => ({ + ...(await getHistory(request)), + items: + config.runtimeUrl === "http://runtime-a.test" + ? [ + historyItem({ + attachments: [ + { + id: "pic_a", + name: "private-a.png", + kind: "image", + mediaType: "image/png", + size: 20 * 1024 * 1024, + downloadUrl: "/api/v1/chat/attachments/pic_a", + previewUrl: "/api/v1/chat/attachments/pic_a/preview", + discarded: false, + }, + ], + }), + ] + : [], + })); + client.previewAttachment = vi.fn( + () => + new Promise((resolve) => { + resolvePreview = resolve; + }), + ); + vi.mocked(createTauriRuntimeClient).mockReturnValue(client); + const createSpy = vi.spyOn(URL, "createObjectURL"); + renderApp(); + await screen.findByText("private-a.png"); + await userEvent.click(screen.getByRole("button", { name: "预览" })); + await connectRuntime("http://runtime-b.test"); + await waitFor(() => { + expect(screen.queryByText("private-a.png")).toBeNull(); + }); + await act(async () => { + resolvePreview({ + status: 200, + ok: true, + mediaType: "image/png", + bytes: [1, 2, 3, 4], + body: null, + }); + }); + expect(screen.queryByRole("dialog", { name: "图片查看器" })).toBeNull(); + expect(createSpy).not.toHaveBeenCalled(); + createSpy.mockRestore(); + }); + test("uses native attachment bridges for timeline actions", async () => { const client = runtimeClientStub({ getHistory: vi.fn(async () => ({ diff --git a/apps/undefined-chat/src/App.tsx b/apps/undefined-chat/src/App.tsx index ceee074c..20a120f2 100644 --- a/apps/undefined-chat/src/App.tsx +++ b/apps/undefined-chat/src/App.tsx @@ -29,7 +29,7 @@ import { } from "./platform/PlatformContext"; import { AttachmentImageProvider } from "./rendering/AttachmentImageContext"; import { createTauriRuntimeClient } from "./runtime-client/tauri"; -import type { Attachment } from "./runtime-client/types"; +import type { Attachment, RuntimeConfig } from "./runtime-client/types"; import { useTheme } from "./theme/use-theme"; import { isImageAttachment } from "./utils/attachment"; @@ -89,6 +89,11 @@ export function App() { : null; const [setupError, setSetupError] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionRecoveryGeneration, setConnectionRecoveryGeneration] = + useState(0); + const connectionRecoveryRequired = connectionRecoveryGeneration > 0; + const connectionUpdateInFlightRef = useRef(false); const needsSetup = !state.runtimeConfig?.runtimeUrl || !state.runtimeConfig.hasApiKey; @@ -127,9 +132,18 @@ export function App() { apiKey: string, allowInsecure: boolean, ): Promise { + if (connectionUpdateInFlightRef.current) return; + connectionUpdateInFlightRef.current = true; + setIsConnecting(true); setSetupError(null); + let previousConfig: RuntimeConfig | null = null; + let savedConfig: RuntimeConfig | null = null; + let writeStarted = false; try { - await client.saveRuntimeConfig(url); + previousConfig = await client.getRuntimeConfig(); + store.suspendRuntime(); + writeStarted = true; + savedConfig = await client.saveRuntimeConfig(url); if (allowInsecure) { await client.confirmInsecureStorageFallback(); } @@ -137,10 +151,50 @@ export function App() { if (apiKey) { await client.saveApiKey(apiKey); } + await store.bootstrap({ + runtimeUrl: savedConfig.runtimeUrl, + resumeSuspended: true, + }); + if (store.getSnapshot().error) { + setSetupError(store.getSnapshot().error); + store.suspendRuntime(savedConfig); + setConnectionRecoveryGeneration((generation) => generation + 1); + return; + } + setConnectionRecoveryGeneration(0); setIsSettingsOpen(false); - await store.bootstrap(); } catch (err) { - setSetupError(err instanceof Error ? err.message : String(err)); + let detail = err instanceof Error ? err.message : String(err); + if (writeStarted) { + try { + if (previousConfig) { + await client.saveRuntimeConfig(previousConfig.runtimeUrl); + } else { + await client.clearRuntimeConfig(); + } + } catch (rollbackError) { + detail = t("setup.error.rollbackFailed", { + error: detail, + rollbackError: + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + }); + } + let actualConfig = savedConfig ?? previousConfig; + try { + actualConfig = await client.getRuntimeConfig(); + } catch { + // 无法读取时保留最后一次成功保存的目标,仍禁止 Runtime 请求。 + } + store.suspendRuntime(actualConfig); + setConnectionRecoveryGeneration((generation) => generation + 1); + detail = t("setup.error.reconfigure", { error: detail }); + } + setSetupError(detail); + } finally { + connectionUpdateInFlightRef.current = false; + setIsConnecting(false); } } @@ -165,10 +219,12 @@ export function App() { } async function previewAttachment(attachment: Attachment): Promise { + const runtimeConfig = store.getSnapshot().runtimeConfig; try { const result = await client.previewAttachment({ attachmentId: attachment.id, }); + if (store.getSnapshot().runtimeConfig !== runtimeConfig) return; if (!result.ok || result.bytes.length === 0) { window.alert(result.body ?? t("attachment.previewFailed")); return; @@ -188,11 +244,13 @@ export function App() { window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); } catch (err) { + if (store.getSnapshot().runtimeConfig !== runtimeConfig) return; window.alert(err instanceof Error ? err.message : String(err)); } } async function addAttachment(conversationId: string): Promise { + const runtimeConfig = store.getSnapshot().runtimeConfig; try { const selected = await open({ multiple: false, @@ -201,10 +259,12 @@ export function App() { pickerMode: "document", fileAccessMode: "copy", }); + if (store.getSnapshot().runtimeConfig !== runtimeConfig) return; if (typeof selected === "string") { await store.addAttachmentPath(conversationId, selected); } } catch (err) { + if (store.getSnapshot().runtimeConfig !== runtimeConfig) return; window.alert(err instanceof Error ? err.message : String(err)); } } @@ -215,7 +275,9 @@ export function App() { return; } // 尚无会话:先自动创建再把示例填入草稿 + const runtimeConfig = store.getSnapshot().runtimeConfig; void store.createConversation().then(() => { + if (store.getSnapshot().runtimeConfig !== runtimeConfig) return; const conversationId = store.getSnapshot().selectedConversationId; if (conversationId) { store.updateDraft(conversationId, prompt); @@ -223,7 +285,7 @@ export function App() { }); } - const showModal = needsSetup || isSettingsOpen; + const showModal = needsSetup || isSettingsOpen || connectionRecoveryRequired; const closeMobileSidebar = useCallback((): void => { setIsMobileSidebarActive(false); @@ -293,7 +355,7 @@ export function App() { previewImageUrlRef.current = null; } }; - }, []); + }, [state.runtimeConfig?.runtimeUrl]); useEffect(() => { if (!isMobile || !isMobileSidebarActive) { @@ -460,7 +522,10 @@ export function App() { ]); return ( - +
{/* 移动端侧边栏背景遮罩 */}
{ void handleConnect(url, apiKey, allowInsecure); }} onClose={ - needsSetup ? undefined : () => setIsSettingsOpen(false) + needsSetup || isConnecting || connectionRecoveryRequired + ? undefined + : () => setIsSettingsOpen(false) } error={setupError} > @@ -688,7 +760,13 @@ export function App() { : [] } commandSuggestions={state.commands} - disabled={isJobRunning(activeJob)} + disabled={ + isJobRunning(activeJob) || + isConnecting || + connectionRecoveryRequired || + state.bootstrapping || + state.connectionState === "disconnected" + } draft={ selectedConversationId ? (state.draftsByConversation[selectedConversationId] ?? "") diff --git a/apps/undefined-chat/src/chat-store/connection-recovery.test.ts b/apps/undefined-chat/src/chat-store/connection-recovery.test.ts new file mode 100644 index 00000000..97fa5732 --- /dev/null +++ b/apps/undefined-chat/src/chat-store/connection-recovery.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, vi } from "vitest"; +import type { RuntimeConfig } from "../runtime-client/types"; +import { runtimeClientStub } from "../test-fixtures"; +import { createChatStore } from "./store"; + +describe("connection configuration suspension", () => { + test("blocks Runtime operations and lifecycle bootstrap until explicit connection recovery", async () => { + let runtimeConfig: RuntimeConfig = { + runtimeUrl: "http://runtime-a.test", + hasApiKey: true, + }; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => runtimeConfig), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "A draft"); + store.suspendRuntime(); + await Promise.all([ + store.sendSelectedMessage(), + store.createConversation(), + store.deleteConversation("default"), + store.renameConversation("default", "unsafe rename"), + store.selectConversation("default"), + store.addAttachmentPath("default", "/mock/a.txt"), + store.reloadHistory("default"), + store.loadMoreHistory("default"), + store.cancelJob("job-1"), + store.refreshCommandsIfStale(), + store.bootstrap({ resuming: true }), + ]); + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(client.createConversation).not.toHaveBeenCalled(); + expect(client.deleteConversation).not.toHaveBeenCalled(); + expect(client.renameConversation).not.toHaveBeenCalled(); + expect(client.uploadAttachment).not.toHaveBeenCalled(); + expect(client.cancelJob).not.toHaveBeenCalled(); + expect(client.getHistory).toHaveBeenCalledTimes(1); + expect(client.listCommands).toHaveBeenCalledTimes(1); + expect(client.getRuntimeConfig).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().draftsByConversation.default).toBe("A draft"); + await store.bootstrap({ resumeSuspended: true }); + await store.sendSelectedMessage(); + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { text: "A draft", attachmentIds: [], references: [] }, + }); + runtimeConfig = { ...runtimeConfig, runtimeUrl: "http://runtime-b.test" }; + store.suspendRuntime(runtimeConfig); + expect(store.getSnapshot().runtimeConfig).toEqual(runtimeConfig); + expect(store.getSnapshot().conversations).toEqual([]); + expect(store.getSnapshot().draftsByConversation).toEqual({}); + expect(store.getSnapshot().connectionState).toBe("disconnected"); + await store.bootstrap(); + expect(client.getRuntimeConfig).toHaveBeenCalledTimes(2); + await store.bootstrap({ resumeSuspended: true }); + expect(store.getSnapshot().runtimeConfig).toEqual(runtimeConfig); + expect(store.getSnapshot().connectionState).toBe("connected"); + }); +}); diff --git a/apps/undefined-chat/src/chat-store/conversation-rename.test.ts b/apps/undefined-chat/src/chat-store/conversation-rename.test.ts new file mode 100644 index 00000000..f493c566 --- /dev/null +++ b/apps/undefined-chat/src/chat-store/conversation-rename.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi } from "vitest"; +import { conversation, runtimeClientStub } from "../test-fixtures"; +import { createChatStore } from "./store"; + +describe("conversation rename", () => { + test.each([false, true])( + "keeps the current conversation during rename (switch while pending: %s)", + async (switchWhilePending) => { + let finishRename!: (value: { ok: boolean }) => void; + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "A" }), conversation({ id: "B" })], + activeJob: null, + defaultConversationId: "A", + virtualUserId: "webchat", + })), + renameConversation: vi.fn( + () => + new Promise<{ ok: boolean }>((resolve) => { + finishRename = resolve; + }), + ), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + if (!switchWhilePending) await store.selectConversation("B"); + const rename = store.renameConversation("A", "Renamed A"); + if (switchWhilePending) await store.selectConversation("B"); + store.updateDraft("B", "B draft"); + store.dispatch({ type: "mobile-panel/set", panel: "conversations" }); + finishRename({ ok: true }); + await rename; + expect(store.getSnapshot().selectedConversationId).toBe("B"); + expect(store.getSnapshot().settings.mobilePanel).toBe("conversations"); + expect(store.getSnapshot().draftsByConversation.B).toBe("B draft"); + expect( + store.getSnapshot().conversations.find((item) => item.id === "A") + ?.title, + ).toBe("Renamed A"); + await store.sendSelectedMessage(); + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "B", + message: { + text: "B draft", + attachmentIds: [], + references: [], + }, + }); + }, + ); +}); diff --git a/apps/undefined-chat/src/chat-store/history-order.test.ts b/apps/undefined-chat/src/chat-store/history-order.test.ts new file mode 100644 index 00000000..cf698203 --- /dev/null +++ b/apps/undefined-chat/src/chat-store/history-order.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test, vi } from "vitest"; +import type { + HistoryPageResponse, + HistoryResponse, +} from "../runtime-client/types"; +import { historyItem, job, runtimeClientStub } from "../test-fixtures"; +import { createChatStore } from "./store"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function history(start: number, end: number, total = end): HistoryResponse { + return { + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: end - start, + items: Array.from({ length: end - start }, (_, index) => + historyItem({ + messageId: `message-${start + index}`, + content: `正文 ${start + index}`, + }), + ), + limit: 50, + before: null, + hasMore: start > 0, + nextBefore: start > 0 ? start : null, + total, + }; +} + +describe("history request ordering within one Runtime", () => { + test("a late page cannot replace the final history after sending and cancelling a job", async () => { + const oldPage = deferred(); + const finalHistory = history(72, 122); + finalHistory.items.splice( + -2, + 2, + historyItem({ messageId: "new-user", content: "新问题" }), + historyItem({ + messageId: "new-bot", + role: "bot", + content: "新回复", + }), + ); + const client = runtimeClientStub({ + getHistory: vi + .fn() + .mockResolvedValueOnce(history(70, 120)) + .mockResolvedValueOnce(finalHistory), + getHistoryPage: vi.fn(() => oldPage.promise), + cancelJob: vi.fn(async () => + job({ status: "cancelled", historyFinalized: true }), + ), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const pendingPage = store.loadMoreHistory("default"); + store.updateDraft("default", "新问题"); + await store.sendSelectedMessage(); + await store.cancelJob("job-1"); + await vi.waitFor(() => { + expect(store.getSnapshot().historyByConversation.default.items).toEqual( + finalHistory.items, + ); + }); + oldPage.resolve({ ...history(20, 70, 120), before: 70, cursor: 20 }); + await pendingPage; + expect(store.getSnapshot().historyByConversation.default).toEqual({ + items: finalHistory.items, + hasMore: true, + nextBefore: 72, + total: 122, + loading: false, + error: null, + }); + }); + + test.each(["success", "error"])( + "a superseded reload %s cannot replace a newer reload", + async (outcome) => { + const oldReload = deferred(); + const newer = history(72, 122); + const client = runtimeClientStub(); + const store = createChatStore({ client }); + await store.bootstrap(); + vi.mocked(client.getHistory) + .mockImplementationOnce(() => oldReload.promise) + .mockResolvedValueOnce(newer); + const pending = store.reloadHistory("default"); + await store.reloadHistory("default"); + if (outcome === "success") oldReload.resolve(history(70, 120)); + else oldReload.reject(new Error("旧请求失败")); + await pending; + expect(store.getSnapshot().historyByConversation.default).toEqual({ + items: newer.items, + hasMore: true, + nextBefore: 72, + total: 122, + loading: false, + error: null, + }); + }, + ); + + test("an old page error cannot clear a newer reload's loading state", async () => { + const page = deferred(); + const reload = deferred(); + const client = runtimeClientStub({ + getHistory: vi.fn(async () => history(70, 120)), + getHistoryPage: vi.fn(() => page.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const pendingPage = store.loadMoreHistory("default"); + vi.mocked(client.getHistory).mockImplementationOnce(() => reload.promise); + const pendingReload = store.reloadHistory("default"); + page.reject(new Error("迟到分页错误")); + await pendingPage; + expect(store.getSnapshot().historyByConversation.default.loading).toBe( + true, + ); + expect(store.getSnapshot().historyByConversation.default.error).toBeNull(); + reload.resolve(history(72, 122)); + await pendingReload; + expect(store.getSnapshot().historyByConversation.default.loading).toBe( + false, + ); + }); + + test.each(["page", "reload"])( + "an in-flight %s cannot remove a newly sent optimistic message", + async (kind) => { + const response = deferred(); + const client = runtimeClientStub({ + getHistory: vi.fn(async () => history(70, 120)), + getHistoryPage: vi.fn(() => response.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + vi.mocked(client.getHistory).mockImplementationOnce( + () => response.promise, + ); + const pending = + kind === "page" + ? store.loadMoreHistory("default") + : store.reloadHistory("default"); + store.updateDraft("default", "保留新问题"); + await store.sendSelectedMessage(); + response.resolve({ ...history(20, 70, 120), before: 70, cursor: 20 }); + await pending; + const saved = store.getSnapshot().historyByConversation.default; + expect(saved.items.at(-1)?.content).toBe("保留新问题"); + expect(saved.total).toBe(121); + expect(saved.nextBefore).toBe(70); + expect(saved.loading).toBe(false); + }, + ); + + test("a pending history response cannot recreate a deleted conversation's history", async () => { + const response = deferred(); + const client = runtimeClientStub(); + const store = createChatStore({ client }); + await store.bootstrap(); + vi.mocked(client.getHistory).mockImplementationOnce(() => response.promise); + const pending = store.reloadHistory("default"); + await store.deleteConversation("default"); + response.resolve(history(70, 120)); + await pending; + expect(store.getSnapshot().historyByConversation.default).toBeUndefined(); + }); +}); diff --git a/apps/undefined-chat/src/chat-store/resume-completion.test.ts b/apps/undefined-chat/src/chat-store/resume-completion.test.ts new file mode 100644 index 00000000..4dba85bb --- /dev/null +++ b/apps/undefined-chat/src/chat-store/resume-completion.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, test, vi } from "vitest"; +import type { + ActiveJobsResponse, + ChatJob, + EventStreamSubscription, + HistoryResponse, + JobEventsJsonResponse, + RuntimeHealth, +} from "../runtime-client/types"; +import { + conversation, + historyItem, + job, + runtimeClientStub, +} from "../test-fixtures"; +import { createChatStore } from "./store"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function resumeFixture() { + let active: ActiveJobsResponse = { job: null, jobs: [] }; + let completed = false; + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "A" }), conversation({ id: "B" })], + activeJob: null, + defaultConversationId: "A", + virtualUserId: "webchat", + })), + getActiveJobs: vi.fn(async () => active), + sendMessage: vi.fn(async () => + job({ jobId: "job-A", conversationId: "A", hasTimelineSnapshot: true }), + ), + getHistory: vi.fn(async ({ conversationId }): Promise => { + const items = + completed && conversationId === "A" + ? [ + historyItem({ messageId: "user-A", content: "question-A" }), + historyItem({ + messageId: "bot-A", + role: "bot", + content: "completed-in-background", + }), + ] + : []; + return { + conversationId, + virtualUserId: "webchat", + permission: "superadmin", + count: items.length, + items, + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: items.length, + }; + }), + }); + const store = createChatStore({ client }); + return { + client, + store, + complete() { + completed = true; + }, + setActive(response: ActiveJobsResponse) { + active = response; + }, + async start() { + await store.bootstrap(); + store.updateDraft("A", "question-A"); + await store.sendSelectedMessage(); + await store.selectConversation("B"); + }, + }; +} + +describe("completed jobs omitted from a reconnect snapshot", () => { + test("deduplicates a send ACK subscription while resume is still installing its listener", async () => { + const sending = deferred(); + const listener = deferred<() => void>(); + const stream = deferred(); + const accepted = job({ hasTimelineSnapshot: true }); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sending.promise), + startJobEventStream: vi + .fn() + .mockImplementationOnce(() => stream.promise) + .mockResolvedValue({ + subscriptionId: "duplicate-subscription", + jobId: accepted.jobId, + afterSeq: 0, + }), + cancelJob: vi.fn(async () => + job({ status: "cancelled", historyFinalized: true }), + ), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "question"); + const sendRequest = store.sendSelectedMessage(); + vi.mocked(client.getActiveJobs).mockResolvedValueOnce({ + job: accepted, + jobs: [accepted], + }); + vi.mocked(client.listenRuntimeSse).mockImplementationOnce( + () => listener.promise, + ); + const resuming = store.bootstrap({ resuming: true }); + await vi.waitFor(() => + expect(client.listenRuntimeSse).toHaveBeenCalledTimes(2), + ); + sending.resolve(accepted); + await vi.waitFor(() => + expect(client.startJobEventStream).toHaveBeenCalledTimes(1), + ); + listener.resolve(() => {}); + await resuming; + stream.resolve({ + subscriptionId: "single-subscription", + jobId: accepted.jobId, + afterSeq: 0, + }); + await sendRequest; + expect(client.startJobEventStream).toHaveBeenCalledTimes(1); + await store.cancelJob(accepted.jobId); + expect(client.stopJobEventStream).toHaveBeenCalledExactlyOnceWith( + "single-subscription", + ); + }); + + test.each(["success", "failure"])( + "ignores late pre-resume stream %s without replacing the recovered subscription", + async (outcome) => { + const sending = deferred(); + const health = deferred(); + const oldStream = deferred(); + const accepted = job({ hasTimelineSnapshot: true }); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sending.promise), + startJobEventStream: vi + .fn() + .mockImplementationOnce(() => oldStream.promise) + .mockResolvedValueOnce({ + subscriptionId: "recovered-subscription", + jobId: accepted.jobId, + afterSeq: 0, + }), + cancelJob: vi.fn(async () => + job({ status: "cancelled", historyFinalized: true }), + ), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "question"); + const sendRequest = store.sendSelectedMessage(); + vi.mocked(client.probeRuntime).mockImplementationOnce( + () => health.promise, + ); + const resuming = store.bootstrap({ resuming: true }); + await vi.waitFor(() => + expect(client.getActiveJobs).toHaveBeenCalledTimes(2), + ); + sending.resolve(accepted); + await vi.waitFor(() => + expect(client.startJobEventStream).toHaveBeenCalledTimes(1), + ); + health.resolve({ ok: true, status: 200, body: "ok" }); + await resuming; + expect(client.startJobEventStream).toHaveBeenCalledTimes(2); + if (outcome === "success") { + oldStream.resolve({ + subscriptionId: "obsolete-subscription", + jobId: accepted.jobId, + afterSeq: 0, + }); + } else { + oldStream.reject(new Error("obsolete stream start failed")); + } + await sendRequest; + if (outcome === "success") { + expect(client.stopJobEventStream).toHaveBeenCalledWith( + "obsolete-subscription", + ); + } else { + expect(client.stopJobEventStream).not.toHaveBeenCalled(); + } + expect(client.fetchJobEventsJson).not.toHaveBeenCalled(); + await store.cancelJob(accepted.jobId); + expect(client.stopJobEventStream).toHaveBeenLastCalledWith( + "recovered-subscription", + ); + }, + ); + + test.each([false, true])( + "retains a send accepted during resume without retaining a completed job (snapshot includes send: %s)", + async (snapshotIncludesSend) => { + const sending = deferred(); + const health = deferred(); + const previous = job({ jobId: "completed-B", conversationId: "B" }); + const accepted = job({ + jobId: "new-A", + conversationId: "A", + hasTimelineSnapshot: true, + }); + const snapshot = { ...accepted, lastSeq: 3, reply: "newer reply" }; + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "A" }), conversation({ id: "B" })], + activeJob: null, + defaultConversationId: "A", + virtualUserId: "webchat", + })), + getActiveJobs: vi + .fn() + .mockResolvedValueOnce({ job: previous, jobs: [previous] }) + .mockResolvedValueOnce({ + job: snapshotIncludesSend ? snapshot : null, + jobs: snapshotIncludesSend ? [snapshot] : [], + }), + sendMessage: vi.fn(() => sending.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("A", "question-A"); + const sendRequest = store.sendSelectedMessage(); + vi.mocked(client.probeRuntime).mockImplementationOnce( + () => health.promise, + ); + const resuming = store.bootstrap({ + preserveSelectionId: "A", + resuming: true, + }); + await vi.waitFor(() => + expect(client.getActiveJobs).toHaveBeenCalledTimes(2), + ); + sending.resolve(accepted); + await sendRequest; + expect(store.getSnapshot().activeJobsByConversation.A?.jobId).toBe( + "new-A", + ); + health.resolve({ ok: true, status: 200, body: "ok" }); + await resuming; + expect(store.getSnapshot().activeJobsByConversation.A).toEqual( + snapshotIncludesSend ? snapshot : accepted, + ); + expect(store.getSnapshot().activeJobsByConversation.B).toBeUndefined(); + expect(store.getSnapshot().jobConversationById).toEqual({ "new-A": "A" }); + expect(store.getSnapshot().connectionState).toBe("streaming"); + expect(client.startJobEventStream).toHaveBeenLastCalledWith({ + jobId: "new-A", + conversationId: "A", + afterSeq: snapshotIncludesSend ? 3 : 0, + }); + expect(client.startJobEventStream).toHaveBeenCalledTimes(3); + }, + ); + + test("refreshes a nonselected conversation even when its old JSON response arrives after resume", async () => { + const { client, store, start, complete } = resumeFixture(); + const terminal = deferred(); + vi.mocked(client.fetchJobEventsJson).mockImplementationOnce( + () => terminal.promise, + ); + await start(); + store.handleRuntimeStatus({ + jobId: "job-A", + subscriptionId: "sub-1", + status: "error", + detail: "offline", + }); + complete(); + await store.bootstrap({ preserveSelectionId: "B", resuming: true }); + terminal.resolve({ + job: job({ + jobId: "job-A", + conversationId: "A", + status: "done", + historyFinalized: true, + lastSeq: 3, + }), + events: [], + after: 0, + lastSeq: 3, + }); + await Promise.resolve(); + await store.selectConversation("A"); + expect(store.getSnapshot().historyByConversation.A.items[1].content).toBe( + "completed-in-background", + ); + expect(client.getHistory).toHaveBeenCalledTimes(4); + const lateTerminal = { + jobId: "job-A", + subscriptionId: "sub-1", + seq: 3, + eventType: "done", + payload: { conversation_id: "A" }, + }; + store.handleRuntimeEvent(lateTerminal); + store.handleRuntimeEvent(lateTerminal); + await vi.waitFor(() => expect(client.getHistory).toHaveBeenCalledTimes(5)); + expect(store.getSnapshot().historyByConversation.A.items).toHaveLength(2); + }); + + test("deduplicates the selected conversation and retains terminal jobs still finalizing history", async () => { + const { client, store, start, complete, setActive } = resumeFixture(); + await start(); + await store.selectConversation("A"); + setActive({ + job: null, + jobs: [ + job({ + jobId: "job-A", + conversationId: "A", + status: "done", + historyFinalized: false, + hasTimelineSnapshot: true, + }), + ], + }); + await store.bootstrap({ preserveSelectionId: "B", resuming: true }); + expect(client.getHistory).toHaveBeenCalledTimes(3); + expect(store.getSnapshot().activeJobsByConversation.A).toBeDefined(); + setActive({ job: null, jobs: [] }); + complete(); + await store.bootstrap({ preserveSelectionId: "A", resuming: true }); + expect(client.getHistory).toHaveBeenCalledTimes(4); + expect(store.getSnapshot().activeJobsByConversation.A).toBeUndefined(); + }); + + test("does not reload a conversation deleted while offline", async () => { + const { client, store, start } = resumeFixture(); + await start(); + vi.mocked(client.listConversations).mockResolvedValue({ + conversations: [conversation({ id: "B" })], + activeJob: null, + defaultConversationId: "B", + virtualUserId: "webchat", + }); + await store.bootstrap({ preserveSelectionId: "B", resuming: true }); + expect( + vi + .mocked(client.getHistory) + .mock.calls.map(([input]) => input.conversationId), + ).toEqual(["A", "B", "B"]); + }); + + test("preserves the history request started by a terminal response during bootstrap", async () => { + const { client, store, start, complete } = resumeFixture(); + await start(); + const snapshot = deferred(); + vi.mocked(client.getActiveJobs).mockImplementationOnce( + () => snapshot.promise, + ); + const terminalHistory = deferred(); + const getHistory = client.getHistory; + vi.mocked(client.getHistory).mockImplementationOnce( + () => terminalHistory.promise, + ); + const resuming = store.bootstrap({ + preserveSelectionId: "B", + resuming: true, + }); + await vi.waitFor(() => + expect(client.getActiveJobs).toHaveBeenCalledTimes(2), + ); + vi.mocked(client.fetchJobEventsJson).mockResolvedValue({ + job: job({ + jobId: "job-A", + conversationId: "A", + status: "done", + historyFinalized: true, + hasTimelineSnapshot: true, + }), + events: [], + after: 0, + lastSeq: 0, + }); + store.handleRuntimeStatus({ + jobId: "job-A", + subscriptionId: "sub-1", + status: "closed", + detail: "done", + }); + await vi.waitFor(() => expect(client.getHistory).toHaveBeenCalledTimes(3)); + snapshot.resolve({ job: null, jobs: [] }); + await resuming; + complete(); + // 获取 fixture 的权威历史结果,但不发起新的 store history 请求。 + terminalHistory.resolve( + await getHistory({ conversationId: "A", limit: 50 }), + ); + await vi.waitFor(() => + expect(store.getSnapshot().historyByConversation.A.items).toHaveLength(2), + ); + expect(store.getSnapshot().historyByConversation.A.items[1].content).toBe( + "completed-in-background", + ); + }); + + test("retries a terminal history refresh invalidated by same-Runtime suspension", async () => { + const { client, store, start, complete } = resumeFixture(); + await start(); + const pendingHistory = deferred(); + vi.mocked(client.getHistory).mockImplementationOnce( + () => pendingHistory.promise, + ); + vi.mocked(client.fetchJobEventsJson).mockResolvedValue({ + job: job({ + jobId: "job-A", + conversationId: "A", + status: "done", + historyFinalized: true, + hasTimelineSnapshot: true, + }), + events: [], + after: 0, + lastSeq: 0, + }); + store.handleRuntimeStatus({ + jobId: "job-A", + subscriptionId: "sub-1", + status: "closed", + detail: "done", + }); + await vi.waitFor(() => expect(client.getHistory).toHaveBeenCalledTimes(3)); + expect(store.getSnapshot().activeJobsByConversation.A).toBeUndefined(); + expect(store.getSnapshot().historyByConversation.A.loading).toBe(true); + store.suspendRuntime(); + complete(); + await store.bootstrap({ preserveSelectionId: "B", resumeSuspended: true }); + expect(client.getHistory).toHaveBeenCalledTimes(5); + const finalHistory = store.getSnapshot().historyByConversation.A; + pendingHistory.resolve({ + conversationId: "A", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + items: [historyItem({ content: "obsolete" })], + }); + await Promise.resolve(); + await store.selectConversation("A"); + expect(store.getSnapshot().historyByConversation.A).toEqual(finalHistory); + expect(finalHistory.items[1].content).toBe("completed-in-background"); + }); + + test("retries a failed background completion refresh when selecting its conversation", async () => { + const { client, store, start, complete } = resumeFixture(); + await start(); + complete(); + const getHistory = vi.mocked(client.getHistory).getMockImplementation(); + if (!getHistory) throw new Error("missing fixture history implementation"); + let failA = true; + vi.mocked(client.getHistory).mockImplementation(async (input) => { + if (input.conversationId === "A" && failA) + throw new Error("temporary history failure"); + return getHistory(input); + }); + await store.bootstrap({ preserveSelectionId: "B", resuming: true }); + expect(store.getSnapshot().historyByConversation.A.error).toBe( + "temporary history failure", + ); + failA = false; + await store.selectConversation("A"); + expect(store.getSnapshot().historyByConversation.A.error).toBeNull(); + expect(store.getSnapshot().historyByConversation.A.items[1].content).toBe( + "completed-in-background", + ); + }); +}); diff --git a/apps/undefined-chat/src/chat-store/runtime-contract.test.ts b/apps/undefined-chat/src/chat-store/runtime-contract.test.ts new file mode 100644 index 00000000..1ae9a9a4 --- /dev/null +++ b/apps/undefined-chat/src/chat-store/runtime-contract.test.ts @@ -0,0 +1,576 @@ +import { invoke } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createTauriRuntimeClient } from "../runtime-client/tauri"; +import { event, historyItem, job, runtimeClientStub } from "../test-fixtures"; +import { createChatStore } from "./store"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +function runtimeJob(overrides: Record = {}) { + return { + job_id: "job-1", + conversation_id: "default", + status: "running", + reply: "", + messages: [], + last_seq: 0, + current_tool_calls: [], + history_finalized: false, + ...overrides, + }; +} + +describe("Runtime job contract through the native client", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + test("fallback preserves the timeline and completed calls without double counting snapshot text", async () => { + const nativeClient = createTauriRuntimeClient(); + const running = job(); + vi.mocked(invoke).mockResolvedValue({ + status: 200, + ok: true, + body: { + job: runtimeJob({ + reply: "你好\n\n世界", + messages: ["你好", "世界"], + last_seq: 4, + }), + after: 3, + last_seq: 4, + events: [ + { + seq: 4, + event: "message", + payload: { + job_id: "job-1", + conversation_id: "default", + content: "世界", + }, + }, + ], + }, + }); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + event({ seq: 1, event: "message", payload: { content: "你好" } }), + event({ + seq: 2, + event: "tool_start", + payload: { webchat_call_id: "search-1", name: "search" }, + }), + event({ + seq: 3, + event: "tool_end", + payload: { + webchat_call_id: "search-1", + ok: true, + result_preview: "结果", + }, + }), + ]); + store.handleRuntimeStatus({ + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "SSE disconnected", + }); + await vi.waitFor(() => { + expect(store.getSnapshot().activeJobsByConversation.default?.reply).toBe( + "你好\n\n世界", + ); + }); + const active = store.getSnapshot().activeJobsByConversation.default; + expect(active?.currentTimeline).toEqual([ + { type: "message", seq: 1, content: "你好" }, + { type: "call", seq: 2, callId: "search-1" }, + { type: "message", seq: 4, content: "世界" }, + ]); + expect(active?.currentToolCalls[0]).toMatchObject({ + id: "search-1", + status: "done", + resultPreview: "结果", + }); + expect(invoke).toHaveBeenCalledWith("fetch_job_events_json", { + input: { jobId: "job-1", afterSeq: 3, conversationId: "default" }, + }); + await vi.advanceTimersByTimeAsync(1000); + expect(store.getSnapshot().activeJobsByConversation.default?.reply).toBe( + "你好\n\n世界", + ); + expect( + store.getSnapshot().activeJobsByConversation.default?.currentTimeline, + ).toEqual(active?.currentTimeline); + }); + + test.each(["sse", "json"])( + "%s terminal delivery waits for history_finalized before releasing the job and reloading history", + async (transport) => { + const nativeClient = createTauriRuntimeClient(); + const running = job(); + const terminalEvent = { + seq: 1, + event: "done", + payload: { + job_id: "job-1", + conversation_id: "default", + status: "done", + }, + }; + vi.mocked(invoke) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ status: "done", last_seq: 1 }), + events: [terminalEvent], + }, + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ + status: "done", + last_seq: 1, + history_finalized: true, + }), + events: [], + }, + }); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + if (transport === "sse") { + store.applyRuntimeEvents("job-1", [terminalEvent]); + await vi.advanceTimersByTimeAsync(1000); + } else { + store.handleRuntimeStatus({ + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "closed", + }); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + } + expect(client.getHistory).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().jobConversationById["job-1"]).toBe("default"); + expect( + store.getSnapshot().activeJobsByConversation.default?.historyFinalized, + ).toBe(false); + store.updateDraft("default", "下一条"); + await store.sendSelectedMessage(); + expect(client.sendMessage).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1000); + expect(client.getHistory).toHaveBeenCalledTimes(2); + expect(client.stopJobEventStream).toHaveBeenCalledWith("sub-1"); + expect( + store.getSnapshot().activeJobsByConversation.default, + ).toBeUndefined(); + expect(store.getSnapshot().jobConversationById["job-1"]).toBeUndefined(); + expect(store.getSnapshot().connectionState).toBe("connected"); + }, + ); + + test("an accepted message keeps its history and empty draft when native SSE startup rejects", async () => { + const nativeClient = createTauriRuntimeClient(); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "start_job_event_stream") { + throw new Error("native subscription failed"); + } + return { + status: command === "send_message" ? 202 : 200, + ok: true, + body: + command === "send_message" + ? runtimeJob() + : { job: runtimeJob(), events: [] }, + }; + }); + const client = runtimeClientStub({ + sendMessage: nativeClient.sendMessage, + startJobEventStream: nativeClient.startJobEventStream, + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "已经发送"); + await store.sendSelectedMessage(); + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith("fetch_job_events_json", { + input: { jobId: "job-1", afterSeq: 0, conversationId: "default" }, + }), + ); + expect(store.getSnapshot().draftsByConversation.default).toBe(""); + expect(store.getSnapshot().sendError).toBeNull(); + expect(store.getSnapshot().connectionState).toBe("json_fallback"); + expect(store.getSnapshot().historyByConversation.default?.items).toEqual([ + expect.objectContaining( + historyItem({ + content: "已经发送", + timestamp: expect.any(String), + messageId: expect.any(String), + }), + ), + ]); + expect(store.getSnapshot().activeJobsByConversation.default?.jobId).toBe( + "job-1", + ); + }); + + test.each(["response", "error"])( + "ignores a late old-job %s after finalization and a new send", + async (completion) => { + const nativeClient = createTauriRuntimeClient(); + let resolveOld!: (value: unknown) => void; + let rejectOld!: (reason: Error) => void; + const pending = new Promise((resolve, reject) => { + resolveOld = resolve; + rejectOld = reject; + }); + vi.mocked(invoke) + .mockReturnValueOnce(pending) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ + status: "done", + last_seq: 1, + history_finalized: true, + }), + events: [], + }, + }); + const running = job(); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + sendMessage: vi.fn(async () => job({ jobId: "job-new" })), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const broken = { + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "closed", + }; + store.handleRuntimeStatus(broken); + store.handleRuntimeStatus(broken); + await vi.waitFor(() => + expect( + store.getSnapshot().activeJobsByConversation.default, + ).toBeUndefined(), + ); + store.updateDraft("default", "新任务"); + await store.sendSelectedMessage(); + if (completion === "response") { + resolveOld({ + status: 200, + ok: true, + body: { job: runtimeJob(), events: [] }, + }); + } else { + rejectOld(new Error("old request failed")); + } + await vi.advanceTimersByTimeAsync(0); + expect(store.getSnapshot().activeJobsByConversation.default?.jobId).toBe( + "job-new", + ); + expect(store.getSnapshot().jobConversationById["job-1"]).toBeUndefined(); + expect(store.getSnapshot().connectionState).toBe("streaming"); + store.applyRuntimeEvents("job-1", [ + { seq: 2, event: "done", payload: { conversation_id: "default" } }, + ]); + expect(store.getSnapshot().activeJobsByConversation.default?.jobId).toBe( + "job-new", + ); + }, + ); + + test("an older concurrent response cannot replace the latest same-job snapshot", async () => { + const nativeClient = createTauriRuntimeClient(); + let resolveOld!: (value: unknown) => void; + const pending = new Promise((resolve) => { + resolveOld = resolve; + }); + vi.mocked(invoke) + .mockReturnValueOnce(pending) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ reply: "已有\n\n新增", last_seq: 2 }), + events: [{ seq: 2, event: "message", payload: { content: "新增" } }], + }, + }); + const running = job(); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + { seq: 1, event: "message", payload: { content: "已有" } }, + ]); + const broken = { + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "closed", + }; + store.handleRuntimeStatus(broken); + store.handleRuntimeStatus(broken); + await vi.waitFor(() => + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(2), + ); + const latest = store.getSnapshot().activeJobsByConversation.default; + resolveOld({ + status: 200, + ok: true, + body: { job: runtimeJob({ reply: "已有", last_seq: 1 }), events: [] }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(store.getSnapshot().activeJobsByConversation.default).toEqual( + latest, + ); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(2); + }); + + test("a snapshot overtaken by SSE keeps the consumed cursor and polls again", async () => { + const nativeClient = createTauriRuntimeClient(); + let resolveOld!: (value: unknown) => void; + const pending = new Promise((resolve) => { + resolveOld = resolve; + }); + vi.mocked(invoke) + .mockReturnValueOnce(pending) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ reply: "已有\n\n新增", last_seq: 2 }), + events: [], + }, + }); + const running = job(); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + { seq: 1, event: "message", payload: { content: "已有" } }, + ]); + store.handleRuntimeStatus({ + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "closed", + }); + store.applyRuntimeEvents("job-1", [ + { seq: 2, event: "message", payload: { content: "新增" } }, + ]); + const latest = store.getSnapshot().activeJobsByConversation.default; + resolveOld({ + status: 200, + ok: true, + body: { job: runtimeJob({ reply: "已有", last_seq: 1 }), events: [] }, + }); + await vi.advanceTimersByTimeAsync(0); + expect(store.getSnapshot().activeJobsByConversation.default).toEqual( + latest, + ); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(2); + await vi.advanceTimersByTimeAsync(1000); + expect(invoke).toHaveBeenLastCalledWith("fetch_job_events_json", { + input: { jobId: "job-1", afterSeq: 2, conversationId: "default" }, + }); + expect(store.getSnapshot().activeJobsByConversation.default?.reply).toBe( + "已有\n\n新增", + ); + expect( + store.getSnapshot().activeJobsByConversation.default?.currentTimeline, + ).toEqual(latest?.currentTimeline); + }); + + test("cancellation keeps the real event cursor so its finalized snapshot can finish", async () => { + const nativeClient = createTauriRuntimeClient(); + vi.mocked(invoke) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: runtimeJob({ status: "cancelled", last_seq: 4 }), + }) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ + status: "cancelled", + last_seq: 4, + history_finalized: true, + }), + events: [ + { + seq: 4, + event: "error", + payload: { + job_id: "job-1", + conversation_id: "default", + error: "cancelled", + }, + }, + ], + }, + }); + const running = job(); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + cancelJob: nativeClient.cancelJob, + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + { seq: 3, event: "message", payload: { content: "取消前回复" } }, + ]); + await store.cancelJob("job-1"); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(3); + expect(store.getSnapshot().activeJobsByConversation.default?.status).toBe( + "cancelled", + ); + expect(client.getHistory).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(invoke).toHaveBeenLastCalledWith("fetch_job_events_json", { + input: { jobId: "job-1", afterSeq: 3, conversationId: "default" }, + }); + expect( + store.getSnapshot().activeJobsByConversation.default, + ).toBeUndefined(); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(4); + expect(client.getHistory).toHaveBeenCalledTimes(2); + }); + + test("an old cancellation rejection cannot overwrite a new job's send error", async () => { + const nativeClient = createTauriRuntimeClient(); + let rejectCancel!: (reason: Error) => void; + const pendingCancel = new Promise((_resolve, reject) => { + rejectCancel = reject; + }); + vi.mocked(invoke) + .mockReturnValueOnce(pendingCancel) + .mockResolvedValueOnce({ + status: 200, + ok: true, + body: { + job: runtimeJob({ status: "done", history_finalized: true }), + events: [], + }, + }); + const running = job(); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + cancelJob: nativeClient.cancelJob, + fetchJobEventsJson: nativeClient.fetchJobEventsJson, + sendMessage: vi.fn(async () => job({ jobId: "job-new" })), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const cancelling = store.cancelJob("job-1"); + store.handleRuntimeStatus({ + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "closed", + }); + await vi.waitFor(() => + expect( + store.getSnapshot().activeJobsByConversation.default, + ).toBeUndefined(), + ); + store.updateDraft("default", "新任务"); + await store.sendSelectedMessage(); + rejectCancel(new Error("old cancellation failed")); + await cancelling; + expect(store.getSnapshot().sendError).toBeNull(); + expect(store.getSnapshot().activeJobsByConversation.default?.jobId).toBe( + "job-new", + ); + }); + + test.each(["connected", "error", "closed"])( + "a late old SSE %s status cannot change a new job's connection state", + async (status) => { + const running = job(); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: running, jobs: [running] })), + fetchJobEventsJson: vi.fn(async () => ({ + job: job({ status: "done", historyFinalized: true }), + after: 0, + lastSeq: 0, + events: [], + })), + sendMessage: vi.fn(async () => job({ jobId: "job-new" })), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.handleRuntimeStatus({ + subscriptionId: "sub-1", + jobId: "job-1", + status: "error", + detail: "closed", + }); + await vi.waitFor(() => + expect( + store.getSnapshot().activeJobsByConversation.default, + ).toBeUndefined(), + ); + store.updateDraft("default", "新任务"); + await store.sendSelectedMessage(); + // 活跃新任务进入正常 fallback,随后旧通知不能改变这一状态或发起额外请求。 + const newStatus = { + subscriptionId: "sub-new", + jobId: "job-new", + detail: "closed", + }; + store.handleRuntimeStatus({ ...newStatus, status: "connected" }); + expect(store.getSnapshot().connectionState).toBe("streaming"); + vi.mocked(client.fetchJobEventsJson).mockImplementationOnce( + () => new Promise(() => {}), + ); + store.handleRuntimeStatus({ ...newStatus, status: "closed" }); + expect(store.getSnapshot().connectionState).toBe("json_fallback"); + const requests = vi.mocked(client.fetchJobEventsJson).mock.calls.length; + store.handleRuntimeStatus({ + subscriptionId: "sub-1", + jobId: "job-1", + status, + detail: "late", + }); + expect(store.getSnapshot().connectionState).toBe("json_fallback"); + expect(client.fetchJobEventsJson).toHaveBeenCalledTimes(requests); + expect(store.getSnapshot().activeJobsByConversation.default?.jobId).toBe( + "job-new", + ); + }, + ); +}); diff --git a/apps/undefined-chat/src/chat-store/runtime-scope.test.ts b/apps/undefined-chat/src/chat-store/runtime-scope.test.ts new file mode 100644 index 00000000..b885adf6 --- /dev/null +++ b/apps/undefined-chat/src/chat-store/runtime-scope.test.ts @@ -0,0 +1,217 @@ +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createTauriRuntimeClient } from "../runtime-client/tauri"; +import type { Attachment, HistoryResponse } from "../runtime-client/types"; +import { + conversation, + historyItem, + job, + runtimeClientStub, +} from "../test-fixtures"; +import { createChatStore } from "./store"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("Runtime instance isolation", () => { + beforeEach(() => vi.resetAllMocks()); + + test("a new Runtime sharing the default conversation sends only its own editor state through Tauri", async () => { + const id = "legacy-system-42"; + let runtimeUrl = "http://runtime-a.example"; + let invokedOrigin = ""; + vi.mocked(invoke).mockImplementation(async () => { + invokedOrigin = runtimeUrl; + return { + status: 202, + ok: true, + body: { job_id: "new", conversation_id: id }, + }; + }); + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => ({ runtimeUrl, hasApiKey: true })), + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id })], + activeJob: null, + defaultConversationId: id, + virtualUserId: "webchat", + })), + getHistory: vi.fn(async () => ({ + conversationId: id, + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + items: [ + historyItem({ + messageId: runtimeUrl, + role: "bot", + content: runtimeUrl, + }), + ], + })), + sendMessage: createTauriRuntimeClient().sendMessage, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft(id, "A private draft"); + store.addReferenceFromMessageId(id, runtimeUrl); + await store.addAttachmentPath(id, "/mock/a-private.pdf"); + store.dispatch({ type: "autoScroll/set", enabled: false }); + runtimeUrl = "http://runtime-b.example"; + await store.bootstrap({ runtimeUrl }); + const state = store.getSnapshot(); + expect(state.draftsByConversation).toEqual({}); + expect(state.referencesByConversation).toEqual({}); + expect(state.attachmentsByConversation).toEqual({}); + expect(state.historyByConversation[id].items[0].content).toBe(runtimeUrl); + expect(state.autoScrollEnabled).toBe(false); + store.updateDraft(id, "B request"); + await store.sendSelectedMessage(); + expect(invokedOrigin).toBe(runtimeUrl); + expect(invoke).toHaveBeenCalledWith("send_message", { + input: { + conversationId: id, + message: { text: "B request", attachment_ids: [], references: [] }, + }, + }); + }); + + test("reconnecting to the same Runtime preserves drafts, references, and ready attachments", async () => { + const client = runtimeClientStub(); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "keep draft"); + store.addReference("default", { messageId: "a", quote: "keep quote" }); + await store.addAttachmentPath("default", "/mock/a.pdf"); + const before = store.getSnapshot(); + await store.bootstrap({ + runtimeUrl: before.runtimeConfig?.runtimeUrl, + resuming: true, + }); + const after = store.getSnapshot(); + expect(after.draftsByConversation).toEqual(before.draftsByConversation); + expect(after.referencesByConversation).toEqual( + before.referencesByConversation, + ); + expect(after.attachmentsByConversation).toEqual( + before.attachmentsByConversation, + ); + }); + + test("old history, uploads, and failed sends cannot repopulate a replacement Runtime", async () => { + let runtimeUrl = "http://runtime-a.example"; + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => ({ runtimeUrl, hasApiKey: true })), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const history = deferred(); + const upload = deferred(); + const send = deferred>(); + vi.mocked(client.getHistory).mockImplementationOnce(() => history.promise); + vi.mocked(client.uploadAttachment).mockImplementationOnce( + () => upload.promise, + ); + vi.mocked(client.sendMessage).mockImplementationOnce(() => send.promise); + store.updateDraft("default", "A pending send"); + const oldSend = store.sendSelectedMessage(); + const oldUpload = store.addAttachmentPath("default", "/mock/a.pdf"); + const oldHistory = store.reloadHistory("default"); + runtimeUrl = "http://runtime-b.example"; + await store.bootstrap({ runtimeUrl }); + store.updateDraft("default", "B new draft"); + history.resolve({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + items: [historyItem({ content: "A late history" })], + }); + upload.resolve({ + id: "A-uid", + name: "a.pdf", + size: 1, + mediaType: "application/pdf", + kind: "file", + downloadUrl: "", + previewUrl: null, + discarded: false, + }); + send.reject(new Error("A late send failure")); + await Promise.all([oldHistory, oldUpload, oldSend]); + expect(store.getSnapshot().draftsByConversation.default).toBe( + "B new draft", + ); + expect(store.getSnapshot().attachmentsByConversation).toEqual({}); + expect(store.getSnapshot().historyByConversation.default.items).toEqual([]); + expect(store.getSnapshot().sendError).toBeNull(); + await store.sendSelectedMessage(); + expect(client.sendMessage).toHaveBeenLastCalledWith({ + conversationId: "default", + message: { text: "B new draft", attachmentIds: [], references: [] }, + }); + }); + + test("a replacement Runtime preempts an old bootstrap and ignores its late SSE callback", async () => { + let runtimeUrl = "http://runtime-a.example"; + const history = deferred(); + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => ({ runtimeUrl, hasApiKey: true })), + }); + vi.mocked(client.getHistory).mockImplementationOnce(() => history.promise); + const store = createChatStore({ client }); + const oldBootstrap = store.bootstrap(); + await vi.waitFor(() => expect(client.getHistory).toHaveBeenCalledTimes(1)); + const oldStatus = vi.mocked(client.listenRuntimeSse).mock.calls[0][1]; + runtimeUrl = "http://runtime-b.example"; + const newJob = job(); + vi.mocked(client.getActiveJobs).mockResolvedValue({ + job: newJob, + jobs: [newJob], + }); + await store.bootstrap({ runtimeUrl }); + oldStatus({ + subscriptionId: "old", + jobId: newJob.jobId, + status: "closed", + detail: "late", + }); + expect(client.fetchJobEventsJson).not.toHaveBeenCalled(); + history.resolve({ + conversationId: "default", + virtualUserId: "webchat", + permission: "superadmin", + count: 1, + limit: 50, + before: null, + hasMore: false, + nextBefore: null, + total: 1, + items: [historyItem({ content: "A late bootstrap" })], + }); + await oldBootstrap; + expect(store.getSnapshot().runtimeConfig?.runtimeUrl).toBe(runtimeUrl); + expect(store.getSnapshot().historyByConversation.default.items).toEqual([]); + expect(store.getSnapshot().connectionState).toBe("streaming"); + }); +}); diff --git a/apps/undefined-chat/src/chat-store/send-recovery.test.ts b/apps/undefined-chat/src/chat-store/send-recovery.test.ts new file mode 100644 index 00000000..3dc8679f --- /dev/null +++ b/apps/undefined-chat/src/chat-store/send-recovery.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, test, vi } from "vitest"; +import type { Attachment, RuntimeClient } from "../runtime-client/types"; +import { conversation, job, runtimeClientStub } from "../test-fixtures"; +import { createChatStore } from "./store"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function attachment(id: string): Attachment { + return { + id, + name: `${id}.txt`, + size: 4, + mediaType: "text/plain", + kind: "file", + downloadUrl: null, + previewUrl: null, + discarded: false, + }; +} + +describe("send failure recovery", () => { + test.each(["new draft", ""])( + "preserves later text edits including an intentional empty draft (%j)", + async (laterDraft) => { + const sent = deferred(); + const client = runtimeClientStub({ + sendMessage: vi + .fn() + .mockImplementationOnce(() => sent.promise) + .mockResolvedValueOnce(job()), + uploadAttachment: vi.fn(async () => attachment("file_a")), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", " first message "); + store.addReference("default", { messageId: "a", quote: "first quote" }); + await store.addAttachmentPath("default", "/mock/a.txt"); + const original = store.getSnapshot(); + const sending = store.sendSelectedMessage(); + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "first message", + attachmentIds: ["file_a"], + references: [{ messageId: "a", quote: "first quote" }], + }, + }); + store.updateDraft("default", "typed after sending"); + store.updateDraft("default", laterDraft); + sent.reject(new Error("POST failed")); + await sending; + const state = store.getSnapshot(); + expect(state.draftsByConversation.default).toBe(laterDraft); + expect(state.attachmentsByConversation.default).toEqual( + original.attachmentsByConversation.default, + ); + expect(state.referencesByConversation.default).toEqual( + original.referencesByConversation.default, + ); + expect( + state.historyByConversation.default.items.some( + (item) => item.content === "first message", + ), + ).toBe(false); + expect(state.sendError).toBe("POST failed"); + await store.sendSelectedMessage(); + expect(client.sendMessage).toHaveBeenLastCalledWith({ + conversationId: "default", + message: { + text: laterDraft, + attachmentIds: ["file_a"], + references: [{ messageId: "a", quote: "first quote" }], + }, + }); + }, + ); + + test("preserves a new pending attachment and a new reference while restoring unchanged text", async () => { + const sent = deferred(); + const uploaded = deferred(); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sent.promise), + uploadAttachment: vi + .fn() + .mockResolvedValueOnce(attachment("file_a")) + .mockImplementationOnce(() => uploaded.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", " unchanged text "); + store.addReference("default", { messageId: "a", quote: "old quote" }); + await store.addAttachmentPath("default", "/mock/a.txt"); + const sending = store.sendSelectedMessage(); + const uploading = store.addAttachmentPath("default", "/mock/b.txt"); + store.addReference("default", { messageId: "b", quote: "new quote" }); + const edited = store.getSnapshot(); + sent.reject(new Error("POST failed")); + await sending; + expect(store.getSnapshot().draftsByConversation.default).toBe( + " unchanged text ", + ); + expect(store.getSnapshot().attachmentsByConversation.default).toEqual( + edited.attachmentsByConversation.default, + ); + expect(store.getSnapshot().referencesByConversation.default).toEqual([ + { messageId: "b", quote: "new quote" }, + ]); + uploaded.resolve(attachment("file_b")); + await uploading; + expect(store.getSnapshot().attachmentsByConversation.default).toMatchObject( + [{ name: "file_b.txt", status: "ready", attachmentId: "file_b" }], + ); + }); + + test("does not restore attachments or references after they were edited then cleared", async () => { + const sent = deferred(); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sent.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "keep original"); + store.addReference("default", { messageId: "a", quote: "old quote" }); + await store.addAttachmentPath("default", "/mock/a.txt"); + const sending = store.sendSelectedMessage(); + await store.addAttachmentPath("default", "/mock/b.txt"); + const newAttachment = + store.getSnapshot().attachmentsByConversation.default[0]; + store.clearAttachment("default", newAttachment.id); + store.addReference("default", { messageId: "b", quote: "new quote" }); + store.clearReference("default", "b"); + sent.reject(new Error("POST failed")); + await sending; + expect(store.getSnapshot().draftsByConversation.default).toBe( + "keep original", + ); + expect(store.getSnapshot().attachmentsByConversation.default).toEqual([]); + expect(store.getSnapshot().referencesByConversation.default).toEqual([]); + }); + + test("editing another conversation still restores this conversation's unchanged fields", async () => { + const sent = deferred(); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sent.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", " exact original "); + const sending = store.sendSelectedMessage(); + store.updateDraft("other", "other conversation draft"); + sent.reject(new Error("POST failed")); + await sending; + expect(store.getSnapshot().draftsByConversation.default).toBe( + " exact original ", + ); + expect(store.getSnapshot().draftsByConversation.other).toBe( + "other conversation draft", + ); + }); + + test.each(["attachments", "references"])( + "an edited %s field does not stop restoration of the other unchanged field", + async (editedField) => { + const sent = deferred(); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sent.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "original draft"); + store.addReference("default", { messageId: "a", quote: "old quote" }); + await store.addAttachmentPath("default", "/mock/a.txt"); + const original = store.getSnapshot(); + const sending = store.sendSelectedMessage(); + if (editedField === "attachments") { + await store.addAttachmentPath("default", "/mock/b.txt"); + } else { + store.addReference("default", { messageId: "b", quote: "new quote" }); + } + const edited = store.getSnapshot(); + sent.reject(new Error("POST failed")); + await sending; + const recovered = store.getSnapshot(); + expect(recovered.attachmentsByConversation.default).toEqual( + editedField === "attachments" + ? edited.attachmentsByConversation.default + : original.attachmentsByConversation.default, + ); + expect(recovered.referencesByConversation.default).toEqual( + editedField === "references" + ? edited.referencesByConversation.default + : original.referencesByConversation.default, + ); + }, + ); + + test.each([false, true])( + "removing a conversation clears its revision and rejects stale recovery (reimport=%s)", + async (reimport) => { + const sent = deferred(); + const client = runtimeClientStub({ + sendMessage: vi.fn(() => sent.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("default", "deleted draft"); + const sending = store.sendSelectedMessage(); + await store.deleteConversation("default"); + if (reimport) { + // 同名会话重新导入并两次编辑,也不能复用已删除会话的旧修订。 + store.dispatch({ + type: "conversation/upsert", + conversation: conversation(), + }); + store.updateDraft("default", "new draft"); + store.updateDraft("default", ""); + } + sent.reject(new Error("POST failed")); + await sending; + expect(store.getSnapshot().draftsByConversation.default).toBe( + reimport ? "" : undefined, + ); + expect( + store.getSnapshot().attachmentsByConversation.default, + ).toBeUndefined(); + expect( + store.getSnapshot().referencesByConversation.default, + ).toBeUndefined(); + }, + ); +}); diff --git a/apps/undefined-chat/src/chat-store/snapshot-recovery.test.tsx b/apps/undefined-chat/src/chat-store/snapshot-recovery.test.tsx new file mode 100644 index 00000000..43c3045d --- /dev/null +++ b/apps/undefined-chat/src/chat-store/snapshot-recovery.test.tsx @@ -0,0 +1,627 @@ +import { invoke } from "@tauri-apps/api/core"; +import { screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { MessageTimeline } from "../message-timeline/MessageTimeline"; +import { createTauriRuntimeClient } from "../runtime-client/tauri"; +import type { ChatJob } from "../runtime-client/types"; +import { event, job, runtimeClientStub } from "../test-fixtures"; +import { renderWithProviders } from "../test-utils"; +import { createChatStore } from "./store"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +function renderJob(activeJob: ChatJob) { + return renderWithProviders( + , + ); +} + +function nativeSnapshot(lastSeq: number) { + return { + job_id: "job-1", + conversation_id: "default", + status: "running", + last_seq: lastSeq, + reply: "Agent before\n\nAgent after\n\nMain answer", + messages: ["Agent before", "Agent after", "Main answer"], + history_finalized: false, + current_tool_calls: [ + { + webchat_call_id: "agent", + name: "research", + is_agent: true, + status: "running", + current_stage: "thinking", + current_stage_detail: "Model detail", + elapsed_ms: 1000, + }, + ], + current_timeline: [ + { + type: "call", + seq: 1, + call: { + id: "agent", + name: "research", + status: "running", + is_agent: true, + children: [{ id: "nested", name: "search", status: "done" }], + timeline: [ + { type: "message", seq: 2, content: "Agent before" }, + { + type: "call", + seq: 3, + call: { id: "nested", name: "search", status: "done" }, + }, + { type: "message", seq: 4, content: "Agent after" }, + ], + }, + }, + { type: "message", seq: 5, content: "Main answer" }, + ], + }; +} + +describe("job snapshot recovery", () => { + beforeEach(() => vi.resetAllMocks()); + + test("keeps a legacy snapshot prefix when the next SSE message arrives", async () => { + const active = job({ lastSeq: 6, reply: "Saved prefix" }); + const store = createChatStore({ + client: runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + }), + }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + event({ seq: 7, event: "message", payload: { content: "Next message" } }), + ]); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect(screen.getByText("Saved prefix")).toBeTruthy(); + expect(screen.getByText("Next message")).toBeTruthy(); + }); + + test("cold native snapshots preserve completed tools and nested message order", async () => { + vi.mocked(invoke).mockResolvedValue({ + status: 200, + ok: true, + body: { job: nativeSnapshot(6), jobs: [nativeSnapshot(6)] }, + }); + const native = createTauriRuntimeClient(); + const store = createChatStore({ + client: runtimeClientStub({ getActiveJobs: native.getActiveJobs }), + }); + await store.bootstrap(); + const active = store.getSnapshot().activeJobsByConversation.default; + expect(active.currentToolCalls[0]?.children?.[0]?.status).toBe("done"); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(6); + renderJob(active); + const agent = screen.getByText("research").closest("details"); + expect(agent).not.toBeNull(); + expect(within(agent as HTMLElement).getByText("Agent before")).toBeTruthy(); + expect(within(agent as HTMLElement).getByText("Agent after")).toBeTruthy(); + expect(screen.getAllByText("Agent before")).toHaveLength(1); + expect(screen.getByText(/Model detail/)).toBeTruthy(); + const before = screen.getByText("Agent before"), + search = screen.getByText("search"), + after = screen.getByText("Agent after"); + expect( + before.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + search.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(agent?.contains(screen.getByText("Main answer"))).toBe(false); + }); + + test.each([false, true])( + "resume advances the cursor with its accepted snapshot (legacy: %s)", + async (legacy) => { + const native = createTauriRuntimeClient(); + let snapshot = nativeSnapshot(6); + vi.mocked(invoke).mockImplementation(async () => { + const raw: Record = { ...snapshot }; + if (legacy) delete raw.current_timeline; + return { status: 200, ok: true, body: { job: raw, jobs: [raw] } }; + }); + const client = runtimeClientStub({ + getActiveJobs: native.getActiveJobs, + fetchJobEventsJson: native.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + snapshot = { + ...nativeSnapshot(7), + reply: + "Agent before\n\nAgent after\n\nMain answer\n\nSnapshot addition", + messages: [...nativeSnapshot(7).messages, "Snapshot addition"], + current_timeline: [ + ...nativeSnapshot(7).current_timeline, + { type: "message", seq: 7, content: "Snapshot addition" }, + ], + }; + await store.bootstrap({ resuming: true }); + expect(client.startJobEventStream).toHaveBeenLastCalledWith({ + jobId: "job-1", + conversationId: "default", + afterSeq: 7, + }); + store.applyRuntimeEvents("job-1", [ + event({ + seq: 7, + event: "message", + payload: { content: "Snapshot addition" }, + }), + ]); + expect( + store + .getSnapshot() + .activeJobsByConversation.default.reply.match(/Snapshot addition/g), + ).toHaveLength(1); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect(screen.getAllByText(/Snapshot addition/)).toHaveLength(1); + }, + ); + + test("streamed agent messages stay in their parent without a duplicated reply fallback", async () => { + const active = job(); + const store = createChatStore({ + client: runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + }), + }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + event({ + seq: 1, + event: "agent_start", + payload: { webchat_call_id: "agent", name: "research", is_agent: true }, + }), + event({ + seq: 2, + event: "message", + payload: { + content: "Only inside agent", + parent_webchat_call_id: "agent", + }, + }), + ]); + renderJob(store.getSnapshot().activeJobsByConversation.default); + const agent = screen.getByText("research").closest("details"); + expect( + within(agent as HTMLElement).getByText("Only inside agent"), + ).toBeTruthy(); + expect(screen.getAllByText("Only inside agent")).toHaveLength(1); + }); + + test("nested streamed agents retain message/call order and do not leak a fallback at the root", async () => { + const active = job(); + const store = createChatStore({ + client: runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + }), + }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + event({ + seq: 1, + event: "agent_start", + payload: { webchat_call_id: "outer", name: "outer", is_agent: true }, + }), + event({ + seq: 2, + event: "agent_start", + payload: { + webchat_call_id: "inner", + name: "inner", + is_agent: true, + parent_webchat_call_id: "outer", + }, + }), + event({ + seq: 3, + event: "message", + payload: { content: "Before search", parent_webchat_call_id: "inner" }, + }), + event({ + seq: 4, + event: "tool_start", + payload: { + webchat_call_id: "search", + name: "search", + parent_webchat_call_id: "inner", + }, + }), + event({ + seq: 5, + event: "tool_end", + payload: { + webchat_call_id: "search", + name: "search", + parent_webchat_call_id: "inner", + ok: true, + }, + }), + event({ + seq: 6, + event: "message", + payload: { content: "After search", parent_webchat_call_id: "inner" }, + }), + ]); + renderJob(store.getSnapshot().activeJobsByConversation.default); + const inner = screen.getByText("inner").closest("details"); + expect(inner?.contains(screen.getByText("Before search"))).toBe(true); + expect(inner?.contains(screen.getByText("After search"))).toBe(true); + expect(screen.getAllByText("Before search")).toHaveLength(1); + expect(screen.getAllByText("search")).toHaveLength(1); + expect( + screen + .getByText("Before search") + .compareDocumentPosition(screen.getByText("search")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + screen + .getByText("search") + .compareDocumentPosition(screen.getByText("After search")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + test("JSON recovery applies the complete snapshot and rejects its duplicated event", async () => { + vi.mocked(invoke).mockResolvedValue({ + status: 200, + ok: true, + body: { + job: nativeSnapshot(6), + events: [ + { seq: 5, event: "message", payload: { content: "Main answer" } }, + ], + }, + }); + const native = createTauriRuntimeClient(); + const active = job(); + const store = createChatStore({ + client: runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + fetchJobEventsJson: native.fetchJobEventsJson, + }), + }); + await store.bootstrap(); + store.handleRuntimeStatus({ + jobId: "job-1", + subscriptionId: "sub-1", + status: "error", + detail: "disconnected", + }); + await vi.waitFor(() => + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(6), + ); + store.applyRuntimeEvents("job-1", [ + event({ seq: 5, event: "message", payload: { content: "Main answer" } }), + ]); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect(screen.getAllByText("Main answer")).toHaveLength(1); + expect(screen.getAllByText("Agent before")).toHaveLength(1); + store.suspendRuntime(); + }); + + test("legacy JSON snapshots recover message gaps outside the replay buffer", async () => { + const active = job({ lastSeq: 6, reply: "Prefix", messages: ["Prefix"] }); + const store = createChatStore({ + client: runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + fetchJobEventsJson: vi.fn(async () => ({ + job: job({ + lastSeq: 9, + reply: "Prefix\n\nMissing buffered message\n\nLatest", + messages: ["Prefix", "Missing buffered message", "Latest"], + }), + events: [ + event({ seq: 9, event: "message", payload: { content: "Latest" } }), + ], + after: 6, + lastSeq: 9, + })), + }), + }); + await store.bootstrap(); + store.handleRuntimeStatus({ + jobId: "job-1", + subscriptionId: "sub-1", + status: "error", + detail: "disconnected", + }); + await vi.waitFor(() => + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(9), + ); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect(screen.getByText("Prefix")).toBeTruthy(); + expect(screen.getByText("Missing buffered message")).toBeTruthy(); + expect(screen.getAllByText("Latest")).toHaveLength(1); + store.suspendRuntime(); + }); + + test("a late bootstrap snapshot cannot roll back events consumed while it was loading", async () => { + const active = job({ lastSeq: 6, reply: "Prefix" }); + let resolveSnapshot!: (value: { job: ChatJob; jobs: ChatJob[] }) => void; + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + vi.mocked(client.getActiveJobs).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ); + const bootstrap = store.bootstrap({ resuming: true }); + await vi.waitFor(() => expect(resolveSnapshot).toBeDefined()); + store.applyRuntimeEvents("job-1", [ + event({ seq: 8, event: "message", payload: { content: "Newer event" } }), + ]); + resolveSnapshot({ job: active, jobs: [active] }); + await bootstrap; + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(8); + expect(client.startJobEventStream).toHaveBeenLastCalledWith({ + jobId: "job-1", + conversationId: "default", + afterSeq: 8, + }); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect(screen.getByText("Newer event")).toBeTruthy(); + }); + + test("a tool recovered inside a snapshot uses its later streamed completion", async () => { + const snapshot = nativeSnapshot(6); + const agent = snapshot.current_timeline[0].call; + if (!agent) throw new Error("fixture must contain the agent"); + agent.children[0].status = "running"; + const child = agent.timeline[1].call; + if (!child) throw new Error("fixture must contain the child"); + child.status = "running"; + vi.mocked(invoke).mockResolvedValue({ + status: 200, + ok: true, + body: { job: snapshot, jobs: [snapshot] }, + }); + const native = createTauriRuntimeClient(); + const store = createChatStore({ + client: runtimeClientStub({ getActiveJobs: native.getActiveJobs }), + }); + await store.bootstrap(); + store.applyRuntimeEvents("job-1", [ + event({ + seq: 7, + event: "tool_end", + payload: { + webchat_call_id: "nested", + parent_webchat_call_id: "agent", + name: "search", + ok: true, + result_preview: "Latest completed result", + }, + }), + ]); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect( + screen.getByText("search").closest("details")?.classList.contains("done"), + ).toBe(true); + expect(screen.getByText("Latest completed result")).toBeTruthy(); + expect(screen.getAllByText("search")).toHaveLength(1); + }); + + test.each([false, true])( + "an in-flight JSON snapshot cannot overwrite later SSE (complete timeline: %s)", + async (complete) => { + let resolveSnapshot!: (value: unknown) => void; + vi.mocked(invoke).mockImplementation( + () => + new Promise((resolve) => { + resolveSnapshot = resolve; + }), + ); + const native = createTauriRuntimeClient(); + const active = job(); + const store = createChatStore({ + client: runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + fetchJobEventsJson: native.fetchJobEventsJson, + }), + }); + await store.bootstrap(); + store.handleRuntimeStatus({ + jobId: "job-1", + subscriptionId: "sub-1", + status: "error", + detail: "disconnected", + }); + store.applyRuntimeEvents("job-1", [ + event({ + seq: 8, + event: "message", + payload: { content: "Newer SSE message" }, + }), + ]); + const latest = store.getSnapshot().activeJobsByConversation.default; + const snapshot: Record = nativeSnapshot(6); + if (!complete) delete snapshot.current_timeline; + resolveSnapshot({ + status: 200, + ok: true, + body: { job: snapshot, events: [] }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(store.getSnapshot().activeJobsByConversation.default).toEqual( + latest, + ); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(8); + store.suspendRuntime(); + }, + ); + + test.each(["running", "done", "error", "cancelled"])( + "legacy resume replays same-body tool and Agent changes before advancing the cursor (%s)", + async (status) => { + let seq = 1; + const rawSnapshot = () => ({ + job_id: "job-1", + conversation_id: "default", + status: "running", + last_seq: seq, + reply: "Same body", + messages: ["Same body"], + current_tool_calls: [], + history_finalized: false, + }); + const replay = [ + { + seq: 2, + event: "agent_start", + payload: { + webchat_call_id: "agent", + name: "research", + is_agent: true, + }, + }, + { + seq: 3, + event: "agent_stage", + payload: { + webchat_call_id: "agent", + agent_name: "research", + stage: "thinking", + detail: "Newest stage detail", + }, + }, + { + seq: 4, + event: "tool_start", + payload: { + webchat_call_id: "nested", + parent_webchat_call_id: "agent", + name: "search", + }, + }, + ...(status === "running" + ? [] + : [ + { + seq: 5, + event: "tool_end", + payload: { + webchat_call_id: "nested", + parent_webchat_call_id: "agent", + name: "search", + status, + ok: status !== "error", + result_preview: "Completed search", + }, + }, + ]), + { + seq: 6, + event: "tool_start", + payload: { webchat_call_id: "pending", name: "pending" }, + }, + ]; + vi.mocked(invoke).mockImplementation(async (command) => ({ + status: 200, + ok: true, + body: + command === "get_active_jobs" + ? { job: rawSnapshot(), jobs: [rawSnapshot()] } + : { job: rawSnapshot(), events: replay, after: 1, last_seq: seq }, + })); + const native = createTauriRuntimeClient(); + const client = runtimeClientStub({ + getActiveJobs: native.getActiveJobs, + fetchJobEventsJson: native.fetchJobEventsJson, + }); + const store = createChatStore({ client }); + await store.bootstrap(); + seq = 6; + await store.bootstrap({ resuming: true }); + const active = store.getSnapshot().activeJobsByConversation.default; + expect(active.reply).toBe("Same body"); + expect( + active.currentToolCalls.find((call) => call.id === "agent"), + ).toMatchObject({ + currentStage: "thinking", + detail: "Newest stage detail", + children: [{ id: "nested", status }], + }); + expect( + active.currentToolCalls.some((call) => call.id === "pending"), + ).toBe(true); + expect(invoke).toHaveBeenCalledWith("fetch_job_events_json", { + input: { jobId: "job-1", conversationId: "default", afterSeq: 1 }, + }); + expect(client.startJobEventStream).toHaveBeenLastCalledWith({ + jobId: "job-1", + conversationId: "default", + afterSeq: 6, + }); + store.applyRuntimeEvents( + "job-1", + replay.map((item) => event(item)), + ); + renderJob(store.getSnapshot().activeJobsByConversation.default); + expect(screen.getAllByText("search")).toHaveLength(1); + expect( + screen + .getByText("search") + .closest("details") + ?.classList.contains(status), + ).toBe(true); + store.suspendRuntime(); + }, + ); + + test("a failed legacy resume replay keeps the old cursor available for SSE recovery", async () => { + let active = job({ + lastSeq: 1, + reply: "Same body", + messages: ["Same body"], + }); + const client = runtimeClientStub({ + getActiveJobs: vi.fn(async () => ({ job: active, jobs: [active] })), + fetchJobEventsJson: vi.fn(async () => { + throw new Error("offline"); + }), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + active = { ...active, lastSeq: 4 }; + await store.bootstrap({ resuming: true }); + expect(store.getSnapshot().eventCursorByJob["job-1"]).toBe(1); + expect(client.startJobEventStream).toHaveBeenLastCalledWith({ + jobId: "job-1", + conversationId: "default", + afterSeq: 1, + }); + store.applyRuntimeEvents("job-1", [ + event({ + seq: 2, + event: "tool_start", + payload: { webchat_call_id: "recovered", name: "recovered" }, + }), + ]); + expect( + store.getSnapshot().activeJobsByConversation.default.currentToolCalls[0] + ?.name, + ).toBe("recovered"); + store.suspendRuntime(); + }); +}); diff --git a/apps/undefined-chat/src/chat-store/store.test.ts b/apps/undefined-chat/src/chat-store/store.test.ts index aeb4f08a..9e2f804e 100644 --- a/apps/undefined-chat/src/chat-store/store.test.ts +++ b/apps/undefined-chat/src/chat-store/store.test.ts @@ -383,6 +383,7 @@ describe("chat store", () => { jobId: "job-1", conversationId: "default", status: "done", + historyFinalized: true, lastSeq: 2, }); const client = runtimeClientStub({ @@ -455,6 +456,7 @@ describe("chat store", () => { jobId: "job-1", conversationId: "default", status: "cancelled", + historyFinalized: true, lastSeq: 4, }); const client = runtimeClientStub({ diff --git a/apps/undefined-chat/src/chat-store/store.ts b/apps/undefined-chat/src/chat-store/store.ts index 6202eebc..eae2ee0a 100644 --- a/apps/undefined-chat/src/chat-store/store.ts +++ b/apps/undefined-chat/src/chat-store/store.ts @@ -1,6 +1,7 @@ import type { HistoryItem, RuntimeClient, + RuntimeConfig, RuntimeSseEvent, RuntimeSseStatus, SendMessageInput, @@ -26,9 +27,12 @@ export type { export type ChatStore = { bootstrap: (opts?: { + runtimeUrl?: string; preserveSelectionId?: string; resuming?: boolean; + resumeSuspended?: boolean; }) => Promise; + suspendRuntime: (runtimeConfig?: RuntimeConfig | null) => void; refreshCommandsIfStale: () => Promise; createConversation: (title?: string) => Promise; deleteConversation: (conversationId: string) => Promise; @@ -168,10 +172,38 @@ function withoutConversationJob( export function isJobRunning(job: ChatJob | null | undefined): boolean { return Boolean( - job && !["done", "error", "cancelled"].includes(job.status.toLowerCase()), + job && + (!["done", "error", "cancelled"].includes(job.status.toLowerCase()) || + !job.historyFinalized), ); } +function acceptJobSnapshot(job: ChatJob, previous?: ChatJob): ChatJob { + if (job.hasTimelineSnapshot || job.currentTimeline.length > 0) return job; + const sameJob = previous?.jobId === job.jobId ? previous : undefined; + if (sameJob && job.reply === sameJob.reply) { + return { + ...job, + currentTimeline: sameJob.currentTimeline, + currentToolCalls: sameJob.currentToolCalls, + }; + } + // 旧 Runtime 没有 timeline,先保存完整正文前缀,再续接快照之后的事件。 + return { + ...job, + currentTimeline: [ + ...(job.reply + ? [{ type: "message" as const, seq: job.lastSeq, content: job.reply }] + : []), + ...job.currentToolCalls.map((call) => ({ + type: "call" as const, + seq: job.lastSeq, + callId: call.id, + })), + ], + }; +} + export function chatReducer(state: ChatState, action: ChatAction): ChatState { switch (action.type) { case "connection/set": @@ -184,12 +216,19 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { error: null, }; case "bootstrap/success": { - const activeJobs = activeJobsByConversation(action.activeJobs); + const snapshots = action.activeJobs.map((job) => { + const previous = state.activeJobsByConversation[job.conversationId]; + // 订阅仍可能在 bootstrap 请求期间前进;更旧快照不能倒退已展示事件。 + if (previous?.jobId === job.jobId && previous.lastSeq > job.lastSeq) + return previous; + return acceptJobSnapshot(job, previous); + }); + const activeJobs = activeJobsByConversation(snapshots); return { ...state, bootstrapping: false, connectionState: - action.activeJobs.length > 0 ? "streaming" : "connected", + Object.keys(activeJobs).length > 0 ? "streaming" : "connected", runtimeConfig: action.runtimeConfig, health: action.health, conversations: action.conversations.map((item) => ({ @@ -198,7 +237,13 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { })), selectedConversationId: action.selectedConversationId, activeJobsByConversation: activeJobs, - jobConversationById: jobConversationById(action.activeJobs), + jobConversationById: jobConversationById(snapshots), + eventCursorByJob: { + ...state.eventCursorByJob, + ...Object.fromEntries( + snapshots.map((job) => [job.jobId, job.lastSeq]), + ), + }, commands: action.commands, error: null, }; @@ -295,8 +340,14 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { item.id === action.conversation.id ? action.conversation : item, ) : [action.conversation, ...state.conversations], - selectedConversationId: action.conversation.id, - settings: { ...state.settings, mobilePanel: "chat" }, + selectedConversationId: + action.select === false + ? state.selectedConversationId + : action.conversation.id, + settings: + action.select === false + ? state.settings + : { ...state.settings, mobilePanel: "chat" }, }; } case "conversation/creating": @@ -342,6 +393,7 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { return { ...state, sendError: action.error }; case "commands/set": return { ...state, commands: action.commands }; + case "job/snapshot": case "job/upsert": { const activeJobs = { ...state.activeJobsByConversation }; const jobConversations = { @@ -358,6 +410,16 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { ...state, activeJobsByConversation: activeJobs, jobConversationById: jobConversations, + eventCursorByJob: + action.type === "job/snapshot" + ? { + ...state.eventCursorByJob, + [action.job.jobId]: Math.max( + state.eventCursorByJob[action.job.jobId] ?? 0, + action.job.lastSeq, + ), + } + : state.eventCursorByJob, conversations: state.conversations.map((item) => item.id === action.job.conversationId ? { ...item, isRunning: isJobRunning(action.job) } @@ -620,6 +682,23 @@ function chatEventFromRuntimeEvent(event: RuntimeSseEvent): ChatEvent { * 对齐 WebUI upsertTimelineToolBlock 的 key 策略(webchat_call_id || tool_call_id)。 * 有 parentCallId 时在匹配父块的 children 里递归 upsert;否则在顶层 upsert。 */ +function updateToolCall( + list: ToolCallSnapshot[], + callId: string, + patch: (call: ToolCallSnapshot) => ToolCallSnapshot, +): ToolCallSnapshot[] { + return list.map((call) => + call.id === callId + ? patch(call) + : { + ...call, + children: call.children + ? updateToolCall(call.children, callId, patch) + : undefined, + }, + ); +} + function upsertToolCall( list: ToolCallSnapshot[], callId: string, @@ -667,11 +746,21 @@ export function createChatStore({ client: RuntimeClient; }): ChatStore { let state = createInitialChatState(); + let runtimeUrl: string | null = null; + let runtimeGeneration = 0; + let runtimeSuspended = false; + const draftRevisionsByConversation = new Map(); + let draftRevision = 0; + const historyRequestsByConversation = new Map(); + let historyRequestGeneration = 0; const listeners = new Set<() => void>(); const subscriptionsByJob = new Map(); + const streamRequestsByJob = new Map(); const fallbackTimersByJob = new Map>(); /** 每个 job 当前的 JSON 轮询退避间隔(ms),随连续失败指数增长、成功后重置 */ const fallbackBackoffByJob = new Map(); + const latestFallbackRequestByJob = new Map(); + let fallbackRequestGeneration = 0; let unlistenRuntimeSse: (() => void) | null = null; /** bootstrap 重入保护:已在进行中的 bootstrap promise,避免并发重入导致 SSE 监听泄漏 */ let inFlightBootstrap: Promise | null = null; @@ -687,6 +776,19 @@ export function createChatStore({ } function dispatch(action: ChatAction): void { + if ( + action.type === "history/set" || + action.type === "history/error" || + action.type === "message/optimisticUser" || + action.type === "conversation/remove" + ) { + historyRequestsByConversation.delete(action.conversationId); + } + if (action.type === "draft/set") { + draftRevisionsByConversation.set(action.conversationId, ++draftRevision); + } else if (action.type === "conversation/remove") { + draftRevisionsByConversation.delete(action.conversationId); + } state = chatReducer(state, action); // 自动滚动偏好在状态更新后持久化(reducer 保持纯函数,副作用集中在此) if (action.type === "autoScroll/set") { @@ -702,19 +804,78 @@ export function createChatStore({ void client.stopJobEventStream(subscriptionId); } subscriptionsByJob.clear(); + streamRequestsByJob.clear(); for (const timer of fallbackTimersByJob.values()) { clearTimeout(timer); } fallbackTimersByJob.clear(); + latestFallbackRequestByJob.clear(); + fallbackBackoffByJob.clear(); + } + + function resetRuntimeScope(nextUrl: string): void { + runtimeUrl = nextUrl; + runtimeGeneration += 1; + clearNativeSubscriptions(); + historyRequestsByConversation.clear(); + sendInFlight = false; + lastCommandsFetchedAt = 0; + draftRevisionsByConversation.clear(); + state = { + ...createInitialChatState(), + autoScrollEnabled: state.autoScrollEnabled, + platform: state.platform, + settings: state.settings, + }; + } + + function suspendRuntime(runtimeConfig = state.runtimeConfig): void { + if (runtimeConfig?.runtimeUrl && runtimeConfig.runtimeUrl !== runtimeUrl) { + resetRuntimeScope(runtimeConfig.runtimeUrl); + } else { + runtimeGeneration += 1; + clearNativeSubscriptions(); + sendInFlight = false; + } + runtimeSuspended = true; + historyRequestsByConversation.clear(); + inFlightBootstrap = null; + state = { + ...state, + runtimeConfig, + bootstrapping: false, + connectionState: "disconnected", + attachmentsByConversation: Object.fromEntries( + Object.entries(state.attachmentsByConversation).map( + ([conversationId, attachments]) => [ + conversationId, + attachments.map((item) => + ["queued", "uploading"].includes(item.status) + ? { + ...item, + status: "error" as const, + error: "error.attachmentsFailed", + } + : item, + ), + ], + ), + ), + }; + emit(); } async function loadHistory(conversationId: string): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; + const request = beginHistoryRequest(conversationId); dispatch({ type: "history/loading", conversationId }); try { const history = await client.getHistory({ conversationId, limit: HISTORY_LIMIT, }); + if (!isCurrentHistoryRequest(conversationId, generation, request)) return; dispatch({ type: "history/set", conversationId, @@ -724,6 +885,7 @@ export function createChatStore({ total: history.total, }); } catch (err) { + if (!isCurrentHistoryRequest(conversationId, generation, request)) return; // 单会话历史加载失败降级为会话级错误,不升级为全局致命错误 dispatch({ type: "history/error", @@ -733,17 +895,61 @@ export function createChatStore({ } } + function beginHistoryRequest(conversationId: string): number { + const request = ++historyRequestGeneration; + historyRequestsByConversation.set(conversationId, request); + return request; + } + + function isCurrentHistoryRequest( + conversationId: string, + generation: number, + request: number, + ): boolean { + return ( + generation === runtimeGeneration && + historyRequestsByConversation.get(conversationId) === request + ); + } + async function startStreamForJob(job: ChatJob): Promise { - if (subscriptionsByJob.has(job.jobId)) { + if (runtimeSuspended) return; + const generation = runtimeGeneration; + if ( + subscriptionsByJob.has(job.jobId) || + streamRequestsByJob.has(job.jobId) + ) { return; } - const subscription = await client.startJobEventStream({ - jobId: job.jobId, - afterSeq: state.eventCursorByJob[job.jobId] ?? job.lastSeq, - conversationId: job.conversationId, - }); - if (subscription.subscriptionId) { - subscriptionsByJob.set(job.jobId, subscription.subscriptionId); + const request = Symbol(); + streamRequestsByJob.set(job.jobId, request); + const isCurrentRequest = (): boolean => + generation === runtimeGeneration && + streamRequestsByJob.get(job.jobId) === request && + state.activeJobsByConversation[job.conversationId]?.jobId === job.jobId; + try { + const subscription = await client.startJobEventStream({ + jobId: job.jobId, + afterSeq: Math.max(state.eventCursorByJob[job.jobId] ?? 0, job.lastSeq), + conversationId: job.conversationId, + }); + if (!isCurrentRequest()) { + if (subscription.subscriptionId) { + void client.stopJobEventStream(subscription.subscriptionId); + } + return; + } + if (subscription.subscriptionId) { + subscriptionsByJob.set(job.jobId, subscription.subscriptionId); + } + } catch { + if (!isCurrentRequest()) return; + // job 已建立;订阅失败只切换传输方式,不能回滚成功发送的输入。 + fetchJobEventsFallback(job.jobId); + } finally { + if (streamRequestsByJob.get(job.jobId) === request) { + streamRequestsByJob.delete(job.jobId); + } } } @@ -754,6 +960,26 @@ export function createChatStore({ fallbackTimersByJob.delete(jobId); } fallbackBackoffByJob.delete(jobId); + latestFallbackRequestByJob.delete(jobId); + } + + function finishJob(jobId: string, conversationId: string): void { + const active = state.activeJobsByConversation[conversationId]; + if (active && active.jobId !== jobId) { + return; + } + stopFallbackPolling(jobId); + streamRequestsByJob.delete(jobId); + const subscriptionId = subscriptionsByJob.get(jobId); + if (subscriptionId) { + subscriptionsByJob.delete(jobId); + void client.stopJobEventStream(subscriptionId); + } + dispatch({ type: "job/remove", conversationId, jobId }); + if (Object.keys(state.activeJobsByConversation).length === 0) { + dispatch({ type: "connection/set", connectionState: "connected" }); + } + void loadHistory(conversationId); } /** 计算并推进某个 job 的下一次轮询退避间隔(指数增长,封顶 JSON_FALLBACK_MAX_MS) */ @@ -771,57 +997,100 @@ export function createChatStore({ if (fallbackTimersByJob.has(jobId)) { return; } + const generation = runtimeGeneration; const timer = setTimeout(() => { + if (generation !== runtimeGeneration) return; fallbackTimersByJob.delete(jobId); fetchJobEventsFallback(jobId); }, delayMs ?? JSON_FALLBACK_BASE_MS); fallbackTimersByJob.set(jobId, timer); } - function fetchJobEventsFallback(jobId: string): void { + async function fetchJobEventsFallback( + jobId: string, + ): Promise { + const generation = runtimeGeneration; + const conversationId = state.jobConversationById[jobId]; + if ( + !conversationId || + state.activeJobsByConversation[conversationId]?.jobId !== jobId + ) { + return null; + } + const requestGeneration = ++fallbackRequestGeneration; + latestFallbackRequestByJob.set(jobId, requestGeneration); + const isCurrentRequest = (): boolean => + generation === runtimeGeneration && + latestFallbackRequestByJob.get(jobId) === requestGeneration && + state.activeJobsByConversation[conversationId]?.jobId === jobId; const timer = fallbackTimersByJob.get(jobId); if (timer) { clearTimeout(timer); fallbackTimersByJob.delete(jobId); } dispatch({ type: "connection/set", connectionState: "json_fallback" }); - void client + return client .fetchJobEventsJson({ jobId, afterSeq: state.eventCursorByJob[jobId] ?? 0, - conversationId: state.jobConversationById[jobId], + conversationId, }) .then((response) => { + if (!isCurrentRequest()) return null; + const active = state.activeJobsByConversation[conversationId]; + const consumedSeq = Math.max( + state.eventCursorByJob[jobId] ?? 0, + active?.lastSeq ?? 0, + ); + if (response.job.lastSeq < consumedSeq) { + // SSE 可能在请求途中推进;旧快照不能回退正文或阶段,继续拉取即可。 + scheduleFallbackPolling(jobId); + return active ?? null; + } // 拉取成功:重置退避间隔 fallbackBackoffByJob.delete(jobId); - dispatch({ type: "job/upsert", job: response.job }); - applyRuntimeEvents(jobId, response.events); - const hasTerminalEvent = response.events.some((item) => - TERMINAL_EVENTS.has(item.event), - ); + if (!response.job.hasTimelineSnapshot) { + // 旧服务仅提供 reply;先回放增量以保留已有调用树。 + applyRuntimeEvents(jobId, response.events); + } + const replayedJob = + state.activeJobsByConversation[response.job.conversationId]; + const replayComplete = + !response.job.messages.length || + JSON.stringify(response.job.messages) === + JSON.stringify(replayedJob?.messages); + const snapshot = response.job.hasTimelineSnapshot + ? response.job + : acceptJobSnapshot({ + ...response.job, + currentTimeline: replayComplete + ? (replayedJob?.currentTimeline ?? []) + : [], + currentToolCalls: + replayedJob?.currentToolCalls ?? response.job.currentToolCalls, + }); + dispatch({ type: "job/snapshot", job: snapshot }); + if (isJobRunning(response.job)) { scheduleFallbackPolling(jobId, JSON_FALLBACK_BASE_MS); - } else if (!hasTerminalEvent && response.job.conversationId) { - void loadHistory(response.job.conversationId); - dispatch({ type: "connection/set", connectionState: "connected" }); + } else if (response.job.conversationId) { + finishJob(jobId, response.job.conversationId); } + return snapshot; }) .catch(() => { + if (!isCurrentRequest()) return null; // 单次失败不直接放弃:仅当该 job 仍是活跃任务时按指数退避继续重试, // 否则停止轮询。连接状态保持 json_fallback(仍在尝试),而非直接 disconnected。 - const conversationId = state.jobConversationById[jobId]; const stillActive = - conversationId != null && state.activeJobsByConversation[conversationId]?.jobId === jobId; if (stillActive) { scheduleFallbackPolling(jobId, nextFallbackDelay(jobId)); } else { stopFallbackPolling(jobId); - dispatch({ - type: "connection/set", - connectionState: "disconnected", - }); + dispatch({ type: "connection/set", connectionState: "disconnected" }); } + return null; }); } @@ -829,13 +1098,24 @@ export function createChatStore({ preserveSelectionId?: string, resuming = false, ): Promise { + let generation = runtimeGeneration; + let runtimeConfig: RuntimeConfig | null = null; dispatch({ type: "bootstrap/start" }); // Android 切后台返回等续接场景:标记为 resuming,连接成功后由后续状态覆盖 if (resuming) { dispatch({ type: "connection/set", connectionState: "resuming" }); } try { - const runtimeConfig = await client.getRuntimeConfig(); + runtimeConfig = await client.getRuntimeConfig(); + if (generation !== runtimeGeneration) return; + if ( + runtimeConfig?.runtimeUrl && + runtimeConfig.runtimeUrl !== runtimeUrl + ) { + resetRuntimeScope(runtimeConfig.runtimeUrl); + generation = runtimeGeneration; + dispatch({ type: "bootstrap/start" }); + } if (!runtimeConfig?.runtimeUrl || !runtimeConfig.hasApiKey) { dispatch({ type: "bootstrap/error", @@ -845,6 +1125,9 @@ export function createChatStore({ return; } + const jobsBeforeRefresh = new Set( + Object.values(state.activeJobsByConversation).map((job) => job.jobId), + ); const [health, conversationsResponse, activeJobs, commands] = await Promise.all([ client.probeRuntime(), @@ -852,7 +1135,9 @@ export function createChatStore({ client.getActiveJobs(), client.listCommands(), ]); + if (generation !== runtimeGeneration) return; lastCommandsFetchedAt = Date.now(); + runtimeSuspended = false; // 仅选取真实存在于会话列表中的会话:defaultConversationId 是后端常量 // (legacy-system-42),未必对应已存在的会话,盲目用它会触发 getHistory 404 const conversationIds = new Set( @@ -871,38 +1156,117 @@ export function createChatStore({ conversationsResponse.conversations[0]?.id || activeJobs.jobs[0]?.conversationId || ""; + const recoveredJobs: ChatJob[] = []; + for (const snapshot of activeJobs.jobs) { + const previous = + state.activeJobsByConversation[snapshot.conversationId]; + if ( + !snapshot.hasTimelineSnapshot && + previous?.jobId === snapshot.jobId && + snapshot.lastSeq > previous.lastSeq + ) { + // 旧 API 不含完整调用树;推进游标前用同一 JSON 回放补齐后台工具/阶段事件。 + const recovered = await fetchJobEventsFallback(snapshot.jobId); + if (generation !== runtimeGeneration) return; + recoveredJobs.push( + recovered ?? + state.activeJobsByConversation[snapshot.conversationId] ?? + previous, + ); + } else { + recoveredJobs.push(snapshot); + } + } + // 快照请求期间,先前发出的发送请求可能刚获 ACK;旧列表不能删除新任务。 + // 请求开始前已存在的任务仍以服务端列表为准,允许回收离线时已完成的任务。 + for (const current of Object.values(state.activeJobsByConversation)) { + if (jobsBeforeRefresh.has(current.jobId)) continue; + const index = recoveredJobs.findIndex( + (snapshot) => snapshot.conversationId === current.conversationId, + ); + if (index < 0) recoveredJobs.push(current); + else if (recoveredJobs[index].jobId !== current.jobId) + recoveredJobs[index] = current; + } + const historyConversationIds = new Set(selected ? [selected] : []); + const recoveredJobIds = new Set(recoveredJobs.map((item) => item.jobId)); + // 已落盘终态会从 active 列表消失;补刷仍存在的非当前会话缓存。 + for (const previous of Object.values(state.activeJobsByConversation)) { + if ( + conversationIds.has(previous.conversationId) && + !recoveredJobIds.has(previous.jobId) + ) { + historyConversationIds.add(previous.conversationId); + } + } + // 配置暂停可能发生在 finishJob 已移除任务、历史刷新尚未返回时。 + for (const [conversationId, history] of Object.entries( + state.historyByConversation, + )) { + if ( + history.loading && + !historyRequestsByConversation.has(conversationId) && + conversationIds.has(conversationId) + ) { + historyConversationIds.add(conversationId); + } + } dispatch({ type: "bootstrap/success", runtimeConfig, health, conversations: conversationsResponse.conversations, selectedConversationId: selected, - activeJobs: activeJobs.jobs, + activeJobs: recoveredJobs, commands: commands.commands, }); clearNativeSubscriptions(); - unlistenRuntimeSse = await client.listenRuntimeSse( - handleRuntimeEvent, - handleRuntimeStatus, + const unlisten = await client.listenRuntimeSse( + (event) => { + if (generation === runtimeGeneration) handleRuntimeEvent(event); + }, + (status) => { + if (generation === runtimeGeneration) handleRuntimeStatus(status); + }, ); - for (const activeJob of activeJobs.jobs) { - await startStreamForJob(activeJob); + if (generation !== runtimeGeneration) { + unlisten(); + return; } - if (selected) { - await loadHistory(selected); + unlistenRuntimeSse = unlisten; + for (const activeJob of Object.values(state.activeJobsByConversation)) { + if (generation !== runtimeGeneration) return; + await startStreamForJob(activeJob); } - if (activeJobs.jobs.length > 0) { + await Promise.all([...historyConversationIds].map(loadHistory)); + if (generation !== runtimeGeneration) return; + if ( + Object.keys(state.activeJobsByConversation).length > 0 && + state.connectionState !== "json_fallback" + ) { dispatch({ type: "connection/set", connectionState: "streaming" }); } } catch (err) { - dispatch({ type: "bootstrap/error", error: errorMessage(err) }); + if (generation !== runtimeGeneration) return; + dispatch({ + type: "bootstrap/error", + runtimeConfig, + error: errorMessage(err), + }); } } async function bootstrap(opts?: { + runtimeUrl?: string; preserveSelectionId?: string; resuming?: boolean; + resumeSuspended?: boolean; }): Promise { + if (runtimeSuspended && !opts?.resumeSuspended) return; + if (opts?.runtimeUrl && opts.runtimeUrl !== runtimeUrl) { + resetRuntimeScope(opts.runtimeUrl); + inFlightBootstrap = null; + } // 重入保护:App 挂载与 Android resume 可能并发触发 bootstrap, // 复用进行中的 promise,避免重复 clearNativeSubscriptions / listenRuntimeSse 造成监听泄漏 if (inFlightBootstrap) { @@ -910,7 +1274,7 @@ export function createChatStore({ } const run = runBootstrap(opts?.preserveSelectionId, opts?.resuming).finally( () => { - inFlightBootstrap = null; + if (inFlightBootstrap === run) inFlightBootstrap = null; }, ); inFlightBootstrap = run; @@ -922,6 +1286,8 @@ export function createChatStore({ * 使 Skills 热重载新增的命令可见。未连接或未配置时跳过。 */ async function refreshCommandsIfStale(): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; if (!state.runtimeConfig?.hasApiKey || state.bootstrapping) { return; } @@ -930,6 +1296,7 @@ export function createChatStore({ } try { const commands = await client.listCommands(); + if (generation !== runtimeGeneration) return; lastCommandsFetchedAt = Date.now(); dispatch({ type: "commands/set", commands: commands.commands }); } catch { @@ -938,19 +1305,27 @@ export function createChatStore({ } async function createConversation(title?: string): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; dispatch({ type: "conversation/creating", creating: true }); try { const conversation = await client.createConversation(title); + if (generation !== runtimeGeneration) return; dispatch({ type: "conversation/upsert", conversation }); await loadHistory(conversation.id); } catch (err) { + if (generation !== runtimeGeneration) return; dispatch({ type: "send/error", error: errorMessage(err) }); } finally { - dispatch({ type: "conversation/creating", creating: false }); + if (generation === runtimeGeneration) { + dispatch({ type: "conversation/creating", creating: false }); + } } } async function deleteConversation(conversationId: string): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; const wasSelected = state.selectedConversationId === conversationId; const nextSelectedId = wasSelected ? (state.conversations.find((item) => item.id !== conversationId)?.id ?? @@ -958,6 +1333,7 @@ export function createChatStore({ : state.selectedConversationId; try { await client.deleteConversation(conversationId); + if (generation !== runtimeGeneration) return; dispatch({ type: "conversation/remove", conversationId, nextSelectedId }); if ( wasSelected && @@ -967,6 +1343,7 @@ export function createChatStore({ await loadHistory(nextSelectedId); } } catch (err) { + if (generation !== runtimeGeneration) return; dispatch({ type: "send/error", error: errorMessage(err) }); } } @@ -975,8 +1352,11 @@ export function createChatStore({ conversationId: string, newTitle: string, ): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; try { await client.renameConversation(conversationId, newTitle); + if (generation !== runtimeGeneration) return; // 更新本地 state 中的会话标题 const conversation = state.conversations.find( (item) => item.id === conversationId, @@ -985,19 +1365,23 @@ export function createChatStore({ dispatch({ type: "conversation/upsert", conversation: { ...conversation, title: newTitle }, + select: false, }); } } catch (err) { + if (generation !== runtimeGeneration) return; dispatch({ type: "send/error", error: errorMessage(err) }); } } async function selectConversation(conversationId: string): Promise { + if (runtimeSuspended) return; if (state.selectedConversationId === conversationId) { return; } dispatch({ type: "conversation/select", conversationId }); - if (!state.historyByConversation[conversationId]) { + const history = state.historyByConversation[conversationId]; + if (!history || history.error) { await loadHistory(conversationId); } } @@ -1006,17 +1390,9 @@ export function createChatStore({ dispatch({ type: "draft/set", conversationId, draft }); } - function latestEventSeq(jobId: string): number { - const cursor = state.eventCursorByJob[jobId] ?? 0; - const events = state.eventsByJob[jobId] ?? []; - const lastEventSeq = events.reduce( - (maxSeq, item) => Math.max(maxSeq, item.seq), - 0, - ); - return Math.max(cursor, lastEventSeq); - } - async function sendSelectedMessage(): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; // in-flight 锁:从点击到 job 建立期间阻止重复发送, // 避免快速双击重复插入乐观消息 / 重复请求后端 if (sendInFlight) { @@ -1031,7 +1407,8 @@ export function createChatStore({ dispatch({ type: "send/error", error: "error.conversationRunning" }); return; } - const text = (state.draftsByConversation[conversationId] ?? "").trim(); + const draft = state.draftsByConversation[conversationId] ?? ""; + const text = draft.trim(); const attachments = state.attachmentsByConversation[conversationId] ?? []; if ( attachments.some((item) => ["queued", "uploading"].includes(item.status)) @@ -1074,6 +1451,10 @@ export function createChatStore({ dispatch({ type: "draft/set", conversationId, draft: "" }); dispatch({ type: "attachments/set", conversationId, attachments: [] }); dispatch({ type: "references/set", conversationId, references: [] }); + const clearedDraftRevision = + draftRevisionsByConversation.get(conversationId); + const clearedAttachments = state.attachmentsByConversation[conversationId]; + const clearedReferences = state.referencesByConversation[conversationId]; const input: SendMessageInput = { conversationId, @@ -1086,11 +1467,13 @@ export function createChatStore({ try { dispatch({ type: "send/error", error: null }); const job = await client.sendMessage(input); - dispatch({ type: "job/upsert", job }); + if (generation !== runtimeGeneration) return; + dispatch({ type: "job/snapshot", job: acceptJobSnapshot(job) }); dispatch({ type: "connection/set", connectionState: "streaming" }); await startStreamForJob(job); } catch (err) { - // 发送失败:移除乐观用户消息并恢复草稿/附件/引用 + if (generation !== runtimeGeneration) return; + // 发送失败:移除乐观消息,仅恢复发送后未再次编辑的字段。 const currentHistory = state.historyByConversation[conversationId]; if (currentHistory) { dispatch({ @@ -1104,31 +1487,61 @@ export function createChatStore({ total: Math.max(0, currentHistory.total - 1), }); } - dispatch({ type: "draft/set", conversationId, draft: text }); - dispatch({ type: "attachments/set", conversationId, attachments }); - dispatch({ type: "references/set", conversationId, references }); + if ( + draftRevisionsByConversation.get(conversationId) === + clearedDraftRevision + ) { + dispatch({ type: "draft/set", conversationId, draft }); + } + if ( + state.attachmentsByConversation[conversationId] === clearedAttachments + ) { + dispatch({ type: "attachments/set", conversationId, attachments }); + } + if ( + state.referencesByConversation[conversationId] === clearedReferences + ) { + dispatch({ type: "references/set", conversationId, references }); + } dispatch({ type: "send/error", error: errorMessage(err) }); } finally { - sendInFlight = false; + if (generation === runtimeGeneration) sendInFlight = false; } } async function cancelJob(jobId: string): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; const conversationId = state.jobConversationById[jobId]; + if (!conversationId) { + return; + } try { const cancelled = await client.cancelJob(jobId); - dispatch({ type: "job/upsert", job: cancelled }); - applyRuntimeEvents(jobId, [ - { - seq: Math.max(latestEventSeq(jobId), cancelled.lastSeq) + 1, - event: "cancelled", - payload: { - job_id: jobId, - conversation_id: cancelled.conversationId || conversationId, - }, + if (generation !== runtimeGeneration) return; + const current = state.activeJobsByConversation[conversationId]; + if (current?.jobId !== jobId) { + return; + } + dispatch({ + type: "job/upsert", + job: { + ...cancelled, + currentTimeline: current.currentTimeline, + currentToolCalls: current.currentToolCalls, }, - ]); + }); + // 取消响应不是增量事件,不伪造序号;真正终态由 Runtime 游标续接。 + if (isJobRunning(cancelled)) { + scheduleFallbackPolling(jobId); + } else { + finishJob(jobId, conversationId); + } } catch (err) { + if (generation !== runtimeGeneration) return; + if (state.activeJobsByConversation[conversationId]?.jobId !== jobId) { + return; + } dispatch({ type: "send/error", error: errorMessage(err) }); } } @@ -1137,6 +1550,8 @@ export function createChatStore({ conversationId: string, filePath: string, ): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; const trimmed = filePath.trim(); if (!trimmed) { return; @@ -1155,6 +1570,7 @@ export function createChatStore({ dispatch({ type: "attachments/set", conversationId, attachments: queued }); try { const attachment = await client.uploadAttachment({ filePath: trimmed }); + if (generation !== runtimeGeneration) return; const current = state.attachmentsByConversation[conversationId] ?? []; dispatch({ type: "attachments/set", @@ -1172,6 +1588,7 @@ export function createChatStore({ ), }); } catch (err) { + if (generation !== runtimeGeneration) return; const current = state.attachmentsByConversation[conversationId] ?? []; dispatch({ type: "attachments/set", @@ -1274,7 +1691,9 @@ export function createChatStore({ conversationId && state.activeJobsByConversation[conversationId]; if (activeJob && activeJob.jobId === jobId) { let updatedJob = activeJob; - const newEvents = events.filter((item) => item.seq > prevCursor); + const newEvents = (state.eventsByJob[jobId] ?? []).filter( + (item) => item.seq > prevCursor, + ); for (const event of newEvents) { if (event.event === "stage" && event.payload) { // stage 事件:更新 currentStage/Detail/StartedAt(对齐 WebUI setChatStage) @@ -1303,14 +1722,38 @@ export function createChatStore({ event.payload.content ?? event.payload.message ?? "", ); if (content) { + const parentCallId = String( + event.payload.parent_webchat_call_id || "", + ); updatedJob = { ...updatedJob, reply: updatedJob.reply + content, - currentTimeline: [ - ...updatedJob.currentTimeline, - { type: "message", seq: event.seq, content }, - ], + messages: [...updatedJob.messages, content], }; + if (parentCallId) { + updatedJob = { + ...updatedJob, + currentToolCalls: updateToolCall( + updatedJob.currentToolCalls, + parentCallId, + (parent) => ({ + ...parent, + timeline: [ + ...(parent.timeline ?? []), + { type: "message", seq: event.seq, content }, + ], + }), + ), + }; + } else { + updatedJob = { + ...updatedJob, + currentTimeline: [ + ...updatedJob.currentTimeline, + { type: "message", seq: event.seq, content }, + ], + }; + } } } else if ( event.event === "tool_start" || @@ -1369,6 +1812,22 @@ export function createChatStore({ }), ), }; + if (isStart && parentCallId) { + updatedJob = { + ...updatedJob, + currentToolCalls: updateToolCall( + updatedJob.currentToolCalls, + parentCallId, + (parent) => ({ + ...parent, + timeline: [ + ...(parent.timeline ?? []), + { type: "call", seq: event.seq, callId }, + ], + }), + ), + }; + } // 顶层 tool_start/agent_start 首次进入主时间线(对齐 WebUI append 顺序; // 嵌套子工具在父块 children 内,由 ToolBlock 递归渲染,不进主时间线) if ( @@ -1427,27 +1886,42 @@ export function createChatStore({ } } } - if (updatedJob !== activeJob) { - dispatch({ type: "job/upsert", job: updatedJob }); + if (newEvents.length > 0) { + dispatch({ + type: "job/upsert", + job: { + ...updatedJob, + lastSeq: Math.max( + updatedJob.lastSeq, + state.eventCursorByJob[jobId] ?? 0, + ), + }, + }); } } - // 终态事件:清理 job 并重载历史 - const doneEvent = events.find((item) => - ["done", "error", "cancelled"].includes(item.event), + // 终态事件先保留会话和展示状态,确认 Runtime 历史落盘后再释放发送锁。 + const doneEvent = events.find( + (item) => item.seq > prevCursor && TERMINAL_EVENTS.has(item.event), ); if (doneEvent && conversationId) { - stopFallbackPolling(jobId); - const subscriptionId = subscriptionsByJob.get(jobId); - if (subscriptionId) { - subscriptionsByJob.delete(jobId); - void client.stopJobEventStream(subscriptionId); + const currentJob = state.activeJobsByConversation[conversationId]; + if (currentJob && currentJob.jobId !== jobId) { + return; } - dispatch({ type: "job/remove", conversationId, jobId }); - if (Object.keys(state.activeJobsByConversation).length === 0) { - dispatch({ type: "connection/set", connectionState: "connected" }); + if (currentJob && !currentJob.historyFinalized) { + const status = + doneEvent.payload.error === "cancelled" + ? "cancelled" + : doneEvent.event; + dispatch({ + type: "job/upsert", + job: { ...currentJob, status }, + }); + scheduleFallbackPolling(jobId); + } else { + finishJob(jobId, conversationId); } - void loadHistory(conversationId); } } @@ -1456,6 +1930,13 @@ export function createChatStore({ } function handleRuntimeStatus(status: RuntimeSseStatus): void { + const conversationId = state.jobConversationById[status.jobId]; + if ( + !conversationId || + state.activeJobsByConversation[conversationId]?.jobId !== status.jobId + ) { + return; + } if (status.status === "connected") { dispatch({ type: "connection/set", connectionState: "streaming" }); return; @@ -1465,21 +1946,19 @@ export function createChatStore({ return; } if (status.status === "closed") { - const conversationId = state.jobConversationById[status.jobId]; - if (conversationId && state.activeJobsByConversation[conversationId]) { - fetchJobEventsFallback(status.jobId); - return; - } - dispatch({ type: "connection/set", connectionState: "connected" }); + fetchJobEventsFallback(status.jobId); } } async function loadMoreHistory(conversationId: string): Promise { + if (runtimeSuspended) return; + const generation = runtimeGeneration; const historyState = state.historyByConversation[conversationId]; if (!historyState?.hasMore || historyState.loading) { return; } + const request = beginHistoryRequest(conversationId); dispatch({ type: "history/loading", conversationId }); try { @@ -1488,6 +1967,7 @@ export function createChatStore({ historyState.nextBefore ?? undefined, HISTORY_LIMIT, ); + if (!isCurrentHistoryRequest(conversationId, generation, request)) return; const newItems = [...response.items, ...historyState.items]; @@ -1500,6 +1980,7 @@ export function createChatStore({ total: response.total, }); } catch (error) { + if (!isCurrentHistoryRequest(conversationId, generation, request)) return; dispatch({ type: "history/error", conversationId, @@ -1510,6 +1991,7 @@ export function createChatStore({ return { bootstrap, + suspendRuntime, refreshCommandsIfStale, createConversation, deleteConversation, diff --git a/apps/undefined-chat/src/chat-store/types.ts b/apps/undefined-chat/src/chat-store/types.ts index 7c1a2e35..f6ec8523 100644 --- a/apps/undefined-chat/src/chat-store/types.ts +++ b/apps/undefined-chat/src/chat-store/types.ts @@ -44,7 +44,8 @@ export type ToolBlockStatus = "running" | "done" | "error" | "cancelled"; export type TimelineEntry = | { type: "input"; timestamp: number; content: string } | { type: "output"; timestamp: number; content: string } - | { type: "error"; timestamp: number; message: string }; + | { type: "error"; timestamp: number; message: string } + | { type: "call"; timestamp: number; callId: string }; export type ToolBlock = { webchatCallId: string; @@ -197,7 +198,11 @@ export type ChatAction = } // 会话管理 | { type: "conversation/select"; conversationId: string } - | { type: "conversation/upsert"; conversation: Conversation } + | { + type: "conversation/upsert"; + conversation: Conversation; + select?: boolean; + } | { type: "conversation/remove"; conversationId: string; @@ -209,6 +214,7 @@ export type ChatAction = | { type: "send/error"; error: string | null } // 任务与事件 | { type: "job/upsert"; job: ChatJob } + | { type: "job/snapshot"; job: ChatJob } | { type: "job/remove"; conversationId: string; jobId: string } | { type: "events/apply"; jobId: string; events: ChatEvent[] } // 工具块 diff --git a/apps/undefined-chat/src/chat-store/upload-suspension.test.ts b/apps/undefined-chat/src/chat-store/upload-suspension.test.ts new file mode 100644 index 00000000..4728679f --- /dev/null +++ b/apps/undefined-chat/src/chat-store/upload-suspension.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test, vi } from "vitest"; +import type { Attachment, RuntimeConfig } from "../runtime-client/types"; +import { runtimeClientStub } from "../test-fixtures"; +import { createChatStore } from "./store"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const uploaded: Attachment = { + id: "file-A", + name: "A.txt", + size: 4, + kind: "file", + mediaType: "text/plain", + downloadUrl: "/attachment/file-A", + previewUrl: null, + discarded: false, +}; + +describe("attachment uploads across connection settings suspension", () => { + test.each(["success", "failure"])( + "settles late %s uploads according to the connection boundary", + async (outcome) => { + for (const boundary of [ + "resume", + "same", + "different", + "completed", + ] as const) { + let config: RuntimeConfig = { + runtimeUrl: "http://runtime-a.test", + hasApiKey: true, + }; + const pending = deferred(); + const client = runtimeClientStub({ + getRuntimeConfig: vi.fn(async () => config), + uploadAttachment: vi.fn(async () => pending.promise), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const uploading = store.addAttachmentPath("default", "/isolated/A.txt"); + const finish = () => + outcome === "success" + ? pending.resolve(uploaded) + : pending.reject(new Error("upload-failed")); + if (boundary === "completed") { + finish(); + await uploading; + } + if (boundary !== "resume") store.suspendRuntime(); + if (boundary === "different") + config = { ...config, runtimeUrl: "http://runtime-b.test" }; + await store.bootstrap({ resuming: true, resumeSuspended: true }); + if (boundary !== "completed") { + finish(); + await uploading; + } + const queue = + store.getSnapshot().attachmentsByConversation.default ?? []; + expect(queue.map((item) => item.status)).toEqual( + boundary === "different" + ? [] + : [ + boundary === "same" + ? "error" + : outcome === "success" + ? "ready" + : "error", + ], + ); + if (boundary === "same") { + expect(queue[0].error).toBe("error.attachmentsFailed"); + store.updateDraft("default", "send"); + await store.sendSelectedMessage(); + expect(store.getSnapshot().sendError).toBe("error.attachmentsFailed"); + expect(client.sendMessage).not.toHaveBeenCalled(); + } + } + }, + ); + + test.each(["success", "failure"])( + "removed uploads cannot reappear after late %s responses", + async (outcome) => { + for (const suspended of [false, true]) { + const pending = deferred(); + const client = runtimeClientStub({ + uploadAttachment: vi + .fn() + .mockImplementationOnce(() => pending.promise) + .mockResolvedValue({ ...uploaded, id: "reselected" }), + }); + const store = createChatStore({ client }); + await store.bootstrap(); + const old = store.addAttachmentPath("default", "/isolated/A.txt"); + if (suspended) store.suspendRuntime(); + const id = store.getSnapshot().attachmentsByConversation.default[0].id; + store.clearAttachment("default", id); + if (suspended) await store.bootstrap({ resumeSuspended: true }); + await store.addAttachmentPath("default", "/isolated/A.txt"); + if (outcome === "success") pending.resolve(uploaded); + else pending.reject(new Error("obsolete upload failure")); + await old; + expect( + store.getSnapshot().attachmentsByConversation.default, + ).toHaveLength(1); + expect( + store.getSnapshot().attachmentsByConversation.default[0].attachmentId, + ).toBe("reselected"); + store.updateDraft("default", "send-after-reselect"); + await store.sendSelectedMessage(); + expect(client.sendMessage).toHaveBeenCalledWith({ + conversationId: "default", + message: { + text: "send-after-reselect", + attachmentIds: ["reselected"], + references: [], + }, + }); + } + }, + ); +}); diff --git a/apps/undefined-chat/src/connection-recovery.test.tsx b/apps/undefined-chat/src/connection-recovery.test.tsx new file mode 100644 index 00000000..0f4cb882 --- /dev/null +++ b/apps/undefined-chat/src/connection-recovery.test.tsx @@ -0,0 +1,108 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { App } from "./App"; +import { LanguageProvider, LOCALE_STORAGE_KEY } from "./i18n"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); + +describe("connection recovery through the real Tauri client and store", () => { + beforeEach(() => { + vi.resetAllMocks(); + localStorage.clear(); + localStorage.setItem(LOCALE_STORAGE_KEY, "zh-CN"); + vi.mocked(listen).mockResolvedValue(() => undefined); + }); + + test.each([false, true])( + "key failure with rollback failure=%s keeps UI and Native target consistent and prevents Runtime requests", + async (rollbackFails) => { + let nativeUrl = "http://runtime-a.test"; + const runtimeCalls: Array<{ command: string; origin: string }> = []; + vi.mocked(invoke).mockImplementation(async (command, args) => { + switch (command) { + case "get_runtime_config": + return { runtimeUrl: nativeUrl }; + case "load_api_key_status": + return { available: true, storage: "system-keyring" }; + case "save_runtime_config": { + const { input } = args as { input: { runtimeUrl: string } }; + if (rollbackFails && input.runtimeUrl === "http://runtime-a.test") { + throw new Error("rollback denied"); + } + nativeUrl = input.runtimeUrl; + return { runtimeUrl: nativeUrl }; + } + case "save_api_key": + throw new Error("keyring denied"); + } + runtimeCalls.push({ command, origin: nativeUrl }); + switch (command) { + case "probe_runtime": + return { status: 200, ok: true, body: "ok" }; + case "list_conversations": + return { + conversations: [ + { id: "legacy-system-42", title: "A conversation" }, + ], + default_conversation_id: "legacy-system-42", + }; + case "get_history": + return { + conversation_id: "legacy-system-42", + items: [ + { message_id: "a-history", role: "user", content: "A history" }, + ], + }; + case "get_active_jobs": + return { jobs: [] }; + case "list_commands": + return { commands: [] }; + default: + throw new Error(`Unexpected Native command: ${command}`); + } + }); + render( + + + , + ); + await screen.findByText("A history"); + await userEvent.type( + screen.getByLabelText("消息输入"), + "A private draft", + ); + fireEvent.keyDown(window, { key: ",", ctrlKey: true }); + await userEvent.clear(screen.getByLabelText("Runtime URL")); + await userEvent.type( + screen.getByLabelText("Runtime URL"), + "http://runtime-b.test", + ); + await userEvent.type(screen.getByLabelText("API Key"), "B-key"); + await userEvent.click(screen.getByRole("button", { name: "保存并连接" })); + await screen.findByText(/keyring denied/); + expect(nativeUrl).toBe( + rollbackFails ? "http://runtime-b.test" : "http://runtime-a.test", + ); + expect(screen.getByLabelText("Runtime URL")).toHaveValue(nativeUrl); + expect(screen.getByLabelText("消息输入")).toBeDisabled(); + expect(screen.getByLabelText("消息输入")).toHaveValue( + rollbackFails ? "" : "A private draft", + ); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.getByLabelText("Runtime URL")).toBeInTheDocument(); + fireEvent.keyDown(window, { key: "Enter", ctrlKey: true }); + expect(runtimeCalls.map(({ origin }) => origin)).toEqual( + Array(5).fill("http://runtime-a.test"), + ); + expect( + vi + .mocked(invoke) + .mock.calls.filter(([command]) => command === "send_message"), + ).toEqual([]); + }, + ); +}); diff --git a/apps/undefined-chat/src/i18n/index.ts b/apps/undefined-chat/src/i18n/index.ts index 014b5a87..a7a8a2cb 100644 --- a/apps/undefined-chat/src/i18n/index.ts +++ b/apps/undefined-chat/src/i18n/index.ts @@ -53,6 +53,8 @@ export type TranslationKey = | "setup.error.needUrl" | "setup.error.needApiKey" | "setup.error.invalidUrl" + | "setup.error.reconfigure" + | "setup.error.rollbackFailed" // 附件操作(App 级 alert / 选择器) | "attachment.saveFailed" | "attachment.saved" @@ -224,6 +226,10 @@ export const dictionaries: Record = { "setup.error.needUrl": "请输入 Runtime URL", "setup.error.needApiKey": "请输入 API Key", "setup.error.invalidUrl": "URL 格式不正确", + "setup.error.reconfigure": + "连接配置保存失败,请重新输入 API Key 并连接。{error}", + "setup.error.rollbackFailed": + "{error};恢复旧 Runtime URL 失败:{rollbackError}", "attachment.saveFailed": "附件保存失败", "attachment.saved": "已保存 {name}", "attachment.previewFailed": "附件无法预览", @@ -377,6 +383,10 @@ export const dictionaries: Record = { "setup.error.needUrl": "Please enter the Runtime URL", "setup.error.needApiKey": "Please enter the API Key", "setup.error.invalidUrl": "Invalid URL format", + "setup.error.reconfigure": + "Connection settings were not saved. Enter the API key and reconnect. {error}", + "setup.error.rollbackFailed": + "{error}; restoring the previous Runtime URL failed: {rollbackError}", "attachment.saveFailed": "Failed to save attachment", "attachment.saved": "Saved {name}", "attachment.previewFailed": "Attachment cannot be previewed", diff --git a/apps/undefined-chat/src/image-viewer/ImageViewerModal.tsx b/apps/undefined-chat/src/image-viewer/ImageViewerModal.tsx index 60018c72..5e096574 100644 --- a/apps/undefined-chat/src/image-viewer/ImageViewerModal.tsx +++ b/apps/undefined-chat/src/image-viewer/ImageViewerModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { ImageViewerState } from "../chat-store/types"; import { useTranslation } from "../i18n"; +import { useRetainedAttachmentBlobUrl } from "../rendering/AttachmentImageContext"; export type ImageViewerModalProps = { imageViewer: ImageViewerState | null; @@ -15,6 +16,7 @@ export function ImageViewerModal({ const previousActiveElementRef = useRef(null); const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); + useRetainedAttachmentBlobUrl(imageViewer?.open ? imageViewer.src : null); useEffect(() => { if (imageViewer?.open) { diff --git a/apps/undefined-chat/src/message-timeline/MessageTimeline.test.tsx b/apps/undefined-chat/src/message-timeline/MessageTimeline.test.tsx index 4b81297b..e11a52fc 100644 --- a/apps/undefined-chat/src/message-timeline/MessageTimeline.test.tsx +++ b/apps/undefined-chat/src/message-timeline/MessageTimeline.test.tsx @@ -1,4 +1,10 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { + act, + fireEvent, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -304,10 +310,10 @@ describe("MessageTimeline", () => { configurable: true, value: 1400, }); - await Promise.resolve(); - for (const callback of rafCallbacks.splice(0)) { - callback(0); - } + await act(async () => { + await Promise.resolve(); + for (const callback of rafCallbacks.splice(0)) callback(0); + }); expect(timeline.scrollTop).toBe(420); expect(onLoadMoreHistory).toHaveBeenCalledOnce(); diff --git a/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx b/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx index 9e379123..050acf75 100644 --- a/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx +++ b/apps/undefined-chat/src/message-timeline/MessageTimeline.tsx @@ -95,6 +95,7 @@ function convertToolCallSnapshot(snap: ToolCallSnapshot): HistoryToolCall { ui_hint: snap.uiHint, duration_ms: snap.durationMs ?? snap.elapsedMs ?? undefined, current_stage: snap.currentStage, + current_stage_detail: snap.detail, children: snap.children?.map(convertToolCallSnapshot), timeline: snap.timeline, }; @@ -849,6 +850,9 @@ export function MessageTimeline({ { }); describe("MessageTimelineContent", () => { + test("uses Runtime snake_case IDs to render one child shared by children and timeline", () => { + const child = { + webchat_call_id: "agent/search", + name: "search", + status: "done", + timeline: [{ type: "message", content: "Nested completed body" }], + }; + renderContent( + , + ); + expect(screen.getAllByText("search")).toHaveLength(1); + expect(screen.getAllByText("Nested completed body")).toHaveLength(1); + expect( + screen + .getByText("search") + .closest("details") + ?.getAttribute("data-call-id"), + ).toBe("agent/search"); + }); + + test("keeps historical Agent messages and nested calls inside the Agent in order", () => { + renderContent( + , + ); + const agent = screen.getByText("research").closest("details"); + expect(agent?.contains(screen.getByText("Before"))).toBe(true); + expect(agent?.contains(screen.getByText("After"))).toBe(true); + expect(agent?.contains(screen.getByText("Final"))).toBe(false); + expect(screen.getAllByText("Before")).toHaveLength(1); + expect( + screen + .getByText("Before") + .compareDocumentPosition(screen.getByText("search")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + screen + .getByText("search") + .compareDocumentPosition(screen.getByText("After")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + test("renders message text and tool calls in order without duplicating content", () => { renderContent( void; /** @@ -61,6 +67,23 @@ function normalizeEntries(timeline: unknown[]): HistoryTimelineEntry[] { ); } +function hasTimelineMessage(entries: HistoryTimelineEntry[]): boolean { + return entries.some((entry) => + entry.type === "message" + ? Boolean(entry.content?.trim()) + : entry.type === "call" && isToolCall(entry.call) + ? hasCallMessage(entry.call) + : false, + ); +} + +function hasCallMessage(call: HistoryToolCall): boolean { + return ( + hasTimelineMessage(normalizeEntries(call.timeline ?? [])) || + (call.children ?? []).some(hasCallMessage) + ); +} + /** * 判断 timeline 是否包含可渲染的工具调用或消息片段。 * 仅在为真时才走统一时间线渲染模式。 @@ -93,36 +116,41 @@ function convertHistoryToolCallToToolBlock( ? "cancelled" : "done"; - // 转换子工具调用(优先从 timeline 中提取,兼容旧的 children 字段) const children = new Map(); - - // 从 timeline 中提取子调用(新格式) - if (Array.isArray(call.timeline)) { - const childCalls = call.timeline.filter( - (e): e is HistoryTimelineEntry & { call: HistoryToolCall } => - e.type === "call" && isToolCall(e.call), - ); - - childCalls.forEach((entry, idx) => { - const childBlock = convertHistoryToolCallToToolBlock(entry.call, idx); - children.set(childBlock.webchatCallId, childBlock); - }); - } - - // 兼容旧格式:直接的 children 数组 + const timeline: ToolBlockType["timeline"] = []; + // 旧 children 与新版时间线引用同一子块,时间线保留 Agent 正文与调用的先后顺序。 if (Array.isArray(call.children)) { call.children.forEach((child, idx) => { const childBlock = convertHistoryToolCallToToolBlock(child, idx); children.set(childBlock.webchatCallId, childBlock); }); } + normalizeEntries(call.timeline ?? []).forEach((entry, idx) => { + const timestamp = entry.seq ?? idx; + if (entry.type === "message" && entry.content) { + timeline.push({ type: "output", timestamp, content: entry.content }); + } else if (entry.type === "call") { + let callId = entry.callId; + if (isToolCall(entry.call)) { + const childBlock = convertHistoryToolCallToToolBlock(entry.call, idx); + callId = childBlock.webchatCallId; + children.set(callId, childBlock); + } + if (callId && children.has(callId)) + timeline.push({ type: "call", timestamp, callId }); + } + }); const now = Date.now(); const startTime = now - (call.duration_ms || 0); const endTime = mappedStatus === "running" ? undefined : now; return { - webchatCallId: call.id || `call-${index}-${call.name}`, + webchatCallId: + call.id || + call.webchat_call_id || + call.tool_call_id || + `call-${index}-${call.name}`, toolName: call.name || "--", status: mappedStatus, isAgent: call.is_agent, @@ -132,7 +160,7 @@ function convertHistoryToolCallToToolBlock( currentStage: call.current_stage, stageDetail: call.current_stage_detail, children, - timeline: [], + timeline, startTime, endTime, }; @@ -145,14 +173,17 @@ function convertHistoryToolCallToToolBlock( export function MessageTimelineContent({ timeline, fallbackContent, + completeTimeline = false, attachments = [], onPreviewHtml, onImageClick, }: MessageTimelineContentProps) { const entries = normalizeEntries(timeline); - const hasMessageText = entries.some( - (e) => e.type === "message" && Boolean(e.content?.trim()), - ); + const hasMessageText = + entries.some( + (entry) => entry.type === "message" && Boolean(entry.content?.trim()), + ) || + (completeTimeline && hasTimelineMessage(entries)); // 本地图片预览状态:仅在外部未提供 onImageClick 时启用 const [previewImage, setPreviewImage] = useState<{ @@ -197,7 +228,7 @@ export function MessageTimelineContent({ return null; })} - {/* timeline 中无任何顶层文本时,用完整正文兜底 */} + {/* 时间线完全没有正文时才兜底,避免重复展示 Agent 内正文。 */} {!hasMessageText && fallbackContent?.trim() ? ( ` 元素实现原生折叠/展开(running 自动展开,done/error 后 2s 自动折叠,用户交互后不再自动折叠) - ✅ **状态指示** - 支持 running / done / error / cancelled 状态,带视觉反馈 -- ✅ **时间线展示** - 显示工具执行的输入、输出、错误历史 +- ✅ **时间线展示** - 显示工具执行的输入、输出、错误历史;Agent 内正文支持 Markdown,并与子调用按时间线顺序交错展示 - ✅ **嵌套渲染** - 支持递归渲染子工具调用 - ✅ **实时计时** - 运行中通过统一时钟 `useChatClock` 每 500ms 刷新用时,结束后定格;自动格式化(ms/s/m) - ✅ **Agent 阶段明细** - Agent 运行中在状态位展示阶段标签(`currentStage`),并可附加阶段明细 `stageDetail`(如模型名/子步骤) @@ -37,7 +37,8 @@ export type ToolBlock = { export type TimelineEntry = | { type: "input"; timestamp: number; content: string } | { type: "output"; timestamp: number; content: string } - | { type: "error"; timestamp: number; message: string }; + | { type: "error"; timestamp: number; message: string } + | { type: "call"; timestamp: number; callId: string }; ``` ## 使用示例 @@ -69,7 +70,12 @@ const toolBlock = { const childTool = { /* ... */ }; const parentTool = { // ... - children: new Map([["child-id", childTool]]) + children: new Map([["child-id", childTool]]), + timeline: [ + { type: "output", timestamp: 1, content: "调用之前的说明" }, + { type: "call", timestamp: 2, callId: "child-id" }, + { type: "output", timestamp: 3, content: "调用之后的说明" } + ] }; @@ -92,7 +98,7 @@ const toolBlocks = useStore(state => state.toolBlocksByJob[jobId]); 实时运行的工具块由 store 维护;历史消息则由 `MessageTimelineContent` 消费一条已交错好的 `timeline`,其中 `type === "call"` 的条目经 `convertHistoryToolCallToToolBlock` 转换为本组件所需的 `ToolBlock`(映射 `arguments_preview`/`result_preview`/`is_agent`/`current_stage`/`current_stage_detail` -等字段,并按 `duration_ms` 反推 `startTime`)。 +等字段,并按 `duration_ms` 反推 `startTime`)。调用身份兼容 Runtime 的 `webchat_call_id`、`tool_call_id` 与已有 `id`,同一子调用在 `children` 和 `timeline` 中只渲染一次。嵌套 `message` 转为输出条目,`call` 引用对应子块;已被时间线引用的子块不会再次追加到末尾。 `timeline` 由 `MessageTimeline` 的 `buildHistoryTimeline` 按可用性多级回退构建: @@ -158,7 +164,7 @@ const toolBlocks = useStore(state => state.toolBlocksByJob[jobId]); - 状态样式(running / done / error) - 时长格式化(ms / s / m) - 折叠展开交互 -- 时间线渲染(input / output / error) +- 时间线渲染(input / output / error / call),流式与历史 Agent 正文归属和子调用先后顺序 - 嵌套子工具递归渲染(单个 / 多个) - 空状态处理(无时间线、无子工具) - 结果预览:JSON/Python 风格结构化、非结构化 Markdown、附件图片标签 fallback diff --git a/apps/undefined-chat/src/message-timeline/ToolBlock.tsx b/apps/undefined-chat/src/message-timeline/ToolBlock.tsx index aec975d6..a801c669 100644 --- a/apps/undefined-chat/src/message-timeline/ToolBlock.tsx +++ b/apps/undefined-chat/src/message-timeline/ToolBlock.tsx @@ -195,9 +195,12 @@ function renderPreviewSection({ } function renderTimelineEntry( - entry: ToolBlockType["timeline"][number], + entry: Exclude, index: number, t: TranslateFn, + attachments: Attachment[], + onPreviewHtml: (input: HtmlPreviewRequest) => void, + onImageClick?: (src: string, alt: string) => void, ): React.ReactElement { const content = entry.type === "error" ? entry.message : entry.content; const label = @@ -215,7 +218,14 @@ function renderTimelineEntry( return (
{label} -
{content}
+
+ +
); } @@ -359,16 +369,45 @@ export function ToolBlock({ {childrenArray.length > 0 || timeline.length > 0 ? (
- {timeline.map((entry, index) => renderTimelineEntry(entry, index, t))} - {childrenArray.map((child) => ( - - ))} + {timeline.map((entry, index) => { + if (entry.type !== "call") + return renderTimelineEntry( + entry, + index, + t, + attachments, + onPreviewHtml, + onImageClick, + ); + const child = children.get(entry.callId); + return child ? ( + + ) : null; + })} + {childrenArray + .filter( + (child) => + !timeline.some( + (entry) => + entry.type === "call" && + entry.callId === child.webchatCallId, + ), + ) + .map((child) => ( + + ))}
) : null} diff --git a/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts b/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts index 9e044827..e89d8833 100644 --- a/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts +++ b/apps/undefined-chat/src/platform/AndroidLifecycle.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import { createChatStore } from "../chat-store/store"; +import { + conversation, + historyItem, + job, + runtimeClientStub, +} from "../test-fixtures"; import { isAndroid, setupAndroidLifecycle } from "./AndroidLifecycle"; const listeners = new Map void | Promise>(); @@ -69,6 +76,62 @@ describe("AndroidLifecycle", () => { }); describe("setupAndroidLifecycle", () => { + it("cleans registrations resolved after teardown and ignores queued resume callbacks", async () => { + const client = runtimeClientStub(); + const store = createChatStore({ client }); + const cleanup = setupAndroidLifecycle(store); + const queuedResume = listeners.get("tauri://resumed"); + cleanup(); + await Promise.resolve(); + expect(unlisten).toHaveBeenCalledTimes(2); + await queuedResume?.(); + expect(client.getRuntimeConfig).not.toHaveBeenCalled(); + }); + + it("reloads a completed background conversation through the real resume listener", async () => { + let completed = false; + const client = runtimeClientStub({ + listConversations: vi.fn(async () => ({ + conversations: [conversation({ id: "A" }), conversation({ id: "B" })], + activeJob: null, + defaultConversationId: "A", + virtualUserId: "webchat", + })), + sendMessage: vi.fn(async () => job({ conversationId: "A" })), + }); + const getHistory = client.getHistory; + client.getHistory = vi.fn(async (input) => { + const history = await getHistory(input); + const items = + completed && input.conversationId === "A" + ? [historyItem({ role: "bot", content: "后台已完成" })] + : []; + return { + ...history, + conversationId: input.conversationId, + items, + total: items.length, + }; + }); + const store = createChatStore({ client }); + await store.bootstrap(); + store.updateDraft("A", "question"); + await store.sendSelectedMessage(); + await store.selectConversation("B"); + const cleanup = setupAndroidLifecycle(store); + await vi.waitFor(() => + expect(listeners.has("tauri://resumed")).toBe(true), + ); + completed = true; + await listeners.get("tauri://resumed")?.(); + expect(store.getSnapshot().selectedConversationId).toBe("B"); + await store.selectConversation("A"); + expect(store.getSnapshot().historyByConversation.A.items[0].content).toBe( + "后台已完成", + ); + cleanup(); + }); + it("re-bootstraps on android resume and cleans listeners", async () => { const mockStore = { bootstrap: vi.fn(async () => undefined), diff --git a/apps/undefined-chat/src/platform/AndroidLifecycle.ts b/apps/undefined-chat/src/platform/AndroidLifecycle.ts index 0bcf2865..f20725c5 100644 --- a/apps/undefined-chat/src/platform/AndroidLifecycle.ts +++ b/apps/undefined-chat/src/platform/AndroidLifecycle.ts @@ -9,17 +9,21 @@ import type { ChatStore } from "../chat-store/store"; */ export function setupAndroidLifecycle(store: ChatStore): () => void { const unlisteners: Array<() => void> = []; + let disposed = false; const appWindow = getCurrentWindow(); + const registerUnlistener = (unlisten: () => void): void => { + if (disposed) unlisten(); + else unlisteners.push(unlisten); + }; // 监听应用暂停事件 appWindow .listen(TauriEvent.WINDOW_SUSPENDED, () => { + if (disposed) return; console.log("[Lifecycle] App paused"); // 可以在这里保存状态或清理资源 }) - .then((unlisten: () => void) => { - unlisteners.push(unlisten); - }) + .then(registerUnlistener) .catch((err: unknown) => { console.error("[Lifecycle] Failed to listen android-pause:", err); }); @@ -27,6 +31,7 @@ export function setupAndroidLifecycle(store: ChatStore): () => void { // 监听应用恢复事件 appWindow .listen(TauriEvent.WINDOW_RESUMED, async () => { + if (disposed) return; console.log("[Lifecycle] App resumed, re-bootstrapping..."); try { @@ -58,16 +63,15 @@ export function setupAndroidLifecycle(store: ChatStore): () => void { console.error("[Lifecycle] Failed to reconnect:", err); } }) - .then((unlisten: () => void) => { - unlisteners.push(unlisten); - }) + .then(registerUnlistener) .catch((err: unknown) => { console.error("[Lifecycle] Failed to listen android-resume:", err); }); // 返回清理函数 return () => { - for (const unlisten of unlisteners) { + disposed = true; + for (const unlisten of unlisteners.splice(0)) { unlisten(); } }; diff --git a/apps/undefined-chat/src/rendering/AttachmentImage.tsx b/apps/undefined-chat/src/rendering/AttachmentImage.tsx index 2169fc9a..5b3ed682 100644 --- a/apps/undefined-chat/src/rendering/AttachmentImage.tsx +++ b/apps/undefined-chat/src/rendering/AttachmentImage.tsx @@ -33,12 +33,13 @@ export function AttachmentImage({ style, onOpenImage, }: AttachmentImageProps) { - const { loadAttachmentBlob } = useAttachmentImage(); + const { loadAttachmentBlob, retainAttachmentBlob } = useAttachmentImage(); const { t } = useTranslation(); const [state, setState] = useState({ status: "loading" }); useEffect(() => { let cancelled = false; + const release = retainAttachmentBlob(uid); setState({ status: "loading" }); loadAttachmentBlob(uid) .then((loaded) => { @@ -52,8 +53,9 @@ export function AttachmentImage({ }); return () => { cancelled = true; + release(); }; - }, [uid, loadAttachmentBlob]); + }, [uid, loadAttachmentBlob, retainAttachmentBlob]); if (state.status === "loading") { return ( diff --git a/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx b/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx index adf67af6..7b97fd67 100644 --- a/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx +++ b/apps/undefined-chat/src/rendering/AttachmentImageContext.test.tsx @@ -130,4 +130,62 @@ describe("AttachmentImageContext", () => { expect(revokeSpy).toHaveBeenCalledTimes(2); revokeSpy.mockRestore(); }); + + it("Provider 卸载后迟到的预览不再创建或缓存旧实例 blob", async () => { + let resolve!: (result: AttachmentPreviewResult) => void; + const createSpy = vi.spyOn(URL, "createObjectURL"); + const { client } = makeClient( + () => + new Promise((done) => { + resolve = done; + }), + ); + const { result, unmount } = renderHook(() => useAttachmentImage(), { + wrapper: makeWrapper(client), + }); + const pending = result.current.loadAttachmentBlob("pic_old"); + unmount(); + resolve(imageResult()); + expect(await pending).toBeNull(); + expect(createSpy).not.toHaveBeenCalled(); + createSpy.mockRestore(); + }); + + it("切换 Runtime 释放旧缓存,相同 UID 在新实例重新加载并拒绝旧回包", async () => { + let resolve!: (result: AttachmentPreviewResult) => void; + let runtimeUrl = "http://runtime-a.test"; + const { client, previewAttachment } = makeClient(); + previewAttachment.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + const { result, rerender } = renderHook(() => useAttachmentImage(), { + wrapper: Wrapper, + }); + const pending = result.current.loadAttachmentBlob("pic_pending"); + const cached = await result.current.loadAttachmentBlob("pic_shared"); + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + runtimeUrl = "http://runtime-b.test"; + rerender(); + expect(revokeSpy).toHaveBeenCalledWith(cached?.url); + const current = await result.current.loadAttachmentBlob("pic_shared"); + expect(current?.url).not.toBe(cached?.url); + const createSpy = vi.spyOn(URL, "createObjectURL"); + resolve(imageResult()); + expect(await pending).toBeNull(); + expect(createSpy).not.toHaveBeenCalled(); + expect(await result.current.loadAttachmentBlob("pic_shared")).toBe(current); + expect(previewAttachment).toHaveBeenCalledTimes(3); + revokeSpy.mockRestore(); + createSpy.mockRestore(); + }); }); diff --git a/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx b/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx index b41a55e0..6a594680 100644 --- a/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx +++ b/apps/undefined-chat/src/rendering/AttachmentImageContext.tsx @@ -4,8 +4,8 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, - useRef, } from "react"; import type { RuntimeClient } from "../runtime-client/types"; @@ -16,6 +16,8 @@ export type LoadedAttachmentBlob = { type AttachmentImageContextValue = { loadAttachmentBlob: (uid: string) => Promise; + retainAttachmentBlob: (uid: string) => () => void; + retainBlobUrl: (url: string) => (() => void) | undefined; }; const AttachmentImageContext = @@ -26,7 +28,9 @@ const DEFAULT_MAX_CACHE_SIZE = 60; export type AttachmentImageProviderProps = { /** 仅依赖 previewAttachment,便于测试以最小桩注入 */ client: Pick; - /** blob 缓存上限,超出按 LRU 淘汰并 revoke。默认 60。 */ + /** 按 Runtime URL 隔离 UID 缓存,保持消费组件与 DOM 的生命周期。 */ + runtimeUrl?: string | null; + /** blob 缓存目标上限;超出时仅淘汰无消费者的 LRU 项。默认 60。 */ maxCacheSize?: number; children: ReactNode; }; @@ -41,23 +45,65 @@ export type AttachmentImageProviderProps = { * - LRU 缓存 uid→blobUrl:缩略图/正文图/全屏大图复用同一 blob,避免重复拉取。 * - inflight 去重:同一 UID 并发请求只发一次。 * - blob URL 归本 Provider 所有:消费组件**不**自行 revoke(缩略图与大图共享同一 - * URL,组件卸载即 revoke 会让仍打开的大图破图),仅在 LRU 淘汰或 Provider 卸载 - * 时 revoke。 + * URL,组件卸载即 revoke 会让仍打开的大图破图)。消费者持有引用期间跳过 + * LRU 淘汰;切换 Runtime 或 Provider 卸载时统一 revoke。 */ export function AttachmentImageProvider({ client, + runtimeUrl, maxCacheSize = DEFAULT_MAX_CACHE_SIZE, children, }: AttachmentImageProviderProps) { - const cacheRef = useRef>(new Map()); - const inflightRef = useRef>>( - new Map(), + const scope = useMemo( + () => ({ + runtimeUrl, + cache: new Map(), + inflight: new Map>(), + consumers: new Map(), + generation: 0, + }), + [runtimeUrl], + ); + + const evictUnused = useCallback(() => { + for (const [uid, loaded] of scope.cache) { + if (scope.cache.size <= maxCacheSize) break; + if (scope.consumers.has(uid)) continue; + scope.cache.delete(uid); + URL.revokeObjectURL(loaded.url); + } + }, [maxCacheSize, scope]); + + const retainAttachmentBlob = useCallback( + (uid: string): (() => void) => { + const generation = scope.generation; + scope.consumers.set(uid, (scope.consumers.get(uid) ?? 0) + 1); + let released = false; + return () => { + if (released || generation !== scope.generation) return; + released = true; + const remaining = (scope.consumers.get(uid) ?? 1) - 1; + if (remaining > 0) scope.consumers.set(uid, remaining); + else scope.consumers.delete(uid); + evictUnused(); + }; + }, + [scope, evictUnused], + ); + + const retainBlobUrl = useCallback( + (url: string): (() => void) | undefined => { + for (const [uid, loaded] of scope.cache) { + if (loaded.url === url) return retainAttachmentBlob(uid); + } + return undefined; + }, + [scope, retainAttachmentBlob], ); const loadAttachmentBlob = useCallback( (uid: string): Promise => { - const cache = cacheRef.current; - const inflight = inflightRef.current; + const { cache, inflight } = scope; const cached = cache.get(uid); if (cached) { @@ -71,8 +117,10 @@ export function AttachmentImageProvider({ if (pending) return pending; const promise = (async (): Promise => { + const generation = scope.generation; try { const result = await client.previewAttachment({ attachmentId: uid }); + if (generation !== scope.generation) return null; if (!result.ok || result.bytes.length === 0) { return null; } @@ -85,42 +133,38 @@ export function AttachmentImageProvider({ mediaType, }; cache.set(uid, loaded); - // 超上限淘汰最旧(队首)并 revoke - while (cache.size > maxCacheSize) { - const oldestKey = cache.keys().next().value; - if (oldestKey === undefined) break; - const victim = cache.get(oldestKey); - cache.delete(oldestKey); - if (victim) URL.revokeObjectURL(victim.url); - } + evictUnused(); return loaded; } catch { return null; } finally { - inflight.delete(uid); + if (generation === scope.generation) inflight.delete(uid); } })(); inflight.set(uid, promise); return promise; }, - [client, maxCacheSize], + [client, evictUnused, scope], ); - // Provider 卸载时释放全部 blob URL + // 切换 Runtime 或卸载时释放全部 blob URL,拒绝旧作用域迟到的响应。 useEffect(() => { - const cache = cacheRef.current; + const { cache, inflight } = scope; return () => { + scope.generation += 1; + inflight.clear(); + scope.consumers.clear(); for (const { url } of cache.values()) { URL.revokeObjectURL(url); } cache.clear(); }; - }, []); + }, [scope]); const value = useMemo( - () => ({ loadAttachmentBlob }), - [loadAttachmentBlob], + () => ({ loadAttachmentBlob, retainAttachmentBlob, retainBlobUrl }), + [loadAttachmentBlob, retainAttachmentBlob, retainBlobUrl], ); return ( @@ -142,3 +186,12 @@ export function useAttachmentImage(): AttachmentImageContextValue { } return value; } + +/** 大图独立持有 Provider 的 URL;外部图片和无 Provider 的查看器无需持有。 */ +export function useRetainedAttachmentBlobUrl(url: string | null): void { + const value = useContext(AttachmentImageContext); + // 在被动 effect 释放缩略图前接管引用,支持同次渲染中移除源缩略图。 + useLayoutEffect(() => { + if (url) return value?.retainBlobUrl(url); + }, [value, url]); +} diff --git a/apps/undefined-chat/src/rendering/CodeBlock.test.tsx b/apps/undefined-chat/src/rendering/CodeBlock.test.tsx index fff133fb..25e98b14 100644 --- a/apps/undefined-chat/src/rendering/CodeBlock.test.tsx +++ b/apps/undefined-chat/src/rendering/CodeBlock.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import hljs from "highlight.js"; import type { ReactElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LanguageProvider, LOCALE_STORAGE_KEY } from "../i18n"; @@ -30,6 +31,7 @@ describe("CodeBlock", () => { afterEach(() => { vi.clearAllMocks(); + vi.restoreAllMocks(); }); it("应该渲染代码块", () => { @@ -190,6 +192,42 @@ describe("CodeBlock", () => { }).not.toThrow(); }); + it.each(["notalanguage", "vue"])( + "未知语言 %s 的 HTML 保持为可复制的代码文本", + async (language) => { + const code = + '\n
\n& '; + const { container } = renderCodeBlock( + , + ); + expect(container.querySelector("code")?.textContent).toBe(code); + expect(container.querySelector("style, form, input, script")).toBeNull(); + expect(screen.getByText("plaintext")).toBeInTheDocument(); + fireEvent.click(screen.getByText("复制")); + await waitFor(() => expect(mockWriteText).toHaveBeenCalledWith(code)); + }, + ); + + it.each(["highlight", "highlightAuto"] as const)( + "%s 抛出异常时 HTML 同样保持为文本", + (method) => { + vi.spyOn(hljs, method).mockImplementation(() => { + throw new Error("受控高亮异常"); + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + const code = + '
'; + const { container } = renderCodeBlock( + , + ); + expect(container.querySelector("code")?.textContent).toBe(code); + expect(container.querySelector("style, form")).toBeNull(); + }, + ); + it("应该正确渲染空代码", () => { const code = ""; renderCodeBlock(); diff --git a/apps/undefined-chat/src/rendering/CodeBlock.tsx b/apps/undefined-chat/src/rendering/CodeBlock.tsx index 53d66edf..5c1bb41c 100644 --- a/apps/undefined-chat/src/rendering/CodeBlock.tsx +++ b/apps/undefined-chat/src/rendering/CodeBlock.tsx @@ -39,6 +39,9 @@ export function CodeBlock({ // htm 别名映射到 html const normalizedLanguage = language.toLowerCase() === "htm" ? "html" : language; + if (!hljs.getLanguage(normalizedLanguage)) { + return { highlightedCode: null, detectedLanguage: "plaintext" }; + } // 指定了语言,尝试使用指定语言高亮 const result = hljs.highlight(trimmedCode, { language: normalizedLanguage, @@ -58,7 +61,7 @@ export function CodeBlock({ } catch (err) { console.error("Highlight error:", err); return { - highlightedCode: trimmedCode, + highlightedCode: null, detectedLanguage: "plaintext", }; } @@ -141,9 +144,13 @@ export function CodeBlock({
 					
+						// Only highlighter output is HTML; fallback code is rendered as text.
+						dangerouslySetInnerHTML={
+							highlightedCode === null ? undefined : { __html: highlightedCode }
+						}
+					>
+						{highlightedCode === null ? code.trim() : undefined}
+					
 				
diff --git a/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx b/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx index 94a56570..b1ab04da 100644 --- a/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx +++ b/apps/undefined-chat/src/rendering/MarkdownContent.test.tsx @@ -198,6 +198,19 @@ describe("MarkdownContent", () => { expect(screen.getByText("复制")).toBeInTheDocument(); }); + it("未知 fenced-code 语言不会将代码里的 HTML 插入正文", () => { + const code = + '\n'; + const { container } = render( + , + ); + expect(container.querySelector("code")?.textContent).toBe(code); + expect(container.querySelector("style, form, input")).toBeNull(); + }); + it("渲染混合内容", () => { const mixedContent = `# 标题 diff --git a/apps/undefined-chat/src/rendering/blob-lifecycle.test.tsx b/apps/undefined-chat/src/rendering/blob-lifecycle.test.tsx new file mode 100644 index 00000000..5e878248 --- /dev/null +++ b/apps/undefined-chat/src/rendering/blob-lifecycle.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { StrictMode, useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { ImageViewerState } from "../chat-store/types"; +import { LanguageProvider } from "../i18n"; +import { ImageViewerModal } from "../image-viewer/ImageViewerModal"; +import { runtimeClientStub } from "../test-fixtures"; +import { AttachmentImage } from "./AttachmentImage"; +import { AttachmentImageProvider } from "./AttachmentImageContext"; + +function Gallery({ + images, + removeOnOpen = false, +}: { + images: { id: string; uid: string }[]; + removeOnOpen?: boolean; +}) { + const [viewer, setViewer] = useState(null); + const [hideThumbnails, setHideThumbnails] = useState(false); + return ( + <> + {!hideThumbnails && + images.map(({ id, uid }) => ( + { + setViewer({ open: true, src, alt }); + if (removeOnOpen) setHideThumbnails(true); + }} + /> + ))} + setViewer(null)} /> + + ); +} + +describe("active image blob lifetimes", () => { + afterEach(() => vi.restoreAllMocks()); + + test("61 mounted images keep their URLs, and an open viewer retains a removed thumbnail", async () => { + const revoke = vi.spyOn(URL, "revokeObjectURL").mockClear(); + const client = runtimeClientStub(); + const uids = Array.from({ length: 61 }, (_, index) => `pic_${index}`); + const tree = (shown: string[]) => ( + + + ({ id: uid, uid }))} /> + + + ); + const { rerender, unmount } = render(tree(uids.slice(0, 1))); + const first = (await screen.findByAltText("pic_0")) as HTMLImageElement; + const firstUrl = first.src; + rerender(tree(uids)); + await waitFor(() => + expect(document.querySelectorAll("img")).toHaveLength(61), + ); + expect(screen.getByAltText("pic_0")).toBe(first); + expect(first).toHaveAttribute("src", firstUrl); + expect(revoke).not.toHaveBeenCalled(); + expect(firstUrl).toMatch(/^blob:/); + expect((await (await fetch(firstUrl)).arrayBuffer()).byteLength).toBe(4); + fireEvent.click(first); + expect(screen.getByRole("dialog").querySelector("img")).toHaveAttribute( + "src", + firstUrl, + ); + rerender(tree(uids.slice(1))); + expect(first.isConnected).toBe(false); + expect(revoke).not.toHaveBeenCalledWith(firstUrl); + expect((await (await fetch(firstUrl)).arrayBuffer()).byteLength).toBe(4); + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + expect(revoke).toHaveBeenCalledWith(firstUrl); + expect(revoke).toHaveBeenCalledTimes(1); + await expect(fetch(firstUrl)).rejects.toThrow(); + unmount(); + expect(revoke).toHaveBeenCalledTimes(61); + }); + + test("two consumers sharing a UID retain it until both unmount", async () => { + const revoke = vi.spyOn(URL, "revokeObjectURL").mockClear(); + const client = runtimeClientStub(); + const tree = (count: number) => ( + + + ({ + id, + uid: "pic_shared", + }))} + /> + + + ); + const { rerender } = render(tree(2)); + const first = await screen.findByAltText("first"); + const second = await screen.findByAltText("second"); + const url = first.getAttribute("src"); + expect(second).toHaveAttribute("src", url); + expect(client.previewAttachment).toHaveBeenCalledTimes(1); + rerender(tree(1)); + expect(revoke).not.toHaveBeenCalledWith(url); + rerender(tree(0)); + expect(revoke).toHaveBeenCalledWith(url); + }); + + test("opening a viewer and removing its source in the same render transfers the reference", async () => { + const revoke = vi.spyOn(URL, "revokeObjectURL").mockClear(); + render( + + + + + , + ); + const thumbnail = await screen.findByAltText("handoff"); + const url = thumbnail.getAttribute("src"); + fireEvent.click(thumbnail); + expect(thumbnail.isConnected).toBe(false); + expect(screen.getByRole("dialog").querySelector("img")).toHaveAttribute( + "src", + url, + ); + expect(revoke).not.toHaveBeenCalledWith(url); + fireEvent.click(screen.getByRole("button", { name: "关闭" })); + expect(revoke).toHaveBeenCalledWith(url); + }); + + test("StrictMode cleanup does not discard the current consumer's blob", async () => { + const revoke = vi.spyOn(URL, "revokeObjectURL").mockClear(); + const { unmount } = render( + + + + + + + , + ); + const thumbnail = await screen.findByAltText("strict"); + const url = thumbnail.getAttribute("src"); + expect(revoke).not.toHaveBeenCalledWith(url); + unmount(); + expect(revoke).toHaveBeenCalledWith(url); + }); +}); diff --git a/apps/undefined-chat/src/runtime-client/tauri.test.ts b/apps/undefined-chat/src/runtime-client/tauri.test.ts index b74a56af..74bb9470 100644 --- a/apps/undefined-chat/src/runtime-client/tauri.test.ts +++ b/apps/undefined-chat/src/runtime-client/tauri.test.ts @@ -17,6 +17,23 @@ describe("createTauriRuntimeClient", () => { vi.resetAllMocks(); }); + test("clears the persisted target through the registered Native command", async () => { + vi.mocked(invoke).mockResolvedValueOnce(undefined); + await createTauriRuntimeClient().clearRuntimeConfig(); + expect(invoke).toHaveBeenCalledWith("clear_runtime_config"); + }); + + test("does not persist a URL if the API key status request rejects", async () => { + vi.mocked(invoke).mockRejectedValueOnce( + new Error("key status unavailable"), + ); + await expect( + createTauriRuntimeClient().saveRuntimeConfig("http://runtime-b.test"), + ).rejects.toThrow("key status unavailable"); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith("load_api_key_status"); + }); + test("normalizes conversation and active job responses from Tauri commands", async () => { vi.mocked(invoke).mockResolvedValueOnce({ status: 200, diff --git a/apps/undefined-chat/src/runtime-client/tauri.ts b/apps/undefined-chat/src/runtime-client/tauri.ts index 0bdedde8..50e659c5 100644 --- a/apps/undefined-chat/src/runtime-client/tauri.ts +++ b/apps/undefined-chat/src/runtime-client/tauri.ts @@ -217,8 +217,39 @@ function normalizeEvent(value: unknown): ChatEvent { }; } -function normalizeToolCall(value: unknown): ToolCallSnapshot { - const raw = record(value); +function normalizeToolCall( + value: unknown, + liveCalls?: Map, +): ToolCallSnapshot { + const original = record(value); + const id = + text(original.id) || + text(original.webchat_call_id) || + text(original.webchatCallId) || + text(original.name, "tool"); + const raw: RawRecord = { + ...original, + ...liveCalls?.get(id), + children: original.children, + timeline: original.timeline, + }; + const children = new Map( + arrayRecords(raw.children).map((child) => { + const normalized = normalizeToolCall(child, liveCalls); + return [normalized.id, normalized]; + }), + ); + const timeline = Array.isArray(raw.timeline) + ? raw.timeline.map((value) => { + const entry = record(value); + if (entry.type !== "call" || !entry.call) return value; + const child = normalizeToolCall(entry.call, liveCalls); + children.set(child.id, child); + // 流式更新只改 children;时间线引用同一个子块,不能保留旧 inline 状态。 + return { type: "call", seq: numberValue(entry.seq), callId: child.id }; + }) + : undefined; + return { id: text(raw.id) || @@ -229,7 +260,11 @@ function normalizeToolCall(value: unknown): ToolCallSnapshot { status: text(raw.status, "running"), elapsedMs: nullableNumber(field(raw, "elapsedMs", "elapsed_ms")), durationMs: nullableNumber(field(raw, "durationMs", "duration_ms")), - detail: text(raw.detail) || text(raw.result_preview) || undefined, + detail: + text(raw.detail) || + text(raw.current_stage_detail) || + text(raw.result_preview) || + undefined, argumentsPreview: text(raw.argumentsPreview) || text(raw.arguments_preview) || @@ -244,8 +279,8 @@ function normalizeToolCall(value: unknown): ToolCallSnapshot { currentStage: text(raw.currentStage) || text(raw.current_stage) || undefined, isAgent: bool(raw.isAgent) || bool(raw.is_agent) || undefined, - children: arrayRecords(raw.children).map(normalizeToolCall), - timeline: Array.isArray(raw.timeline) ? raw.timeline : undefined, + children: [...children.values()], + timeline, }; } @@ -273,6 +308,37 @@ function normalizeJob(value: unknown): ChatJob | null { if (!jobId) { return null; } + const rawTimeline = field(raw, "currentTimeline", "current_timeline"); + const hasTimelineSnapshot = Array.isArray(rawTimeline); + const liveCalls = arrayRecords( + field(raw, "currentToolCalls", "current_tool_calls"), + ); + const liveById = new Map( + liveCalls.map((call) => [ + text(call.id) || + text(call.webchat_call_id) || + text(call.webchatCallId) || + text(call.name, "tool"), + call, + ]), + ); + const currentToolCalls: ToolCallSnapshot[] = []; + const currentTimeline: ChatJob["currentTimeline"] = []; + for (const entry of arrayRecords(rawTimeline)) { + const seq = numberValue(entry.seq); + if (entry.type === "message" && text(entry.content)) { + currentTimeline.push({ + type: "message", + seq, + content: text(entry.content), + }); + } else if (entry.type === "call" && entry.call) { + const call = normalizeToolCall(entry.call, liveById); + currentToolCalls.push(call); + currentTimeline.push({ type: "call", seq, callId: call.id }); + } + } + return { jobId, conversationId: text(field(raw, "conversationId", "conversation_id")), @@ -299,11 +365,12 @@ function normalizeJob(value: unknown): ChatJob | null { currentAgentStages: arrayRecords( field(raw, "currentAgentStages", "current_agent_stages"), ).map(normalizeAgentStage), - currentToolCalls: arrayRecords( - field(raw, "currentToolCalls", "current_tool_calls"), - ).map(normalizeToolCall), + currentToolCalls: hasTimelineSnapshot + ? currentToolCalls + : liveCalls.map((call) => normalizeToolCall(call)), historyFinalized: bool(field(raw, "historyFinalized", "history_finalized")), - currentTimeline: [], + currentTimeline, + hasTimelineSnapshot, waitingInput: field(raw, "waitingInput", "waiting_input") ?? null, }; } @@ -464,12 +531,11 @@ export function createTauriRuntimeClient(): RuntimeClient { }, async saveRuntimeConfig(runtimeUrl: string): Promise { - const [config, apiKeyStatus] = await Promise.all([ - invoke("save_runtime_config", { - input: { runtimeUrl }, - }), - this.loadApiKeyStatus(), - ]); + // 先确认密钥状态可读取,避免并行读取失败时 URL 已保存却丢失回执。 + const apiKeyStatus = await this.loadApiKeyStatus(); + const config = await invoke("save_runtime_config", { + input: { runtimeUrl }, + }); const normalized = normalizeRuntimeConfig(config, apiKeyStatus); if (!normalized) { throw new Error("Runtime config response did not include runtimeUrl"); @@ -477,6 +543,10 @@ export function createTauriRuntimeClient(): RuntimeClient { return normalized; }, + async clearRuntimeConfig(): Promise { + await invoke("clear_runtime_config"); + }, + async saveApiKey(apiKey: string): Promise { return normalizeApiKeyStatus(await invoke("save_api_key", { apiKey })); }, diff --git a/apps/undefined-chat/src/runtime-client/types.ts b/apps/undefined-chat/src/runtime-client/types.ts index 949c19ac..5d626649 100644 --- a/apps/undefined-chat/src/runtime-client/types.ts +++ b/apps/undefined-chat/src/runtime-client/types.ts @@ -138,6 +138,8 @@ export type ChatJob = { currentToolCalls: ToolCallSnapshot[]; /** 流式时间线:按事件到达顺序的 message 段 + 顶层 call(对齐 WebUI append 顺序) */ currentTimeline: StreamingTimelineItem[]; + /** Runtime 提供完整时间线快照;缺省时兼容旧服务的 reply-only 快照。 */ + hasTimelineSnapshot?: boolean; historyFinalized: boolean; waitingInput: unknown | null; }; @@ -293,6 +295,7 @@ export type EventStreamSubscription = { export type RuntimeClient = { getRuntimeConfig: () => Promise; saveRuntimeConfig: (runtimeUrl: string) => Promise; + clearRuntimeConfig: () => Promise; saveApiKey: (apiKey: string) => Promise; confirmInsecureStorageFallback: () => Promise; loadApiKeyStatus: () => Promise; diff --git a/apps/undefined-chat/src/test-fixtures.ts b/apps/undefined-chat/src/test-fixtures.ts index e10b778e..08ac82b8 100644 --- a/apps/undefined-chat/src/test-fixtures.ts +++ b/apps/undefined-chat/src/test-fixtures.ts @@ -153,6 +153,7 @@ export function runtimeClientStub( runtimeUrl, hasApiKey: true, })), + clearRuntimeConfig: vi.fn(async () => undefined), saveApiKey: vi.fn(async () => apiKeyStatus), confirmInsecureStorageFallback: vi.fn(async () => ({ ...apiKeyStatus, diff --git a/apps/undefined-chat/tests/build-chunks.node.mjs b/apps/undefined-chat/tests/build-chunks.node.mjs new file mode 100644 index 00000000..535da54f --- /dev/null +++ b/apps/undefined-chat/tests/build-chunks.node.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import hljs from "highlight.js"; +import { build, createLogger } from "vite"; + +test("production chunks preserve full highlighting without oversized or circular chunks", async () => { + const directory = await mkdtemp(join(tmpdir(), "undefined-chat-build-")); + try { + const warnings = []; + const logger = createLogger("silent"); + logger.warn = (message) => warnings.push(message); + const result = await build({ + root: fileURLToPath(new URL("..", import.meta.url)), + customLogger: logger, + build: { outDir: directory, emptyOutDir: true }, + }); + assert.ok(!("close" in result), "Expected a one-shot production build"); + const outputs = Array.isArray(result) ? result : [result]; + const chunks = outputs.flatMap((output) => + output.output.filter((item) => item.type === "chunk"), + ); + assert.ok(chunks.length > 1); + assert.deepEqual(warnings, []); + const byFile = new Map(chunks.map((chunk) => [chunk.fileName, chunk])); + for (const chunk of chunks) { + assert.ok(Buffer.byteLength(chunk.code) <= 500_000, chunk.fileName); + } + + function checkImports(file, ancestors) { + assert.ok( + !ancestors.includes(file), + `Circular chunk import through ${file}`, + ); + const chunk = byFile.get(file); + assert.ok(chunk, `Missing emitted chunk ${file}`); + for (const dependency of [...chunk.imports, ...chunk.dynamicImports]) { + checkImports(dependency, [...ancestors, file]); + } + } + for (const chunk of chunks) checkImports(chunk.fileName, []); + + // Execute the emitted CJS wrappers and language chunks, not source imports. + await writeFile(join(directory, "package.json"), '{"type":"module"}'); + const highlighterChunk = chunks.find((chunk) => + Object.keys(chunk.modules).some((id) => + id.endsWith("/highlight.js/lib/index.js"), + ), + ); + assert.ok(highlighterChunk); + const exports = await import( + pathToFileURL(join(directory, highlighterChunk.fileName)).href + ); + const bundled = Object.values(exports).find( + (value) => typeof value?.listLanguages === "function", + ); + assert.ok(bundled, "Missing initialized production highlighter"); + assert.deepEqual(bundled.listLanguages(), hljs.listLanguages()); + for (const language of hljs.listLanguages()) { + const code = 'if (value) { return "hello"; } // test'; + assert.equal( + bundled.highlight(code, { language, ignoreIllegals: true }).value, + hljs.highlight(code, { language, ignoreIllegals: true }).value, + language, + ); + } + const code = 'def greet(name):\n return "hello " + name'; + assert.equal( + bundled.highlightAuto(code).language, + hljs.highlightAuto(code).language, + ); + assert.equal( + bundled.highlightAuto(code).value, + hljs.highlightAuto(code).value, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/undefined-chat/tests/e2e/connection-setup.test.tsx b/apps/undefined-chat/tests/e2e/connection-setup.test.tsx index 28cfefaa..0f3c4ffa 100644 --- a/apps/undefined-chat/tests/e2e/connection-setup.test.tsx +++ b/apps/undefined-chat/tests/e2e/connection-setup.test.tsx @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { App } from "../../src/App"; import { createTauriRuntimeClient } from "../../src/runtime-client/tauri"; +import type { RuntimeConfig } from "../../src/runtime-client/types"; import { runtimeClientStub } from "../../src/test-fixtures"; import { renderWithProviders } from "../../src/test-utils"; @@ -50,12 +51,12 @@ describe("E2E: Connection Setup Flow", () => { test("保存配置并在连接后引导应用", async () => { const client = runtimeClientStub({ getRuntimeConfig: vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ + .fn(async (): Promise => ({ runtimeUrl: "http://localhost:8788", hasApiKey: true, - }), + })) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), }); vi.mocked(createTauriRuntimeClient).mockReturnValue(client); @@ -118,12 +119,12 @@ describe("E2E: Connection Setup Flow", () => { test("支持不安全存储降级选项", async () => { const client = runtimeClientStub({ getRuntimeConfig: vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ + .fn(async (): Promise => ({ runtimeUrl: "http://localhost:8788", hasApiKey: true, - }), + })) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null), }); vi.mocked(createTauriRuntimeClient).mockReturnValue(client); @@ -168,7 +169,9 @@ describe("E2E: Connection Setup Flow", () => { await userEvent.type(keyInput, "sk-test"); await userEvent.click(connectBtn); - expect(await screen.findByText("网络连接失败")).toBeInTheDocument(); + expect(await screen.findByText(/网络连接失败/)).toBeInTheDocument(); + expect(client.clearRuntimeConfig).toHaveBeenCalledOnce(); + expect(screen.getByLabelText("消息输入")).toBeDisabled(); }); test("已有配置时可以通过设置按钮重新打开配置面板", async () => { diff --git a/apps/undefined-chat/vite.config.ts b/apps/undefined-chat/vite.config.ts index b24829ad..d7d80a60 100644 --- a/apps/undefined-chat/vite.config.ts +++ b/apps/undefined-chat/vite.config.ts @@ -1,6 +1,28 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; +function manualChunks(id: string): string | undefined { + // Keep CJS helpers independent so vendor chunks never need to import the app. + if (id.includes("commonjsHelpers.js")) return "vendor-helpers"; + const language = id.match(/\/highlight\.js\/lib\/languages\/([^/.]+)\.js/); + if (language) { + // Keep every language synchronously registered, in bounded stable groups. + const initial = language[1][0]; + const group = + initial < "g" + ? "a-f" + : initial < "m" + ? "g-l" + : initial < "s" + ? "m-r" + : "s-z"; + return `highlight-languages-${group}`; + } + if (id.includes("/node_modules/highlight.js/")) return "highlight"; + if (/\/node_modules\/(react|react-dom|scheduler)\//.test(id)) return "react"; + if (id.includes("/node_modules/")) return "vendor"; +} + export default defineConfig({ plugins: [react()], server: { @@ -13,6 +35,12 @@ export default defineConfig({ }, build: { target: ["es2022", "chrome110", "safari16"], + rollupOptions: { + output: { + manualChunks, + onlyExplicitManualChunks: true, + }, + }, }, test: { environment: "jsdom", diff --git a/apps/undefined-console/README.md b/apps/undefined-console/README.md index c89902d8..67ee0771 100644 --- a/apps/undefined-console/README.md +++ b/apps/undefined-console/README.md @@ -7,7 +7,7 @@ A `Tauri v2` management client for Undefined that targets the remote Management - Desktop builds for `Windows`, `macOS`, and `Linux` - Android build support from the same codebase - Mobile-friendly shell for connecting to remote instances -- Separate connection profiles for `Management` and `Runtime-only` modes +- Local connection profiles with separate Management and Runtime protocol/port settings ## Scripts @@ -39,8 +39,12 @@ npm run tauri:build:no-strip ## Connection model -- **Management mode**: connect with `management_url` and a password/token flow. -- **Runtime-only mode**: connect with `runtime_url` and `X-Undefined-API-Key` compatible credentials. +- **Management**: open the configured WebUI, optionally logging in with its management password before navigation. +- **Runtime**: probe the configured Runtime's `/health` and `/openapi.json`. Chat authentication is configured in Undefined Chat or the WebUI. + +The connection editor stores a host and separate protocol/port choices for Management and Runtime. New profiles use HTTP by default; select HTTPS for TLS endpoints. Existing profiles using `managementUrl` / `runtimeUrl` are migrated with their original HTTP/HTTPS protocols and ports. IPv6 hosts accept either `::1` or `[::1]` and produce a URL with a single pair of brackets. Saving and reloading a migrated profile preserves these choices. + +`npm test` runs the connection migration, save/reload, probe, and login regressions with in-memory storage and network/navigation stubs. It is included in `npm run check` and never contacts a real instance. This scaffold intentionally keeps the frontend shell lightweight. It is suitable for embedding into both browser-hosted WebUI and Tauri clients. diff --git a/apps/undefined-console/biome.json b/apps/undefined-console/biome.json index 98b69ab7..f60f58d2 100644 --- a/apps/undefined-console/biome.json +++ b/apps/undefined-console/biome.json @@ -5,6 +5,7 @@ "files": { "includes": [ "**/src/**/*.ts", + "**/src/**/*.cjs", "**/package.json", "**/tsconfig.json", "**/vite.config.ts", diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json index 559d4873..a29b5aad 100644 --- a/apps/undefined-console/package-lock.json +++ b/apps/undefined-console/package-lock.json @@ -1,12 +1,12 @@ { "name": "undefined-console", - "version": "3.18.4", + "version": "3.18.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undefined-console", - "version": "3.18.4", + "version": "3.18.5", "dependencies": { "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-http": "^2.3.0" diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json index 131b5758..a19284a8 100644 --- a/apps/undefined-console/package.json +++ b/apps/undefined-console/package.json @@ -1,7 +1,7 @@ { "name": "undefined-console", "private": true, - "version": "3.18.4", + "version": "3.18.5", "type": "module", "scripts": { "tauri": "tauri", @@ -9,9 +9,10 @@ "lint": "biome check src package.json tsconfig.json vite.config.ts src-tauri/tauri.conf.json src-tauri/capabilities/default.json", "lint:webui": "biome check --config-path ../../biome.json ../../src/Undefined/webui/static/js", "typecheck": "tsc --noEmit", + "test": "node --test src/connection-profiles.test.cjs", "tauri:fmt:check": "cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", "tauri:check": "cargo check --manifest-path src-tauri/Cargo.toml", - "check": "npm run lint && npm run lint:webui && npm run typecheck && npm run tauri:fmt:check && npm run tauri:check", + "check": "npm run lint && npm run lint:webui && npm run typecheck && npm run test && npm run tauri:fmt:check && npm run tauri:check", "build": "npm run typecheck && vite build", "preview": "vite preview --host 0.0.0.0 --port 4173", "tauri:dev": "tauri dev", diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock index 56f7ac0b..4e084ae2 100644 --- a/apps/undefined-console/src-tauri/Cargo.lock +++ b/apps/undefined-console/src-tauri/Cargo.lock @@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "undefined_console" -version = "3.18.4" +version = "3.18.5" dependencies = [ "serde", "serde_json", diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml index 5b98b80b..e402ee5b 100644 --- a/apps/undefined-console/src-tauri/Cargo.toml +++ b/apps/undefined-console/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "undefined_console" -version = "3.18.4" +version = "3.18.5" description = "Undefined cross-platform management console" authors = ["Undefined contributors"] license = "MIT" diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json index 219a3da0..85e5607f 100644 --- a/apps/undefined-console/src-tauri/tauri.conf.json +++ b/apps/undefined-console/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Undefined Console", - "version": "3.18.4", + "version": "3.18.5", "identifier": "com.undefined.console", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/undefined-console/src/connection-profiles.test.cjs b/apps/undefined-console/src/connection-profiles.test.cjs new file mode 100644 index 00000000..8bb0c50e --- /dev/null +++ b/apps/undefined-console/src/connection-profiles.test.cjs @@ -0,0 +1,252 @@ +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { test } = require("node:test"); +const ts = require("typescript"); + +const PROFILE_KEY = "undefined.console.profiles"; +const source = readFileSync(join(__dirname, "main.ts"), "utf8"); +const compiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, +}).outputText; + +// Execute the actual app and its form handlers. Storage, HTTP and navigation are +// isolated in memory; imports outside the two mocked Tauri modules are rejected. +function loadConsole(profiles, { native = false } = {}) { + const storage = new Map(); + if (profiles !== undefined) + storage.set(PROFILE_KEY, JSON.stringify(profiles)); + const calls = []; + const navigations = []; + const app = { innerHTML: "" }; + const form = { + values: {}, + addEventListener(event, handler) { + this[event] = handler; + }, + }; + const document = { + documentElement: { lang: "", dataset: {} }, + querySelector(selector) { + if (selector === "#app") return app; + if (selector === "#profile-form") return form; + return null; + }, + querySelectorAll: () => [], + }; + const request = async (url, init = {}) => { + // No branch invokes Node/global fetch or any real Runtime. + calls.push({ url, ...init }); + return new Response( + JSON.stringify({ + access_token: "FAKE-TOKEN", + refresh_token: "FAKE-REFRESH", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + const exports = {}; + const load = new Function( + "require", + "exports", + "localStorage", + "document", + "window", + "fetch", + "FormData", + `${compiled}\nreturn { state, openWebui, runProbes, buildManagementUrl, buildRuntimeUrl };`, + ); + const consoleApp = load( + (name) => { + if (name === "@tauri-apps/api/core") return { isTauri: () => native }; + if (name === "@tauri-apps/plugin-http") return { fetch: request }; + if (name === "./style.css") return {}; + throw new Error(`Unexpected import: ${name}`); + }, + exports, + { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + }, + document, + { + location: { + href: "tauri://localhost/", + assign: (url) => navigations.push(url), + }, + }, + request, + class FormData { + constructor(currentForm) { + this.values = currentForm.values; + } + get(key) { + return this.values[key] ?? null; + } + }, + ); + return { + ...consoleApp, + app, + storage, + calls, + navigations, + submit(values) { + form.values = values; + form.submit({ currentTarget: form, preventDefault() {} }); + }, + }; +} + +test("legacy HTTPS origins stay HTTPS through the actual password login and probes", async () => { + const app = loadConsole( + [ + { + id: "tls", + managementUrl: "https://secure.example:443", + runtimeUrl: "https://secure.example:9443", + password: "FAKE-PASSWORD", + }, + ], + { native: true }, + ); + const profile = app.state.profiles[0]; + assert.equal(profile.managementScheme, "https"); + assert.equal(profile.runtimeScheme, "https"); + assert.equal(profile.managementPort, "443"); + assert.equal(profile.runtimePort, "9443"); + await app.openWebui(); + assert.equal( + app.calls[0].url, + "https://secure.example:443/api/v1/management/auth/login", + ); + assert.equal(app.calls[0].method, "POST"); + assert.equal(app.calls[0].body, '{"password":"FAKE-PASSWORD"}'); + assert.equal(new URL(app.navigations[0]).origin, "https://secure.example"); + await app.runProbes(); + assert.ok(app.calls.every((call) => new URL(call.url).protocol === "https:")); + assert.ok( + app.calls.some((call) => call.url === "https://secure.example:9443/health"), + ); +}); + +test("legacy IPv6 produces one pair of brackets and reaches login/navigation", async () => { + const app = loadConsole([ + { + id: "ipv6", + managementUrl: "http://[::1]:8787", + runtimeUrl: "https://[::1]:9443", + password: "FAKE-PASSWORD", + }, + ]); + const profile = app.state.profiles[0]; + assert.equal(profile.host, "::1"); + assert.equal(app.buildManagementUrl(profile), "http://[::1]:8787"); + assert.equal(app.buildRuntimeUrl(profile), "https://[::1]:9443"); + await app.openWebui(); + assert.equal( + app.calls[0].url, + "http://[::1]:8787/api/v1/management/auth/login", + ); + assert.equal(new URL(app.navigations[0]).origin, "http://[::1]:8787"); +}); + +test("the editor saves migrated protocol choices and reloads them without legacy URL fields", async () => { + const app = loadConsole([ + { + id: "migration", + name: "TLS connection", + managementUrl: "https://[2001:db8::1]:8443", + runtimeUrl: "http://[2001:db8::1]:8788", + }, + ]); + assert.match( + app.app.innerHTML, + /name="managementScheme">