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

+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)
}
}