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
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:
commit
c72bf10690
31 files changed
+3616
-270
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! 里对 `Command::AgentServe` 提前分流,不走 `output::print_report`。
|
||||
|
||||
pub mod ports;
|
||||
pub mod probe;
|
||||
pub mod serve;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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", "管理版本与加载器:安装、升级、修复"),
|
||||
|
||||
@@ -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}"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +124,14 @@ 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 _ = 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;
|
||||
@@ -134,12 +142,10 @@ impl SettingsBridge {
|
||||
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);
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
let outcome = self.write_with(|store| match value {
|
||||
Some(language) => {
|
||||
if 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;
|
||||
.is_err()
|
||||
{
|
||||
return Edit::Rejected;
|
||||
}
|
||||
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());
|
||||
Edit::Changed
|
||||
}
|
||||
saved
|
||||
// 「跟随系统」= 删键:`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` 的往返测试)。
|
||||
|
||||
Reference in new issue
Block a user