diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs index 7a2d2ac..8f32caa 100644 --- a/crates/app-iced/src/app/update/agent.rs +++ b/crates/app-iced/src/app/update/agent.rs @@ -187,12 +187,16 @@ impl App { }) } - /// 待确认请求出现 ⇒ 焦点直接落在「允许」并打开键盘巡航;消失 ⇒ 收回输入框。 + /// 待确认请求出现 ⇒ 焦点直接落在「允许」;消失 ⇒ 收回输入框。 /// - /// 为什么要**打开** `keyboard_mode`(而不是只挪焦点):待确认时 `busy` 锁着发送键, - /// `Stop` 又走主后端、清不掉 presenter 的 pending——键盘用户若够不着这两个按钮,这一轮对话 - /// 就再也推不动(复审 P2-2 的原话)。所以这是一个**阻塞态**:焦点环主动亮出来, - /// 按 `Enter` 或 `Space` 就能裁决。 + /// **只挪焦点,不替用户打开键盘巡航**(M119 r2 / 复审 P2-2):r1 在这里顺手把 + /// `keyboard_mode` 置真,而 `sync_confirm_focus` 是 `on_agent` 的无条件尾巴、`Events` 又由心跳 + /// 驱动——于是只要冒出待确认,全局键盘闸就被打开;用户发完那句话接着打下一句,第一个空格 + /// 就成了「批准写操作」。现在那道闸只由真实的键盘表态打开(`Tab`,见 `AgentUi::step_focus`; + /// 或者 `on_agent_key` 把按键送进来时用户本来就在键盘巡航)。 + /// + /// 键盘用户怎么走到确认条:待确认时焦点已经在这两个按钮上,**按一次 `Tab` 点亮焦点环即是 + /// 表态**(`step_focus` 里那条 arming 规则:这一次不移动焦点),之后 `Enter`/`Space` 就能裁决。 /// /// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。 fn sync_confirm_focus(&mut self) { @@ -200,8 +204,7 @@ impl App { let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); if pending && !on_confirm { self.agent.focus = Focus::ConfirmAllow; - self.agent.keyboard_mode = true; - eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Enter/Space 裁决)"); + eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)"); } else if !pending && on_confirm { self.agent.focus = Focus::Composer; self.agent.keyboard_mode = false; @@ -258,6 +261,15 @@ impl App { let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string()); self.drain_agent(); } + Fixture::Armed => { + // 先跑一轮(写操作脚本会挂一个待确认),再按一次 `Tab`: + // 待确认时这一下只 arm、不移动焦点(见 `AgentUi::step_focus` 的 arming 规则) + let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string()); + self.drain_agent(); + let rail = self.agent_host.rail(); + let (busy, pending) = (rail.busy, rail.confirm.is_some()); + self.agent.step_focus(false, busy, pending); + } Fixture::Focused => { let rail = self.agent_host.rail(); let (busy, pending) = (rail.busy, rail.confirm.is_some()); diff --git a/crates/app-iced/src/app/update/agent/tests.rs b/crates/app-iced/src/app/update/agent/tests.rs index 4232332..e1a6da3 100644 --- a/crates/app-iced/src/app/update/agent/tests.rs +++ b/crates/app-iced/src/app/update/agent/tests.rs @@ -275,15 +275,27 @@ fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() { app.agent_host.rail().confirm.is_some(), "先要有一个待确认请求" ); - // ① 焦点主动落在「允许」,并且键盘巡航已经打开(否则 Enter 那道闸过不去) + // ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2) assert_eq!( app.agent.focus, Focus::ConfirmAllow, "焦点要落在可操作元素上" ); - assert!(app.agent.keyboard_mode, "阻塞态要主动亮出焦点环"); + assert!( + !app.agent.keyboard_mode, + "塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」" + ); - // ② Enter = 允许 + // ② 第一次 Tab = 「我要用键盘」的表态:只点亮焦点环,**不移动**焦点 + let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false })); + assert!(app.agent.keyboard_mode, "Tab 之后才算 armed"); + assert_eq!( + app.agent.focus, + Focus::ConfirmAllow, + "arming 的那一次不移动焦点(否则 Enter 会反着裁决)" + ); + + // ③ Enter = 允许 let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter)))); assert!( app.agent_host.rail().confirm.is_none(), @@ -293,12 +305,48 @@ fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() { assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框"); assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环"); - // ③ 第二轮走「Tab 到拒绝 + Space 触发」:键盘用户不必背 Enter 是哪一个 + // ④ 第二轮走「Tab 点环 → Tab 走到拒绝 → Space 触发」:键盘用户不必背 Enter 是哪一个 submit(&mut app, "那把并发改回来"); assert_eq!(app.agent.focus, Focus::ConfirmAllow); let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false })); + let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false })); assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」"); let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space)))); assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决"); assert_eq!(app.agent_host.audit_len(), 2); } + +/// **复审 r2 / P2-2 的反向回归**:没 armed 的时候,空格与回车都不许裁决写操作。 +/// +/// 复审用临时用例复现的路径:WriteOp 提交后(r1 无条件 arm),用户接着打下一句,第一个空格就成了 +/// 「允许」。真实场景里编辑器仍持 iced 焦点,所以这条不是理论问题。 +/// +/// 抓得住:谁把 `sync_confirm_focus` 里的 `keyboard_mode = true` 加回来;谁把 `Space`/`Enter` 的 +/// `Activate` 绕到 `keyboard_mode` 闸之外。 +/// 抓不住:iced 组件树里空格到底有没有进输入框(那要真窗口,出图另算)——这里只断言「没裁决」。 +#[test] +fn a_stray_space_or_enter_never_approves_a_pending_write() { + let mut app = app_with(Script::WriteOp); + submit(&mut app, "把下载并发改小"); + assert!(app.agent_host.rail().confirm.is_some()); + assert!(!app.agent.keyboard_mode, "待确认不该自动 arm"); + + // 用户接着打下一句:先敲一个字符(编辑器自己消化),再敲空格 + let _ = app.update(Message::Agent(AgentMessage::Edit( + text_editor::Action::Edit(text_editor::Edit::Insert('a')), + ))); + let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space)))); + assert!( + app.agent_host.rail().confirm.is_some(), + "未 armed 时空格不许批准写操作" + ); + assert_eq!(app.agent_host.audit_len(), 0, "也不许留下审计"); + + // 回车同理:未 armed 时它归编辑器(发送键),不归确认条 + let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter)))); + assert!( + app.agent_host.rail().confirm.is_some(), + "未 armed 时回车不许批准写操作" + ); + assert_eq!(app.agent_host.audit_len(), 0); +} diff --git a/crates/app-iced/src/pages/agent/conversation.rs b/crates/app-iced/src/pages/agent/conversation.rs index 8c85e78..84980b6 100644 --- a/crates/app-iced/src/pages/agent/conversation.rs +++ b/crates/app-iced/src/pages/agent/conversation.rs @@ -56,7 +56,7 @@ use crate::app::components::interaction::Interaction; use crate::theme::Ui; use super::state::{Focus, RailState}; -use super::{Failure, Message}; +use super::{Disclaimer, Failure, Message}; mod banners; mod composer; @@ -90,11 +90,12 @@ pub struct Conversation<'a> { pub confirm: Option<&'a ConfirmRequest>, /// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。 pub failure: Option>, - /// 这套后端是不是模拟的(`true` ⇒ 标题旁常驻「模拟」标记 + 副标题说清楚)。 + /// 「这是模拟助手」的**四处文案**(副标题 / 占位符 / 空态正文 / 标题胶囊), + /// 全部由 [`Disclaimer::of`] 一次算好——视图里没有第二种取词路径。 /// - /// M119 r1 / 复审 P2-1:生产默认后端是脚本化 rig,而界面上曾**没有任何模拟标识**—— - /// 用户敲一句话得到一段像是模型编的回答。这个字段就是那个事实的渲染入口(不是硬编码)。 - pub simulated: bool, + /// M119 r1 / r2 复审 P2-1:生产默认后端是脚本化 rig,界面上曾**没有任何模拟标识**; + /// 上一轮又只有标题胶囊认这个开关,另外三处会反方向地留在「没接真实模型」上。 + pub note: Disclaimer<'a>, /// 三档状态(决定头部画哪组动作)。 pub rail: RailState, /// 宿主。 @@ -119,7 +120,7 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element } body = body.push(if spec.messages.is_empty() { - transcript::empty(ui, catalog) + transcript::empty(ui, catalog, spec.note.empty_body) } else { transcript::list(ui, spec, catalog) }); @@ -148,7 +149,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<' .spacing(ui.space(Space::X2)) .align_y(iced::Alignment::Center); // 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据) - if spec.simulated { + if spec.note.simulated { title = title.push(simulated_chip(ui, catalog)); } title = title.push(unread_badge(ui, spec.unread, catalog)); @@ -159,7 +160,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<' ui, TypeRole::LabelMedium, false, - catalog.text(Key::AgentSubtitle), + spec.note.subtitle, ui.color(Role::OnSurfaceVariant), Length::Fill, ), diff --git a/crates/app-iced/src/pages/agent/conversation/composer.rs b/crates/app-iced/src/pages/agent/conversation/composer.rs index d7fc18c..a642c2c 100644 --- a/crates/app-iced/src/pages/agent/conversation/composer.rs +++ b/crates/app-iced/src/pages/agent/conversation/composer.rs @@ -37,7 +37,7 @@ pub(super) fn composer<'a>( let style = ui.style(TypeRole::BodyMedium); let field = editor(spec.draft) - .placeholder(catalog.text(Key::AgentPlaceholder)) + .placeholder(spec.note.placeholder) .on_action(Message::Edit) .key_binding(binding_for) .size(style.size_px) diff --git a/crates/app-iced/src/pages/agent/conversation/transcript.rs b/crates/app-iced/src/pages/agent/conversation/transcript.rs index 226ab8d..8e7e911 100644 --- a/crates/app-iced/src/pages/agent/conversation/transcript.rs +++ b/crates/app-iced/src/pages/agent/conversation/transcript.rs @@ -28,14 +28,12 @@ use super::super::Message; use super::{text_role, text_role_owned, Conversation}; /// 空会话:引导空态(零条消息时替代气泡列表)。 -pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { +/// +/// `body` 由调用方从 [`super::super::Disclaimer`] 取(模拟态/真实态两句话),这里不再自己取词。 +pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog, body: &'a str) -> Element<'a, Message> { container(empty_state( ui, - EmptyState::new( - IconKind::Ai, - catalog.text(Key::AgentEmptyTitle), - catalog.text(Key::AgentEmptyBody), - ), + EmptyState::new(IconKind::Ai, catalog.text(Key::AgentEmptyTitle), body), )) .width(Length::Fill) .height(Length::Fill) diff --git a/crates/app-iced/src/pages/agent/demo.rs b/crates/app-iced/src/pages/agent/demo.rs index b3f517a..65b213c 100644 --- a/crates/app-iced/src/pages/agent/demo.rs +++ b/crates/app-iced/src/pages/agent/demo.rs @@ -41,6 +41,11 @@ pub enum Fixture { Unread, /// 键盘巡航一次的样子(焦点环 = 10% 状态层)。 Focused, + /// 跑一轮真提交,然后**按一次 `Tab`**(M119 r2 出图用)。 + /// + /// 复现的是键盘用户走到确认条的路径:待确认出现 ⇒ 焦点已落在「允许」但**键盘闸没开** + /// (图 `05`,看不到环);按一次 `Tab` 才算表态(图 `09`,环亮出来且焦点不动)。 + Armed, } /// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容, @@ -86,6 +91,7 @@ pub fn from_env() -> Option<(AgentUi, Fixture)> { Some("turn") => Fixture::Turn, Some("unread") => Fixture::Unread, Some("focused") => Fixture::Focused, + Some("armed") => Fixture::Armed, Some("empty") | None => Fixture::None, Some(other) => { eprintln!("[agent] {FIXTURE_ENV}={other:?} 不是夹具之一,按 empty 处理"); diff --git a/crates/app-iced/src/pages/agent/mod.rs b/crates/app-iced/src/pages/agent/mod.rs index d5d91fc..e370868 100644 --- a/crates/app-iced/src/pages/agent/mod.rs +++ b/crates/app-iced/src/pages/agent/mod.rs @@ -126,6 +126,53 @@ pub struct Failure<'a> { pub body: &'a str, } +/// 「这是模拟助手」的**四处文案**(M119 r2 / 复审 P2-1)。 +/// +/// ## 为什么是一个结构体而不是四个 `if` +/// +/// 上一轮只有标题胶囊由 `simulated` 驱动,另外三处(副标题 / 输入框占位符 / 空态正文)是 +/// **无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后,这三句会 +/// 反方向地留着(`simulated == false` 时界面上还在说「没接真实模型」)。复审把这条列为 P2。 +/// +/// 现在四处**只认这一个开关**:`Disclaimer::of(catalog, simulated)` 一次算好,视图没有第二种 +/// 取词路径。`pages/agent/tests/disclaimer.rs` 钉住「四处一起出现 / 一起消失」。 +#[derive(Debug, Clone, Copy)] +pub struct Disclaimer<'a> { + /// 这套后端是不是模拟的(标题旁的胶囊只在 `true` 时画)。 + pub simulated: bool, + /// 标题下的副标题。 + pub subtitle: &'a str, + /// 输入框占位符。 + pub placeholder: &'a str, + /// 空会话正文。 + pub empty_body: &'a str, +} + +impl<'a> Disclaimer<'a> { + /// 按 `simulated` 选模拟态或真实态那一套文案。 + #[must_use] + pub fn of(catalog: Catalog, simulated: bool) -> Self { + Self { + simulated, + subtitle: if simulated { + catalog.text(Key::AgentSubtitle) + } else { + catalog.text(Key::AgentSubtitleReal) + }, + placeholder: if simulated { + catalog.text(Key::AgentPlaceholder) + } else { + catalog.text(Key::AgentPlaceholderReal) + }, + empty_body: if simulated { + catalog.text(Key::AgentEmptyBody) + } else { + catalog.text(Key::AgentEmptyBodyReal) + }, + } + } +} + /// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。 /// /// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加 @@ -139,7 +186,8 @@ pub struct Session<'a> { pub rail: &'a AgentRail, /// 失败横幅的展示输入。 pub failure: Option>, - /// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记。 + /// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记, + /// 且三处文案走模拟态(见 [`Disclaimer`]:**四处一起变**,只认这一个开关)。 pub simulated: bool, } @@ -172,7 +220,7 @@ pub fn page<'a>( ) -> Element<'a, Message> { let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width); - let body = conversation::view(ui, spec(session, Host::Page), catalog); + let body = conversation::view(ui, spec(session, catalog, Host::Page), catalog); container( container(body) @@ -230,19 +278,23 @@ fn panel<'a>( ) -> Element<'a, Message> { let width = session.agent.rail_width(window_width); - let body = container(conversation::view(ui, spec(session, Host::Rail), catalog)) - .width(Length::Fixed(width)) - .height(Length::Fill) - .padding(Padding { - top: ui.space(Space::X6), - right: ui.space(Space::X4), - bottom: ui.space(Space::X4), - left: ui.space(Space::X4), - }) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), - ..container::Style::default() - }); + let body = container(conversation::view( + ui, + spec(session, catalog, Host::Rail), + catalog, + )) + .width(Length::Fixed(width)) + .height(Length::Fill) + .padding(Padding { + top: ui.space(Space::X6), + right: ui.space(Space::X4), + bottom: ui.space(Space::X4), + left: ui.space(Space::X4), + }) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), + ..container::Style::default() + }); row![splitter(ui, width, catalog), body] .width(Length::Shrink) @@ -300,7 +352,7 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message } /// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。 -fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> { +fn spec<'a>(session: Session<'a>, catalog: Catalog, host: Host) -> Conversation<'a> { Conversation { // 会话内容、未读、busy、待确认:**全部**来自 presenter(M119) messages: &session.rail.messages, @@ -308,7 +360,7 @@ fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> { unread: session.rail.unread, confirm: session.rail.confirm.as_ref(), failure: session.failure, - simulated: session.simulated, + note: Disclaimer::of(catalog, session.simulated), draft: &session.agent.draft, blank: session.agent.draft_is_blank(), rail: session.agent.rail, diff --git a/crates/app-iced/src/pages/agent/state.rs b/crates/app-iced/src/pages/agent/state.rs index a667193..651a8d9 100644 --- a/crates/app-iced/src/pages/agent/state.rs +++ b/crates/app-iced/src/pages/agent/state.rs @@ -300,9 +300,20 @@ impl AgentUi { } /// Tab 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。 + /// + /// **待确认的那一次 Tab 只点亮焦点环、不移动焦点**:待确认出现时 app 层已经把焦点放到 + /// 「允许」上(阻塞态,见 `App::sync_confirm_focus`),但**不会**替用户把键盘巡航打开 + /// (M119 r2 / 复审 P2-2:自动 arm 会让用户随手敲的空格变成「批准」)。所以第一次 `Tab` + /// 是「我要用键盘」的显式表态,此时焦点原地不动——否则第一次 Tab 会把「允许」挪到「拒绝」, + /// 用户按 `Enter` 就是反着裁决。 pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) { - self.focus = self.focus.step(shift, self.rail, busy, confirm_pending); + let arming = !self.keyboard_mode + && confirm_pending + && matches!(self.focus, Focus::ConfirmAllow | Focus::ConfirmDeny); self.keyboard_mode = true; + if !arming { + self.focus = self.focus.step(shift, self.rail, busy, confirm_pending); + } } } diff --git a/crates/app-iced/src/pages/agent/tests.rs b/crates/app-iced/src/pages/agent/tests.rs index 2f7e748..b01d3c7 100644 --- a/crates/app-iced/src/pages/agent/tests.rs +++ b/crates/app-iced/src/pages/agent/tests.rs @@ -14,6 +14,7 @@ use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity} use crate::theme::{Families, Ui}; +mod disclaimer; mod drag; use super::conversation::{Conversation, Host}; @@ -317,13 +318,21 @@ fn the_spec_borrows_the_presenter_rail_verbatim() { failure: None, simulated: true, }; - let spec = super::spec(session, Host::Rail); + let spec = super::spec(session, Catalog::of(Lang::En), Host::Rail); assert_eq!(spec.messages.len(), rail.messages.len()); assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息"); assert_eq!(spec.unread, rail.unread); assert_eq!(spec.busy, rail.busy); assert!(spec.failure.is_none()); - assert!(spec.simulated, "模拟事实要原样传给视图(标记不是硬编码)"); + assert!( + spec.note.simulated, + "模拟事实要原样传给视图(标记不是硬编码)" + ); + assert!( + spec.note.subtitle.to_lowercase().contains("simulated"), + "副标题也归这个开关管:{}", + spec.note.subtitle + ); } /// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。 @@ -376,7 +385,7 @@ fn every_state_builds_in_both_hosts() { blank: agent.draft_is_blank(), confirm: rail.confirm.as_ref(), failure: shown, - simulated: true, + note: Disclaimer::of(catalog, true), rail: agent.rail, host: Host::Rail, focus: agent.keyboard_mode.then_some(agent.focus), diff --git a/crates/app-iced/src/pages/agent/tests/disclaimer.rs b/crates/app-iced/src/pages/agent/tests/disclaimer.rs new file mode 100644 index 0000000..fcb637f --- /dev/null +++ b/crates/app-iced/src/pages/agent/tests/disclaimer.rs @@ -0,0 +1,96 @@ +//! 「这是模拟助手」的四处文案必须**一起**变(M119 r2 / 复审 P2-1 的验收标准)。 +//! +//! ## 为什么这个文件存在 +//! +//! 复审 r2 的 P2-1 抓得很准:`simulated` 是真字段,但 r1 只有**标题胶囊**一个消费者,另外三处 +//! (副标题 / 输入框占位符 / 空态正文)是**无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」。 +//! 于是「接上真 transport 之后四处一起消失」只是一句声明——没有测试钉住它。 +//! +//! 现在四处都从 [`Disclaimer`] 取词(`Disclaimer::of(catalog, simulated)`),本文件就是那句声明的 +//! 证据:`simulated == true` 四处都提模拟;`simulated == false` 四处**都不提**。 +//! +//! 抓得住:谁把某一处改回无条件取词(那就会出现「字段说真、界面说模拟」的反向假话)。 +//! 抓不住:文案本身翻得好不好读(那要人看),也不管视图有没有真的用这几个字段 +//! (那是 `tests.rs::the_spec_borrows_the_presenter_rail_verbatim` 与出图的活)。 + +use unlucky_i18n::{Catalog, Key, Lang}; + +use super::super::Disclaimer; + +/// 这句话里有没有「模拟 / 没接真实模型」的意思(中英各一套写法)。 +fn mentions_simulated(text: &str) -> bool { + let lower = text.to_lowercase(); + lower.contains("simulated") || lower.contains("no real model") || text.contains("模拟") +} + +/// **四处一起出现**(`simulated == true`):副标题 / 占位符 / 空态正文 / 标题胶囊。 +#[test] +fn all_four_disclaimers_appear_when_the_backend_is_simulated() { + for lang in [Lang::En, Lang::ZhCn] { + let catalog = Catalog::of(lang); + let note = Disclaimer::of(catalog, true); + assert!(note.simulated, "{}:开关要传下去", lang.tag()); + for (place, text) in [ + ("副标题", note.subtitle), + ("输入框占位符", note.placeholder), + ("空态正文", note.empty_body), + // 第四处是标题胶囊:只在 `simulated` 时渲染,文案来自这条 key + ("标题胶囊", catalog.text(Key::AgentSimulated)), + ] { + assert!( + mentions_simulated(text), + "{} 的{place}没有说清「这是模拟」:{text:?}", + lang.tag() + ); + } + } +} + +/// **四处一起消失**(`simulated == false`):三处文案都不许再提模拟,胶囊也不该被画。 +/// +/// 这条就是复审要的那一句验收:接上真 transport 那天,界面不会再反向地声明「没接真实模型」。 +#[test] +fn no_disclaimer_survives_when_a_real_backend_is_wired() { + for lang in [Lang::En, Lang::ZhCn] { + let catalog = Catalog::of(lang); + let note = Disclaimer::of(catalog, false); + assert!(!note.simulated, "{}:开关要传下去", lang.tag()); + for (place, text) in [ + ("副标题", note.subtitle), + ("输入框占位符", note.placeholder), + ("空态正文", note.empty_body), + ] { + assert!( + !mentions_simulated(text), + "{} 的{place}在真实态还在说模拟:{text:?}", + lang.tag() + ); + } + // 真实态的三句也得是**有内容的话**,不能靠空串糊过去 + for (place, text) in [ + ("副标题", note.subtitle), + ("输入框占位符", note.placeholder), + ("空态正文", note.empty_body), + ] { + assert!(!text.trim().is_empty(), "{} 的{place}是空的", lang.tag()); + } + } +} + +/// 两态必须是**两套不同的文案**(同一个 key 两边都用就退化成「永远说模拟」或「永远不说」)。 +#[test] +fn the_two_states_use_different_keys() { + let catalog = Catalog::of(Lang::En); + assert_ne!( + catalog.text(Key::AgentSubtitle), + catalog.text(Key::AgentSubtitleReal) + ); + assert_ne!( + catalog.text(Key::AgentPlaceholder), + catalog.text(Key::AgentPlaceholderReal) + ); + assert_ne!( + catalog.text(Key::AgentEmptyBody), + catalog.text(Key::AgentEmptyBodyReal) + ); +} diff --git a/crates/app-iced/src/presenter/agent.rs b/crates/app-iced/src/presenter/agent.rs index dbadc0e..f6dd375 100644 --- a/crates/app-iced/src/presenter/agent.rs +++ b/crates/app-iced/src/presenter/agent.rs @@ -88,8 +88,10 @@ pub(crate) struct AgentHost { /// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。 /// /// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport` - /// 的那一天,这里传 `false`,界面上的「模拟」标记会自动消失——**标记不是硬编码在视图里的**, - /// 它是这个事实字段的渲染。 + /// 的那一天,这里传 `false`——**四处「模拟」措辞会一起消失**(标题胶囊 / 副标题 / 输入框占位符 / + /// 空态正文,全部出自 `pages::agent::Disclaimer::of(catalog, simulated)`)。 + /// 标记不是硬编码在视图里的,它是这个事实字段的渲染;「四处一起变」由 + /// `pages/agent/tests/disclaimer.rs` 钉住(r1 时只有一处认它,那句声明是空的)。 simulated: bool, queue: EventQueue, failure: FailureSlot, diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index a83532d..c3e329a 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -126,6 +126,39 @@ mod logs; mod nav; mod settings; +/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。 +/// +/// ## 它是什么 +/// +/// M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」 +/// 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来, +/// 再原封不动抄进来(生成方式见 `tests.rs` 里 `split_keeps_every_translation` 的注释)。 +/// M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条 +/// (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。 +/// +/// ## 为什么不是「数条数」 +/// +/// 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。 +/// 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红: +/// +/// | 坏法 | 红在哪 | +/// |---|---| +/// | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 | +/// | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 | +/// | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) | +/// | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 | +/// +/// **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。 +/// +/// ## M108 更新了三条、新增了一条(版本管理改名) +/// +/// 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」: +/// [`Key::NavDownload`] / [`Key::DownloadTitle`] / [`Key::DownloadSubtitle`] +/// 三条译文按令改掉,并**新增** [`Key::NavDownloadExpanded`](240 展开态的全标签, +/// 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。 +/// +/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的, +/// 其余一字未动」,不再是「整表一字未动」。 #[cfg(test)] mod golden; diff --git a/crates/i18n/src/catalog/agent.rs b/crates/i18n/src/catalog/agent.rs index 8e168bf..22dac43 100644 --- a/crates/i18n/src/catalog/agent.rs +++ b/crates/i18n/src/catalog/agent.rs @@ -50,6 +50,15 @@ macro_rules! agent_entries { AgentConfirmDeny => ("Deny", "拒绝"), // 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」 AgentSimulated => ("Simulated", "模拟"), + // ── M119 r2:三处文案的**真实态**配对(模拟态用上面那三条已有的 key) + // + // 复审 r2 的 P2-1:上一轮只有标题胶囊由 `simulated` 驱动,副标题/占位符/空态正文是 + // 无条件取词,而那三条译文里写着「模拟 / 没接真实模型」——接上真 transport 之后 + // 这三句会**反方向**地留着。现在配成对:`simulated == true` 走模拟态, + // `false` 走这三条中性话(`pages/agent/tests/disclaimer.rs` 钉住四处一起变)。 + AgentSubtitleReal => ("Conversation", "会话"), + AgentPlaceholderReal => ("Message the assistant…", "给助手发消息…"), + AgentEmptyBodyReal => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), } } }; } diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index db77d40..489ec58 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -1,36 +1,7 @@ -//! 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。 +//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。 //! -//! ## 它是什么 -//! -//! M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」 -//! 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来, -//! 再原封不动抄进来(生成方式见 `catalog/tests.rs` 里 `split_keeps_every_translation` 的注释)。 -//! M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条 -//! (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。 -//! -//! ## 为什么不是「数条数」 -//! -//! 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。 -//! 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红: -//! -//! | 坏法 | 红在哪 | -//! |---|---| -//! | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 | -//! | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 | -//! | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) | -//! | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 | -//! -//! **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。 -//! -//! ## M108 更新了三条、新增了一条(版本管理改名) -//! -//! 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」: -//! [`super::Key::NavDownload`] / [`super::Key::DownloadTitle`] / [`super::Key::DownloadSubtitle`] -//! 三条译文按令改掉,并**新增** [`super::Key::NavDownloadExpanded`](240 展开态的全标签, -//! 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。 -//! -//! 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的, -//! 其余一字未动」,不再是「整表一字未动」。 +//! 「为什么有这么一张表、它抓得住什么」写在 `catalog.rs` 中 `mod golden;` 的文档里—— +//! M119 r2 把它搬到那儿,是为了给这张表腾出追加新 key 的行数(400 行硬线),一个字的说明都没删。 /// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。 pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ @@ -150,6 +121,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("AgentConfirmAllow", "Allow", "允许"), ("AgentConfirmDeny", "Deny", "拒绝"), ("AgentSimulated", "Simulated", "模拟"), + ("AgentSubtitleReal", "Conversation", "会话"), + ("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"), + ("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), // ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**, // 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。 ("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),