fix(app-iced): M119 r1 —— 常驻「模拟」标识 + 确认条的键盘通路

复审 r1 的两条 p2,都是「说成真的、其实是假的」与「能走进死路」这个级别的问题。

## P2-1:界面必须自己说清「这是模拟」

原判:生产默认后端是**脚本化 rig**(`ULL_AGENT_SCRIPT` 的 6 种剧情),而「后端未接 / 模型还没
接上」两句诚实文案被我改成了中性话,理由写着「由端点标签说」——**那条端点标签在壳里根本不存在**。
于是默认配置下用户敲一句话得到一段像是模型编的回答。判据:没读过代码的用户,打开右栏、敲一句话,
必须能自己看出来这不是真模型(不接受 tooltip、不接受藏在设置里)。

修法(①恢复诚实文案 + ②常驻可见标记,并用):

| 位置 | 文案 / 形态 | 谁给的 |
|---|---|---|
| 标题右侧**常驻胶囊**(`SurfaceVariant` tonal)| `Key::AgentSimulated`(Simulated / 模拟)| `AgentHost::is_simulated()` → `Session.simulated` → 视图 |
| 标题下副标题 | 「模拟助手 · 还没接真实模型」 | `Key::AgentSubtitle`(原本是「未接」被改成中性的「会话」,现在写回来且更明确)|
| 空会话正文 | 「…当前是模拟助手,没有接真实模型。」 | `Key::AgentEmptyBody` |
| 输入框占位符 | 「给模拟助手发消息…」 | `Key::AgentPlaceholder` |

**标记不是硬编码在视图里**:`AgentHost.simulated` 是事实字段(今天 transport 只有 rig ⇒ 恒 true)。
接真 `AgentTransport` 那天把它传 `false`,四个位置的「模拟」措辞会一起消失——这是为什么用字段而不是
写死一句话。

## P2-2:确认条必须有键盘通路

原判:Allow/Deny 只有鼠标路径 ⇒ 键盘用户在写操作确认处**永久卡死**(`busy` 锁发送、
`Stop` 走主后端、清不掉 presenter 的 pending confirm)。

- 焦点环多两格:`Focus::ConfirmAllow` / `Focus::ConfirmDeny`,**只在有待确认时**存在
  (鼠标用户不会 Tab 到不存在的按钮);
- 待确认出现 ⇒ `sync_confirm_focus()` 把焦点直接落到「允许」并**打开键盘巡航**(阻塞态主动亮环),
  消失 ⇒ 焦点收回输入框、巡航关闭;
- `Enter` 与 `Space` 都映射到 `Activate`(`Space` 不抢输入框:`keyboard_mode` 那道闸在
  鼠标点进输入框时是关的);
- 焦点环不是 10% 状态层——在**实心主色**按钮上那层几乎看不见(出图实测过一版就是没有)。
  改成 2dp `Primary` 描边 + 3px 留白,两个按钮恒定加这层 padding,所以焦点变化不移动布局。

测试(`crates/app-iced/src/app/update/agent/tests.rs`):
`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` —— 全程只有键盘消息:
确认到达后断言 `focus == ConfirmAllow && keyboard_mode`,发一条 `KeyPressed(Enter)` 完成允许;
第二轮 `Tab`(走到 Deny)+ `KeyPressed(Space)` 完成拒绝;两轮各记一条审计、焦点都收回 Composer。
另有 `the_default_backend_reports_itself_as_simulated` 钉住 P2-1 的事实来源。

## 视图入参顺带收成结构体

`docked`/`page` 本来是 6 个形参(`ui, agent, rail, failure, catalog, width`),这一轮还要加
`simulated` ⇒ 7 个。按塔台登记的可维护性口径改成 `Session { agent, rail, failure, simulated }`:
两个宿主各 3 个形参,以后再加展示事实只改 `Session`。

## 出图(18 张,浅深各一)

`DevDocs/artifacts/m119/`:新增 `08-agent-simulated-*`(空会话 + 四处「模拟」措辞)与
`09-agent-confirm-focus-*`(确认条 + 焦点环落在「允许」);其余 7 组全部重出(文案与焦点环都变了)。

## 门禁(真实退出码)

| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(601 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2277 passed / 0 failed / 11 ignored)|

`golden.rs` 400 行(+1 条 `AgentSimulated` 正好顶线);全树受检文件无 >400。
This commit is contained in:
AskaEth committed 2026-11-05 09:17:50 +08:00
1 parent b5e96c61ac
commit a22baebe70
13 files changed
+383 -122

No files matched your search

+36 -5
View File
@@ -106,13 +106,16 @@ impl App {
self.view_dirty = true; self.view_dirty = true;
} }
agent::Message::Tab { shift } => { agent::Message::Tab { shift } => {
let busy = self.agent_host.rail().busy; let rail = self.agent_host.rail();
self.agent.step_focus(shift, busy); let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(shift, busy, pending);
} }
agent::Message::Activate => self.activate_focused(catalog), agent::Message::Activate => self.activate_focused(catalog),
agent::Message::Key(event) => return self.on_agent_key(event), agent::Message::Key(event) => return self.on_agent_key(event),
} }
// 待确认请求出现/消失时,把键盘焦点挪到(或挪离)确认条的按钮上
self.sync_confirm_focus();
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入) // 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
self.sync_agent_presenter(); self.sync_agent_presenter();
if geometry_changed { if geometry_changed {
@@ -184,6 +187,27 @@ impl App {
}) })
} }
/// 待确认请求出现 ⇒ 焦点直接落在「允许」并打开键盘巡航;消失 ⇒ 收回输入框。
///
/// 为什么要**打开** `keyboard_mode`(而不是只挪焦点):待确认时 `busy` 锁着发送键,
/// `Stop` 又走主后端、清不掉 presenter 的 pending——键盘用户若够不着这两个按钮,这一轮对话
/// 就再也推不动(复审 P2-2 的原话)。所以这是一个**阻塞态**:焦点环主动亮出来,
/// 按 `Enter` 或 `Space` 就能裁决。
///
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
fn sync_confirm_focus(&mut self) {
let pending = self.agent_host.rail().confirm.is_some();
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 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
}
}
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。 /// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
/// ///
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变, /// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
@@ -235,8 +259,9 @@ impl App {
self.drain_agent(); self.drain_agent();
} }
Fixture::Focused => { Fixture::Focused => {
let busy = self.agent_host.rail().busy; let rail = self.agent_host.rail();
self.agent.step_focus(false, busy); let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
} }
} }
} }
@@ -269,7 +294,11 @@ impl App {
}, },
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由 // `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。 // `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
Key::Named(key::Named::Enter) => agent::Message::Activate, //
// M119 r1 / P2-2:`Space` 与 `Enter` 等价(键盘用户对确认条的习惯动作是空格)。
// 它不会抢输入框里的空格:`activate_focused` 头一行就是 `keyboard_mode` 闸,
// 而用鼠标点进输入框时那道闸是关的(`AgentUi::edit` 的 `Click` 分支)。
Key::Named(key::Named::Enter | key::Named::Space) => agent::Message::Activate,
_ => return Task::none(), _ => return Task::none(),
}; };
@@ -315,6 +344,8 @@ impl App {
agent::Message::Detach agent::Message::Detach
}) })
} }
(_, Focus::ConfirmAllow) => Some(agent::Message::Confirm(true)),
(_, Focus::ConfirmDeny) => Some(agent::Message::Confirm(false)),
(_, Focus::Send) => Some(agent::Message::Submit), (_, Focus::Send) => Some(agent::Message::Submit),
(_, Focus::Stop) => Some(agent::Message::Stop), (_, Focus::Stop) => Some(agent::Message::Stop),
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格, // 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
+83 -1
View File
@@ -22,11 +22,14 @@ use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole};
use unlucky_presenter::mock::MockBackend; use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::settings_bridge::SettingsBridge;
use iced::keyboard::key::{Code, Named, Physical};
use iced::keyboard::{key, Location, Modifiers};
use crate::app::state::presenter::transport::Script; use crate::app::state::presenter::transport::Script;
use crate::app::state::presenter::AgentHost; use crate::app::state::presenter::AgentHost;
use crate::app::Message; use crate::app::Message;
use crate::cli::Options; use crate::cli::Options;
use crate::shell::agent::{Message as AgentMessage, RailState}; use crate::shell::agent::{Focus, Message as AgentMessage, RailState};
use crate::theme::Families; use crate::theme::Families;
use super::App; use super::App;
@@ -202,6 +205,27 @@ fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() {
} }
/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。 /// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。
/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。
///
/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。
/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。
#[test]
fn the_default_backend_reports_itself_as_simulated() {
let app = app_with(Script::Qa);
assert!(
app.agent_host.is_simulated(),
"脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了"
);
// 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它
let session = crate::shell::agent::Session {
agent: &app.agent,
rail: app.agent_host.rail(),
failure: app.agent_failure(),
simulated: app.agent_host.is_simulated(),
};
assert!(session.simulated);
}
#[test] #[test]
fn typing_syncs_the_draft_into_the_presenter() { fn typing_syncs_the_draft_into_the_presenter() {
let mut app = app_with(Script::Qa); let mut app = app_with(Script::Qa);
@@ -220,3 +244,61 @@ fn typing_syncs_the_draft_into_the_presenter() {
assert!(app.agent.draft_is_blank(), "编辑器清空"); assert!(app.agent.draft_is_blank(), "编辑器清空");
assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空"); assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空");
} }
/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。
fn press(named: key::Named) -> iced::keyboard::Event {
let key = key::Key::Named(named);
iced::keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: Physical::Code(Code::Enter),
location: Location::Standard,
modifiers: Modifiers::default(),
text: None,
repeat: false,
}
}
/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。
///
/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端
/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。
///
/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`;
/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。
/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。
#[test]
fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
assert!(
app.agent_host.rail().confirm.is_some(),
"先要有一个待确认请求"
);
// ① 焦点主动落在「允许」,并且键盘巡航已经打开(否则 Enter 那道闸过不去)
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"焦点要落在可操作元素上"
);
assert!(app.agent.keyboard_mode, "阻塞态要主动亮出焦点环");
// ② Enter = 允许
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
assert!(
app.agent_host.rail().confirm.is_none(),
"Enter 必须真的裁决掉"
);
assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计");
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
// ③ 第二轮走「Tab 到拒绝 + Space 触发」:键盘用户不必背 Enter 是哪一个
submit(&mut app, "那把并发改回来");
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
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);
}
+10 -20
View File
@@ -58,10 +58,15 @@ impl App {
let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
// M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层 // M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层
// 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。 // 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。
let rail = self.agent_host.rail();
let failure = self.agent_failure();
// 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG` // 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG`
let agent_catalog = self.lang().catalog(); let agent_catalog = self.lang().catalog();
// 会话入参:rail 借 presenter、失败横幅由 app 层备好、`simulated` 是后端的事实
let session = agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
};
let account = self.rail_account(); let account = self.rail_account();
match pages::Screen::of(self.state.route()) { match pages::Screen::of(self.state.route()) {
@@ -85,15 +90,8 @@ impl App {
self.language(), self.language(),
) )
.map(Message::Page), .map(Message::Page),
agent::docked( agent::docked(&self.ui, session, agent_catalog, self.width)
&self.ui, .map(|element| element.map(Message::Agent)),
&self.agent,
rail,
failure,
agent_catalog,
self.width,
)
.map(|element| element.map(Message::Agent)),
overlay, overlay,
self.options.force_state, self.options.force_state,
self.lang(), self.lang(),
@@ -112,15 +110,7 @@ impl App {
}, },
APP_TITLE, APP_TITLE,
background, background,
agent::page( agent::page(&self.ui, session, agent_catalog, self.width).map(Message::Agent),
&self.ui,
&self.agent,
rail,
failure,
agent_catalog,
self.width,
)
.map(Message::Agent),
None, None,
overlay, overlay,
self.options.force_state, self.options.force_state,
+6 -3
View File
@@ -55,9 +55,12 @@ impl App {
// M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`) // M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`)
agent::docked( agent::docked(
&self.ui, &self.ui,
&self.agent, agent::Session {
self.agent_host.rail(), agent: &self.agent,
self.agent_failure(), rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
},
self.lang().catalog(), self.lang().catalog(),
self.width, self.width,
) )
+41 -12
View File
@@ -90,6 +90,11 @@ pub struct Conversation<'a> {
pub confirm: Option<&'a ConfirmRequest>, pub confirm: Option<&'a ConfirmRequest>,
/// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。 /// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。
pub failure: Option<Failure<'a>>, pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(`true` ⇒ 标题旁常驻「模拟」标记 + 副标题说清楚)。
///
/// M119 r1 / 复审 P2-1:生产默认后端是脚本化 rig,而界面上曾**没有任何模拟标识**——
/// 用户敲一句话得到一段像是模型编的回答。这个字段就是那个事实的渲染入口(不是硬编码)。
pub simulated: bool,
/// 三档状态(决定头部画哪组动作)。 /// 三档状态(决定头部画哪组动作)。
pub rail: RailState, pub rail: RailState,
/// 宿主。 /// 宿主。
@@ -121,7 +126,7 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element
// 确认条贴着输入区(动作在下面,读完就能按)。 // 确认条贴着输入区(动作在下面,读完就能按)。
if let Some(request) = spec.confirm { if let Some(request) = spec.confirm {
body = body.push(banners::confirm(ui, request, catalog)); body = body.push(banners::confirm(ui, request, catalog, spec.focus));
} }
body.push(divider(ui)) body.push(divider(ui))
@@ -132,19 +137,21 @@ pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element
// ---------------------------------------------------------------- 头部 // ---------------------------------------------------------------- 头部
fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let title = row![ let mut title = row![text_role(
text_role( ui,
ui, TypeRole::TitleLarge,
TypeRole::TitleLarge, true,
true, catalog.text(Key::AgentTitle),
catalog.text(Key::AgentTitle), ui.color(Role::OnSurface),
ui.color(Role::OnSurface), Length::Shrink,
Length::Shrink, )]
),
unread_badge(ui, spec.unread, catalog),
]
.spacing(ui.space(Space::X2)) .spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
// 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据)
if spec.simulated {
title = title.push(simulated_chip(ui, catalog));
}
title = title.push(unread_badge(ui, spec.unread, catalog));
let titles = column![ let titles = column![
title, title,
@@ -268,6 +275,28 @@ pub(super) fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> B
} }
} }
/// 「模拟」标记:`SurfaceVariant` / `OnSurfaceVariant` 的 tonal 小胶囊(中性信息,不是告警)。
fn simulated_chip<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
let style = ui.style(TypeRole::LabelMedium);
container(
text(catalog.text(Key::AgentSimulated))
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceVariant))),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> { fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> {
if unread == 0 { if unread == 0 {
return space::horizontal().into(); return space::horizontal().into();
@@ -35,7 +35,9 @@ use unlucky_presenter::dto::ConfirmRequest;
use crate::app::components::buttons::{self, Button}; use crate::app::components::buttons::{self, Button};
use crate::theme::Ui; use crate::theme::Ui;
use super::super::state::Focus;
use super::super::{Failure, Message}; use super::super::{Failure, Message};
use super::focused_state;
use super::text_role_owned; use super::text_role_owned;
/// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。 /// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。
@@ -57,6 +59,7 @@ pub(super) fn confirm<'a>(
ui: &'a Ui, ui: &'a Ui,
request: &'a ConfirmRequest, request: &'a ConfirmRequest,
catalog: Catalog, catalog: Catalog,
focus: Option<Focus>,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示, // 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示,
// 否则用户是在盲签。句框走 catalog,摘要本身是正文。 // 否则用户是在盲签。句框走 catalog,摘要本身是正文。
@@ -64,21 +67,67 @@ pub(super) fn confirm<'a>(
Key::AgentConfirmRequest, Key::AgentConfirmRequest,
&[("summary", request.summary.as_str())], &[("summary", request.summary.as_str())],
); );
// 两个按钮都在 Tab 环上(`Focus::ConfirmAllow` / `Focus::ConfirmDeny`):键盘用户
// 用 Enter/Space 就能裁决,不必去找鼠标(P2-2 的那条死路)
let deny = focus == Some(Focus::ConfirmDeny);
let allow = focus == Some(Focus::ConfirmAllow);
let actions: Vec<Element<'a, Message>> = vec![ let actions: Vec<Element<'a, Message>> = vec![
buttons::outlined( ring(
ui, ui,
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false)) buttons::outlined(
.leading(IconKind::Close), ui,
focused_state(
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
.leading(IconKind::Close),
deny,
),
),
deny,
), ),
buttons::filled( ring(
ui, ui,
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true)) buttons::filled(
.leading(IconKind::Check), ui,
focused_state(
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
.leading(IconKind::Check),
allow,
),
),
allow,
), ),
]; ];
banner(ui, title, String::new(), actions) banner(ui, title, String::new(), actions)
} }
/// 焦点环:`Primary` 的 2dp 描边(与输入框聚焦同一套语言)。
///
/// 为什么不能只用 `Interaction::Focused`(10% 状态层):在**实心主色**按钮上那层几乎看不出来
/// ——M119 r1 复审要求「焦点确实落在可操作元素上」且要能出图证明,10% 层在浅色图里等于没有。
/// 所以键盘焦点在这两个按钮上额外加一圈描边(鼠标用户的界面不受影响:只有 `focused` 才画)。
fn ring<'a>(ui: &'a Ui, button: Element<'a, Message>, focused: bool) -> Element<'a, Message> {
// 外圈留 3px 空气:环与按钮之间不贴边,才看得成「一圈」而不是「两条重合的描边」。
// 两个按钮**都**加这层 padding(`focused` 与否一样),所以焦点变化不移动布局。
let gap = ui.space(Space::X1) * 0.75;
let outer = ui.metrics().control_height + gap * 2.0;
container(button)
.padding(gap)
.style(move |_theme| container::Style {
border: if focused {
iced::Border {
color: ui.color(Role::Primary),
width: crate::theme::STROKE_PX * 2.0,
// 与按钮同一种「药丸」:`Full` 的半径 = 边长 / 2(这里用加过 padding 的边长)
radius: ui.radius(ShapeScale::Full, outer),
}
} else {
iced::Border::default()
},
..container::Style::default()
})
.into()
}
/// 横幅本体:图标块 + 标题/正文 + 右侧动作。 /// 横幅本体:图标块 + 标题/正文 + 右侧动作。
/// ///
/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边, /// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边,
+49 -46
View File
@@ -126,23 +126,38 @@ pub struct Failure<'a> {
pub body: &'a str, pub body: &'a str,
} }
/// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。
///
/// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加
/// 「是不是模拟后端」——那会变成 7 个,正是塔台登记过的可维护性观察项。收成结构体之后两个宿主
/// 各 3 个形参,且以后再加展示事实只改这个结构体。
#[derive(Clone, Copy)]
pub struct Session<'a> {
/// 三档/草稿/焦点(纯 UI 态)。
pub agent: &'a AgentUi,
/// 会话内容 / 未读 / busy / 待确认(presenter 的唯一真相)。
pub rail: &'a AgentRail,
/// 失败横幅的展示输入。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记。
pub simulated: bool,
}
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。 /// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
/// ///
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。 /// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
pub fn docked<'a>( pub fn docked<'a>(
ui: &'a Ui, ui: &'a Ui,
agent: &'a AgentUi, session: Session<'a>,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
catalog: Catalog, catalog: Catalog,
window_width: f32, window_width: f32,
) -> Option<Element<'a, Message>> { ) -> Option<Element<'a, Message>> {
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条 // 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。 // (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match agent.effective_rail(window_width) { match session.agent.effective_rail(window_width) {
RailState::Detached => None, RailState::Detached => None,
RailState::Collapsed => Some(strip::strip(ui, rail, catalog)), RailState::Collapsed => Some(strip::strip(ui, session.rail, catalog)),
RailState::Expanded => Some(panel(ui, agent, rail, failure, catalog, window_width)), RailState::Expanded => Some(panel(ui, session, catalog, window_width)),
} }
} }
@@ -151,15 +166,13 @@ pub fn docked<'a>(
/// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。 /// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。
pub fn page<'a>( pub fn page<'a>(
ui: &'a Ui, ui: &'a Ui,
agent: &'a AgentUi, session: Session<'a>,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
catalog: Catalog, catalog: Catalog,
window_width: f32, window_width: f32,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width); let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(agent, rail, failure, Host::Page), catalog); let body = conversation::view(ui, spec(session, Host::Page), catalog);
container( container(
container(body) container(body)
@@ -211,31 +224,25 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
fn panel<'a>( fn panel<'a>(
ui: &'a Ui, ui: &'a Ui,
agent: &'a AgentUi, session: Session<'a>,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
catalog: Catalog, catalog: Catalog,
window_width: f32, window_width: f32,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let width = agent.rail_width(window_width); let width = session.agent.rail_width(window_width);
let body = container(conversation::view( let body = container(conversation::view(ui, spec(session, Host::Rail), catalog))
ui, .width(Length::Fixed(width))
spec(agent, rail, failure, Host::Rail), .height(Length::Fill)
catalog, .padding(Padding {
)) top: ui.space(Space::X6),
.width(Length::Fixed(width)) right: ui.space(Space::X4),
.height(Length::Fill) bottom: ui.space(Space::X4),
.padding(Padding { left: ui.space(Space::X4),
top: ui.space(Space::X6), })
right: ui.space(Space::X4), .style(move |_theme| container::Style {
bottom: ui.space(Space::X4), background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
left: ui.space(Space::X4), ..container::Style::default()
}) });
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
row![splitter(ui, width, catalog), body] row![splitter(ui, width, catalog), body]
.width(Length::Shrink) .width(Length::Shrink)
@@ -293,24 +300,20 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message
} }
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。 /// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>( fn spec<'a>(session: Session<'a>, host: Host) -> Conversation<'a> {
agent: &'a AgentUi,
rail: &'a AgentRail,
failure: Option<Failure<'a>>,
host: Host,
) -> Conversation<'a> {
Conversation { Conversation {
// 会话内容、未读、busy、待确认:**全部**来自 presenter(M119) // 会话内容、未读、busy、待确认:**全部**来自 presenter(M119)
messages: &rail.messages, messages: &session.rail.messages,
busy: rail.busy, busy: session.rail.busy,
unread: rail.unread, unread: session.rail.unread,
confirm: rail.confirm.as_ref(), confirm: session.rail.confirm.as_ref(),
failure, failure: session.failure,
draft: &agent.draft, simulated: session.simulated,
blank: agent.draft_is_blank(), draft: &session.agent.draft,
rail: agent.rail, blank: session.agent.draft_is_blank(),
rail: session.agent.rail,
host, host,
// 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮) // 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮)
focus: agent.keyboard_mode.then_some(agent.focus), focus: session.agent.keyboard_mode.then_some(session.agent.focus),
} }
} }
+23 -7
View File
@@ -72,6 +72,13 @@ pub enum Focus {
Collapse, Collapse,
/// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。 /// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。
Detach, Detach,
/// 确认条的「允许」(只在有待确认写操作时存在于环上)。
///
/// M119 r1 / 复审 P2-2:确认条原本只有鼠标路径——键盘用户在写操作确认这一步**卡死**
/// (`busy` 锁住发送,`Stop` 走的是主后端、清不掉 presenter 的待确认)。这两格就是那条键盘通路。
ConfirmAllow,
/// 确认条的「拒绝」。
ConfirmDeny,
/// 发送。 /// 发送。
Send, Send,
/// 中断(只在流式中出现)。 /// 中断(只在流式中出现)。
@@ -86,14 +93,19 @@ impl Focus {
match self { match self {
Focus::Collapse => "collapse", Focus::Collapse => "collapse",
Focus::Detach => "detach", Focus::Detach => "detach",
Focus::ConfirmAllow => "confirm-allow",
Focus::ConfirmDeny => "confirm-deny",
Focus::Send => "send", Focus::Send => "send",
Focus::Stop => "stop", Focus::Stop => "stop",
Focus::Composer => "composer", Focus::Composer => "composer",
} }
} }
/// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。 /// Tab 环上「这一档真正存在的控件」。
fn order(state: RailState, busy: bool) -> Vec<Focus> { ///
/// `confirm_pending` = 有待确认的写操作(那时条上多两格:允许 / 拒绝)。`busy` 在等确认时
/// 也是真(presenter 的 `busy = streaming || confirm.is_some()`),所以环上还会多一格中断。
fn order(state: RailState, busy: bool, confirm_pending: bool) -> Vec<Focus> {
let mut order = Vec::new(); let mut order = Vec::new();
match state { match state {
RailState::Collapsed => order.push(Focus::Collapse), RailState::Collapsed => order.push(Focus::Collapse),
@@ -103,6 +115,10 @@ impl Focus {
} }
RailState::Detached => order.push(Focus::Detach), RailState::Detached => order.push(Focus::Detach),
} }
if confirm_pending {
order.push(Focus::ConfirmAllow);
order.push(Focus::ConfirmDeny);
}
if state != RailState::Collapsed { if state != RailState::Collapsed {
order.push(Focus::Send); order.push(Focus::Send);
if busy { if busy {
@@ -116,8 +132,8 @@ impl Focus {
/// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。 /// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。
/// ///
/// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。 /// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。
pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus { pub fn step(self, shift: bool, state: RailState, busy: bool, confirm_pending: bool) -> Focus {
let order = Self::order(state, busy); let order = Self::order(state, busy, confirm_pending);
let Some(at) = order.iter().position(|focus| *focus == self) else { let Some(at) = order.iter().position(|focus| *focus == self) else {
return if shift { order.last() } else { order.first() } return if shift { order.last() } else { order.first() }
.copied() .copied()
@@ -283,9 +299,9 @@ impl AgentUi {
self.pointer = None; self.pointer = None;
} }
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` 由 presenter 的 rail 给。 /// Tab 走一格(走一次就把键盘巡航打开)。`busy` / `confirm_pending` 都由 presenter 的 rail 给。
pub fn step_focus(&mut self, shift: bool, busy: bool) { pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) {
self.focus = self.focus.step(shift, self.rail, busy); self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
self.keyboard_mode = true; self.keyboard_mode = true;
} }
} }
+46 -10
View File
@@ -192,6 +192,9 @@ fn every_action_has_a_readable_label() {
Key::AgentErrorTitle, Key::AgentErrorTitle,
Key::AgentErrorUnavailable, Key::AgentErrorUnavailable,
Key::AgentErrorNotImplemented, Key::AgentErrorNotImplemented,
// M119 r1 / P2-1:「这是模拟」的常驻标记也必须有词(用户不点就要能看到)
Key::AgentSimulated,
Key::AgentSubtitle,
] { ] {
assert!( assert!(
!catalog.text(key).trim().is_empty(), !catalog.text(key).trim().is_empty(),
@@ -234,7 +237,7 @@ fn the_focus_ring_walks_the_controls_that_exist() {
let mut focus = Focus::Collapse; let mut focus = Focus::Collapse;
let mut walked = vec![focus]; let mut walked = vec![focus];
for _ in 0..4 { for _ in 0..4 {
focus = focus.step(false, RailState::Expanded, false); focus = focus.step(false, RailState::Expanded, false, false);
walked.push(focus); walked.push(focus);
} }
assert_eq!( assert_eq!(
@@ -250,33 +253,53 @@ fn the_focus_ring_walks_the_controls_that_exist() {
// 反向:Collapse 往回走是 Composer // 反向:Collapse 往回走是 Composer
assert_eq!( assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false), Focus::Collapse.step(true, RailState::Expanded, false, false),
Focus::Composer Focus::Composer
); );
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给) // 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
assert_eq!( assert_eq!(
Focus::Send.step(false, RailState::Expanded, true), Focus::Send.step(false, RailState::Expanded, true, false),
Focus::Stop Focus::Stop
); );
// 独立页只有「收回 + 输入区」 // 独立页只有「收回 + 输入区」
assert_eq!( assert_eq!(
Focus::Detach.step(false, RailState::Detached, false), Focus::Detach.step(false, RailState::Detached, false, false),
Focus::Send Focus::Send
); );
// 折叠档没有会话动作:环上只有「展开」一格 // 折叠档没有会话动作:环上只有「展开」一格
assert_eq!( assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false), Focus::Collapse.step(false, RailState::Collapsed, false, false),
Focus::Collapse Focus::Collapse
); );
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门) // `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new(); let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode); assert!(!agent.keyboard_mode);
agent.step_focus(false, false); agent.step_focus(false, false, false);
assert!(agent.keyboard_mode); assert!(agent.keyboard_mode);
// M119 r1 / P2-2:有待确认写操作时,环上多两格(允许 / 拒绝),且**排在发送之前**——
// 阻塞态该先被看见。这也是键盘用户唯一能推进这一轮的路径。
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, true, true),
Focus::ConfirmAllow
);
assert_eq!(
Focus::ConfirmAllow.step(false, RailState::Expanded, true, true),
Focus::ConfirmDeny
);
assert_eq!(
Focus::ConfirmDeny.step(false, RailState::Expanded, true, true),
Focus::Send
);
// 没有待确认时这两格不在环上(鼠标用户不该 Tab 到不存在的按钮)
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, false, false),
Focus::Send
);
} }
/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。 /// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。
@@ -288,12 +311,19 @@ fn the_spec_borrows_the_presenter_rail_verbatim() {
let mut agent = AgentUi::new(); let mut agent = AgentUi::new();
agent.expand(); agent.expand();
let rail = conversation_rail(); let rail = conversation_rail();
let spec = super::spec(&agent, &rail, None, Host::Rail); let session = Session {
agent: &agent,
rail: &rail,
failure: None,
simulated: true,
};
let spec = super::spec(session, Host::Rail);
assert_eq!(spec.messages.len(), rail.messages.len()); assert_eq!(spec.messages.len(), rail.messages.len());
assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息"); assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息");
assert_eq!(spec.unread, rail.unread); assert_eq!(spec.unread, rail.unread);
assert_eq!(spec.busy, rail.busy); assert_eq!(spec.busy, rail.busy);
assert!(spec.failure.is_none()); assert!(spec.failure.is_none());
assert!(spec.simulated, "模拟事实要原样传给视图(标记不是硬编码)");
} }
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。 /// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
@@ -330,9 +360,14 @@ fn every_state_builds_in_both_hosts() {
let mut agent = AgentUi::new(); let mut agent = AgentUi::new();
agent.rail = state; agent.rail = state;
for shown in [None, Some(failure)] { for shown in [None, Some(failure)] {
let _: iced::Element<'_, Message> = let session = Session {
page(&ui, &agent, rail, shown, catalog, window); agent: &agent,
let _ = docked(&ui, &agent, rail, shown, catalog, window); rail,
failure: shown,
simulated: true,
};
let _: iced::Element<'_, Message> = page(&ui, session, catalog, window);
let _ = docked(&ui, session, catalog, window);
let spec = Conversation { let spec = Conversation {
messages: &rail.messages, messages: &rail.messages,
draft: &agent.draft, draft: &agent.draft,
@@ -341,6 +376,7 @@ fn every_state_builds_in_both_hosts() {
blank: agent.draft_is_blank(), blank: agent.draft_is_blank(),
confirm: rail.confirm.as_ref(), confirm: rail.confirm.as_ref(),
failure: shown, failure: shown,
simulated: true,
rail: agent.rail, rail: agent.rail,
host: Host::Rail, host: Host::Rail,
focus: agent.keyboard_mode.then_some(agent.focus), focus: agent.keyboard_mode.then_some(agent.focus),
@@ -32,11 +32,15 @@ fn the_detached_state_frees_the_rail_slot() {
let ui = ui(); let ui = ui();
let mut agent = AgentUi::new(); let mut agent = AgentUi::new();
agent.detach(); agent.detach();
let session = super::super::Session {
agent: &agent,
rail: &AgentRail::new(),
failure: None,
simulated: true,
};
assert!(docked( assert!(docked(
&ui, &ui,
&agent, session,
&AgentRail::new(),
None,
unlucky_i18n::Catalog::of(unlucky_i18n::Lang::En), unlucky_i18n::Catalog::of(unlucky_i18n::Lang::En),
1180.0 1180.0
) )
+13
View File
@@ -85,6 +85,12 @@ pub(crate) enum SubmitOutcome {
/// AI 助手栏的 presenter 句柄。 /// AI 助手栏的 presenter 句柄。
pub(crate) struct AgentHost { pub(crate) struct AgentHost {
presenter: Presenter, presenter: Presenter,
/// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。
///
/// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport`
/// 的那一天,这里传 `false`,界面上的「模拟」标记会自动消失——**标记不是硬编码在视图里的**,
/// 它是这个事实字段的渲染。
simulated: bool,
queue: EventQueue, queue: EventQueue,
failure: FailureSlot, failure: FailureSlot,
/// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。 /// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。
@@ -101,6 +107,8 @@ impl AgentHost {
let failure = transport.failure(); let failure = transport.failure();
Self { Self {
presenter: Presenter::new(Box::new(transport)), presenter: Presenter::new(Box::new(transport)),
// 脚本化 rig ⇒ 一定是模拟的(见字段文档)
simulated: true,
queue, queue,
failure, failure,
last_failure: None, last_failure: None,
@@ -118,6 +126,11 @@ impl AgentHost {
self.presenter.agent_rail() self.presenter.agent_rail()
} }
/// 这套后端是不是模拟的(界面据此常驻「模拟」标记;见字段文档)。
pub(crate) fn is_simulated(&self) -> bool {
self.simulated
}
/// 最近一次提交失败的具名原因。 /// 最近一次提交失败的具名原因。
pub(crate) fn last_failure(&self) -> Option<AgentFailure> { pub(crate) fn last_failure(&self) -> Option<AgentFailure> {
self.last_failure self.last_failure
+10 -6
View File
@@ -14,9 +14,10 @@ macro_rules! agent_entries {
$($acc)* $($acc)*
// ── AI 助手(右栗 + 独立页共用) // ── AI 助手(右栗 + 独立页共用)
AgentTitle => ("Assistant", "助手"), AgentTitle => ("Assistant", "助手"),
// M119:接线之前这条写的是「后端未接」。真接进 presenter 之后那句话成了假话, // M119 r1 复审 P2-1:这条一度被改成中性的「会话」,理由是「跑什么后端由端点标签说」——
// 改成中性的「会话」;「跑的是什么后端」由端点标签与错误横幅说,不在标题下重复。 // 但端点标签在壳里根本不存在,于是默认配置下用户看到的是一个像是接上了模型的助手。
AgentSubtitle => ("Conversation", "会话"), // 现在把诚实的话写回来,而且它是**常驻**的(标题下就一行,不点也能看到)。
AgentSubtitle => ("Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
AgentCollapse => ("Collapse", "收起"), AgentCollapse => ("Collapse", "收起"),
AgentDetach => ("Pop out", "拆出"), AgentDetach => ("Pop out", "拆出"),
AgentDock => ("Dock back", "收回"), AgentDock => ("Dock back", "收回"),
@@ -24,10 +25,11 @@ macro_rules! agent_entries {
AgentStripLabel => ("AI", "助手"), AgentStripLabel => ("AI", "助手"),
AgentSend => ("Send", "发送"), AgentSend => ("Send", "发送"),
AgentStop => ("Stop", "中断"), AgentStop => ("Stop", "中断"),
AgentPlaceholder => ("Message the assistant…", "给助手发消息…"), // 输入框上再说一次:用户真要动手的位置就在这儿
AgentPlaceholder => ("Message the simulated assistant…", "给模拟助手发消息…"),
AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"), AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"),
// M119:同上——「模型还没接上」这句在接线后是假话。真接的是什么由端点标签说。 // 空会话是最容易被误读的一屏(没有人说过话,很容易以为是「连上了但还没问」)
AgentEmptyBody => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), AgentEmptyBody => ("Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"), AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"),
AgentUnread => ("{count} unread", "{count} 条未读"), AgentUnread => ("{count} unread", "{count} 条未读"),
@@ -46,6 +48,8 @@ macro_rules! agent_entries {
AgentConfirmRequest => ("The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"), AgentConfirmRequest => ("The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
AgentConfirmAllow => ("Allow", "允许"), AgentConfirmAllow => ("Allow", "允许"),
AgentConfirmDeny => ("Deny", "拒绝"), AgentConfirmDeny => ("Deny", "拒绝"),
// 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」
AgentSimulated => ("Simulated", "模拟"),
} } } }
}; };
} }
+4 -3
View File
@@ -124,7 +124,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"), ("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
("LogsEmptyAction", "Choose an instance", "选择实例"), ("LogsEmptyAction", "Choose an instance", "选择实例"),
("AgentTitle", "Assistant", "助手"), ("AgentTitle", "Assistant", "助手"),
("AgentSubtitle", "Conversation", "会话"), ("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
("AgentCollapse", "Collapse", "收起"), ("AgentCollapse", "Collapse", "收起"),
("AgentDetach", "Pop out", "拆出"), ("AgentDetach", "Pop out", "拆出"),
("AgentDock", "Dock back", "收回"), ("AgentDock", "Dock back", "收回"),
@@ -132,9 +132,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentStripLabel", "AI", "助手"), ("AgentStripLabel", "AI", "助手"),
("AgentSend", "Send", "发送"), ("AgentSend", "Send", "发送"),
("AgentStop", "Stop", "中断"), ("AgentStop", "Stop", "中断"),
("AgentPlaceholder", "Message the assistant…", "给助手发消息…"), ("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"),
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"), ("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
("AgentEmptyBody", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"), ("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"), ("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"), ("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
("AgentUnread", "{count} unread", "{count} 条未读"), ("AgentUnread", "{count} unread", "{count} 条未读"),
@@ -149,6 +149,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"), ("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
("AgentConfirmAllow", "Allow", "允许"), ("AgentConfirmAllow", "Allow", "允许"),
("AgentConfirmDeny", "Deny", "拒绝"), ("AgentConfirmDeny", "Deny", "拒绝"),
("AgentSimulated", "Simulated", "模拟"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**, // ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。 // 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"), ("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),