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:
1 parent
82f3bbbff6
commit
9cb605f316
23 files changed
+2939
-73
No files matched your search
@@ -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,171 @@
|
||||
//! 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::Secret;
|
||||
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(value) => {
|
||||
self.settings.ai_about.key = Secret::new(value);
|
||||
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,122 @@ 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("sk-live-app-path".to_string())));
|
||||
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,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 }),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user