Files
Unlucky-Launcher/crates/app-iced/src/pages/settings/ai/view.rs
T
AskaEth 93d02ab12b fix(m174): 复审 r1 五项 —— 失败带 target、密钥消息掩码、请求体断言、桥写前重读、结构化分类
P2-1(假绿截图):「测试连接」失败类也带上被请求的 target。`ProbeOutcome` 的五个失败变体
改成 `{ target, detail }`,`ProbeResult::from_outcome` 不再把端点/模型填成空串;view 另加一条
「目标为空就整行不画」的保险。**截图种子改走 `from_outcome`**(新增
`ProbeResult::screenshot_seed`):种子只伪造「结论是哪一档」,字段形状由产品路径决定——上一版
手填字段,于是「失败态目标为空」只在产品上出现、截图永远看不见。

P2-2(明文进日志):`Message::EditKey` 的载荷从 `String` 换成 `ai::Secret`(`Debug` 掩码)。
用例 `the_key_message_never_prints_the_key` 同时钉住裸消息与整条 `pages::Message` 漏斗。

P2-3(模型没被证上线):假服务器按 `Content-Length` 读请求正文,新用例
`the_request_carries_the_configured_model` 断言正文里 `"model":"gpt-test"`。变异实测暴露出
一处真相:`ChatRequest.model` 被 `RigProvider::build_request` 静默忽略,线上模型来自
`ProviderConfig.model`(已另开 finding,并写进 probe 的注释)。

P2-4(数据丢失):`SettingsBridge::persist` 写前 `store.reload()`。`SettingStore::save` 是整份
文档落盘,不重读就会拿 ≤1 秒前的旧快照盖掉刚写的 `ai.*`。两条回归用例:
`presenter::settings_bridge::persist_keeps_keys_another_writer_just_saved` 与 app-iced 侧的
`ai::store::appearance_write_back_keeps_the_ai_keys`(保存 AI → 改主题 → 值还在)。

P2-5(子串匹配):删掉 `classify()`。分类改由 `brain::rig::map_error` 从 rig 0.45 的结构化
报告(`ErrorReport` 的 `kind` / `http_status` / `code`)翻译成新的 `ProviderFailure`
(`brain/provider/failure.rs`),`ProviderError` 因此带上 `kind`。`closed_port_is_unreachable`
不再依赖错误文本:连接被拒恒为 `ErrorKind::Http` ⇒ unreachable。防退化用例
`factory_error_carries_the_structured_classification` 断言「纯文本 401 不许被猜成 auth」。

顺带(400 行硬线):`provider.rs` / `rig.rs` 的用例各自拆到 `provider/tests.rs` /
`rig/tests.rs`(纯搬移)。
2026-11-08 16:20:34 +08:00

361 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 「助手」分组的一张卡片(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, Secret, 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));
// 目标行只在真有值时才画(r1 复审 P2-1 的第二道保险):失败类现在**都会**带上 target,
// 但「没发请求」与将来的新档可能没有;空着画出来就是「目标: · 模型 · N 毫秒」。
if !(result.base_url.is_empty() && result.model.is_empty()) {
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(Secret::new(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 }),
)
}