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 804fa3d..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)。 @@ -54,6 +59,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 { @@ -174,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 @@ -193,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(()) } @@ -239,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 { @@ -264,77 +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 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/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..314bd49 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,170 @@ -//! 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_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(secret) => { + self.settings.ai_about.key = secret; + 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..248897f 100644 --- a/crates/app-iced/src/app/update/settings/tests.rs +++ b/crates/app-iced/src/app/update/settings/tests.rs @@ -265,3 +265,124 @@ 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(pages::settings::ai::Secret::new( + "sk-live-app-path", + )))); + 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..8b13789 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/outcome.rs @@ -0,0 +1 @@ + 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..8525143 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/state.rs @@ -0,0 +1,387 @@ +//! 「助手」分组的运行期状态:四条草稿 + 一颗探测结论 + 校验。 +//! +//! 状态住在 `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 = 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; + // r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定, + // 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」 + // 这类缺陷只在产品上出现、截图永远看不见。 + state.probe = Probe::Done(ProbeResult::screenshot_seed( + kind, + state.resolved_base_url(), + state.model.clone(), + 412, + )); + } + } + 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..40c0f23 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/store.rs @@ -0,0 +1,100 @@ +//! 「助手」分组的**真读写**:`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::{SaveOutcome, 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}"))?; + } + + // **只有 `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 => "配置文件不可信(加载时就降级成只读)", + } +} + +/// 文档 → 快照(密钥只取长度,值不出本函数)。 +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..293e016 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/store_tests.rs @@ -0,0 +1,211 @@ +//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。 +//! +//! | 用例 | 守什么 | 变异 | +//! |---|---|---| +//! | `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` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 | +//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 | +//! | `read_only_config_is_reported_as_a_failure` | 只读配置上保存**不许**报成功 | `save()` 恒返回 Ok | + +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(); +} + +/// **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(); +} + +/// **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/app-iced/src/pages/settings/ai/tests.rs b/crates/app-iced/src/pages/settings/ai/tests.rs new file mode 100644 index 0000000..9bef712 --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/tests.rs @@ -0,0 +1,390 @@ +//! 「助手」分组状态机的用例(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()); +} + +/// **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(); + 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); +} + +/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。 +/// +/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷—— +/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。 +#[test] +fn failures_carry_the_target_that_was_dialed() { + use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target}; + + 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!(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_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()); + } + } +} + +#[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); + // 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(), + "未知种子不伪造状态" + ); +} + +#[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..542ea6f --- /dev/null +++ b/crates/app-iced/src/pages/settings/ai/view.rs @@ -0,0 +1,360 @@ +//! 「助手」分组的一张卡片(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, Secret, 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)); + // 目标行只在真有值时才画(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, + 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(Secret::new(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..4f59f95 100644 --- a/crates/app-iced/src/pages/settings/ai_about.rs +++ b/crates/app-iced/src/pages/settings/ai_about.rs @@ -1,39 +1,79 @@ -//! 设置页「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), + /// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。 + /// + /// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness + /// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里, + /// 用裸 `String` 等于给密钥开了一条到日志的路。 + EditKey(super::ai::Secret), + /// 密钥输入框明文/掩码 + 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..40c1bff --- /dev/null +++ b/crates/cli/src/commands/agent/probe.rs @@ -0,0 +1,361 @@ +//! 「测试连接」(M174):拿 `config/launcher.json` 里的 AI 四项,走**现有 provider 实现** +//! ([`RigProvider`])发一次最小请求,并把失败分成四类。 +//! +//! ## 分类从哪来(r1 复审 P2-5 之后) +//! +//! **不在本模块猜文本**:分类由 `unlucky_agent::brain::rig::map_error` 从 rig 0.45 的结构化 +//! 报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成 +//! [`ProviderFailure`],本模块只做「枚举 → 界面结论」的映射: +//! +//! | [`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`] | +//! +//! ## 三条硬纪律 +//! +//! 1. **配置不完整时一个请求都不发**:[`ProbeOutcome::NotConfigured`] 在造 provider **之前**返回, +//! 连工厂都不会被调用(守卫②:假 provider 的构造计数必须是 0)。 +//! 2. **密钥永不外流**:`detail` 出本模块前先过 [`unlucky_agent::redact::redact_text`],再把 +//! **本次的密钥原文**替换成 [`MASK`]——即使服务端把 key 回显在错误体里,界面上也看不到它。 +//! [`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, ProviderFailure, 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;再长用户会以为按钮没反应。 +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(8); + +/// 探测请求只发这一句:够触发一次真实补全,又不烧 token。 +const PING: &str = "ping"; + +/// 从设置文件读出来的 AI 四项(快照)。 +/// +/// `api_key` 在这里是**明文**(要用它发请求),所以 `Debug` 手写成掩码。 +#[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", + } + } +} + +/// 被请求的那一份配置(端点 + 模型):失败结论也要带它,用户才知道「改的是不是这一份」。 +#[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 { + /// 实际请求的目标。 + target: Target, + }, + /// 鉴权失败(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, + }, + /// 其它(协议 / 解析 / 5xx):不硬塞进上面四类。 + Failed { + /// 实际请求的目标。 + target: Target, + /// 脱敏后的原因。 + 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", + } + } + + /// 这条结论是关于哪个端点/模型的(`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), + } + } +} + +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_bool("ai.enabled").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 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, target), + }; + + let request = ChatRequest { + model: target.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 { target }, + Ok(Err(error)) => outcome_from_error(&error, settings, target), + Err(_) => ProbeOutcome::Timeout { + target, + detail: format!("{} s", timeout.as_secs_f32()), + }, + } + }) +} + +/// provider 错误 → 分类结论(`detail` 先脱敏再出门;分类来自 [`ProviderError::kind`])。 +fn outcome_from_error( + error: &ProviderError, + settings: &AiSettings, + target: Target, +) -> ProbeOutcome { + let detail = scrub(&error.to_string(), settings.secret()); + 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 }, + } +} + +/// 文本脱敏:先走 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..0d56513 --- /dev/null +++ b/crates/cli/src/commands/agent/probe/tests.rs @@ -0,0 +1,287 @@ +//! 「测试连接」的守卫(M174)。四类失败**各有一条真 TCP 假服务器**用例——不是拿字符串假装的。 +//! +//! | 用例 | 守什么 | 变异(改成什么会红) | +//! |---|---|---| +//! | `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(_))` 当成失败 | +//! | `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}; +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, +} + +/// 一条收到的请求:头 + 正文(P2-3:正文里才有 `model`)。 +#[derive(Clone)] +struct Hit { + head: String, + body: String, +} + +/// 一个只服务 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 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, + } + } + 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) => { + 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 { target, detail } = &outcome else { + panic!("401 必须归到鉴权失败,实际 {outcome:?}"); + }; + assert!(!detail.is_empty()); + // P2-1:失败结论必须说清「对哪个端点 / 哪个模型」。 + assert_eq!(target.base_url, server.base_url()); + assert_eq!(target.model, "gpt-test"); + // 请求确实发出去了(否则「鉴权失败」是凭空说的)。 + 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 必须归到模型不存在,实际 {outcome:?}" + ); + assert_eq!(outcome.target().map(|t| t.model.as_str()), Some("gpt-nope")); +} + +#[test] +fn closed_port_is_unreachable() { + // 绑一个端口再放掉:这个端口上没人监听 = 连接被拒。 + let port = { + 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(&base_url, "gpt-test"), + Duration::from_secs(5), + ); + assert_eq!( + outcome.kind(), + "unreachable", + "没人监听的端口必须归到端点不可达,实际 {outcome:?}" + ); + assert_eq!( + outcome.target().map(|t| t.base_url.as_str()), + Some(base_url.as_str()) + ); +} + +#[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 { target } => { + assert_eq!(target.base_url, server.base_url()); + assert_eq!(target.model, "gpt-test"); + } + other => panic!("200 + 合法 SSE 必须算通过,实际 {other:?}"), + } +} + +/// **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 new file mode 100644 index 0000000..5753c47 --- /dev/null +++ b/crates/cli/src/commands/agent/probe/tests_guards.rs @@ -0,0 +1,290 @@ +//! 「测试连接」的**不发请求**那一半守卫(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, 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.head.starts_with("POST /v1/chat/completions"), + "OpenAI 兼容形态请求的是 /v1/chat/completions,实际请求行:{}", + 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` 必须是掩码。 +#[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("写配置文件"); +} + +/// 工厂阶段的错误也按**结构化分类**走(r1 复审 P2-5 之后:这里不再看文本)。 +/// +/// 两条一起钉住: +/// 1. 工厂给的是 `classified(Auth, …)` ⇒ 结论就是 `auth`(分类信息不丢); +/// 2. 工厂给的是 `ProviderError::new("401 bad key")`(**纯文本、没有分类**)⇒ 结论是 `failed`, +/// **不是** `auth`。第二条是 P2-5 的绊线:谁把「按 `"401"` 子串猜类别」加回来,这条立刻红。 +#[test] +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( + &classified, + &settings("http://127.0.0.1:1/v1", "gpt-test"), + Duration::from_millis(50), + ); + assert_eq!( + outcome.kind(), + "auth", + "工厂阶段的结构化分类不许丢:{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/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(()) } diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 3b9021c..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,27 +124,28 @@ impl SettingsBridge { } } - /// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + /// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 + /// + /// **写走 [`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; - }; - 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`)改过就重读;返回新的生效值。 @@ -288,6 +294,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(); 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` 的往返测试)。