Merge branch 'feat/m174-agentcfg-ai-provider-base-url-model'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-08 17:26:14 +08:00
commit c72bf10690
31 files changed
+3630 -284

No files matched your search

+1 -1
View File
@@ -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;
+70 -86
View File
@@ -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<Self> {
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<String>) -> Self {
Self {
kind: ProviderFailure::Other,
message: message.into(),
}
}
/// 带分类构造(`brain::rig` 的 `map_error` 与 [`ProviderConfig::validate`] 用)。
#[must_use]
pub fn classified(kind: ProviderFailure, message: impl Into<String>) -> 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;
@@ -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)
}
}
+107
View File
@@ -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}"
);
}
+60 -83
View File
@@ -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<dyn Error>`)。
///
/// 结构化判据(类型 + `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::<std::io::Error>() {
return io.kind() == std::io::ErrorKind::TimedOut;
}
source = cause.source();
}
false
}
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
@@ -287,82 +341,5 @@ fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
}
#[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;
+82
View File
@@ -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("对话历史为空"));
}
@@ -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();
+2
View File
@@ -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 {
@@ -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<Message> {
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<Message> {
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<Message> {
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)),
),
))
},
)
}
}
@@ -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();
}
@@ -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};
@@ -0,0 +1 @@
@@ -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<String>) -> 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<SaveNotice>,
/// 探测结论
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<MissingField> {
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<Self> {
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,
}
}
}
@@ -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<Snapshot, String> {
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;
@@ -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:?} 没有文案");
}
}
@@ -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<unlucky_i18n::Key> = 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:?} 下没有译文"
);
}
}
}
@@ -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 }),
)
}
+70 -30
View File
@@ -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<Element>`:拿不到 `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<super::ai::ProbeResult>),
}
impl Message {
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::AiAbout`)。
#[must_use]
pub fn into_group(self) -> super::GroupMessage {
super::GroupMessage::AiAbout(self)
}
}
+24 -6
View File
@@ -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
@@ -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(), "演示下标仍在范围内");
}
@@ -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());
+1
View File
@@ -9,6 +9,7 @@
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
pub mod ports;
pub mod probe;
pub mod serve;
use serde_json::{json, Value};
+361
View File
@@ -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<String>,
}
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<ProviderConfig, Missing> {
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;
@@ -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<Mutex<Vec<Hit>>>,
}
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<Hit> {
self.hits.lock().expect("锁").clone()
}
}
fn handle(mut stream: TcpStream, behaviour: &Behaviour, sink: &Arc<Mutex<Vec<Hit>>>) {
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::<usize>().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;
@@ -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<dyn ChatProvider>)
});
// 四种「不完整」:禁用 / 缺端点 / 缺模型 / 缺密钥(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:?}"
);
}
+53
View File
@@ -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", "管理版本与加载器:安装、升级、修复"),
+88 -5
View File
@@ -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}"),
} }
};
}
+22 -22
View File
@@ -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<Value, String> {
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(())
}
+62 -20
View File
@@ -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();
@@ -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<F>(&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
}
}
+112 -21
View File
@@ -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` 的往返测试)。