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

+22 -22
View File
@@ -1,14 +1,12 @@
//! 设置页分组:按 `core::setting` schema 的键生成行,写回走 `SettingStore::set`。
//!
//! 三件纪律(AGENTS §6):
//! 1. 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` / AI `set_config` 同源);
//! 2. 不自己拼 JSON,也不绕 schema(非法键/越界值由 `store.set` 拒绝);
//! 3. 这里只认「键名 + 字符串值」,领域语义留在 core。
//! 三件纪律(AGENTS §6):① 读写同一份 `<数据根>/config/launcher.json`(与 `ull config set` /
//! AI `set_config` 同源);② 不自己拼 JSON、不绕 schema(非法键/越界值由 `store.set` 拒绝);
//! ③ 这里只认「键名 + 字符串值」,领域语义留在 core。
//!
//! 分组 id 与设置页子导航同名:`download` / `java` / `data` / `ai` / `mcp`。
use serde_json::Value;
use unlucky_core::game::java::JdkVendor;
use unlucky_core::setting::spec::ValueKind;
use crate::dto::{SettingGroupView, SettingRowView};
@@ -243,10 +241,22 @@ fn data_group(env: &HostEnv) -> SettingGroupView {
))
}
/// AI(D4):开关/端点/模型/YOLO。
/// AI(D4):开关/端点/模型/密钥状态/YOLO。
/// **选项表直接取 schema 的 `AI_PROVIDERS`**(M174):原先手写三项、漏了 `disabled`,于是被别的
/// 写者设成 `disabled` 的机器在这一页会显示成 `openai_compatible`(`selected()` 找不到就回 0)。
/// **密钥只显示状态不显示值**:`ai.api_key` 是 `locked` 凭据,进 DTO 就等于进事件流/日志。
fn ai_group(env: &HostEnv) -> SettingGroupView {
let providers = ["openai_compatible", "anthropic", "ollama"];
let providers = unlucky_core::setting::keys::AI_PROVIDERS;
let provider = text(env, "ai.provider");
let chosen = if provider.is_empty() {
"openai_compatible"
} else {
provider.as_str()
};
let key_state = match text(env, "ai.api_key").trim().chars().count() {
0 => "未配置(在 iced 设置页填写)".to_string(),
chars => format!("已配置({chars} 个字符)"),
};
SettingGroupView::new("ai", "AI 助手(D4)")
.with(SettingRowView::toggle(
"ai.enabled",
@@ -258,15 +268,8 @@ fn ai_group(env: &HostEnv) -> SettingGroupView {
"ai.provider",
"模型端点",
"OpenAI 兼容(含本地 llama.cpp)/ Anthropic / Ollama",
&providers,
selected(
&providers,
if provider.is_empty() {
"openai_compatible"
} else {
&provider
},
),
providers,
selected(providers, chosen),
))
.with(SettingRowView::text(
"ai.base_url",
@@ -280,6 +283,7 @@ fn ai_group(env: &HostEnv) -> SettingGroupView {
"如 deepseek-chat",
&text(env, "ai.model"),
))
.with(SettingRowView::read_only("API 密钥", &key_state))
.with(SettingRowView::toggle(
"ai.yolo",
"YOLO 模式",
@@ -338,11 +342,8 @@ pub fn set(env: &HostEnv, key: &str, value: &str) -> Result<(), String> {
/// 键 → 正确类型的 JSON 值(schema 会再校验范围/枚举)。
///
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):原先是「硬编码 Int/Bool
/// 键名,其余当字符串」,于是 `appearance.window_opacity`(Float)被当 String 送进 `store.set`,
/// schema 的 `as_f64()` 返回 `None` ⇒ `InvalidValue` ⇒ **`save()` 不执行**(重启回旧值 + 弹 toast)。
/// 现在新增任何 Float/Int/Bool 键都自动正确;集合级守门是
/// `every_float_key_in_the_schema_can_be_written_back`。
/// **按 schema 的 `ValueKind` 取类型,不硬编码键名**(复审 P1-1):硬编码会让 Float 键被当 String
/// 送进 `store.set` ⇒ `InvalidValue` ⇒ `save()` 不执行。守门:`every_float_key_in_the_schema_can_be_written_back`。
fn parse_value(key: &str, value: &str) -> Result<Value, String> {
let schema = unlucky_core::setting::keys::launcher_schema();
let Some(spec) = schema.spec(key) else {
@@ -391,7 +392,6 @@ pub fn clear_vendor_priority(env: &HostEnv) -> Result<(), String> {
.set("java.vendorPriority", Value::Array(Vec::new()))
.and_then(|()| store.save())
.map_err(|error| format!("清空发行版优先级失败:{error}"))?;
let _ = JdkVendor::ALL;
Ok(())
}