From fdcc04802e6385d8cbf4ee63fc1b769745a8db27 Mon Sep 17 00:00:00 2001 From: patricLee Date: Fri, 21 Aug 2026 15:58:28 +0800 Subject: [PATCH 1/4] fix(boss): stabilize periodic unread chat loading Reuse a ready owned chat tab, perform one bounded recovery on load timeout, and add orchestration regression tests plus investigation notes. --- output/boss-chat-tab-timeout-delivery.md | 55 +++++ output/boss-chat-tab-timeout-diagnosis.md | 34 ++++ output/boss-chat-tab-timeout-fix.md | 27 +++ output/boss-chat-tab-timeout-review.md | 29 +++ src-tauri/src/rpa/boss/handler/chat_list.rs | 5 + .../src/rpa/boss/handler/reply_unread.rs | 191 +++++++++++++++++- 6 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 output/boss-chat-tab-timeout-delivery.md create mode 100644 output/boss-chat-tab-timeout-diagnosis.md create mode 100644 output/boss-chat-tab-timeout-fix.md create mode 100644 output/boss-chat-tab-timeout-review.md diff --git a/output/boss-chat-tab-timeout-delivery.md b/output/boss-chat-tab-timeout-delivery.md new file mode 100644 index 0000000..18d54b0 --- /dev/null +++ b/output/boss-chat-tab-timeout-delivery.md @@ -0,0 +1,55 @@ +# Delivery + +状态:Partial +- 步骤 review 最新一轮评审仍有 blocking:[qa-engineer] Bugfix 缺少修复前红灯证据:`output/boss-chat-tab-timeout-fix.md:13-18` 只描述修复后的行为测试,共享黑板也只记录修复后 `reply_unread` 测试 9 passed、0 failed;没有真实命令输出证明其中至少一个回归用例在旧的无条件导航实现上失败。按 bugfix 红→绿要求,现有证据不足以支撑可合并,需要保留旧实现或修复前提交上的同一目标测试失败输出,以及当前实现上的通过输出。 +- 步骤 review 最新一轮评审仍有 blocking:[qa-engineer] 关键错误分支“首次等待失败后,恢复导航本身失败”没有行为测试覆盖。实现位于 `src-tauri/src/rpa/boss/handler/reply_unread.rs:218-221`,但现有四个编排测试 `src-tauri/src/rpa/boss/handler/reply_unread.rs:743-785` 只覆盖就绪复用、首次等待成功、恢复等待成功和两次等待超时;没有给 `navigate_results` 注入恢复导航错误并断言恰好两次导航、仅一次等待,以及错误同时保留首次等待和恢复导航上下文。这是已批准诊断合同 `output/boss-chat-tab-timeout-diagnosis.md:28-30` 所要求的最终失败上下文路径,属于关键失败路径测试断裂。 +- 步骤 review 最新一轮评审仍有 blocking:[devops-engineer] 缺少真实 BOSS 运行环境中的启动与行为响应证据,现有证据只能证明编排单元测试和静态检查通过,不能证明周期任务在标签超时场景下已恢复稳定运行,因此不足以支撑本修复为 release-ready。`output/boss-chat-tab-timeout-fix.md:28` 明确承认线上页面与风控响应无法由本地单元测试完整复现;至少需要核对修复版本的运行日志,证明就绪页被复用、首次失败时仅恢复一次,并且后续能够进入未读列表处理或明确终止。 +- 步骤 review 最新一轮评审仍有 blocking:[devops-engineer] `output/boss-chat-tab-timeout-fix.md:3-8,28` 将重复冷导航直接定性为“根因”和“已确认”,但文档同时承认没有真实线上复现;当前日志只能支持该因素与故障相符,尚不能排除登录状态、风控页面、标签 DOM 陈旧或接口加载异常。该完成措辞超出现有证据,存在发布结论失真的风险。 + +## 交付范围 +- 目标:定位 BOSS 周期间歇自动回复反复等待会话分类标签超时的根因,实施稳健修复并验证 +- 路由:class=debug · kind=bugfix · depth=standard +- 计划 boss-chat-tab-timeout:3/3 done · 0 blocked · 0 未结算 + - [done] diagnose 追踪沟通页面导航、分类标签等待与周期重试链路,形成根因诊断(backend-engineer,验收 source-present) + - [done] fix 修复分类标签就绪判定与异常恢复逻辑并补充回归测试(backend-engineer,验收 build-test) + - [done] review 评审故障修复的正确性、回归风险与可观测性(qa-engineer,验收 review-clean) + +## 关键文件 +- src-tauri/src/rpa/boss/handler/chat_list.rs(主会话声明的改动) +- src-tauri/src/rpa/boss/handler/reply_unread.rs(主会话声明的改动) +- output/boss-chat-tab-timeout-fix.md(主会话声明的改动) +- output/boss-chat-tab-timeout-review.md(主会话声明的改动) + +## 验证 +- [diagnose] source-present → pass +- [fix] source-present → pass +- [fix] `pnpm run build` → pass exit=0 +- [fix] `pnpm run test` → pass exit=0 +- [fix] lint → skipped +- [fix] typecheck → skipped +- [review] review-clean → skipped +- 合同:N/A —— 本次改动不涉及前后端接口合同 +- 治理/安全:review:安全席无 blocking(仅评审意见,未替代自动化扫描) +- 运行:not verified —— 未启动服务做真实运行探测 +- 部署:not deployed —— 未获部署授权,也未执行任何部署动作 + +## 未完成或风险 +- 步骤 review 最新一轮评审仍有 blocking:[qa-engineer] Bugfix 缺少修复前红灯证据:`output/boss-chat-tab-timeout-fix.md:13-18` 只描述修复后的行为测试,共享黑板也只记录修复后 `reply_unread` 测试 9 passed、0 failed;没有真实命令输出证明其中至少一个回归用例在旧的无条件导航实现上失败。按 bugfix 红→绿要求,现有证据不足以支撑可合并,需要保留旧实现或修复前提交上的同一目标测试失败输出,以及当前实现上的通过输出。 +- 步骤 review 最新一轮评审仍有 blocking:[qa-engineer] 关键错误分支“首次等待失败后,恢复导航本身失败”没有行为测试覆盖。实现位于 `src-tauri/src/rpa/boss/handler/reply_unread.rs:218-221`,但现有四个编排测试 `src-tauri/src/rpa/boss/handler/reply_unread.rs:743-785` 只覆盖就绪复用、首次等待成功、恢复等待成功和两次等待超时;没有给 `navigate_results` 注入恢复导航错误并断言恰好两次导航、仅一次等待,以及错误同时保留首次等待和恢复导航上下文。这是已批准诊断合同 `output/boss-chat-tab-timeout-diagnosis.md:28-30` 所要求的最终失败上下文路径,属于关键失败路径测试断裂。 +- 步骤 review 最新一轮评审仍有 blocking:[devops-engineer] 缺少真实 BOSS 运行环境中的启动与行为响应证据,现有证据只能证明编排单元测试和静态检查通过,不能证明周期任务在标签超时场景下已恢复稳定运行,因此不足以支撑本修复为 release-ready。`output/boss-chat-tab-timeout-fix.md:28` 明确承认线上页面与风控响应无法由本地单元测试完整复现;至少需要核对修复版本的运行日志,证明就绪页被复用、首次失败时仅恢复一次,并且后续能够进入未读列表处理或明确终止。 +- 步骤 review 最新一轮评审仍有 blocking:[devops-engineer] `output/boss-chat-tab-timeout-fix.md:3-8,28` 将重复冷导航直接定性为“根因”和“已确认”,但文档同时承认没有真实线上复现;当前日志只能支持该因素与故障相符,尚不能排除登录状态、风控页面、标签 DOM 陈旧或接口加载异常。该完成措辞超出现有证据,存在发布结论失真的风险。 +- 未验证:fix / lint:skipped —— 未探测到该项目的对应命令,也没有在 .pi/dev/config.json 中配置 +- 未验证:fix / typecheck:skipped —— 未探测到该项目的对应命令,也没有在 .pi/dev/config.json 中配置 +- 未验证:review / review-clean:skipped —— 只读评审由 dev_review 调度,不在机械验收中判定 + +## 证据 +- `.pi/dev/plan.json`:计划 DAG 与每步状态(可恢复) +- `.pi/dev/route.json`:本次路由定级与理由 +- `.pi/dev/ledger.jsonl`:append-only 审计账本(路由 / 验收 / 评审 / 确认门 / 交付) +- 验收报告 4 份,最近一次 review @ 2026-08-21T06:09:15.477Z +- `.pi/dev/evidence/`:命令完整输出(报告中的 output 已截断) +- 评审报告 2 份,含各席位 accepts / blocking / advisory / evidence 原文 +- `.pi/dev/blackboard.md`:当前黑板(合同、实际状态、finding、待确认项) + +## 恢复/继续 +- 计划已全部结算;继续新目标请先调用 dev_route 重新定级,不要沿用本次路由 \ No newline at end of file diff --git a/output/boss-chat-tab-timeout-diagnosis.md b/output/boss-chat-tab-timeout-diagnosis.md new file mode 100644 index 0000000..19d9839 --- /dev/null +++ b/output/boss-chat-tab-timeout-diagnosis.md @@ -0,0 +1,34 @@ +# BOSS 周期间歇自动回复超时诊断 + +## 结论 + +反复失败不是模型回复、未读列表切换或登录必然失效,而是**长驻周期任务复用同一个 owned tab,却在每一轮回复开始时都对当前沟通页再次执行 `page.get(BOSS_CHAT_URL)`,制造了不必要的整页冷导航**。BOSS 沟通页冷加载不稳定时,页面在 20 秒内没有重新挂载 `.label-list li .label-name`,于是 `wait_for_chat_page` 抛出日志中的固定错误。 + +## 证据链 + +1. 日志错误文本只可能来自 `src-tauri/src/rpa/boss/handler/chat_list.rs:53-54`:等待 `.label-list li .label-name` 超时。 +2. `src-tauri/src/rpa/boss/handler/reply_unread.rs:50-52` 每轮均先记录“正在打开沟通页面”,随后无条件调用 `page.get(BOSS_CHAT_URL)`,再等待分类标签。 +3. 该运行是 managed 周期投递任务。`src-tauri/src/rpa/run_flow.rs` 中 `PeriodicTarget::OwnedTab` 会让同步历史、自动回复和投递长期共用任务的 `main_tab`;`idle_until` 每个轮询周期都会再次调用 `run_reply_round`。因此这里不是一次性新标签页,无条件 `get` 会每隔数分钟重复刷新同一页面。 +4. 12:06 历史同步刚在相同 tab 完成,12:06:53 自动回复又重新打开沟通页并成功;从 12:12 起连续刷新后大量超时,但 13:11、13:20 又能成功并打印“没有未读消息”。间歇成功证明: + - 选择器并非永久失效; + - 登录并非持续失效; + - 故障符合重复冷导航触发的页面加载/接口/风控抖动,而不是业务判断失败。 +5. 每次失败约发生在“正在打开沟通页面”后 20 多秒,与 `wait_for_chat_page(..., 20s)` 完全吻合;错误发生在任何未读列表逻辑之前。 + +## 根因与放大因素 + +- **直接根因**:复用 tab 的轮询路径缺少“当前沟通页已经就绪则直接复用”的快速路径。 +- **放大因素**:就绪失败后没有一次受控恢复,也不记录当前 URL/页面状态;所有失败都被笼统描述为“可能未登录或未加载完成”,导致调度器每隔数分钟以同样方式整页重载并重复失败。 +- **不是根因**:“会话没有可恢复的求职方案归属”只影响采用哪个回复配置;它发生在成功读取会话之后,与分类标签加载超时无关。 + +## 修复合同 + +1. 打开沟通页前先探测分类标签:已有标签时复用当前 DOM,不执行导航。 +2. 标签不存在时才导航;导航后等待标签。 +3. 首次导航等待失败时执行一次受控恢复(重新导航并等待),避免瞬时空白/未挂载直接判整轮失败;禁止无限重试。 +4. 最终失败信息应保留首次与恢复失败上下文,便于区分持续登录失效和偶发加载失败。 +5. 把“是否需要导航/是否恢复”的状态决策抽成可单元测试的纯逻辑,覆盖:已就绪、首次成功、首次失败后恢复成功、两次均失败。 + +## 风险边界 + +本地无法完整复现线上 BOSS 页面和风控响应,因此不能证明所有页面异常都消失。修复目标是去掉确定存在的重复冷加载,并为一次瞬时失败提供有界恢复;若恢复后仍无标签,应继续返回错误而不是把页面误判成“没有未读”。 diff --git a/output/boss-chat-tab-timeout-fix.md b/output/boss-chat-tab-timeout-fix.md new file mode 100644 index 0000000..aca029a --- /dev/null +++ b/output/boss-chat-tab-timeout-fix.md @@ -0,0 +1,27 @@ +# BOSS 周期间歇自动回复超时修复 + +## 高概率诱因(待真实环境确认) + +周期任务长期复用 owned tab,但此前每轮自动回复都会无条件 `page.get(BOSS_CHAT_URL)`。这会把已经可用的沟通页反复变成冷加载;BOSS 页面偶发未在 20 秒内重新挂载分类标签时,整轮便以“等待会话分类标签超时”失败。 + +日志中偶尔成功打印“没有未读消息”,说明标签选择器并非永久失效、登录也并非持续失效,故障与重复冷导航引起的间歇性加载失败高度吻合,但现有证据不能排除短时登录态、风控页、陈旧 DOM 或接口异常。 + +## 实际修复 + +- `src-tauri/src/rpa/boss/handler/chat_list.rs` + - 增加分类标签存在性探测 `is_chat_page_ready`。 +- `src-tauri/src/rpa/boss/handler/reply_unread.rs` + - 用 `ensure_chat_page` 替代每轮无条件导航,并以可替换的 `ChatPageDriver` 隔离浏览器副作用。 + - 当前 DOM 已有分类标签时直接复用页面。 + - 标签不存在才导航;首次等待失败后只执行一次恢复导航,禁止无限重试。 + - 最终错误同时保留首次等待及恢复导航/等待上下文。 + - 行为级回归测试直接驱动同一编排函数,断言:已就绪时 0 次导航、首次成功 1 次导航、恢复成功恰好 2 次导航、恢复导航失败保留两段上下文、两次超时后终止且错误包含两段上下文。 + +## 行为边界 + +- 正常就绪页面不再被周期性刷新。 +- 非沟通页或未就绪页仍会导航至 BOSS 沟通页。 +- 一次瞬时冷加载失败有一次恢复机会。 +- 两次均失败仍返回错误,不会误报成“没有未读消息”。 +- 线上页面与风控响应无法由本地单元测试完整复现;本修复消除代码中确定存在的每轮无条件冷加载,并增强有界恢复与诊断信息,但线上故障是否消失仍需修复版本运行日志确认。 +- 修复前没有保留同一行为测试的红灯命令输出;原始运行日志与旧代码中的无条件 `page.get` 是故障证据,但不等价于严格的测试红灯记录。 diff --git a/output/boss-chat-tab-timeout-review.md b/output/boss-chat-tab-timeout-review.md new file mode 100644 index 0000000..4c432c2 --- /dev/null +++ b/output/boss-chat-tab-timeout-review.md @@ -0,0 +1,29 @@ +# BOSS 沟通页超时修复评审结算 + +## 结论 + +评审已完成两轮。实现已解决“周期轮询无条件冷导航”的确定代码问题,并增加有界恢复和行为级编排测试,但交付状态仍应为 **Partial**,不能表述为线上故障已确认消失。 + +## 已验证 + +- 首轮修复后,`reply_unread` 目标测试运行结果为 9 passed、0 failed。 +- `cargo check --manifest-path src-tauri/Cargo.toml` 通过。 +- `pnpm run build` 通过。 +- 前端测试 22 个文件、202 条测试通过。 +- 行为测试覆盖:已就绪零导航、首次成功一次导航、首次等待失败后恢复成功恰好两次导航、两次等待失败终止且保留两段错误上下文。 + +## 评审后追加 + +- 增加“首次等待失败且恢复导航失败”测试,断言两次导航、一次等待,并保留首次等待及恢复导航错误。 +- 修正文档措辞:重复冷导航为高概率诱因,不再声称未经真实环境验证的唯一根因或线上治愈。 + +## 未验证 / 阻塞证据 + +- 新增最后一条测试后的重验未完成:第一次因 Windows MSVC `LNK1318` PDB LIMIT 链接失败;再次尝试因磁盘空间不足(os error 112)失败。两次均是构建环境失败,不是测试断言失败,但新增测试仍没有绿色运行证据。 +- 没有旧实现上同一行为测试的红灯命令输出;原运行日志和旧代码无条件 `page.get` 只能作为故障证据,不能冒充严格红→绿记录。 +- 没有修复版本在真实 BOSS owned tab 上连续轮询的运行日志,因此无法验证真实登录态、风控页、陈旧 DOM 和接口抖动场景。 +- 全仓 `cargo fmt --check` 受既存格式差异影响;lint 和独立 typecheck 未配置/未单独验证。 + +## 发布建议 + +暂不标记 release-ready。清理构建磁盘空间后应串行重跑目标 Rust 测试;随后在受控 BOSS 账号上观察至少若干轮日志,确认出现“沟通页面已就绪,复用当前页面”,并验证首次加载失败最多恢复一次且后续能进入未读列表或明确终止。 diff --git a/src-tauri/src/rpa/boss/handler/chat_list.rs b/src-tauri/src/rpa/boss/handler/chat_list.rs index 88d0b96..87fc25c 100644 --- a/src-tauri/src/rpa/boss/handler/chat_list.rs +++ b/src-tauri/src/rpa/boss/handler/chat_list.rs @@ -47,6 +47,11 @@ const RELOAD_VIA: &str = "全部"; /// 代价只是真的没有未读时多等 3 秒,比误报「没有未读」便宜得多。 const EMPTY_MUST_HOLD: Duration = Duration::from_secs(3); +/// 分类标签已经挂载时,当前 owned tab 可以直接复用,无需再次冷导航。 +pub(crate) fn is_chat_page_ready(page: &Page) -> Result { + Ok(page.ele(LABEL_ITEM)?.is_some()) +} + /// 等沟通页就绪。分类标签是页面骨架里最先稳定下来的部分,两个任务打开页面后 /// 的第一步都是等它——20 秒不是保守:一次冷加载实测就要 8 秒上下。 pub(crate) fn wait_for_chat_page(page: &Page, timeout: Duration) -> Result<(), anyhow::Error> { diff --git a/src-tauri/src/rpa/boss/handler/reply_unread.rs b/src-tauri/src/rpa/boss/handler/reply_unread.rs index 59fc80c..554e873 100644 --- a/src-tauri/src/rpa/boss/handler/reply_unread.rs +++ b/src-tauri/src/rpa/boss/handler/reply_unread.rs @@ -16,7 +16,11 @@ use crate::{ logger, rpa::{ boss::{ - handler::{actions::BossActions, chat_list, chat_list::ListState, send_messages}, + handler::{ + actions::BossActions, + chat_list::{self, ListState}, + send_messages, + }, model::{ChatMessage, UnreadChat}, BOSS_CHAT_URL, }, @@ -47,9 +51,7 @@ pub async fn reply_unread_on_page( app_runtime_config: &AppRuntimeConfig, ) -> Result, anyhow::Error> { let app_runtime_config = app_runtime_config.clone(); - logger::info("正在打开沟通页面")?; - page.get(BOSS_CHAT_URL).context("打开 BOSS 沟通页面失败")?; - chat_list::wait_for_chat_page(page, Duration::from_secs(20))?; + ensure_chat_page(page)?; // 每轮重新拉一次未读列表再处理一个会话。两个原因:点开会话会让它变已读、 // 从未读列表消失,整列 DOM 跟着重排,一次性取出的卡片快照从第二个起就指向 @@ -156,6 +158,78 @@ pub async fn reply_unread_on_page( Ok(Vec::new()) } +trait ChatPageDriver { + fn labels_ready(&mut self) -> Result; + fn navigate(&mut self) -> Result<(), anyhow::Error>; + fn wait_until_ready(&mut self) -> Result<(), anyhow::Error>; + fn info(&mut self, message: &str) -> Result<(), anyhow::Error>; + fn warning(&mut self, message: &str) -> Result<(), anyhow::Error>; +} + +struct BrowserChatPageDriver<'a> { + page: &'a rust_drission::Page, +} + +impl ChatPageDriver for BrowserChatPageDriver<'_> { + fn labels_ready(&mut self) -> Result { + chat_list::is_chat_page_ready(self.page).context("探测 BOSS 沟通页状态失败") + } + + fn navigate(&mut self) -> Result<(), anyhow::Error> { + self.page + .get(BOSS_CHAT_URL) + .context("打开 BOSS 沟通页面失败") + } + + fn wait_until_ready(&mut self) -> Result<(), anyhow::Error> { + chat_list::wait_for_chat_page(self.page, Duration::from_secs(20)) + } + + fn info(&mut self, message: &str) -> Result<(), anyhow::Error> { + logger::info(message) + } + + fn warning(&mut self, message: &str) -> Result<(), anyhow::Error> { + logger::warning(message) + } +} + +/// 优先复用 owned tab 中已经就绪的沟通页。只有分类标签不存在时才冷导航; +/// 首次等待失败后再做一次受控恢复,第二次仍失败就把两次上下文一起返回。 +fn ensure_chat_page(page: &rust_drission::Page) -> Result<(), anyhow::Error> { + ensure_chat_page_with(&mut BrowserChatPageDriver { page }) +} + +fn ensure_chat_page_with(driver: &mut impl ChatPageDriver) -> Result<(), anyhow::Error> { + if driver.labels_ready()? { + driver.info("沟通页面已就绪,复用当前页面")?; + return Ok(()); + } + + driver.info("正在打开沟通页面")?; + driver.navigate()?; + match driver.wait_until_ready() { + Ok(()) => Ok(()), + Err(first_error) => { + driver.warning(&format!( + "沟通页面首次加载未就绪,执行一次受控恢复:{first_error:#}" + ))?; + + if let Err(recovery_navigation_error) = driver.navigate() { + return Err(anyhow!( + "BOSS 沟通页恢复失败;首次等待:{first_error:#};恢复导航:{recovery_navigation_error:#}" + )); + } + + driver.wait_until_ready().map_err(|recovery_error| { + anyhow!( + "BOSS 沟通页两次加载均未就绪;首次等待:{first_error:#};恢复等待:{recovery_error:#}" + ) + }) + } + } +} + /// 处理单个已打开的会话。 /// /// 顺序上有两处是刻意安排的: @@ -613,6 +687,115 @@ fn mark_resume_sent(job_id: &str) { #[cfg(test)] mod tests { use super::*; + use std::collections::VecDeque; + + struct FakeChatPageDriver { + labels_ready: bool, + wait_results: VecDeque>, + navigate_results: VecDeque>, + navigate_calls: usize, + wait_calls: usize, + } + + impl FakeChatPageDriver { + fn new(labels_ready: bool, waits: &[Result<(), &'static str>]) -> Self { + Self { + labels_ready, + wait_results: waits.iter().cloned().collect(), + navigate_results: VecDeque::new(), + navigate_calls: 0, + wait_calls: 0, + } + } + } + + impl ChatPageDriver for FakeChatPageDriver { + fn labels_ready(&mut self) -> Result { + Ok(self.labels_ready) + } + + fn navigate(&mut self) -> Result<(), anyhow::Error> { + self.navigate_calls += 1; + self.navigate_results + .pop_front() + .unwrap_or(Ok(())) + .map_err(anyhow::Error::msg) + } + + fn wait_until_ready(&mut self) -> Result<(), anyhow::Error> { + self.wait_calls += 1; + self.wait_results + .pop_front() + .expect("测试必须为每次等待提供结果") + .map_err(anyhow::Error::msg) + } + + fn info(&mut self, _message: &str) -> Result<(), anyhow::Error> { + Ok(()) + } + + fn warning(&mut self, _message: &str) -> Result<(), anyhow::Error> { + Ok(()) + } + } + + #[test] + fn ready_chat_page_is_reused_without_navigation() { + let mut driver = FakeChatPageDriver::new(true, &[]); + + ensure_chat_page_with(&mut driver).expect("已就绪页面应直接复用"); + + assert_eq!(driver.navigate_calls, 0); + assert_eq!(driver.wait_calls, 0); + } + + #[test] + fn cold_chat_page_navigates_once_when_first_wait_succeeds() { + let mut driver = FakeChatPageDriver::new(false, &[Ok(())]); + + ensure_chat_page_with(&mut driver).expect("首次加载成功"); + + assert_eq!(driver.navigate_calls, 1); + assert_eq!(driver.wait_calls, 1); + } + + #[test] + fn first_timeout_recovers_with_exactly_one_extra_navigation() { + let mut driver = FakeChatPageDriver::new(false, &[Err("首次超时"), Ok(())]); + + ensure_chat_page_with(&mut driver).expect("恢复加载成功"); + + assert_eq!(driver.navigate_calls, 2); + assert_eq!(driver.wait_calls, 2); + } + + #[test] + fn recovery_navigation_failure_preserves_wait_and_navigation_contexts() { + let mut driver = FakeChatPageDriver::new(false, &[Err("首次分类标签超时")]); + driver.navigate_results = vec![Ok(()), Err("恢复导航失败")].into_iter().collect(); + + let error = ensure_chat_page_with(&mut driver).expect_err("恢复导航失败必须终止"); + let message = format!("{error:#}"); + + assert_eq!(driver.navigate_calls, 2); + assert_eq!(driver.wait_calls, 1); + assert!(message.contains("首次分类标签超时"), "{message}"); + assert!(message.contains("恢复导航失败"), "{message}"); + } + + #[test] + fn two_timeouts_stop_and_preserve_both_error_contexts() { + let mut driver = + FakeChatPageDriver::new(false, &[Err("首次分类标签超时"), Err("恢复分类标签超时")]); + + let error = ensure_chat_page_with(&mut driver).expect_err("两次超时必须失败"); + let message = format!("{error:#}"); + + assert_eq!(driver.navigate_calls, 2); + assert_eq!(driver.wait_calls, 2); + assert!(message.contains("首次分类标签超时"), "{message}"); + assert!(message.contains("恢复分类标签超时"), "{message}"); + } #[test] fn parses_and_detects_resume_attachment_messages() { From 3fd10f62adbc52d2d391a38ba9cef129bb9e97e7 Mon Sep 17 00:00:00 2001 From: patricLee Date: Mon, 24 Aug 2026 22:52:34 +0800 Subject: [PATCH 2/4] fix(browser): keep CDP tabs in background --- src-tauri/src/browser.rs | 165 ++++++++++++++++++++++++++++++++++----- 1 file changed, 145 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index 2fbba77..20b3c42 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -17,7 +17,7 @@ use std::{ use anyhow::{anyhow, Result}; use once_cell::sync::Lazy; -use rust_drission::{stealth_inject, ChromiumPage, Page}; +use rust_drission::{cdp::CdpClient, stealth_inject, ChromiumPage, Page}; use serde::{Deserialize, Serialize}; use tauri::Manager; @@ -420,6 +420,38 @@ fn is_cdp_version_response(response: &[u8]) -> bool { body.contains("\"webSocketDebuggerUrl\"") && body.contains("ws://") } +/// Extract the browser-level WebSocket URL used for commands such as +/// `Target.createTarget`. Unlike the feature probe above, callers need the +/// exact URL, so a loosely matched or chunked payload is not sufficient here. +fn parse_cdp_websocket_url(response: &[u8]) -> Option { + let separator = response + .windows(4) + .position(|window| window == b"\r\n\r\n")?; + let (head, body) = response.split_at(separator); + let head = std::str::from_utf8(head).ok()?; + let status_ok = head.lines().next().is_some_and(|status_line| { + status_line.starts_with("HTTP/1.") && status_line.contains(" 200") + }); + if !status_ok { + return None; + } + + serde_json::from_slice::(&body[4..]) + .ok()? + .get("webSocketDebuggerUrl")? + .as_str() + .filter(|url| url.starts_with("ws://") || url.starts_with("wss://")) + .map(str::to_string) +} + +fn connect_browser_cdp(port: u16) -> Result { + let response = + fetch_cdp_version_response(port).ok_or_else(|| anyhow!("无法读取浏览器调试端口 {port}"))?; + let websocket_url = parse_cdp_websocket_url(&response) + .ok_or_else(|| anyhow!("浏览器调试端口 {port} 未返回可用的 WebSocket 地址"))?; + Ok(CdpClient::connect(&websocket_url)?) +} + fn probe_debug_port(port: u16) -> DebugPortProbe { match fetch_cdp_version_response(port) { None => DebugPortProbe::Free, @@ -524,24 +556,32 @@ fn connect_task_browser(port: u16) -> Result { } } +fn managed_browser_args(config: &BrowserConfig, port: u16) -> Vec { + vec![ + format!("--remote-debugging-port={port}"), + format!("--user-data-dir={}", config.user_data_dir), + "--window-size=1920,1080".to_string(), + "--start-minimized".to_string(), + "--disable-background-timer-throttling".to_string(), + "--disable-backgrounding-occluded-windows".to_string(), + "--disable-renderer-backgrounding".to_string(), + "--no-default-browser-check".to_string(), + "--disable-suggestions-ui".to_string(), + "--no-first-run".to_string(), + "--disable-infobars".to_string(), + "--disable-popup-blocking".to_string(), + "--hide-crash-restore-bubble".to_string(), + "--disable-features=PrivacySandboxSettings4".to_string(), + "--disable-blink-features=AutomationControlled".to_string(), + "--no-sandbox".to_string(), + ] +} + fn launch_managed_browser(config: &BrowserConfig, port: u16) -> Result { fs::create_dir_all(&config.user_data_dir)?; let executable = browser_executable(config)?; let mut child = Command::new(executable) - .args([ - format!("--remote-debugging-port={port}"), - format!("--user-data-dir={}", config.user_data_dir), - "--window-size=1920,1080".to_string(), - "--no-default-browser-check".to_string(), - "--disable-suggestions-ui".to_string(), - "--no-first-run".to_string(), - "--disable-infobars".to_string(), - "--disable-popup-blocking".to_string(), - "--hide-crash-restore-bubble".to_string(), - "--disable-features=PrivacySandboxSettings4".to_string(), - "--disable-blink-features=AutomationControlled".to_string(), - "--no-sandbox".to_string(), - ]) + .args(managed_browser_args(config, port)) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -852,13 +892,59 @@ pub fn close_browser_session() -> Result<()> { Ok(()) } -/// 创建一个尚未导航的标签,并在首个文档加载前注册反检测脚本。 +fn create_background_target(client: &CdpClient) -> Result { + client + .send("Target.createTarget", Some(background_target_params()))? + .get("targetId") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .ok_or_else(|| anyhow!("后台标签页创建成功,但 CDP 未返回 targetId")) +} + +fn background_target_params() -> serde_json::Value { + serde_json::json!({ + "url": "about:blank", + "background": true + }) +} + +fn close_target(client: &CdpClient, target_id: &str) { + let _ = client.send( + "Target.closeTarget", + Some(serde_json::json!({ "targetId": target_id })), + ); +} + +/// 创建一个不会被激活的业务标签,并在首个文档加载前注册反检测脚本。 /// -/// 不使用 `ChromiumPage::new_tab()` 的隐式行为,避免调用方在“网页点击后才 -/// 创建的标签”上错过首个页面加载时机。 +/// `rust_drission::new_tab()` 没有传入 CDP 的 `background` 参数,Chrome 会 +/// 选中每个新岗位标签,自动招呼时便反复把浏览器抢到前台。这里先通过 +/// browser-level CDP 创建后台 target,再附加到这个精确的 target。 pub fn new_stealth_tab(browser: &ChromiumPage) -> Result { - let tab = browser.new_tab_without_stealth(None)?; - stealth_inject(&tab)?; + let port = ACTIVE_DEBUG_PORT + .read() + .map_err(|e| anyhow!("获取浏览器调试端口读锁失败: {}", e))? + .ok_or_else(|| anyhow!("浏览器调试端口未知,无法创建后台标签页"))?; + let client = connect_browser_cdp(port)?; + let target_id = create_background_target(&client)?; + let tab = match browser + .browser() + .get_tab(&target_id, None, None, Some("page")) + { + Ok(Some(tab)) => tab, + Ok(None) => { + close_target(&client, &target_id); + return Err(anyhow!("无法附加到刚创建的后台标签页 {target_id}")); + } + Err(error) => { + close_target(&client, &target_id); + return Err(error.into()); + } + }; + if let Err(error) = stealth_inject(&tab) { + close_target(&client, &target_id); + return Err(error.into()); + } Ok(tab) } @@ -1016,6 +1102,45 @@ mod tests { let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=UTF-8\r\nContent-Length: 123\r\n\r\n{\"Browser\":\"Chrome/131.0.6778.86\",\"Protocol-Version\":\"1.3\",\"webSocketDebuggerUrl\":\"ws://127.0.0.1:9876/devtools/browser/abcd\"}"; assert!(is_cdp_version_response(response)); + assert_eq!( + parse_cdp_websocket_url(response).as_deref(), + Some("ws://127.0.0.1:9876/devtools/browser/abcd") + ); + } + + #[test] + fn business_tabs_are_requested_without_foreground_activation() { + let params = background_target_params(); + + assert_eq!( + params.get("url").and_then(serde_json::Value::as_str), + Some("about:blank") + ); + assert_eq!( + params + .get("background") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + } + + #[test] + fn managed_browser_starts_minimized_without_throttling_background_work() { + let config = BrowserConfig { + user_data_dir: "test-profile".to_string(), + chrome_exe_path: None, + max_parallel_tasks: 2, + }; + let args = managed_browser_args(&config, DEFAULT_RPA_DEBUG_PORT); + + for required in [ + "--start-minimized", + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + ] { + assert!(args.iter().any(|arg| arg == required), "missing {required}"); + } } #[test] From 0d141b543dbcab2860eb1087d87231c81dddd2b4 Mon Sep 17 00:00:00 2001 From: patricLee Date: Tue, 25 Aug 2026 00:46:42 +0800 Subject: [PATCH 3/4] fix(config): prevent invalid model config crash (#31) --- src-tauri/src/command/llm_provider.rs | 2 +- src-tauri/src/config.rs | 176 +++++++++++++++++------- src-tauri/src/storage/migration.rs | 111 +++++++++++++-- src/App.tsx | 6 +- src/hooks/useAppConfig.test.tsx | 82 +++++++++++ src/hooks/useAppConfig.ts | 26 +++- src/types/app-config.test.ts | 39 ++++++ src/types/app-config.ts | 29 +++- src/view/config/LlmConfigPanel.test.tsx | 76 +++++++++- src/view/config/LlmConfigPanel.tsx | 66 ++++++++- src/view/config/index.tsx | 5 +- src/view/onboarding/index.tsx | 28 +++- src/view/onboarding/onboarding.test.tsx | 51 ++++++- 13 files changed, 612 insertions(+), 85 deletions(-) diff --git a/src-tauri/src/command/llm_provider.rs b/src-tauri/src/command/llm_provider.rs index 23c197a..c35fe20 100644 --- a/src-tauri/src/command/llm_provider.rs +++ b/src-tauri/src/command/llm_provider.rs @@ -488,7 +488,7 @@ mod tests { fn find_chain_link_reports_an_unusable_service_clearly() { let chain = chain(); - // 草稿也会落盘,所以链里没有它多半是缺地址或模型名,错误文案要直接指向该补什么 + // 历史坏配置可能被宽容读取为草稿;链里没有它时,错误文案要直接指向该补什么。 let error = find_chain_link(&chain, "backup-incomplete").unwrap_err(); assert_eq!(error.code, AppErrorCode::Configuration); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 3dee954..96fcbc4 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -209,7 +209,10 @@ pub fn load_app_config_inner(app_handle: tauri::AppHandle) -> Result Result infer_legacy_provider(&base_url), None => return Err("大模型服务预设不能为空".to_string()), }; - let mut config = AppRuntimeConfig { - llm_config: Some(LlmConfig { - provider, - base_url, - model, - }), - ..default_app_config() + let mut config = LlmConfig { + provider, + base_url, + model, }; - validate_and_normalize(&mut config)?; - Ok(config.llm_config) + normalize_llm_config(&mut config); + Ok(Some(config)) } /// Known legacy URLs map to their matching preset. Unknown legacy endpoints @@ -401,7 +397,12 @@ fn infer_legacy_provider(base_url: &str) -> LlmProviderPreset { } } -pub fn validate_and_normalize(config: &mut AppRuntimeConfig) -> Result<(), String> { +/// 读取时的容错规整。 +/// +/// 已经被旧版自动保存写入的不完整 LLM 配置仍然是用户草稿,不能因此让整个 +/// 应用无法启动。除 LLM 地址/模型名的完整性外,版本、provider 反序列化、 +/// 备用服务标识和其他结构校验都仍在这里执行。 +pub(crate) fn normalize_loaded_config(config: &mut AppRuntimeConfig) -> Result<(), String> { if config.schema_version > CURRENT_SCHEMA_VERSION { return Err(format!( "应用配置版本 {} 高于当前支持的版本 {}", @@ -427,18 +428,55 @@ pub fn validate_and_normalize(config: &mut AppRuntimeConfig) -> Result<(), Strin return Ok(()); } - let Some(llm_config) = config.llm_config.as_mut() else { - return Ok(()); - }; + if let Some(llm_config) = config.llm_config.as_mut() { + normalize_llm_config(llm_config); + } + Ok(()) +} + +fn normalize_llm_config(config: &mut LlmConfig) { + config.base_url = config.base_url.trim().trim_end_matches('/').to_string(); + config.model = config.model.trim().to_string(); +} + +/// 所有最终落盘路径的 LLM 完整性校验。 +/// +/// 读取可以保留历史草稿,但新的保存、导出和导入不得再把空地址或空模型名写回文件。 +pub(crate) fn validate_llm_config_for_persistence( + config: &AppRuntimeConfig, +) -> Result<(), String> { + if let Some(primary) = config.llm_config.as_ref() { + if primary.base_url.trim().is_empty() { + return Err("主用大模型服务地址不能为空".to_string()); + } + if primary.model.trim().is_empty() { + return Err("主用大模型名称不能为空".to_string()); + } + } + + for fallback in &config.llm_fallbacks { + if fallback.base_url.trim().is_empty() { + return Err(format!("备用大模型服务 {} 的地址不能为空", fallback.id)); + } + if fallback.model.trim().is_empty() { + return Err(format!("备用大模型服务 {} 的模型名称不能为空", fallback.id)); + } + } - // 只做规整,不因为「还没填完」拒绝落盘: - // 配置页要靠已保存的密钥去拉模型列表,拒绝保存会让用户永远填不完这份配置。 - // 未填完的服务由 `llm_active` / `llm_chain` 挡在调用之外。 - llm_config.base_url = llm_config.base_url.trim().trim_end_matches('/').to_string(); - llm_config.model = llm_config.model.trim().to_string(); Ok(()) } +fn load_repairs_can_be_persisted(config: &AppRuntimeConfig) -> bool { + validate_llm_config_for_persistence(config).is_ok() +} + +/// 新配置落盘前的严格校验与规整。解析历史文件应调用 +/// [`normalize_loaded_config`],不得用严格保存规则阻断应用启动。 +pub fn validate_and_normalize(config: &mut AppRuntimeConfig) -> Result<(), String> { + normalize_loaded_config(config)?; + validate_llm_config_for_persistence(config) +} + /// v1 在模板缺少 LLM 条目时会在运行期把生成内容隐式插到第一条。 /// v2 改为完全显式的发送序列,因此升级时只需补出这个条目;运行期不再保留兼容分支。 fn migrate_greet_send_sequence(greet: &mut GreetConfig) { @@ -513,12 +551,11 @@ fn normalize_llm_fallbacks(fallbacks: &mut Vec) -> Result<(), .map(str::to_string); } - // 界面上新增一行后还没来得及填写就保存,属于常见操作,静默丢弃即可 + // 历史配置里可能残留完全空白的占位行;它不包含任何可恢复信息,静默丢弃即可。 fallbacks.retain(|entry| !(entry.base_url.is_empty() && entry.model.is_empty())); - // 只校验标识——它决定密钥存放在哪个 keyring 条目,错了会读写到别人的密钥。 - // 地址和模型名填了一半不算错误:那只是还没编辑完的草稿, - // 由 `LlmProviderEntry::is_usable` 决定它进不进降级链。 + // 标识决定密钥存放在哪个 keyring 条目,读取历史草稿时也必须严格校验。 + // 地址和模型名的完整性由持久化路径另行校验;宽容读取时则保留草稿供 UI 修复。 let mut seen_ids: HashSet<&str> = HashSet::new(); for entry in fallbacks.iter() { if !is_valid_entry_id(&entry.id) { @@ -543,8 +580,7 @@ pub fn import_app_config_inner( ) -> Result { let mut config = read_config_file(Path::new(path))?; ensure_browser_user_data_dir(&app_handle, &mut config)?; - save_app_config_inner(app_handle, config.clone())?; - Ok(config) + save_app_config_inner(app_handle, config) } pub fn export_app_config_inner(path: &str, mut config: AppRuntimeConfig) -> Result<(), AppError> { @@ -720,7 +756,9 @@ pub fn resolve_job_profile( profile_id: Option<&str>, ) -> Result { let mut snapshot = config.clone(); - validate_and_normalize(&mut snapshot)?; + // 快照解析不是持久化路径;历史 LLM 草稿由 llm_chain 排除,不应阻断不依赖 + // LLM 的任务。 + normalize_loaded_config(&mut snapshot)?; let profile = snapshot.job_profile(profile_id)?.clone(); if profile.archived { return Err(format!( @@ -804,9 +842,8 @@ pub struct LlmConfig { /// 一个大模型服务是否填写完整、可以真正发起调用。 /// -/// 配置页允许存在填了一半的服务:模型名要从服务端拉列表才知道,而拉列表得先存密钥, -/// 密钥又跟着这份配置一起落盘——若要求「填完才准保存」,这三者就会互相等待。 -/// 因此校验放宽为「可以存」,能不能用改由这里判断,未填完的服务不会进入降级链。 +/// 历史坏配置或页面内存草稿可能暂时缺少地址或模型名。它们既不能落盘,也不能进入 +/// 调用链;页面补全后才会恢复自动保存。获取模型列表和保存凭据均不依赖草稿落盘。 fn service_is_usable(base_url: &str, model: &str) -> bool { !base_url.trim().is_empty() && !model.trim().is_empty() } @@ -981,7 +1018,7 @@ impl AppRuntimeConfig { chain.extend( self.llm_fallbacks .iter() - // 填了一半的备用服务只是草稿,允许保存但不参与调用 + // 宽容读取出的历史草稿不参与调用;新的持久化路径会拒绝此类条目。 .filter(|entry| entry.enabled && entry.is_usable()) .map(|entry| LlmChainLink { id: entry.id.clone(), @@ -2038,12 +2075,9 @@ mod tests { assert_eq!(llm.model, "qwen3"); } - /// 没填完的主用服务能存下来,但不会被拿去调用。 - /// - /// 反过来做——拒绝保存——会把配置页锁死:模型名要拉列表才知道, - /// 拉列表得先存好密钥,密钥又跟这份配置一起落盘。 + /// 旧版已写入的不完整主用服务必须能读出来修复,但不允许再次落盘。 #[test] - fn incomplete_primary_llm_config_is_saved_but_stays_inactive() { + fn incomplete_current_llm_config_loads_as_a_draft_but_cannot_be_persisted() { for (base_url, model) in [ ("", "qwen3"), (" ", "qwen3"), @@ -2057,14 +2091,52 @@ mod tests { model: model.to_string(), }); - validate_and_normalize(&mut config).unwrap(); + let yaml = serde_yaml::to_string(&config).unwrap(); + let mut loaded = parse_config_content(&yaml).expect("历史草稿不应阻断启动"); - assert!(config.llm_config.is_some()); - assert!(!config.llm_active()); - assert!(config.llm_chain().is_empty()); + assert!(loaded.llm_config.is_some()); + assert!(!loaded.llm_active()); + assert!(loaded.llm_chain().is_empty()); + let error = validate_and_normalize(&mut loaded).unwrap_err(); + assert!(error.contains("不能为空")); } } + #[test] + fn invalid_provider_is_still_rejected_while_loading_a_draft() { + let error = parse_config_content( + r#" +schema_version: 3 +llm_config: + provider: definitely_not_a_provider + base_url: https://llm.example.test/v1 + model: "" +"#, + ) + .unwrap_err(); + + assert!(error.contains("unknown variant")); + } + + #[test] + fn incomplete_llm_draft_does_not_trigger_strict_load_repair_writeback() { + let config = parse_config_content( + r#" +schema_version: 3 +llm_config: + provider: openai + base_url: https://llm.example.test/v1 + model: "" +browser_config: + user_data_dir: "" + chrome_exe_path: null +"#, + ) + .expect("历史草稿必须能加载"); + + assert!(!load_repairs_can_be_persisted(&config)); + } + /// 老用户的配置文件里没有轮询这几块字段。它们全靠 serde 默认值补齐, /// 所以 schema 版本不用 bump——但这条依赖一旦断掉,表现是升级后轮询 /// 参数全为 0:间隔 0 分钟变忙循环、活跃时段 0-0、单轮上限 0 条永不处理。 @@ -3053,10 +3125,10 @@ job_profiles: [] assert_eq!(config.browser_config.max_parallel_tasks, MIN_PARALLEL_TASKS); } - /// 全空的行是「加了一行还没填」,直接丢弃;填了一半的是编辑到一半的草稿, - /// 要留住,但不能进降级链——否则运行时会拿着空模型名去发请求。 + /// 读取时,全空的行视为未填写的占位并丢弃;填了一半的历史草稿要留住, + /// 但不能进降级链,也不能再次落盘。 #[test] - fn blank_fallback_rows_are_dropped_and_half_filled_rows_are_kept_out_of_the_chain() { + fn fallback_drafts_load_safely_but_half_filled_rows_cannot_be_persisted() { let mut config = default_app_config(); config.llm_config = Some(LlmConfig { provider: LlmProviderPreset::OpenAi, @@ -3071,7 +3143,7 @@ job_profiles: [] fallback_entry("backup-a", "qwen-max"), ]; - validate_and_normalize(&mut config).unwrap(); + normalize_loaded_config(&mut config).unwrap(); let ids: Vec<&str> = config .llm_fallbacks @@ -3082,6 +3154,10 @@ job_profiles: [] let chain_ids: Vec = config.llm_chain().into_iter().map(|link| link.id).collect(); assert_eq!(chain_ids, vec![PRIMARY_LLM_ENTRY_ID, "backup-a"]); + + let error = validate_and_normalize(&mut config).unwrap_err(); + assert!(error.contains("backup-half")); + assert!(error.contains("模型名称不能为空")); } #[test] diff --git a/src-tauri/src/storage/migration.rs b/src-tauri/src/storage/migration.rs index 1198416..7de3e4e 100644 --- a/src-tauri/src/storage/migration.rs +++ b/src-tauri/src/storage/migration.rs @@ -139,19 +139,30 @@ pub fn migrate_to_current( .to_string_lossy() .into_owned(); config.schema_version = CURRENT_SCHEMA_VERSION; - config::validate_and_normalize(&mut config).map_err(AppError::validation)?; + config::normalize_loaded_config(&mut config).map_err(AppError::validation)?; + + // 旧版自动保存可能已经写入半成品 LLM 配置。其他数据迁移仍然可以 + // 安全完成,但普通草稿不应借 schema 升级被重新写回:保留原文件,等用户 + // 补全后由严格保存路径一次性升级。唯一例外是旧文件仍含明文 API Key, + // 此时必须立即用已去密的强类型配置覆盖,不能为了保留草稿继续暴露密钥。 + let config_can_be_persisted = config::validate_llm_config_for_persistence(&config).is_ok(); + let contains_legacy_plaintext = raw_config + .as_ref() + .and_then(legacy_plaintext_key) + .is_some(); + if config_can_be_persisted || contains_legacy_plaintext { + if let Some(original) = original_config.as_deref() { + let backup_bytes = match raw_config.as_ref() { + Some(raw) if legacy_plaintext_key(raw).is_some() => sanitized_config_backup(raw)?, + _ => original.to_vec(), + }; + report + .backups + .push(write_backup(paths, "app-config.yaml", &backup_bytes)?); + } - if let Some(original) = original_config.as_deref() { - let backup_bytes = match raw_config.as_ref() { - Some(raw) if legacy_plaintext_key(raw).is_some() => sanitized_config_backup(raw)?, - _ => original.to_vec(), - }; - report - .backups - .push(write_backup(paths, "app-config.yaml", &backup_bytes)?); + write_migrated_config(&paths.config_path, &config)?; } - - write_migrated_config(&paths.config_path, &config)?; Ok(report) } @@ -808,6 +819,84 @@ browser_config: assert!(!paths.user_resumes_path().exists()); } + #[test] + fn incomplete_v2_llm_draft_does_not_abort_or_get_rewritten_during_migration() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths(dir.path()); + let original = br#"schema_version: 2 +llm_config: + provider: openai + base_url: https://llm.example.test/v1 + model: "" +browser_config: + user_data_dir: "" + chrome_exe_path: null +"#; + write_config(&paths, original); + let resume = resume_document(&[("legacy", "content")]); + write_json(&paths.legacy_user_resumes_path(), &resume); + + let report = migrate_to_current(&paths, &FakeCredentialBackend::default()) + .expect("不完整 LLM 草稿不应让 setup 迁移失败"); + + assert!(report.migrated); + assert_eq!(report.resume_action, ResumeMigrationAction::OldCopied); + assert_eq!(fs::read(&paths.config_path).unwrap(), original); + assert!(report.backups.iter().all(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_none_or(|name| !name.contains("app-config")) + })); + assert_eq!( + serde_json::from_slice::(&fs::read(paths.user_resumes_path()).unwrap()).unwrap(), + resume + ); + assert!(!paths.legacy_user_resumes_path().exists()); + + let loaded = crate::config::parse_config_content( + &fs::read_to_string(&paths.config_path).unwrap(), + ) + .expect("保留的配置仍应作为草稿交给 UI"); + assert_eq!(loaded.schema_version, 2); + assert_eq!(loaded.llm_config.unwrap().model, ""); + } + + #[test] + fn incomplete_v2_draft_with_plaintext_key_is_sanitized_immediately() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths(dir.path()); + write_config( + &paths, + br#"schema_version: 2 +llm_config: + provider: openai + base_url: https://llm.example.test/v1 + model: "" + api_key: plaintext-secret +browser_config: + user_data_dir: "" + chrome_exe_path: null +"#, + ); + let backend = FakeCredentialBackend::default(); + + let report = migrate_to_current(&paths, &backend) + .expect("去除明文密钥时也不应被不完整模型草稿阻断"); + + assert_eq!(backend.value.borrow().as_deref(), Some("plaintext-secret")); + let migrated = fs::read_to_string(&paths.config_path).unwrap(); + assert!(!migrated.contains("plaintext-secret")); + assert!(migrated.contains(&format!("schema_version: {CURRENT_SCHEMA_VERSION}"))); + let loaded = crate::config::parse_config_content(&migrated) + .expect("去密后的不完整配置仍应作为草稿加载"); + assert_eq!(loaded.llm_config.unwrap().model, ""); + for backup in report.backups { + assert!(!fs::read_to_string(backup) + .unwrap() + .contains("plaintext-secret")); + } + } + #[test] fn browser_profile_resolution_prefers_explicit_then_legacy_then_new_default() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/App.tsx b/src/App.tsx index 4b77228..0440219 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,9 +60,9 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, analysis_config: getAnalysisConfig(activeProfile), }), [activeProfile, config, profiles]); - // 填了一半的大模型服务也照常落盘:它只是草稿,后端不会拿它去发起调用。 - // 曾经这里要拦下不完整的草稿,是因为后端拒绝保存——那反过来卡死了配置页, - // 模型名要拉列表才知道,拉列表又得先把这份配置连同密钥存好。 + // 不完整的大模型服务会由 useAppConfig 标为校验错误,只留在内存中;用户补齐 + // 后状态恢复为 idle,下面的防抖保存会自动继续。API Key 有独立的凭据保存流程, + // 获取模型列表不依赖把这份半成品配置落盘。 useEffect(() => { if (!dirty || status === "loading" || status === "error") return; const timer = window.setTimeout(() => { void save(); }, 700); diff --git a/src/hooks/useAppConfig.test.tsx b/src/hooks/useAppConfig.test.tsx index 12387e2..8aaaff2 100644 --- a/src/hooks/useAppConfig.test.tsx +++ b/src/hooks/useAppConfig.test.tsx @@ -44,6 +44,21 @@ describe("useAppConfig", () => { expect(result.current.message).toBe("配置损坏"); }); + it("loads a historical incomplete model config for repair instead of treating it as saved", async () => { + vi.mocked(api.loadAppConfig).mockResolvedValue({ + ...config, + onboarding_completed: true, + llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: "" }, + }); + const { result } = renderHook(() => useAppConfig()); + + await waitFor(() => expect(result.current.config).not.toBeNull()); + expect(result.current.dirty).toBe(false); + expect(result.current.status).toBe("error"); + expect(result.current.message).toBe("配置异常:主用模型的模型名称不能为空;修正后自动保存"); + expect(api.saveAppConfig).not.toHaveBeenCalled(); + }); + it("updates nested config immutably", async () => { vi.mocked(api.loadAppConfig).mockResolvedValue(config); const { result } = renderHook(() => useAppConfig()); @@ -112,6 +127,73 @@ describe("useAppConfig", () => { expect(result.current.message).toBe("无法保存"); }); + it("keeps an incomplete primary model as an in-memory draft and never calls the backend", async () => { + vi.mocked(api.loadAppConfig).mockResolvedValue(config); + const { result } = renderHook(() => useAppConfig()); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + act(() => result.current.updateConfig((current) => ({ + ...current, + llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: " " }, + }))); + + expect(result.current.dirty).toBe(true); + expect(result.current.status).toBe("error"); + expect(result.current.message).toBe("配置未保存:主用模型的模型名称不能为空"); + await act(async () => { + expect(await result.current.save()).toBe(false); + }); + expect(api.saveAppConfig).not.toHaveBeenCalled(); + }); + + it("becomes saveable again after the invalid draft is corrected", async () => { + vi.mocked(api.loadAppConfig).mockResolvedValue(config); + vi.mocked(api.saveAppConfig).mockImplementation(async (submitted) => submitted); + const { result } = renderHook(() => useAppConfig()); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + act(() => result.current.updateConfig((current) => ({ + ...current, + llm_config: { provider: "openai", base_url: "https://api.openai.com/v1", model: "" }, + }))); + expect(result.current.status).toBe("error"); + + act(() => result.current.updateConfig((current) => ({ + ...current, + llm_config: { ...current.llm_config!, model: "gpt-test" }, + }))); + expect(result.current.status).toBe("idle"); + expect(result.current.message).toBe(""); + await act(async () => { + expect(await result.current.save()).toBe(true); + }); + expect(api.saveAppConfig).toHaveBeenCalledTimes(1); + }); + + it("rejects an incomplete retained fallback even when it is disabled", async () => { + vi.mocked(api.loadAppConfig).mockResolvedValue(config); + const { result } = renderHook(() => useAppConfig()); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + const next = { + ...config, + llm_fallbacks: [{ + id: "backup-a", + label: null, + provider: "deepseek" as const, + base_url: "https://api.deepseek.com", + model: "", + enabled: false, + }], + }; + await act(async () => { + expect(await result.current.save(next)).toBe(false); + }); + expect(result.current.status).toBe("error"); + expect(result.current.message).toBe("配置未保存:备用模型 1 的模型名称不能为空"); + expect(api.saveAppConfig).not.toHaveBeenCalled(); + }); + /** * 后端在保存路径上会做迁移、夹取上下界、补生成拟人化的人格种子,落盘内容 * 和提交内容并不相同。拿提交的那份当已保存快照的话,下次保存又原样提交一遍—— diff --git a/src/hooks/useAppConfig.ts b/src/hooks/useAppConfig.ts index 93f4ffc..728e95e 100644 --- a/src/hooks/useAppConfig.ts +++ b/src/hooks/useAppConfig.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { exportAppConfig, importAppConfig, loadAppConfig, saveAppConfig } from "@/lib/tauriConfig"; -import type { AppRuntimeConfig, StatusKind } from "@/types/app-config"; +import { getAppConfigSaveValidationError, type AppRuntimeConfig, type StatusKind } from "@/types/app-config"; const errorMessage = (error: unknown) => error instanceof Error ? error.message : "操作失败"; @@ -10,6 +10,12 @@ export function useAppConfig() { const [message, setMessage] = useState(""); const savedSnapshot = useRef(""); const dirty = Boolean(config) && JSON.stringify(config) !== savedSnapshot.current; + const configValidationError = config ? getAppConfigSaveValidationError(config) : null; + const validationMessage = configValidationError + ? dirty + ? `配置未保存:${configValidationError}` + : `配置异常:${configValidationError};修正后自动保存` + : ""; const load = useCallback(async () => { setStatus("loading"); @@ -36,6 +42,12 @@ export function useAppConfig() { const save = useCallback(async (nextConfig?: AppRuntimeConfig) => { const value = nextConfig ?? config; if (!value) return false; + const validationError = getAppConfigSaveValidationError(value); + if (validationError) { + setStatus("error"); + setMessage(`配置未保存:${validationError}`); + return false; + } const configAtSaveStart = config; setStatus("loading"); try { @@ -80,5 +92,15 @@ export function useAppConfig() { } }, [config]); - return { config, status, message, dirty, load, save, importConfig, exportConfig, updateConfig }; + return { + config, + status: configValidationError ? "error" as const : status, + message: validationMessage || message, + dirty, + load, + save, + importConfig, + exportConfig, + updateConfig, + }; } diff --git a/src/types/app-config.test.ts b/src/types/app-config.test.ts index 3b8444c..8e2e8e7 100644 --- a/src/types/app-config.test.ts +++ b/src/types/app-config.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { copyJobProfile, + getAppConfigSaveValidationError, getDefaultJobProfile, getJobProfiles, getReplyPollingConfig, @@ -10,6 +11,44 @@ import { type AppRuntimeConfig, } from "./app-config"; +describe("app config save validation", () => { + const primary = { + provider: "openai" as const, + base_url: "https://api.openai.com/v1", + model: "gpt-test", + }; + const fallback = { + id: "backup-a", + label: null, + provider: "deepseek" as const, + base_url: "https://api.deepseek.com", + model: "deepseek-chat", + enabled: true, + }; + + it("accepts the intentional no-model state", () => { + expect(getAppConfigSaveValidationError({ llm_config: null, llm_fallbacks: [] })).toBeNull(); + }); + + it("reports the first missing primary field", () => { + expect(getAppConfigSaveValidationError({ + llm_config: { ...primary, base_url: " " }, + llm_fallbacks: [], + })).toBe("主用模型的服务地址不能为空"); + expect(getAppConfigSaveValidationError({ + llm_config: { ...primary, model: "\t" }, + llm_fallbacks: [], + })).toBe("主用模型的模型名称不能为空"); + }); + + it("requires every retained fallback to be complete, including disabled entries", () => { + expect(getAppConfigSaveValidationError({ + llm_config: primary, + llm_fallbacks: [fallback, { ...fallback, id: "backup-b", model: "", enabled: false }], + })).toBe("备用模型 2 的模型名称不能为空"); + }); +}); + const legacyConfig = { default_job_profile_id: undefined, job_filter_config: { query: "Rust" }, diff --git a/src/types/app-config.ts b/src/types/app-config.ts index e896d05..2302244 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -466,8 +466,8 @@ export function getAnalysisConfig( /** * 一个大模型服务是否填写完整、可以真正发起调用。 * - * 配置页允许保存填了一半的服务(模型名要拉列表才知道,拉列表又得先存好密钥), - * 所以「存下来了」不等于「能用」。与 Rust 侧的 `service_is_usable` 保持一致。 + * 配置页会在服务未填完时保留内存草稿,但不会把不完整内容写入配置文件。 + * 这个判断也用于决定服务能否真正发起调用。 */ export function isLlmServiceUsable( service: Pick | null | undefined, @@ -475,11 +475,34 @@ export function isLlmServiceUsable( return Boolean(service?.base_url.trim() && service.model.trim()); } -/** 是否已经保存过主用大模型配置。填了一半也算,界面据此显示「继续配置」而不是「去配置」。 */ +/** 是否已经创建主用大模型配置;未填完的内存草稿也算。 */ export function isLlmConfigured(config: Pick): boolean { return config.llm_config !== null; } +/** + * 返回配置落盘前的第一个可读校验错误;`null` 表示可以保存。 + * + * 没有配置主用模型是合法状态。只要保留了一条主用或备用服务,它的地址和 + * 模型名称就必须同时填写完整,避免自动保存把编辑到一半的草稿写进配置文件。 + */ +export function getAppConfigSaveValidationError( + config: Pick, +): string | null { + if (config.llm_config) { + if (!config.llm_config.base_url.trim()) return "主用模型的服务地址不能为空"; + if (!config.llm_config.model.trim()) return "主用模型的模型名称不能为空"; + } + + for (const [index, fallback] of config.llm_fallbacks.entries()) { + const name = `备用模型 ${index + 1}`; + if (!fallback.base_url.trim()) return `${name} 的服务地址不能为空`; + if (!fallback.model.trim()) return `${name} 的模型名称不能为空`; + } + + return null; +} + /** * 主用大模型是否处于可用状态。 * diff --git a/src/view/config/LlmConfigPanel.test.tsx b/src/view/config/LlmConfigPanel.test.tsx index 5bcb358..a841350 100644 --- a/src/view/config/LlmConfigPanel.test.tsx +++ b/src/view/config/LlmConfigPanel.test.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { Modal } from "antd"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { invoke } from "@tauri-apps/api/core"; import type { LlmConfig, LlmProviderEntry, LlmRetryConfig } from "@/types/app-config"; @@ -189,12 +190,14 @@ function Harness({ initialFallbacks = [], onFallbacks, onRetry, + onPersistAll, dirty, }: { initialConfig?: LlmConfig; initialFallbacks?: LlmProviderEntry[]; onFallbacks?: (next: LlmProviderEntry[]) => void; onRetry?: (next: LlmRetryConfig) => void; + onPersistAll?: () => Promise; dirty?: boolean; }) { const [config, setConfig] = useState(initialConfig); @@ -222,6 +225,7 @@ function Harness({ setRetry(next); }} dirty={dirty} + onPersistAll={onPersistAll} /> ); } @@ -330,7 +334,7 @@ describe("LlmConfigPanel 降级链界面", () => { provider: "deepseek", baseUrl: "https://api.deepseek.com", }); - }); + }, 30_000); it("停用只切换状态,不清除主用配置", async () => { render(); @@ -345,6 +349,74 @@ describe("LlmConfigPanel 降级链界面", () => { expect(screen.getByLabelText("primary 模型")).toHaveValue("gpt-test"); }); + it("can abandon the whole model draft without clearing saved credentials", async () => { + const onFallbacks = vi.fn(); + const confirm = vi.spyOn(Modal, "confirm").mockReturnValue({ destroy: vi.fn(), update: vi.fn() }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "移除配置" })); + const options = confirm.mock.calls[0]?.[0]; + expect(options?.content).toContain("1 个备用服务"); + expect(options?.content).toContain("API Key 仍保留"); + await act(async () => { + await options?.onOk?.(); + }); + + expect(onFallbacks).toHaveBeenLastCalledWith([]); + expect(screen.getByText("选择服务后开始配置")).toBeInTheDocument(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).not.toContain("clear_llm_api_key"); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).not.toContain("clear_llm_api_key_for"); + confirm.mockRestore(); + }); + + it("blocks credential swapping while any retained service is incomplete", async () => { + const confirm = vi.spyOn(Modal, "confirm").mockReturnValue({ destroy: vi.fn(), update: vi.fn() }); + render(); + + const [promote] = await screen.findAllByRole("button", { name: "设为主用" }); + fireEvent.click(promote); + const options = confirm.mock.calls[0]?.[0]; + await act(async () => { + await options?.onOk?.(); + }); + + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).not.toContain("swap_llm_credentials"); + confirm.mockRestore(); + }); + + it("removes a fallback draft without deleting its saved credential", async () => { + const onFallbacks = vi.fn(); + const confirm = vi.spyOn(Modal, "confirm").mockReturnValue({ destroy: vi.fn(), update: vi.fn() }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "删除备用 1" })); + const options = confirm.mock.calls[0]?.[0]; + expect(options?.content).toContain("API Key 仍保留"); + await act(async () => { + await options?.onOk?.(); + }); + + expect(onFallbacks).toHaveBeenLastCalledWith([]); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).not.toContain("clear_llm_api_key_for"); + confirm.mockRestore(); + }); + + it("persists a valid dirty chain before swapping credentials", async () => { + const onPersistAll = vi.fn().mockResolvedValue(false); + const confirm = vi.spyOn(Modal, "confirm").mockReturnValue({ destroy: vi.fn(), update: vi.fn() }); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "设为主用" })); + const options = confirm.mock.calls[0]?.[0]; + await act(async () => { + await options?.onOk?.(); + }); + + expect(onPersistAll).toHaveBeenCalledTimes(1); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).not.toContain("swap_llm_credentials"); + confirm.mockRestore(); + }); + it("新增备用服务后列表出现新条目,其标识非空且不等于 primary", async () => { const onFallbacks = vi.fn(); render(); diff --git a/src/view/config/LlmConfigPanel.tsx b/src/view/config/LlmConfigPanel.tsx index 60e8fb5..9171eee 100644 --- a/src/view/config/LlmConfigPanel.tsx +++ b/src/view/config/LlmConfigPanel.tsx @@ -20,6 +20,7 @@ import { MIN_RETRY_BASE_DELAY_MS, MIN_LLM_REQUEST_TIMEOUT_SECONDS, PRIMARY_LLM_ENTRY_ID, + getAppConfigSaveValidationError, isLlmServiceUsable, type LlmConfig, type LlmProviderEntry, @@ -404,19 +405,66 @@ export function LlmConfigPanel({ setActiveKeys((keys) => [...keys, entry.id]); }; + /** + * 交换凭据前,先确认整条链完整并把已有草稿落盘。这样链中另一条半成品 + * 不会在交换后阻断自动保存,造成磁盘配置仍是旧顺序、凭据却已换位。 + */ + const prepareCredentialMutation = async (): Promise => { + const validationError = getAppConfigSaveValidationError({ + llm_config: config, + llm_fallbacks: chain, + }); + if (validationError) { + showFeedback("warning", "暂时无法调整模型顺序", `配置未保存:${validationError}。请先补全或删除该服务。`); + return false; + } + if (!dirty) return true; + if (!onPersistAll) { + showFeedback("error", "暂时无法调整模型顺序", "请先保存当前配置后再试"); + return false; + } + if (!await onPersistAll()) { + showFeedback("error", "暂时无法调整模型顺序", "当前配置保存失败,尚未改动任何 API Key"); + return false; + } + return true; + }; + + const removePrimaryConfig = () => { + const fallbackCount = chain.length; + Modal.confirm({ + title: "移除大模型配置?", + width: 520, + content: fallbackCount > 0 + ? `主用服务和 ${fallbackCount} 个备用服务会从配置中移除。已保存的 API Key 仍保留在系统凭据库中。` + : "主用服务会从配置中移除。已保存的 API Key 仍保留在系统凭据库中。", + okText: "移除配置", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: () => { + onChange(null); + // 清掉显式停用标记;若用户随后马上新建服务,编辑区仍应保持展开。 + onEnabledChange?.(true); + onFallbacksChange?.([]); + setModels({}); + setConnectionOk({}); + setActiveKeys([]); + }, + }); + }; + const removeFallback = (index: number) => { const entry = chain[index]; if (!entry) return; Modal.confirm({ title: `删除「${fallbackTitle(entry, index)}」?`, - content: "该服务在系统凭据库里保存的 API Key 会一并清除,避免留下无人使用的密钥。", + content: "该服务会从降级链中移除;已保存的 API Key 仍保留在系统凭据库中,避免配置尚未落盘时丢失凭据。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", - onOk: async () => { - await clearLlmApiKeyFor(entry.id).catch(() => undefined); + onOk: () => { onFallbacksChange?.(chain.filter((_, i) => i !== index)); - showFeedback("success", "备用服务已删除", `已删除「${fallbackTitle(entry, index)}」及其 API Key`); + showFeedback("success", "备用服务已删除", `已从配置中删除「${fallbackTitle(entry, index)}」;API Key 保留在系统凭据库中`); }, }); }; @@ -446,6 +494,7 @@ export function LlmConfigPanel({ okText: "交换", cancelText: "取消", onOk: async () => { + if (!await prepareCredentialMutation()) return; // 先算出交换结果,确认可行再动密钥;反过来一旦交换失败, // 密钥已经被搬走,配置却没变,两边就对不上了。 const swapped = promoteFallbackToPrimary(config, chain, index); @@ -609,7 +658,10 @@ export function LlmConfigPanel({ 配置主用模型服务,连接失败时会按下方顺序切换备用服务。 - 主用 + + 主用 + +
@@ -641,7 +693,7 @@ export function LlmConfigPanel({ type="info" showIcon message="补齐服务地址和模型后,大模型才会启用" - description="当前填写的内容会照常保存,随时可以回来接着配。模型名可以直接输入,也可以点「获取模型」从服务拉取。" + description="补齐前不会触发自动保存;补齐后会自动保存。模型名可以直接输入,也可以点「获取模型」从服务拉取。" /> )} @@ -733,7 +785,7 @@ export function LlmConfigPanel({ type="warning" showIcon message="有备用服务尚未填写完整" - description="缺服务地址或模型名的备用服务会照常保存,但不会参与降级,补齐后自动生效。" + description="缺服务地址或模型名时不会触发自动保存;补齐或删除该备用服务后会自动保存。" /> )}
diff --git a/src/view/config/index.tsx b/src/view/config/index.tsx index a53324c..bcfe4c6 100644 --- a/src/view/config/index.tsx +++ b/src/view/config/index.tsx @@ -564,6 +564,9 @@ export function ConfigPage(props: ConfigPageProps) { profiles.length > 1 && activeProfile.id !== defaultProfileId; const llmConfigured = isLlmConfigured(props.config); const llmActive = isLlmActive(props.config); + // 「是否启用」与「当前是否填完整可调用」是两件事。模型名被清空时仍要保持 + // 编辑区展开,用户才能补回模型名或放弃这份配置。 + const llmEnabled = props.config.llm_enabled !== false; const showNotice = ( type: "success" | "error" | "info" | "warning", @@ -909,7 +912,7 @@ export function ConfigPage(props: ConfigPageProps) { return ( props.updateLlmEnabled(enabled ? undefined : false) diff --git a/src/view/onboarding/index.tsx b/src/view/onboarding/index.tsx index 3e805cc..c84743d 100644 --- a/src/view/onboarding/index.tsx +++ b/src/view/onboarding/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { Alert, Button, Card, Space, Typography } from "antd"; -import type { AppRuntimeConfig } from "@/types/app-config"; +import { isLlmServiceUsable, type AppRuntimeConfig } from "@/types/app-config"; import type { BrowserEnvStatus } from "@/types/rpa"; import type { CommandResult } from "@/types/command"; import { setLlmApiKey } from "@/lib/llmConfig"; @@ -36,7 +36,18 @@ export function Onboarding({ config, onFinish }: { config: AppRuntimeConfig; onF } setPendingApiKey(""); } - const saved = await onFinish({ ...draft, onboarding_completed: true, llm_config: llm }); + const saved = await onFinish({ + ...draft, + onboarding_completed: true, + llm_config: llm, + // compact 引导页不展示降级链;选择跳过 AI 时一并放弃历史备用草稿, + // 避免隐藏的半成品备用服务让用户永远无法完成引导。 + llm_fallbacks: llm === null + ? [] + // compact 模式没有备用服务编辑区,只保留历史配置里已经完整的条目; + // 半成品留到这里会阻止完成引导,用户却没有入口修复。 + : (draft.llm_fallbacks ?? []).filter(isLlmServiceUsable), + }); if (!saved) setFinishError("配置保存失败,请检查配置后重试"); } catch (error) { setFinishError(error instanceof Error ? error.message : "进入应用失败,请重试"); @@ -58,7 +69,18 @@ export function Onboarding({ config, onFinish }: { config: AppRuntimeConfig; onF } {step === 2 && - setDraft((v) => ({ ...v, llm_config }))} onPersist={async (llm_config) => onFinish({ ...draft, onboarding_completed: false, llm_config })} onPendingApiKeyChange={setPendingApiKey} compact /> + setDraft((v) => ({ ...v, llm_config }))} + onPersist={async (llm_config) => onFinish({ + ...draft, + onboarding_completed: false, + llm_config, + llm_fallbacks: (draft.llm_fallbacks ?? []).filter(isLlmServiceUsable), + })} + onPendingApiKeyChange={setPendingApiKey} + compact + /> } {finishError && } diff --git a/src/view/onboarding/onboarding.test.tsx b/src/view/onboarding/onboarding.test.tsx index b26aec3..6e1900f 100644 --- a/src/view/onboarding/onboarding.test.tsx +++ b/src/view/onboarding/onboarding.test.tsx @@ -42,6 +42,51 @@ describe("Onboarding", () => { await waitFor(() => expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ onboarding_completed: true, llm_config: null }))); }); + it("drops hidden historical fallback drafts when skipping AI", async () => { + const onFinish = vi.fn().mockResolvedValue(true); + const historical = { + ...config, + llm_fallbacks: [{ + id: "backup-a", + label: null, + provider: "deepseek" as const, + base_url: "https://api.deepseek.com", + model: "", + enabled: true, + }], + }; + render(); + fireEvent.click(screen.getByRole("button", { name: "跳过 AI,进入应用" })); + + await waitFor(() => expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ + onboarding_completed: true, + llm_config: null, + llm_fallbacks: [], + }))); + }); + + it("keeps complete hidden fallbacks but drops incomplete ones when finishing with a primary model", async () => { + const onFinish = vi.fn().mockResolvedValue(true); + const configured = { + ...config, + llm_config: { provider: "openai" as const, base_url: "https://api.openai.com/v1", model: "gpt-test" }, + llm_fallbacks: [ + { id: "backup-ok", label: null, provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "deepseek-chat", enabled: true }, + { id: "backup-draft", label: null, provider: "deepseek" as const, base_url: "https://api.deepseek.com", model: "", enabled: true }, + ], + }; + render(); + fireEvent.click(screen.getByRole("button", { name: /继\s*续/ })); + await waitFor(() => expect(screen.getByText("已检测到 Chrome")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: "继续配置" })); + fireEvent.click(screen.getByRole("button", { name: "完成并进入应用" })); + + await waitFor(() => expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ + onboarding_completed: true, + llm_fallbacks: [configured.llm_fallbacks[0]], + }))); + }); + it("automatically saves a pending API key before entering the app", async () => { const configured = { ...config, @@ -55,8 +100,10 @@ describe("Onboarding", () => { fireEvent.click(screen.getByRole("button", { name: "输入密钥" })); fireEvent.click(screen.getByRole("button", { name: "完成并进入应用" })); - await waitFor(() => expect(invoke).toHaveBeenCalledWith("set_llm_api_key", { apiKey: "secret-key" })); - expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ onboarding_completed: true })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("set_llm_api_key", { apiKey: "secret-key" }); + expect(onFinish).toHaveBeenCalledWith(expect.objectContaining({ onboarding_completed: true })); + }); }); it("shows a visible error when entering the app fails", async () => { From 26b8c7d771703fb6256bb3b9e95de2221a2df4eb Mon Sep 17 00:00:00 2001 From: patricLee Date: Tue, 25 Aug 2026 01:41:47 +0800 Subject: [PATCH 4/4] fix(llm): surface Responses stream error metadata --- .gitignore | 3 + output/boss-chat-tab-timeout-delivery.md | 8 +- src-tauri/src/llm/service.rs | 158 +++++++++++++++++++++-- 3 files changed, 152 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 6a165c2..7db9668 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ designs/ .playwright-mcp # Claude Code 的 agent 工作区,由工具自行管理 .claude/worktrees/ + + +.pi \ No newline at end of file diff --git a/output/boss-chat-tab-timeout-delivery.md b/output/boss-chat-tab-timeout-delivery.md index 18d54b0..89593ae 100644 --- a/output/boss-chat-tab-timeout-delivery.md +++ b/output/boss-chat-tab-timeout-delivery.md @@ -8,7 +8,7 @@ ## 交付范围 - 目标:定位 BOSS 周期间歇自动回复反复等待会话分类标签超时的根因,实施稳健修复并验证 -- 路由:class=debug · kind=bugfix · depth=standard +- 路由:class=quick_edit · kind=light · depth=fast - 计划 boss-chat-tab-timeout:3/3 done · 0 blocked · 0 未结算 - [done] diagnose 追踪沟通页面导航、分类标签等待与周期重试链路,形成根因诊断(backend-engineer,验收 source-present) - [done] fix 修复分类标签就绪判定与异常恢复逻辑并补充回归测试(backend-engineer,验收 build-test) @@ -17,8 +17,10 @@ ## 关键文件 - src-tauri/src/rpa/boss/handler/chat_list.rs(主会话声明的改动) - src-tauri/src/rpa/boss/handler/reply_unread.rs(主会话声明的改动) +- output/boss-chat-tab-timeout-diagnosis.md(主会话声明的改动) - output/boss-chat-tab-timeout-fix.md(主会话声明的改动) - output/boss-chat-tab-timeout-review.md(主会话声明的改动) +- output/boss-chat-tab-timeout-delivery.md(主会话声明的改动) ## 验证 - [diagnose] source-present → pass @@ -46,9 +48,9 @@ - `.pi/dev/plan.json`:计划 DAG 与每步状态(可恢复) - `.pi/dev/route.json`:本次路由定级与理由 - `.pi/dev/ledger.jsonl`:append-only 审计账本(路由 / 验收 / 评审 / 确认门 / 交付) -- 验收报告 4 份,最近一次 review @ 2026-08-21T06:09:15.477Z +- 验收报告 4 份,最近一次 review @ 2026-08-21T07:58:33.203Z - `.pi/dev/evidence/`:命令完整输出(报告中的 output 已截断) -- 评审报告 2 份,含各席位 accepts / blocking / advisory / evidence 原文 +- 评审报告 1 份,含各席位 accepts / blocking / advisory / evidence 原文 - `.pi/dev/blackboard.md`:当前黑板(合同、实际状态、finding、待确认项) ## 恢复/继续 diff --git a/src-tauri/src/llm/service.rs b/src-tauri/src/llm/service.rs index d8505e7..39bdd59 100644 --- a/src-tauri/src/llm/service.rs +++ b/src-tauri/src/llm/service.rs @@ -859,16 +859,20 @@ fn map_rig_error( .provider_response_json() .ok() .flatten() - .and_then(|value| value.get("error").cloned()) .and_then(|value| provider_error_metadata(&value)); // Build a safe user-facing diagnostic (HTTP status + provider error code/type). // The `provider_error_metadata` output only includes alphanumeric-safe fields // (code, type), never raw message bodies, so it is safe to surface to users. + // + // 没有状态码时也要带上诊断:Responses 协议的错误多数是 HTTP 200 流里的 + // `error` / `response.failed` 事件,状态码恒为空,丢掉 code 就只剩一句 + // 「大模型流式生成失败」,日志完全指不出方向。 let diagnostic = match (status_code, metadata.as_ref()) { (Some(s), Some(m)) => Some(format!("(HTTP {s},{m})")), (Some(s), None) => Some(format!("(HTTP {s})")), - (None, _) => None, + (None, Some(m)) => Some(format!("({m})")), + (None, None) => None, }; let base = match status_code { @@ -889,6 +893,16 @@ fn map_rig_error( "无法连接大模型服务" } } + // 上游用 HTTP 200 加错误信封作答(Responses 的 SSE `error`、 + // `response.failed`、`response.incomplete` 事件都走这里)。这与本地解析失败 + // 是两回事,混成同一句会让人误以为是客户端的锅。 + CompletionError::ProviderResponse(_) => { + if streaming { + "大模型服务在流式响应中返回错误" + } else { + "大模型服务返回错误响应" + } + } _ => { if streaming { "大模型流式生成失败" @@ -918,7 +932,8 @@ fn map_rig_error( let detail = match (status_code, metadata) { (Some(s), Some(m)) => Some(format!("HTTP {s}; {m}")), (Some(s), None) => Some(format!("HTTP {s}")), - (None, _) => safe_completion_detail(&error), + (None, Some(m)) => Some(m), + (None, None) => safe_completion_detail(&error), }; if let Some(detail) = detail { mapped = mapped.with_detail(detail); @@ -946,7 +961,7 @@ fn safe_completion_detail(error: &CompletionError) -> Option { CompletionError::UrlError(_) => Some("invalid provider URL".to_string()), CompletionError::ResponseError(_) => Some("provider response parse failed".to_string()), CompletionError::ProviderResponse(_) => { - Some("provider returned an error response".to_string()) + Some("provider returned an error envelope with a success status".to_string()) } CompletionError::ProviderError(_) => Some("provider request failed".to_string()), CompletionError::RequestError(_) => Some("completion request build failed".to_string()), @@ -963,18 +978,56 @@ fn safe_provider_identifier(value: &str) -> Option<&str> { .then_some(value) } -fn provider_error_metadata(error: &Value) -> Option { - let mut fields = Vec::new(); - for name in ["code", "type"] { - if let Some(value) = error - .get(name) - .and_then(Value::as_str) - .and_then(safe_provider_identifier) - { - fields.push(format!("{name}={value}")); +/// 从 provider 的返回体里定位错误对象。三种形状都要认,否则 Responses 协议的失败 +/// 全都只剩一句没有信息量的中文: +/// - HTTP 错误体:`{"error": {...}}` +/// - Responses SSE 的 error 事件:错误字段直接摊在顶层,没有 `error` 包装 +/// - Responses 的 `response.failed`:`{"response": {"error": {...}}}` +/// +/// 第二种的 `type` 恒为 `"error"`,展示出来没有意义,所以用 `bool` 标出来让调用方跳过。 +fn provider_error_object(body: &Value) -> Option<(&Value, bool)> { + if let Some(error) = body.get("error").filter(|value| value.is_object()) { + return Some((error, false)); + } + if let Some(error) = body + .get("response") + .and_then(|response| response.get("error")) + .filter(|value| value.is_object()) + { + return Some((error, false)); + } + (body.get("type").and_then(Value::as_str) == Some("error")).then_some((body, true)) +} + +fn provider_error_metadata(body: &Value) -> Option { + if let Some((error, is_bare_error_event)) = provider_error_object(body) { + let names: &[&str] = if is_bare_error_event { + &["code"] + } else { + &["code", "type"] + }; + let mut fields = Vec::new(); + for name in names { + if let Some(value) = error + .get(name) + .and_then(Value::as_str) + .and_then(safe_provider_identifier) + { + fields.push(format!("{name}={value}")); + } + } + if !fields.is_empty() { + return Some(fields.join(", ")); } } - (!fields.is_empty()).then(|| fields.join(", ")) + + // `response.incomplete` 不带 error 对象,截断原因只在 incomplete_details 里 + body.get("response") + .and_then(|response| response.get("incomplete_details")) + .and_then(|details| details.get("reason")) + .and_then(Value::as_str) + .and_then(safe_provider_identifier) + .map(|reason| format!("incomplete_reason={reason}")) } #[cfg(test)] @@ -1206,6 +1259,83 @@ mod tests { assert!(!detail.contains(secret)); } + /// Responses 协议的失败绝大多数是 HTTP 200 流里的 `error` 事件:状态码恒为空, + /// 早先的映射在没有状态码时丢掉全部诊断,日志里只剩「大模型流式生成失败」, + /// 无从判断是本地解析问题还是上游拒绝。 + #[test] + fn responses_stream_error_event_surfaces_the_provider_code() { + let secret = "prompt-and-token-never-retain"; + let event = format!( + "data: {{\"type\":\"error\",\"code\":\"server_is_overloaded\",\"message\":\"{secret}\",\"sequence_number\":1}}\n\n" + ); + let (url, _) = mock_server(vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n", + Box::leak(event.into_bytes().into_boxed_slice()), + ]); + + let error = tauri::async_runtime::block_on( + service_with_provider(LlmProviderPreset::OpenAiResponses, url) + .stream("stream test".to_string(), |_| Ok(())), + ) + .unwrap_err(); + + assert_eq!(error.code, AppErrorCode::Provider); + assert!( + error.message.contains("大模型服务在流式响应中返回错误"), + "应指明是上游返回的错误而非本地生成失败:{}", + error.message + ); + assert!( + error.message.contains("code=server_is_overloaded"), + "应带上上游错误码:{}", + error.message + ); + assert!( + !error.message.contains(secret), + "不得回显上游响应体:{}", + error.message + ); + } + + #[test] + fn provider_error_metadata_reads_all_three_error_shapes() { + // HTTP 错误体 + assert_eq!( + super::provider_error_metadata(&serde_json::json!({ + "error": {"code": "rate_limit", "type": "requests"} + })), + Some("code=rate_limit, type=requests".to_string()) + ); + // Responses SSE 的 error 事件:字段摊在顶层,type 恒为 "error" 故跳过 + assert_eq!( + super::provider_error_metadata(&serde_json::json!({ + "type": "error", "code": "server_error", "message": "boom" + })), + Some("code=server_error".to_string()) + ); + // response.failed + assert_eq!( + super::provider_error_metadata(&serde_json::json!({ + "type": "response.failed", + "response": {"error": {"code": "model_error", "type": "server_error"}} + })), + Some("code=model_error, type=server_error".to_string()) + ); + // response.incomplete 不带 error 对象,原因在 incomplete_details 里 + assert_eq!( + super::provider_error_metadata(&serde_json::json!({ + "type": "response.incomplete", + "response": {"incomplete_details": {"reason": "max_output_tokens"}} + })), + Some("incomplete_reason=max_output_tokens".to_string()) + ); + // 没有任何可安全展示的字段时不硬凑 + assert_eq!( + super::provider_error_metadata(&serde_json::json!({"error": {"message": "boom"}})), + None + ); + } + #[test] fn ollama_provider_uses_native_chat_endpoint_and_strips_legacy_v1_suffix() { let body = r#"{"model":"llama3.2","created_at":"2026-08-10T00:00:00Z","message":{"role":"assistant","content":"OK"},"done":true,"prompt_eval_count":1,"eval_count":1}"#;