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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user