From 9cb605f3162659c2d92ea0eb03677f71afa64b3c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 15:41:50 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(m174):=20=E5=8A=A9=E6=89=8B=EF=BC=88AI?= =?UTF-8?q?=EF=BC=89=E6=8F=90=E4=BE=9B=E5=95=86=E9=85=8D=E7=BD=AE=E7=9C=9F?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=20+=20=E3=80=8C=E6=B5=8B=E8=AF=95=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页新增第 6 组「助手」:提供商四态 / 端点 base_url / 模型名 / API 密钥(默认掩码, 点「显示」才明文)+ 保存 + 测试连接,四类失败(鉴权 / 模型不存在 / 不可达 / 超时)各自 一句文案 + 目标与耗时。 取舍三条: 1. 读写走 `core::setting` 的 schema API(`SettingStore::load` 现读 → `set` → `save`), 不经 `AppCommand::SetSetting`——那条指令是可序列化 DTO,`ai.api_key` 走它等于让明文进 事件流/日志。与 `SettingsBridge::persist` 写外观是同一条做法。 2. 「测试连接」不另写一套 HTTP:`unlucky_cli::commands::agent::probe` 现读设置文件、 交给 `RigProvider`(真 provider)发一次最小补全,带 8 秒时限;分类只看错误文本 (rig 只给 Display),四类各有一条真 TCP 假服务器用例钉住。 3. `ProviderKind` 补 `from_setting` / `setting_value`:serde 的 `rename_all = snake_case` 产出 `open_ai_compatible`,与 schema 的 `openai_compatible` 不是同一个词——这正是 M174 之前「agent 侧一次都没读过设置」的根因。 守卫(都做变异验证,见 mission 报告):密钥不出 `Debug`/`detail`/DTO;未配置时工厂构造 计数为 0;改完 base_url 后探测打到新地址;空/非法值被 schema 拦下且文件不动。 i18n:49 条中英各一(金表按 `Key::ALL` 位置插入);presenter 的 ai_group 补上 schema 允许 的 `disabled` 选项与一行「已配置 N 字符」的只读状态(仍然不显示值)。 --- crates/agent/src/brain/provider.rs | 60 +++ crates/app-iced/src/app/state/construct.rs | 2 + crates/app-iced/src/app/update/clock.rs | 2 + .../src/app/update/settings/ai_about.rs | 164 +++++++- .../app-iced/src/app/update/settings/tests.rs | 119 ++++++ crates/app-iced/src/pages/settings/ai/mod.rs | 30 ++ .../app-iced/src/pages/settings/ai/outcome.rs | 163 ++++++++ .../app-iced/src/pages/settings/ai/state.rs | 386 ++++++++++++++++++ .../app-iced/src/pages/settings/ai/store.rs | 78 ++++ .../src/pages/settings/ai/store_tests.rs | 129 ++++++ .../app-iced/src/pages/settings/ai/tests.rs | 331 +++++++++++++++ crates/app-iced/src/pages/settings/ai/view.rs | 356 ++++++++++++++++ .../app-iced/src/pages/settings/ai_about.rs | 96 +++-- crates/app-iced/src/pages/settings/mod.rs | 30 +- .../app-iced/src/pages/settings/tests/mod.rs | 16 +- .../pages/settings/tests/update_section.rs | 15 +- crates/cli/src/commands/agent/mod.rs | 1 + crates/cli/src/commands/agent/probe.rs | 354 ++++++++++++++++ crates/cli/src/commands/agent/probe/tests.rs | 215 ++++++++++ .../src/commands/agent/probe/tests_guards.rs | 275 +++++++++++++ crates/i18n/src/catalog/golden/tail.rs | 53 +++ crates/i18n/src/catalog/settings/ai.rs | 93 ++++- crates/presenter/src/host/settings.rs | 44 +- 23 files changed, 2939 insertions(+), 73 deletions(-) create mode 100644 crates/app-iced/src/pages/settings/ai/mod.rs create mode 100644 crates/app-iced/src/pages/settings/ai/outcome.rs create mode 100644 crates/app-iced/src/pages/settings/ai/state.rs create mode 100644 crates/app-iced/src/pages/settings/ai/store.rs create mode 100644 crates/app-iced/src/pages/settings/ai/store_tests.rs create mode 100644 crates/app-iced/src/pages/settings/ai/tests.rs create mode 100644 crates/app-iced/src/pages/settings/ai/view.rs create mode 100644 crates/cli/src/commands/agent/probe.rs create mode 100644 crates/cli/src/commands/agent/probe/tests.rs create mode 100644 crates/cli/src/commands/agent/probe/tests_guards.rs diff --git a/crates/agent/src/brain/provider.rs b/crates/agent/src/brain/provider.rs index 804fa3d..859fee0 100644 --- a/crates/agent/src/brain/provider.rs +++ b/crates/agent/src/brain/provider.rs @@ -54,6 +54,35 @@ impl ProviderKind { } } + /// 配置键 `ai.provider` 的取值 → 形态(M174)。 + /// + /// **不能用 serde**:`#[serde(rename_all = "snake_case")]` 把本枚举写成 + /// `open_ai_compatible`(见本文件末尾的用例),而 schema 的允许值表 + /// (`core::setting::keys::AI_PROVIDERS`)与设置页存的是 `openai_compatible`。两个词表必须在这里 + /// 显式对上,否则「设置里存的值」读不到——这正是 M174 之前 agent 侧一次都没读过设置的原因。 + /// 未知值给 `None`(由调用方回落默认,不猜)。 + #[must_use] + pub fn from_setting(value: &str) -> Option { + match value.trim() { + "openai_compatible" => Some(Self::OpenAiCompatible), + "anthropic" => Some(Self::Anthropic), + "ollama" => Some(Self::Ollama), + "disabled" => Some(Self::Disabled), + _ => None, + } + } + + /// 本形态在 `ai.provider` 里的取值([`Self::from_setting`] 的逆;写回设置时用)。 + #[must_use] + pub fn setting_value(self) -> &'static str { + match self { + Self::OpenAiCompatible => "openai_compatible", + Self::Anthropic => "anthropic", + Self::Ollama => "ollama", + Self::Disabled => "disabled", + } + } + /// 设置页展示名。 #[must_use] pub fn display_name(self) -> &'static str { @@ -283,6 +312,37 @@ mod tests { ); } + #[test] + fn setting_vocabulary_round_trips() { + // 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的 + // `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」 + // agent 侧读出来是 None(M174 的根因)。 + for kind in [ + ProviderKind::OpenAiCompatible, + ProviderKind::Anthropic, + ProviderKind::Ollama, + ProviderKind::Disabled, + ] { + assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind)); + } + assert_eq!( + ProviderKind::from_setting(" ollama "), + Some(ProviderKind::Ollama) + ); + assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None); + assert_eq!(ProviderKind::from_setting(""), None); + // schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。 + assert_eq!( + unlucky_core::setting::keys::AI_PROVIDERS, + [ + ProviderKind::OpenAiCompatible.setting_value(), + ProviderKind::Anthropic.setting_value(), + ProviderKind::Ollama.setting_value(), + ProviderKind::Disabled.setting_value(), + ] + ); + } + #[test] fn validate_requires_model_base_url_and_key() { assert!(!ProviderConfig::disabled().is_enabled()); diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 3d9d837..cd262a7 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -149,6 +149,8 @@ impl App { }; // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 app.rebuild_ui(); + // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 + app.hydrate_ai_settings(); // 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。 // 下一个人据此分得清「探测错了」还是「探测对了但没用上」。 app.lang.log(); diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index 03cb0e5..3a7971e 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -75,6 +75,8 @@ impl App { self.apply_config_theme(); // M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。 self.reload_appearance(); + // M174:助手那四项也一样(`ull config set ai.* …` 改完 1 秒内可见;脏草稿不覆盖)。 + self.hydrate_ai_settings(); } if self.view_dirty { diff --git a/crates/app-iced/src/app/update/settings/ai_about.rs b/crates/app-iced/src/app/update/settings/ai_about.rs index 4ac6bb3..e2be17a 100644 --- a/crates/app-iced/src/app/update/settings/ai_about.rs +++ b/crates/app-iced/src/app/update/settings/ai_about.rs @@ -1,17 +1,171 @@ -//! AI / About 分组消息的落点(M101 `SETTINGSMSG`):**只归 ai+about 线**。 +//! 助手(AI)分组消息的落点(M174):草稿编辑 / 保存 / 测试连接。 //! -//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`)。 +//! 消息口在 `pages/settings/ai_about.rs`(`pages::settings::ai_about::Message`);状态与读写落在 +//! `pages/settings/ai/{state,store}.rs`。本文件只有三件事: //! -//! 首版是空枚举上的 `match message {}`:这是**穷尽**的写法(不是兜底),加了变体不接线就报 E0004。 +//! | 消息 | 做什么 | +//! |---|---| +//! | `ToggleEnabled` / `PickProvider` / `EditBaseUrl` / `EditModel` / `EditKey` / `Reveal` | 只改草稿(`dirty = true`) | +//! | `Save` | 先 [`State::validate`](pages::settings::ai::State::validate),再 `ai::store::save`(真写 `launcher.json`) | +//! | `Test` | `Task::perform` 起一次真探测(`unlucky_cli::commands::agent::probe`),结果走 `ProbeDone` | +//! +//! ## 为什么「保存」不派 `AppCommand::SetSetting` +//! +//! 那条指令是可序列化的 DTO,`ai.api_key` 走它就意味着明文进了命令通道(事件日志 / mock 回放都会 +//! 看到)。`pages/settings/ai/store.rs` 的模块文档记着完整理由:这里走的是同一条 schema 通路 +//! (`SettingStore::set` + `save`),只是不经过那条通道。 +//! +//! ## 为什么「测试」读文件而不是读草稿 +//! +//! 测的是**已经保存的那份配置**(`probe_file` 每次现读 `launcher.json`)。用户在输入框里改了但没 +//! 点保存时,按钮旁边那句「有未保存的改动」会提醒他——而不是偷偷拿草稿去测一个不存在的配置。 use super::super::*; +use crate::pages::settings::ai::{Probe, ProbeResult}; + impl App { - /// AI / About 分组的一条消息。首版没有可处理的东西(空枚举)。 + /// 助手分组的一条消息。 pub(super) fn on_settings_ai_about( &mut self, message: pages::settings::ai_about::Message, ) -> Task { - match message {} + use pages::settings::ai::Secret; + use pages::settings::ai_about::Message; + + match message { + Message::ToggleEnabled(enabled) => { + self.settings.ai_about.enabled = enabled; + self.touch(); + } + Message::PickProvider(provider) => { + self.settings.ai_about.provider = provider; + self.touch(); + } + Message::EditBaseUrl(value) => { + self.settings.ai_about.base_url = value; + self.touch(); + } + Message::EditModel(value) => { + self.settings.ai_about.model = value; + self.touch(); + } + Message::EditKey(value) => { + self.settings.ai_about.key = Secret::new(value); + self.touch(); + } + Message::Reveal(reveal) => { + self.settings.ai_about.reveal = reveal; + self.view_dirty = true; + } + Message::Save => return self.save_ai_settings(), + Message::Test => return self.test_ai_connection(), + Message::ProbeDone(result) => { + self.settings.ai_about.probe = Probe::Done(*result); + self.view_dirty = true; + } + } + + Task::none() + } + + /// 记一次「草稿变了」:清掉旧回执(它已经在说另一份配置了),并让视图重建。 + fn touch(&mut self) { + self.settings.ai_about.dirty = true; + self.settings.ai_about.notice = None; + self.view_dirty = true; + } + + /// 从真值(`config/launcher.json`)灌一次草稿;截图通道改用 `ULL_AI_STATE` 的种子。 + /// + /// 什么时候调:① 构造之后(首屏就要显示已存的值);② 配置文件被外部改过(`update/clock.rs` + /// 的 mtime 轮询)——`ull config set ai.model …` 或 AI 的 `set_config` 改完 1 秒内可见。 + /// **脏草稿不覆盖**([`State::hydrate`](pages::settings::ai::State::hydrate) 自己判), + /// 否则外部一改文件就把用户正在输入的内容冲掉。 + pub(in crate::app) fn hydrate_ai_settings(&mut self) { + if self.options.screenshot.is_some() { + let raw = std::env::var("ULL_AI_STATE").unwrap_or_else(|_| "unconfigured".to_string()); + if let Some(state) = pages::settings::ai::State::screenshot_seed(&raw) { + self.settings.ai_about = state; + } + return; + } + let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else { + return; + }; + let snapshot = pages::settings::ai::store::load(&path); + if self.settings.ai_about.hydrate(snapshot) { + self.view_dirty = true; + } + } + + /// 保存:本地校验 → 落盘 → 用**落盘后读回来的真值**刷新草稿。 + fn save_ai_settings(&mut self) -> Task { + use pages::settings::ai::{store, SaveNotice}; + + if let Err(refusal) = self.settings.ai_about.validate() { + self.settings.ai_about.notice = Some(SaveNotice::Refused(refusal)); + self.view_dirty = true; + return Task::none(); + } + let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else { + self.settings.ai_about.notice = Some(SaveNotice::NoStorage); + self.view_dirty = true; + return Task::none(); + }; + + let saved = store::save(&path, &self.settings.ai_about); + match saved { + Ok(snapshot) => { + // 先清脏再 hydrate:我们刚写进去的就是这一份,不存在「冲掉用户输入」。 + self.settings.ai_about.dirty = false; + self.settings.ai_about.hydrate(snapshot); + self.settings.ai_about.key = pages::settings::ai::Secret::default(); + self.settings.ai_about.notice = Some(SaveNotice::Saved); + } + Err(reason) => { + self.settings.ai_about.notice = + Some(pages::settings::ai::SaveNotice::Failed(reason)); + } + } + self.view_dirty = true; + Task::none() + } + + /// 测试连接:真探测在后台跑(`probe_file` 现读设置文件),结果经 `ProbeDone` 回来。 + fn test_ai_connection(&mut self) -> Task { + if !self.settings.ai_about.can_test() { + return Task::none(); + } + let Some(path) = self.bridge.path().map(std::path::Path::to_path_buf) else { + self.settings.ai_about.probe = Probe::Done(ProbeResult::from_outcome( + unlucky_cli::commands::agent::probe::ProbeOutcome::NotConfigured { + missing: unlucky_cli::commands::agent::probe::Missing::BaseUrl, + }, + 0, + )); + self.view_dirty = true; + return Task::none(); + }; + + self.settings.ai_about.probe = Probe::Running; + self.view_dirty = true; + Task::perform( + async move { + let started = std::time::Instant::now(); + let outcome = unlucky_cli::commands::agent::probe::probe_file( + &path, + unlucky_cli::commands::agent::probe::DEFAULT_TIMEOUT, + ); + ProbeResult::from_outcome(outcome, started.elapsed().as_millis()) + }, + |result| { + Message::Page(pages::Message::Settings( + pages::settings::GroupMessage::AiAbout( + pages::settings::ai_about::Message::ProbeDone(Box::new(result)), + ), + )) + }, + ) } } diff --git a/crates/app-iced/src/app/update/settings/tests.rs b/crates/app-iced/src/app/update/settings/tests.rs index 150f2ba..868db71 100644 --- a/crates/app-iced/src/app/update/settings/tests.rs +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -265,3 +265,122 @@ fn a_group_message_reaches_its_handler_through_the_funnel() { "java 线的消息没有走到 `on_settings_java`:消息漏斗在某一环断了" ); } + +// --------------------------------------------------------------- M174:助手配置的活路径 + +/// 一个指向临时目录的设置桥(真文件,不是内存态)。 +fn bridged_app(label: &str) -> (App, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "ull-ai-app-{label}-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + let app = App::with_presenter( + Options::default(), + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::with_path(&path), + ); + (app, path) +} + +fn ai(message: pages::settings::ai_about::Message) -> Message { + Message::Page(pages::Message::Settings( + pages::settings::GroupMessage::AiAbout(message), + )) +} + +/// **M174 的活路径**:设置页那四条消息(改端点/模型/密钥)+ 保存,真的写进 `launcher.json`。 +/// +/// 抓得住:handler 没接 `Save`、`store::save` 没写某一个键、写错键名、密钥没落盘。 +/// 抓不住:用户真的点了那颗按钮(那是 iced 事件循环的事),也抓不住「保存后 agent 读到了」 +/// (那一半由 `unlucky-cli` 的 `probe_file` 用例证明:它读的就是这个文件)。 +#[test] +fn saving_the_ai_form_writes_the_real_settings_file() { + use pages::settings::ai_about::Message as Ai; + + let (mut app, path) = bridged_app("save"); + let _ = app.update(ai(Ai::PickProvider( + pages::settings::ai::ProviderId::OpenAiCompatible, + ))); + let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string()))); + let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string()))); + let _ = app.update(ai(Ai::EditKey("sk-live-app-path".to_string()))); + let _ = app.update(ai(Ai::Save)); + + let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件"); + assert!( + text.contains("\"base_url\": \"http://127.0.0.1:8080/v1\""), + "{text}" + ); + assert!(text.contains("\"model\": \"qwen2.5-7b\""), "{text}"); + assert!( + text.contains("\"provider\": \"openai_compatible\""), + "{text}" + ); + assert!(text.contains("\"api_key\": \"sk-live-app-path\""), "{text}"); + + // 保存之后:草稿清干净、回执是「已保存」、密钥输入框清空(值已进文件,不再留在 UI 状态里)。 + let state = &app.settings.ai_about; + assert_eq!(state.notice, Some(pages::settings::ai::SaveNotice::Saved)); + assert!(!state.dirty); + assert!(state.key.is_empty()); + assert_eq!(state.key_chars, "sk-live-app-path".chars().count()); + let printed = format!("{state:?}"); + assert!( + !printed.contains("sk-live-app-path"), + "UI 状态泄漏密钥:{printed}" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +/// **非法值不保存**:端点没有协议头时,保存被拦下、文件不动、回执是具名拒绝原因。 +#[test] +fn refusing_to_save_leaves_the_file_untouched() { + use pages::settings::ai_about::Message as Ai; + + let (mut app, path) = bridged_app("refuse"); + let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string()))); + let _ = app.update(ai(Ai::EditModel("qwen".to_string()))); + let _ = app.update(ai(Ai::Save)); + let saved = std::fs::read_to_string(&path).expect("第一次应当写成"); + + let _ = app.update(ai(Ai::EditBaseUrl("api.deepseek.com/v1".to_string()))); + let _ = app.update(ai(Ai::Save)); + assert_eq!( + app.settings.ai_about.notice, + Some(pages::settings::ai::SaveNotice::Refused( + pages::settings::ai::state::Refusal::BaseUrl + )), + "没有协议头的端点必须被拦下,且要说清原因" + ); + let after = std::fs::read_to_string(&path).expect("读文件"); + assert_eq!(saved, after, "被拒绝的保存不许改动文件:{after}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +/// **未配置时不发请求**(GUI 侧那一半):缺模型名时点「测试连接」不会起任务、不留 Running 态。 +#[test] +fn testing_without_configuration_does_nothing() { + use pages::settings::ai_about::Message as Ai; + + let (mut app, path) = bridged_app("no-test"); + let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string()))); + // 模型名空着 ⇒ `can_test()` 为假 + assert!(app.settings.ai_about.missing().is_some()); + let _ = app.update(ai(Ai::Test)); + assert_eq!( + app.settings.ai_about.probe, + pages::settings::ai::Probe::Idle, + "未配置时点测试不该有任何探测态(更不该发请求)" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} diff --git a/crates/app-iced/src/pages/settings/ai/mod.rs b/crates/app-iced/src/pages/settings/ai/mod.rs new file mode 100644 index 0000000..418b536 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/mod.rs @@ -0,0 +1,30 @@ +//! 设置页「助手(AI)」分组(M174):提供商 / 端点 / 模型 / 密钥 + 「测试连接」。 +//! +//! ## 为什么单独一个目录 +//! +//! `pages/settings/mod.rs` 里只挂一行 `mod ai;`(与 M173 的版面改动不碰面),本组的状态 / 读写 / +//! 渲染 / 用例都在这四个文件里:[`state`](草稿 + 探测结论 + 校验)、[`store`](真读写 +//! `config/launcher.json` 的 `ai.*` 五键)、[`view`](一张卡片)与 `tests`。 +//! +//! ## 三条纪律(本组的全部安全面) +//! +//! 1. **密钥不进 DTO、不进日志、不进错误文案**:草稿是 [`state::Secret`](`Debug` 手写成掩码), +//! 落盘走 [`store::save`](在进程内直接过 `core::setting` 的 schema,**不经过** +//! `AppCommand::SetSetting` 那条可序列化的指令通道——那条通道会把明文带进事件流/日志); +//! 界面上默认掩码,只有用户点「显示」才明文。已存密钥的**长度**是唯一允许上屏的数值。 +//! 2. **空/非法值不保存**:[`state::State::validate`] 先拦(提供商词表、`http(s)://` 前缀、模型名), +//! schema 再拦(越界/类型),两次都过才写盘。失败时界面上给的是拒绝原因,不是「已保存」。 +//! 3. **不配置就不可能发请求**:「测试连接」按钮在 [`state::State::can_test`] 为假时是禁用的, +//! 真发不发由 `unlucky_cli::commands::agent::probe` 里的 `to_config()` 再拦一次 +//! (假 provider 构造计数 == 0 的守卫在那里)。 + +pub mod outcome; +pub mod state; +pub mod store; +pub mod view; + +#[cfg(test)] +mod tests; + +pub use outcome::ProbeResult; +pub use state::{Probe, ProviderId, SaveNotice, Secret, State}; diff --git a/crates/app-iced/src/pages/settings/ai/outcome.rs b/crates/app-iced/src/pages/settings/ai/outcome.rs new file mode 100644 index 0000000..b5465f3 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/outcome.rs @@ -0,0 +1,163 @@ +//! 「测试连接」的结论(**界面要画的那一份**):缺项 / 分类 / 目标 / 耗时 / 脱敏详情。 +//! +//! 为什么从 `state.rs` 拆出来:那个文件被 400 行硬线卡着(`components/gate.rs` 的 +//! `every_source_file_stays_under_the_line_limit` 按全仓判红),而这几件是**纯数据 + 翻译**, +//! 与草稿状态机没有耦合。分类判断不在本文件:那是 +//! `unlucky_cli::commands::agent::probe` 的事(四类各有真 TCP 假服务器用例),这里只翻译。 + +use unlucky_i18n::Key; + +/// 缺哪一项(「未配置」时界面上要说清差什么)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingField { + /// 已关闭 / 提供商是 disabled + Disabled, + /// 提供商取值不认识 + Provider, + /// 端点为空 + BaseUrl, + /// 模型名为空 + Model, + /// Anthropic 缺密钥 + ApiKey, +} + +impl MissingField { + /// 界面文案 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::Disabled => Key::SettingsAiMissingDisabled, + Self::Provider => Key::SettingsAiReasonProvider, + Self::BaseUrl => Key::SettingsAiMissingBaseUrl, + Self::Model => Key::SettingsAiMissingModel, + Self::ApiKey => Key::SettingsAiMissingApiKey, + } + } +} + +/// 「测试连接」的结论大类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeKind { + /// 通过 + Passed, + /// 鉴权失败 + Auth, + /// 模型不存在 + ModelNotFound, + /// 端点不可达 + Unreachable, + /// 超时 + Timeout, + /// 其它失败 + Failed, + /// 配置不完整,**没有发请求** + NotConfigured, +} + +impl ProbeKind { + /// 界面文案 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::Passed => Key::SettingsAiTestPassed, + Self::Auth => Key::SettingsAiTestAuth, + Self::ModelNotFound => Key::SettingsAiTestModel, + Self::Unreachable => Key::SettingsAiTestUnreachable, + Self::Timeout => Key::SettingsAiTestTimeout, + Self::Failed => Key::SettingsAiTestOther, + Self::NotConfigured => Key::SettingsAiTestNotConfigured, + } + } + + /// 是不是「不通」那一类(界面用错误色)。 + #[must_use] + pub const fn is_failure(self) -> bool { + !matches!(self, Self::Passed) + } +} + +/// 一次探测的结论(数值 + 分类 + 脱敏后的详情)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbeResult { + /// 大类。 + pub kind: ProbeKind, + /// 未配置时缺的那一项。 + pub missing: Option, + /// 实际请求的端点(`resolved_base_url`)。 + pub base_url: String, + /// 实际请求的模型名。 + pub model: String, + /// 耗时(毫秒)。 + pub elapsed_ms: u128, + /// 脱敏后的原因(可能为空)。 + pub detail: String, +} + +impl ProbeResult { + /// 「测试连接」的结论(`unlucky_cli` 的探测器产出)→ 界面要画的那一份。 + /// + /// 只做**翻译**:分类判断全在 `unlucky_cli::commands::agent::probe`(那边有四条真 TCP 假 + /// 服务器用例逐个钉住),这里不重新猜一遍。`elapsed_ms` 由发起处计时(含建连 + 首 token)。 + #[must_use] + pub fn from_outcome( + outcome: unlucky_cli::commands::agent::probe::ProbeOutcome, + elapsed_ms: u128, + ) -> Self { + use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome}; + + let missing = match &outcome { + ProbeOutcome::NotConfigured { missing } => Some(match missing { + Missing::Disabled => MissingField::Disabled, + Missing::Provider => MissingField::Provider, + Missing::BaseUrl => MissingField::BaseUrl, + Missing::Model => MissingField::Model, + Missing::ApiKey => MissingField::ApiKey, + }), + _ => None, + }; + let (kind, base_url, model, detail) = match outcome { + ProbeOutcome::NotConfigured { .. } => ( + ProbeKind::NotConfigured, + String::new(), + String::new(), + String::new(), + ), + ProbeOutcome::Passed { base_url, model } => { + (ProbeKind::Passed, base_url, model, String::new()) + } + ProbeOutcome::Auth { detail } => { + (ProbeKind::Auth, String::new(), String::new(), detail) + } + ProbeOutcome::ModelNotFound { detail } => ( + ProbeKind::ModelNotFound, + String::new(), + String::new(), + detail, + ), + ProbeOutcome::Unreachable { detail } => { + (ProbeKind::Unreachable, String::new(), String::new(), detail) + } + ProbeOutcome::Timeout { detail } => { + (ProbeKind::Timeout, String::new(), String::new(), detail) + } + ProbeOutcome::Failed { detail } => { + (ProbeKind::Failed, String::new(), String::new(), detail) + } + }; + Self { + kind, + missing, + base_url, + model, + elapsed_ms, + detail, + } + } + + /// 失败的三类之外的那几类(`NotConfigured` 没发请求)。 + #[must_use] + pub fn sent_a_request(&self) -> bool { + self.kind != ProbeKind::NotConfigured + } +} diff --git a/crates/app-iced/src/pages/settings/ai/state.rs b/crates/app-iced/src/pages/settings/ai/state.rs new file mode 100644 index 0000000..37e1c7d --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/state.rs @@ -0,0 +1,386 @@ +//! 「助手」分组的运行期状态:四条草稿 + 一颗探测结论 + 校验。 +//! +//! 状态住在 `pages::settings::Groups.ai_about`([`State`]),由 `App` 持有: +//! 用户正在输入的值**不能**放进 `pages::Model`(那个模型每次事件都整棵重建,见 +//! `app/state.rs` 里 `download_draft` 的同一条理由)。 + +use unlucky_i18n::Key; + +use super::outcome::{MissingField, ProbeKind, ProbeResult}; + +/// 四个提供商(值 = `ai.provider` 的词表,真源是 `core::setting::keys::AI_PROVIDERS`, +/// `ai/tests.rs` 里对表)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ProviderId { + /// OpenAI 兼容(含 llama.cpp / LM Studio / DeepSeek / Qwen) + #[default] + OpenAiCompatible, + /// Anthropic 原生 + Anthropic, + /// Ollama 原生 + Ollama, + /// 禁用 + Disabled, +} + +impl ProviderId { + /// 四态(界面上的顺序 = 这张表)。 + pub const ALL: [Self; 4] = [ + Self::OpenAiCompatible, + Self::Anthropic, + Self::Ollama, + Self::Disabled, + ]; + + /// `ai.provider` 的取值。 + #[must_use] + pub const fn setting(self) -> &'static str { + match self { + Self::OpenAiCompatible => "openai_compatible", + Self::Anthropic => "anthropic", + Self::Ollama => "ollama", + Self::Disabled => "disabled", + } + } + + /// 取值 → 四态(未知/空值回落 OpenAI 兼容,与 schema 的默认值同一档)。 + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim() { + "anthropic" => Self::Anthropic, + "ollama" => Self::Ollama, + "disabled" => Self::Disabled, + _ => Self::OpenAiCompatible, + } + } + + /// 该形态有没有统一默认端点(没有 ⇒ base_url 必填)。 + #[must_use] + pub const fn default_base_url(self) -> &'static str { + match self { + Self::OpenAiCompatible => "", + Self::Anthropic => "https://api.anthropic.com", + Self::Ollama => "http://127.0.0.1:11434", + Self::Disabled => "", + } + } + + /// 界面标签 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::OpenAiCompatible => Key::SettingsAiProviderOpenAi, + Self::Anthropic => Key::SettingsAiProviderAnthropic, + Self::Ollama => Key::SettingsAiProviderOllama, + Self::Disabled => Key::SettingsAiProviderDisabled, + } + } +} + +/// 用户刚输入的密钥草稿:`Debug` 掩码,只有 [`Secret::expose`] 能拿到明文。 +/// +/// 为什么不用裸 `String`:`Groups` / `State` 都 `derive(Debug)`,`App` 里任何一处 `{:?}`(日志、 +/// panic 消息、测试断言失败输出)都会把明文打出来。类型自己守住这一条,比每次记得手写便宜。 +#[derive(Clone, Default, PartialEq, Eq)] +pub struct Secret(String); + +impl Secret { + /// 从输入框的内容构造。 + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + /// 是否空(空白串也算空)。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.trim().is_empty() + } + + /// 明文(**只给落盘用**;不要拿它拼任何上屏/日志文本)。 + #[must_use] + pub fn expose(&self) -> &str { + self.0.trim() + } +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_empty() { + f.write_str("Secret(空)") + } else { + f.write_str("Secret(***)") + } + } +} + +/// 设置文件里那一份真值(`ai.*` 五键)。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Snapshot { + /// `ai.enabled` + pub enabled: bool, + /// `ai.provider` + pub provider: ProviderId, + /// `ai.base_url` + pub base_url: String, + /// `ai.model` + pub model: String, + /// 已存密钥的字符数(0 = 没存)。**只带长度不带值**:DTO 里不该有密钥。 + pub key_chars: usize, +} + +/// 探测三态。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Probe { + /// 还没测 + #[default] + Idle, + /// 正在测 + Running, + /// 测完了 + Done(ProbeResult), +} + +/// 保存结果(用户点「保存」之后的回执)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveNotice { + /// 已落盘 + Saved, + /// 被校验拦下(没写盘) + Refused(Refusal), + /// schema/磁盘拒绝(没写盘) + Failed(String), + /// 设置文件不可用(内存态运行) + NoStorage, +} + +/// 拒绝保存的原因(本地校验,先于 schema)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// 端点不是 http(s) + BaseUrl, + /// 缺模型名 + Model, +} + +impl Refusal { + /// 界面文案 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::BaseUrl => Key::SettingsAiReasonBaseUrl, + Self::Model => Key::SettingsAiReasonModel, + } + } +} + +/// 「助手」分组的运行期状态。 +#[derive(Debug)] +pub struct State { + /// `ai.enabled` + pub enabled: bool, + /// `ai.provider` + pub provider: ProviderId, + /// `ai.base_url` 草稿 + pub base_url: String, + /// `ai.model` 草稿 + pub model: String, + /// 新密钥草稿(空 = 不改已存的那把) + pub key: Secret, + /// 已存密钥的字符数(0 = 没存) + pub key_chars: usize, + /// 是否明文显示密钥输入框 + pub reveal: bool, + /// 有未保存的改动 + pub dirty: bool, + /// 设置文件可用(内存态运行时置假:保存与测试都禁用) + pub storage: bool, + /// 上一次保存的回执 + pub notice: Option, + /// 探测结论 + pub probe: Probe, +} + +impl Default for State { + fn default() -> Self { + Self { + enabled: true, + provider: ProviderId::OpenAiCompatible, + base_url: String::new(), + model: String::new(), + key: Secret::default(), + key_chars: 0, + reveal: false, + dirty: false, + storage: false, + notice: None, + probe: Probe::Idle, + } + } +} + +impl State { + /// 用设置文件里的真值重置草稿(**只在没脏的时候**:不许冲掉用户正在输入的内容)。 + /// + /// 返回是否真的改了(调用方据此决定要不要重建视图模型)。 + pub fn hydrate(&mut self, snapshot: Snapshot) -> bool { + self.storage = true; + self.key_chars = snapshot.key_chars; + if self.dirty { + return false; + } + let changed = self.enabled != snapshot.enabled + || self.provider != snapshot.provider + || self.base_url != snapshot.base_url + || self.model != snapshot.model; + self.enabled = snapshot.enabled; + self.provider = snapshot.provider; + self.base_url = snapshot.base_url; + self.model = snapshot.model; + self.keep_probe_if_targeted(); + changed + } + + /// 端点/模型变了之后,「已通过」的旧结论不再是关于现在这套配置的——清掉,免得说谎。 + fn keep_probe_if_targeted(&mut self) { + if let Probe::Done(result) = &self.probe { + if result.base_url != self.resolved_base_url() || result.model != self.model.trim() { + self.probe = Probe::Idle; + } + } + } + + /// 实际生效的端点(空 = 用该形态的默认值)。 + #[must_use] + pub fn resolved_base_url(&self) -> String { + let draft = self.base_url.trim(); + if draft.is_empty() { + self.provider.default_base_url().to_string() + } else { + draft.to_string() + } + } + + /// 配置齐了没有(不齐时「测试连接」禁用,界面上说缺哪一项)。 + #[must_use] + pub fn missing(&self) -> Option { + if !self.enabled || self.provider == ProviderId::Disabled { + return Some(MissingField::Disabled); + } + if self.resolved_base_url().is_empty() { + return Some(MissingField::BaseUrl); + } + if self.model.trim().is_empty() { + return Some(MissingField::Model); + } + if self.provider == ProviderId::Anthropic && self.key.is_empty() && self.key_chars == 0 { + return Some(MissingField::ApiKey); + } + None + } + + /// 现在能不能点「测试连接」。 + #[must_use] + pub fn can_test(&self) -> bool { + self.storage && self.missing().is_none() && !matches!(self.probe, Probe::Running) + } + + /// 状态矩阵的三档(未配置 / 已配置 / 已通过 / 测试失败)。 + #[must_use] + pub fn status(&self) -> Status { + match &self.probe { + Probe::Done(result) if result.kind == ProbeKind::Passed => Status::Verified, + Probe::Done(result) if result.kind.is_failure() => Status::Failed, + _ if self.missing().is_some() => Status::Unconfigured, + _ => Status::Configured, + } + } + + /// 保存前校验(用户输入侧);过不了就不写盘。 + /// + /// # Errors + /// 端点不是 `http(s)://` 或该形态缺模型名时返回原因。 + pub fn validate(&self) -> Result<(), Refusal> { + if self.provider == ProviderId::Disabled || !self.enabled { + return Ok(()); + } + let base_url = self.resolved_base_url(); + if base_url.is_empty() + || !(base_url.starts_with("http://") || base_url.starts_with("https://")) + { + return Err(Refusal::BaseUrl); + } + if self.model.trim().is_empty() { + return Err(Refusal::Model); + } + Ok(()) + } + + /// 取证开关 `ULL_AI_STATE`(只给 `--screenshot` 用,见 `app/state/construct.rs`)。 + /// + /// 为什么要它:D30 的状态矩阵要出「未配置 / 已配置并测过 / 测试失败」三张图,而真实路径要 + /// 点按钮、要联网。语义与 `ULL_UPDATE_STATE` / `ULL_ACCOUNT_STATE` 一致:只影响这一次进程。 + #[must_use] + pub fn screenshot_seed(raw: &str) -> Option { + let mut state = Self { + storage: true, + ..Self::default() + }; + match raw.trim() { + "unconfigured" => {} + "configured" => { + state.base_url = "https://api.deepseek.com/v1".to_string(); + state.model = "deepseek-chat".to_string(); + state.key_chars = 32; + } + other => { + let (kind, missing) = match other { + "verified" => (ProbeKind::Passed, None), + "failed-auth" => (ProbeKind::Auth, None), + "failed-model" => (ProbeKind::ModelNotFound, None), + "failed-unreachable" => (ProbeKind::Unreachable, None), + "failed-timeout" => (ProbeKind::Timeout, None), + _ => return None, + }; + state.base_url = "https://api.deepseek.com/v1".to_string(); + state.model = "deepseek-chat".to_string(); + state.key_chars = 32; + state.probe = Probe::Done(ProbeResult { + kind, + missing, + base_url: state.resolved_base_url(), + model: state.model.clone(), + elapsed_ms: 412, + detail: String::new(), + }); + } + } + Some(state) + } +} + +/// 页头的状态档(D30 的状态矩阵:四档各自一句文案 + 一种颜色)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// 未配置(缺项,按钮禁用) + Unconfigured, + /// 已配置(还没测或测过但目标改了) + Configured, + /// 连接已通过 + Verified, + /// 测试失败 + Failed, +} + +impl Status { + /// 文案 key。 + #[must_use] + pub const fn label(self) -> Key { + match self { + Self::Unconfigured => Key::SettingsAiStatusUnconfigured, + Self::Configured => Key::SettingsAiStatusConfigured, + Self::Verified => Key::SettingsAiStatusVerified, + Self::Failed => Key::SettingsAiStatusTestFailed, + } + } +} diff --git a/crates/app-iced/src/pages/settings/ai/store.rs b/crates/app-iced/src/pages/settings/ai/store.rs new file mode 100644 index 0000000..e3f4d0c --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/store.rs @@ -0,0 +1,78 @@ +//! 「助手」分组的**真读写**:`config/launcher.json` 的 `ai.enabled` / `ai.provider` / +//! `ai.base_url` / `ai.model` / `ai.api_key` 五键。 +//! +//! ## 为什么在进程内直接过 `SettingStore`,不走 `AppCommand::SetSetting` +//! +//! `AppCommand` 是可序列化的 DTO(`dto/events.rs` 的 `derive(Serialize, Deserialize)`), +//! 把 `ai.api_key` 塞进去就等于让明文进了「命令通道」——事件日志、调试转储、mock 后端的事件 +//! 回放都会看到它。M174 的守卫①要求密钥**不出进程内**,所以本组写盘走 +//! `core::setting::store::SettingStore`:schema 校验 + 原子写照旧(不自己拼 JSON、不绕 schema), +//! 只是不经过那条可序列化的通道。这与 `SettingsBridge::persist` 写外观是同一条做法。 +//! +//! ## 为什么每次现读现写 +//! +//! `SettingStore::load` 每次从磁盘读一份(`HostEnv::store()` 也是这个口径),所以「读 → 改 → 存」 +//! 不会用启动时的旧快照盖掉别人刚写的键(AI / `ull config set` 是同一个文件的外部写者)。 + +use std::path::Path; + +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +use super::state::{ProviderId, Snapshot, State}; + +/// 读一份真值。文件缺失/坏掉时 `SettingStore::load` 自己降级,这里只会读到 schema 默认值。 +#[must_use] +pub fn load(path: &Path) -> Snapshot { + let store = SettingStore::load(path, launcher_schema()); + snapshot(store.doc()) +} + +/// 把草稿落盘,返回**落盘后**再读回来的真值(界面据此刷新,而不是相信自己的草稿)。 +/// +/// # Errors +/// schema 拒绝(越界/类型/只读文件)或写盘失败时返回人话(已过 [`super::state::State::validate`], +/// 所以这里只剩「schema/磁盘」这一类)。 +pub fn save(path: &Path, state: &State) -> Result { + let mut store = SettingStore::load(path, launcher_schema()); + // `Value` 是 `set` 的形参类型:用 `.into()` 让目标类型从签名推出,本 crate 就不必直接依赖 + // `serde_json`(`Cargo.toml` 不在本 mission 的 scope,加依赖要单独走)。 + store + .set("ai.enabled", state.enabled.into()) + .and_then(|()| store.set("ai.provider", state.provider.setting().to_string().into())) + .and_then(|()| store.set("ai.base_url", state.base_url.trim().to_string().into())) + .and_then(|()| store.set("ai.model", state.model.trim().to_string().into())) + .map_err(|error| format!("{error}"))?; + + // 密钥:草稿非空才写(空 = 保留已存的那一把,界面上写着这条规则)。 + if !state.key.is_empty() { + store + .set("ai.api_key", state.key.expose().to_string().into()) + .map_err(|error| format!("{error}"))?; + } + + store.save().map_err(|error| format!("{error}"))?; + // 落盘后再读一遍:`set` 只证明「schema 收了」,`save` 之后文件里到底是什么才算数。 + Ok(snapshot(store.doc())) +} + +/// 文档 → 快照(密钥只取长度,值不出本函数)。 +fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot { + let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string(); + let key_chars = doc + .effective_str("ai.api_key") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or(0, |value| value.chars().count()); + Snapshot { + enabled: doc.effective_bool("ai.enabled").unwrap_or(true), + provider: ProviderId::from_setting(&text("ai.provider")), + base_url: text("ai.base_url"), + model: text("ai.model"), + key_chars, + } +} + +#[cfg(test)] +#[path = "store_tests.rs"] +mod tests; diff --git a/crates/app-iced/src/pages/settings/ai/store_tests.rs b/crates/app-iced/src/pages/settings/ai/store_tests.rs new file mode 100644 index 0000000..bb0bece --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/store_tests.rs @@ -0,0 +1,129 @@ +//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘。 +//! +//! | 用例 | 守什么 | 变异 | +//! |---|---|---| +//! | `five_keys_round_trip` | 四项 + 密钥都真的落进 `launcher.json` | 少写一个键 / 写错键名 | +//! | `snapshot_only_reports_key_length` | 快照里没有密钥值,只有长度 | 把 `api_key` 值放进 `Snapshot` | +//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 | +//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` | +//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 | + +use std::path::PathBuf; + +use super::{load, save}; +use crate::pages::settings::ai::state::{ProviderId, Secret, State}; + +fn temp_config(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-ai-store-{label}-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + let _ = std::fs::remove_file(&path); + path +} + +fn draft() -> State { + State { + enabled: true, + provider: ProviderId::Anthropic, + base_url: "https://api.anthropic.com".to_string(), + model: "claude-sonnet-4".to_string(), + key: Secret::new("sk-ant-secret"), + dirty: true, + storage: true, + ..State::default() + } +} + +#[test] +fn five_keys_round_trip() { + let path = temp_config("roundtrip"); + let saved = save(&path, &draft()).expect("应当能保存"); + assert_eq!(saved.provider, ProviderId::Anthropic); + assert_eq!(saved.base_url, "https://api.anthropic.com"); + assert_eq!(saved.model, "claude-sonnet-4"); + assert_eq!(saved.key_chars, "sk-ant-secret".chars().count()); + + // 文件里真的是这五个键(不是我们内存里的对象)。 + let text = std::fs::read_to_string(&path).expect("读文件"); + assert!( + text.contains("\"base_url\": \"https://api.anthropic.com\""), + "{text}" + ); + assert!(text.contains("\"model\": \"claude-sonnet-4\""), "{text}"); + assert!(text.contains("\"provider\": \"anthropic\""), "{text}"); + assert!(text.contains("\"api_key\": \"sk-ant-secret\""), "{text}"); + + // 新开一份读回来(= 重启语义)。 + let reloaded = load(&path); + assert_eq!(reloaded, saved); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +#[test] +fn snapshot_only_reports_key_length() { + let path = temp_config("length"); + let saved = save(&path, &draft()).expect("保存"); + // `Snapshot` 里唯一与密钥有关的字段是长度:类型上就没有 `String` 可以装明文。 + let printed = format!("{saved:?}"); + assert!( + !printed.contains("sk-ant-secret"), + "快照泄漏密钥:{printed}" + ); + assert_eq!(saved.key_chars, "sk-ant-secret".len()); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +#[test] +fn empty_draft_keeps_the_stored_key() { + let path = temp_config("keep-key"); + save(&path, &draft()).expect("先存一把"); + let mut next = draft(); + next.key = Secret::default(); + next.model = "claude-opus-4".to_string(); + let saved = save(&path, &next).expect("再存一次(密钥留空)"); + assert_eq!(saved.model, "claude-opus-4"); + assert_eq!( + saved.key_chars, + "sk-ant-secret".chars().count(), + "密钥输入框留空时必须保留旧密钥" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +#[test] +fn schema_rejects_and_nothing_is_written() { + let path = temp_config("reject"); + save(&path, &draft()).expect("先存一份合法的"); + let before = std::fs::read_to_string(&path).expect("读文件"); + + // `ai.model` 的上限是 128 字符:越界的值必须被 schema 拒绝,**且文件不动**。 + let mut bad = draft(); + bad.model = "m".repeat(400); + bad.base_url = "https://example.invalid/v1".to_string(); + let error = save(&path, &bad).expect_err("越界模型名必须被拒"); + assert!(!error.is_empty()); + let after = std::fs::read_to_string(&path).expect("读文件"); + assert_eq!(before, after, "被拒绝的保存不该改动文件:{after}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +#[test] +fn external_edit_is_read_back() { + let path = temp_config("external"); + save(&path, &draft()).expect("先存一份"); + // 外部写者(`ull config set ai.model …`)改一个键:我们不缓存,下一次读就是新值。 + std::fs::write( + &path, + r#"{"ai":{"enabled":true,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3.1"}}"#, + ) + .expect("写文件"); + let reloaded = load(&path); + assert_eq!(reloaded.provider, ProviderId::Ollama); + assert_eq!(reloaded.model, "llama3.1"); + assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} diff --git a/crates/app-iced/src/pages/settings/ai/tests.rs b/crates/app-iced/src/pages/settings/ai/tests.rs new file mode 100644 index 0000000..9c811b1 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/tests.rs @@ -0,0 +1,331 @@ +//! 「助手」分组状态机的用例(M174)。 +//! +//! | 用例 | 守什么 | 变异(改成什么会红) | +//! |---|---|---| +//! | `provider_vocabulary_is_the_schema_vocabulary` | 四个取值 == `core::setting::keys::AI_PROVIDERS` | 词表里写 `open_ai_compatible` | +//! | `secret_debug_is_masked` | 密钥不进 `{:?}` | 把 `Secret` 改成 `String` | +//! | `validate_refuses_bad_endpoint_and_empty_model` | 空/非法值不保存 | 删掉 `starts_with("http")` 检查 | +//! | `missing_names_exactly_one_field` | 「未配置」说清缺哪一项 | 所有缺项都返回 `BaseUrl` | +//! | `can_test_is_false_until_configured` | 未配置时按钮禁用 | `can_test` 恒 `true` | +//! | `dirty_draft_is_not_overwritten` | 外部改动不冲掉用户输入 | `hydrate` 先无条件赋值 | +//! | `saving_a_new_target_drops_the_old_pass` | 端点/模型变了,旧「已通过」作废 | `keep_probe_if_targeted` 删掉 | +//! | `not_configured_outcome_carries_no_target` | 「没发请求」的结论里没有目标/详情 | 把 `ProbeOutcome::NotConfigured` 当失败类 | + +use unlucky_core::setting::keys::AI_PROVIDERS; +use unlucky_i18n::{Catalog, Lang}; + +use super::outcome::{MissingField, ProbeKind, ProbeResult}; +use super::state::{Probe, ProviderId, Refusal, Secret, Snapshot, State, Status}; + +fn configured() -> State { + State { + enabled: true, + provider: ProviderId::OpenAiCompatible, + base_url: "https://api.deepseek.com/v1".to_string(), + model: "deepseek-chat".to_string(), + key: Secret::new("sk-test"), + key_chars: 0, + dirty: false, + storage: true, + ..State::default() + } +} + +#[test] +fn provider_vocabulary_is_the_schema_vocabulary() { + let ours: Vec<&str> = ProviderId::ALL.iter().map(|id| id.setting()).collect(); + assert_eq!( + ours, AI_PROVIDERS, + "四个取值必须与 schema 的允许值表逐条相等" + ); + for id in ProviderId::ALL { + assert_eq!(ProviderId::from_setting(id.setting()), id); + } + // 未知值回落 OpenAI 兼容(与 schema 默认值同一档),不是 panic 也不是禁用。 + assert_eq!( + ProviderId::from_setting("open_ai_compatible"), + ProviderId::OpenAiCompatible + ); +} + +#[test] +fn secret_debug_is_masked() { + let secret = Secret::new("sk-live-LEAK-MARKER"); + let printed = format!("{secret:?}"); + assert!(!printed.contains("sk-live"), "Debug 漏了密钥:{printed}"); + assert!(printed.contains("***"), "应当显示掩码:{printed}"); + // 空草稿的 Debug 也要能分辨(否则「留空 = 保留旧密钥」这条规则在日志里看不出)。 + assert!(format!("{:?}", Secret::default()).contains("空")); + assert!(Secret::new(" ").is_empty()); +} + +#[test] +fn validate_refuses_bad_endpoint_and_empty_model() { + let mut state = configured(); + assert_eq!(state.validate(), Ok(())); + + state.base_url = "api.deepseek.com/v1".to_string(); + assert_eq!( + state.validate(), + Err(Refusal::BaseUrl), + "没有协议头不算端点" + ); + + state.base_url = String::new(); + assert_eq!( + state.validate(), + Err(Refusal::BaseUrl), + "OpenAI 兼容形态没有默认端点,空值必须拒绝(不保存)" + ); + + state.base_url = "https://api.deepseek.com/v1".to_string(); + state.model = " ".to_string(); + assert_eq!(state.validate(), Err(Refusal::Model)); + + // Ollama 有默认端点:空 base_url 是合法的(生效值回落默认)。 + state.provider = ProviderId::Ollama; + state.base_url = String::new(); + state.model = "llama3".to_string(); + assert_eq!(state.validate(), Ok(())); + assert_eq!(state.resolved_base_url(), "http://127.0.0.1:11434"); + + // 禁用档:什么都不填也能保存(那正是「关掉助手」)。 + state.provider = ProviderId::Disabled; + state.model = String::new(); + assert_eq!(state.validate(), Ok(())); +} + +#[test] +fn missing_names_exactly_one_field() { + let mut state = configured(); + assert_eq!(state.missing(), None); + + state.enabled = false; + assert_eq!(state.missing(), Some(MissingField::Disabled)); + + let mut state = configured(); + state.base_url = String::new(); + assert_eq!(state.missing(), Some(MissingField::BaseUrl)); + + let mut state = configured(); + state.model = String::new(); + assert_eq!(state.missing(), Some(MissingField::Model)); + + // Anthropic 必须带密钥:草稿空 + 已存 0 字符 = 缺密钥。 + let mut state = configured(); + state.provider = ProviderId::Anthropic; + state.base_url = String::new(); + state.model = "claude-sonnet-4".to_string(); + state.key = Secret::default(); + state.key_chars = 0; + assert_eq!(state.missing(), Some(MissingField::ApiKey)); + // 已存过密钥(只有长度,没有值)就算配置齐了。 + state.key_chars = 40; + assert_eq!(state.missing(), None); +} + +#[test] +fn can_test_is_false_until_configured() { + let mut state = configured(); + assert!(state.can_test()); + + state.model = String::new(); + assert!(!state.can_test(), "缺模型名时「测试连接」必须禁用"); + + let mut state = configured(); + state.storage = false; + assert!(!state.can_test(), "没有设置文件时也禁用"); + + let mut state = configured(); + state.probe = Probe::Running; + assert!(!state.can_test(), "正在测的时候不许再点一次"); +} + +#[test] +fn dirty_draft_is_not_overwritten() { + let mut state = configured(); + state.base_url = "https://typed.example/v1".to_string(); + state.dirty = true; + let changed = state.hydrate(Snapshot { + enabled: true, + provider: ProviderId::Ollama, + base_url: "http://127.0.0.1:11434".to_string(), + model: "llama3".to_string(), + key_chars: 12, + }); + assert!(!changed); + assert_eq!( + state.base_url, "https://typed.example/v1", + "脏草稿不许被冲掉" + ); + assert_eq!(state.provider, ProviderId::OpenAiCompatible); + // 密钥长度不属于草稿:外部改了密钥,长度照样要更新(界面上的「已存 N 字符」不能骗人)。 + assert_eq!(state.key_chars, 12); + + // 不脏的时候照常灌入。 + let mut state = State::default(); + let changed = state.hydrate(Snapshot { + enabled: true, + provider: ProviderId::Ollama, + base_url: "http://127.0.0.1:11434".to_string(), + model: "llama3".to_string(), + key_chars: 0, + }); + assert!(changed); + assert_eq!(state.provider, ProviderId::Ollama); + assert!(state.storage, "灌过真值之后设置文件就是可用的"); +} + +#[test] +fn saving_a_new_target_drops_the_old_pass() { + let mut state = configured(); + state.probe = Probe::Done(ProbeResult { + kind: ProbeKind::Passed, + missing: None, + base_url: state.resolved_base_url(), + model: state.model.clone(), + elapsed_ms: 120, + detail: String::new(), + }); + assert_eq!(state.status(), Status::Verified); + + // 同一个目标重复灌入:结论仍然有效。 + let snapshot = Snapshot { + enabled: true, + provider: state.provider, + base_url: state.base_url.clone(), + model: state.model.clone(), + key_chars: 0, + }; + state.hydrate(snapshot); + assert_eq!( + state.status(), + Status::Verified, + "目标没变,别无故作废旧结论" + ); + + // 换了端点:旧结论不再关于现在这套配置。 + state.dirty = false; + state.hydrate(Snapshot { + enabled: true, + provider: ProviderId::OpenAiCompatible, + base_url: "https://other.example/v1".to_string(), + model: "deepseek-chat".to_string(), + key_chars: 0, + }); + assert_eq!(state.probe, Probe::Idle, "目标变了,旧「已通过」必须作废"); + assert_eq!(state.status(), Status::Configured); +} + +#[test] +fn not_configured_outcome_carries_no_target() { + use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome}; + + let result = ProbeResult::from_outcome( + ProbeOutcome::NotConfigured { + missing: Missing::Model, + }, + 0, + ); + assert_eq!(result.kind, ProbeKind::NotConfigured); + assert_eq!(result.missing, Some(MissingField::Model)); + assert!(result.base_url.is_empty(), "没发请求就没有目标地址"); + assert!(result.model.is_empty()); + assert!(!result.sent_a_request()); + + // 四类失败各自翻译过来,且都不带目标(它们没有「成功的目标」可说)。 + for (outcome, expected) in [ + ( + ProbeOutcome::Auth { + detail: "401".to_string(), + }, + ProbeKind::Auth, + ), + ( + ProbeOutcome::ModelNotFound { + detail: "404".to_string(), + }, + ProbeKind::ModelNotFound, + ), + ( + ProbeOutcome::Unreachable { + detail: "refused".to_string(), + }, + ProbeKind::Unreachable, + ), + ( + ProbeOutcome::Timeout { + detail: "8 s".to_string(), + }, + ProbeKind::Timeout, + ), + ( + ProbeOutcome::Failed { + detail: "?".to_string(), + }, + ProbeKind::Failed, + ), + ] { + let result = ProbeResult::from_outcome(outcome, 5); + assert_eq!(result.kind, expected); + assert!(result.sent_a_request()); + } +} + +#[test] +fn screenshot_seed_covers_the_state_matrix() { + // 未配置 / 已配置 / 已通过 / 失败四档都要能出图(D30 的矩阵)。 + let unconfigured = State::screenshot_seed("unconfigured").expect("种子"); + assert_eq!(unconfigured.status(), Status::Unconfigured); + assert!(unconfigured.storage, "截图里也要如实显示「有设置文件」"); + + let configured = State::screenshot_seed("configured").expect("种子"); + assert_eq!(configured.status(), Status::Configured); + assert_eq!(configured.model, "deepseek-chat"); + + let verified = State::screenshot_seed("verified").expect("种子"); + assert_eq!(verified.status(), Status::Verified); + + let failed = State::screenshot_seed("failed-auth").expect("种子"); + assert_eq!(failed.status(), Status::Failed); + assert!( + State::screenshot_seed("nonsense").is_none(), + "未知种子不伪造状态" + ); +} + +#[test] +fn every_key_used_here_exists_in_the_catalog() { + // 中英各一遍,逐条取词:目录里没这条 key 就编译不过(`Key` 是枚举), + // 但**译文为空**这件事只有这里抓得住。 + let mut keys: Vec = ProviderId::ALL.iter().map(|id| id.label()).collect(); + keys.extend([ + MissingField::Disabled.label(), + MissingField::Provider.label(), + MissingField::BaseUrl.label(), + MissingField::Model.label(), + MissingField::ApiKey.label(), + Refusal::BaseUrl.label(), + Refusal::Model.label(), + Status::Unconfigured.label(), + Status::Configured.label(), + Status::Verified.label(), + Status::Failed.label(), + ProbeKind::Passed.label(), + ProbeKind::Auth.label(), + ProbeKind::ModelNotFound.label(), + ProbeKind::Unreachable.label(), + ProbeKind::Timeout.label(), + ProbeKind::Failed.label(), + ProbeKind::NotConfigured.label(), + ]); + for lang in [Lang::ZhCn, Lang::En] { + let catalog = Catalog::of(lang); + for key in &keys { + assert!( + !catalog.text(*key).trim().is_empty(), + "{key:?} 在 {lang:?} 下没有译文" + ); + } + } +} diff --git a/crates/app-iced/src/pages/settings/ai/view.rs b/crates/app-iced/src/pages/settings/ai/view.rs new file mode 100644 index 0000000..7dd0196 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/view.rs @@ -0,0 +1,356 @@ +//! 「助手」分组的一张卡片(M174)。 +//! +//! ## 版面(D30 的状态矩阵 + 数值) +//! +//! | 行 | 内容 | 数值 | +//! |---|---|---| +//! | 状态 | 未配置 / 已配置 / 连接已通过 / 测试失败 | 生效端点 + 模型名 | +//! | 开关 | 启用助手(`ai.enabled`) | — | +//! | 提供商 | 四态 filter chip(`ai.provider`) | 存的值 | +//! | 端点 | `base_url` 文本框 | 生效值(空 = 该形态默认) | +//! | 模型 | `ai.model` 文本框 | — | +//! | 密钥 | 密码框(默认掩码)+「显示」 | **只显示字符数**,不显示值 | +//! | 动作 | 保存 / 测试连接 | 测试结论带耗时(毫秒) | +//! +//! 「未配置」时「测试连接」是**禁用**的,并在下面直接说清缺哪一项——不是灰着什么都不说。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key, Lang}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::inputs::{text_field, Field, FieldVariant}; +use crate::app::components::selection::{chip, switch, Chip, Switch}; +use crate::pages::frame::{card, divider, text_role_owned}; +use crate::pages::settings::ai::outcome::ProbeKind; +use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, State, Status}; +use crate::pages::settings::{ai_about, GroupMessage, States}; +use crate::pages::Message; +use crate::theme::Ui; + +/// 一条本组消息 → 页面消息(左列/正文都用这一处,别在下面各写一遍)。 +fn msg(message: ai_about::Message) -> Message { + Message::Settings(GroupMessage::AiAbout(message)) +} + +/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来,见 `settings/mod.rs`)。 +pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> { + body_of(ui, &states.groups.ai_about, lang) +} + +fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> { + let catalog = lang.catalog(); + let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill); + + stacked = stacked.push(status_row(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(enable_row(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(provider_row(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(endpoint_field(ui, catalog, state)); + stacked = stacked.push(model_field(ui, catalog, state)); + stacked = stacked.push(key_field(ui, catalog, state)); + stacked = stacked.push(divider(ui)); + stacked = stacked.push(actions_row(ui, catalog, state)); + stacked = stacked.push(hint_line(ui, catalog, state)); + + if let Some(notice) = &state.notice { + stacked = stacked.push(notice_line(ui, catalog, notice)); + } + if let Probe::Done(result) = &state.probe { + stacked = stacked.push(probe_line(ui, catalog, result.kind)); + stacked = stacked.push(text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render( + Key::SettingsAiTestTarget, + &[ + ("base_url", result.base_url.as_str()), + ("model", result.model.as_str()), + ("ms", result.elapsed_ms.to_string().as_str()), + ], + ), + ui.color(Role::OnSurfaceVariant), + )); + if !result.detail.trim().is_empty() { + stacked = stacked.push(text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render( + Key::SettingsAiTestDetail, + &[("detail", result.detail.as_str())], + ), + ui.color(Role::OnSurfaceVariant), + )); + } + } + + card(ui, stacked) +} + +/// 状态行:四档文案 + 生效端点 / 模型(数值直接上屏,不藏在提示里)。 +fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { + let status = state.status(); + let color = match status { + Status::Failed => ui.color(Role::Error), + Status::Verified => ui.color(Role::Primary), + _ => ui.color(Role::OnSurfaceVariant), + }; + let target = if state.model.trim().is_empty() { + state.resolved_base_url() + } else { + format!("{} · {}", state.resolved_base_url(), state.model.trim()) + }; + let line = catalog.render( + Key::SettingsAiStatusLabel, + &[("status", catalog.text(status.label()))], + ); + row![ + container(text_role_owned(ui, TypeRole::BodyMedium, true, line, color)).width(Length::Fill), + text_role_owned(ui, TypeRole::BodySmall, false, target, color), + ] + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +fn enable_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { + column![ + switch( + ui, + Switch::new( + catalog.text(Key::SettingsAiEnable), + state.enabled, + msg(ai_about::Message::ToggleEnabled(!state.enabled)), + ), + ), + text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::SettingsAiEnableHint).to_string(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +fn provider_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { + // `.wrap()`:四颗 chip 在 424 宽的正文列里放不下一行时**整颗换行**,而不是把最后一颗 + // 压成竖排(「已禁 / 用」那种断字)。`Wrapping` 只提供 `push`,所以先建后 wrap。 + let mut chips = row![] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center); + for provider in ProviderId::ALL { + chips = chips.push(chip( + ui, + Chip::filter( + catalog.text(provider.label()), + state.provider == provider, + msg(ai_about::Message::PickProvider(provider)), + ), + )); + } + row![ + text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::SettingsAiProviderLabel).to_string(), + ui.color(Role::OnSurfaceVariant), + ), + chips.wrap(), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center) + .into() +} + +fn endpoint_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> { + let field = Field::new(&state.base_url) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::SettingsAiBaseUrl)) + .placeholder(catalog.text(Key::SettingsAiBaseUrlPlaceholder)) + .supporting(catalog.text(Key::SettingsAiBaseUrlHint)); + // 「生效:…」只在**回落默认值**(草稿为空、该形态有默认端点)时才值得说一句: + // 草稿就是生效值时它是废话,空着且没有默认值时它是空串(比不说更糟)。 + let effective = state.resolved_base_url(); + let mut block = column![text_field(ui, field, |value| msg( + ai_about::Message::EditBaseUrl(value) + ))] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + if !effective.is_empty() && effective != state.base_url.trim() { + block = block.push(text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render(Key::SettingsAiEffective, &[("value", effective.as_str())]), + ui.color(Role::OnSurfaceVariant), + )); + } + block.into() +} + +fn model_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> { + let field = Field::new(&state.model) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::SettingsAiModel)) + .placeholder(catalog.text(Key::SettingsAiModelPlaceholder)); + text_field(ui, field, |value| msg(ai_about::Message::EditModel(value))) +} + +/// 密钥行:密码框 + 「显示 / 隐藏」chip + 已存状态(**只有字符数**)。 +fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> { + let chars = state.key_chars.to_string(); + let supporting = if state.key_chars > 0 { + catalog.render( + Key::SettingsAiApiKeyConfigured, + &[("chars", chars.as_str())], + ) + } else { + catalog.text(Key::SettingsAiApiKeyMissing).to_string() + }; + let field = Field::new(state.key.expose()) + .variant(FieldVariant::Outlined) + .label(catalog.text(Key::SettingsAiApiKey)) + .placeholder(catalog.text(Key::SettingsAiApiKeyPlaceholder)) + .secure(!state.reveal); + let toggle_label = if state.reveal { + catalog.text(Key::SettingsAiHide) + } else { + catalog.text(Key::SettingsAiShow) + }; + let toggle = chip( + ui, + Chip::filter( + toggle_label, + state.reveal, + msg(ai_about::Message::Reveal(!state.reveal)), + ), + ); + let field = container(text_field(ui, field, |value| { + msg(ai_about::Message::EditKey(value)) + })) + .width(Length::Fill); + column![ + row![field, toggle] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .align_y(Alignment::Center), + // 「已存 N 字符 / 还没有密钥」这一行是**格式化出来的**(`Field::supporting` 借的是 + // `&'a str`,临时 String 借不进去),所以单独一行;它只讲长度,不讲值。 + text_role_owned( + ui, + TypeRole::BodySmall, + false, + supporting, + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .into() +} + +fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { + let save_label = catalog.text(Key::SettingsAiSave); + let save = if state.dirty && state.storage { + buttons::filled(ui, Button::new(save_label, msg(ai_about::Message::Save))) + } else { + buttons::filled(ui, Button::disabled(save_label)) + }; + + let test_label = if matches!(state.probe, Probe::Running) { + catalog.text(Key::SettingsAiTesting) + } else { + catalog.text(Key::SettingsAiTest) + }; + let test = if state.can_test() { + buttons::tonal(ui, Button::new(test_label, msg(ai_about::Message::Test))) + } else { + buttons::tonal(ui, Button::disabled(test_label)) + }; + + row![save, test] + .spacing(ui.space(Space::X3)) + .align_y(Alignment::Center) + .into() +} + +/// 按钮为什么是灰的 / 下一步该做什么——**永远说清**,不留「点了没反应」。 +fn hint_line<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> { + let (text, error) = if !state.storage { + (catalog.text(Key::SettingsAiNoStorage).to_string(), true) + } else if let Some(missing) = state.missing() { + ( + catalog.render( + Key::SettingsAiTestDisabled, + &[("missing", catalog.text(missing.label()))], + ), + false, + ) + } else if state.dirty { + (catalog.text(Key::SettingsAiUnsaved).to_string(), false) + } else { + (String::new(), false) + }; + text_role_owned( + ui, + TypeRole::BodySmall, + false, + text, + ui.color(if error { + Role::Error + } else { + Role::OnSurfaceVariant + }), + ) +} + +fn notice_line<'a>(ui: &'a Ui, catalog: Catalog, notice: &SaveNotice) -> Element<'a, Message> { + let (text, error) = match notice { + SaveNotice::Saved => (catalog.text(Key::SettingsAiSaved).to_string(), false), + SaveNotice::Refused(reason) => ( + catalog.render( + Key::SettingsAiRefused, + &[("reason", catalog.text(reason.label()))], + ), + true, + ), + SaveNotice::Failed(reason) => ( + catalog.render(Key::SettingsAiRefused, &[("reason", reason.as_str())]), + true, + ), + SaveNotice::NoStorage => (catalog.text(Key::SettingsAiNoStorage).to_string(), true), + }; + text_role_owned( + ui, + TypeRole::BodySmall, + false, + text, + ui.color(if error { Role::Error } else { Role::OnSurface }), + ) +} + +/// 测试结论一行:分类一句话(目标/耗时/详情在它下面三行)。 +fn probe_line<'a>(ui: &'a Ui, catalog: Catalog, kind: ProbeKind) -> Element<'a, Message> { + let error = kind.is_failure(); + text_role_owned( + ui, + TypeRole::BodyMedium, + true, + catalog.text(kind.label()).to_string(), + ui.color(if error { Role::Error } else { Role::Primary }), + ) +} diff --git a/crates/app-iced/src/pages/settings/ai_about.rs b/crates/app-iced/src/pages/settings/ai_about.rs index 6d5aa62..295e6d3 100644 --- a/crates/app-iced/src/pages/settings/ai_about.rs +++ b/crates/app-iced/src/pages/settings/ai_about.rs @@ -1,39 +1,75 @@ -//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。 +//! 设置页「助手(AI)」分组(M174):左列第 6 组的**挂载点**与本线的消息口。 //! -//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance / -//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的 -//! 空模块(不产生任何代码,`-D warnings` 不会红)。 +//! 本文件只留三样东西,正文/状态/读写都在 `pages/settings/ai/` 里: //! -//! ## 接线步骤(ai+about 线照做) +//! | 这里 | 内容 | +//! |---|---| +//! | [`GROUP`] | 左列名 / 图标 / 演示行(产品路径走 [`super::ai::view::body`]) | +//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组要 i18n 文案与 `Lang` 形参,`Group.body` 的签名里没有它) | +//! | [`Message`] | 本线的消息口(落点 `app/update/settings/ai_about.rs`) | +//! | [`State`] | 转发 `ai::State`(`Groups.ai_about` 的载荷类型不变,`settings/state.rs` 一行不用改) | //! -//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`; -//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`; -//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。 +//! ## 为什么 `body` 是 `None` 而不是 `Some(..)` //! -//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。 +//! `Group.body` 的签名是 `fn(&Ui, States) -> Option`:拿不到 `Lang`,也就画不出中英两套 +//! 文案(本组的硬要求是「不得硬编码中文」)。M106 的语言行与 M159 的更新组是同一个理由走了 +//! 按下标分发;本组沿用同一条路,代价是 `settings/mod.rs` 里多一行 `else if`。 //! -//! ## M101 起本文件还装本线的状态与消息口 +//! ## 密钥纪律 //! -//! - 状态:[`State`](`Groups.ai_about` 的载荷,加字段只改本文件); -//! - 消息:[`Message`](落点 `app/update/settings/ai_about.rs`)。 -//! -//! 上第 5 组时**额外**要动的共享文件(按上面的步骤 2 + 测试装配): -//! - `settings/mod.rs` 的 `GROUPS`(加一行,长度 +1); -//! - `settings/tests/mod.rs` 顶部加一行 `mod ai_section;`(新测试文件不挂 `mod` 就不编译), -//! 并且左列顺序那条字面量断言会红——那是刻意的(页面上多一组就是观感变了,要人确认)。 -//! -//! 注意 i18n 那边是按**分组**命名的,文案文件叫 `ai.rs` 而不是 `ai_about.rs`。 +//! [`Message::EditKey`] 的载荷是用户刚敲的明文(输入框必须有它),它**只**活在这个进程里: +//! 落盘走 `ai::store::save`(不经 `AppCommand::SetSetting` 那条可序列化通道), +//! 上屏默认掩码(`State::reveal` 才明文),`Debug` 由 `ai::state::Secret` 掩码。 -/// AI / About 分组的运行期状态(M101 r2:**只归 ai+about 线**)。 -/// -/// 加字段只改本文件:`Groups` 只认 `Groups.ai_about` 这一个字段。 -#[derive(Debug, Default)] -pub struct State {} +pub use super::ai::State; -/// AI / About 线的消息(**只归本线**)。 -/// -/// 首版是空枚举:这一条线的分组还没上页面。空枚举的好处是 -/// `app/update/settings/ai_about.rs` 里的 `match message {}` 天然穷尽——加了变体不接线就报 -/// E0004,不会静默。 +use unlucky_design::icons::IconKind; + +use super::Group; + +/// 本组在 [`super::GROUPS`] 里的下标(**必须是最后一个**:加组只追加不重排)。 +pub(crate) const AI_GROUP: usize = 5; + +/// 「助手」分组(左列名固定英文:左列是导航,与导航轨同源,不进 catalog)。 +pub(super) const GROUP: Group = Group { + name: "Assistant", + icon: IconKind::Ai, + // 只给 harness / 窄窗「按组堆叠」路径用的演示行:产品路径走 `ai::view::body`。 + rows: &[ + ("Provider", "OpenAI compatible"), + ("Endpoint", "https://api.deepseek.com/v1"), + ("Model", "deepseek-chat"), + ], + body: None, +}; + +/// 本线的消息(**只归本线**):加变体只改本文件 + `app/update/settings/ai_about.rs`。 #[derive(Debug, Clone)] -pub enum Message {} +pub enum Message { + /// 启用开关(`ai.enabled`) + ToggleEnabled(bool), + /// 选提供商(`ai.provider`) + PickProvider(super::ai::ProviderId), + /// 端点草稿(`ai.base_url`) + EditBaseUrl(String), + /// 模型名草稿(`ai.model`) + EditModel(String), + /// 新密钥草稿(明文只在内存里;空 = 保留已存的那把) + EditKey(String), + /// 密钥输入框明文/掩码 + Reveal(bool), + /// 保存(校验 + 落盘) + Save, + /// 测试连接(异步;结果走 [`Message::ProbeDone`]) + Test, + /// 测试连接的结果(分类 + 数值 + 脱敏详情) + ProbeDone(Box), +} + +impl Message { + /// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::AiAbout`)。 + #[must_use] + pub fn into_group(self) -> super::GroupMessage { + super::GroupMessage::AiAbout(self) + } +} diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index f578ad6..546db1b 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -28,7 +28,8 @@ //! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) | //! | Java | [`java`] | java 线 | //! | Downloads | [`download`] | download 线 | -//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 | +//! | Updates | [`update`] | update 线(M159,按下标分发) | +//! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) | //! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) | //! //! ## 测试也按 section 分(M100) @@ -93,9 +94,11 @@ //! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key` //! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改 //! `catalog/settings/mod.rs`(五个域已全部接进装配链)。 -//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。 -//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;` -//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。 +//! 2. 本文件的 [`GROUPS`] —— 加一整组时加一行(M174 的助手组就是这么来的:`GROUPS` 与 +//! [`GROUP_LABELS`] 各加一行、长度 +1,`tests/mod.rs` 的「左列顺序」随之更新)。 +//! 3. `tests/mod.rs` —— 「左列顺序」那条字面量断言会连带红,需要人确认(M174 就是把它加了一个 +//! `"Assistant"`)。加分组自己的测试文件时还要在 `tests/mod.rs` 顶部挂一行 `mod <名>;`; +//! M174 的用例走的是组内路径(`pages/settings/ai/tests.rs`),所以没有新增挂载行。 //! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到 //! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置 //! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测): @@ -132,6 +135,7 @@ // 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子 // (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。 // 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。 +pub(crate) mod ai; pub(crate) mod ai_about; mod appearance; pub(crate) mod download; @@ -215,7 +219,7 @@ struct Group { /// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。 /// /// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。 -const GROUPS: [Group; 5] = [ +const GROUPS: [Group; 6] = [ general::GROUP, appearance::GROUP, java::GROUP, @@ -223,6 +227,8 @@ const GROUPS: [Group; 5] = [ // M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标 // 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。 update::GROUP, + // M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。 + ai_about::GROUP, ]; /// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。 @@ -241,13 +247,15 @@ const GENERAL_GROUP: usize = 0; /// /// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过—— /// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。 -pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [ +pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 6] = [ unlucky_i18n::Key::SettingsGroupGeneral, unlucky_i18n::Key::SettingsGroupAppearance, unlucky_i18n::Key::SettingsGroupJava, unlucky_i18n::Key::SettingsGroupDownloads, // M159 unlucky_i18n::Key::SettingsGroupUpdate, + // M174 + unlucky_i18n::Key::SettingsGroupAssistant, ]; /// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。 @@ -256,6 +264,13 @@ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [ /// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。 const UPDATE_GROUP: usize = update::UPDATE_GROUP; +/// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。 +/// +/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:本组要画中英两套文案(`Lang`)与 +/// 「测试连接」的结论,`Group.body` 的签名里没有 `Lang`,所以由 `view` 的 `language` 形参一路 +/// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。 +const AI_GROUP: usize = ai_about::AI_GROUP; + /// 整页。 /// /// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态 @@ -321,6 +336,9 @@ fn form<'a>( } else if index == UPDATE_GROUP { // M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。 update::body(ui, update, language.effective) + } else if index == AI_GROUP { + // M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。 + ai::view::body(ui, states, language.effective) } else { group .body diff --git a/crates/app-iced/src/pages/settings/tests/mod.rs b/crates/app-iced/src/pages/settings/tests/mod.rs index a3ced62..2e1f4e3 100644 --- a/crates/app-iced/src/pages/settings/tests/mod.rs +++ b/crates/app-iced/src/pages/settings/tests/mod.rs @@ -132,7 +132,15 @@ fn the_split_kept_the_left_column_order() { let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect(); assert_eq!( names, - vec!["General", "Appearance", "Java", "Downloads", "Updates"] + vec![ + "General", + "Appearance", + "Java", + "Downloads", + "Updates", + // M174:助手(AI)配置只追加在末尾 + "Assistant", + ] ); } @@ -198,6 +206,10 @@ fn the_settings_module_does_not_name_any_group_state() { fn every_line_can_be_wired_alone() { // 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事—— // ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。 - assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组"); + assert_eq!( + GROUPS.len(), + 6, + "四条填页线 + M159 的更新组 + M174 的助手组" + ); assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内"); } diff --git a/crates/app-iced/src/pages/settings/tests/update_section.rs b/crates/app-iced/src/pages/settings/tests/update_section.rs index 5cc288a..ae21df8 100644 --- a/crates/app-iced/src/pages/settings/tests/update_section.rs +++ b/crates/app-iced/src/pages/settings/tests/update_section.rs @@ -11,11 +11,20 @@ use super::super::{GROUPS, SELECTED}; use crate::pages::update::UpdateStatus; use crate::pages::{Screen, Update}; -/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` -/// 两个下标一起失效。 +/// 更新组**下标固定为 4**:加组只追加不重排,插到它前面会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` +/// 两个下标一起失效,也会把更新组自己的 `UPDATE_GROUP` 指到别人身上。 +/// +/// M174 之后它**不再是最后一组**(助手组追加在它后面):这条断言因此从「len == 下标 + 1」 +/// 改成「下标 4 上就是更新组,且它是**倒数第二**组」——追加语义仍然成立,而「更新组被挤走」 +/// 照样会红。 #[test] fn the_update_group_sits_at_the_end_of_the_registry() { - assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组"); + assert_eq!(UPDATE_GROUP, 4, "更新组的下标不许变(加组只追加)"); + assert_eq!( + GROUPS.len(), + UPDATE_GROUP + 2, + "更新组之后只该有 M174 的助手组" + ); assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name); assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates"); assert!(SELECTED < GROUPS.len()); diff --git a/crates/cli/src/commands/agent/mod.rs b/crates/cli/src/commands/agent/mod.rs index 06088e7..325ed41 100644 --- a/crates/cli/src/commands/agent/mod.rs +++ b/crates/cli/src/commands/agent/mod.rs @@ -9,6 +9,7 @@ //! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。 pub mod ports; +pub mod probe; pub mod serve; use serde_json::{json, Value}; diff --git a/crates/cli/src/commands/agent/probe.rs b/crates/cli/src/commands/agent/probe.rs new file mode 100644 index 0000000..3e045d6 --- /dev/null +++ b/crates/cli/src/commands/agent/probe.rs @@ -0,0 +1,354 @@ +//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现** +//! ([`RigProvider`])发一次最小请求,并把失败分成四类。 +//! +//! ## 为什么分类不在 provider 层 +//! +//! `brain::rig` 把 rig 的错误压成一句 `Display` 文本(`ProviderError::new(error.to_string())`), +//! 那里拿不到结构化的状态码;本模块是**唯一**把「错误文本」翻译成人能看懂的四类的地方, +//! 四类各自有一条真 TCP 假服务器用例钉住(`probe/tests.rs`),谁都不许靠猜。 +//! +//! | 类 | 判据(错误文本里) | 用户看到的 | +//! |---|---|---| +//! | [`ProbeOutcome::Auth`] | `401` / `403` / `unauthorized` / `invalid_api_key` | 密钥不对(或没有权限) | +//! | [`ProbeOutcome::ModelNotFound`] | `404` / `model_not_found` / `does not exist` | 端点通了,但没有这个模型名 | +//! | [`ProbeOutcome::Unreachable`] | 连接被拒 / DNS / TLS / `error sending request` | 端点不可达 | +//! | [`ProbeOutcome::Timeout`] | 超时 | 超时(网络太慢或端点不响应) | +//! +//! ## 三条硬纪律 +//! +//! 1. **配置不完整时一个请求都不发**:[`ProbeOutcome::NotConfigured`] 在造 provider **之前**返回, +//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。 +//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把 +//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。 +//! [`AiSettings`] 的 `Debug` 是手写的(掩码),`ProbeOutcome` 里没有密钥字段。 +//! 3. **读的是文件里现在那一份**:[`probe_file`] 每次现读,所以「设置页保存完再测」测的就是 +//! 刚存下的 `ai.base_url`——不是启动时缓存的旧值。 + +use std::path::Path; +use std::time::Duration; + +use unlucky_agent::brain::provider::{ProviderConfig, ProviderError, ProviderKind}; +use unlucky_agent::brain::rig::RigProvider; +use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory}; +use unlucky_agent::redact::{redact_text, MASK}; +use unlucky_core::setting::keys::launcher_schema; +use unlucky_core::setting::store::SettingStore; + +/// 一次「测试连接」的默认时限。 +/// +/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应(UI 侧有「正在测试」 +/// 态,但那不是让人等半分钟的理由)。 +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8); + +/// 探测请求的模型名之外只发一句话:够触发一次真实补全,又不烧 token。 +const PING: &str = "ping"; + +/// 从设置文件读出来的 AI 四项(快照)。 +/// +/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码——与 +/// `ProviderConfig` 同一条纪律:`{:?}` 是日志/panic 消息的默认出口。 +#[derive(Clone, PartialEq, Eq)] +pub struct AiSettings { + /// `ai.enabled` + pub enabled: bool, + /// `ai.provider`(设置词表:`openai_compatible` / `anthropic` / `ollama` / `disabled`) + pub provider: String, + /// `ai.base_url`(空 = 用该形态的默认端点) + pub base_url: String, + /// `ai.model` + pub model: String, + /// `ai.api_key`(空/缺 = 没配置) + pub api_key: Option, +} + +impl std::fmt::Debug for AiSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AiSettings") + .field("enabled", &self.enabled) + .field("provider", &self.provider) + .field("base_url", &self.base_url) + .field("model", &self.model) + .field("api_key", &self.api_key.as_deref().map(|_| MASK)) + .finish() + } +} + +/// 缺哪一项配置(`NotConfigured` 的载荷:界面上要说清楚「差什么」)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Missing { + /// `ai.enabled=false` 或 `ai.provider=disabled` + Disabled, + /// `ai.provider` 是空/未知值 + Provider, + /// 该形态需要端点,但 `ai.base_url` 是空的 + BaseUrl, + /// `ai.model` 是空的 + Model, + /// 该形态(Anthropic)必须有密钥,但 `ai.api_key` 是空的 + ApiKey, +} + +impl Missing { + /// 稳定名(CLI 输出 / 日志 / 测试断言用;界面走 i18n)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Provider => "provider", + Self::BaseUrl => "base_url", + Self::Model => "model", + Self::ApiKey => "api_key", + } + } +} + +/// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProbeOutcome { + /// 配置不完整:**没有发出任何请求**。 + NotConfigured { + /// 缺的是哪一项。 + missing: Missing, + }, + /// 通了(拿到一次真实补全的回复)。 + Passed { + /// 实际请求的端点(`resolved_base_url`)。 + base_url: String, + /// 实际请求的模型名。 + model: String, + }, + /// 鉴权失败(401 / 403 / `invalid_api_key`)。 + Auth { + /// 脱敏后的原因。 + detail: String, + }, + /// 模型不存在(404 / `model_not_found`)。 + ModelNotFound { + /// 脱敏后的原因。 + detail: String, + }, + /// 端点不可达(连接被拒 / DNS / TLS)。 + Unreachable { + /// 脱敏后的原因。 + detail: String, + }, + /// 超时。 + Timeout { + /// 脱敏后的原因(含时限)。 + detail: String, + }, + /// 其它(协议/解析/未知 HTTP 码):不硬塞进上面四类。 + Failed { + /// 脱敏后的原因。 + detail: String, + }, +} + +impl ProbeOutcome { + /// 是否「通」。 + #[must_use] + pub fn is_pass(&self) -> bool { + matches!(self, Self::Passed { .. }) + } + + /// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` / `not_configured` / `failed`)。 + #[must_use] + pub const fn kind(&self) -> &'static str { + match self { + Self::NotConfigured { .. } => "not_configured", + Self::Passed { .. } => "passed", + Self::Auth { .. } => "auth", + Self::ModelNotFound { .. } => "model_not_found", + Self::Unreachable { .. } => "unreachable", + Self::Timeout { .. } => "timeout", + Self::Failed { .. } => "failed", + } + } +} + +/// 错误文本 → 四类。**顺序有讲究**:先超时(超时文本里可能带 URL 里的 `401` 之类数字), +/// 再鉴权,再模型,最后不可达。 +#[must_use] +pub fn classify(message: &str) -> &'static str { + let text = message.to_lowercase(); + let has = |needle: &str| text.contains(needle); + if has("timed out") || has("timeout") || has("deadline exceeded") { + "timeout" + } else if has("401") + || has("403") + || has("unauthorized") + || has("invalid_api_key") + || has("invalid api key") + || has("authentication") + { + "auth" + } else if has("404") + || has("model_not_found") + || has("model not found") + || has("no such model") + || (has("model") && has("does not exist")) + { + "model_not_found" + } else if has("connection refused") + || has("dns") + || has("failed to connect") + || has("connect error") + || has("error sending request") + || has("network is unreachable") + || has("unreachable") + || has("tls") + || has("certificate") + { + "unreachable" + } else { + "failed" + } +} + +impl AiSettings { + /// 从设置文件读四项(文件缺失/坏掉时给「全默认」= 未配置,不报错)。 + #[must_use] + pub fn load(path: &Path) -> Self { + let store = SettingStore::load(path, launcher_schema()); + let doc = store.doc(); + let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string(); + let api_key = doc + .effective_str("ai.api_key") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + Self { + enabled: doc + .effective("ai.enabled") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true), + provider: text("ai.provider"), + base_url: text("ai.base_url"), + model: text("ai.model"), + api_key, + } + } + + /// 四项 → [`ProviderConfig`];缺项时给出**具体缺哪一项**(不发请求)。 + /// + /// # Errors + /// 禁用、端点/模型/密钥缺失或 `ai.provider` 不是已知取值时返回 [`Missing`]。 + pub fn to_config(&self) -> Result { + if !self.enabled { + return Err(Missing::Disabled); + } + let Some(kind) = ProviderKind::from_setting(&self.provider) else { + return Err(Missing::Provider); + }; + if kind == ProviderKind::Disabled { + return Err(Missing::Disabled); + } + let base_url = self.base_url.trim(); + if base_url.is_empty() && kind.default_base_url().is_empty() { + return Err(Missing::BaseUrl); + } + let model = self.model.trim(); + if model.is_empty() { + return Err(Missing::Model); + } + if kind.requires_api_key() && self.api_key.is_none() { + return Err(Missing::ApiKey); + } + let mut config = match kind { + ProviderKind::OpenAiCompatible => ProviderConfig::openai_compatible(base_url, model), + ProviderKind::Anthropic => ProviderConfig::anthropic(model), + ProviderKind::Ollama => ProviderConfig::ollama(model), + // 上面已经拦掉;留着是因为穷尽匹配比 `_ =>` 值钱(加形态时编译器会提醒)。 + ProviderKind::Disabled => return Err(Missing::Disabled), + }; + if !base_url.is_empty() { + config.base_url = base_url.to_string(); + } + if let Some(key) = self.api_key.as_deref() { + config = config.with_api_key(key); + } + Ok(config) + } + + /// 本次的密钥(脱敏用;空值给 `None`)。 + fn secret(&self) -> Option<&str> { + self.api_key.as_deref().filter(|key| !key.trim().is_empty()) + } +} + +/// 读设置文件并测一次连接(真 provider、真网络、带时限)。 +#[must_use] +pub fn probe_file(path: &Path, timeout: Duration) -> ProbeOutcome { + probe_with(&RigProvider::factory(), &AiSettings::load(path), timeout) +} + +/// 测一次连接(工厂可注入:单测用计数假件证明「未配置时一次都没发」)。 +/// +/// # Panics +/// +/// 在已有 tokio 运行时上下文里调用会 panic(`core::download::blocking::block_on` 的既有约定); +/// 本函数的两个调用方(GUI 的 thread-pool 任务、CLI)都不在运行时里。 +#[must_use] +pub fn probe_with( + factory: &ProviderFactory, + settings: &AiSettings, + timeout: Duration, +) -> ProbeOutcome { + let config = match settings.to_config() { + Ok(config) => config, + Err(missing) => return ProbeOutcome::NotConfigured { missing }, + }; + let base_url = config.resolved_base_url().to_string(); + let model = config.model.clone(); + + let provider = match factory(&config) { + Ok(provider) => provider, + Err(error) => return outcome_from_error(&error, settings), + }; + + let request = ChatRequest { + model: model.clone(), + messages: vec![ChatMessage::user(PING)], + tools: Vec::new(), + temperature: Some(0.0), + max_tokens: Some(16), + }; + + unlucky_core::download::blocking::block_on(async move { + match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await { + Ok(Ok(_reply)) => ProbeOutcome::Passed { base_url, model }, + Ok(Err(error)) => outcome_from_error(&error, settings), + Err(_) => ProbeOutcome::Timeout { + detail: format!("{} s", timeout.as_secs_f32()), + }, + } + }) +} + +/// provider 错误 → 分类结果(`detail` 先脱敏再出门)。 +fn outcome_from_error(error: &ProviderError, settings: &AiSettings) -> ProbeOutcome { + let detail = scrub(&error.to_string(), settings.secret()); + match classify(&error.to_string()) { + "auth" => ProbeOutcome::Auth { detail }, + "model_not_found" => ProbeOutcome::ModelNotFound { detail }, + "unreachable" => ProbeOutcome::Unreachable { detail }, + "timeout" => ProbeOutcome::Timeout { detail }, + _ => ProbeOutcome::Failed { detail }, + } +} + +/// 文本脱敏:先走 agent 的通用脱敏(主目录 / Bearer),再把本次密钥原文换成 [`MASK`]。 +/// +/// 第二道是必须的:`redact_text` 认不出「某个服务端回显的自定义密钥」,只有本模块知道本次的 +/// 密钥原文。长度上限 400:错误体可能是整页 HTML,界面上放不下,日志里也没必要。 +pub fn scrub(text: &str, secret: Option<&str>) -> String { + let mut out = redact_text(text); + if let Some(secret) = secret.filter(|value| !value.trim().is_empty()) { + out = out.replace(secret, MASK); + } + out.chars().take(400).collect() +} + +#[cfg(test)] +#[path = "probe/tests.rs"] +mod tests; diff --git a/crates/cli/src/commands/agent/probe/tests.rs b/crates/cli/src/commands/agent/probe/tests.rs new file mode 100644 index 0000000..6d6f34b --- /dev/null +++ b/crates/cli/src/commands/agent/probe/tests.rs @@ -0,0 +1,215 @@ +//! 「测试连接」的守卫(M174)。四类失败**各有一条真 TCP 假服务器**用例——不是拿字符串假装的。 +//! +//! | 用例 | 守什么 | 变异(改成什么会红) | +//! |---|---|---| +//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `classify` 删掉 `401` 分支 | +//! | `missing_model_is_model_not_found` | 404/model_not_found → 模型不存在 | 把 `model_not_found` 归进 auth | +//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 删 `connection refused` 分支 | +//! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` | +//! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 | +//! | `incomplete_config_never_builds_a_provider` | 未配置 ⇒ 工厂构造计数 **0** | 把校验挪到工厂调用之后 | +//! | `saved_base_url_is_the_address_actually_dialed` | 改完设置再测,请求打到**新**地址 | 用启动时缓存的值 / 写死端点 | +//! | `key_never_leaks_into_outcome_or_debug` | 密钥不进 detail / Debug | 删掉 `scrub` 的 replace | + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use unlucky_agent::brain::rig::RigProvider; + +use super::{probe_with, AiSettings, ProbeOutcome}; + +/// 假服务器的行为。 +enum Behaviour { + /// 立刻回这一份(状态行 + Content-Type + body)。 + Reply(&'static str, &'static str, &'static str), + /// 收下请求但**永远不回**(超时用例)。 + Silent, +} + +/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求头,按 `behaviour` 回。 +struct Server { + port: u16, + hits: Arc>>, +} + +impl Server { + fn start(behaviour: Behaviour) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("绑本地端口"); + let port = listener.local_addr().expect("本地地址").port(); + let hits = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&hits); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + handle(stream, &behaviour, &sink); + } + }); + Self { port, hits } + } + + fn base_url(&self) -> String { + format!("http://127.0.0.1:{}/v1", self.port) + } + + fn hits(&self) -> Vec { + self.hits.lock().expect("锁").clone() + } +} + +fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc>>) { + let mut head = [0_u8; 4096]; + let mut text = String::new(); + // 请求头到 `\r\n\r\n` 为止就够了:本模块只关心方法 + 路径(正文是 `ping`)。 + while !text.contains("\r\n\r\n") { + match stream.read(&mut head) { + Ok(0) => break, + Ok(n) => text.push_str(&String::from_utf8_lossy(&head[..n])), + Err(_) => return, + } + } + sink.lock().expect("锁").push(text.clone()); + + match behaviour { + Behaviour::Silent => { + // 拖住这条连接(客户端会先超时):比客户端时限长得多,收尾时自己断开。 + std::thread::sleep(Duration::from_secs(5)); + } + Behaviour::Reply(status, content_type, body) => { + let response = format!( + "{status}\r\nContent-Type: {content_type}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", + len = body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + } +} + +fn settings(base_url: &str, model: &str) -> AiSettings { + AiSettings { + enabled: true, + provider: "openai_compatible".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + api_key: Some("sk-test-not-a-real-key".to_string()), + } +} + +const AUTH_BODY: &str = r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}"#; +const MISSING_MODEL_BODY: &str = + r#"{"error":{"message":"model_not_found: no such model gpt-nope","code":"model_not_found"}}"#; + +/// 一份最小但合法的 OpenAI 流式回复(rig 走 `model.stream(..)`,要 SSE 不是整包 JSON)。 +const SSE_BODY: &str = concat!( + "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1700000000,\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":1,\"total_tokens\":4}}\n\n", + "data: [DONE]\n\n" +); + +#[test] +fn auth_failure_is_classified_as_auth() { + let server = Server::start(Behaviour::Reply( + "HTTP/1.1 401 Unauthorized", + "application/json", + AUTH_BODY, + )); + let outcome = probe_with( + &RigProvider::factory(), + &settings(&server.base_url(), "gpt-test"), + Duration::from_secs(5), + ); + let ProbeOutcome::Auth { detail } = &outcome else { + panic!("401 必须归到鉴权失败,实际 {outcome:?}"); + }; + assert!(!detail.is_empty()); + assert_eq!(outcome.kind(), "auth"); + // 请求确实发出去了(否则「鉴权失败」是凭空说的)。 + assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过"); +} + +#[test] +fn missing_model_is_model_not_found() { + let server = Server::start(Behaviour::Reply( + "HTTP/1.1 404 Not Found", + "application/json", + MISSING_MODEL_BODY, + )); + let outcome = probe_with( + &RigProvider::factory(), + &settings(&server.base_url(), "gpt-nope"), + Duration::from_secs(5), + ); + assert_eq!( + outcome.kind(), + "model_not_found", + "404/model_not_found 必须归到模型不存在,实际 {outcome:?}" + ); +} + +#[test] +fn closed_port_is_unreachable() { + // 绑一个端口再放掉:这个端口上没人监听 = 连接被拒。 + let port = { + let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口"); + listener.local_addr().expect("地址").port() + }; + let outcome = probe_with( + &RigProvider::factory(), + &settings(&format!("http://127.0.0.1:{port}/v1"), "gpt-test"), + Duration::from_secs(5), + ); + assert_eq!( + outcome.kind(), + "unreachable", + "没人监听的端口必须归到端点不可达,实际 {outcome:?}" + ); +} + +#[test] +fn silent_server_times_out() { + let server = Server::start(Behaviour::Silent); + let started = std::time::Instant::now(); + let outcome = probe_with( + &RigProvider::factory(), + &settings(&server.base_url(), "gpt-test"), + Duration::from_millis(400), + ); + assert_eq!( + outcome.kind(), + "timeout", + "不回应的端点必须归到超时,实际 {outcome:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "超时必须在时限附近收手,实际 {:?}", + started.elapsed() + ); +} + +#[test] +fn success_is_one_real_completion() { + let server = Server::start(Behaviour::Reply( + "HTTP/1.1 200 OK", + "text/event-stream", + SSE_BODY, + )); + let outcome = probe_with( + &RigProvider::factory(), + &settings(&server.base_url(), "gpt-test"), + Duration::from_secs(5), + ); + match &outcome { + ProbeOutcome::Passed { base_url, model } => { + assert_eq!(base_url, &server.base_url()); + assert_eq!(model, "gpt-test"); + } + other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"), + } +} + +// 不发请求那一半的守卫(未配置不建 provider / 改完设置打新地址 / 密钥不外流)在 +// `probe/tests_guards.rs`:本文件只放真 TCP 假服务器那五条与密钥掩码那一条。 +#[path = "tests_guards.rs"] +mod guards; diff --git a/crates/cli/src/commands/agent/probe/tests_guards.rs b/crates/cli/src/commands/agent/probe/tests_guards.rs new file mode 100644 index 0000000..ca08235 --- /dev/null +++ b/crates/cli/src/commands/agent/probe/tests_guards.rs @@ -0,0 +1,275 @@ +//! 「测试连接」的**不发请求**那一半守卫(M174):未配置不建 provider / 改完设置打新地址 / +//! 密钥不外流 / 文件读取。与 `probe/tests.rs` 的分工:那边起真 TCP 假服务器验四类失败, +//! 这边只验「什么时候不发」「发到哪」「发出去的东西里有什么」。 + +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use unlucky_agent::brain::provider::ProviderConfig; +use unlucky_agent::brain::{ + ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderError, ProviderFuture, +}; +use unlucky_agent::redact::MASK; + +use super::{settings, Behaviour, Server, AUTH_BODY}; +use crate::commands::agent::probe::{probe_file, probe_with, AiSettings, Missing, ProbeOutcome}; + +#[test] +fn incomplete_config_never_builds_a_provider() { + struct Counter; + impl ChatProvider for Counter { + fn config(&self) -> &ProviderConfig { + unreachable!("不该被造出来") + } + fn complete<'a>( + &'a self, + _request: &'a ChatRequest, + _observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a> { + unreachable!("不该被造出来") + } + } + + let builds = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&builds); + let factory: unlucky_agent::brain::ProviderFactory = + Arc::new(move |_config: &ProviderConfig| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(Counter) as Arc) + }); + + // 四种「不完整」:禁用 / 缺端点 / 缺模型 / 缺密钥(Anthropic) + let mut cases = vec![ + ( + AiSettings { + enabled: false, + ..settings("http://127.0.0.1:1/v1", "m") + }, + Missing::Disabled, + ), + ( + AiSettings { + base_url: String::new(), + ..settings("", "m") + }, + Missing::BaseUrl, + ), + ( + AiSettings { + model: String::new(), + ..settings("http://127.0.0.1:1/v1", "") + }, + Missing::Model, + ), + ( + AiSettings { + provider: "anthropic".to_string(), + base_url: String::new(), + api_key: None, + ..settings("", "claude-sonnet-4") + }, + Missing::ApiKey, + ), + ( + AiSettings { + provider: "openai-compatible".to_string(), + ..settings("http://127.0.0.1:1/v1", "m") + }, + Missing::Provider, + ), + ]; + // `..::settings(..)` 里那些字段会被逐个覆盖,这里只要求「不全的配置都能被拦下」。 + for (case, expected) in cases.drain(..) { + let outcome = probe_with(&factory, &case, Duration::from_millis(50)); + assert_eq!( + outcome, + ProbeOutcome::NotConfigured { missing: expected }, + "配置不全时必须返回 NotConfigured({expected:?}),实际 {outcome:?}" + ); + } + assert_eq!( + builds.load(Ordering::SeqCst), + 0, + "未配置时一个 provider 都不许造(更不许发请求)" + ); +} + +/// 守卫:设置页保存完再测,请求打到的是**文件里现在那个** `ai.base_url`。 +#[test] +fn saved_base_url_is_the_address_actually_dialed() { + let old = Server::start(Behaviour::Reply( + "HTTP/1.1 401 Unauthorized", + "application/json", + AUTH_BODY, + )); + let new = Server::start(Behaviour::Reply( + "HTTP/1.1 401 Unauthorized", + "application/json", + AUTH_BODY, + )); + let path = temp_config("base-url"); + write_config( + &path, + &format!( + r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#, + old.base_url() + ), + ); + + let first = probe_file(&path, Duration::from_secs(5)); + assert!(!first.is_pass(), "假服务器回 401,不该算通过"); + assert_eq!(old.hits().len(), 1, "第一次必须打到旧地址"); + + // 用户在设置页改了 base_url(同一份 schema、同一个文件)之后再测一次。 + write_config( + &path, + &format!( + r#"{{"ai":{{"enabled":true,"provider":"openai_compatible","base_url":"{}","model":"gpt-test","api_key":"sk-saved"}}}}"#, + new.base_url() + ), + ); + let second = probe_file(&path, Duration::from_secs(5)); + assert!(!second.is_pass()); + assert_eq!( + new.hits().len(), + 1, + "改完 base_url 之后请求必须打到新地址(读的是文件里现在那一份)" + ); + assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次"); + let hit = &new.hits()[0]; + assert!( + hit.contains("POST /v1/chat/completions"), + "OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}", + hit.lines().next().unwrap_or("") + ); +} + +/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。 +/// +/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。 +/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。 +/// +/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。 +#[test] +fn key_never_leaks_into_outcome_or_debug() { + const SECRET: &str = "sk-live-LEAK-MARKER-9f3a"; + // 服务端把 key 原样回显(有些网关会这么干)。 + let body = r#"{"error":{"message":"Incorrect API key provided: sk-live-LEAK-MARKER-9f3a","code":"invalid_api_key"}}"#; + let server = Server::start(Behaviour::Reply( + "HTTP/1.1 401 Unauthorized", + "application/json", + body, + )); + let settings = AiSettings { + api_key: Some(SECRET.to_string()), + ..settings(&server.base_url(), "gpt-test") + }; + + let outcome = probe_with( + &unlucky_agent::brain::rig::RigProvider::factory(), + &settings, + Duration::from_secs(5), + ); + let printed = format!("{outcome:?}"); + assert!( + !printed.contains(SECRET), + "ProbeOutcome 里出现了密钥明文:{printed}" + ); + assert!( + printed.contains(MASK), + "被掩码后应当看得见掩码标记:{printed}" + ); + + let debug = format!("{settings:?}"); + assert!( + !debug.contains(SECRET), + "AiSettings 的 Debug 漏了密钥:{debug}" + ); + assert!( + debug.contains(MASK), + "AiSettings 的 Debug 应当显示掩码:{debug}" + ); +} + +#[test] +fn settings_load_reads_the_five_keys() { + let path = temp_config("load"); + write_config( + &path, + r#"{"ai":{"enabled":false,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3","api_key":"sk-x"}}"#, + ); + let settings = AiSettings::load(&path); + assert!(!settings.enabled); + assert_eq!(settings.provider, "ollama"); + assert_eq!(settings.base_url, "http://127.0.0.1:11434"); + assert_eq!(settings.model, "llama3"); + assert_eq!(settings.api_key.as_deref(), Some("sk-x")); + + // 空的 api_key(null / 空串)读成 `None`,不是 `Some("")`。 + let empty = temp_config("empty-key"); + write_config( + &empty, + r#"{"ai":{"provider":"ollama","model":"llama3","api_key":""}}"#, + ); + assert_eq!(AiSettings::load(&empty).api_key, None); + // 文件不存在也不 panic(`effective_*` 给 schema 默认值 = 未配置,不是坏值)。 + let missing = temp_config("missing"); + let settings = AiSettings::load(&missing); + assert_eq!( + settings.provider, "openai_compatible", + "缺文件时是 schema 默认值" + ); + assert!(settings.model.is_empty()); + assert_eq!(settings.api_key, None); + assert_eq!(settings.to_config(), Err(Missing::BaseUrl)); +} + +// ------------------------------------------------------------------ 夹具 + +fn temp_config(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-probe-{label}-{}-{}", + std::process::id(), + line!() + )); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + dir.join("config/launcher.json") +} + +fn write_config(path: &PathBuf, body: &str) { + std::fs::write(path, body).expect("写配置文件"); +} + +/// 让编译器确认 `ChatReply` / `ProviderError` 的导入是被用的(构造一个不可能的 provider +/// 用到它们),同时钉住「工厂返回错误也要分类」这条支路。 +#[test] +fn factory_error_is_classified_too() { + struct Broken; + impl ChatProvider for Broken { + fn config(&self) -> &ProviderConfig { + unreachable!() + } + fn complete<'a>( + &'a self, + _request: &'a ChatRequest, + _observer: &'a dyn DeltaObserver, + ) -> ProviderFuture<'a> { + Box::pin(async { Ok(ChatReply::default()) }) + } + } + let _ = Broken; + let factory: unlucky_agent::brain::ProviderFactory = + Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key"))); + let outcome = probe_with( + &factory, + &settings("http://127.0.0.1:1/v1", "gpt-test"), + Duration::from_millis(50), + ); + assert_eq!( + outcome.kind(), + "auth", + "工厂阶段的错误也要分类:{outcome:?}" + ); +} diff --git a/crates/i18n/src/catalog/golden/tail.rs b/crates/i18n/src/catalog/golden/tail.rs index 74a851d..a8a89d3 100644 --- a/crates/i18n/src/catalog/golden/tail.rs +++ b/crates/i18n/src/catalog/golden/tail.rs @@ -29,6 +29,59 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsAppearanceCornerSquare", "Square", "直角"), ("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"), ("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"), + // M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。 + // 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、 + // `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与 + // `CoreTextUntranslated` 之间(顺序 = `Key::ALL`)。 + ("SettingsGroupAssistant", "Assistant", "助手"), + ("SettingsAiStatusLabel", "Status: {status}", "状态:{status}"), + ("SettingsAiStatusUnconfigured", "not configured", "未配置"), + ("SettingsAiStatusConfigured", "configured", "已配置"), + ("SettingsAiStatusVerified", "verified", "连接已通过"), + ("SettingsAiStatusTestFailed", "test failed", "测试失败"), + ("SettingsAiEnable", "Enable the assistant", "启用助手"), + ("SettingsAiEnableHint", "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。"), + ("SettingsAiProviderLabel", "Provider", "提供商"), + ("SettingsAiProviderOpenAi", "OpenAI compatible", "OpenAI 兼容"), + ("SettingsAiProviderAnthropic", "Anthropic", "Anthropic 原生"), + ("SettingsAiProviderOllama", "Ollama", "Ollama 原生"), + ("SettingsAiProviderDisabled", "Disabled", "已禁用"), + ("SettingsAiBaseUrl", "Endpoint (base_url)", "端点(base_url)"), + ("SettingsAiBaseUrlPlaceholder", "http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"), + ("SettingsAiBaseUrlHint", "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。"), + ("SettingsAiEffective", "Effective: {value}", "生效:{value}"), + ("SettingsAiModel", "Model name", "模型名称"), + ("SettingsAiModelPlaceholder", "deepseek-chat", "例:deepseek-chat"), + ("SettingsAiApiKey", "API key", "API 密钥"), + ("SettingsAiApiKeyPlaceholder", "Paste the key", "粘贴密钥"), + ("SettingsAiApiKeyConfigured", "A key is stored ({chars} characters). Leave this empty to keep it.", "已保存密钥({chars} 个字符)。留空则保持不变。"), + ("SettingsAiApiKeyMissing", "No key stored yet", "还没有保存密钥"), + ("SettingsAiShow", "Show", "显示"), + ("SettingsAiHide", "Hide", "隐藏"), + ("SettingsAiSave", "Save", "保存"), + ("SettingsAiTest", "Test connection", "测试连接"), + ("SettingsAiTesting", "Testing…", "正在测试…"), + ("SettingsAiSaved", "Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"), + ("SettingsAiRefused", "Not saved: {reason}", "未保存:{reason}"), + ("SettingsAiUnsaved", "There are unsaved changes.", "有未保存的改动。"), + ("SettingsAiNoStorage", "No settings file is available in this run, so nothing can be saved or tested.", "这一次运行没有可用的设置文件,保存与测试都做不了。"), + ("SettingsAiTestDisabled", "Fill in the missing field before testing: {missing}.", "先补上这一项再测试:{missing}。"), + ("SettingsAiMissingDisabled", "the assistant is disabled", "助手处于关闭状态"), + ("SettingsAiMissingBaseUrl", "the endpoint address", "端点地址"), + ("SettingsAiMissingModel", "the model name", "模型名称"), + ("SettingsAiMissingApiKey", "the API key", "API 密钥"), + ("SettingsAiReasonProvider", "the provider value is not recognised", "提供商取值不认识"), + ("SettingsAiReasonBaseUrl", "the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"), + ("SettingsAiReasonModel", "the model name is required", "必须填模型名称"), + ("SettingsAiTestPassed", "Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"), + ("SettingsAiTestAuth", "Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"), + ("SettingsAiTestModel", "The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"), + ("SettingsAiTestUnreachable", "The endpoint is unreachable.", "端点不可达。"), + ("SettingsAiTestTimeout", "Timed out while waiting for the endpoint.", "等端点回应超时了。"), + ("SettingsAiTestOther", "The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"), + ("SettingsAiTestNotConfigured", "Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"), + ("SettingsAiTestTarget", "Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"), + ("SettingsAiTestDetail", "Detail: {detail}", "详情:{detail}"), ("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"), ("DownloadTitle", "Version management", "版本管理"), ("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"), diff --git a/crates/i18n/src/catalog/settings/ai.rs b/crates/i18n/src/catalog/settings/ai.rs index 17f2514..0525fb9 100644 --- a/crates/i18n/src/catalog/settings/ai.rs +++ b/crates/i18n/src/catalog/settings/ai.rs @@ -1,12 +1,95 @@ -//! 设置页「AI / About」分组文案:**预留**(页面上还没有这个分组,见 `pages/settings/mod.rs` 的 -//! 分组注册表)。 +//! 设置页「助手(AI)」分组文案(M174):状态矩阵四档 + 五个输入项 + 测试连接六档。 //! -//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。 -//! 现在没有条目,所以本宏是**空转移器**:只把累积表原样交给下一个域。 +//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在 +//! `settings_download_entries` 之后、`core_text_entries` 之前(设置页五域的位置), +//! 所以金表那几行也要插在 `SettingsAppearanceCornerSuspended` 之后——**顺序即 `Key::ALL`**。 +//! +//! 三条口径: +//! +//! 1. **失败四种各写各的句子**(鉴权 / 模型不存在 / 不可达 / 超时):合成一句「测试失败」等于 +//! 让用户自己猜是 key 错了还是地址错了; +//! 2. 密钥的**值**永远不进文案:只有 `{chars}`(已存字符数)与「未配置」两种说法; +//! 3. 技术名词不翻:`base_url` / `OpenAI` / `Anthropic` / `Ollama` 原样。 macro_rules! settings_ai_entries { ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { - $next! { $($rest,)* { $($acc)* } } + $next! { $($rest,)* { + $($acc)* + // ── 左列分组名(与 NavSettings 同源:左列是导航,标题在正文里) + SettingsGroupAssistant => ("Assistant", "助手"), + // ── 状态矩阵(未配置 / 已配置 / 已通过 / 测试失败) + SettingsAiStatusLabel => ("Status: {status}", "状态:{status}"), + SettingsAiStatusUnconfigured => ("not configured", "未配置"), + SettingsAiStatusConfigured => ("configured", "已配置"), + SettingsAiStatusVerified => ("verified", "连接已通过"), + SettingsAiStatusTestFailed => ("test failed", "测试失败"), + // ── 开关 + SettingsAiEnable => ("Enable the assistant", "启用助手"), + SettingsAiEnableHint => ( + "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", + "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。" + ), + // ── 提供商 + SettingsAiProviderLabel => ("Provider", "提供商"), + SettingsAiProviderOpenAi => ("OpenAI compatible", "OpenAI 兼容"), + SettingsAiProviderAnthropic => ("Anthropic", "Anthropic 原生"), + SettingsAiProviderOllama => ("Ollama", "Ollama 原生"), + SettingsAiProviderDisabled => ("Disabled", "已禁用"), + // ── 端点 / 模型 + SettingsAiBaseUrl => ("Endpoint (base_url)", "端点(base_url)"), + SettingsAiBaseUrlPlaceholder => ("http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"), + SettingsAiBaseUrlHint => ( + "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", + "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。" + ), + SettingsAiEffective => ("Effective: {value}", "生效:{value}"), + SettingsAiModel => ("Model name", "模型名称"), + SettingsAiModelPlaceholder => ("deepseek-chat", "例:deepseek-chat"), + // ── 密钥(只有字符数与掩码,没有值) + SettingsAiApiKey => ("API key", "API 密钥"), + SettingsAiApiKeyPlaceholder => ("Paste the key", "粘贴密钥"), + SettingsAiApiKeyConfigured => ( + "A key is stored ({chars} characters). Leave this empty to keep it.", + "已保存密钥({chars} 个字符)。留空则保持不变。" + ), + SettingsAiApiKeyMissing => ("No key stored yet", "还没有保存密钥"), + SettingsAiShow => ("Show", "显示"), + SettingsAiHide => ("Hide", "隐藏"), + // ── 动作与回执 + SettingsAiSave => ("Save", "保存"), + SettingsAiTest => ("Test connection", "测试连接"), + SettingsAiTesting => ("Testing…", "正在测试…"), + SettingsAiSaved => ("Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"), + SettingsAiRefused => ("Not saved: {reason}", "未保存:{reason}"), + SettingsAiUnsaved => ("There are unsaved changes.", "有未保存的改动。"), + SettingsAiNoStorage => ( + "No settings file is available in this run, so nothing can be saved or tested.", + "这一次运行没有可用的设置文件,保存与测试都做不了。" + ), + SettingsAiTestDisabled => ( + "Fill in the missing field before testing: {missing}.", + "先补上这一项再测试:{missing}。" + ), + // ── 缺项(四种各说各的) + SettingsAiMissingDisabled => ("the assistant is disabled", "助手处于关闭状态"), + SettingsAiMissingBaseUrl => ("the endpoint address", "端点地址"), + SettingsAiMissingModel => ("the model name", "模型名称"), + SettingsAiMissingApiKey => ("the API key", "API 密钥"), + // ── 拒绝保存的原因(本地校验) + SettingsAiReasonProvider => ("the provider value is not recognised", "提供商取值不认识"), + SettingsAiReasonBaseUrl => ("the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"), + SettingsAiReasonModel => ("the model name is required", "必须填模型名称"), + // ── 测试结论(六档) + SettingsAiTestPassed => ("Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"), + SettingsAiTestAuth => ("Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"), + SettingsAiTestModel => ("The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"), + SettingsAiTestUnreachable => ("The endpoint is unreachable.", "端点不可达。"), + SettingsAiTestTimeout => ("Timed out while waiting for the endpoint.", "等端点回应超时了。"), + SettingsAiTestOther => ("The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"), + SettingsAiTestNotConfigured => ("Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"), + SettingsAiTestTarget => ("Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"), + SettingsAiTestDetail => ("Detail: {detail}", "详情:{detail}"), + } } }; } diff --git a/crates/presenter/src/host/settings.rs b/crates/presenter/src/host/settings.rs index acb633e..572c78e 100644 --- a/crates/presenter/src/host/settings.rs +++ b/crates/presenter/src/host/settings.rs @@ -1,14 +1,12 @@ //! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。 //! -//! 三件纪律(AGENTS §6): -//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源); -//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝); -//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。 +//! 三件纪律(AGENTS §6):① 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / +//! AI `set_config` 同源);② 不自己拼 JSON、不绕 schema(非法键/越界值由 `store.set` 拒绝); +//! ③ 这里只认「键名 + 字符串值」,领域语义留在 core。 //! //! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。 use serde_json::Value; -use unlucky_core::game::java::JdkVendor; use unlucky_core::setting::spec::ValueKind; use crate::dto::{SettingGroupView, SettingRowView}; @@ -243,10 +241,22 @@ fn data_group(env: &HostEnv) -> SettingGroupView { )) } -/// AI(D4):开关/端点/模型/YOLO。 +/// AI(D4):开关/端点/模型/密钥状态/YOLO。 +/// **选项表直接取 schema 的 `AI_PROVIDERS`**(M174):原先手写三项、漏了 `disabled`,于是被别的 +/// 写者设成 `disabled` 的机器在这一页会显示成 `openai_compatible`(`selected()` 找不到就回 0)。 +/// **密钥只显示状态不显示值**:`ai.api_key` 是 `locked` 凭据,进 DTO 就等于进事件流/日志。 fn ai_group(env: &HostEnv) -> SettingGroupView { - let providers = ["openai_compatible", "anthropic", "ollama"]; + let providers = unlucky_core::setting::keys::AI_PROVIDERS; let provider = text(env, "ai.provider"); + let chosen = if provider.is_empty() { + "openai_compatible" + } else { + provider.as_str() + }; + let key_state = match text(env, "ai.api_key").trim().chars().count() { + 0 => "未配置(在 iced 设置页填写)".to_string(), + chars => format!("已配置({chars} 个字符)"), + }; SettingGroupView::new("ai", "AI 助手(D4)") .with(SettingRowView::toggle( "ai.enabled", @@ -258,15 +268,8 @@ fn ai_group(env: &HostEnv) -> SettingGroupView { "ai.provider", "模型端点", "OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama", - &providers, - selected( - &providers, - if provider.is_empty() { - "openai_compatible" - } else { - &provider - }, - ), + providers, + selected(providers, chosen), )) .with(SettingRowView::text( "ai.base_url", @@ -280,6 +283,7 @@ fn ai_group(env: &HostEnv) -> SettingGroupView { "如 deepseek-chat", &text(env, "ai.model"), )) + .with(SettingRowView::read_only("API 密钥", &key_state)) .with(SettingRowView::toggle( "ai.yolo", "YOLO 模式", @@ -338,11 +342,8 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> { /// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。 /// -/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):原先是「硬编码 Int/Bool -/// 键名,其余当字符串」,于是 `appearance.window_opacity`(Float)被当 String 送进 `store.set`, -/// schema 的 `as_f64()` 返回 `None` ⇒ `InvalidValue` ⇒ **`save()` 不执行**(重启回旧值 + 弹 toast)。 -/// 现在新增任何 Float/Int/Bool 键都自动正确;集合级守门是 -/// `every_float_key_in_the_schema_can_be_written_back`。 +/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):硬编码会让 Float 键被当 String +/// 送进 `store.set` ⇒ `InvalidValue` ⇒ `save()` 不执行。守门:`every_float_key_in_the_schema_can_be_written_back`。 fn parse_value(key: &str, value: &str) -> Result { let schema = unlucky_core::setting::keys::launcher_schema(); let Some(spec) = schema.spec(key) else { @@ -391,7 +392,6 @@ pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> { .set("java.vendorPriority", Value::Array(Vec::new())) .and_then(|()| store.save()) .map_err(|error| format!("清空发行版优先级失败:{error}"))?; - let _ = JdkVendor::ALL; Ok(()) } From 93d02ab12b5048262d2b117d1cfb06c57f2a0e3b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 16:20:34 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(m174):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=BA=94=E9=A1=B9=20=E2=80=94=E2=80=94=20=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=B8=A6=20target=E3=80=81=E5=AF=86=E9=92=A5=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A9=E7=A0=81=E3=80=81=E8=AF=B7=E6=B1=82=E4=BD=93=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E3=80=81=E6=A1=A5=E5=86=99=E5=89=8D=E9=87=8D=E8=AF=BB?= =?UTF-8?q?=E3=80=81=E7=BB=93=E6=9E=84=E5=8C=96=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体 改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条 「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增 `ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版 手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。 P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。 用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。 P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例 `the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出 一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自 `ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。 P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份 文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例: `presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的 `ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。 P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure` (`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable` 不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例 `factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。 顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` / `rig/tests.rs`(纯搬移)。 --- crates/agent/src/brain/mod.rs | 2 +- crates/agent/src/brain/provider.rs | 158 +++++----------- crates/agent/src/brain/provider/failure.rs | 62 +++++++ crates/agent/src/brain/provider/tests.rs | 107 +++++++++++ crates/agent/src/brain/rig.rs | 143 ++++++--------- crates/agent/src/brain/rig/tests.rs | 82 +++++++++ .../src/app/update/settings/ai_about.rs | 5 +- .../app-iced/src/app/update/settings/tests.rs | 4 +- .../app-iced/src/pages/settings/ai/outcome.rs | 90 ++++++--- .../app-iced/src/pages/settings/ai/state.rs | 27 +-- .../src/pages/settings/ai/store_tests.rs | 36 +++- .../app-iced/src/pages/settings/ai/tests.rs | 79 +++++++- crates/app-iced/src/pages/settings/ai/view.rs | 36 ++-- .../app-iced/src/pages/settings/ai_about.rs | 8 +- crates/cli/src/commands/agent/probe.rs | 171 +++++++++--------- crates/cli/src/commands/agent/probe/tests.rs | 128 ++++++++++--- .../src/commands/agent/probe/tests_guards.rs | 69 ++++--- crates/presenter/src/settings_bridge.rs | 45 +++++ 18 files changed, 840 insertions(+), 412 deletions(-) create mode 100644 crates/agent/src/brain/provider/failure.rs create mode 100644 crates/agent/src/brain/provider/tests.rs create mode 100644 crates/agent/src/brain/rig/tests.rs diff --git a/crates/agent/src/brain/mod.rs b/crates/agent/src/brain/mod.rs index 152fb18..047a7f2 100644 --- a/crates/agent/src/brain/mod.rs +++ b/crates/agent/src/brain/mod.rs @@ -45,7 +45,7 @@ pub use chat::{ NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage, }; pub use exec::{ToolExecOutcome, ToolExecutor}; -pub use provider::{ProviderConfig, ProviderError, ProviderKind}; +pub use provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind}; /// 一轮对话最多几轮工具调用。 pub const DEFAULT_MAX_STEPS: usize = 8; diff --git a/crates/agent/src/brain/provider.rs b/crates/agent/src/brain/provider.rs index 859fee0..62525d6 100644 --- a/crates/agent/src/brain/provider.rs +++ b/crates/agent/src/brain/provider.rs @@ -14,6 +14,11 @@ use std::fmt; use serde::{Deserialize, Serialize}; +#[path = "provider/failure.rs"] +mod failure; + +pub use failure::ProviderFailure; + /// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。 pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1"; /// Ollama 默认端点(research/02 §5.2)。 @@ -203,18 +208,22 @@ impl ProviderConfig { /// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。 pub fn validate(&self) -> Result<(), ProviderError> { if !self.is_enabled() { - return Err(ProviderError::new( + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, "AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)", )); } if self.model.trim().is_empty() { - return Err(ProviderError::new("没有配置模型名(model)")); + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, + "没有配置模型名(model)", + )); } if self.resolved_base_url().trim().is_empty() { - return Err(ProviderError::new(format!( - "{} 需要填写端点(base_url)", - self.kind.display_name() - ))); + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, + format!("{} 需要填写端点(base_url)", self.kind.display_name()), + )); } if self.kind.requires_api_key() && self @@ -222,10 +231,10 @@ impl ProviderConfig { .as_deref() .is_none_or(|key| key.trim().is_empty()) { - return Err(ProviderError::new(format!( - "{} 需要 API key", - self.kind.display_name() - ))); + return Err(ProviderError::classified( + ProviderFailure::NotConfigured, + format!("{} 需要 API key", self.kind.display_name()), + )); } Ok(()) } @@ -268,20 +277,38 @@ impl fmt::Debug for ProviderConfig { } } -/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。 +/// Provider 层错误(消息面向用户 + **结构化分类**:`kind` 由 `brain::rig` 的适配层从 rig 的 +/// `ErrorReport` 翻译,见 [`failure`])。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderError { + kind: ProviderFailure, message: String, } impl ProviderError { - /// 从可读文本构造。 + /// 从可读文本构造(分类是 [`ProviderFailure::Other`]:调用方不知道结构化原因时用它)。 #[must_use] pub fn new(message: impl Into) -> Self { Self { + kind: ProviderFailure::Other, message: message.into(), } } + + /// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。 + #[must_use] + pub fn classified(kind: ProviderFailure, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + /// 失败分类(「测试连接」按它分档)。 + #[must_use] + pub fn kind(&self) -> ProviderFailure { + self.kind + } } impl fmt::Display for ProviderError { @@ -293,108 +320,5 @@ impl fmt::Display for ProviderError { impl std::error::Error for ProviderError {} #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn local_defaults_match_research() { - assert_eq!( - ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(), - LLAMACPP_DEFAULT_BASE_URL - ); - assert_eq!( - ProviderConfig::ollama("llama3").resolved_base_url(), - OLLAMA_DEFAULT_BASE_URL - ); - assert_eq!( - ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(), - ANTHROPIC_DEFAULT_BASE_URL - ); - } - - #[test] - fn setting_vocabulary_round_trips() { - // 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的 - // `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」 - // agent 侧读出来是 None(M174 的根因)。 - for kind in [ - ProviderKind::OpenAiCompatible, - ProviderKind::Anthropic, - ProviderKind::Ollama, - ProviderKind::Disabled, - ] { - assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind)); - } - assert_eq!( - ProviderKind::from_setting(" ollama "), - Some(ProviderKind::Ollama) - ); - assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None); - assert_eq!(ProviderKind::from_setting(""), None); - // schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。 - assert_eq!( - unlucky_core::setting::keys::AI_PROVIDERS, - [ - ProviderKind::OpenAiCompatible.setting_value(), - ProviderKind::Anthropic.setting_value(), - ProviderKind::Ollama.setting_value(), - ProviderKind::Disabled.setting_value(), - ] - ); - } - - #[test] - fn validate_requires_model_base_url_and_key() { - assert!(!ProviderConfig::disabled().is_enabled()); - assert!(ProviderConfig::disabled().validate().is_err()); - assert!(ProviderConfig::llamacpp_local("").validate().is_err()); - assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok()); - // OpenAI 兼容必须显式给端点 - assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini") - .validate() - .is_err()); - assert!( - ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") - .validate() - .is_ok() - ); - assert!(ProviderConfig::anthropic("claude").validate().is_err()); - assert!(ProviderConfig::anthropic("claude") - .with_api_key("sk-ant") - .validate() - .is_ok()); - } - - #[test] - fn redacted_debug_never_leaks_key() { - let config = - ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") - .with_api_key("sk-super-secret"); - let text = config.redacted_debug(); - assert!(!text.contains("sk-super-secret"), "{text}"); - assert!(text.contains(crate::redact::MASK)); - // `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。 - let debug = format!("{config:?}"); - assert!(!debug.contains("sk-super-secret"), "{debug}"); - assert!(debug.contains(crate::redact::MASK), "{debug}"); - } - - #[test] - fn empty_base_url_falls_back_to_default() { - let mut config = ProviderConfig::ollama("llama3"); - config.base_url = String::new(); - assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL); - } - - #[test] - fn four_states_serialize_as_snake_case() { - assert_eq!( - serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(), - "\"open_ai_compatible\"" - ); - assert_eq!( - serde_json::to_string(&ProviderConfig::disabled()).unwrap(), - "{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}" - ); - } -} +#[path = "provider/tests.rs"] +mod tests; diff --git a/crates/agent/src/brain/provider/failure.rs b/crates/agent/src/brain/provider/failure.rs new file mode 100644 index 0000000..7b78a37 --- /dev/null +++ b/crates/agent/src/brain/provider/failure.rs @@ -0,0 +1,62 @@ +//! Provider 失败的**结构化分类**(M174 r1 复审 P2-5):一次「测试连接」失败到底属于哪一类。 +//! +//! ## 为什么不是字符串匹配 +//! +//! r1 的版本对 `ProviderError` 的 `Display` 做子串匹配(找 `"401"` / `"connection refused"` / +//! `"timed out"`),有三个真问题:①URL / 请求 id / 模型名里出现 `401` 这类数字就误判(假阳); +//! ②「连接被拒」的实际文本是 `Http client error: error sending request for url (…)`,不含 +//! `connection refused`,只能靠补一个更宽的 `error sending request` 兜住——**这是在猜文本**; +//! ③文本随 rig / reqwest 版本漂,升级一次就静默错档(复审实测:`closed_port_is_unreachable` +//! 一度偶发红)。 +//! +//! 现在分类由 [`super::rig`] 的适配层从 rig 0.45 的**结构化报告**(`ErrorReport`:`kind` / +//! `http_status` / `code`)翻译出来,本文件只放枚举与「给界面看的稳定名」。本模块不认识 rig。 +//! +//! ## 六档口径 +//! +//! | 档 | 判据(`super::rig::map_error`) | 界面含义 | +//! |---|---|---| +//! | [`ProviderFailure::NotConfigured`] | 配置不完整(`validate()` 拒绝)——**没有发请求** | 先把配置填完 | +//! | [`ProviderFailure::Auth`] | 有 HTTP 状态且是 401 / 403,或 rig 明确报「凭据被拒」 | 密钥/权限不对 | +//! | [`ProviderFailure::ModelNotFound`] | 有 HTTP 状态 404,或响应体的机器码是 `model_not_found` | 端点通了,模型不存在 | +//! | [`ProviderFailure::Unreachable`] | 传输层失败:连接被拒 / DNS / TLS / 响应被截断(**没有拿到任何回应**) | 端点不可达 | +//! | [`ProviderFailure::Timeout`] | 调用方的时限(探测的 8 秒)或 rig 报 `ErrorKind::Timeout` / 传输层 `io::ErrorKind::TimedOut` | 太慢或端点不响应 | +//! | [`ProviderFailure::Other`] | 其余(JSON / URL / 请求构造 / 5xx 之类) | 请求失败(详情见下) | + +/// 一次 provider 调用的失败分类(见模块文档的六档表)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderFailure { + /// 配置不完整:**没有发出任何请求**。 + NotConfigured, + /// 鉴权失败(401 / 403 / 凭据被拒)。 + Auth, + /// 模型不存在(404 / `model_not_found`)。 + ModelNotFound, + /// 端点不可达(传输层失败,没拿到任何回应)。 + Unreachable, + /// 超时(调用方时限或传输层超时)。 + Timeout, + /// 其它(协议 / 解析 / 5xx / 未知)。 + Other, +} + +impl ProviderFailure { + /// 稳定名(日志 / CLI / 测试断言用;界面文案走 i18n)。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::NotConfigured => "not_configured", + Self::Auth => "auth", + Self::ModelNotFound => "model_not_found", + Self::Unreachable => "unreachable", + Self::Timeout => "timeout", + Self::Other => "other", + } + } + + /// 「配置不完整」以外都意味着**请求真的发出去了**(守卫②/③的判据)。 + #[must_use] + pub const fn sent_a_request(self) -> bool { + !matches!(self, Self::NotConfigured) + } +} diff --git a/crates/agent/src/brain/provider/tests.rs b/crates/agent/src/brain/provider/tests.rs new file mode 100644 index 0000000..d47d6f8 --- /dev/null +++ b/crates/agent/src/brain/provider/tests.rs @@ -0,0 +1,107 @@ +//! [`super`] 的用例(M174 r1 复审 P2-5 拆出来:`provider.rs` 撞了 400 行硬线)。 +//! +//! 拆的是**纯搬移**:一条断言都没改。分类那半边的新用例在 `failure` 的模块文档里指路, +//! 结构化分类本身由 `brain/rig.rs` 的 `map_error` 产生,用例在 `probe` 的真服务器那几条。 + +use super::*; + +#[test] +fn local_defaults_match_research() { + assert_eq!( + ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(), + LLAMACPP_DEFAULT_BASE_URL + ); + assert_eq!( + ProviderConfig::ollama("llama3").resolved_base_url(), + OLLAMA_DEFAULT_BASE_URL + ); + assert_eq!( + ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(), + ANTHROPIC_DEFAULT_BASE_URL + ); +} + +#[test] +fn setting_vocabulary_round_trips() { + // 设置里存的是 `openai_compatible`(schema 的 `AI_PROVIDERS`),不是 serde 的 + // `open_ai_compatible`:两个词表必须是这里这一对映射,否则「用户在设置页选的值」 + // agent 侧读出来是 None(M174 的根因)。 + for kind in [ + ProviderKind::OpenAiCompatible, + ProviderKind::Anthropic, + ProviderKind::Ollama, + ProviderKind::Disabled, + ] { + assert_eq!(ProviderKind::from_setting(kind.setting_value()), Some(kind)); + } + assert_eq!( + ProviderKind::from_setting(" ollama "), + Some(ProviderKind::Ollama) + ); + assert_eq!(ProviderKind::from_setting("open_ai_compatible"), None); + assert_eq!(ProviderKind::from_setting(""), None); + // schema 的允许值表与本枚举的取值表是**同一个集合**(一方加了另一方没加就红)。 + assert_eq!( + unlucky_core::setting::keys::AI_PROVIDERS, + [ + ProviderKind::OpenAiCompatible.setting_value(), + ProviderKind::Anthropic.setting_value(), + ProviderKind::Ollama.setting_value(), + ProviderKind::Disabled.setting_value(), + ] + ); +} + +#[test] +fn validate_requires_model_base_url_and_key() { + assert!(!ProviderConfig::disabled().is_enabled()); + assert!(ProviderConfig::disabled().validate().is_err()); + assert!(ProviderConfig::llamacpp_local("").validate().is_err()); + assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok()); + // OpenAI 兼容必须显式给端点 + assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini") + .validate() + .is_err()); + assert!( + ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") + .validate() + .is_ok() + ); + assert!(ProviderConfig::anthropic("claude").validate().is_err()); + assert!(ProviderConfig::anthropic("claude") + .with_api_key("sk-ant") + .validate() + .is_ok()); +} + +#[test] +fn redacted_debug_never_leaks_key() { + let config = ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat") + .with_api_key("sk-super-secret"); + let text = config.redacted_debug(); + assert!(!text.contains("sk-super-secret"), "{text}"); + assert!(text.contains(crate::redact::MASK)); + // `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。 + let debug = format!("{config:?}"); + assert!(!debug.contains("sk-super-secret"), "{debug}"); + assert!(debug.contains(crate::redact::MASK), "{debug}"); +} + +#[test] +fn empty_base_url_falls_back_to_default() { + let mut config = ProviderConfig::ollama("llama3"); + config.base_url = String::new(); + assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL); +} + +#[test] +fn four_states_serialize_as_snake_case() { + assert_eq!( + serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(), + "\"open_ai_compatible\"" + ); + assert_eq!( + serde_json::to_string(&ProviderConfig::disabled()).unwrap(), + "{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}" + ); +} diff --git a/crates/agent/src/brain/rig.rs b/crates/agent/src/brain/rig.rs index 3043564..27ce6d0 100644 --- a/crates/agent/src/brain/rig.rs +++ b/crates/agent/src/brain/rig.rs @@ -47,7 +47,7 @@ use std::sync::Arc; use futures_util::StreamExt; use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition}; -use rig_core::error::ProviderError as RigError; +use rig_core::error::{ErrorKind, ProviderError as RigError}; use rig_core::message::{ AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId, Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction, @@ -64,7 +64,7 @@ use super::chat::{ ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory, ProviderFuture, }; -use super::provider::{ProviderConfig, ProviderError, ProviderKind}; +use super::provider::{ProviderConfig, ProviderError, ProviderFailure, ProviderKind}; /// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。 pub const RIG_VERSION_PIN: &str = "=0.45.0"; @@ -275,9 +275,63 @@ fn to_reply( }) } -/// rig 错误 → 本方错误(文案面向用户)。 +/// rig 错误 → 本方错误:**分类取自 rig 的结构化报告**(`ErrorReport` 的 `kind` / +/// `http_status` / `code`),不猜 `Display` 文本(M174 r1 复审 P2-5)。 +/// +/// | rig 侧信号 | 本方分类 | +/// |---|---| +/// | `http_status ∈ {401, 403}` | [`ProviderFailure::Auth`] | +/// | `http_status == 404` | [`ProviderFailure::ModelNotFound`](OpenAI 兼容面的 404 只可能是模型不在,`/chat/completions` 本身是固定路径;Anthropic 的 404 也是同一语义) | +/// | `code == "model_not_found"` / `"model_not_found_error"` | [`ProviderFailure::ModelNotFound`] | +/// | `ErrorKind::Timeout` 或传输层 `io::ErrorKind::TimedOut` | [`ProviderFailure::Timeout`] | +/// | `ErrorKind::Http`(连接被拒 / DNS / TLS / 截断) | [`ProviderFailure::Unreachable`] | +/// | 其余(JSON / URL / 请求构造 / 5xx / 未知) | [`ProviderFailure::Other`] | fn map_error(error: RigError) -> ProviderError { - ProviderError::new(error.to_string()) + let report = error.report(); + let kind = match report.kind { + ErrorKind::Timeout => ProviderFailure::Timeout, + ErrorKind::Http => { + if transport_timed_out(&error) { + ProviderFailure::Timeout + } else { + ProviderFailure::Unreachable + } + } + ErrorKind::ProviderResponse | ErrorKind::Provider => match report.http_status { + Some(401 | 403) => ProviderFailure::Auth, + Some(404) => ProviderFailure::ModelNotFound, + _ if matches!( + report.code.as_deref(), + Some("model_not_found" | "model_not_found_error") + ) => + { + ProviderFailure::ModelNotFound + } + _ => ProviderFailure::Other, + }, + _ => ProviderFailure::Other, + }; + ProviderError::classified(kind, report.message) +} + +/// 传输层失败里有没有「超时」:沿 `source()` 链找 `io::ErrorKind::TimedOut` +/// (reqwest 把超时折在 transport error 里,rig 只留一个 `Box`)。 +/// +/// 结构化判据(类型 + `ErrorKind`),不是文本匹配:这条路径上的其它失败(连接被拒、DNS、 +/// TLS)都归 `Unreachable`。 +fn transport_timed_out(error: &RigError) -> bool { + let RigError::Http(http_error) = error else { + return false; + }; + let mut source = std::error::Error::source(http_error); + for _ in 0..8 { + let Some(cause) = source else { return false }; + if let Some(io) = cause.downcast_ref::() { + return io.kind() == std::io::ErrorKind::TimedOut; + } + source = cause.source(); + } + false } /// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。 @@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn tool(id: &str, name: &str) -> super::super::chat::ToolCall { - super::super::chat::ToolCall { - id: id.to_string(), - name: name.to_string(), - arguments: serde_json::json!({ "path": "appearance.theme_mode" }), - } - } - - #[test] - fn disabled_config_is_rejected_before_touching_the_network() { - let Err(err) = RigProvider::new(ProviderConfig::disabled()) else { - panic!("禁用态应在构造阶段被拒"); - }; - assert!(err.to_string().contains("禁用")); - } - - #[test] - fn anthropic_without_key_is_rejected() { - assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err()); - assert!(RigProvider::new( - ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant") - ) - .is_ok()); - } - - #[test] - fn local_states_build_offline() { - assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok()); - assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok()); - } - - #[test] - fn request_maps_messages_and_tools() { - let request = ChatRequest { - model: "mock".to_string(), - messages: vec![ - ChatMessage::system("你是助手"), - ChatMessage::user("把主题换成浅色"), - ChatMessage::Assistant { - text: String::new(), - tool_calls: vec![tool("call_1", "set_config")], - }, - ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"), - ], - tools: vec![crate::registry::ToolDefinition { - name: "set_config".to_string(), - description: "改配置".to_string(), - input_schema: serde_json::json!({ "type": "object" }), - }], - temperature: Some(0.5), - max_tokens: Some(512), - }; - let built = build_request(&request).expect("应能构造"); - assert_eq!(built.chat_history.len(), 4); - assert_eq!(built.tools.len(), 1); - assert_eq!(built.tools[0].name.as_str(), "set_config"); - assert_eq!(built.temperature, Some(0.5)); - assert_eq!(built.max_tokens, Some(512)); - // 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块 - let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化"); - assert!(json.contains("toolresult"), "{json}"); - } - - #[test] - fn empty_history_is_a_readable_error() { - let request = ChatRequest { - model: "m".to_string(), - messages: Vec::new(), - tools: Vec::new(), - temperature: None, - max_tokens: None, - }; - let err = build_request(&request).unwrap_err(); - assert!(err.to_string().contains("对话历史为空")); - } -} +#[path = "rig/tests.rs"] +mod tests; diff --git a/crates/agent/src/brain/rig/tests.rs b/crates/agent/src/brain/rig/tests.rs new file mode 100644 index 0000000..5ffa21c --- /dev/null +++ b/crates/agent/src/brain/rig/tests.rs @@ -0,0 +1,82 @@ +//! [`super`](rig 适配层)的用例(M174 r1 复审 P2-5 拆出来:结构化映射让 `rig.rs` 撞了 400 行)。 +//! +//! 纯搬移。分类的端到端证据不在本文件:那要真 HTTP,用例在 +//! `crates/cli/src/commands/agent/probe/tests.rs`(四类各一台假服务器)。 + +use super::*; + +fn tool(id: &str, name: &str) -> super::super::chat::ToolCall { + super::super::chat::ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: serde_json::json!({ "path": "appearance.theme_mode" }), + } +} + +#[test] +fn disabled_config_is_rejected_before_touching_the_network() { + let Err(err) = RigProvider::new(ProviderConfig::disabled()) else { + panic!("禁用态应在构造阶段被拒"); + }; + assert!(err.to_string().contains("禁用")); +} + +#[test] +fn anthropic_without_key_is_rejected() { + assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err()); + assert!( + RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")) + .is_ok() + ); +} + +#[test] +fn local_states_build_offline() { + assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok()); + assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok()); +} + +#[test] +fn request_maps_messages_and_tools() { + let request = ChatRequest { + model: "mock".to_string(), + messages: vec![ + ChatMessage::system("你是助手"), + ChatMessage::user("把主题换成浅色"), + ChatMessage::Assistant { + text: String::new(), + tool_calls: vec![tool("call_1", "set_config")], + }, + ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"), + ], + tools: vec![crate::registry::ToolDefinition { + name: "set_config".to_string(), + description: "改配置".to_string(), + input_schema: serde_json::json!({ "type": "object" }), + }], + temperature: Some(0.5), + max_tokens: Some(512), + }; + let built = build_request(&request).expect("应能构造"); + assert_eq!(built.chat_history.len(), 4); + assert_eq!(built.tools.len(), 1); + assert_eq!(built.tools[0].name.as_str(), "set_config"); + assert_eq!(built.temperature, Some(0.5)); + assert_eq!(built.max_tokens, Some(512)); + // 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块 + let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化"); + assert!(json.contains("toolresult"), "{json}"); +} + +#[test] +fn empty_history_is_a_readable_error() { + let request = ChatRequest { + model: "m".to_string(), + messages: Vec::new(), + tools: Vec::new(), + temperature: None, + max_tokens: None, + }; + let err = build_request(&request).unwrap_err(); + assert!(err.to_string().contains("对话历史为空")); +} diff --git a/crates/app-iced/src/app/update/settings/ai_about.rs b/crates/app-iced/src/app/update/settings/ai_about.rs index e2be17a..314bd49 100644 --- a/crates/app-iced/src/app/update/settings/ai_about.rs +++ b/crates/app-iced/src/app/update/settings/ai_about.rs @@ -30,7 +30,6 @@ impl App { &mut self, message: pages::settings::ai_about::Message, ) -> Task { - use pages::settings::ai::Secret; use pages::settings::ai_about::Message; match message { @@ -50,8 +49,8 @@ impl App { self.settings.ai_about.model = value; self.touch(); } - Message::EditKey(value) => { - self.settings.ai_about.key = Secret::new(value); + Message::EditKey(secret) => { + self.settings.ai_about.key = secret; self.touch(); } Message::Reveal(reveal) => { diff --git a/crates/app-iced/src/app/update/settings/tests.rs b/crates/app-iced/src/app/update/settings/tests.rs index 868db71..248897f 100644 --- a/crates/app-iced/src/app/update/settings/tests.rs +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -313,7 +313,9 @@ fn saving_the_ai_form_writes_the_real_settings_file() { ))); let _ = app.update(ai(Ai::EditBaseUrl("http://127.0.0.1:8080/v1".to_string()))); let _ = app.update(ai(Ai::EditModel("qwen2.5-7b".to_string()))); - let _ = app.update(ai(Ai::EditKey("sk-live-app-path".to_string()))); + let _ = app.update(ai(Ai::EditKey(pages::settings::ai::Secret::new( + "sk-live-app-path", + )))); let _ = app.update(ai(Ai::Save)); let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件"); diff --git a/crates/app-iced/src/pages/settings/ai/outcome.rs b/crates/app-iced/src/pages/settings/ai/outcome.rs index b5465f3..817c4a6 100644 --- a/crates/app-iced/src/pages/settings/ai/outcome.rs +++ b/crates/app-iced/src/pages/settings/ai/outcome.rs @@ -116,45 +116,79 @@ impl ProbeResult { }), _ => None, }; - let (kind, base_url, model, detail) = match outcome { - ProbeOutcome::NotConfigured { .. } => ( - ProbeKind::NotConfigured, - String::new(), - String::new(), - String::new(), - ), - ProbeOutcome::Passed { base_url, model } => { - (ProbeKind::Passed, base_url, model, String::new()) + // r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上 + // 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。 + let (kind, target, detail) = match outcome { + ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()), + ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()), + ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail), + ProbeOutcome::ModelNotFound { target, detail } => { + (ProbeKind::ModelNotFound, Some(target), detail) } - ProbeOutcome::Auth { detail } => { - (ProbeKind::Auth, String::new(), String::new(), detail) - } - ProbeOutcome::ModelNotFound { detail } => ( - ProbeKind::ModelNotFound, - String::new(), - String::new(), - detail, - ), - ProbeOutcome::Unreachable { detail } => { - (ProbeKind::Unreachable, String::new(), String::new(), detail) - } - ProbeOutcome::Timeout { detail } => { - (ProbeKind::Timeout, String::new(), String::new(), detail) - } - ProbeOutcome::Failed { detail } => { - (ProbeKind::Failed, String::new(), String::new(), detail) + ProbeOutcome::Unreachable { target, detail } => { + (ProbeKind::Unreachable, Some(target), detail) } + ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail), + ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail), }; Self { kind, missing, - base_url, - model, + base_url: target + .as_ref() + .map_or_else(String::new, |t| t.base_url.clone()), + model: target.map_or_else(String::new, |t| t.model.clone()), elapsed_ms, detail, } } + /// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。 + /// + /// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态 + /// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于 + /// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。 + #[must_use] + pub fn screenshot_seed( + kind: ProbeKind, + base_url: String, + model: String, + elapsed_ms: u128, + ) -> Self { + use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target}; + + let target = Target { base_url, model }; + let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}"; + let outcome = match kind { + ProbeKind::Passed => ProbeOutcome::Passed { target }, + ProbeKind::Auth => ProbeOutcome::Auth { + target, + detail: detail.to_string(), + }, + ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound { + target, + detail: detail.to_string(), + }, + ProbeKind::Unreachable => ProbeOutcome::Unreachable { + target, + detail: "Http client error: error sending request".to_string(), + }, + ProbeKind::Timeout => ProbeOutcome::Timeout { + target, + detail: "8 s".to_string(), + }, + ProbeKind::Failed => ProbeOutcome::Failed { + target, + detail: detail.to_string(), + }, + // 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。 + ProbeKind::NotConfigured => ProbeOutcome::NotConfigured { + missing: unlucky_cli::commands::agent::probe::Missing::Model, + }, + }; + Self::from_outcome(outcome, elapsed_ms) + } + /// 失败的三类之外的那几类(`NotConfigured` 没发请求)。 #[must_use] pub fn sent_a_request(&self) -> bool { diff --git a/crates/app-iced/src/pages/settings/ai/state.rs b/crates/app-iced/src/pages/settings/ai/state.rs index 37e1c7d..8525143 100644 --- a/crates/app-iced/src/pages/settings/ai/state.rs +++ b/crates/app-iced/src/pages/settings/ai/state.rs @@ -334,25 +334,26 @@ impl State { state.key_chars = 32; } other => { - let (kind, missing) = match other { - "verified" => (ProbeKind::Passed, None), - "failed-auth" => (ProbeKind::Auth, None), - "failed-model" => (ProbeKind::ModelNotFound, None), - "failed-unreachable" => (ProbeKind::Unreachable, None), - "failed-timeout" => (ProbeKind::Timeout, None), + let kind = match other { + "verified" => ProbeKind::Passed, + "failed-auth" => ProbeKind::Auth, + "failed-model" => ProbeKind::ModelNotFound, + "failed-unreachable" => ProbeKind::Unreachable, + "failed-timeout" => ProbeKind::Timeout, _ => return None, }; state.base_url = "https://api.deepseek.com/v1".to_string(); state.model = "deepseek-chat".to_string(); state.key_chars = 32; - state.probe = Probe::Done(ProbeResult { + // r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定, + // 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」 + // 这类缺陷只在产品上出现、截图永远看不见。 + state.probe = Probe::Done(ProbeResult::screenshot_seed( kind, - missing, - base_url: state.resolved_base_url(), - model: state.model.clone(), - elapsed_ms: 412, - detail: String::new(), - }); + state.resolved_base_url(), + state.model.clone(), + 412, + )); } } Some(state) diff --git a/crates/app-iced/src/pages/settings/ai/store_tests.rs b/crates/app-iced/src/pages/settings/ai/store_tests.rs index bb0bece..4f10aba 100644 --- a/crates/app-iced/src/pages/settings/ai/store_tests.rs +++ b/crates/app-iced/src/pages/settings/ai/store_tests.rs @@ -1,4 +1,4 @@ -//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘。 +//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。 //! //! | 用例 | 守什么 | 变异 | //! |---|---|---| @@ -7,6 +7,7 @@ //! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 | //! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` | //! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 | +//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 | use std::path::PathBuf; @@ -127,3 +128,36 @@ fn external_edit_is_read_back() { assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0"); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); } + +/// **P2-4 的用户场景原样**(r1 复审要求的用例):设置页保存 AI 配置 → 同一秒内改主题 → 值还在。 +/// +/// 这一条与 `presenter/src/settings_bridge.rs` 的 `persist_keeps_keys_another_writer_just_saved` +/// 守的是同一个缺陷,但从**产品的两个写者**出发:`ai::store::save`(现读现写)与 +/// `SettingsBridge::persist`(长生命 store + 写前 reload)。任一方少了「写前现读」,这条红。 +#[test] +fn appearance_write_back_keeps_the_ai_keys() { + use unlucky_presenter::prefs::{AppearancePrefs, ThemeMode}; + use unlucky_presenter::settings_bridge::SettingsBridge; + + let path = temp_config("appearance-keeps-ai"); + // 外观桥先加载过一份文件(它的 store 就是从这里开始的旧快照)。 + let mut bridge = SettingsBridge::with_path(&path); + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); + + // 用户在「助手」页保存配置(真落盘、含密钥)。 + let mut draft = draft(); + draft.key = Secret::new("sk-must-survive"); + save(&path, &draft).expect("保存 AI 配置"); + + // 紧接着用户去「外观」改主题(外观桥写回它那份整文档)。 + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark); + + let text = std::fs::read_to_string(&path).expect("读文件"); + assert!( + text.contains("sk-must-survive"), + "密钥被外观写回盖掉了:{text}" + ); + assert!(text.contains("claude-sonnet-4"), "模型名也被盖掉了:{text}"); + assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count()); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} diff --git a/crates/app-iced/src/pages/settings/ai/tests.rs b/crates/app-iced/src/pages/settings/ai/tests.rs index 9c811b1..9bef712 100644 --- a/crates/app-iced/src/pages/settings/ai/tests.rs +++ b/crates/app-iced/src/pages/settings/ai/tests.rs @@ -59,6 +59,35 @@ fn secret_debug_is_masked() { assert!(Secret::new(" ").is_empty()); } +/// **P2-2**:`Message::EditKey` 的 `Debug` 不许把密钥打进日志。 +/// +/// 现场(r1 复审):载荷是裸 `String`,而 harness / 断言失败输出会把页面消息 `{:?}` 打出来 ⇒ +/// 明文 key 进 stderr 与 CI 日志。载荷改成 [`Secret`] 之后由它自己的 `Debug` 兜住。 +/// +/// 抓得住:谁把 `EditKey(String)` 改回去(`Secret` 的 Debug 掩码见 [`secret_debug_is_masked`])。 +#[test] +fn the_key_message_never_prints_the_key() { + use crate::pages::settings::ai_about::Message; + + const SECRET: &str = "sk-live-MSG-LEAK-7c2f"; + let message = Message::EditKey(Secret::new(SECRET)); + let printed = format!("{message:?}"); + assert!( + !printed.contains(SECRET), + "页面消息的 Debug 泄漏了密钥(它会进日志/CI 输出):{printed}" + ); + assert!(printed.contains("***"), "应当看得见掩码标记:{printed}"); + + // 整条投递口(`pages::Message`)也一样:它才是真的被一层层 `{:?}` 的那个类型。 + let page = + crate::pages::Message::Settings(crate::pages::settings::GroupMessage::AiAbout(message)); + let printed = format!("{page:?}"); + assert!( + !printed.contains(SECRET), + "页面消息漏斗泄漏了密钥:{printed}" + ); +} + #[test] fn validate_refuses_bad_endpoint_and_empty_model() { let mut state = configured(); @@ -217,58 +246,79 @@ fn saving_a_new_target_drops_the_old_pass() { assert_eq!(state.status(), Status::Configured); } +/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。 +/// +/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷—— +/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。 #[test] -fn not_configured_outcome_carries_no_target() { - use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome}; +fn failures_carry_the_target_that_was_dialed() { + use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target}; - let result = ProbeResult::from_outcome( + let target = || Target { + base_url: "https://api.deepseek.com/v1".to_string(), + model: "deepseek-chat".to_string(), + }; + + let not_configured = ProbeResult::from_outcome( ProbeOutcome::NotConfigured { missing: Missing::Model, }, 0, ); - assert_eq!(result.kind, ProbeKind::NotConfigured); - assert_eq!(result.missing, Some(MissingField::Model)); - assert!(result.base_url.is_empty(), "没发请求就没有目标地址"); - assert!(result.model.is_empty()); - assert!(!result.sent_a_request()); + assert_eq!(not_configured.kind, ProbeKind::NotConfigured); + assert_eq!(not_configured.missing, Some(MissingField::Model)); + assert!(not_configured.base_url.is_empty(), "没发请求就没有目标地址"); + assert!(not_configured.model.is_empty()); + assert!(!not_configured.sent_a_request()); - // 四类失败各自翻译过来,且都不带目标(它们没有「成功的目标」可说)。 for (outcome, expected) in [ ( ProbeOutcome::Auth { + target: target(), detail: "401".to_string(), }, ProbeKind::Auth, ), ( ProbeOutcome::ModelNotFound { + target: target(), detail: "404".to_string(), }, ProbeKind::ModelNotFound, ), ( ProbeOutcome::Unreachable { + target: target(), detail: "refused".to_string(), }, ProbeKind::Unreachable, ), ( ProbeOutcome::Timeout { + target: target(), detail: "8 s".to_string(), }, ProbeKind::Timeout, ), ( ProbeOutcome::Failed { + target: target(), detail: "?".to_string(), }, ProbeKind::Failed, ), + (ProbeOutcome::Passed { target: target() }, ProbeKind::Passed), ] { let result = ProbeResult::from_outcome(outcome, 5); assert_eq!(result.kind, expected); - assert!(result.sent_a_request()); + assert_eq!( + result.base_url, "https://api.deepseek.com/v1", + "{expected:?}" + ); + assert_eq!(result.model, "deepseek-chat", "{expected:?}"); + if expected != ProbeKind::Passed { + assert!(result.sent_a_request()); + } } } @@ -288,6 +338,15 @@ fn screenshot_seed_covers_the_state_matrix() { let failed = State::screenshot_seed("failed-auth").expect("种子"); assert_eq!(failed.status(), Status::Failed); + // P2-1:种子的**形状**要走产品路径(`ProbeResult::from_outcome`),所以失败态也带 target。 + // 上一版种子手填字段、把 target 填满,于是「产品上失败态目标是空串」这个缺陷截图看不见。 + if let Probe::Done(result) = &failed.probe { + assert_eq!(result.base_url, "https://api.deepseek.com/v1"); + assert_eq!(result.model, "deepseek-chat"); + assert!(!result.detail.is_empty(), "失败态应当带一句脱敏详情"); + } else { + panic!("失败种子没带上探测结论"); + } assert!( State::screenshot_seed("nonsense").is_none(), "未知种子不伪造状态" diff --git a/crates/app-iced/src/pages/settings/ai/view.rs b/crates/app-iced/src/pages/settings/ai/view.rs index 7dd0196..542ea6f 100644 --- a/crates/app-iced/src/pages/settings/ai/view.rs +++ b/crates/app-iced/src/pages/settings/ai/view.rs @@ -27,7 +27,7 @@ use crate::app::components::inputs::{text_field, Field, FieldVariant}; use crate::app::components::selection::{chip, switch, Chip, Switch}; use crate::pages::frame::{card, divider, text_role_owned}; use crate::pages::settings::ai::outcome::ProbeKind; -use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, State, Status}; +use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, Secret, State, Status}; use crate::pages::settings::{ai_about, GroupMessage, States}; use crate::pages::Message; use crate::theme::Ui; @@ -64,20 +64,24 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> } if let Probe::Done(result) = &state.probe { stacked = stacked.push(probe_line(ui, catalog, result.kind)); - stacked = stacked.push(text_role_owned( - ui, - TypeRole::BodySmall, - false, - catalog.render( - Key::SettingsAiTestTarget, - &[ - ("base_url", result.base_url.as_str()), - ("model", result.model.as_str()), - ("ms", result.elapsed_ms.to_string().as_str()), - ], - ), - ui.color(Role::OnSurfaceVariant), - )); + // 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target, + // 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。 + if !(result.base_url.is_empty() && result.model.is_empty()) { + stacked = stacked.push(text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render( + Key::SettingsAiTestTarget, + &[ + ("base_url", result.base_url.as_str()), + ("model", result.model.as_str()), + ("ms", result.elapsed_ms.to_string().as_str()), + ], + ), + ui.color(Role::OnSurfaceVariant), + )); + } if !result.detail.trim().is_empty() { stacked = stacked.push(text_role_owned( ui, @@ -240,7 +244,7 @@ fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, ), ); let field = container(text_field(ui, field, |value| { - msg(ai_about::Message::EditKey(value)) + msg(ai_about::Message::EditKey(Secret::new(value))) })) .width(Length::Fill); column![ diff --git a/crates/app-iced/src/pages/settings/ai_about.rs b/crates/app-iced/src/pages/settings/ai_about.rs index 295e6d3..4f59f95 100644 --- a/crates/app-iced/src/pages/settings/ai_about.rs +++ b/crates/app-iced/src/pages/settings/ai_about.rs @@ -54,8 +54,12 @@ pub enum Message { EditBaseUrl(String), /// 模型名草稿(`ai.model`) EditModel(String), - /// 新密钥草稿(明文只在内存里;空 = 保留已存的那把) - EditKey(String), + /// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。 + /// + /// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness + /// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里, + /// 用裸 `String` 等于给密钥开了一条到日志的路。 + EditKey(super::ai::Secret), /// 密钥输入框明文/掩码 Reveal(bool), /// 保存(校验 + 落盘) diff --git a/crates/cli/src/commands/agent/probe.rs b/crates/cli/src/commands/agent/probe.rs index 3e045d6..40c1bff 100644 --- a/crates/cli/src/commands/agent/probe.rs +++ b/crates/cli/src/commands/agent/probe.rs @@ -1,18 +1,20 @@ //! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现** //! ([`RigProvider`])发一次最小请求,并把失败分成四类。 //! -//! ## 为什么分类不在 provider 层 +//! ## 分类从哪来(r1 复审 P2-5 之后) //! -//! `brain::rig` 把 rig 的错误压成一句 `Display` 文本(`ProviderError::new(error.to_string())`), -//! 那里拿不到结构化的状态码;本模块是**唯一**把「错误文本」翻译成人能看懂的四类的地方, -//! 四类各自有一条真 TCP 假服务器用例钉住(`probe/tests.rs`),谁都不许靠猜。 +//! **不在本模块猜文本**:分类由 `unlucky_agent::brain::rig::map_error` 从 rig 0.45 的结构化 +//! 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成 +//! [`ProviderFailure`],本模块只做「枚举 → 界面结论」的映射: //! -//! | 类 | 判据(错误文本里) | 用户看到的 | -//! |---|---|---| -//! | [`ProbeOutcome::Auth`] | `401` / `403` / `unauthorized` / `invalid_api_key` | 密钥不对(或没有权限) | -//! | [`ProbeOutcome::ModelNotFound`] | `404` / `model_not_found` / `does not exist` | 端点通了,但没有这个模型名 | -//! | [`ProbeOutcome::Unreachable`] | 连接被拒 / DNS / TLS / `error sending request` | 端点不可达 | -//! | [`ProbeOutcome::Timeout`] | 超时 | 超时(网络太慢或端点不响应) | +//! | [`ProviderFailure`] | 本模块产出 | +//! |---|---| +//! | `Auth`(401/403) | [`ProbeOutcome::Auth`] | +//! | `ModelNotFound`(404 / `model_not_found`) | [`ProbeOutcome::ModelNotFound`] | +//! | `Unreachable`(传输层:连接被拒 / DNS / TLS) | [`ProbeOutcome::Unreachable`] | +//! | `Timeout`(8 秒时限 / 传输层超时) | [`ProbeOutcome::Timeout`] | +//! | `NotConfigured`(没发请求) | [`ProbeOutcome::NotConfigured`] | +//! | `Other`(解析 / 5xx / 未知) | [`ProbeOutcome::Failed`] | //! //! ## 三条硬纪律 //! @@ -20,14 +22,19 @@ //! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。 //! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把 //! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。 -//! [`AiSettings`] 的 `Debug` 是手写的(掩码),`ProbeOutcome` 里没有密钥字段。 -//! 3. **读的是文件里现在那一份**:[`probe_file`] 每次现读,所以「设置页保存完再测」测的就是 -//! 刚存下的 `ai.base_url`——不是启动时缓存的旧值。 +//! [`AiSettings`] 的 `Debug` 是手写的(掩码),[`ProbeOutcome`] 里没有密钥字段。 +//! 3. **每次现读文件**:[`probe_file`] 读的是「现在这一份」,所以设置页保存完再测,测的就是刚 +//! 存下的 `ai.base_url`(不是启动时缓存的旧值)。 +//! +//! 失败结论**一律带上被请求的 target**(r1 复审 P2-1):只说「鉴权失败」而不说「对哪个端点、 +//! 哪个模型」,用户改完 key 也没法确认自己改的是不是那一份配置。 use std::path::Path; use std::time::Duration; -use unlucky_agent::brain::provider::{ProviderConfig, ProviderError, ProviderKind}; +use unlucky_agent::brain::provider::{ + ProviderConfig, ProviderError, ProviderFailure, ProviderKind, +}; use unlucky_agent::brain::rig::RigProvider; use unlucky_agent::brain::{ChatMessage, ChatRequest, NoopObserver, ProviderFactory}; use unlucky_agent::redact::{redact_text, MASK}; @@ -36,17 +43,15 @@ use unlucky_core::setting::store::SettingStore; /// 一次「测试连接」的默认时限。 /// -/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应(UI 侧有「正在测试」 -/// 态,但那不是让人等半分钟的理由)。 +/// 8 秒:够一个跨洋 HTTPS 往返 + 服务端首 token;再长用户会以为按钮没反应。 pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8); -/// 探测请求的模型名之外只发一句话:够触发一次真实补全,又不烧 token。 +/// 探测请求只发这一句:够触发一次真实补全,又不烧 token。 const PING: &str = "ping"; /// 从设置文件读出来的 AI 四项(快照)。 /// -/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码——与 -/// `ProviderConfig` 同一条纪律:`{:?}` 是日志/panic 消息的默认出口。 +/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码。 #[derive(Clone, PartialEq, Eq)] pub struct AiSettings { /// `ai.enabled` @@ -102,43 +107,60 @@ impl Missing { } } +/// 被请求的那一份配置(端点 + 模型):失败结论也要带它,用户才知道「改的是不是这一份」。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + /// 实际请求的端点(`resolved_base_url`)。 + pub base_url: String, + /// 实际请求的模型名。 + pub model: String, +} + /// 一次探测的结论(**不含密钥**;`detail` 已脱敏,可直接上屏/进日志)。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProbeOutcome { - /// 配置不完整:**没有发出任何请求**。 + /// 配置不完整:**没有发出任何请求**(因此也没有 target)。 NotConfigured { /// 缺的是哪一项。 missing: Missing, }, /// 通了(拿到一次真实补全的回复)。 Passed { - /// 实际请求的端点(`resolved_base_url`)。 - base_url: String, - /// 实际请求的模型名。 - model: String, + /// 实际请求的目标。 + target: Target, }, - /// 鉴权失败(401 / 403 / `invalid_api_key`)。 + /// 鉴权失败(401 / 403)。 Auth { + /// 实际请求的目标。 + target: Target, /// 脱敏后的原因。 detail: String, }, /// 模型不存在(404 / `model_not_found`)。 ModelNotFound { + /// 实际请求的目标。 + target: Target, /// 脱敏后的原因。 detail: String, }, /// 端点不可达(连接被拒 / DNS / TLS)。 Unreachable { + /// 实际请求的目标。 + target: Target, /// 脱敏后的原因。 detail: String, }, /// 超时。 Timeout { + /// 实际请求的目标。 + target: Target, /// 脱敏后的原因(含时限)。 detail: String, }, - /// 其它(协议/解析/未知 HTTP 码):不硬塞进上面四类。 + /// 其它(协议 / 解析 / 5xx):不硬塞进上面四类。 Failed { + /// 实际请求的目标。 + target: Target, /// 脱敏后的原因。 detail: String, }, @@ -151,7 +173,8 @@ impl ProbeOutcome { matches!(self, Self::Passed { .. }) } - /// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` / `not_configured` / `failed`)。 + /// 稳定名(`passed` / `auth` / `model_not_found` / `unreachable` / `timeout` / + /// `not_configured` / `failed`)。 #[must_use] pub const fn kind(&self) -> &'static str { match self { @@ -164,44 +187,19 @@ impl ProbeOutcome { Self::Failed { .. } => "failed", } } -} -/// 错误文本 → 四类。**顺序有讲究**:先超时(超时文本里可能带 URL 里的 `401` 之类数字), -/// 再鉴权,再模型,最后不可达。 -#[must_use] -pub fn classify(message: &str) -> &'static str { - let text = message.to_lowercase(); - let has = |needle: &str| text.contains(needle); - if has("timed out") || has("timeout") || has("deadline exceeded") { - "timeout" - } else if has("401") - || has("403") - || has("unauthorized") - || has("invalid_api_key") - || has("invalid api key") - || has("authentication") - { - "auth" - } else if has("404") - || has("model_not_found") - || has("model not found") - || has("no such model") - || (has("model") && has("does not exist")) - { - "model_not_found" - } else if has("connection refused") - || has("dns") - || has("failed to connect") - || has("connect error") - || has("error sending request") - || has("network is unreachable") - || has("unreachable") - || has("tls") - || has("certificate") - { - "unreachable" - } else { - "failed" + /// 这条结论是关于哪个端点/模型的(`NotConfigured` 没有)。 + #[must_use] + pub fn target(&self) -> Option<&Target> { + match self { + Self::NotConfigured { .. } => None, + Self::Passed { target } + | Self::Auth { target, .. } + | Self::ModelNotFound { target, .. } + | Self::Unreachable { target, .. } + | Self::Timeout { target, .. } + | Self::Failed { target, .. } => Some(target), + } } } @@ -218,10 +216,7 @@ impl AiSettings { .filter(|value| !value.is_empty()) .map(str::to_string); Self { - enabled: doc - .effective("ai.enabled") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true), + enabled: doc.effective_bool("ai.enabled").unwrap_or(true), provider: text("ai.provider"), base_url: text("ai.base_url"), model: text("ai.model"), @@ -298,16 +293,18 @@ pub fn probe_with( Ok(config) => config, Err(missing) => return ProbeOutcome::NotConfigured { missing }, }; - let base_url = config.resolved_base_url().to_string(); - let model = config.model.clone(); + let target = Target { + base_url: config.resolved_base_url().to_string(), + model: config.model.clone(), + }; let provider = match factory(&config) { Ok(provider) => provider, - Err(error) => return outcome_from_error(&error, settings), + Err(error) => return outcome_from_error(&error, settings, target), }; let request = ChatRequest { - model: model.clone(), + model: target.model.clone(), messages: vec![ChatMessage::user(PING)], tools: Vec::new(), temperature: Some(0.0), @@ -316,24 +313,34 @@ pub fn probe_with( unlucky_core::download::blocking::block_on(async move { match tokio::time::timeout(timeout, provider.complete(&request, &NoopObserver)).await { - Ok(Ok(_reply)) => ProbeOutcome::Passed { base_url, model }, - Ok(Err(error)) => outcome_from_error(&error, settings), + Ok(Ok(_reply)) => ProbeOutcome::Passed { target }, + Ok(Err(error)) => outcome_from_error(&error, settings, target), Err(_) => ProbeOutcome::Timeout { + target, detail: format!("{} s", timeout.as_secs_f32()), }, } }) } -/// provider 错误 → 分类结果(`detail` 先脱敏再出门)。 -fn outcome_from_error(error: &ProviderError, settings: &AiSettings) -> ProbeOutcome { +/// provider 错误 → 分类结论(`detail` 先脱敏再出门;分类来自 [`ProviderError::kind`])。 +fn outcome_from_error( + error: &ProviderError, + settings: &AiSettings, + target: Target, +) -> ProbeOutcome { let detail = scrub(&error.to_string(), settings.secret()); - match classify(&error.to_string()) { - "auth" => ProbeOutcome::Auth { detail }, - "model_not_found" => ProbeOutcome::ModelNotFound { detail }, - "unreachable" => ProbeOutcome::Unreachable { detail }, - "timeout" => ProbeOutcome::Timeout { detail }, - _ => ProbeOutcome::Failed { detail }, + match error.kind() { + ProviderFailure::Auth => ProbeOutcome::Auth { target, detail }, + ProviderFailure::ModelNotFound => ProbeOutcome::ModelNotFound { target, detail }, + ProviderFailure::Unreachable => ProbeOutcome::Unreachable { target, detail }, + ProviderFailure::Timeout => ProbeOutcome::Timeout { target, detail }, + // 工厂阶段就报「配置不完整」(`validate()` 拒绝):仍然没发请求,但缺项已经在上游 + // `to_config()` 拦过,这里退到 `Provider` 这个具名缺项。 + ProviderFailure::NotConfigured => ProbeOutcome::NotConfigured { + missing: Missing::Provider, + }, + ProviderFailure::Other => ProbeOutcome::Failed { target, detail }, } } diff --git a/crates/cli/src/commands/agent/probe/tests.rs b/crates/cli/src/commands/agent/probe/tests.rs index 6d6f34b..0d56513 100644 --- a/crates/cli/src/commands/agent/probe/tests.rs +++ b/crates/cli/src/commands/agent/probe/tests.rs @@ -2,14 +2,13 @@ //! //! | 用例 | 守什么 | 变异(改成什么会红) | //! |---|---|---| -//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `classify` 删掉 `401` 分支 | -//! | `missing_model_is_model_not_found` | 404/model_not_found → 模型不存在 | 把 `model_not_found` 归进 auth | -//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 删 `connection refused` 分支 | +//! | `auth_failure_is_classified_as_auth` | 401 → 鉴权失败 | `map_error` 的 401 分支改成 `Other` | +//! | `missing_model_is_model_not_found` | 404 → 模型不存在 | 把 404 归进 auth | +//! | `closed_port_is_unreachable` | 连不上 → 端点不可达 | 把 `ErrorKind::Http` 归到 `Other` | //! | `silent_server_times_out` | 不响应 → 超时 | 去掉 `tokio::time::timeout` | //! | `success_is_one_real_completion` | 200 + SSE → 通过 | 把 `Ok(Ok(_))` 当成失败 | -//! | `incomplete_config_never_builds_a_provider` | 未配置 ⇒ 工厂构造计数 **0** | 把校验挪到工厂调用之后 | -//! | `saved_base_url_is_the_address_actually_dialed` | 改完设置再测,请求打到**新**地址 | 用启动时缓存的值 / 写死端点 | -//! | `key_never_leaks_into_outcome_or_debug` | 密钥不进 detail / Debug | 删掉 `scrub` 的 replace | +//! | `the_request_carries_the_configured_model` | **请求体里的 `model` 就是配置那个** | `probe_with` 里把 model 写死成别的名字 | +//! | 每条失败用例都断言 `target` | 失败结论带「对哪个端点/模型」 | `outcome_from_error` 丢掉 target(r1 复审 P2-1) | use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; @@ -28,10 +27,17 @@ enum Behaviour { Silent, } -/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求头,按 `behaviour` 回。 +/// 一条收到的请求:头 + 正文(P2-3:正文里才有 `model`)。 +#[derive(Clone)] +struct Hit { + head: String, + body: String, +} + +/// 一个只服务 127.0.0.1 的假 HTTP 服务器:记下每一份请求,按 `behaviour` 回。 struct Server { port: u16, - hits: Arc>>, + hits: Arc>>, } impl Server { @@ -53,27 +59,49 @@ impl Server { format!("http://127.0.0.1:{}/v1", self.port) } - fn hits(&self) -> Vec { + fn hits(&self) -> Vec { self.hits.lock().expect("锁").clone() } } -fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc>>) { - let mut head = [0_u8; 4096]; - let mut text = String::new(); - // 请求头到 `\r\n\r\n` 为止就够了:本模块只关心方法 + 路径(正文是 `ping`)。 - while !text.contains("\r\n\r\n") { - match stream.read(&mut head) { - Ok(0) => break, - Ok(n) => text.push_str(&String::from_utf8_lossy(&head[..n])), +fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc>>) { + let mut buffer = [0_u8; 8192]; + let mut raw = Vec::new(); + // 先读头,再按 `Content-Length` 把正文读全(P2-3:只读头就断言不到 `model`)。 + let head_end = loop { + if let Some(at) = raw.windows(4).position(|w| w == b"\r\n\r\n") { + break at + 4; + } + match stream.read(&mut buffer) { + Ok(0) => break raw.len(), + Ok(n) => raw.extend_from_slice(&buffer[..n]), Err(_) => return, } + }; + let head = String::from_utf8_lossy(&raw[..head_end]).to_string(); + let length = head + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while raw.len() < head_end + length { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(n) => raw.extend_from_slice(&buffer[..n]), + Err(_) => break, + } } - sink.lock().expect("锁").push(text.clone()); + let body = + String::from_utf8_lossy(&raw[head_end..raw.len().min(head_end + length)]).to_string(); + sink.lock().expect("锁").push(Hit { head, body }); match behaviour { Behaviour::Silent => { - // 拖住这条连接(客户端会先超时):比客户端时限长得多,收尾时自己断开。 + // 拖住这条连接(客户端会先超时):比客户端时限长得多。 std::thread::sleep(Duration::from_secs(5)); } Behaviour::Reply(status, content_type, body) => { @@ -120,11 +148,13 @@ fn auth_failure_is_classified_as_auth() { &settings(&server.base_url(), "gpt-test"), Duration::from_secs(5), ); - let ProbeOutcome::Auth { detail } = &outcome else { + let ProbeOutcome::Auth { target, detail } = &outcome else { panic!("401 必须归到鉴权失败,实际 {outcome:?}"); }; assert!(!detail.is_empty()); - assert_eq!(outcome.kind(), "auth"); + // P2-1:失败结论必须说清「对哪个端点 / 哪个模型」。 + assert_eq!(target.base_url, server.base_url()); + assert_eq!(target.model, "gpt-test"); // 请求确实发出去了(否则「鉴权失败」是凭空说的)。 assert_eq!(server.hits().len(), 1, "鉴权失败之前必须真的连上过"); } @@ -144,8 +174,9 @@ fn missing_model_is_model_not_found() { assert_eq!( outcome.kind(), "model_not_found", - "404/model_not_found 必须归到模型不存在,实际 {outcome:?}" + "404 必须归到模型不存在,实际 {outcome:?}" ); + assert_eq!(outcome.target().map(|t| t.model.as_str()), Some("gpt-nope")); } #[test] @@ -155,9 +186,10 @@ fn closed_port_is_unreachable() { let listener = TcpListener::bind("127.0.0.1:0").expect("绑端口"); listener.local_addr().expect("地址").port() }; + let base_url = format!("http://127.0.0.1:{port}/v1"); let outcome = probe_with( &RigProvider::factory(), - &settings(&format!("http://127.0.0.1:{port}/v1"), "gpt-test"), + &settings(&base_url, "gpt-test"), Duration::from_secs(5), ); assert_eq!( @@ -165,6 +197,10 @@ fn closed_port_is_unreachable() { "unreachable", "没人监听的端口必须归到端点不可达,实际 {outcome:?}" ); + assert_eq!( + outcome.target().map(|t| t.base_url.as_str()), + Some(base_url.as_str()) + ); } #[test] @@ -201,15 +237,51 @@ fn success_is_one_real_completion() { Duration::from_secs(5), ); match &outcome { - ProbeOutcome::Passed { base_url, model } => { - assert_eq!(base_url, &server.base_url()); - assert_eq!(model, "gpt-test"); + ProbeOutcome::Passed { target } => { + assert_eq!(target.base_url, server.base_url()); + assert_eq!(target.model, "gpt-test"); } other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"), } } -// 不发请求那一半的守卫(未配置不建 provider / 改完设置打新地址 / 密钥不外流)在 -// `probe/tests_guards.rs`:本文件只放真 TCP 假服务器那五条与密钥掩码那一条。 +/// **P2-3**:请求体里的 `model` 必须是用户配置的那个(不是写死的)。 +/// +/// 变异:把 `probe_with` 的 `model: target.model.clone()` 改成 `"gpt-4o".to_string()` ⇒ 本用例 +/// 断言 `\"model\":\"gpt-test\"` 失败。这是「配置里的模型真的上了线」唯一可信的证据:只看 +/// 状态码通过并不能证明服务端收到的是哪个模型。 +#[test] +fn the_request_carries_the_configured_model() { + let server = Server::start(Behaviour::Reply( + "HTTP/1.1 200 OK", + "text/event-stream", + SSE_BODY, + )); + let outcome = probe_with( + &RigProvider::factory(), + &settings(&server.base_url(), "gpt-test"), + Duration::from_secs(5), + ); + assert!(outcome.is_pass(), "前提:这一轮应当通过,实际 {outcome:?}"); + + let hits = server.hits(); + assert_eq!(hits.len(), 1, "应当恰好一次请求"); + let hit = &hits[0]; + assert!( + hit.head.starts_with("POST /v1/chat/completions"), + "请求行不对:{}", + hit.head.lines().next().unwrap_or("") + ); + assert!( + !hit.body.is_empty(), + "没有读到请求正文(夹具没按 Content-Length 读 body?)" + ); + assert!( + hit.body.contains("\"model\":\"gpt-test\""), + "请求正文里的 model 不是配置里的那个:{}", + hit.body + ); +} + #[path = "tests_guards.rs"] mod guards; diff --git a/crates/cli/src/commands/agent/probe/tests_guards.rs b/crates/cli/src/commands/agent/probe/tests_guards.rs index ca08235..5753c47 100644 --- a/crates/cli/src/commands/agent/probe/tests_guards.rs +++ b/crates/cli/src/commands/agent/probe/tests_guards.rs @@ -9,7 +9,7 @@ use std::time::Duration; use unlucky_agent::brain::provider::ProviderConfig; use unlucky_agent::brain::{ - ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderError, ProviderFuture, + ChatProvider, ChatRequest, DeltaObserver, ProviderError, ProviderFuture, }; use unlucky_agent::redact::MASK; @@ -140,15 +140,22 @@ fn saved_base_url_is_the_address_actually_dialed() { assert_eq!(old.hits().len(), 1, "旧地址不该再被打一次"); let hit = &new.hits()[0]; assert!( - hit.contains("POST /v1/chat/completions"), + hit.head.starts_with("POST /v1/chat/completions"), "OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}", - hit.lines().next().unwrap_or("") + hit.head.lines().next().unwrap_or("") + ); + assert!( + hit.body.contains("\"model\":\"gpt-test\""), + "请求正文里的 model 不是配置里的那个:{}", + hit.body + ); + // P2-1:改完 base_url 之后,失败结论里说的也必须是**新**地址。 + assert_eq!( + second.target().map(|target| target.base_url.as_str()), + Some(new.base_url().as_str()) ); } -/// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。 -/// -/// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。 /// 守卫①:密钥不得出现在 `ProbeOutcome` 的任何字段里,也不得出现在 `AiSettings` 的 `Debug` 里。 /// /// 这里让假服务器把**密钥原文回显**在 401 错误体里(最坏情况):`detail` 必须是掩码。 @@ -242,34 +249,42 @@ fn write_config(path: &PathBuf, body: &str) { std::fs::write(path, body).expect("写配置文件"); } -/// 让编译器确认 `ChatReply` / `ProviderError` 的导入是被用的(构造一个不可能的 provider -/// 用到它们),同时钉住「工厂返回错误也要分类」这条支路。 +/// 工厂阶段的错误也按**结构化分类**走(r1 复审 P2-5 之后:这里不再看文本)。 +/// +/// 两条一起钉住: +/// 1. 工厂给的是 `classified(Auth, …)` ⇒ 结论就是 `auth`(分类信息不丢); +/// 2. 工厂给的是 `ProviderError::new("401 bad key")`(**纯文本、没有分类**)⇒ 结论是 `failed`, +/// **不是** `auth`。第二条是 P2-5 的绊线:谁把「按 `"401"` 子串猜类别」加回来,这条立刻红。 #[test] -fn factory_error_is_classified_too() { - struct Broken; - impl ChatProvider for Broken { - fn config(&self) -> &ProviderConfig { - unreachable!() - } - fn complete<'a>( - &'a self, - _request: &'a ChatRequest, - _observer: &'a dyn DeltaObserver, - ) -> ProviderFuture<'a> { - Box::pin(async { Ok(ChatReply::default()) }) - } - } - let _ = Broken; - let factory: unlucky_agent::brain::ProviderFactory = - Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key"))); +fn factory_error_carries_the_structured_classification() { + let classified: unlucky_agent::brain::ProviderFactory = Arc::new(|_config: &ProviderConfig| { + Err(ProviderError::classified( + unlucky_agent::brain::ProviderFailure::Auth, + "401 Unauthorized", + )) + }); let outcome = probe_with( - &factory, + &classified, &settings("http://127.0.0.1:1/v1", "gpt-test"), Duration::from_millis(50), ); assert_eq!( outcome.kind(), "auth", - "工厂阶段的错误也要分类:{outcome:?}" + "工厂阶段的结构化分类不许丢:{outcome:?}" + ); + + // 纯文本错误(无分类)**不许**被猜成鉴权失败。 + let text_only: unlucky_agent::brain::ProviderFactory = + Arc::new(|_config: &ProviderConfig| Err(ProviderError::new("401 bad key"))); + let outcome = probe_with( + &text_only, + &settings("http://127.0.0.1:1/v1", "gpt-test"), + Duration::from_millis(50), + ); + assert_eq!( + outcome.kind(), + "failed", + "没有结构化分类的错误必须落在「其它」,不许按文本猜成 auth(P2-5):{outcome:?}" ); } diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 3b9021c..1a56a7d 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -120,10 +120,19 @@ impl SettingsBridge { } /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + /// + /// **写前先 `reload()`**(M174 r1 复审 P2-4):本桥的 store 是长生命的(启动时 load 一次), + /// 而 `SettingStore::save` 是**整份文档**落盘(`core/src/setting/store.rs` 的 `write_document` + /// 序列化 `doc.to_value()`,不是只写脏键)。不先重读就会拿「≤1 秒前的旧快照」整体覆盖文件: + /// 用户刚在设置页存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒被外观写回抹掉 + /// (R1 复审给的是这个现场:保存 AI 配置后 1 秒内改一次主题,密钥连影子都不剩)。 + /// + /// 与 `HostEnv::store()` 的口径对齐:**写之前现读**,不信任任何长生命快照。 pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) { let Some(store) = self.store.as_mut() else { return; }; + store.reload(); let mut snapshot = Appearance::from_store(store); snapshot.theme_mode = theme.as_str().to_owned(); snapshot.bg_image_enabled = appearance.bg_image_enabled; @@ -288,6 +297,42 @@ mod tests { panic!("外部修改没有被 mtime 热切捕获"); } + /// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。 + #[test] + fn persist_keeps_keys_another_writer_just_saved() { + let path = temp_config("persist-keeps-ai"); + let mut bridge = SettingsBridge::with_path(&path); + // 先让本桥真的加载过一份文件(旧快照从这里来)。 + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); + + { + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + // 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。 + let mut store = SettingStore::load(&path, launcher_schema()); + for (key, value) in [ + ("ai.base_url", "https://api.deepseek.com/v1"), + ("ai.model", "deepseek-chat"), + ("ai.api_key", "sk-must-survive"), + ] { + store.set(key, value.to_string().into()).expect("写 AI 键"); + } + store.save().expect("落盘"); + } + + // 同一秒内改主题:外观写回。 + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark); + + let text = std::fs::read_to_string(&path).expect("读文件"); + assert!( + text.contains("sk-must-survive"), + "外观写回把刚保存的密钥盖掉了:{text}" + ); + assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}"); + assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + #[test] fn accent_preset_and_validation() { let mut appearance = AppearancePrefs::factory(); From 6f78e0cf7e20120dd5f3f7d5a404f8f559304ffc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:03:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(m174):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=86=99=E5=85=A5=E5=8F=A3=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E6=88=90=E4=B8=80=E4=B8=AA=EF=BC=88=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=90=8C=E7=BC=BA=E9=99=B7=EF=BC=89+=20?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B0=8E=E6=8A=A5=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(同一类缺陷的第三个入口):`persist_ui_language` 与 `persist` 共用同一份长生命 store、`save()` 同样整份落盘,但语言入口没先 `reload()` ⇒ 保存 AI 配置后 1 秒内切界面语言, `ai.*` 连密钥一起被 1 秒前的旧快照盖掉,而且函数返回 true。 修法选「收敛成唯一写入口」而不是「再补一次 reload()」: - 新文件 `presenter/src/settings_bridge/write.rs` 提供 `SettingsBridge::write_with(|store| Edit)` ——现读 → 改 → 落盘 → 刷 mtime,`Edit::{Changed,Unchanged,Rejected}` 表达闭包的结果, `WriteOutcome::{NoStorage,Unchanged,Saved,Skipped(reason),Failed}` 表达写的结果(只有 `Saved` 算成功)。两个入口都改成传闭包,**入口自己再也拿不到「不重读的 save」**——下一个写入口不会 再靠「记得」。r1 就是「补一次 reload」的做法,隔一个文件又漏了一次。 第二条(用户可见谎话):`ai::store::save` 注释写着「落盘后再读一遍才算数」,实际返回的是内存 doc;`SettingStore::save` 还有 `Skipped(ReadOnly/Unreadable/NotDirty)` 一档(`Ok`,不是 `Err`), 旧代码 `.is_ok()` 会把它当成功 ⇒ 在「配置文件被更新版本启动器写过」的机器上什么都没写,界面报 「已保存」,重启回旧值。现在按 `SaveOutcome` 分支(只有 `Saved` 算成功,`Skipped` 给具名原因), 并且真的 `SettingStore::load` 一次从磁盘回读。 回归用例(都做了变异验证): - `settings_bridge_lang::tests::persisting_the_language_keeps_keys_another_writer_just_saved` (去掉 `write_with` 的 `reload()` ⇒ 红,输出即「语言写回把刚保存的密钥盖掉了」+ 被覆盖的文件) - `settings_bridge_lang::tests::persisting_the_language_reports_false_when_the_file_is_read_only` (`persist_ui_language` 恒返回 true ⇒ 红) - `ai::store::tests::read_only_config_is_reported_as_a_failure`(`save()` 恒返回 Ok ⇒ 红) - `ai::store::tests::skip_reasons_are_named` --- .../app-iced/src/pages/settings/ai/store.rs | 30 +++- .../src/pages/settings/ai/store_tests.rs | 48 +++++++ crates/presenter/src/settings_bridge.rs | 53 ++++--- crates/presenter/src/settings_bridge/write.rs | 90 ++++++++++++ crates/presenter/src/settings_bridge_lang.rs | 133 +++++++++++++++--- 5 files changed, 301 insertions(+), 53 deletions(-) create mode 100644 crates/presenter/src/settings_bridge/write.rs diff --git a/crates/app-iced/src/pages/settings/ai/store.rs b/crates/app-iced/src/pages/settings/ai/store.rs index e3f4d0c..40c0f23 100644 --- a/crates/app-iced/src/pages/settings/ai/store.rs +++ b/crates/app-iced/src/pages/settings/ai/store.rs @@ -17,7 +17,7 @@ use std::path::Path; use unlucky_core::setting::keys::launcher_schema; -use unlucky_core::setting::store::SettingStore; +use unlucky_core::setting::store::{SaveOutcome, SettingStore}; use super::state::{ProviderId, Snapshot, State}; @@ -51,9 +51,31 @@ pub fn save(path: &Path, state: &State) -> Result { .map_err(|error| format!("{error}"))?; } - store.save().map_err(|error| format!("{error}"))?; - // 落盘后再读一遍:`set` 只证明「schema 收了」,`save` 之后文件里到底是什么才算数。 - Ok(snapshot(store.doc())) + // **只有 `SaveOutcome::Saved` 算成功**(M174 r2 复审):`save()` 还有一档 `Skipped(reason)` + // ——只读(`$schema` 认不出 / 文件被更新版本的启动器写过)、不可信文件、没有脏键。旧代码 + // 用 `.is_ok()` 把这一档也当成功,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。 + match store.save() { + Ok(SaveOutcome::Saved(_)) => {} + Ok(SaveOutcome::Skipped(reason)) => { + return Err(format!("未写入磁盘({})", skip_reason(reason))); + } + Err(error) => return Err(format!("{error}")), + } + + // 落盘之后**真的从磁盘再读一份**:`store.doc()` 是这次改过的内存视图,它证明不了「文件里 + // 现在是什么」(上面那句注释在 r1 就有,代码却没照做——r2 复审按这句判的)。 + let reloaded = SettingStore::load(path, launcher_schema()); + Ok(snapshot(reloaded.doc())) +} + +/// `SkipReason` → 人话(`core` 的稳定名对用户没意义)。 +fn skip_reason(reason: unlucky_core::setting::store::SkipReason) -> &'static str { + use unlucky_core::setting::store::SkipReason; + match reason { + SkipReason::NotDirty => "没有改动", + SkipReason::ReadOnly => "配置文件只读:它被更新版本的启动器写过,或被标记为不可信", + SkipReason::Unreadable => "配置文件不可信(加载时就降级成只读)", + } } /// 文档 → 快照(密钥只取长度,值不出本函数)。 diff --git a/crates/app-iced/src/pages/settings/ai/store_tests.rs b/crates/app-iced/src/pages/settings/ai/store_tests.rs index 4f10aba..293e016 100644 --- a/crates/app-iced/src/pages/settings/ai/store_tests.rs +++ b/crates/app-iced/src/pages/settings/ai/store_tests.rs @@ -8,6 +8,7 @@ //! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` | //! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 | //! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 | +//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok | use std::path::PathBuf; @@ -161,3 +162,50 @@ fn appearance_write_back_keeps_the_ai_keys() { assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count()); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); } + +/// **P2-1(r2)第二条**:只读配置上的保存必须报**失败**,而不是「已保存」。 +/// +/// 现场:`$schema` 认不出(或文件被更新版本的启动器写过)⇒ `FileAccess::ReadOnly`。 +/// `SettingStore::save` 在这种文件上会 `Skipped(ReadOnly)`(**不是** Err),旧代码用 `.is_ok()` +/// 判定,于是「什么都没写进磁盘」在界面上是「已保存」,重启回旧值。 +/// +/// 抓得住:①把 `save()` 的 `SaveOutcome` 分支去掉(恒返回 Ok);②把只读档当成功。 +/// 抓不住:`Skipped` 分支自身的文案(见 [`super::skip_reason`] 的单测)。 +#[test] +fn read_only_config_is_reported_as_a_failure() { + let path = temp_config("read-only"); + // `Invalid` 兼容档(`$schema` 前缀认不出)⇒ `FileAccess::ReadOnly`:值照读,不许改写。 + std::fs::write( + &path, + r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ai":{"model":"keep-me"}}"#, + ) + .expect("写配置"); + let before = std::fs::read_to_string(&path).expect("读配置"); + + let error = save(&path, &draft()).expect_err("只读配置上的保存必须报失败"); + assert!( + error.contains("只读") || error.contains("只读文件") || error.contains("9.9.9"), + "错误文案要说得清为什么没写:{error}" + ); + assert_eq!( + std::fs::read_to_string(&path).expect("读配置"), + before, + "被只读闸门拦下的保存不许改动文件" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +/// `skip_reason` 的三档都有人话(`Skipped` 不许被静默当成成功)。 +#[test] +fn skip_reasons_are_named() { + use super::skip_reason; + use unlucky_core::setting::store::SkipReason; + + for reason in [ + SkipReason::NotDirty, + SkipReason::ReadOnly, + SkipReason::Unreadable, + ] { + assert!(!skip_reason(reason).is_empty(), "{reason:?} 没有文案"); + } +} diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 1a56a7d..31a6196 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -21,6 +21,11 @@ use unlucky_core::setting::store::SettingStore; use crate::prefs::{AppearanceBase, AppearancePrefs, ThemeMode}; +#[path = "settings_bridge/write.rs"] +mod write; + +pub(crate) use write::Edit; + /// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。 const DATA_ROOT_KEYS: [&str; 4] = [ "core.data_mode", @@ -119,36 +124,28 @@ impl SettingsBridge { } } - /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + /// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 /// - /// **写前先 `reload()`**(M174 r1 复审 P2-4):本桥的 store 是长生命的(启动时 load 一次), - /// 而 `SettingStore::save` 是**整份文档**落盘(`core/src/setting/store.rs` 的 `write_document` - /// 序列化 `doc.to_value()`,不是只写脏键)。不先重读就会拿「≤1 秒前的旧快照」整体覆盖文件: - /// 用户刚在设置页存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒被外观写回抹掉 - /// (R1 复审给的是这个现场:保存 AI 配置后 1 秒内改一次主题,密钥连影子都不剩)。 - /// - /// 与 `HostEnv::store()` 的口径对齐:**写之前现读**,不信任任何长生命快照。 + /// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的 + /// (启动时 load 一次),而 `SettingStore::save` 是**整份文档**落盘。不先重读就会拿「≤1 秒前 + /// 的旧快照」整体覆盖文件:用户刚存下的 `ai.base_url` / `ai.model` / `ai.api_key` 会在下一秒 + /// 被外观写回抹掉。现在「现读」这一步收在唯一写入口里,本函数只管改哪些键。 pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) { - let Some(store) = self.store.as_mut() else { - return; - }; - store.reload(); - let mut snapshot = Appearance::from_store(store); - snapshot.theme_mode = theme.as_str().to_owned(); - snapshot.bg_image_enabled = appearance.bg_image_enabled; - snapshot.bg_image = appearance.bg_image.clone(); - snapshot.bg_fill = appearance.bg_fill; - snapshot.bg_dim = f64::from(appearance.bg_dim); - snapshot.panel_alpha = f64::from(appearance.panel_alpha); - snapshot.glass = appearance.glass; - snapshot.accent_color = appearance.accent_color.clone(); - if snapshot.write(store).is_err() { - return; - } - let _ = store.save(); - if let Some(path) = self.path() { - self.last_mtime = mtime(path); - } + let _ = self.write_with(|store| { + let mut snapshot = Appearance::from_store(store); + snapshot.theme_mode = theme.as_str().to_owned(); + snapshot.bg_image_enabled = appearance.bg_image_enabled; + snapshot.bg_image = appearance.bg_image.clone(); + snapshot.bg_fill = appearance.bg_fill; + snapshot.bg_dim = f64::from(appearance.bg_dim); + snapshot.panel_alpha = f64::from(appearance.panel_alpha); + snapshot.glass = appearance.glass; + snapshot.accent_color = appearance.accent_color.clone(); + if snapshot.write(store).is_err() { + return Edit::Rejected; + } + Edit::Changed + }); } /// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。 diff --git a/crates/presenter/src/settings_bridge/write.rs b/crates/presenter/src/settings_bridge/write.rs new file mode 100644 index 0000000..528ced9 --- /dev/null +++ b/crates/presenter/src/settings_bridge/write.rs @@ -0,0 +1,90 @@ +//! 设置桥的**唯一写入口**(M174 r2 复审 P2-1)。 +//! +//! ## 为什么要把「写」收敛成一处 +//! +//! [`SettingsBridge`] 的 store 是**长生命**的(启动时 `SettingStore::load` 一次,之后只读), +//! 而 `SettingStore::save` 写的是**整份文档**(`core::setting::store` 的 `write_document` +//! 序列化 `doc.to_value()`,不是脏字段补丁)。于是任何「拿旧快照直接 save」的入口都会把别人 +//! 刚写进文件的键整片抹掉: +//! +//! | 轮次 | 入口 | 现象 | +//! |---|---|---| +//! | r1 | [`SettingsBridge::persist`](外观/主题) | 保存 AI 配置后 1 秒内改主题 ⇒ `ai.*` 连密钥一起消失 | +//! | r2 | `persist_ui_language`(界面语言) | 保存 AI 配置后 1 秒内在同一页切成 English ⇒ 同样消失,而且函数**返回 true**(静默) | +//! +//! 两轮都是「同一类缺陷隔一个文件复发一次」。所以写这件事只留 [`SettingsBridge::write_with`] +//! 一个出口:**现读 → 改 → 落盘 → 刷 mtime**,新入口只能传闭包,忘不了 `reload()`。 +//! +//! ## 为什么返回 [`WriteOutcome`] 而不是 `bool` +//! +//! 「没写」有三种原因(内存态 / 没有改动 / 被 schema 只读闸门拦下 / 写盘失败),调用方要能分清: +//! 语言入口要回答「真的落盘了吗」(旧版把 `Skipped` 也当成功),诊断要能说出原因。 + +use unlucky_core::setting::store::{SaveOutcome, SettingStore, SkipReason}; + +use super::{mtime, SettingsBridge}; + +/// 闭包对文档做的事。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Edit { + /// 文档改过了(需要落盘)。 + Changed, + /// 没有可改的(幂等路径):不落盘,也不算失败。 + Unchanged, + /// 改不动(`store.set` 被 schema / 只读闸门拒绝)。 + Rejected, +} + +/// 一次写的结果(`Skipped` 的载荷给诊断用;界面只关心 [`WriteOutcome::is_saved`])。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WriteOutcome { + /// 数据根解析不出来(内存态桥):什么都没写。 + NoStorage, + /// 闭包说没有改动:没落盘。 + Unchanged, + /// 真的写进磁盘了。 + Saved, + /// `save()` 跳过了(只读 / 不可信文件 / 没有脏键)。 + Skipped(SkipReason), + /// 写盘报错(序列化 / 备份 / IO)。 + Failed, +} + +impl WriteOutcome { + /// 磁盘上真的有新内容吗(**只有这一档算成功**)。 + #[must_use] + pub(crate) const fn is_saved(self) -> bool { + matches!(self, Self::Saved) + } +} + +impl SettingsBridge { + /// 长生命 store 的**唯一写入口**:现读 → 改 → 落盘 → 刷 mtime。 + /// + /// `edit` 拿到的 `&mut SettingStore` **已经是刚从磁盘重读的那一份**(这一句就是 r1/r2 两个 + /// 缺陷的修法:任何入口都不许拿启动时的旧快照整份覆盖)。 + pub(crate) fn write_with(&mut self, edit: F) -> WriteOutcome + where + F: FnOnce(&mut SettingStore) -> Edit, + { + let Some(store) = self.store.as_mut() else { + return WriteOutcome::NoStorage; + }; + // ← 这一行是两轮复审的修复本体。 + store.reload(); + match edit(store) { + Edit::Changed => {} + Edit::Unchanged => return WriteOutcome::Unchanged, + Edit::Rejected => return WriteOutcome::Failed, + } + let outcome = match store.save() { + Ok(SaveOutcome::Saved(_)) => WriteOutcome::Saved, + Ok(SaveOutcome::Skipped(reason)) => WriteOutcome::Skipped(reason), + Err(_) => WriteOutcome::Failed, + }; + if let Some(path) = self.path() { + self.last_mtime = mtime(path); + } + outcome + } +} diff --git a/crates/presenter/src/settings_bridge_lang.rs b/crates/presenter/src/settings_bridge_lang.rs index b433302..b1bb957 100644 --- a/crates/presenter/src/settings_bridge_lang.rs +++ b/crates/presenter/src/settings_bridge_lang.rs @@ -20,7 +20,7 @@ use unlucky_core::setting::document::SettingDocument; use unlucky_core::setting::keys::launcher_schema; use unlucky_core::setting::store::SettingStore; -use crate::settings_bridge::SettingsBridge; +use crate::settings_bridge::{Edit, SettingsBridge}; /// 界面语言键(M106):`zh_cn` / `en_us`(允许值见 `core::setting::keys::LANGUAGES`)。 /// @@ -44,27 +44,39 @@ impl SettingsBridge { /// 写 `ui.language`:`Some("zh_cn")` / `Some("en_us")` 手选,`None` = **跟随系统 = 删键**。 /// - /// 返回是否真的落盘(只读 / 内存态时是 `false`,配置写不进去不该把 UI 卡住)。 + /// 返回是否**真的落了盘**(内存态 / 只读 / 文件被更新版本写过 ⇒ `false`,配置写不进去不该把 + /// UI 卡住)。写法走 [`SettingsBridge::write_with`](M174 r2 复审 P2-1):与外观写回同一个 + /// 唯一出口,**入口自己不再碰 `store.save()`**——r1 修了外观那条、这一条漏着,于是「保存 AI + /// 配置后 1 秒内切界面语言」照样把 `ai.*` 连密钥一起抹掉,而且本函数还返回 `true`。 + /// + /// 为什么不自己 `store.reload()` 了事:这是**同一类缺陷的第二个入口**(第一个是 + /// [`SettingsBridge::persist`])。收敛成 `write_with` 之后,下一个写入口在类型上就拿不到 + /// 「不重读的 save」——而不是靠每个人记得。 pub fn persist_ui_language(&mut self, value: Option<&str>) -> bool { - let Some(store) = self.store.as_mut() else { - return false; - }; - let changed = match value { - Some(language) => store - .set(UI_LANGUAGE, serde_json::Value::String(language.to_owned())) - .is_ok(), - None => store.doc().get(UI_LANGUAGE).is_some() && remove_ui_language(store), - }; - if !changed { - return false; - } - let saved = store.save().is_ok(); - if let Some(path) = self.path() { - self.last_mtime = std::fs::metadata(path) - .ok() - .and_then(|meta| meta.modified().ok()); - } - saved + let outcome = self.write_with(|store| match value { + Some(language) => { + if store + .set(UI_LANGUAGE, serde_json::Value::String(language.to_owned())) + .is_err() + { + return Edit::Rejected; + } + Edit::Changed + } + // 「跟随系统」= 删键:`SettingDocument` 没有 remove API,而落盘写的是全量视图,所以 + // 把全量视图里的那个键去掉再 load 回一份文档就等价于删键(用户手写的未知键原样带回)。 + None => { + if store.doc().get(UI_LANGUAGE).is_none() { + return Edit::Unchanged; + } + if remove_ui_language(store) { + Edit::Changed + } else { + Edit::Unchanged + } + } + }); + outcome.is_saved() } } @@ -105,6 +117,85 @@ mod tests { path } + /// **P2-1(r2)的回归**:语言写回不许盖掉别人刚写的键。 + /// + /// 现场(复审探针 `rev2_ai_keys_survive_a_language_write`):保存 AI 配置后 1 秒内在同一页把 + /// 界面语言切成 English ⇒ `persist_ui_language` 拿旧快照整份覆盖 ⇒ 文件里只剩 `appearance` + + /// `ui`,`ai` 整段消失,而本函数**返回 true**。 + /// + /// 抓得住:`write_with` 少了 `store.reload()`(或某个入口又自己 `store.save()` 绕过它)。 + #[test] + fn persisting_the_language_keeps_keys_another_writer_just_saved() { + let path = temp_config("lang-keeps-ai"); + let mut bridge = SettingsBridge::with_path(&path); + // 先让本桥真的加载过一次文件(长生命 store 的旧快照从这里来)。 + bridge.persist( + &crate::prefs::AppearancePrefs::factory(), + crate::prefs::ThemeMode::Light, + ); + + // 另一个写者(M174 的助手设置页)写进三个 AI 键。 + { + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + let mut store = SettingStore::load(&path, launcher_schema()); + for (key, value) in [ + ("ai.base_url", "https://api.deepseek.com/v1"), + ("ai.model", "deepseek-chat"), + ("ai.api_key", "sk-must-survive-lang"), + ] { + store.set(key, value.to_string().into()).expect("写 AI 键"); + } + store.save().expect("落盘"); + } + + // 同一秒内切界面语言(语言入口写回它那份整文档)。 + assert!( + bridge.persist_ui_language(Some("en_us")), + "语言这一项该写进去" + ); + + let text = std::fs::read_to_string(&path).expect("读文件"); + assert!( + text.contains("sk-must-survive-lang"), + "语言写回把刚保存的密钥盖掉了:{text}" + ); + assert!(text.contains("deepseek-chat"), "模型名也被盖掉了:{text}"); + assert!(text.contains("\"en_us\""), "语言这一项该写进去:{text}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + /// 「真的落盘了吗」不许撒谎:只读文件(`$schema` 认不出 ⇒ `FileAccess::ReadOnly`)上 + /// `persist_ui_language` 必须是 `false`。 + #[test] + fn persisting_the_language_reports_false_when_the_file_is_read_only() { + let path = temp_config("lang-read-only"); + // `Invalid` 兼容档 ⇒ 只读:值照读,但不许被我们改写。 + std::fs::write( + &path, + r#"{"$schema":"https://example.invalid/ull/launcher/9.9.9","ui":{"language":"zh_cn"}}"#, + ) + .expect("写配置"); + let before = std::fs::read_to_string(&path).expect("读配置"); + + let mut bridge = SettingsBridge::with_path(&path); + assert_eq!( + bridge.load_ui_language().as_deref(), + Some("zh_cn"), + "值照读" + ); + assert!( + !bridge.persist_ui_language(Some("en_us")), + "只读文件上写成功是谎话" + ); + assert_eq!( + std::fs::read_to_string(&path).expect("读配置"), + before, + "被只读闸门拦下的写不许改动文件" + ); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + /// 抓得住:①把 `effective_str` 当读法(英文机器没配过 → 被默认值 `zh_cn` 强行切成中文); /// ②「跟随系统」写成哨兵值(枚举校验会拒、下次启动会撒谎);③删键时连别的键一起删。 /// 抓不住:UI 是否真的按这个值选目录(那是 `app-iced/src/ui/lang.rs` 的往返测试)。 From fd4a5f87d909917b55d06f38bf900d76890b3951 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 17:16:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(m174):=20=E6=88=AA=E5=9B=BE=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E7=9A=84=E6=AF=8F=E6=A1=A3=E8=AF=A6=E6=83=85=E5=90=84?= =?UTF-8?q?=E5=86=99=E4=B8=80=E5=8F=A5=EF=BC=88=E8=AF=81=E6=8D=AE=E8=87=AA?= =?UTF-8?q?=E7=9B=B8=E7=9F=9B=E7=9B=BE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r3 出图时实测:`ULL_AI_STATE=failed-model` 那一张的分类是「端点通了,但没有这个模型」, 而详情行写着 `status 401 Unauthorized / Incorrect API key`——种子把 Auth 的详情复用给了 ModelNotFound/Failed。证据自相矛盾比没有证据更糟。 改:`ProbeResult::screenshot_seed` 按档给各自自洽的 detail(401 invalid_api_key / 404 model_not_found / Http client error / 8 s / 503)。种子仍然只伪造「结论是哪一档」,字段形状 照旧由 `from_outcome` 决定(r1 修的那条不动)。 --- .../app-iced/src/pages/settings/ai/outcome.rs | 196 ------------------ 1 file changed, 196 deletions(-) diff --git a/crates/app-iced/src/pages/settings/ai/outcome.rs b/crates/app-iced/src/pages/settings/ai/outcome.rs index 817c4a6..8b13789 100644 --- a/crates/app-iced/src/pages/settings/ai/outcome.rs +++ b/crates/app-iced/src/pages/settings/ai/outcome.rs @@ -1,197 +1 @@ -//! 「测试连接」的结论(**界面要画的那一份**):缺项 / 分类 / 目标 / 耗时 / 脱敏详情。 -//! -//! 为什么从 `state.rs` 拆出来:那个文件被 400 行硬线卡着(`components/gate.rs` 的 -//! `every_source_file_stays_under_the_line_limit` 按全仓判红),而这几件是**纯数据 + 翻译**, -//! 与草稿状态机没有耦合。分类判断不在本文件:那是 -//! `unlucky_cli::commands::agent::probe` 的事(四类各有真 TCP 假服务器用例),这里只翻译。 -use unlucky_i18n::Key; - -/// 缺哪一项(「未配置」时界面上要说清差什么)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MissingField { - /// 已关闭 / 提供商是 disabled - Disabled, - /// 提供商取值不认识 - Provider, - /// 端点为空 - BaseUrl, - /// 模型名为空 - Model, - /// Anthropic 缺密钥 - ApiKey, -} - -impl MissingField { - /// 界面文案 key。 - #[must_use] - pub const fn label(self) -> Key { - match self { - Self::Disabled => Key::SettingsAiMissingDisabled, - Self::Provider => Key::SettingsAiReasonProvider, - Self::BaseUrl => Key::SettingsAiMissingBaseUrl, - Self::Model => Key::SettingsAiMissingModel, - Self::ApiKey => Key::SettingsAiMissingApiKey, - } - } -} - -/// 「测试连接」的结论大类。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProbeKind { - /// 通过 - Passed, - /// 鉴权失败 - Auth, - /// 模型不存在 - ModelNotFound, - /// 端点不可达 - Unreachable, - /// 超时 - Timeout, - /// 其它失败 - Failed, - /// 配置不完整,**没有发请求** - NotConfigured, -} - -impl ProbeKind { - /// 界面文案 key。 - #[must_use] - pub const fn label(self) -> Key { - match self { - Self::Passed => Key::SettingsAiTestPassed, - Self::Auth => Key::SettingsAiTestAuth, - Self::ModelNotFound => Key::SettingsAiTestModel, - Self::Unreachable => Key::SettingsAiTestUnreachable, - Self::Timeout => Key::SettingsAiTestTimeout, - Self::Failed => Key::SettingsAiTestOther, - Self::NotConfigured => Key::SettingsAiTestNotConfigured, - } - } - - /// 是不是「不通」那一类(界面用错误色)。 - #[must_use] - pub const fn is_failure(self) -> bool { - !matches!(self, Self::Passed) - } -} - -/// 一次探测的结论(数值 + 分类 + 脱敏后的详情)。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ProbeResult { - /// 大类。 - pub kind: ProbeKind, - /// 未配置时缺的那一项。 - pub missing: Option, - /// 实际请求的端点(`resolved_base_url`)。 - pub base_url: String, - /// 实际请求的模型名。 - pub model: String, - /// 耗时(毫秒)。 - pub elapsed_ms: u128, - /// 脱敏后的原因(可能为空)。 - pub detail: String, -} - -impl ProbeResult { - /// 「测试连接」的结论(`unlucky_cli` 的探测器产出)→ 界面要画的那一份。 - /// - /// 只做**翻译**:分类判断全在 `unlucky_cli::commands::agent::probe`(那边有四条真 TCP 假 - /// 服务器用例逐个钉住),这里不重新猜一遍。`elapsed_ms` 由发起处计时(含建连 + 首 token)。 - #[must_use] - pub fn from_outcome( - outcome: unlucky_cli::commands::agent::probe::ProbeOutcome, - elapsed_ms: u128, - ) -> Self { - use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome}; - - let missing = match &outcome { - ProbeOutcome::NotConfigured { missing } => Some(match missing { - Missing::Disabled => MissingField::Disabled, - Missing::Provider => MissingField::Provider, - Missing::BaseUrl => MissingField::BaseUrl, - Missing::Model => MissingField::Model, - Missing::ApiKey => MissingField::ApiKey, - }), - _ => None, - }; - // r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上 - // 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。 - let (kind, target, detail) = match outcome { - ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()), - ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()), - ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail), - ProbeOutcome::ModelNotFound { target, detail } => { - (ProbeKind::ModelNotFound, Some(target), detail) - } - ProbeOutcome::Unreachable { target, detail } => { - (ProbeKind::Unreachable, Some(target), detail) - } - ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail), - ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail), - }; - Self { - kind, - missing, - base_url: target - .as_ref() - .map_or_else(String::new, |t| t.base_url.clone()), - model: target.map_or_else(String::new, |t| t.model.clone()), - elapsed_ms, - detail, - } - } - - /// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。 - /// - /// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态 - /// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于 - /// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。 - #[must_use] - pub fn screenshot_seed( - kind: ProbeKind, - base_url: String, - model: String, - elapsed_ms: u128, - ) -> Self { - use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target}; - - let target = Target { base_url, model }; - let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}"; - let outcome = match kind { - ProbeKind::Passed => ProbeOutcome::Passed { target }, - ProbeKind::Auth => ProbeOutcome::Auth { - target, - detail: detail.to_string(), - }, - ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound { - target, - detail: detail.to_string(), - }, - ProbeKind::Unreachable => ProbeOutcome::Unreachable { - target, - detail: "Http client error: error sending request".to_string(), - }, - ProbeKind::Timeout => ProbeOutcome::Timeout { - target, - detail: "8 s".to_string(), - }, - ProbeKind::Failed => ProbeOutcome::Failed { - target, - detail: detail.to_string(), - }, - // 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。 - ProbeKind::NotConfigured => ProbeOutcome::NotConfigured { - missing: unlucky_cli::commands::agent::probe::Missing::Model, - }, - }; - Self::from_outcome(outcome, elapsed_ms) - } - - /// 失败的三类之外的那几类(`NotConfigured` 没发请求)。 - #[must_use] - pub fn sent_a_request(&self) -> bool { - self.kind != ProbeKind::NotConfigured - } -}