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`(纯搬移)。
This commit is contained in:
1 parent
9cb605f316
commit
93d02ab12b
18 files changed
+840
-412
No files matched your search
@@ -30,7 +30,6 @@ impl App {
|
||||
&mut self,
|
||||
message: pages::settings::ai_about::Message,
|
||||
) -> Task<Message> {
|
||||
use pages::settings::ai::Secret;
|
||||
use pages::settings::ai_about::Message;
|
||||
|
||||
match message {
|
||||
@@ -50,8 +49,8 @@ impl App {
|
||||
self.settings.ai_about.model = value;
|
||||
self.touch();
|
||||
}
|
||||
Message::EditKey(value) => {
|
||||
self.settings.ai_about.key = Secret::new(value);
|
||||
Message::EditKey(secret) => {
|
||||
self.settings.ai_about.key = secret;
|
||||
self.touch();
|
||||
}
|
||||
Message::Reveal(reveal) => {
|
||||
|
||||
@@ -313,7 +313,9 @@ fn saving_the_ai_form_writes_the_real_settings_file() {
|
||||
)));
|
||||
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::EditKey(pages::settings::ai::Secret::new(
|
||||
"sk-live-app-path",
|
||||
))));
|
||||
let _ = app.update(ai(Ai::Save));
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("保存之后应当有配置文件");
|
||||
|
||||
@@ -116,45 +116,79 @@ impl ProbeResult {
|
||||
}),
|
||||
_ => 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())
|
||||
// r1 复审 P2-1:**失败也带 target**(端点 + 模型)。以前这里对失败类一律给空串,界面上
|
||||
// 就成了「目标: · 模型 …」,用户改完 key 也无法确认改的是不是那一份配置。
|
||||
let (kind, target, detail) = match outcome {
|
||||
ProbeOutcome::NotConfigured { .. } => (ProbeKind::NotConfigured, None, String::new()),
|
||||
ProbeOutcome::Passed { target } => (ProbeKind::Passed, Some(target), String::new()),
|
||||
ProbeOutcome::Auth { target, detail } => (ProbeKind::Auth, Some(target), detail),
|
||||
ProbeOutcome::ModelNotFound { target, detail } => {
|
||||
(ProbeKind::ModelNotFound, Some(target), detail)
|
||||
}
|
||||
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)
|
||||
ProbeOutcome::Unreachable { target, detail } => {
|
||||
(ProbeKind::Unreachable, Some(target), detail)
|
||||
}
|
||||
ProbeOutcome::Timeout { target, detail } => (ProbeKind::Timeout, Some(target), detail),
|
||||
ProbeOutcome::Failed { target, detail } => (ProbeKind::Failed, Some(target), detail),
|
||||
};
|
||||
Self {
|
||||
kind,
|
||||
missing,
|
||||
base_url,
|
||||
model,
|
||||
base_url: target
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |t| t.base_url.clone()),
|
||||
model: target.map_or_else(String::new, |t| t.model.clone()),
|
||||
elapsed_ms,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
/// 截图种子(`ULL_AI_STATE`):**走 [`ProbeResult::from_outcome`] 的同一条路**。
|
||||
///
|
||||
/// 为什么必须这样(r1 复审 P2-1):上一版种子直接手填 `ProbeResult` 的字段,于是「失败态
|
||||
/// 的目标是空串」这个缺陷只在产品路径上出现,截图永远看不见——**出图不镜像产品路径,等于
|
||||
/// 自欺**。现在种子只会伪造「探测结论是什么」,形状(哪些字段有值)由产品那条路决定。
|
||||
#[must_use]
|
||||
pub fn screenshot_seed(
|
||||
kind: ProbeKind,
|
||||
base_url: String,
|
||||
model: String,
|
||||
elapsed_ms: u128,
|
||||
) -> Self {
|
||||
use unlucky_cli::commands::agent::probe::{ProbeOutcome, Target};
|
||||
|
||||
let target = Target { base_url, model };
|
||||
let detail = "ProviderResponseError: status 401 Unauthorized: {\"error\":{\"message\":\"Incorrect API key provided\"}}";
|
||||
let outcome = match kind {
|
||||
ProbeKind::Passed => ProbeOutcome::Passed { target },
|
||||
ProbeKind::Auth => ProbeOutcome::Auth {
|
||||
target,
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
ProbeKind::ModelNotFound => ProbeOutcome::ModelNotFound {
|
||||
target,
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
ProbeKind::Unreachable => ProbeOutcome::Unreachable {
|
||||
target,
|
||||
detail: "Http client error: error sending request".to_string(),
|
||||
},
|
||||
ProbeKind::Timeout => ProbeOutcome::Timeout {
|
||||
target,
|
||||
detail: "8 s".to_string(),
|
||||
},
|
||||
ProbeKind::Failed => ProbeOutcome::Failed {
|
||||
target,
|
||||
detail: detail.to_string(),
|
||||
},
|
||||
// 未配置态没有 target,也就不该有「测过」的结论:种子里退到「没发请求」。
|
||||
ProbeKind::NotConfigured => ProbeOutcome::NotConfigured {
|
||||
missing: unlucky_cli::commands::agent::probe::Missing::Model,
|
||||
},
|
||||
};
|
||||
Self::from_outcome(outcome, elapsed_ms)
|
||||
}
|
||||
|
||||
/// 失败的三类之外的那几类(`NotConfigured` 没发请求)。
|
||||
#[must_use]
|
||||
pub fn sent_a_request(&self) -> bool {
|
||||
|
||||
@@ -334,25 +334,26 @@ impl State {
|
||||
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),
|
||||
let kind = match other {
|
||||
"verified" => ProbeKind::Passed,
|
||||
"failed-auth" => ProbeKind::Auth,
|
||||
"failed-model" => ProbeKind::ModelNotFound,
|
||||
"failed-unreachable" => ProbeKind::Unreachable,
|
||||
"failed-timeout" => ProbeKind::Timeout,
|
||||
_ => 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 {
|
||||
// r1 复审 P2-1:种子的**形状**必须由产品路径(`ProbeResult::from_outcome`)决定,
|
||||
// 这里只伪造「结论是哪一档」。手填 `ProbeResult` 的字段会让「失败态目标为空」
|
||||
// 这类缺陷只在产品上出现、截图永远看不见。
|
||||
state.probe = Probe::Done(ProbeResult::screenshot_seed(
|
||||
kind,
|
||||
missing,
|
||||
base_url: state.resolved_base_url(),
|
||||
model: state.model.clone(),
|
||||
elapsed_ms: 412,
|
||||
detail: String::new(),
|
||||
});
|
||||
state.resolved_base_url(),
|
||||
state.model.clone(),
|
||||
412,
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(state)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘。
|
||||
//! [`super`] 的用例:真读写五键 + 密钥只留长度 + 非法值不落盘 + 外观写回不盖 AI 键。
|
||||
//!
|
||||
//! | 用例 | 守什么 | 变异 |
|
||||
//! |---|---|---|
|
||||
@@ -7,6 +7,7 @@
|
||||
//! | `empty_draft_keeps_the_stored_key` | 密钥输入框留空 = 保留旧密钥 | 无条件写空串覆盖 |
|
||||
//! | `schema_rejects_and_nothing_is_written` | 非法值不保存 | 忽略 `set` 的 `Err` 直接 `save` |
|
||||
//! | `external_edit_is_read_back` | 现读现写(别人的改动不被旧快照盖掉) | 缓存在内存里不回读 |
|
||||
//! | `appearance_write_back_keeps_the_ai_keys` | **用户场景**:存完 AI 再改主题,值还在 | 两处任一方不重读 |
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -127,3 +128,36 @@ fn external_edit_is_read_back() {
|
||||
assert_eq!(reloaded.key_chars, 0, "外部把 api_key 删了,长度就该是 0");
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
|
||||
/// **P2-4 的用户场景原样**(r1 复审要求的用例):设置页保存 AI 配置 → 同一秒内改主题 → 值还在。
|
||||
///
|
||||
/// 这一条与 `presenter/src/settings_bridge.rs` 的 `persist_keeps_keys_another_writer_just_saved`
|
||||
/// 守的是同一个缺陷,但从**产品的两个写者**出发:`ai::store::save`(现读现写)与
|
||||
/// `SettingsBridge::persist`(长生命 store + 写前 reload)。任一方少了「写前现读」,这条红。
|
||||
#[test]
|
||||
fn appearance_write_back_keeps_the_ai_keys() {
|
||||
use unlucky_presenter::prefs::{AppearancePrefs, ThemeMode};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
let path = temp_config("appearance-keeps-ai");
|
||||
// 外观桥先加载过一份文件(它的 store 就是从这里开始的旧快照)。
|
||||
let mut bridge = SettingsBridge::with_path(&path);
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
|
||||
|
||||
// 用户在「助手」页保存配置(真落盘、含密钥)。
|
||||
let mut draft = draft();
|
||||
draft.key = Secret::new("sk-must-survive");
|
||||
save(&path, &draft).expect("保存 AI 配置");
|
||||
|
||||
// 紧接着用户去「外观」改主题(外观桥写回它那份整文档)。
|
||||
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
|
||||
|
||||
let text = std::fs::read_to_string(&path).expect("读文件");
|
||||
assert!(
|
||||
text.contains("sk-must-survive"),
|
||||
"密钥被外观写回盖掉了:{text}"
|
||||
);
|
||||
assert!(text.contains("claude-sonnet-4"), "模型名也被盖掉了:{text}");
|
||||
assert_eq!(load(&path).key_chars, "sk-must-survive".chars().count());
|
||||
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
|
||||
}
|
||||
@@ -59,6 +59,35 @@ fn secret_debug_is_masked() {
|
||||
assert!(Secret::new(" ").is_empty());
|
||||
}
|
||||
|
||||
/// **P2-2**:`Message::EditKey` 的 `Debug` 不许把密钥打进日志。
|
||||
///
|
||||
/// 现场(r1 复审):载荷是裸 `String`,而 harness / 断言失败输出会把页面消息 `{:?}` 打出来 ⇒
|
||||
/// 明文 key 进 stderr 与 CI 日志。载荷改成 [`Secret`] 之后由它自己的 `Debug` 兜住。
|
||||
///
|
||||
/// 抓得住:谁把 `EditKey(String)` 改回去(`Secret` 的 Debug 掩码见 [`secret_debug_is_masked`])。
|
||||
#[test]
|
||||
fn the_key_message_never_prints_the_key() {
|
||||
use crate::pages::settings::ai_about::Message;
|
||||
|
||||
const SECRET: &str = "sk-live-MSG-LEAK-7c2f";
|
||||
let message = Message::EditKey(Secret::new(SECRET));
|
||||
let printed = format!("{message:?}");
|
||||
assert!(
|
||||
!printed.contains(SECRET),
|
||||
"页面消息的 Debug 泄漏了密钥(它会进日志/CI 输出):{printed}"
|
||||
);
|
||||
assert!(printed.contains("***"), "应当看得见掩码标记:{printed}");
|
||||
|
||||
// 整条投递口(`pages::Message`)也一样:它才是真的被一层层 `{:?}` 的那个类型。
|
||||
let page =
|
||||
crate::pages::Message::Settings(crate::pages::settings::GroupMessage::AiAbout(message));
|
||||
let printed = format!("{page:?}");
|
||||
assert!(
|
||||
!printed.contains(SECRET),
|
||||
"页面消息漏斗泄漏了密钥:{printed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_refuses_bad_endpoint_and_empty_model() {
|
||||
let mut state = configured();
|
||||
@@ -217,58 +246,79 @@ fn saving_a_new_target_drops_the_old_pass() {
|
||||
assert_eq!(state.status(), Status::Configured);
|
||||
}
|
||||
|
||||
/// **P2-1**:失败结论必须带着「对哪个端点 / 哪个模型」,只有「没发请求」才允许没有 target。
|
||||
///
|
||||
/// 抓得住:`ProbeResult::from_outcome` 又把失败类的 target 丢掉(那正是 r1 复审的缺陷——
|
||||
/// 界面上显示成「目标: · 模型 …」,而截图因为走的是手填种子看不见它)。
|
||||
#[test]
|
||||
fn not_configured_outcome_carries_no_target() {
|
||||
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome};
|
||||
fn failures_carry_the_target_that_was_dialed() {
|
||||
use unlucky_cli::commands::agent::probe::{Missing, ProbeOutcome, Target};
|
||||
|
||||
let result = ProbeResult::from_outcome(
|
||||
let target = || Target {
|
||||
base_url: "https://api.deepseek.com/v1".to_string(),
|
||||
model: "deepseek-chat".to_string(),
|
||||
};
|
||||
|
||||
let not_configured = 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());
|
||||
assert_eq!(not_configured.kind, ProbeKind::NotConfigured);
|
||||
assert_eq!(not_configured.missing, Some(MissingField::Model));
|
||||
assert!(not_configured.base_url.is_empty(), "没发请求就没有目标地址");
|
||||
assert!(not_configured.model.is_empty());
|
||||
assert!(!not_configured.sent_a_request());
|
||||
|
||||
// 四类失败各自翻译过来,且都不带目标(它们没有「成功的目标」可说)。
|
||||
for (outcome, expected) in [
|
||||
(
|
||||
ProbeOutcome::Auth {
|
||||
target: target(),
|
||||
detail: "401".to_string(),
|
||||
},
|
||||
ProbeKind::Auth,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::ModelNotFound {
|
||||
target: target(),
|
||||
detail: "404".to_string(),
|
||||
},
|
||||
ProbeKind::ModelNotFound,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::Unreachable {
|
||||
target: target(),
|
||||
detail: "refused".to_string(),
|
||||
},
|
||||
ProbeKind::Unreachable,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::Timeout {
|
||||
target: target(),
|
||||
detail: "8 s".to_string(),
|
||||
},
|
||||
ProbeKind::Timeout,
|
||||
),
|
||||
(
|
||||
ProbeOutcome::Failed {
|
||||
target: target(),
|
||||
detail: "?".to_string(),
|
||||
},
|
||||
ProbeKind::Failed,
|
||||
),
|
||||
(ProbeOutcome::Passed { target: target() }, ProbeKind::Passed),
|
||||
] {
|
||||
let result = ProbeResult::from_outcome(outcome, 5);
|
||||
assert_eq!(result.kind, expected);
|
||||
assert!(result.sent_a_request());
|
||||
assert_eq!(
|
||||
result.base_url, "https://api.deepseek.com/v1",
|
||||
"{expected:?}"
|
||||
);
|
||||
assert_eq!(result.model, "deepseek-chat", "{expected:?}");
|
||||
if expected != ProbeKind::Passed {
|
||||
assert!(result.sent_a_request());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +338,15 @@ fn screenshot_seed_covers_the_state_matrix() {
|
||||
|
||||
let failed = State::screenshot_seed("failed-auth").expect("种子");
|
||||
assert_eq!(failed.status(), Status::Failed);
|
||||
// P2-1:种子的**形状**要走产品路径(`ProbeResult::from_outcome`),所以失败态也带 target。
|
||||
// 上一版种子手填字段、把 target 填满,于是「产品上失败态目标是空串」这个缺陷截图看不见。
|
||||
if let Probe::Done(result) = &failed.probe {
|
||||
assert_eq!(result.base_url, "https://api.deepseek.com/v1");
|
||||
assert_eq!(result.model, "deepseek-chat");
|
||||
assert!(!result.detail.is_empty(), "失败态应当带一句脱敏详情");
|
||||
} else {
|
||||
panic!("失败种子没带上探测结论");
|
||||
}
|
||||
assert!(
|
||||
State::screenshot_seed("nonsense").is_none(),
|
||||
"未知种子不伪造状态"
|
||||
|
||||
@@ -27,7 +27,7 @@ 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::state::{Probe, ProviderId, SaveNotice, Secret, State, Status};
|
||||
use crate::pages::settings::{ai_about, GroupMessage, States};
|
||||
use crate::pages::Message;
|
||||
use crate::theme::Ui;
|
||||
@@ -64,20 +64,24 @@ fn body_of<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Message>
|
||||
}
|
||||
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),
|
||||
));
|
||||
// 目标行只在真有值时才画(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,
|
||||
@@ -240,7 +244,7 @@ fn key_field<'a>(ui: &'a Ui, catalog: Catalog, state: &'a State) -> Element<'a,
|
||||
),
|
||||
);
|
||||
let field = container(text_field(ui, field, |value| {
|
||||
msg(ai_about::Message::EditKey(value))
|
||||
msg(ai_about::Message::EditKey(Secret::new(value)))
|
||||
}))
|
||||
.width(Length::Fill);
|
||||
column![
|
||||
|
||||
@@ -54,8 +54,12 @@ pub enum Message {
|
||||
EditBaseUrl(String),
|
||||
/// 模型名草稿(`ai.model`)
|
||||
EditModel(String),
|
||||
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)
|
||||
EditKey(String),
|
||||
/// 新密钥草稿(明文只在内存里;空 = 保留已存的那把)。
|
||||
///
|
||||
/// 载荷是 [`super::ai::Secret`] 而不是 `String`:**`Debug` 会打掩码**。这条消息会被 harness
|
||||
/// 的 `{:?}` 打进 stderr(r1 复审 P2-2 的现场),也会出现在任何 `assert_eq!` 失败输出里,
|
||||
/// 用裸 `String` 等于给密钥开了一条到日志的路。
|
||||
EditKey(super::ai::Secret),
|
||||
/// 密钥输入框明文/掩码
|
||||
Reveal(bool),
|
||||
/// 保存(校验 + 落盘)
|
||||
|
||||
Reference in new issue
Block a user