feat(m174): 助手(AI)提供商配置真接线 + 「测试连接」

设置页新增第 6 组「助手」:提供商四态 / 端点 base_url / 模型名 / API 密钥(默认掩码,
点「显示」才明文)+ 保存 + 测试连接,四类失败(鉴权 / 模型不存在 / 不可达 / 超时)各自
一句文案 + 目标与耗时。

取舍三条:

1. 读写走 `core::setting` 的 schema API(`SettingStore::load` 现读 → `set` → `save`),
   不经 `AppCommand::SetSetting`——那条指令是可序列化 DTO,`ai.api_key` 走它等于让明文进
   事件流/日志。与 `SettingsBridge::persist` 写外观是同一条做法。
2. 「测试连接」不另写一套 HTTP:`unlucky_cli::commands::agent::probe` 现读设置文件、
   交给 `RigProvider`(真 provider)发一次最小补全,带 8 秒时限;分类只看错误文本
   (rig 只给 Display),四类各有一条真 TCP 假服务器用例钉住。
3. `ProviderKind` 补 `from_setting` / `setting_value`:serde 的 `rename_all = snake_case`
   产出 `open_ai_compatible`,与 schema 的 `openai_compatible` 不是同一个词——这正是
   M174 之前「agent 侧一次都没读过设置」的根因。

守卫(都做变异验证,见 mission 报告):密钥不出 `Debug`/`detail`/DTO;未配置时工厂构造
计数为 0;改完 base_url 后探测打到新地址;空/非法值被 schema 拦下且文件不动。

i18n:49 条中英各一(金表按 `Key::ALL` 位置插入);presenter 的 ai_group 补上 schema 允许
的 `disabled` 选项与一行「已配置 N 字符」的只读状态(仍然不显示值)。
This commit is contained in:
AskaEth committed 2026-11-08 15:57:25 +08:00
1 parent 82f3bbbff6
commit 9cb605f316
23 files changed
+2939 -73

No files matched your search

@@ -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,163 @@
//! 「测试连接」的结论(**界面要画的那一份**):缺项 / 分类 / 目标 / 耗时 / 脱敏详情。
//!
//! 为什么从 `state.rs` 拆出来:那个文件被 400 行硬线卡着(`components/gate.rs` 的
//! `every_source_file_stays_under_the_line_limit` 按全仓判红),而这几件是**纯数据 + 翻译**,
//! 与草稿状态机没有耦合。分类判断不在本文件:那是
//! `unlucky_cli::commands::agent::probe` 的事(四类各有真 TCP 假服务器用例),这里只翻译。
use unlucky_i18n::Key;
/// 缺哪一项(「未配置」时界面上要说清差什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MissingField {
/// 已关闭 / 提供商是 disabled
Disabled,
/// 提供商取值不认识
Provider,
/// 端点为空
BaseUrl,
/// 模型名为空
Model,
/// Anthropic 缺密钥
ApiKey,
}
impl MissingField {
/// 界面文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::Disabled => Key::SettingsAiMissingDisabled,
Self::Provider => Key::SettingsAiReasonProvider,
Self::BaseUrl => Key::SettingsAiMissingBaseUrl,
Self::Model => Key::SettingsAiMissingModel,
Self::ApiKey => Key::SettingsAiMissingApiKey,
}
}
}
/// 「测试连接」的结论大类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeKind {
/// 通过
Passed,
/// 鉴权失败
Auth,
/// 模型不存在
ModelNotFound,
/// 端点不可达
Unreachable,
/// 超时
Timeout,
/// 其它失败
Failed,
/// 配置不完整,**没有发请求**
NotConfigured,
}
impl ProbeKind {
/// 界面文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::Passed => Key::SettingsAiTestPassed,
Self::Auth => Key::SettingsAiTestAuth,
Self::ModelNotFound => Key::SettingsAiTestModel,
Self::Unreachable => Key::SettingsAiTestUnreachable,
Self::Timeout => Key::SettingsAiTestTimeout,
Self::Failed => Key::SettingsAiTestOther,
Self::NotConfigured => Key::SettingsAiTestNotConfigured,
}
}
/// 是不是「不通」那一类(界面用错误色)。
#[must_use]
pub const fn is_failure(self) -> bool {
!matches!(self, Self::Passed)
}
}
/// 一次探测的结论(数值 + 分类 + 脱敏后的详情)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeResult {
/// 大类。
pub kind: ProbeKind,
/// 未配置时缺的那一项。
pub missing: Option<MissingField>,
/// 实际请求的端点(`resolved_base_url`)。
pub base_url: String,
/// 实际请求的模型名。
pub model: String,
/// 耗时(毫秒)。
pub elapsed_ms: u128,
/// 脱敏后的原因(可能为空)。
pub detail: String,
}
impl ProbeResult {
/// 「测试连接」的结论(`unlucky_cli` 的探测器产出)→ 界面要画的那一份。
///
/// 只做**翻译**:分类判断全在 `unlucky_cli::commands::agent::probe`(那边有四条真 TCP 假
/// 服务器用例逐个钉住),这里不重新猜一遍。`elapsed_ms` 由发起处计时(含建连 + 首 token)。
#[must_use]
pub fn from_outcome(
outcome: unlucky_cli::commands::agent::probe::ProbeOutcome,
elapsed_ms: u128,
) -> Self {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome};
let missing = match &outcome {
ProbeOutcome::NotConfigured { missing } => Some(match missing {
Missing::Disabled => MissingField::Disabled,
Missing::Provider => MissingField::Provider,
Missing::BaseUrl => MissingField::BaseUrl,
Missing::Model => MissingField::Model,
Missing::ApiKey => MissingField::ApiKey,
}),
_ => None,
};
let (kind, base_url, model, detail) = match outcome {
ProbeOutcome::NotConfigured { .. } => (
ProbeKind::NotConfigured,
String::new(),
String::new(),
String::new(),
),
ProbeOutcome::Passed { base_url, model } => {
(ProbeKind::Passed, base_url, model, String::new())
}
ProbeOutcome::Auth { detail } => {
(ProbeKind::Auth, String::new(), String::new(), detail)
}
ProbeOutcome::ModelNotFound { detail } => (
ProbeKind::ModelNotFound,
String::new(),
String::new(),
detail,
),
ProbeOutcome::Unreachable { detail } => {
(ProbeKind::Unreachable, String::new(), String::new(), detail)
}
ProbeOutcome::Timeout { detail } => {
(ProbeKind::Timeout, String::new(), String::new(), detail)
}
ProbeOutcome::Failed { detail } => {
(ProbeKind::Failed, String::new(), String::new(), detail)
}
};
Self {
kind,
missing,
base_url,
model,
elapsed_ms,
detail,
}
}
/// 失败的三类之外的那几类(`NotConfigured` 没发请求)。
#[must_use]
pub fn sent_a_request(&self) -> bool {
self.kind != ProbeKind::NotConfigured
}
}
@@ -0,0 +1,386 @@
//! 「助手」分组的运行期状态:四条草稿 + 一颗探测结论 + 校验。
//!
//! 状态住在 `pages::settings::Groups.ai_about`([`State`]),由 `App` 持有:
//! 用户正在输入的值**不能**放进 `pages::Model`(那个模型每次事件都整棵重建,见
//! `app/state.rs` 里 `download_draft` 的同一条理由)。
use unlucky_i18n::Key;
use super::outcome::{MissingField, ProbeKind, ProbeResult};
/// 四个提供商(值 = `ai.provider` 的词表,真源是 `core::setting::keys::AI_PROVIDERS`,
/// `ai/tests.rs` 里对表)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProviderId {
/// OpenAI 兼容(含 llama.cpp / LM Studio / DeepSeek / Qwen)
#[default]
OpenAiCompatible,
/// Anthropic 原生
Anthropic,
/// Ollama 原生
Ollama,
/// 禁用
Disabled,
}
impl ProviderId {
/// 四态(界面上的顺序 = 这张表)。
pub const ALL: [Self; 4] = [
Self::OpenAiCompatible,
Self::Anthropic,
Self::Ollama,
Self::Disabled,
];
/// `ai.provider` 的取值。
#[must_use]
pub const fn setting(self) -> &'static str {
match self {
Self::OpenAiCompatible => "openai_compatible",
Self::Anthropic => "anthropic",
Self::Ollama => "ollama",
Self::Disabled => "disabled",
}
}
/// 取值 → 四态(未知/空值回落 OpenAI 兼容,与 schema 的默认值同一档)。
#[must_use]
pub fn from_setting(value: &str) -> Self {
match value.trim() {
"anthropic" => Self::Anthropic,
"ollama" => Self::Ollama,
"disabled" => Self::Disabled,
_ => Self::OpenAiCompatible,
}
}
/// 该形态有没有统一默认端点(没有 ⇒ base_url 必填)。
#[must_use]
pub const fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "",
Self::Anthropic => "https://api.anthropic.com",
Self::Ollama => "http://127.0.0.1:11434",
Self::Disabled => "",
}
}
/// 界面标签 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::OpenAiCompatible => Key::SettingsAiProviderOpenAi,
Self::Anthropic => Key::SettingsAiProviderAnthropic,
Self::Ollama => Key::SettingsAiProviderOllama,
Self::Disabled => Key::SettingsAiProviderDisabled,
}
}
}
/// 用户刚输入的密钥草稿:`Debug` 掩码,只有 [`Secret::expose`] 能拿到明文。
///
/// 为什么不用裸 `String`:`Groups` / `State` 都 `derive(Debug)`,`App` 里任何一处 `{:?}`(日志、
/// panic 消息、测试断言失败输出)都会把明文打出来。类型自己守住这一条,比每次记得手写便宜。
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
/// 从输入框的内容构造。
#[must_use]
pub fn new(value: impl Into<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, missing) = match other {
"verified" => (ProbeKind::Passed, None),
"failed-auth" => (ProbeKind::Auth, None),
"failed-model" => (ProbeKind::ModelNotFound, None),
"failed-unreachable" => (ProbeKind::Unreachable, None),
"failed-timeout" => (ProbeKind::Timeout, None),
_ => return None,
};
state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = "deepseek-chat".to_string();
state.key_chars = 32;
state.probe = Probe::Done(ProbeResult {
kind,
missing,
base_url: state.resolved_base_url(),
model: state.model.clone(),
elapsed_ms: 412,
detail: String::new(),
});
}
}
Some(state)
}
}
/// 页头的状态档(D30 的状态矩阵:四档各自一句文案 + 一种颜色)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// 未配置(缺项,按钮禁用)
Unconfigured,
/// 已配置(还没测或测过但目标改了)
Configured,
/// 连接已通过
Verified,
/// 测试失败
Failed,
}
impl Status {
/// 文案 key。
#[must_use]
pub const fn label(self) -> Key {
match self {
Self::Unconfigured => Key::SettingsAiStatusUnconfigured,
Self::Configured => Key::SettingsAiStatusConfigured,
Self::Verified => Key::SettingsAiStatusVerified,
Self::Failed => Key::SettingsAiStatusTestFailed,
}
}
}
@@ -0,0 +1,78 @@
//! 「助手」分组的**真读写**:`config/launcher.json` 的 `ai.enabled` / `ai.provider` /
//! `ai.base_url` / `ai.model` / `ai.api_key` 五键。
//!
//! ## 为什么在进程内直接过 `SettingStore`,不走 `AppCommand::SetSetting`
//!
//! `AppCommand` 是可序列化的 DTO(`dto/events.rs` 的 `derive(Serialize, Deserialize)`),
//! 把 `ai.api_key` 塞进去就等于让明文进了「命令通道」——事件日志、调试转储、mock 后端的事件
//! 回放都会看到它。M174 的守卫①要求密钥**不出进程内**,所以本组写盘走
//! `core::setting::store::SettingStore`:schema 校验 + 原子写照旧(不自己拼 JSON、不绕 schema),
//! 只是不经过那条可序列化的通道。这与 `SettingsBridge::persist` 写外观是同一条做法。
//!
//! ## 为什么每次现读现写
//!
//! `SettingStore::load` 每次从磁盘读一份(`HostEnv::store()` 也是这个口径),所以「读 → 改 → 存」
//! 不会用启动时的旧快照盖掉别人刚写的键(AI / `ull config set` 是同一个文件的外部写者)。
use std::path::Path;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use super::state::{ProviderId, Snapshot, State};
/// 读一份真值。文件缺失/坏掉时 `SettingStore::load` 自己降级,这里只会读到 schema 默认值。
#[must_use]
pub fn load(path: &Path) -> Snapshot {
let store = SettingStore::load(path, launcher_schema());
snapshot(store.doc())
}
/// 把草稿落盘,返回**落盘后**再读回来的真值(界面据此刷新,而不是相信自己的草稿)。
///
/// # Errors
/// schema 拒绝(越界/类型/只读文件)或写盘失败时返回人话(已过 [`super::state::State::validate`],
/// 所以这里只剩「schema/磁盘」这一类)。
pub fn save(path: &Path, state: &State) -> Result<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}"))?;
}
store.save().map_err(|error| format!("{error}"))?;
// 落盘后再读一遍:`set` 只证明「schema 收了」,`save` 之后文件里到底是什么才算数。
Ok(snapshot(store.doc()))
}
/// 文档 → 快照(密钥只取长度,值不出本函数)。
fn snapshot(doc: &unlucky_core::setting::document::SettingDocument) -> Snapshot {
let text = |key: &str| doc.effective_str(key).unwrap_or_default().to_string();
let key_chars = doc
.effective_str("ai.api_key")
.map(str::trim)
.filter(|value| !value.is_empty())
.map_or(0, |value| value.chars().count());
Snapshot {
enabled: doc.effective_bool("ai.enabled").unwrap_or(true),
provider: ProviderId::from_setting(&text("ai.provider")),
base_url: text("ai.base_url"),
model: text("ai.model"),
key_chars,
}
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;
@@ -0,0 +1,129 @@
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘。
//!
//! | 用例 | 守什么 | 变异 |
//! |---|---|---|
//! | `five_keys_round_trip` | 四项 + 密钥都真的落进 `launcher.json` | 少写一个键 / 写错键名 |
//! | `snapshot_only_reports_key_length` | 快照里没有密钥值,只有长度 | 把 `api_key` 值放进 `Snapshot` |
//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 |
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
use std::path::PathBuf;
use super::{load, save};
use crate::pages::settings::ai::state::{ProviderId, Secret, State};
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-ai-store-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
path
}
fn draft() -> State {
State {
enabled: true,
provider: ProviderId::Anthropic,
base_url: "https://api.anthropic.com".to_string(),
model: "claude-sonnet-4".to_string(),
key: Secret::new("sk-ant-secret"),
dirty: true,
storage: true,
..State::default()
}
}
#[test]
fn five_keys_round_trip() {
let path = temp_config("roundtrip");
let saved = save(&path, &draft()).expect("应当能保存");
assert_eq!(saved.provider, ProviderId::Anthropic);
assert_eq!(saved.base_url, "https://api.anthropic.com");
assert_eq!(saved.model, "claude-sonnet-4");
assert_eq!(saved.key_chars, "sk-ant-secret".chars().count());
// 文件里真的是这五个键(不是我们内存里的对象)。
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("\"base_url\": \"https://api.anthropic.com\""),
"{text}"
);
assert!(text.contains("\"model\": \"claude-sonnet-4\""), "{text}");
assert!(text.contains("\"provider\": \"anthropic\""), "{text}");
assert!(text.contains("\"api_key\": \"sk-ant-secret\""), "{text}");
// 新开一份读回来(= 重启语义)。
let reloaded = load(&path);
assert_eq!(reloaded, saved);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn snapshot_only_reports_key_length() {
let path = temp_config("length");
let saved = save(&path, &draft()).expect("保存");
// `Snapshot` 里唯一与密钥有关的字段是长度:类型上就没有 `String` 可以装明文。
let printed = format!("{saved:?}");
assert!(
!printed.contains("sk-ant-secret"),
"快照泄漏密钥:{printed}"
);
assert_eq!(saved.key_chars, "sk-ant-secret".len());
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn empty_draft_keeps_the_stored_key() {
let path = temp_config("keep-key");
save(&path, &draft()).expect("先存一把");
let mut next = draft();
next.key = Secret::default();
next.model = "claude-opus-4".to_string();
let saved = save(&path, &next).expect("再存一次(密钥留空)");
assert_eq!(saved.model, "claude-opus-4");
assert_eq!(
saved.key_chars,
"sk-ant-secret".chars().count(),
"密钥输入框留空时必须保留旧密钥"
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn schema_rejects_and_nothing_is_written() {
let path = temp_config("reject");
save(&path, &draft()).expect("先存一份合法的");
let before = std::fs::read_to_string(&path).expect("读文件");
// `ai.model` 的上限是 128 字符:越界的值必须被 schema 拒绝,**且文件不动**。
let mut bad = draft();
bad.model = "m".repeat(400);
bad.base_url = "https://example.invalid/v1".to_string();
let error = save(&path, &bad).expect_err("越界模型名必须被拒");
assert!(!error.is_empty());
let after = std::fs::read_to_string(&path).expect("读文件");
assert_eq!(before, after, "被拒绝的保存不该改动文件:{after}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn external_edit_is_read_back() {
let path = temp_config("external");
save(&path, &draft()).expect("先存一份");
// 外部写者(`ull config set ai.model …`)改一个键:我们不缓存,下一次读就是新值。
std::fs::write(
&path,
r#"{"ai":{"enabled":true,"provider":"ollama","base_url":"http://127.0.0.1:11434","model":"llama3.1"}}"#,
)
.expect("写文件");
let reloaded = load(&path);
assert_eq!(reloaded.provider, ProviderId::Ollama);
assert_eq!(reloaded.model, "llama3.1");
assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
@@ -0,0 +1,331 @@
//! 「助手」分组状态机的用例(M174)。
//!
//! | 用例 | 守什么 | 变异(改成什么会红) |
//! |---|---|---|
//! | `provider_vocabulary_is_the_schema_vocabulary` | 四个取值 == `core::setting::keys::AI_PROVIDERS` | 词表里写 `open_ai_compatible` |
//! | `secret_debug_is_masked` | 密钥不进 `{:?}` | 把 `Secret` 改成 `String` |
//! | `validate_refuses_bad_endpoint_and_empty_model` | 空/非法值不保存 | 删掉 `starts_with("http")` 检查 |
//! | `missing_names_exactly_one_field` | 「未配置」说清缺哪一项 | 所有缺项都返回 `BaseUrl` |
//! | `can_test_is_false_until_configured` | 未配置时按钮禁用 | `can_test` 恒 `true` |
//! | `dirty_draft_is_not_overwritten` | 外部改动不冲掉用户输入 | `hydrate` 先无条件赋值 |
//! | `saving_a_new_target_drops_the_old_pass` | 端点/模型变了,旧「已通过」作废 | `keep_probe_if_targeted` 删掉 |
//! | `not_configured_outcome_carries_no_target` | 「没发请求」的结论里没有目标/详情 | 把 `ProbeOutcome::NotConfigured` 当失败类 |
use unlucky_core::setting::keys::AI_PROVIDERS;
use unlucky_i18n::{Catalog, Lang};
use super::outcome::{MissingField, ProbeKind, ProbeResult};
use super::state::{Probe, ProviderId, Refusal, Secret, Snapshot, State, Status};
fn configured() -> State {
State {
enabled: true,
provider: ProviderId::OpenAiCompatible,
base_url: "https://api.deepseek.com/v1".to_string(),
model: "deepseek-chat".to_string(),
key: Secret::new("sk-test"),
key_chars: 0,
dirty: false,
storage: true,
..State::default()
}
}
#[test]
fn provider_vocabulary_is_the_schema_vocabulary() {
let ours: Vec<&str> = ProviderId::ALL.iter().map(|id| id.setting()).collect();
assert_eq!(
ours, AI_PROVIDERS,
"四个取值必须与 schema 的允许值表逐条相等"
);
for id in ProviderId::ALL {
assert_eq!(ProviderId::from_setting(id.setting()), id);
}
// 未知值回落 OpenAI 兼容(与 schema 默认值同一档),不是 panic 也不是禁用。
assert_eq!(
ProviderId::from_setting("open_ai_compatible"),
ProviderId::OpenAiCompatible
);
}
#[test]
fn secret_debug_is_masked() {
let secret = Secret::new("sk-live-LEAK-MARKER");
let printed = format!("{secret:?}");
assert!(!printed.contains("sk-live"), "Debug 漏了密钥:{printed}");
assert!(printed.contains("***"), "应当显示掩码:{printed}");
// 空草稿的 Debug 也要能分辨(否则「留空 = 保留旧密钥」这条规则在日志里看不出)。
assert!(format!("{:?}", Secret::default()).contains("空"));
assert!(Secret::new(" ").is_empty());
}
#[test]
fn validate_refuses_bad_endpoint_and_empty_model() {
let mut state = configured();
assert_eq!(state.validate(), Ok(()));
state.base_url = "api.deepseek.com/v1".to_string();
assert_eq!(
state.validate(),
Err(Refusal::BaseUrl),
"没有协议头不算端点"
);
state.base_url = String::new();
assert_eq!(
state.validate(),
Err(Refusal::BaseUrl),
"OpenAI 兼容形态没有默认端点,空值必须拒绝(不保存)"
);
state.base_url = "https://api.deepseek.com/v1".to_string();
state.model = " ".to_string();
assert_eq!(state.validate(), Err(Refusal::Model));
// Ollama 有默认端点:空 base_url 是合法的(生效值回落默认)。
state.provider = ProviderId::Ollama;
state.base_url = String::new();
state.model = "llama3".to_string();
assert_eq!(state.validate(), Ok(()));
assert_eq!(state.resolved_base_url(), "http://127.0.0.1:11434");
// 禁用档:什么都不填也能保存(那正是「关掉助手」)。
state.provider = ProviderId::Disabled;
state.model = String::new();
assert_eq!(state.validate(), Ok(()));
}
#[test]
fn missing_names_exactly_one_field() {
let mut state = configured();
assert_eq!(state.missing(), None);
state.enabled = false;
assert_eq!(state.missing(), Some(MissingField::Disabled));
let mut state = configured();
state.base_url = String::new();
assert_eq!(state.missing(), Some(MissingField::BaseUrl));
let mut state = configured();
state.model = String::new();
assert_eq!(state.missing(), Some(MissingField::Model));
// Anthropic 必须带密钥:草稿空 + 已存 0 字符 = 缺密钥。
let mut state = configured();
state.provider = ProviderId::Anthropic;
state.base_url = String::new();
state.model = "claude-sonnet-4".to_string();
state.key = Secret::default();
state.key_chars = 0;
assert_eq!(state.missing(), Some(MissingField::ApiKey));
// 已存过密钥(只有长度,没有值)就算配置齐了。
state.key_chars = 40;
assert_eq!(state.missing(), None);
}
#[test]
fn can_test_is_false_until_configured() {
let mut state = configured();
assert!(state.can_test());
state.model = String::new();
assert!(!state.can_test(), "缺模型名时「测试连接」必须禁用");
let mut state = configured();
state.storage = false;
assert!(!state.can_test(), "没有设置文件时也禁用");
let mut state = configured();
state.probe = Probe::Running;
assert!(!state.can_test(), "正在测的时候不许再点一次");
}
#[test]
fn dirty_draft_is_not_overwritten() {
let mut state = configured();
state.base_url = "https://typed.example/v1".to_string();
state.dirty = true;
let changed = state.hydrate(Snapshot {
enabled: true,
provider: ProviderId::Ollama,
base_url: "http://127.0.0.1:11434".to_string(),
model: "llama3".to_string(),
key_chars: 12,
});
assert!(!changed);
assert_eq!(
state.base_url, "https://typed.example/v1",
"脏草稿不许被冲掉"
);
assert_eq!(state.provider, ProviderId::OpenAiCompatible);
// 密钥长度不属于草稿:外部改了密钥,长度照样要更新(界面上的「已存 N 字符」不能骗人)。
assert_eq!(state.key_chars, 12);
// 不脏的时候照常灌入。
let mut state = State::default();
let changed = state.hydrate(Snapshot {
enabled: true,
provider: ProviderId::Ollama,
base_url: "http://127.0.0.1:11434".to_string(),
model: "llama3".to_string(),
key_chars: 0,
});
assert!(changed);
assert_eq!(state.provider, ProviderId::Ollama);
assert!(state.storage, "灌过真值之后设置文件就是可用的");
}
#[test]
fn saving_a_new_target_drops_the_old_pass() {
let mut state = configured();
state.probe = Probe::Done(ProbeResult {
kind: ProbeKind::Passed,
missing: None,
base_url: state.resolved_base_url(),
model: state.model.clone(),
elapsed_ms: 120,
detail: String::new(),
});
assert_eq!(state.status(), Status::Verified);
// 同一个目标重复灌入:结论仍然有效。
let snapshot = Snapshot {
enabled: true,
provider: state.provider,
base_url: state.base_url.clone(),
model: state.model.clone(),
key_chars: 0,
};
state.hydrate(snapshot);
assert_eq!(
state.status(),
Status::Verified,
"目标没变,别无故作废旧结论"
);
// 换了端点:旧结论不再关于现在这套配置。
state.dirty = false;
state.hydrate(Snapshot {
enabled: true,
provider: ProviderId::OpenAiCompatible,
base_url: "https://other.example/v1".to_string(),
model: "deepseek-chat".to_string(),
key_chars: 0,
});
assert_eq!(state.probe, Probe::Idle, "目标变了,旧「已通过」必须作废");
assert_eq!(state.status(), Status::Configured);
}
#[test]
fn not_configured_outcome_carries_no_target() {
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome};
let result = ProbeResult::from_outcome(
ProbeOutcome::NotConfigured {
missing: Missing::Model,
},
0,
);
assert_eq!(result.kind, ProbeKind::NotConfigured);
assert_eq!(result.missing, Some(MissingField::Model));
assert!(result.base_url.is_empty(), "没发请求就没有目标地址");
assert!(result.model.is_empty());
assert!(!result.sent_a_request());
// 四类失败各自翻译过来,且都不带目标(它们没有「成功的目标」可说)。
for (outcome, expected) in [
(
ProbeOutcome::Auth {
detail: "401".to_string(),
},
ProbeKind::Auth,
),
(
ProbeOutcome::ModelNotFound {
detail: "404".to_string(),
},
ProbeKind::ModelNotFound,
),
(
ProbeOutcome::Unreachable {
detail: "refused".to_string(),
},
ProbeKind::Unreachable,
),
(
ProbeOutcome::Timeout {
detail: "8 s".to_string(),
},
ProbeKind::Timeout,
),
(
ProbeOutcome::Failed {
detail: "?".to_string(),
},
ProbeKind::Failed,
),
] {
let result = ProbeResult::from_outcome(outcome, 5);
assert_eq!(result.kind, expected);
assert!(result.sent_a_request());
}
}
#[test]
fn screenshot_seed_covers_the_state_matrix() {
// 未配置 / 已配置 / 已通过 / 失败四档都要能出图(D30 的矩阵)。
let unconfigured = State::screenshot_seed("unconfigured").expect("种子");
assert_eq!(unconfigured.status(), Status::Unconfigured);
assert!(unconfigured.storage, "截图里也要如实显示「有设置文件」");
let configured = State::screenshot_seed("configured").expect("种子");
assert_eq!(configured.status(), Status::Configured);
assert_eq!(configured.model, "deepseek-chat");
let verified = State::screenshot_seed("verified").expect("种子");
assert_eq!(verified.status(), Status::Verified);
let failed = State::screenshot_seed("failed-auth").expect("种子");
assert_eq!(failed.status(), Status::Failed);
assert!(
State::screenshot_seed("nonsense").is_none(),
"未知种子不伪造状态"
);
}
#[test]
fn every_key_used_here_exists_in_the_catalog() {
// 中英各一遍,逐条取词:目录里没这条 key 就编译不过(`Key` 是枚举),
// 但**译文为空**这件事只有这里抓得住。
let mut keys: Vec<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,356 @@
//! 「助手」分组的一张卡片(M174)。
//!
//! ## 版面(D30 的状态矩阵 + 数值)
//!
//! | 行 | 内容 | 数值 |
//! |---|---|---|
//! | 状态 | 未配置 / 已配置 / 连接已通过 / 测试失败 | 生效端点 + 模型名 |
//! | 开关 | 启用助手(`ai.enabled`) | — |
//! | 提供商 | 四态 filter chip(`ai.provider`) | 存的值 |
//! | 端点 | `base_url` 文本框 | 生效值(空 = 该形态默认) |
//! | 模型 | `ai.model` 文本框 | — |
//! | 密钥 | 密码框(默认掩码)+「显示」 | **只显示字符数**,不显示值 |
//! | 动作 | 保存 / 测试连接 | 测试结论带耗时(毫秒) |
//!
//! 「未配置」时「测试连接」是**禁用**的,并在下面直接说清缺哪一项——不是灰着什么都不说。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::inputs::{text_field, Field, FieldVariant};
use crate::app::components::selection::{chip, switch, Chip, Switch};
use crate::pages::frame::{card, divider, text_role_owned};
use crate::pages::settings::ai::outcome::ProbeKind;
use crate::pages::settings::ai::state::{Probe, ProviderId, SaveNotice, State, Status};
use crate::pages::settings::{ai_about, GroupMessage, States};
use crate::pages::Message;
use crate::theme::Ui;
/// 一条本组消息 → 页面消息(左列/正文都用这一处,别在下面各写一遍)。
fn msg(message: ai_about::Message) -> Message {
Message::Settings(GroupMessage::AiAbout(message))
}
/// 一张卡片(`pages::settings::form` 按下标把 `Lang` 递进来,见 `settings/mod.rs`)。
pub(crate) fn body<'a>(ui: &'a Ui, states: States<'a>, lang: Lang) -> Element<'a, Message> {
body_of(ui, &states.groups.ai_about, lang)
}
fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message> {
let catalog = lang.catalog();
let mut stacked = column![].spacing(ui.space(Space::X2)).width(Length::Fill);
stacked = stacked.push(status_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(enable_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(provider_row(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(endpoint_field(ui, catalog, state));
stacked = stacked.push(model_field(ui, catalog, state));
stacked = stacked.push(key_field(ui, catalog, state));
stacked = stacked.push(divider(ui));
stacked = stacked.push(actions_row(ui, catalog, state));
stacked = stacked.push(hint_line(ui, catalog, state));
if let Some(notice) = &state.notice {
stacked = stacked.push(notice_line(ui, catalog, notice));
}
if let Probe::Done(result) = &state.probe {
stacked = stacked.push(probe_line(ui, catalog, result.kind));
stacked = stacked.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsAiTestTarget,
&[
("base_url", result.base_url.as_str()),
("model", result.model.as_str()),
("ms", result.elapsed_ms.to_string().as_str()),
],
),
ui.color(Role::OnSurfaceVariant),
));
if !result.detail.trim().is_empty() {
stacked = stacked.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(
Key::SettingsAiTestDetail,
&[("detail", result.detail.as_str())],
),
ui.color(Role::OnSurfaceVariant),
));
}
}
card(ui, stacked)
}
/// 状态行:四档文案 + 生效端点 / 模型(数值直接上屏,不藏在提示里)。
fn status_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let status = state.status();
let color = match status {
Status::Failed => ui.color(Role::Error),
Status::Verified => ui.color(Role::Primary),
_ => ui.color(Role::OnSurfaceVariant),
};
let target = if state.model.trim().is_empty() {
state.resolved_base_url()
} else {
format!("{} · {}", state.resolved_base_url(), state.model.trim())
};
let line = catalog.render(
Key::SettingsAiStatusLabel,
&[("status", catalog.text(status.label()))],
);
row![
container(text_role_owned(ui, TypeRole::BodyMedium, true, line, color)).width(Length::Fill),
text_role_owned(ui, TypeRole::BodySmall, false, target, color),
]
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
fn enable_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
column![
switch(
ui,
Switch::new(
catalog.text(Key::SettingsAiEnable),
state.enabled,
msg(ai_about::Message::ToggleEnabled(!state.enabled)),
),
),
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsAiEnableHint).to_string(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn provider_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
// `.wrap()`:四颗 chip 在 424 宽的正文列里放不下一行时**整颗换行**,而不是把最后一颗
// 压成竖排(「已禁 / 用」那种断字)。`Wrapping` 只提供 `push`,所以先建后 wrap。
let mut chips = row![]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
for provider in ProviderId::ALL {
chips = chips.push(chip(
ui,
Chip::filter(
catalog.text(provider.label()),
state.provider == provider,
msg(ai_about::Message::PickProvider(provider)),
),
));
}
row![
text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::SettingsAiProviderLabel).to_string(),
ui.color(Role::OnSurfaceVariant),
),
chips.wrap(),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center)
.into()
}
fn endpoint_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.base_url)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsAiBaseUrl))
.placeholder(catalog.text(Key::SettingsAiBaseUrlPlaceholder))
.supporting(catalog.text(Key::SettingsAiBaseUrlHint));
// 「生效:…」只在**回落默认值**(草稿为空、该形态有默认端点)时才值得说一句:
// 草稿就是生效值时它是废话,空着且没有默认值时它是空串(比不说更糟)。
let effective = state.resolved_base_url();
let mut block = column![text_field(ui, field, |value| msg(
ai_about::Message::EditBaseUrl(value)
))]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
if !effective.is_empty() && effective != state.base_url.trim() {
block = block.push(text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(Key::SettingsAiEffective, &[("value", effective.as_str())]),
ui.color(Role::OnSurfaceVariant),
));
}
block.into()
}
fn model_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let field = Field::new(&state.model)
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsAiModel))
.placeholder(catalog.text(Key::SettingsAiModelPlaceholder));
text_field(ui, field, |value| msg(ai_about::Message::EditModel(value)))
}
/// 密钥行:密码框 + 「显示 / 隐藏」chip + 已存状态(**只有字符数**)。
fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a, Message> {
let chars = state.key_chars.to_string();
let supporting = if state.key_chars > 0 {
catalog.render(
Key::SettingsAiApiKeyConfigured,
&[("chars", chars.as_str())],
)
} else {
catalog.text(Key::SettingsAiApiKeyMissing).to_string()
};
let field = Field::new(state.key.expose())
.variant(FieldVariant::Outlined)
.label(catalog.text(Key::SettingsAiApiKey))
.placeholder(catalog.text(Key::SettingsAiApiKeyPlaceholder))
.secure(!state.reveal);
let toggle_label = if state.reveal {
catalog.text(Key::SettingsAiHide)
} else {
catalog.text(Key::SettingsAiShow)
};
let toggle = chip(
ui,
Chip::filter(
toggle_label,
state.reveal,
msg(ai_about::Message::Reveal(!state.reveal)),
),
);
let field = container(text_field(ui, field, |value| {
msg(ai_about::Message::EditKey(value))
}))
.width(Length::Fill);
column![
row![field, toggle]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.align_y(Alignment::Center),
// 「已存 N 字符 / 还没有密钥」这一行是**格式化出来的**(`Field::supporting` 借的是
// `&'a str`,临时 String 借不进去),所以单独一行;它只讲长度,不讲值。
text_role_owned(
ui,
TypeRole::BodySmall,
false,
supporting,
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn actions_row<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let save_label = catalog.text(Key::SettingsAiSave);
let save = if state.dirty && state.storage {
buttons::filled(ui, Button::new(save_label, msg(ai_about::Message::Save)))
} else {
buttons::filled(ui, Button::disabled(save_label))
};
let test_label = if matches!(state.probe, Probe::Running) {
catalog.text(Key::SettingsAiTesting)
} else {
catalog.text(Key::SettingsAiTest)
};
let test = if state.can_test() {
buttons::tonal(ui, Button::new(test_label, msg(ai_about::Message::Test)))
} else {
buttons::tonal(ui, Button::disabled(test_label))
};
row![save, test]
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.into()
}
/// 按钮为什么是灰的 / 下一步该做什么——**永远说清**,不留「点了没反应」。
fn hint_line<'a>(ui: &'a Ui, catalog: Catalog, state: &State) -> Element<'a, Message> {
let (text, error) = if !state.storage {
(catalog.text(Key::SettingsAiNoStorage).to_string(), true)
} else if let Some(missing) = state.missing() {
(
catalog.render(
Key::SettingsAiTestDisabled,
&[("missing", catalog.text(missing.label()))],
),
false,
)
} else if state.dirty {
(catalog.text(Key::SettingsAiUnsaved).to_string(), false)
} else {
(String::new(), false)
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error {
Role::Error
} else {
Role::OnSurfaceVariant
}),
)
}
fn notice_line<'a>(ui: &'a Ui, catalog: Catalog, notice: &SaveNotice) -> Element<'a, Message> {
let (text, error) = match notice {
SaveNotice::Saved => (catalog.text(Key::SettingsAiSaved).to_string(), false),
SaveNotice::Refused(reason) => (
catalog.render(
Key::SettingsAiRefused,
&[("reason", catalog.text(reason.label()))],
),
true,
),
SaveNotice::Failed(reason) => (
catalog.render(Key::SettingsAiRefused, &[("reason", reason.as_str())]),
true,
),
SaveNotice::NoStorage => (catalog.text(Key::SettingsAiNoStorage).to_string(), true),
};
text_role_owned(
ui,
TypeRole::BodySmall,
false,
text,
ui.color(if error { Role::Error } else { Role::OnSurface }),
)
}
/// 测试结论一行:分类一句话(目标/耗时/详情在它下面三行)。
fn probe_line<'a>(ui: &'a Ui, catalog: Catalog, kind: ProbeKind) -> Element<'a, Message> {
let error = kind.is_failure();
text_role_owned(
ui,
TypeRole::BodyMedium,
true,
catalog.text(kind.label()).to_string(),
ui.color(if error { Role::Error } else { Role::Primary }),
)
}
+66 -30
View File
@@ -1,39 +1,75 @@
//! 设置页「AI / About」分组:**预留**(页面上还没有这个分组,左列 4 项里没有它)。
//! 设置页「助手(AI)」分组(M174):左列第 6 组的**挂载点**与本线的消息口。
//!
//! 为什么先留一个空文件:设置页正文接下来由四条线并行接(java / download / language+appearance /
//! ai+about)。文件位置先占好,四条线就不会在同一天抢同一个新文件名;本文件现在是**只有文档**的
//! 空模块(不产生任何代码,`-D warnings` 不会红)。
//! 本文件只留三样东西,正文/状态/读写都在 `pages/settings/ai/` 里:
//!
//! ## 接线步骤(ai+about 线照做)
//! | 这里 | 内容 |
//! |---|---|
//! | [`GROUP`] | 左列名 / 图标 / 演示行(产品路径走 [`super::ai::view::body`]) |
//! | [`AI_GROUP`] | 本组在 `GROUPS` 里的下标(与 General / Update 同一条**按下标分发**:本组要 i18n 文案与 `Lang` 形参,`Group.body` 的签名里没有它) |
//! | [`Message`] | 本线的消息口(落点 `app/update/settings/ai_about.rs`) |
//! | [`State`] | 转发 `ai::State`(`Groups.ai_about` 的载荷类型不变,`settings/state.rs` 一行不用改) |
//!
//! 1. 在本文件写 `pub(super) const GROUP: Group = Group { … }`,形状抄 `java.rs`;
//! 2. 到 `settings/mod.rs` 的 `GROUPS` 里加一行 `ai_about::GROUP,`,并把数组长度 `4` 改成 `5`;
//! 3. 文案 key 写进 `crates/i18n/src/catalog/settings/ai.rs`(那边已经接进装配链,同样只改一个文件)。
//! ## 为什么 `body` 是 `None` 而不是 `Some(..)`
//!
//! 界面上「多了一组」这件事本身是行为变化,所以**只在真有那一组内容时才做**,不要为占位先加。
//! `Group.body` 的签名是 `fn(&Ui, States) -> Option<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),
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)
EditKey(String),
/// 密钥输入框明文/掩码
Reveal(bool),
/// 保存(校验 + 落盘)
Save,
/// 测试连接(异步;结果走 [`Message::ProbeDone`])
Test,
/// 测试连接的结果(分类 + 数值 + 脱敏详情)
ProbeDone(Box<super::ai::ProbeResult>),
}
impl Message {
/// 一条本组消息 → 设置页的投递口(`pages::settings::GroupMessage::AiAbout`)。
#[must_use]
pub fn into_group(self) -> super::GroupMessage {
super::GroupMessage::AiAbout(self)
}
}
+24 -6
View File
@@ -28,7 +28,8 @@
//! | Appearance | [`appearance`] | language+appearance 线(真控件已在 `pages/appearance/section.rs`) |
//! | Java | [`java`] | java 线 |
//! | Downloads | [`download`] | download 线 |
//! | (页面上还没有这个分组) | [`ai_about`] | ai+about 线:先建分组再接线 |
//! | Updates | [`update`] | update 线(M159,按下标分发) |
//! | Assistant | [`ai_about`](正文在 [`ai`]) | ai 线(M174,按下标分发:本组要 `Lang`) |
//! | 页头 / 侧列 / 滚动区 / 演示卡片 / 空态位 | [`frame`] | 谁都不该改(改了就是改版面) |
//!
//! ## 测试也按 section 分(M100)
@@ -93,9 +94,11 @@
//! (`split_keeps_every_translation` 逐条比 key + 英/中译文)。**注意**:`catalog.rs` 的 `Key`
//! 枚举由 `catalog!` 宏从各域文件累积生成,加 key **不用**改 `catalog.rs`,也不用改
//! `catalog/settings/mod.rs`(五个域已全部接进装配链)。
//! 2. 本文件的 [`GROUPS`] —— 只有 ai+about 线**上第 5 组**时加一行(页面上多一组本来就是整页的变化)。
//! 3. `tests/mod.rs` —— ai+about 线新建 `tests/ai_section.rs` 时要加一行 `mod ai_section;`
//! (测试文件不挂 `mod` 就不编译),以及「左列顺序」那条字面量断言会连带红,需要人确认。
//! 2. 本文件的 [`GROUPS`] —— 加一整组时加一行(M174 的助手组就是这么来的:`GROUPS` 与
//! [`GROUP_LABELS`] 各加一行、长度 +1,`tests/mod.rs` 的「左列顺序」随之更新)。
//! 3. `tests/mod.rs` —— 「左列顺序」那条字面量断言会连带红,需要人确认(M174 就是把它加了一个
//! `"Assistant"`)。加分组自己的测试文件时还要在 `tests/mod.rs` 顶部挂一行 `mod <名>;`;
//! M174 的用例走的是组内路径(`pages/settings/ai/tests.rs`),所以没有新增挂载行。
//! 4. **读页外真值**(r2 复审 P1 补的)—— [`States`] 只有 `appearance` + `groups` 两格,组正文读不到
//! `pages::Model`(「当前语言」的值在 `Model.lang`,`pages/model.rs`),也读不到 presenter 的设置
//! 快照。两条路的**真实成本**(`States` 没有 `Default`,加字段必然 E0063,已实测):
@@ -132,6 +135,7 @@
// 四条填页线的文件要能被**各自那条落点**(`app/update/settings/<线>.rs`)与状态架子
// (`state::Groups`)命名,所以是 `pub(crate)`:最小面,不是 crate 公开 API。
// 分组正文文件 `appearance` / `frame` / `general` 只在设置页内部用,保持私有。
pub(crate) mod ai;
pub(crate) mod ai_about;
mod appearance;
pub(crate) mod download;
@@ -215,7 +219,7 @@ struct Group {
/// 分组注册表:**顺序 = 左列自上而下**,也是 `crate::pages::appearance::APPEARANCE_GROUP` 的下标来源。
///
/// 每一项都指向它自己文件里的 `GROUP`(M99 之前是四份字面量挤在本文件里)。
const GROUPS: [Group; 5] = [
const GROUPS: [Group; 6] = [
general::GROUP,
appearance::GROUP,
java::GROUP,
@@ -223,6 +227,8 @@ const GROUPS: [Group; 5] = [
// M159:自更新只**追加**在末尾(插在中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP` 两个下标
// 全部失效,`tests/mod.rs` 的「左列顺序」那条也会红)。
update::GROUP,
// M174:助手(AI)配置。同样只追加(下标 = `ai_about::AI_GROUP`),正文走按下标分发。
ai_about::GROUP,
];
/// 演示选中的分组(没有真实外观状态时——`pages_demo` harness——用它)。
@@ -241,13 +247,15 @@ const GENERAL_GROUP: usize = 0;
///
/// 为什么不放进 [`Group`]:那会让另外三组的 `GROUP` 字面量(各自文件、各自填页线)编译不过——
/// 与 [`GENERAL_GROUP`] 同一条理由。`Group.name` 因此退化为**id**(测试按它认组;显示走这张表)。
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 6] = [
unlucky_i18n::Key::SettingsGroupGeneral,
unlucky_i18n::Key::SettingsGroupAppearance,
unlucky_i18n::Key::SettingsGroupJava,
unlucky_i18n::Key::SettingsGroupDownloads,
// M159
unlucky_i18n::Key::SettingsGroupUpdate,
// M174
unlucky_i18n::Key::SettingsGroupAssistant,
];
/// 「更新」分组在 [`GROUPS`] 里的下标(真源在 `settings/update.rs`,这里只转发)。
@@ -256,6 +264,13 @@ pub(super) const GROUP_LABELS: [unlucky_i18n::Key; 5] = [
/// 所以由 `view` 的 `update` 形参一路递到下标的 [`form`] 分发点,`GROUP.body` 保持 `None`。
const UPDATE_GROUP: usize = update::UPDATE_GROUP;
/// 「助手(AI)」分组在 [`GROUPS`] 里的下标(真源在 `settings/ai_about.rs`,这里只转发)。
///
/// 与 [`GENERAL_GROUP`] / [`UPDATE_GROUP`] 同一条做法:本组要画中英两套文案(`Lang`)与
/// 「测试连接」的结论,`Group.body` 的签名里没有 `Lang`,所以由 `view` 的 `language` 形参一路
/// 递到 [`form`] 的分发点,`GROUP.body` 保持 `None`。
const AI_GROUP: usize = ai_about::AI_GROUP;
/// 整页。
///
/// `states` 是「画一帧时那份只读状态」:[`States::appearance`] 是 M93 接进来的外观运行期状态
@@ -321,6 +336,9 @@ fn form<'a>(
} else if index == UPDATE_GROUP {
// M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。
update::body(ui, update, language.effective)
} else if index == AI_GROUP {
// M174:助手组要 i18n 文案与「测试连接」结论,与 General / Update 同一条按下标分发。
ai::view::body(ui, states, language.effective)
} else {
group
.body
@@ -132,7 +132,15 @@ fn the_split_kept_the_left_column_order() {
let names: Vec<&str> = GROUPS.iter().map(|group| group.name).collect();
assert_eq!(
names,
vec!["General", "Appearance", "Java", "Downloads", "Updates"]
vec![
"General",
"Appearance",
"Java",
"Downloads",
"Updates",
// M174:助手(AI)配置只追加在末尾
"Assistant",
]
);
}
@@ -198,6 +206,10 @@ fn the_settings_module_does_not_name_any_group_state() {
fn every_line_can_be_wired_alone() {
// 判据是「每条线只改自己的文件」,不是任何一条具体断言:这里只钉住架子本身的两件事——
// ① 每组都有 `GROUP`(成员齐);② 演示下标合法。真正的 per-group 断言在各自文件里。
assert_eq!(GROUPS.len(), 5, "四条填页线 + M159 的更新组");
assert_eq!(
GROUPS.len(),
6,
"四条填页线 + M159 的更新组 + M174 的助手组"
);
assert!(SELECTED < GROUPS.len(), "演示下标仍在范围内");
}
@@ -11,11 +11,20 @@ use super::super::{GROUPS, SELECTED};
use crate::pages::update::UpdateStatus;
use crate::pages::{Screen, Update};
/// 更新组**必须是最后一组**:加组只追加不重排,插到中间会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效。
/// 更新组**下标固定为 4**:加组只追加不重排,插到它前面会让 `APPEARANCE_GROUP` / `GENERAL_GROUP`
/// 两个下标一起失效,也会把更新组自己的 `UPDATE_GROUP` 指到别人身上。
///
/// M174 之后它**不再是最后一组**(助手组追加在它后面):这条断言因此从「len == 下标 + 1」
/// 改成「下标 4 上就是更新组,且它是**倒数第二**组」——追加语义仍然成立,而「更新组被挤走」
/// 照样会红。
#[test]
fn the_update_group_sits_at_the_end_of_the_registry() {
assert_eq!(GROUPS.len(), UPDATE_GROUP + 1, "更新组之后不该还有别的组");
assert_eq!(UPDATE_GROUP, 4, "更新组的下标不许变(加组只追加)");
assert_eq!(
GROUPS.len(),
UPDATE_GROUP + 2,
"更新组之后只该有 M174 的助手组"
);
assert_eq!(GROUPS[UPDATE_GROUP].name, UPDATE_GROUP_META.name);
assert_eq!(GROUPS[UPDATE_GROUP].name, "Updates");
assert!(SELECTED < GROUPS.len());