From d4fd675935660a1a3c6d86d5c7316ed961d306e4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 08:02:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20M119=20=E6=8A=8A=20presenter?= =?UTF-8?q?=20=E7=9A=84=20agent=20=E5=9B=9B=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E7=AA=84=E5=85=A5=E5=8F=A3=E6=8E=A5=E8=BF=9B?= =?UTF-8?q?=20iced=20=E5=A3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完, 但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。 这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。 ## 接线层(新增 `crates/app-iced/src/presenter/`) | 文件 | 是什么 | |---|---| | `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)| | `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)| | `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` | | `mod.rs` | 逻辑路径说明 + **线程/通道边界** | 物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用 `#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。 ## 事件回流 `transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经 **既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec)`)回到 `on_agent`,再由 `Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。 ## 会话内容只留一份 `AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()` (`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是 空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。 ## 视图 - 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型; - 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与 **写操作确认**(摘要 + Allow/Deny → `agent_confirm`); - 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。 ## 未读口径 presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。 `refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回, 带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。 ## 账户页 账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与 列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」; 失败态(名字不合法 / 凭据失效)都给具名错误。 ## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed) - `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零; 确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。 - `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态; M114 的三条回归闸(原内联测试搬出来)。 - `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。 顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs` 的 `docked` 调用点补上 rail 与失败横幅。 --- crates/app-iced/src/app/state.rs | 18 +- crates/app-iced/src/app/state/construct.rs | 6 +- crates/app-iced/src/app/state/refresh.rs | 3 + crates/app-iced/src/app/tests.rs | 10 +- crates/app-iced/src/app/update/account.rs | 121 +------ .../app-iced/src/app/update/account/tests.rs | 303 ++++++++++++++++ crates/app-iced/src/app/update/agent.rs | 170 +++++++-- crates/app-iced/src/app/update/agent/tests.rs | 222 ++++++++++++ crates/app-iced/src/app/update/clock.rs | 9 + crates/app-iced/src/app/view.rs | 8 +- crates/app-iced/src/app/view/account_page.rs | 11 +- .../app-iced/src/pages/agent/conversation.rs | 179 +++------- .../src/pages/agent/conversation/banners.rs | 155 ++++++++ .../src/pages/agent/conversation/composer.rs | 33 +- .../pages/agent/conversation/transcript.rs | 124 +++++++ crates/app-iced/src/pages/agent/demo.rs | 121 ++++--- crates/app-iced/src/pages/agent/mod.rs | 246 +++++-------- crates/app-iced/src/pages/agent/state.rs | 136 ++----- crates/app-iced/src/pages/agent/strip.rs | 134 +++++++ crates/app-iced/src/pages/agent/tests.rs | 215 ++++++----- crates/app-iced/src/pages/agent/tests/drag.rs | 13 +- crates/app-iced/src/presenter/agent.rs | 264 ++++++++++++++ crates/app-iced/src/presenter/mod.rs | 41 +++ crates/app-iced/src/presenter/rig.rs | 211 +++++++++++ crates/app-iced/src/presenter/transport.rs | 335 ++++++++++++++++++ 25 files changed, 2390 insertions(+), 698 deletions(-) create mode 100644 crates/app-iced/src/app/update/account/tests.rs create mode 100644 crates/app-iced/src/app/update/agent/tests.rs create mode 100644 crates/app-iced/src/pages/agent/conversation/banners.rs create mode 100644 crates/app-iced/src/pages/agent/conversation/transcript.rs create mode 100644 crates/app-iced/src/pages/agent/strip.rs create mode 100644 crates/app-iced/src/presenter/agent.rs create mode 100644 crates/app-iced/src/presenter/mod.rs create mode 100644 crates/app-iced/src/presenter/rig.rs create mode 100644 crates/app-iced/src/presenter/transport.rs diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 45f3b7d..2ab97b1 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -68,6 +68,13 @@ mod construct; mod refresh; mod theme; +/// iced 侧的 presenter 接线层(M119):物理目录 `src/presenter/`,逻辑名字在 `crate::app::state` 下。 +/// +/// 为什么用 `#[path]` 而不是在 `lib.rs` 加一行 `mod presenter;`:本 mission 的 scope 不含 +/// `lib.rs` / `app/mod.rs`,而 `shell::agent` 挂 `pages/agent/` 是同一个先例。没有第二份实现。 +#[path = "../presenter/mod.rs"] +pub(super) mod presenter; + pub(crate) use boot_probe::{log_first_frame, STARTED}; /// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。 @@ -138,11 +145,20 @@ pub struct App { /// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。 /// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。 pub(super) settings: pages::settings::Groups, - /// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 会话/草稿/未读。 + /// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 草稿/焦点。 /// /// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改, /// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock>`)。 + /// + /// **只留 UI 态**:会话内容 / 未读 / busy 在 `agent_host` 的 rail 上(M119 起)。 pub(super) agent: crate::shell::agent::AgentUi, + /// AI 助手的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。 + /// + /// 为什么单独一份 `AppState` 而不是把 `state` 交给它:`Presenter` 的可写状态后门 + /// (`state_mut`)只在 presenter crate 的 `#[cfg(test)]` 里存在,而 `app/subscription.rs` + /// 等处仍要直读 `App.state`。所以口径是——**`App.state` 管除 rail 外的一切,这个句柄管 rail**; + /// `AppEvent::Agent` 只喂这里(`App.state.ai` 从此不再被读,也不再被写)。 + pub(super) agent_host: presenter::AgentHost, /// 下载页的安装草稿(M105:选中版本 + 加载器 + 加载器版本 + 实例名)。 /// /// 为什么住在 `App` 而不是页面模型:`refresh_view` 每次事件都把 `self.view` 整棵重建, diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 585ce9d..8d65d91 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -118,8 +118,10 @@ impl App { appearance_prefs, hwnd: None, settings: pages::settings::Groups::default(), - // 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态 - agent: crate::shell::agent::demo::from_env().unwrap_or_default(), + // 出厂是**空会话**(M119 删了 M104 的样本会话);取证开关 `ULL_AGENT_*` 的档位 + // 与夹具在 `boot_agent` 里落地(它要先让 presenter 知道栏可见性,见那里)。 + agent: crate::shell::agent::AgentUi::new(), + agent_host: super::presenter::AgentHost::from_env(), // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 download_draft: pages::InstallDraft::default(), install_cancelled_seen: 0, diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index 118a553..855c3c8 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -19,6 +19,9 @@ impl App { // 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前, // 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。 self.sync_agent_route(); + // M119:把「栏可见吗」写回 presenter(未读口径的输入,两个窄入口:`navigate` + `prefs_mut`)。 + // 必须在 `sync_agent_route` 之后:档位刚被它对齐过,这里读到的才是这一帧的真值。 + self.sync_agent_presenter(); // M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。 // 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 —— diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index 101f2b3..ce40508 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -280,7 +280,8 @@ fn entering_the_agent_route_detaches_the_rail() { let mut app = headless(Options::default(), SchemeFlavor::Light); let _ = app.update(Message::Boot); assert_eq!(app.agent.rail, RailState::Collapsed, "出厂是折叠档"); - assert_eq!(app.agent.unread, 2, "出厂带两条未读"); + // M119:出厂没有样本会话,未读由 presenter 按真事件累加 ⇒ 出厂就是 0 + assert_eq!(app.agent_host.rail().unread, 0, "出厂 0 条未读"); // ① 外壳导航第五项(真实入口) let _ = app.update(Message::Select(NavItem::Ai)); @@ -290,7 +291,7 @@ fn entering_the_agent_route_detaches_the_rail() { RailState::Detached, "进了独立页,档位必须是 Detached——否则页头会画右栗才有的动作" ); - assert_eq!(app.agent.unread, 0, "进独立页即读"); + assert_eq!(app.agent_host.rail().unread, 0, "进独立页即读"); // ③ Esc 真的离开这一页 let _ = app.update(Message::Agent(crate::shell::agent::Message::Escape)); @@ -314,13 +315,14 @@ fn entering_the_agent_route_detaches_the_rail() { fn the_page_navigation_entry_also_detaches() { let mut app = headless(Options::default(), SchemeFlavor::Light); let _ = app.update(Message::Boot); - app.agent.expand(); + // 走真消息(不是直接改字段):这样「展开即读」的 `agent_mark_read` 也会真的发生 + let _ = app.update(Message::Agent(crate::shell::agent::Message::Expand)); assert_eq!(app.agent.rail, RailState::Expanded); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Agent))); assert_eq!(app.state.route(), &Route::Agent); assert_eq!(app.agent.rail, RailState::Detached); - assert_eq!(app.agent.unread, 0); + assert_eq!(app.agent_host.rail().unread, 0); // 页面层回退(不是 Dock 消息)也要把档位收回来 let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); diff --git a/crates/app-iced/src/app/update/account.rs b/crates/app-iced/src/app/update/account.rs index 9c18bd8..c2a8467 100644 --- a/crates/app-iced/src/app/update/account.rs +++ b/crates/app-iced/src/app/update/account.rs @@ -207,123 +207,4 @@ pub(super) fn force_account_state(app: &mut App) { } #[cfg(test)] -mod tests { - use unlucky_design::color::SchemeFlavor; - use unlucky_presenter::dto::{ - AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginPhase, DeviceLoginView, LoginFailure, - }; - use unlucky_presenter::mock::MockBackend; - use unlucky_presenter::settings_bridge::SettingsBridge; - - use crate::pages::account::{Form, Message as Account, Panel, Track}; - use crate::theme::Families; - - use super::App; - - /// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。 - fn headless() -> App { - App::with_presenter( - crate::cli::Options::default(), - Families { - text: "Segoe UI", - display: "Segoe UI", - mono: "Consolas", - }, - SchemeFlavor::Light, - Box::new(MockBackend::new()), - SettingsBridge::memory_only(), - ) - } - - fn seed(app: &mut App, event: CoreEvent) { - app.state.apply(AppEvent::Core(event)); - app.refresh_view(); - } - - /// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。 - /// - /// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制 - /// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin` - /// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。 - /// - /// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。 - /// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按 - /// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。 - #[test] - fn close_really_closes_the_login_panel_on_both_tracks() { - // ① 微软轨:失败态 - let mut app = headless(); - seed( - &mut app, - CoreEvent::DeviceLoginChanged(DeviceLoginView { - phase: DeviceLoginPhase::Failed, - message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(), - failure: Some(LoginFailure::NotWhitelisted), - ..DeviceLoginView::new() - }), - ); - assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示"); - let _ = app.on_account(Account::Close); - assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉"); - assert_eq!(app.state.device_login.phase, DeviceLoginPhase::Idle); - - // ② 微软轨:成功态(同样不在 busy 里) - let mut app = headless(); - seed( - &mut app, - CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")), - ); - assert_eq!(app.account.panel, Panel::Microsoft); - let _ = app.on_account(Account::Close); - assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉"); - - // ③ 第三方轨:失败态 - let mut app = headless(); - seed( - &mut app, - CoreEvent::AuthlibLoginChanged(AuthlibLoginView { - phase: DeviceLoginPhase::Failed, - server: "https://example.invalid/api/yggdrasil/".to_string(), - server_name: String::new(), - message: "服务器返回 HTTP 404".to_string(), - failure: Some(LoginFailure::NotAuthlibServer), - account_name: String::new(), - }), - ); - app.account_form.open(Track::Authlib); - app.refresh_view(); - assert_eq!( - app.account.panel, - Panel::Authlib, - "第三方轨失败态面板要显示" - ); - let _ = app.on_account(Account::Close); - assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉"); - assert_eq!(app.state.authlib_login.phase, DeviceLoginPhase::Idle); - } - - /// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。 - /// - /// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。 - /// 抓不住:真实 dispatch 的副作用(那要真后端)。 - #[test] - fn the_form_state_follows_the_page_messages() { - let mut form = Form::new(); - assert_eq!(form.panel, Panel::None); - form.open(Track::Authlib); - assert_eq!(form.panel, Panel::Authlib); - form.password = "hunter2".to_string(); - form.close(); - assert_eq!(form.panel, Panel::None); - assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉"); - } - - /// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。 - #[test] - fn the_add_section_starts_collapsed() { - let mut form = Form::new(); - assert!(!form.section_open); - form.section_open = true; - assert!(form.section_open); - } -} +mod tests; diff --git a/crates/app-iced/src/app/update/account/tests.rs b/crates/app-iced/src/app/update/account/tests.rs new file mode 100644 index 0000000..d74492b --- /dev/null +++ b/crates/app-iced/src/app/update/account/tests.rs @@ -0,0 +1,303 @@ +//! **树测试②(M119 验收核心)**:账户页消费 presenter 的账户列表与登录事件。 +//! +//! 后端是 UI 侧的 [`AccountRig`]:它把 `AppCommand` 翻成**与真 host 同形状**的 `CoreEvent` +//! (`AccountsChanged` / `DeviceLoginChanged` / `AccountNoticeChanged`),数据在内存里。 +//! 于是这条测试钉住的是「**接线**」——用户点一下之后,命令出去、事件回来、页面重算、横幅出现; +//! 抓不住的是 `core::auth::AccountStore` 的落盘(那条由 presenter 自己的 +//! `host::accounts::offline_add_list_remove_round_trips` 覆盖,原因见 `presenter/rig.rs` 的模块文档)。 +//! +//! 覆盖任务书第 3 条与第 4 条的账户那一半: +//! +//! | 断言 | 抓得住什么 | +//! |---|---| +//! | 添加离线档案 → 列表出现一行 | `AddOfflineAccount` 没派发 / 事件没回流 / 页面没重建 | +//! | 切换当前账号 → 「当前」标记换人 | `SwitchAccount` 没派发(M114 的 `active` 标记是页面唯一来源)| +//! | 删除 → 列表回到空态 | `RemoveAccount` 没派发 | +//! | 设备码「进行中可见 + 可取消」 | `StartDeviceLogin` / `CancelDeviceLogin` 没接线(只剩转圈)| +//! | 失败态:名字不合法 / 凭据失效都给**具名**错误 | 失败被吞掉(复审 r1 P2-2 的老毛病)| + +use unlucky_design::color::SchemeFlavor; +use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_presenter::dto::{ + AccountNoticeCode, AppEvent, AuthlibLoginView, CoreEvent, DeviceLoginView, LoginFailure, +}; +use unlucky_presenter::mock::MockBackend; +use unlucky_presenter::settings_bridge::SettingsBridge; + +use crate::app::state::presenter::rig::AccountRig; +use crate::pages::account::model::{AuthlibPanel, DevicePanel}; +use crate::pages::account::{Form, Message as Account, Panel, Track}; +use crate::theme::Families; + +use super::App; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 不碰磁盘的 App,后端换成**账户 rig**(其余分支照旧走 `MockBackend` 的能力就够)。 +fn with_rig() -> App { + App::with_presenter( + crate::cli::Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(AccountRig::new()), + SettingsBridge::memory_only(), + ) +} + +/// 不碰磁盘的 App(mock 后端 + 内存设置桥),与 `app/tests.rs` 的 `headless` 同源。 +fn headless() -> App { + App::with_presenter( + crate::cli::Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) +} + +fn seed(app: &mut App, event: CoreEvent) { + app.state.apply(AppEvent::Core(event)); + app.refresh_view(); +} + +/// **添加 → 切换 → 删除**(任务书第 3 条,一条端到端)。 +/// +/// 抓得住:三条命令任何一条没派发 / 事件没回流 / `refresh_view` 没带上账户页。 +/// 抓不住:真实账户库的落盘(见模块文档)。 +#[test] +fn an_offline_profile_is_added_switched_and_removed_end_to_end() { + let mut app = with_rig(); + assert!(app.account.is_empty(), "出厂是空态"); + + // ① 添加离线档案:走真消息(点轨道卡 → 填名字 → 提交) + let _ = app.on_account(Account::Open(Track::Offline)); + assert_eq!(app.account_form.panel, Panel::Offline); + // 表单输入也走真消息(纯 UI 态,只写 `App::account_form` + 置脏) + let _ = app.on_account(Account::OfflineName("Steve".to_string())); + assert!( + app.account_form.offline_ready(), + "3–16 位字母:按钮该是亮的" + ); + let _ = app.on_account(Account::Submit); + + assert_eq!(app.account.rows.len(), 1, "列表出现一行(不是静默)"); + assert_eq!(app.account.rows[0].name, "Steve"); + assert_eq!( + app.account.notice.as_ref().map(|notice| notice.code), + Some(AccountNoticeCode::Added), + "页面横幅给出的是一次性结果" + ); + let id = app.account.rows[0].id.clone(); + + // ② 切换当前账号 + let _ = app.on_account(Account::Switch(id.clone())); + assert_eq!( + app.account.active_row().map(|row| row.name.as_str()), + Some("Steve"), + "「当前」标记跟着切换走" + ); + + // ③ 删除 + let _ = app.on_account(Account::Remove(id)); + assert!(app.account.rows.is_empty(), "删除后回到空态"); + assert_eq!( + app.account.notice.as_ref().map(|notice| notice.code), + Some(AccountNoticeCode::Removed) + ); + // 两个横幅文案都有词(中英各一条,不是硬编码) + for lang in [Lang::En, Lang::ZhCn] { + assert!(!Catalog::of(lang).text(Key::AccountNoticeRemoved).is_empty()); + } + // 整棵树在三种状态下都能构图 + let _ = app.view(); +} + +/// 设备码流程:**指令发出 + 进行中可见 + 可取消**(任务书第 3 条的后半,真实网络不要求)。 +#[test] +fn the_device_code_flow_is_started_visible_and_cancellable() { + let mut app = with_rig(); + assert_eq!(app.account.device, DevicePanel::Hidden); + + let _ = app.on_account(Account::StartDevice); + match &app.account.device { + DevicePanel::Code { + code, remaining, .. + } => { + assert_eq!(code, "ABCD-EFGH", "用户码要上屏"); + assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑"); + } + other => panic!("进行中状态必须可见,实际 {other:?}"), + } + assert_eq!(app.account.panel, Panel::Microsoft, "面板要切到微软那条轨"); + let _ = app.view(); + + let _ = app.on_account(Account::Cancel); + assert_eq!(app.account.device, DevicePanel::Hidden, "取消要真的收掉"); + assert_eq!( + app.state.device_login.phase, + unlucky_presenter::dto::DeviceLoginPhase::Idle + ); +} + +/// 失败态①:**未实现 / 名字不合法** ⇒ 页面横幅给出结构化码(不是静默无语)。 +#[test] +fn an_invalid_profile_name_surfaces_a_named_failure() { + let mut app = with_rig(); + let _ = app.on_account(Account::Open(Track::Offline)); + // 名字不合法时**发送键是灰的**(与 host 同一份判据),所以这里直接派命令: + // 抓的是「host 真的回了一个结构化失败码」这条链路,而不是按钮的禁用态。 + assert!(!app.account_form.offline_ready()); + app.dispatch(unlucky_presenter::dto::AppCommand::AddOfflineAccount( + "ab".to_string(), + )); + + assert!(app.account.rows.is_empty(), "不合法的名字不入列表"); + let notice = app.account.notice.as_ref().expect("必须有横幅"); + assert_eq!(notice.code, AccountNoticeCode::OfflineNameInvalid); + assert!(notice.failure, "失败档(配色不同)"); + assert_eq!(notice.subject, "ab", "横幅要带上是哪个名字"); + // 文案走 catalog,中英各一条 + for lang in [Lang::En, Lang::ZhCn] { + assert!(!Catalog::of(lang) + .text(Key::AccountNoticeOfflineNameInvalid) + .is_empty()); + } + let _ = app.view(); +} + +/// 失败态②:**账户凭据失效** ⇒ 第三方轨面板进入 `Failed(BadCredentials)`(只带原因码)。 +/// +/// 为什么只断言原因码:M114 r3 起面板类型里**没有** `message`/`detail` 字段(原文只进 presenter +/// 日志),页面据码选 catalog 文案。这条测试与「页面画得出文案」之间的桥梁是 +/// `pages::account::login::failure_body_key`(同一 crate 内的穷尽 match,缺一条编译不过)。 +#[test] +fn expired_credentials_surface_a_named_failure_code() { + let mut app = headless(); + seed( + &mut app, + CoreEvent::AuthlibLoginChanged(AuthlibLoginView::failed_as( + LoginFailure::BadCredentials, + "服务器返回 401:凭据已失效(token=…已脱敏)", + )), + ); + app.account_form.open(Track::Authlib); + app.refresh_view(); + + assert_eq!( + app.account.authlib, + AuthlibPanel::Failed { + failure: LoginFailure::BadCredentials + }, + "凭据失效要落成具名原因码,而不是静默回 Idle" + ); + // 原因码对应的那句译文两套语言都得有 + for lang in [Lang::En, Lang::ZhCn] { + let text = Catalog::of(lang).text(Key::AccountFailureBadCredentials); + assert!(!text.trim().is_empty(), "{} 缺这条失败文案", lang.tag()); + } + // 原文不许出现在页面的任何可视文本里(M114 的凭据红线) + assert!( + !format!("{:?}", app.account).contains("服务器返回 401"), + "后端原文不许进页面模型" + ); + let _ = app.view(); +} + +// ---------------------------------------------------------------- M114 的既有回归闸 + +/// **复审 r2 P2-1 的回归闸**:登录失败/成功态下按 `Close`,面板必须真的关掉。 +/// +/// 为什么这条以前是死按钮:`Page::build` 只要 `device_login.phase != Idle` 就把面板强制 +/// 回 Microsoft,而 `Close` 只在 `busy()`(Requesting/Waiting)时才派 `CancelDeviceLogin` +/// ⇒ Failed/Succeeded 下点了没反应。现在判据改成「非 Idle」,两条轨都覆盖。 +/// +/// 抓得住:判据退回 `busy()`;`Close` 忘了 dispatch;`CancelDeviceLogin` 没有把状态收回 Idle。 +/// 抓不住:`CancelDeviceLogin` 在**真 host** 上的副作用(这里跑的是 mock,但 mock 已按 +/// 真 host 的契约发 Idle,见 `presenter/src/mock.rs`)。 +#[test] +fn close_really_closes_the_login_panel_on_both_tracks() { + // ① 微软轨:失败态 + let mut app = headless(); + seed( + &mut app, + CoreEvent::DeviceLoginChanged(DeviceLoginView { + phase: unlucky_presenter::dto::DeviceLoginPhase::Failed, + message: "微软拒绝了这次登录:AADSTS7000218(HTTP 403)".to_string(), + failure: Some(LoginFailure::NotWhitelisted), + ..DeviceLoginView::new() + }), + ); + assert_eq!(app.account.panel, Panel::Microsoft, "失败态面板要显示"); + let _ = app.on_account(Account::Close); + assert_eq!(app.account.panel, Panel::None, "Close 之后面板必须关掉"); + assert_eq!( + app.state.device_login.phase, + unlucky_presenter::dto::DeviceLoginPhase::Idle + ); + + // ② 微软轨:成功态(同样不在 busy 里) + let mut app = headless(); + seed( + &mut app, + CoreEvent::DeviceLoginChanged(DeviceLoginView::succeeded("Steve")), + ); + assert_eq!(app.account.panel, Panel::Microsoft); + let _ = app.on_account(Account::Close); + assert_eq!(app.account.panel, Panel::None, "成功提示也要能关掉"); + + // ③ 第三方轨:失败态 + let mut app = headless(); + seed( + &mut app, + CoreEvent::AuthlibLoginChanged(AuthlibLoginView { + phase: unlucky_presenter::dto::DeviceLoginPhase::Failed, + server: "https://example.invalid/api/yggdrasil/".to_string(), + server_name: String::new(), + message: "服务器返回 HTTP 404".to_string(), + failure: Some(LoginFailure::NotAuthlibServer), + account_name: String::new(), + }), + ); + app.account_form.open(Track::Authlib); + app.refresh_view(); + assert_eq!( + app.account.panel, + Panel::Authlib, + "第三方轨失败态面板要显示" + ); + let _ = app.on_account(Account::Close); + assert_eq!(app.account.panel, Panel::None, "第三方轨也要能关掉"); + assert_eq!( + app.state.authlib_login.phase, + unlucky_presenter::dto::DeviceLoginPhase::Idle + ); +} + +/// 表单态是纯 UI 状态:打开/关闭面板不派命令,但会把面板切对。 +/// +/// 抓得住:`Open` 忘了写 `panel`(点了轨道卡什么都不出现)、`close()` 不清密码。 +/// 抓不住:真实 dispatch 的副作用(那要真后端)。 +#[test] +fn the_form_state_follows_the_page_messages() { + let mut form = Form::new(); + assert_eq!(form.panel, Panel::None); + form.open(Track::Authlib); + assert_eq!(form.panel, Panel::Authlib); + form.password = "hunter2".to_string(); + form.close(); + assert_eq!(form.panel, Panel::None); + assert!(form.password.is_empty(), "关闭面板必须把一次性密码清掉"); +} + +/// 三张轨道卡默认收起(有账号时),`ToggleAdd` 展开(复审 r1 P2-4)。 +#[test] +fn the_add_section_starts_collapsed() { + let mut form = Form::new(); + assert!(!form.section_open); + form.section_open = true; + assert!(form.section_open); +} diff --git a/crates/app-iced/src/app/update/agent.rs b/crates/app-iced/src/app/update/agent.rs index 902875c..ecfef69 100644 --- a/crates/app-iced/src/app/update/agent.rs +++ b/crates/app-iced/src/app/update/agent.rs @@ -1,42 +1,60 @@ -//! 助手栏消息的落点(M104):三档状态迁移 / 输入 / 拖拽 / 键盘。 +//! 助手栏消息的落点(M104 建,M119 接线):三档状态迁移 / 输入 / 键盘 / **presenter 四契约**。 //! //! ## 为什么单独一个文件 //! //! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。 -//! 助手栏有 11 个变体,塞回主文件必破线。任务也只允许主文件加一行 `mod agent;`。 +//! 助手栏有十几个变体,塞回主文件必破线。 //! -//! ## 分工:视图态 vs 业务命令 +//! ## 分工:视图态 vs presenter 契约(M119 之后的分界线) //! //! | 消息 | 落到哪 | //! |---|---| -//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`) | -//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去) | -//! | `Edit` / `Submit` / `Stop` | 改视图态:草稿、追加一条占位提示 | +//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`)+ `agent_mark_read` | +//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去)+ `agent_mark_read` | +//! | `Edit` | 视图态(草稿),并同步进 presenter 的草稿(`agent_set_draft`) | +//! | `Submit` | [`AgentHost::submit`] → `agent_submit`;失败**不留白**(具名错误进横幅) | +//! | `Stop` | `AppCommand::StopAgent`(真后端才收敛得出结果;同步 rig 下是 no-op) | +//! | `Confirm(accept)` | [`AgentHost::confirm`] → `agent_confirm`(YOLO 下 presenter 自己放行并记审计) | +//! | `Events(..)` | [`AgentHost::apply`] → `AppState::apply`(**事件回流的唯一落点**) | //! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 | -//! | `Key` / `Tab` / `Activate` | 键盘路径 | +//! | `Key` / `Tab` / `Activate` | 键盘路径(`Tab` 的环要在流式中多一格,`busy` 从 rail 取) | //! -//! **没有任何 `AppCommand` 派发**:本 mission 不接 AI 后端(M103 的四个签名一行都不调)。 -//! `Submit` 不假装有回复——它只把草稿记进会话 + 追加一条「占位外壳、没发给模型」的提示, -//! `Stop` 同理。真接线时这两个分支各加一次 `dispatch`,视图不用动。 +//! **会话内容一行都不在这里攒**:`transcript` / `busy` / `unread` / `confirm` 全在 presenter 的 +//! `AgentRail` 上(M119 删掉了 M104 的自持副本)。 +//! +//! ## 事件为什么绕一圈消息再回来 +//! +//! `agent_submit` / `agent_confirm` 的端口是同步的,但**事件走队列**(见 +//! `app/state/presenter/transport.rs`):抽队列的那一步必须和用户点击走同一条漏斗,否则两处各改 +//! 一次状态,流式标记与未读会打架。所以 [`App::drain_agent`] 抽出来之后立刻 +//! `on_agent(agent::Message::Events(..))` —— 同一条 `Message::Agent(..)` 通道。 use iced::keyboard::{key, Key}; use iced::Task; -use unlucky_i18n::{Catalog, Lang}; +use unlucky_i18n::{Catalog, Key as TextKey}; +use unlucky_presenter::dto::AppCommand; -use crate::app::{MIN_SIZE, WINDOW_SIZE}; +use crate::app::MIN_SIZE; +use crate::app::WINDOW_SIZE; +use crate::shell::agent::demo::{self, Fixture}; use crate::shell::agent::{self, Focus, RailState}; +use super::super::state::presenter::agent::SubmitOutcome; use super::*; +#[cfg(test)] +mod tests; + impl App { /// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。 pub(super) fn on_agent(&mut self, message: agent::Message) -> Task { - let catalog = Catalog::of(Lang::En); + // M106 口径:文案走**生效语言**的目录,不再恒英文 + let catalog = Catalog::of(self.lang()); - // 只有**几何/路由**会变的几支才需要重建视图模型(`pages::Model` 里没有会话内容: - // transcript / draft 全在 `App.agent` 上)。复审 P3-9:以前每个 `Edit`(= 每敲一个字符) - // 和每个 `DragTo`(= 指针频率)都重建一次整张模型,白白克隆 N 个实例的名字。 + // 只有**几何**会变的几支才需要重建页面模型(`pages::Model` 里没有会话内容:会话在 + // presenter 的 rail 上,右栗自己那棵树每次 `view()` 都从 rail 现读)。复审 P3-9 的教训: + // 每敲一个字符都重建整张模型是白克隆。 let geometry_changed = matches!( message, agent::Message::Expand @@ -51,31 +69,52 @@ impl App { ); match message { - agent::Message::Expand => self.agent.expand(), + agent::Message::Expand => { + self.agent.expand(); + // 「展开即读」是 presenter 的语义(`agent_mark_read`),壳只负责在正确的时刻调它 + self.agent_host.mark_read(); + } agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(), agent::Message::Detach => { self.agent.detach(); + self.agent_host.mark_read(); self.state.navigate(Route::Agent); } agent::Message::Dock => { self.agent.dock(); + self.agent_host.mark_read(); self.leave_agent_page(); } agent::Message::DragStart => self.agent.drag_start(), agent::Message::DragTo(x) => self.agent.drag_to(self.width, x), agent::Message::DragEnd => self.agent.drag_end(), - agent::Message::Edit(action) => self.agent.edit(action), - agent::Message::Submit => { - if self.agent.submit(catalog) { - eprintln!("[agent] submit → 占位(没有接后端)"); + agent::Message::Edit(action) => { + self.agent.edit(action); + // 草稿同步给 presenter(切形态不丢;它也是 rail 上唯一允许躺原文的字段) + self.agent_host.set_draft(self.agent.draft.text()); + } + agent::Message::Submit => self.submit_agent(), + agent::Message::Stop => self.dispatch(AppCommand::StopAgent), + agent::Message::Confirm(accept) => { + self.agent_host.confirm(accept); + self.drain_agent(); + } + agent::Message::Events(events) => { + for event in events { + self.agent_host.apply(event); } + self.view_dirty = true; + } + agent::Message::Tab { shift } => { + let busy = self.agent_host.rail().busy; + self.agent.step_focus(shift, busy); } - agent::Message::Stop => self.agent.stop(catalog), - agent::Message::Tab { shift } => self.agent.step_focus(shift), agent::Message::Activate => self.activate_focused(catalog), agent::Message::Key(event) => return self.on_agent_key(event), } + // 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入) + self.sync_agent_presenter(); if geometry_changed { self.view_dirty = true; self.refresh_view(); @@ -83,6 +122,68 @@ impl App { Task::none() } + /// 发送:草稿 → `agent_submit` → 抽事件。 + /// + /// 三种结局各自有落点:发出去(清草稿)/ 后端失败(具名横幅,见 [`App::agent_failure`])/ + /// presenter 拒收(日志一行——这三种情形正常情况下被禁用态挡住,够不着)。 + fn submit_agent(&mut self) { + let text = self.agent.draft.text(); + if text.trim().is_empty() { + return; + } + match self.agent_host.submit(text) { + SubmitOutcome::Sent => { + self.agent.clear_draft(); + self.agent_host.set_draft(String::new()); + self.drain_agent(); + } + SubmitOutcome::Failed(failure) => { + eprintln!("[agent] 提交失败({})", failure.code()); + } + SubmitOutcome::Rejected => { + eprintln!("[agent] presenter 拒收:空文本 / 上一轮未完 / 待确认 三闸该先挡住"); + } + } + } + + /// 抽干 presenter 的事件队列,**经既有 Message 通道**回流(见模块文档末节)。 + pub(crate) fn drain_agent(&mut self) { + let events = self.agent_host.drain(); + if events.is_empty() { + return; + } + let _ = self.on_agent(agent::Message::Events(events)); + } + + /// 把「栏可见吗」写回 presenter(`navigate` + `prefs_mut` 两个窄入口,见 `AgentHost`)。 + pub(crate) fn sync_agent_presenter(&mut self) { + let on_page = self.state.route() == &Route::Agent; + let collapsed = self.agent.rail == RailState::Collapsed; + self.agent_host.sync_visibility(on_page, collapsed); + } + + /// 失败横幅要显示的东西(`None` = 没有错误)。 + /// + /// 两个来源都取**展示输入**:具名原因码 → catalog 译文;或 presenter 已脱敏的这一轮原因。 + /// 壳在这一层不做任何脱敏(那是 presenter 的`scrub_text`),也不拼中英(那是 catalog 的事)。 + pub(crate) fn agent_failure(&self) -> Option> { + let catalog = self.lang().catalog(); + if let Some(failure) = self.agent_host.last_failure() { + return Some(agent::Failure { + title: TextKey::AgentErrorTitle, + body: catalog.text(failure.body_key()), + }); + } + self.agent_host + .rail() + .error + .as_deref() + .map(|body| agent::Failure { + title: TextKey::AgentErrorTitle, + body, + }) + } + /// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。 /// /// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变, @@ -106,12 +207,14 @@ impl App { } } - /// 启动时助手栏要做的两件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。 + /// 启动时助手栏要做的三件事(从 `on_boot` 搬出来:那边只许留一行调用,复审 P3-6)。 /// /// 1. **窗口尺寸 adopt**:唯一真源是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸 /// 钳位)。`App::with_presenter` 只认识常量 1180×760,不 adopt 的话验收矩阵开 900 / 1520 的窗 /// 时页面还按 1180 排版(窄窗里内容被裁)。 - /// 2. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在 + /// 2. **取证夹具**(`ULL_AGENT_*`):先落档位,再**跑一轮真提交**——夹具不造消息,消息只能 + /// 由 presenter 产生(M119 的口径,见 `pages/agent/demo.rs`)。 + /// 3. **拆出档直接落到独立页**:`ULL_AGENT_RAIL=detached`(以及将来持久化的偏好)时,会话就在 /// 独立页里,启动后不该停在实例页。 pub(super) fn boot_agent(&mut self) { let (window_size, _source) = crate::renderer::acceptance::window::window_size( @@ -121,6 +224,23 @@ impl App { self.width = window_size.0; self.height = window_size.1; + if let Some((ui, fixture)) = demo::from_env() { + self.agent = ui; + // 先让 presenter 知道「栏现在看得见吗」,再跑提交,未读口径才对 + self.sync_agent_presenter(); + match fixture { + Fixture::None => {} + Fixture::Turn | Fixture::Unread => { + let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string()); + self.drain_agent(); + } + Fixture::Focused => { + let busy = self.agent_host.rail().busy; + self.agent.step_focus(false, busy); + } + } + } + if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent { eprintln!("[agent] rail=detached → Route::Agent"); self.state.navigate(Route::Agent); diff --git a/crates/app-iced/src/app/update/agent/tests.rs b/crates/app-iced/src/app/update/agent/tests.rs new file mode 100644 index 0000000..a532030 --- /dev/null +++ b/crates/app-iced/src/app/update/agent/tests.rs @@ -0,0 +1,222 @@ +//! **树测试①(M119 验收核心)**:AI 右栏在**真实 presenter 状态**下渲染。 +//! +//! 走的是同一条用户路径:`Message::Agent(Submit)` → `AgentHost::submit` → `Presenter::agent_submit` +//! → transport → 事件队列 → `Message::Agent(Events)` → `Presenter::apply` → `AgentRail`。 +//! **没有一条断言看本地数组**:`transcript` 这类壳自己的会话副本 M119 已经删了,能断言的只剩 +//! presenter 的 rail,而 rail 正是右栏画的东西(`pages/agent::spec` 从它借)。 +//! +//! 覆盖任务书点名的四件事: +//! +//! | 断言 | 抓得住什么 | +//! |---|---| +//! | 提交后 rail 出现「你 + 助手」两条、正文来自事件的流式增量 | 提交没接到 presenter、或右栏还在画样本会话 | +//! | `busy` 翻真 → 收敛后翻假 | 事件没回流、或流式标记没收敛(输入框会永远禁用) | +//! | 标记已读后未读归零 | `agent_mark_read` 没接、或未读口径没跟着 rail | +//! | 三种失败都给**具名**横幅 | 失败被吞掉(M104 的「静默无语」)| + +use iced::widget::text_editor; + +use unlucky_design::color::SchemeFlavor; +use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_presenter::dto::{AgentEvent, AppEvent, ChatRole}; +use unlucky_presenter::mock::MockBackend; +use unlucky_presenter::settings_bridge::SettingsBridge; + +use crate::app::state::presenter::transport::Script; +use crate::app::state::presenter::AgentHost; +use crate::app::Message; +use crate::cli::Options; +use crate::shell::agent::{Message as AgentMessage, RailState}; +use crate::theme::Families; + +use super::App; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 不碰磁盘的 App(mock 后端 + 内存设置桥 + **本脚本的** presenter 句柄)。 +fn app_with(script: Script) -> App { + let mut app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ); + app.agent_host = AgentHost::with_script(script); + // 换句柄 = 可见性还没写回去;`refresh_view` 是那条单点同步 + app.refresh_view(); + app +} + +/// 走真消息提交(用户路径:填草稿 → 点发送)。 +fn submit(app: &mut App, text: &str) { + app.agent.draft = text_editor::Content::with_text(text); + let _ = app.update(Message::Agent(AgentMessage::Submit)); +} + +/// **提交 → 事件回流 → 右栏出现这一轮 → 标记已读归零**(任务书第 2 条)。 +/// +/// 抓得住:①`Submit` 没接 `agent_submit`(rail 空);②事件没经 Message 通道回流(rail 只有半截); +/// ③`expand` 没调 `agent_mark_read`(未读不清);④右栏读的还是壳自己的副本。 +/// 抓不住:气泡在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/`)。 +#[test] +fn a_submitted_turn_lands_in_the_rail_and_reading_it_zeroes_unread() { + let mut app = app_with(Script::Qa); + // 出厂是折叠档 ⇒ presenter 认为「用户没在看着」,到达的助手消息应计未读 + assert_eq!(app.agent.rail, RailState::Collapsed); + assert_eq!(app.agent_host.rail().unread, 0, "空会话没有未读"); + + submit(&mut app, "Which instance should I start?"); + + let rail = app.agent_host.rail(); + assert_eq!( + rail.messages.len(), + 2, + "一条「你」+ 一条助手(事件产生,不是本地回显)" + ); + assert_eq!(rail.messages[0].role, ChatRole::User); + assert_eq!(rail.messages[0].text, "Which instance should I start?"); + assert_eq!(rail.messages[1].role, ChatRole::Assistant); + assert!( + rail.messages[1].text.contains("Fabric"), + "正文来自流式增量:{:?}", + rail.messages[1].text + ); + assert!(!rail.messages[1].streaming, "Qa 脚本会收敛"); + assert!(!rail.busy, "收敛之后不忙(发送键要能再按)"); + assert_eq!(rail.unread, 1, "折叠态到达的一条助手消息 = 一条未读"); + + // 右栏画的正是这份 rail(`pages/agent::spec` 直接借它)——构图不 panic + let _ = app.view(); + + // 打开栏 = 读过:`agent_mark_read` + let _ = app.update(Message::Agent(AgentMessage::Expand)); + assert_eq!(app.agent_host.rail().unread, 0, "标记已读后未读归零"); + assert_eq!(app.agent_host.rail().messages.len(), 2, "已读不改会话"); +} + +/// **busy 正确翻转**:流式中忙 → 后端收敛后不忙。 +/// +/// 抓得住:`busy` 还由壳自己算(那它不会跟 rail 走);`Events` 通道被当成「只追加消息」而丢掉 +/// `MessageFinished`(输入框会永远禁用——这是最要命的坏法之一)。 +#[test] +fn busy_flips_with_the_backend_events() { + let mut app = app_with(Script::Streaming); + submit(&mut app, "hello"); + assert!(app.agent_host.rail().busy, "流式没收敛前输入要禁用"); + + // 后端收敛:一条 `MessageFinished` 经同一条 Message 通道回流 + let index = app.agent_host.rail().messages.len() - 1; + let _ = app.update(Message::Agent(AgentMessage::Events(vec![AppEvent::Agent( + AgentEvent::MessageFinished { index }, + )]))); + assert!(!app.agent_host.rail().busy, "收敛之后必须能再发"); +} + +/// 写操作确认:rail 上有待确认 ⇒ 忙;裁决后收敛并记一条审计。 +#[test] +fn a_pending_write_op_is_visible_and_decidable() { + let mut app = app_with(Script::WriteOp); + submit(&mut app, "把下载并发改小"); + + let rail = app.agent_host.rail(); + let request = rail.confirm.as_ref().expect("要挂一个待确认请求"); + assert_eq!(request.tool, "set_config"); + assert!( + rail.busy, + "等确认也算忙(否则用户能在一轮写操作中间再发一句)" + ); + let _ = app.view(); + + let _ = app.update(Message::Agent(AgentMessage::Confirm(true))); + assert!(app.agent_host.rail().confirm.is_none(), "裁决后清空"); + assert!(!app.agent_host.rail().busy); + assert_eq!(app.agent_host.audit_len(), 1, "裁决要进审计(YOLO 下也是)"); +} + +/// 失败态①:**AI 端点不可达** ⇒ 具名横幅(不是静默,也不是把后端原文糊上屏)。 +#[test] +fn an_unreachable_endpoint_shows_a_named_error() { + let mut app = app_with(Script::Unreachable); + submit(&mut app, "hello"); + + let failure = app.agent_failure().expect("提交失败必须给横幅"); + // 文案跟着**生效语言**走(`LangState` 在这台机器上探到 zh-CN,硬编码英文会假红) + let effective = app.lang().catalog(); + assert_eq!(failure.title, Key::AgentErrorTitle, "标题是 catalog 键"); + assert_eq!(failure.body, effective.text(Key::AgentErrorUnavailable)); + assert!(!failure.body.trim().is_empty()); + assert!( + app.agent_host.rail().messages.is_empty(), + "失败不产生消息:不许假装有回复" + ); + let _ = app.view(); +} + +/// 失败态②:**未实现路由** ⇒ 另一句具名错误(两种失败不能糊成一句)。 +#[test] +fn an_unimplemented_route_shows_its_own_named_error() { + let mut app = app_with(Script::NotImplemented); + submit(&mut app, "hello"); + + let failure = app.agent_failure().expect("必须给横幅"); + let effective = app.lang().catalog(); + assert_eq!(failure.body, effective.text(Key::AgentErrorNotImplemented)); + // 两条原因码要两句话(中英两套都得分开),不是一句万能错误 + for lang in [Lang::En, Lang::ZhCn] { + let catalog = Catalog::of(lang); + assert_ne!( + catalog.text(Key::AgentErrorNotImplemented), + catalog.text(Key::AgentErrorUnavailable), + "{} 下两条原因码糊成一句了", + lang.tag() + ); + } +} + +/// 失败态③:**这一轮回复中途断流** ⇒ 横幅用 presenter 已脱敏的那句原因,已到的增量保留。 +#[test] +fn a_turn_level_failure_keeps_the_partial_answer_and_the_reason() { + let mut app = app_with(Script::FailsMidStream); + submit(&mut app, "读一下日志"); + + let rail = app.agent_host.rail(); + // 用户 + 助手(流式占位)+ 系统条目(`Failed` 会额外追加一条,错误也要能回翻) + assert_eq!(rail.messages.len(), 3, "断流不撤销已到的增量"); + assert!( + rail.messages[1].text.contains("日志"), + "已到的增量要留着:{:?}", + rail.messages[1].text + ); + assert!(!rail.busy, "断流要收敛掉流式标记"); + let failure = app.agent_failure().expect("`Failed` 事件要立横幅"); + assert!( + failure.body.contains("read timeout"), + "横幅正文是 presenter 脱敏后的原因:{}", + failure.body + ); +} + +/// 输入框的草稿会同步给 presenter(切形态不丢的语义在 presenter 那边)。 +#[test] +fn typing_syncs_the_draft_into_the_presenter() { + let mut app = app_with(Script::Qa); + app.agent.draft = text_editor::Content::with_text("half typed"); + let _ = app.update(Message::Agent(AgentMessage::Edit( + text_editor::Action::Edit(text_editor::Edit::Insert('!')), + ))); + assert_eq!( + app.agent_host.rail().draft, + app.agent.draft.text(), + "编辑器里是什么,rail 上就该是什么(同一个串,不是它自己的副本)" + ); + assert!(app.agent_host.rail().draft.contains('!'), "改动真的进去了"); + // 发送成功后草稿两边都清空 + let _ = app.update(Message::Agent(AgentMessage::Submit)); + assert!(app.agent.draft_is_blank(), "编辑器清空"); + assert!(app.agent_host.rail().draft.is_empty(), "presenter 侧也清空"); +} diff --git a/crates/app-iced/src/app/update/clock.rs b/crates/app-iced/src/app/update/clock.rs index ac9ef06..b36909e 100644 --- a/crates/app-iced/src/app/update/clock.rs +++ b/crates/app-iced/src/app/update/clock.rs @@ -12,6 +12,11 @@ //! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | //! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | //! +//! ## M119 增加了什么 +//! +//! [`App::pump`] 里多了一行 [`App::drain_agent`]:AI 助手的事件队列和 core 的事件队列同拍抽干, +//! 于是「右栗的未读/流式状态」和页面快照共享同一个刷新节奏(不会一个 30 Hz、一个 1 Hz)。 + use std::time::{Duration, Instant}; use unlucky_presenter::dto::AppCommand; @@ -98,6 +103,10 @@ impl App { /// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。 pub(in crate::app) fn pump(&mut self) { + // M119:AI 助手的事件队列与 core 的事件队列**同拍**抽干(顺序无关:两个队列互不依赖)。 + // 抽出来的这批事件经 `Message::Agent(Events)` 回流,见 `update/agent.rs`。 + self.drain_agent(); + let events = self.backend.drain_events(); if events.is_empty() { return; diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 44ac62a..5910a61 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -56,6 +56,10 @@ impl App { .map(|element| element.map(Message::Page)); // 右栗(M104):拆出档返回 `None`(会话已在独立页里);拖拽期间另有铺满窗口的取指针层。 let overlay = agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent)); + // M119:会话内容 / 未读 / busy / 待确认全部来自 presenter 的 rail,失败横幅是 app 层 + // 备好的**展示输入**(标题 catalog 键 + 已脱敏正文)。壳在这里不做任何脱敏。 + let rail = self.agent_host.rail(); + let failure = self.agent_failure(); let account = self.rail_account(); match pages::Screen::of(self.state.route()) { @@ -79,7 +83,7 @@ impl App { self.language(), ) .map(Message::Page), - agent::docked(&self.ui, &self.agent, self.width) + agent::docked(&self.ui, &self.agent, rail, failure, self.width) .map(|element| element.map(Message::Agent)), overlay, self.options.force_state, @@ -99,7 +103,7 @@ impl App { }, APP_TITLE, background, - agent::page(&self.ui, &self.agent, self.width).map(Message::Agent), + agent::page(&self.ui, &self.agent, rail, failure, self.width).map(Message::Agent), None, overlay, self.options.force_state, diff --git a/crates/app-iced/src/app/view/account_page.rs b/crates/app-iced/src/app/view/account_page.rs index 08e13e1..e42e2ec 100644 --- a/crates/app-iced/src/app/view/account_page.rs +++ b/crates/app-iced/src/app/view/account_page.rs @@ -52,8 +52,15 @@ impl App { // M106:账户页的文案跟着**生效语言**走(`LangState::boot` 自己读 `ULL_LANG`, // 所以 M114 早期那个「截图专用语言开关」已删——两套开关必然漂)。 pages::account::view(&self.ui, metrics, &self.account, self.lang()).map(Message::Page), - agent::docked(&self.ui, &self.agent, self.width) - .map(|element| element.map(Message::Agent)), + // M119:右栗的会话内容 / 未读 / busy 全部来自 presenter 的 rail(`agent_host`) + agent::docked( + &self.ui, + &self.agent, + self.agent_host.rail(), + self.agent_failure(), + self.width, + ) + .map(|element| element.map(Message::Agent)), overlay, self.options.force_state, self.lang(), diff --git a/crates/app-iced/src/pages/agent/conversation.rs b/crates/app-iced/src/pages/agent/conversation.rs index fb257d9..fd3c9cd 100644 --- a/crates/app-iced/src/pages/agent/conversation.rs +++ b/crates/app-iced/src/pages/agent/conversation.rs @@ -3,24 +3,26 @@ //! ## 输入契约(纯数据入、消息出) //! //! ```text -//! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element +//! Conversation { messages, busy, unread, confirm, failure, draft, blank, rail, host, focus } +//! ──view()──▶ Element //! ``` //! -//! - **入**:全部是**借用**的纯数据(`&[Turn]` / `&text_editor::Content` / `bool` / 枚举), -//! 组件自己不持有会话状态、不读环境、不碰 presenter(任务里「不许提前调用 M103 的 API」); +//! - **入**:全部是**借用**的纯数据。会话三件(`messages` / `busy` / `unread` / `confirm`) +//! 直接从 presenter 的 `AgentRail` 借来(M119),组件自己不持有会话状态;`failure` 是 app 层 +//! 备好的**展示输入**(标题是 catalog 键、正文是已脱敏串),见 `super::Failure`。 //! - **出**:控件树只发 [`super::Message`](`Collapse` / `Detach` / `Dock` / `Submit` / `Stop` / -//! `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被两个宿主 -//! 摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。 +//! `Confirm` / `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被 +//! 两个宿主摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。 //! //! ## 两个宿主只差「外壳」 //! //! | 宿主 | 谁调用 | 给的宽度 | 差别 | //! |---|---|---|---| -//! | [`Host::Rail`] | [`super::rail`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 | +//! | [`Host::Rail`] | [`super::docked`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 | //! | [`Host::Page`] | [`super::page`](独立页) | 行宽上限 424 | 头部动作是 `⇤` 收回;标题是页级标题 | //! //! **判据不是「长得像」而是「同一个函数」**:`tests::both_hosts_render_the_same_conversation` -//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`mod.rs` 里不许出现第二份气泡/输入框装配。 +//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`super::mod` 里不许出现第二份气泡/输入框装配。 //! //! ## 控件与可达性(任务第 4 条逐项) //! @@ -30,34 +32,37 @@ //! | Enter 发送 / Shift+Enter 换行 | [`composer`] 的 `key_binding`:`Binding::Custom(Submit)` vs `Binding::Enter`,用 `modifiers.shift()` 分流(**只有输入框聚焦时生效**,不会抢别处的 Enter) | //! | 发送禁用态 | 草稿空白或流式中 → `Button::disabled`(M3 的 10% 容器 / 38% 内容,不是自创灰色) | //! | 流式中断 | 只在 `busy` 时出现 `Stop`(`IconKind::Close` + 文字标签) | -//! | 未读点 | 折叠条上的圆点([`super::rail`])+ 面板头部的「N unread」胶囊 | +//! | 未读点 | 折叠条上的圆点([`super::strip`])+ 面板头部的「N unread」胶囊 | +//! | 失败态 | [`banners::failure`]:具名标题(catalog)+ 已脱敏正文(M119) | +//! | 写操作确认 | [`banners::confirm`]:请求摘要 + Allow / Deny 两个文字按钮(M119) | //! | `⌄` / `⇥` / `⇤` | 头部两个文字按钮,图标只是**前导**,标签一律可读 | //! | 键盘 | `Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键(订阅在 `app/subscription.rs`) | use iced::widget::text_editor; -use iced::widget::{column, container, row, rule, scrollable, space, text}; +use iced::widget::{column, container, row, rule, space, text}; use iced::{Background, Border, Element, Length, Padding, Theme}; use unlucky_design::color::Role; -use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{ChatMessage, ConfirmRequest}; + use crate::app::components::buttons::{self, Button}; -use crate::app::components::feedback::empty_state::{empty_state, EmptyState}; use crate::app::components::interaction::Interaction; -use crate::pages::frame; use crate::theme::Ui; -use super::state::{Focus, RailState, Speaker, Turn}; -use super::Message; +use super::state::{Focus, RailState}; +use super::{Failure, Message}; +mod banners; mod composer; +mod transcript; -pub(super) use composer::text_role; +pub(super) use composer::{text_role, text_role_owned}; /// 会话视图落在哪种外壳里(只影响头部动作与标题密度,**不影响**对话本体)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -71,16 +76,20 @@ pub enum Host { /// 会话视图的**输入契约**(见模块文档;全部是借用的纯数据)。 #[derive(Clone, Copy)] pub struct Conversation<'a> { - /// 会话内容(零条 = 空会话,画引导空态)。 - pub transcript: &'a [Turn], + /// 会话内容(presenter 的 `AgentRail::messages`,零条 = 空会话,画引导空态)。 + pub messages: &'a [ChatMessage], /// 输入框内容(iced 的 `text_editor` 自己持有编辑状态)。 pub draft: &'a text_editor::Content, - /// 是否在流式回复(决定 `Stop` 是否出现、`Send` 是否禁用)。 + /// 是否在流式回复 / 有待确认写操作(来自 rail 的 `busy`:决定 `Stop` 出现与 `Send` 禁用)。 pub busy: bool, - /// 未读数(>0 时头部画胶囊)。 + /// 未读数(>0 时头部画胶囊;来自 rail 的 `unread`)。 pub unread: usize, /// 草稿是否空白(发送键的禁用判据,由 `AgentUi::draft_is_blank` 给)。 pub blank: bool, + /// 待确认的写操作(`Some` = 画确认条)。 + pub confirm: Option<&'a ConfirmRequest>, + /// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。 + pub failure: Option>, /// 三档状态(决定头部画哪组动作)。 pub rail: RailState, /// 宿主。 @@ -94,23 +103,30 @@ pub struct Conversation<'a> { /// 画会话视图(**右栗与独立页的唯一实现**)。 pub fn view<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { - let body = if spec.transcript.is_empty() { - empty(ui, catalog) - } else { - transcript(ui, spec, catalog) - }; + let mut body = column![header(ui, spec, catalog), divider(ui)] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .height(Length::Fill); - column![ - header(ui, spec, catalog), - divider(ui), - body, - divider(ui), - composer::composer(ui, spec, catalog), - ] - .spacing(ui.space(Space::X2)) - .width(Length::Fill) - .height(Length::Fill) - .into() + // 失败横幅放在会话**上方**:错误是这一轮的结果,比历史更需要先看见(M119)。 + if let Some(failure) = spec.failure { + body = body.push(banners::failure(ui, failure, catalog)); + } + + body = body.push(if spec.messages.is_empty() { + transcript::empty(ui, catalog) + } else { + transcript::list(ui, spec, catalog) + }); + + // 确认条贴着输入区(动作在下面,读完就能按)。 + if let Some(request) = spec.confirm { + body = body.push(banners::confirm(ui, request, catalog)); + } + + body.push(divider(ui)) + .push(composer::composer(ui, spec, catalog)) + .into() } // ---------------------------------------------------------------- 头部 @@ -244,7 +260,7 @@ fn label_button<'a>( } /// 焦点环:键盘走到这一格时叠 10% 的 `Focused` 状态层(组件库的取证状态也是它)。 -fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> { +pub(super) fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> { if focused { button.state(Interaction::Focused) } else { @@ -279,97 +295,6 @@ fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, .into() } -// ---------------------------------------------------------------- 对话本体 - -fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> { - container(empty_state( - ui, - EmptyState::new( - IconKind::Ai, - catalog.text(Key::AgentEmptyTitle), - catalog.text(Key::AgentEmptyBody), - ), - )) - .width(Length::Fill) - .height(Length::Fill) - .into() -} - -fn transcript<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> { - let mut list = column![].spacing(ui.space(Space::X3)).width(Length::Fill); - - for turn in spec.transcript { - list = list.push(bubble(ui, turn, catalog)); - } - - scrollable(container(list).padding(Padding::from([ui.space(Space::X1), 0.0]))) - .width(Length::Fill) - .height(Length::Fill) - .style(frame::scroll_style(ui)) - .into() -} - -/// 一条消息:角色小标 + 正文。三种说话人三种容器(用户 = `SecondaryContainer`、 -/// 助手 = 卡片层、提示 = 无容器只留文字),气泡整体右/左对齐。 -fn bubble<'a>(ui: &'a Ui, turn: &'a Turn, catalog: Catalog) -> Element<'a, Message> { - let (role, fill, ink, align) = match turn.speaker { - Speaker::You => ( - Key::AgentRoleYou, - Some(ui.color(Role::SecondaryContainer)), - ui.color(Role::OnSecondaryContainer), - iced::Alignment::End, - ), - Speaker::Assistant => ( - Key::AgentRoleAssistant, - Some(ui.layer(MaterialLayer::Card)), - ui.color(Role::OnSurface), - iced::Alignment::Start, - ), - Speaker::Notice => ( - Key::AgentRoleNotice, - None, - ui.color(Role::OnSurfaceVariant), - iced::Alignment::Start, - ), - }; - - let inner = column![ - text_role( - ui, - TypeRole::LabelMedium, - false, - catalog.text(role), - ui.color(Role::OnSurfaceVariant), - Length::Fill, - ), - text_role( - ui, - TypeRole::BodyMedium, - false, - turn.text.as_str(), - ink, - Length::Fill, - ), - ] - .spacing(ui.space(Space::X1)) - .width(Length::Fill); - - let card = container(inner) - .width(Length::Fill) - .padding(Padding::from([ui.space(Space::X2), ui.space(Space::X3)])) - .style(move |_theme| container::Style { - background: fill.map(Background::Color), - border: if fill.is_some() { - ui.outlined(ShapeScale::Medium) - } else { - Border::default() - }, - ..container::Style::default() - }); - - container(card).width(Length::Fill).align_x(align).into() -} - /// 一像素分隔线(`pages::frame::divider` 绑在 `pages::Message` 上,这里要自己的消息类型)。 fn divider<'a>(ui: &'a Ui) -> Element<'a, Message> { rule::horizontal::(ui.metrics().divider_thickness) diff --git a/crates/app-iced/src/pages/agent/conversation/banners.rs b/crates/app-iced/src/pages/agent/conversation/banners.rs new file mode 100644 index 0000000..83f62da --- /dev/null +++ b/crates/app-iced/src/pages/agent/conversation/banners.rs @@ -0,0 +1,155 @@ +//! 会话里的两条**横幅**(M119 从 `conversation.rs` 拆出来守 400 行硬线): +//! 失败态(具名错误)与写操作确认。 +//! +//! ## 为什么必须存在 +//! +//! 任务书第 4 条:「presenter 返回错误时 UI 必须显示**具名**错误而不是静默无语」。M104 的壳没有 +//! 任何错误位——`Submit` 只在本地数组追加一句「没发给模型」的提示。现在两条来源都有了落点: +//! +//! | 来源 | 触发 | 画什么 | +//! |---|---|---| +//! | `AgentHost::last_failure` | `agent_submit` 返回 `Err`(端点不可达 / 路由未实现) | 标题 = [`Key::AgentErrorTitle`],正文 = 原因码对应的 catalog 译文 | +//! | `AgentRail::error` | 后端发 `AgentEvent::Failed`(这一轮回复中止) | 标题同上,正文 = presenter 已脱敏的那句原因 | +//! +//! 两者都不把后端诊断原文直接上屏:诊断串只进日志(M114 复审 r1 立的口径)。 +//! +//! ## 确认条 +//! +//! `rail.confirm` 非空 ⇒ 一轮写操作在等用户裁决,而 `busy` 会一直锁着输入(presenter 的 +//! `sync_busy` 语义:`busy = streaming || confirm.is_some()`)。所以这段 UI 不是装饰: +//! **不按就不动**。两个按钮都带文字标签(M104 的「不得只有图标」同一条规矩)。 + +use iced::widget::{column, container, row, Row}; +use iced::{Alignment, Background, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; + +use unlucky_presenter::dto::ConfirmRequest; + +use crate::app::components::buttons::{self, Button}; +use crate::theme::Ui; + +use super::super::{Failure, Message}; +use super::text_role_owned; + +/// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。 +pub(super) fn failure<'a>( + ui: &'a Ui, + failure: Failure<'a>, + catalog: Catalog, +) -> Element<'a, Message> { + banner( + ui, + catalog.text(failure.title).to_string(), + failure.body.to_string(), + Vec::new(), + ) +} + +/// 写操作确认条:一行请求摘要 + Allow / Deny。 +pub(super) fn confirm<'a>( + ui: &'a Ui, + request: &'a ConfirmRequest, + catalog: Catalog, +) -> Element<'a, Message> { + // 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示, + // 否则用户是在盲签。句框走 catalog,摘要本身是正文。 + let title = catalog.render( + Key::AgentConfirmRequest, + &[("summary", request.summary.as_str())], + ); + let actions: Vec> = vec![ + buttons::outlined( + ui, + Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false)) + .leading(IconKind::Close), + ), + buttons::filled( + ui, + Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true)) + .leading(IconKind::Check), + ), + ]; + banner(ui, title, String::new(), actions) +} + +/// 横幅本体:图标块 + 标题/正文 + 右侧动作。 +/// +/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边, +/// `ErrorContainer` / `OnErrorContainer` 只作左侧图标块的强调,不是通栏红条)。 +fn banner<'a>( + ui: &'a Ui, + title: String, + body: String, + actions: Vec>, +) -> Element<'a, Message> { + let side = ui.space(Space::X8); + let glyph_px = ui.space(Space::X6); + let chip = container(crate::icons::view_at( + IconKind::Error, + ui.color(Role::Error), + glyph_px, + )) + .width(Length::Fixed(side)) + .height(Length::Fixed(side)) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::ErrorContainer))), + border: iced::Border { + radius: ui.radius(ShapeScale::Full, side), + ..iced::Border::default() + }, + ..container::Style::default() + }); + + let mut lines = column![text_role_owned( + ui, + TypeRole::TitleSmall, + true, + title, + ui.color(Role::OnSurface), + Length::Fill, + )] + .spacing(ui.space(Space::X1)); + if !body.is_empty() { + lines = lines.push(text_role_owned( + ui, + TypeRole::BodySmall, + false, + body, + ui.color(Role::OnSurfaceVariant), + Length::Fill, + )); + } + + let mut content = Row::new() + .spacing(ui.space(Space::X3)) + .align_y(Alignment::Center) + .width(Length::Fill) + .push(chip) + .push(Element::from(container(lines).width(Length::Fill))); + if !actions.is_empty() { + content = content.push(Element::from( + row(actions) + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + )); + } + + container(content) + .width(Length::Fill) + .padding(ui.metrics().card_padding) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Card))), + border: ui.outlined(ShapeScale::Medium), + ..container::Style::default() + }) + .into() +} diff --git a/crates/app-iced/src/pages/agent/conversation/composer.rs b/crates/app-iced/src/pages/agent/conversation/composer.rs index 3fdbf53..d7fc18c 100644 --- a/crates/app-iced/src/pages/agent/conversation/composer.rs +++ b/crates/app-iced/src/pages/agent/conversation/composer.rs @@ -120,7 +120,38 @@ pub(super) fn binding_for(press: KeyPress) -> Option> { } } -/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。 +/// 一档文字(**借来的**正文)。 +/// +/// 与 `pages::frame::text_role` 同源,但那边返回的是 `Element`,助手栏要自己的 +/// 消息类型——这是 M104 起就有的分工,不是重复实现。 +/// 一档文字(**拼出来的**正文:工具条目 `name · summary`、横幅文案都走它)。 +/// +/// 为什么不能直接用借用版:正文是 `String`,而 `Element` 不允许借用局部变量。 +pub(crate) fn text_role_owned<'a>( + ui: &'a Ui, + role: TypeRole, + emphasized: bool, + content: String, + color: iced::Color, + width: Length, +) -> Element<'a, Message> { + let style = if emphasized { + ui.emphasized(role) + } else { + ui.style(role) + }; + + text(content) + .size(style.size_px) + .line_height(iced::widget::text::LineHeight::Absolute(Pixels( + style.line_height_px, + ))) + .font(ui.font_of(style)) + .color(color) + .width(width) + .into() +} + pub(crate) fn text_role<'a>( ui: &'a Ui, role: TypeRole, diff --git a/crates/app-iced/src/pages/agent/conversation/transcript.rs b/crates/app-iced/src/pages/agent/conversation/transcript.rs new file mode 100644 index 0000000..226ab8d --- /dev/null +++ b/crates/app-iced/src/pages/agent/conversation/transcript.rs @@ -0,0 +1,124 @@ +//! 对话本体:空态 + 消息气泡(M119 从 `conversation.rs` 拆出来守 400 行硬线)。 +//! +//! ## 数据从哪来 +//! +//! 直接画 presenter 的 `AgentRail::messages`(`ChatMessage`):`role` 决定角色小标与气泡容器, +//! `text` 是正文(**已经过 presenter 的第二道脱敏闸门** `scrub_text`),`tool` 是工具调用条目 +//! (D4:工具调用会话可见)。壳里不再有 `Speaker`/`Turn` 那种自己的会话类型——那是一份要跟 +//! presenter 对齐的镜像,M119 已经删掉。 + +use iced::widget::{column, container, scrollable}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; + +use unlucky_presenter::dto::{ChatMessage, ChatRole}; + +use crate::app::components::feedback::empty_state::{empty_state, EmptyState}; +use crate::pages::frame; +use crate::theme::Ui; + +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> { + container(empty_state( + ui, + EmptyState::new( + IconKind::Ai, + catalog.text(Key::AgentEmptyTitle), + catalog.text(Key::AgentEmptyBody), + ), + )) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// 气泡列表(可滚动)。 +pub(super) fn list<'a>( + ui: &'a Ui, + spec: Conversation<'a>, + catalog: Catalog, +) -> Element<'a, Message> { + let mut items = column![].spacing(ui.space(Space::X3)).width(Length::Fill); + + for message in spec.messages { + items = items.push(bubble(ui, message, catalog)); + } + + scrollable(container(items).padding(Padding::from([ui.space(Space::X1), 0.0]))) + .width(Length::Fill) + .height(Length::Fill) + .style(frame::scroll_style(ui)) + .into() +} + +/// 一条消息:角色小标 + 正文。三种容器(用户 = `SecondaryContainer`、助手/工具 = 卡片层、 +/// 系统提示 = 无容器只留文字),气泡整体右/左对齐。 +fn bubble<'a>(ui: &'a Ui, message: &'a ChatMessage, catalog: Catalog) -> Element<'a, Message> { + let (role, fill, ink, align) = match message.role { + ChatRole::User => ( + Key::AgentRoleYou, + Some(ui.color(Role::SecondaryContainer)), + ui.color(Role::OnSecondaryContainer), + Alignment::End, + ), + // 工具条目也挂在助手名下:它是助手这一轮做的事,不是第三方说话人 + ChatRole::Assistant | ChatRole::Tool => ( + Key::AgentRoleAssistant, + Some(ui.layer(MaterialLayer::Card)), + ui.color(Role::OnSurface), + Alignment::Start, + ), + ChatRole::System => ( + Key::AgentRoleNotice, + None, + ui.color(Role::OnSurfaceVariant), + Alignment::Start, + ), + }; + + // 工具条目:正文是结果摘要,名字前缀一行(`tool.name`,与 ToolSpec 注册表同名)。 + // 两者都来自 presenter 的事件,已过脱敏;这里只拼展示串。 + let body = match &message.tool { + Some(tool) => format!("{} · {}", tool.name, tool.summary), + None => message.text.clone(), + }; + + let inner = column![ + text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(role), + ui.color(Role::OnSurfaceVariant), + Length::Fill, + ), + text_role_owned(ui, TypeRole::BodyMedium, false, body, ink, Length::Fill), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + let card = container(inner) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), ui.space(Space::X3)])) + .style(move |_theme| container::Style { + background: fill.map(Background::Color), + border: if fill.is_some() { + ui.outlined(ShapeScale::Medium) + } else { + Border::default() + }, + ..container::Style::default() + }); + + container(card).width(Length::Fill).align_x(align).into() +} diff --git a/crates/app-iced/src/pages/agent/demo.rs b/crates/app-iced/src/pages/agent/demo.rs index 07c56a8..b3f517a 100644 --- a/crates/app-iced/src/pages/agent/demo.rs +++ b/crates/app-iced/src/pages/agent/demo.rs @@ -1,54 +1,54 @@ -//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `ULL_AGENT_*` 取证开关。 +//! 取证开关(`ULL_AGENT_*`):三档档位 / 展开宽度 / 会话夹具。 //! -//! ## 为什么是内置样本而不是 presenter +//! ## 与 M104 的区别:这里不再有「样本会话」 //! -//! 本 mission 只做**壳**:真数据由 M103(`presenter::agent::AgentRail` + 四个签名)供, -//! 这里一行 presenter 调用都没有(任务里点名「不许提前调用」)。样本的职责只有两个: -//! ①让三档状态在图上都有内容;②让「空会话」有对照([`FIXTURE_ENV`])。 +//! M104 的 `demo::transcript()` 是**内置会话样本**,右栗出厂就画着四条假对话——那是「壳先搭好、 +//! 等 M103 接」的过渡态。M119 接线之后会话的唯一来源是 presenter,于是样本只能以**真链路**的 +//! 方式进来:夹具不造消息,而是**走一次真的提交**(`agent_submit` → transport → 事件队列 → +//! `Message::Agent(Events)` → `apply`)。跑什么脚本由 `ULL_AGENT_SCRIPT`(见 +//! [`crate::app::state::presenter`])决定,夹具只决定「要不要在启动时跑这一轮」。 //! -//! 样本正文是**演示数据**,与 `pages/instances/data.rs` 的 demo 同一性质:它不是界面文案, -//! 不进 i18n 词表(界面上会随真模型一起消失)。界面自己的文案(标题/按钮/空态/提示)全部走 `Key`。 +//! 于是出厂态是**空会话**(空态引导卡)——这才是没有模型的启动器该有的样子。 //! -//! ## 取证开关(只影响这一次进程,语义与 `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 一致) +//! ## 取值表 //! //! | 变量 | 取值 | 作用 | //! |---|---|---| //! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) | -//! | [`FIXTURE_ENV`] | `empty` / `streaming` / `unread` / `focused` | 空会话(零消息)/ 流式中(中断键可见)/ 展开但带未读(单独看徽标)/ 键盘焦点环(`Tab` 走过一次的样子) | +//! | [`FIXTURE_ENV`] | `turn` / `unread` / `focused` / `empty` | 启动时跑一轮真提交 / 折叠态跑(留未读)/ 键盘焦点环 / 什么都不做 | //! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) | +//! +//! 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。 use super::metrics::PRESETS; -use super::state::{AgentUi, RailState, Speaker, Turn}; +use super::state::{AgentUi, RailState}; /// 启动档位。 pub const RAIL_ENV: &str = "ULL_AGENT_RAIL"; -/// 会话样本(`empty` / `streaming`)。 +/// 会话夹具(`turn` / `unread` / `focused` / `empty`)。 pub const FIXTURE_ENV: &str = "ULL_AGENT_FIXTURE"; /// 展开档宽度(只接受三档预设值)。 pub const WIDTH_ENV: &str = "ULL_AGENT_WIDTH"; -/// 内置会话样本(4 条,两类说话人 + 一条工具提示)。 -pub fn transcript() -> Vec { - vec![ - Turn::new(Speaker::You, "Which instance should I start for a 1.20.4 test?"), - Turn::new( - Speaker::Assistant, - "Survival is on 1.20.4 with Fabric 0.16.5 and its files are present. Redstone Lab is the same \ - version but its folder is missing a jar.", - ), - Turn::new(Speaker::You, "Use Survival then. Anything I should know first?"), - Turn::new( - Speaker::Assistant, - "Java 21 is already adopted for that instance, and the last launch exited normally. \ - Say the word and I will queue it.", - ), - ] +/// 夹具:启动时对**真 presenter** 做什么。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fixture { + /// 什么都不做(出厂态:空会话)。`ULL_AGENT_FIXTURE=empty` 显式写出来也一样。 + None, + /// 跑一轮真提交(脚本由 `ULL_AGENT_SCRIPT` 决定:问答 / 断流 / 写操作 / 两种失败)。 + Turn, + /// 折叠态跑一轮真提交:于是未读会真的被 presenter 累加(不是硬编码的数字)。 + Unread, + /// 键盘巡航一次的样子(焦点环 = 10% 状态层)。 + Focused, } -/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。 -/// -/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。 -pub fn from_env() -> Option { +/// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容, +/// 不进 i18n 词表——接上真模型之后它就不存在了)。 +pub const DEMO_QUESTION: &str = "Which instance should I start for a 1.20.4 test?"; + +/// 按环境变量构造一份 UI 状态 + 夹具(没设任何变量 = `None`,调用方用出厂态)。 +pub fn from_env() -> Option<(AgentUi, Fixture)> { let rail = std::env::var(RAIL_ENV).ok(); let fixture = std::env::var(FIXTURE_ENV).ok(); let width = std::env::var(WIDTH_ENV).ok(); @@ -57,11 +57,7 @@ pub fn from_env() -> Option { return None; } - let fixture = fixture.as_deref().map(str::trim); - let mut ui = match fixture { - Some("empty") => AgentUi::empty(), - _ => AgentUi::new(), - }; + let mut ui = AgentUi::new(); if let Some(raw) = width.as_deref().map(str::trim) { match raw.parse::() { @@ -70,13 +66,9 @@ pub fn from_env() -> Option { } } - if fixture == Some("streaming") { - ui.pretend_streaming(); - } - // 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义 // (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。 - // 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(见模块文档的表)。 + // 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(折叠态跑一轮)。 match rail.as_deref().map(str::trim) { None => ui.expand(), Some(name) => match RailState::from_name(name) { @@ -90,24 +82,43 @@ pub fn from_env() -> Option { }, } - if fixture == Some("unread") { - ui.unread = 3; - } - - if fixture == Some("focused") { - // 键盘巡航一次的样子(焦点环 = 10% 状态层)。真窗口里按一次 `Tab` 就会到这一态, - // 但截图通道收不到键盘输入,所以只能这样取证。 - ui.step_focus(false); - } + let fixture = match fixture.as_deref().map(str::trim) { + Some("turn") => Fixture::Turn, + Some("unread") => Fixture::Unread, + Some("focused") => Fixture::Focused, + Some("empty") | None => Fixture::None, + Some(other) => { + eprintln!("[agent] {FIXTURE_ENV}={other:?} 不是夹具之一,按 empty 处理"); + Fixture::None + } + }; eprintln!( - "[agent] rail={} width={} turns={} busy={} unread={}", + "[agent] rail={} width={} fixture={fixture:?}", ui.rail.name(), ui.expanded(), - ui.transcript.len(), - ui.busy, - ui.unread ); - Some(ui) + Some((ui, fixture)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_demo_question_is_plain_ascii_so_a_clean_ci_has_the_glyphs() { + assert!( + DEMO_QUESTION.is_ascii(), + "夹具正文不带非 ascii(干净 CI 无中文字体)" + ); + } + + #[test] + fn every_rail_state_name_round_trips_through_the_env_parser() { + for state in RailState::ALL { + assert_eq!(RailState::from_name(state.name()), Some(state)); + } + assert_eq!(RailState::from_name("nope"), None); + } } diff --git a/crates/app-iced/src/pages/agent/mod.rs b/crates/app-iced/src/pages/agent/mod.rs index 409cca4..b59f926 100644 --- a/crates/app-iced/src/pages/agent/mod.rs +++ b/crates/app-iced/src/pages/agent/mod.rs @@ -1,29 +1,27 @@ //! # AI 助手(右栗三档 + `Route::Agent` 独立页) //! //! 用户定案(D16):「右侧常驻可折叠,允许拆分成独立页」+「中栗宽度可调(磁铁吸附)」。 -//! 本模块交付的是**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。真数据来自 -//! M103(`presenter::agent::AgentRail`,四个签名见任务上下文)——本 mission **一行都不调**, -//! 会话内容用的是内置样本([`demo`])。 +//! 本模块交付**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。 +//! +//! **会话数据来自 presenter**(M119):唯一来源是 `app/state.rs` 里的 `agent_host` → +//! `AgentRail`。壳里不再有样本会话、也不再自己数未读——M104 当时的 `demo::transcript()` +//! 只剩「截图取证要不要自动跑一轮」这一件事(见 [`demo`])。 //! //! ## 文件与职责 //! //! | 文件 | 职责 | //! |---|---| -//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的状态) | +//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的纯 UI 态) | //! | `conversation` | **会话视图组件**:右栗与独立页唯一的对话 UI(输入契约见该文件) | -//! | [`demo`] | 占位样本会话 + `ULL_AGENT_*` 取证开关 | +//! | [`demo`] | `ULL_AGENT_*` 取证开关(自动跑一轮 / 三档 / 宽度 / 焦点环) | //! | 本文件 | 两个**宿主**([`docked`] 右栗 / [`page`] 独立页)+ 消息枚举 + 拖拽层 | //! | `tests.rs` / `metrics/tests.rs` | 三档迁移 / 单实现判据 / a11y 文案;数值表与磁铁在后者 | //! //! ## 为什么物理路径在 `pages/agent/`、逻辑路径却是 `crate::shell::agent` //! -//! 本 mission 的 scope 给了 `pages/agent/**`,但**没给** `pages/mod.rs`(那是一个四条页面线共用的 -//! 声明文件)。于是 `shell/mod.rs` 用 `#[path = "../pages/agent/mod.rs"]` 挂载它——与 -//! `app/mod.rs` 挂 `../components/mod.rs` 是同一个先例(理由也一样:别动共用声明文件)。 -//! -//! **没有第二份实现**:物理目录只有 `crates/app-iced/src/pages/agent/`,`crate::shell::agent` -//! 只是它的逻辑名字。搬回 `pages::agent` 只需在 `pages/mod.rs` 加一行 `mod agent;`, -//! 那一步不属于本 mission(报告里登记)。 +//! M104 的 scope 给了 `pages/agent/**`,但**没给** `pages/mod.rs`(那是四条页面线共用的声明文件)。 +//! 于是 `shell/mod.rs` 用 `#[path = "../pages/agent/mod.rs"]` 挂载它——与 `app/mod.rs` 挂 +//! `../components/mod.rs` 是同一个先例。**没有第二份实现**:物理目录只有这一处。 //! //! ## 三档状态([`RailState`]) //! @@ -36,37 +34,39 @@ //! ## 为什么消息不走 `pages::Message` //! //! 助手栏是**外壳级视图**(挂在任何页面右边),不是某一页的消息:塞进 `pages::Message` 要动 -//! `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的 M101 改),语义也不对。 -//! 所以是一等公民 [`Message`] + `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。 +//! `app/update/page_messages.rs` 的穷尽 match,语义也不对。所以是一等公民 [`Message`] + +//! `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。 pub(crate) mod demo; mod metrics; mod state; mod conversation; +mod strip; #[cfg(test)] mod tests; pub use state::{AgentUi, Focus, RailState}; -use iced::widget::{column, container, mouse_area, row, rule, space, stack, text, tooltip}; +use iced::widget::{column, container, mouse_area, row, rule, space, text, tooltip}; use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme}; use unlucky_design::color::Role; use unlucky_design::elevation::MaterialLayer; -use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key, Lang}; -use crate::app::components::buttons::{self, IconVariant}; +use unlucky_presenter::agent::AgentRail; +use unlucky_presenter::dto::AppEvent; + use crate::pages::{self, layout}; use crate::theme::Ui; use conversation::{Conversation, Host}; -use metrics::{COLLAPSED_WIDTH, RESIZE_GUTTER}; +use metrics::RESIZE_GUTTER; /// 界面语言(与产品其它页面同一口径:i18n 接线前是英文基线,D28 的「英文环境零中文负担」)。 const LANG: Lang = Lang::En; @@ -86,8 +86,16 @@ pub enum Message { Edit(iced::widget::text_editor::Action), /// 发送(`Send` 键 / 输入框里按 Enter)。 Submit, - /// 中断流式回复。 + /// 中断流式回复(`AppCommand::StopAgent`;收敛靠后端事件)。 Stop, + /// 允许 / 拒绝当前待确认的写操作(`rails.confirm` 非空时才有按钮)。 + Confirm(bool), + /// **事件回流**(M119):presenter 侧产生的 `AppEvent` 整批回到 `update` 的那条通道。 + /// + /// 为什么是消息而不是直接改状态:异步后端在后台线程里产事件,UI 只能在节拍里抽; + /// 抽出来的这一批必须和「用户点击」走同一条漏斗(`on_agent`),否则两处各改一次状态, + /// 未读/流式标记会打架。落点见 `app/update/agent.rs` 的 `Events` 分支。 + Events(Vec), /// 按下拖拽缝。 DragStart, /// 拖拽中:窗口坐标里的指针 x。 @@ -107,30 +115,56 @@ pub enum Message { Key(iced::keyboard::Event), } +/// 错误横幅的**展示输入**(M119「失败态可见」的唯一落点)。 +/// +/// 为什么要包一层而不是把 `String` 传进来:标题必须是 catalog 键(`lang` 说哪种语言就哪种), +/// 正文则有两种合法来源——具名原因码对应的 catalog 译文(端不可达 / 未实现),或 presenter +/// 已经脱敏过的这一轮错误原文(`AgentRail::error`)。两者都是「已 sanitize 的展示串」, +/// 页面不再加工(UI 层不复制脱敏逻辑)。 +#[derive(Debug, Clone, Copy)] +pub struct Failure<'a> { + /// 横幅标题的 catalog 键。 + pub title: Key, + /// 横幅正文(已脱敏 / 已是译文)。 + pub body: &'a str, +} + /// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。 /// /// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。 pub fn docked<'a>( ui: &'a Ui, agent: &'a AgentUi, + rail: &'a AgentRail, + failure: Option>, window_width: f32, ) -> Option> { // 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条 // (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。 match agent.effective_rail(window_width) { RailState::Detached => None, - RailState::Collapsed => Some(strip(ui, agent)), - RailState::Expanded => Some(panel(ui, agent, window_width)), + RailState::Collapsed => Some(strip::strip(ui, rail)), + RailState::Expanded => Some(panel(ui, agent, rail, failure, window_width)), } } /// `Route::Agent` 独立页(与右栗**同一个** `conversation::view`)。 /// /// 页级骨架(页边距 + 正文列宽)与其它页面同源:列宽取 [`layout::frame_metrics`],单列 ≤424。 -pub fn page<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> { +pub fn page<'a>( + ui: &'a Ui, + agent: &'a AgentUi, + rail: &'a AgentRail, + failure: Option>, + window_width: f32, +) -> Element<'a, Message> { let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width); - let body = conversation::view(ui, spec(agent, Host::Page), Catalog::of(LANG)); + let body = conversation::view( + ui, + spec(agent, rail, failure, Host::Page), + Catalog::of(LANG), + ); container( container(body) @@ -178,137 +212,35 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { ) } -// ---------------------------------------------------------------- 折叠档:48 图标条 - -fn strip<'a>(ui: &'a Ui, agent: &'a AgentUi) -> Element<'a, Message> { - let catalog = Catalog::of(LANG); - let side = ui.metrics().control_height; - - // 图标按钮 40×40(M3 icon button),未读点叠在右上角;下面两行小字保证「不得只有图标」: - // 条上每个可点元素都有可读文字(完整名字在 tooltip 里)。 - let icon: Element<'a, Message> = buttons::icon( - ui, - IconVariant::Standard, - IconKind::Ai, - Some(Message::Expand), - None, - ); - - let glyph: Element<'a, Message> = if agent.unread > 0 { - stack![ - icon, - container(dot(ui)) - .width(Length::Fixed(side)) - .height(Length::Fixed(side)) - .align_x(Alignment::End) - .align_y(Alignment::Start) - .padding(Padding::from([0.0, ui.space(Space::X1)])), - ] - .into() - } else { - icon - }; - - let labelled = container(column![ - glyph, - conversation::text_role( - ui, - TypeRole::LabelMedium, - false, - catalog.text(Key::AgentStripLabel), - ui.color(Role::OnSurfaceVariant), - // `Shrink` 而不是 `Fill`:48 宽的条上 Fill 会让文字贴左缘,"2 unread" 会被窗角裁掉 - Length::Shrink, - ), - unread_text(ui, agent.unread, catalog), - ]) - .width(Length::Fixed(COLLAPSED_WIDTH)) - .align_x(Alignment::Center) - .padding(Padding::from([ui.space(Space::X6), 0.0])); - - let tip_style = ui.style(TypeRole::BodySmall); - let tip = container( - text(catalog.text(Key::AgentOpen)) - .size(tip_style.size_px) - .font(ui.font_of(tip_style)) - .color(ui.color(Role::OnSurface)), - ) - .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), - border: Border { - radius: ui.radius_dp(ShapeScale::Small), - ..Border::default() - }, - ..container::Style::default() - }); - - container(tooltip(labelled, tip, tooltip::Position::Left)) - .width(Length::Fixed(COLLAPSED_WIDTH)) - .height(Length::Fill) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), - ..container::Style::default() - }) - .into() -} - -/// 未读点(8×8,`Primary`)。 -fn dot<'a>(ui: &'a Ui) -> Element<'a, Message> { - let side = ui.space(Space::X2); - - container(space::horizontal()) - .width(Length::Fixed(side)) - .height(Length::Fixed(side)) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.color(Role::Primary))), - border: Border { - radius: ui.radius(ShapeScale::Full, side), - ..Border::default() - }, - ..container::Style::default() - }) - .into() -} - -/// 未读的**可读形式**:条上只放计数(`2`),完整那句(`2 unread`)在面板头部的胶囊里。 -/// -/// 为什么不是「2 unread」:48px 的条装不下那 8 个字符(实测会贴到窗角被裁),条上本来只需 -/// 「有几条」。零未读时给一个等高留白,免得条上元素上下跳。 -fn unread_text<'a>(ui: &'a Ui, unread: usize, _catalog: Catalog) -> Element<'a, Message> { - let style = ui.style(TypeRole::LabelMedium); - - if unread == 0 { - return space::vertical().height(style.line_height_px).into(); - } - - text(unread.to_string()) - .size(style.size_px) - .font(ui.font_of(style)) - .color(ui.color(Role::Primary)) - .width(Length::Shrink) - .into() -} - // ---------------------------------------------------------------- 展开档:拖拽缝 + 面板 -fn panel<'a>(ui: &'a Ui, agent: &'a AgentUi, window_width: f32) -> Element<'a, Message> { +fn panel<'a>( + ui: &'a Ui, + agent: &'a AgentUi, + rail: &'a AgentRail, + failure: Option>, + window_width: f32, +) -> Element<'a, Message> { let width = agent.rail_width(window_width); let catalog = Catalog::of(LANG); - let body = container(conversation::view(ui, spec(agent, 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(agent, rail, failure, 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) @@ -366,12 +298,20 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message } /// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。 -fn spec<'a>(agent: &'a AgentUi, host: Host) -> Conversation<'a> { +fn spec<'a>( + agent: &'a AgentUi, + rail: &'a AgentRail, + failure: Option>, + host: Host, +) -> Conversation<'a> { Conversation { - transcript: &agent.transcript, + // 会话内容、未读、busy、待确认:**全部**来自 presenter(M119) + messages: &rail.messages, + busy: rail.busy, + unread: rail.unread, + confirm: rail.confirm.as_ref(), + failure, draft: &agent.draft, - busy: agent.busy, - unread: agent.unread, blank: agent.draft_is_blank(), rail: agent.rail, host, diff --git a/crates/app-iced/src/pages/agent/state.rs b/crates/app-iced/src/pages/agent/state.rs index ff3613b..33fff82 100644 --- a/crates/app-iced/src/pages/agent/state.rs +++ b/crates/app-iced/src/pages/agent/state.rs @@ -1,6 +1,13 @@ -//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`AgentUi`](挂在 `App` 上的 UI 状态)。 +//! 右栗的**纯 UI 状态**:三档状态机 + 展开宽度 + 草稿 + 键盘焦点环 + [`AgentUi`]。 //! -//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「状态怎么变」。 +//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「UI 状态怎么变」。 +//! +//! ## 会话内容为什么不在这个文件里(M119) +//! +//! M104 的 `AgentUi` 自带 `transcript` / `busy` / `unread` 三个字段,数据来自内置样本 +//! ([`super::demo`])——那是「壳先搭好、等 M103 接」的过渡态。M119 把这三个字段**删掉**: +//! 会话的唯一来源是 presenter 的 `AgentRail`(`app/state.rs` 的 `agent_host`),右栗与独立页 +//! 直接读它。于是在这个文件里再放一份就是第二份真相,而两份真相必然漂。 //! //! ## 三档状态(用户定案 D16) //! @@ -10,21 +17,15 @@ //! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 | //! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 | //! -//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,`transcript` / `draft` / -//! `busy` / `unread` 一条都不丢(「拆出去再接回来,话说了一半还在」)。 +//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,草稿与焦点一条都不丢 +//! (会话本身在 presenter 上,三档共享得比原来更彻底)。 //! -//! ## 为什么状态是 `App` 的字段 +//! ## 未读 / busy 从哪来 //! -//! 这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,状态挂在 `App` 上最自然。 -//! 早先想过 `OnceLock>`(当时 `App` 字段不在 scope),tower 明确否掉:那要测试串行化、 -//! 而且不是 per-App。 -//! -//! **持久化欠账**:`presenter::prefs::UiPrefs` 里的键不在本 mission 的 scope,所以三档与吸附宽度 -//! **只在本次进程内有效**(重启回到折叠 / 320)。接手点:`UiPrefs` 加两个键 + `App::new` 读一次, -//! 本文件的 [`AgentUi::rail`] / [`AgentUi::set_expanded`] 就是那两个落点。 +//! 都在 `AgentRail` 上:未读由 presenter 按「栏可见吗」累加、`agent_mark_read()` 清零; +//! `busy` 由「有流式消息 || 有写操作待确认」算出来。壳里**不再自己数**未读。 use iced::widget::text_editor; -use unlucky_i18n::{Catalog, Key}; use super::metrics::{ content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH, @@ -64,36 +65,6 @@ impl RailState { } } -/// 会话里的一个说话人。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Speaker { - /// 用户。 - You, - /// 助手(占位样本数据;真模型由 M103 的 presenter 供)。 - Assistant, - /// 系统提示(占位说明 / 中断记录)。 - Notice, -} - -/// 一条会话消息(**纯数据**:视图只读它,不改它)。 -#[derive(Debug, Clone)] -pub struct Turn { - /// 说话人。 - pub speaker: Speaker, - /// 正文(多行;空会话时不会有 `Turn`)。 - pub text: String, -} - -impl Turn { - /// 造一条。 - pub fn new(speaker: Speaker, text: impl Into) -> Self { - Self { - speaker, - text: text.into(), - } - } -} - /// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { @@ -143,6 +114,8 @@ impl Focus { } /// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。 + /// + /// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。 pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus { let order = Self::order(state, busy); let Some(at) = order.iter().position(|focus| *focus == self) else { @@ -165,14 +138,8 @@ pub struct AgentUi { pub rail: RailState, /// 展开档的吸附宽度(三档共享一个值:收起再展开回到同一宽度)。 expanded: f32, - /// 会话内容(占位样本数据,真模型由 M103 接)。 - pub transcript: Vec, /// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。 pub draft: text_editor::Content, - /// 是否在流式回复(`false` = 没有中断键、发送键可用)。 - pub busy: bool, - /// 未读数(折叠条画点 + 一行可读文字;展开/拆出即清零)。 - pub unread: usize, /// 键盘软焦点。 pub focus: Focus, /// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。 @@ -186,7 +153,7 @@ pub struct AgentUi { } impl AgentUi { - /// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。 + /// 出厂态:**折叠** + 空草稿 + 无未读(未读由 presenter 按事件累加,M119)。 /// /// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够, /// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。 @@ -194,24 +161,13 @@ impl AgentUi { Self { rail: RailState::Collapsed, expanded: DEFAULT_EXPANDED_WIDTH, - transcript: super::demo::transcript(), draft: text_editor::Content::new(), - busy: false, - unread: 2, focus: Focus::Composer, keyboard_mode: false, pointer: None, } } - /// 空会话(出图矩阵的「零消息」那一张:清空 transcript 与未读)。 - pub fn empty() -> Self { - let mut ui = Self::new(); - ui.transcript.clear(); - ui.unread = 0; - ui - } - /// 当前展开宽度(吸附后的值)。 pub fn expanded(&self) -> f32 { self.expanded @@ -249,7 +205,8 @@ impl AgentUi { content_width(window_width, self.rail, self.expanded) } - /// 收起(`⌄` / `Esc`)。未读不清零:收起后新消息仍要能显示成点。 + /// 收起(`⌄` / `Esc`)。未读**不**清零:收起后新消息仍要能显示成点(清零由 app 层在 + /// 「打开栏」时调 `agent_mark_read` 做——那是 presenter 的语义,不是 UI 的)。 pub fn collapse(&mut self) { self.keyboard_mode = false; if self.rail == RailState::Expanded { @@ -260,19 +217,17 @@ impl AgentUi { } } - /// 展开(点图标条)。展开即视为读过:未读清零(与主流聊天客户端的「打开即读」同口径)。 + /// 展开(点图标条)。展开即视为读过——**清零动作在 app 层**(`mark_read`),这里只改档位。 pub fn expand(&mut self) { self.keyboard_mode = false; self.rail = RailState::Expanded; - self.unread = 0; self.focus = Focus::Collapse; } - /// 拆成独立页(`⇥`)。会话状态一条不动。 + /// 拆成独立页(`⇥`)。UI 状态一条不动(会话在 presenter 上,本来就不归这里管)。 pub fn detach(&mut self) { self.keyboard_mode = false; self.rail = RailState::Detached; - self.unread = 0; self.focus = Focus::Detach; } @@ -280,7 +235,6 @@ impl AgentUi { pub fn dock(&mut self) { self.keyboard_mode = false; self.rail = RailState::Expanded; - self.unread = 0; self.focus = Focus::Collapse; } @@ -288,8 +242,7 @@ impl AgentUi { /// /// 这里**不**分 Enter / Shift+Enter:分流在 `conversation::composer::binding_for`(非 shift 的 /// Enter 根本不会变成 `Action`,它直接发 `Message::Submit`)。所以走到这里的 `Edit(Enter)` - /// 一定是「Shift+Enter 换行」那一支,`perform` 就够——早先这里有一段与之等价的 `if` 分支, - /// 读起来像关键拦截,实际是冗余(复审 P3-2 指出后删掉)。 + /// 一定是「Shift+Enter 换行」那一支,`perform` 就够。 pub fn edit(&mut self, action: text_editor::Action) { use iced::widget::text_editor::Action; @@ -301,44 +254,9 @@ impl AgentUi { self.draft.perform(action); } - /// 发送:把草稿记成一条「你」的消息 + 一条占位说明。 - /// - /// **不接后端**(M103 的 presenter API 不在本 mission 里调用):所以既不改 `busy`, - /// 也不假装有回复——只追加一条**说明自己没接模型**的提示。返回是否真的发了。 - pub fn submit(&mut self, catalog: Catalog) -> bool { - let text = self.draft.text(); - let trimmed = text.trim(); - if trimmed.is_empty() || self.busy { - return false; - } - - self.transcript - .push(Turn::new(Speaker::You, trimmed.to_owned())); - self.transcript.push(Turn::new( - Speaker::Notice, - catalog.text(Key::AgentNotePending), - )); + /// 清空输入框(发送成功之后调:草稿已经交给 presenter 了)。 + pub fn clear_draft(&mut self) { self.draft = text_editor::Content::new(); - true - } - - /// 中断流式回复(`Stop`)。占位期只在**视图态**上停:记一条提示,不派任何业务命令。 - pub fn stop(&mut self, catalog: Catalog) { - if !self.busy { - return; - } - self.busy = false; - self.transcript.push(Turn::new( - Speaker::Notice, - catalog.text(Key::AgentNoteStopped), - )); - } - - /// 一键置为「流式中」(出图矩阵的 `streaming` 态与 `Stop` 键要靠它;真流式由 M103 驱动)。 - pub fn pretend_streaming(&mut self) { - self.busy = true; - self.rail = RailState::Expanded; - self.unread = 0; } /// 输入框是否为空(发送键的禁用判据)。 @@ -365,9 +283,9 @@ impl AgentUi { self.pointer = None; } - /// Tab 走一格(走一次就把键盘巡航打开)。 - pub fn step_focus(&mut self, shift: bool) { - self.focus = self.focus.step(shift, self.rail, self.busy); + /// Tab 走一格(走一次就把键盘巡航打开)。`busy` 由 presenter 的 rail 给。 + pub fn step_focus(&mut self, shift: bool, busy: bool) { + self.focus = self.focus.step(shift, self.rail, busy); self.keyboard_mode = true; } } diff --git a/crates/app-iced/src/pages/agent/strip.rs b/crates/app-iced/src/pages/agent/strip.rs new file mode 100644 index 0000000..6254c90 --- /dev/null +++ b/crates/app-iced/src/pages/agent/strip.rs @@ -0,0 +1,134 @@ +//! 折叠档:48px 图标条(M119 从 `mod.rs` 拆出来守 400 行硬线)。 +//! +//! 条上三行:AI 图标(带未读点)+ 小字标签 + 未读计数。为什么未读既有点又有数字:48px 宽 +//! 装不下 `2 unread` 那 8 个字符(实测贴到窗角被裁),所以条上只放「有几条」,完整那句 +//! (`{count} unread`)在面板头部的胶囊里。零未读时给一个等高留白,免得条上元素上下跳。 +//! +//! 未读数来自 presenter 的 `AgentRail::unread`(M119 起壳里不再自己数)。 + +use iced::widget::{column, container, space, stack, text, tooltip}; +use iced::{Alignment, Background, Border, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; + +use unlucky_presenter::agent::AgentRail; + +use crate::app::components::buttons::{self, IconVariant}; +use crate::theme::Ui; + +use super::metrics::COLLAPSED_WIDTH; +use super::{conversation, Message}; + +/// 折叠条。 +pub(super) fn strip<'a>(ui: &'a Ui, rail: &'a AgentRail) -> Element<'a, Message> { + let catalog = Catalog::of(super::LANG); + let side = ui.metrics().control_height; + + // 图标按钮 40×40(M3 icon button),未读点叠在右上角;下面两行小字保证「不得只有图标」: + // 条上每个可点元素都有可读文字(完整名字在 tooltip 里)。 + let icon: Element<'a, Message> = buttons::icon( + ui, + IconVariant::Standard, + IconKind::Ai, + Some(Message::Expand), + None, + ); + + let glyph: Element<'a, Message> = if rail.unread > 0 { + stack![ + icon, + container(dot(ui)) + .width(Length::Fixed(side)) + .height(Length::Fixed(side)) + .align_x(Alignment::End) + .align_y(Alignment::Start) + .padding(Padding::from([0.0, ui.space(Space::X1)])), + ] + .into() + } else { + icon + }; + + let labelled = container(column![ + glyph, + conversation::text_role( + ui, + TypeRole::LabelMedium, + false, + catalog.text(Key::AgentStripLabel), + ui.color(Role::OnSurfaceVariant), + // `Shrink` 而不是 `Fill`:48 宽的条上 Fill 会让文字贴左缘,"2 unread" 会被窗角裁掉 + Length::Shrink, + ), + unread_text(ui, rail.unread), + ]) + .width(Length::Fixed(COLLAPSED_WIDTH)) + .align_x(Alignment::Center) + .padding(Padding::from([ui.space(Space::X6), 0.0])); + + let tip_style = ui.style(TypeRole::BodySmall); + let tip = container( + text(catalog.text(Key::AgentOpen)) + .size(tip_style.size_px) + .font(ui.font_of(tip_style)) + .color(ui.color(Role::OnSurface)), + ) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: Border { + radius: ui.radius_dp(ShapeScale::Small), + ..Border::default() + }, + ..container::Style::default() + }); + + container(tooltip(labelled, tip, tooltip::Position::Left)) + .width(Length::Fixed(COLLAPSED_WIDTH)) + .height(Length::Fill) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), + ..container::Style::default() + }) + .into() +} + +/// 未读点(8×8,`Primary`)。 +fn dot<'a>(ui: &'a Ui) -> Element<'a, Message> { + let side = ui.space(Space::X2); + + container(space::horizontal()) + .width(Length::Fixed(side)) + .height(Length::Fixed(side)) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.color(Role::Primary))), + border: Border { + radius: ui.radius(ShapeScale::Full, side), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 未读计数(零 = 等高留白,免得条上元素上下跳)。 +fn unread_text<'a>(ui: &'a Ui, unread: usize) -> Element<'a, Message> { + let style = ui.style(TypeRole::LabelMedium); + + if unread == 0 { + return space::vertical().height(style.line_height_px).into(); + } + + text(unread.to_string()) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::Primary)) + .width(Length::Shrink) + .into() +} diff --git a/crates/app-iced/src/pages/agent/tests.rs b/crates/app-iced/src/pages/agent/tests.rs index d65073e..6eff0b5 100644 --- a/crates/app-iced/src/pages/agent/tests.rs +++ b/crates/app-iced/src/pages/agent/tests.rs @@ -1,17 +1,23 @@ //! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。 //! //! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。 +//! +//! M119 起这里的重心变了:会话内容不再由本模块持有,所以「提交会怎样」这类端到端断言搬到了 +//! `app/update/agent/tests.rs`(那里能拿到 presenter 句柄)。这里留的是**纯 UI 态**与**视图契约**。 use unlucky_design::color::SchemeFlavor; use unlucky_design::typography::Platform; use unlucky_i18n::{Catalog, Key, Lang}; +use unlucky_presenter::agent::AgentRail; +use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity}; + use crate::theme::{Families, Ui}; mod drag; use super::conversation::{Conversation, Host}; -use super::state::{AgentUi, Focus, RailState, Speaker, Turn}; +use super::state::{AgentUi, Focus, RailState}; use super::*; const FAMILIES: Families = Families { @@ -24,24 +30,47 @@ pub(super) fn ui() -> Ui { Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) } -/// 三档迁移:会话与草稿跟着走、未读按「打开即读」清零。 +/// 一条只给视图用的 rail(会话内容由 presenter 生产,这里只造**渲染输入**)。 +fn rail(messages: Vec, unread: usize, busy: bool) -> AgentRail { + AgentRail { + messages, + unread, + busy, + ..AgentRail::new() + } +} + +fn conversation_rail() -> AgentRail { + rail( + vec![ + ChatMessage::user("Which instance should I start?"), + ChatMessage { + role: ChatRole::Assistant, + text: "Survival is on 1.20.4.".to_string(), + tool: None, + streaming: false, + }, + ], + 2, + false, + ) +} + +/// 三档迁移:**草稿与焦点**跟着走(会话在 presenter 上,本来就不归这里管)。 /// -/// 抓得住:拆出去再收回,**transcript 与 draft** 不丢(`busy` 不在这条测试的断言里); -/// `expand()/detach()/dock()` 都把 `unread` 清零;`collapse()` 不清(收起后新消息还要能点亮)。 -/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看),也不测 `busy` 的迁移。 +/// 抓得住:`expand()/detach()/dock()` 把档位改对且不清草稿;`collapse()` 对 Detached 等价于 `dock()`。 +/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。 #[test] -fn the_three_states_share_one_conversation() { +fn the_three_states_share_one_draft() { let mut agent = AgentUi::new(); assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档"); - assert_eq!(agent.unread, 2, "出厂带两条未读(折叠条上要有点)"); agent.expand(); assert_eq!(agent.rail, RailState::Expanded); - assert_eq!(agent.unread, 0, "展开即读"); // 走 `AgentUi::edit`(= app 层真正的落点),而不是直接操作 iced 的 `Content`: // 走到这里的 `Edit(Enter)` 只可能是 Shift+Enter(非 shift 的 Enter 在 composer 的 - // `key_binding` 里就变成 `Message::Submit` 了,见 composer 的 `enter_sends_and_shift_enter_breaks_the_line`)。 + // `key_binding` 里就变成 `Message::Submit` 了)。 agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival"); agent.edit(iced::widget::text_editor::Action::Edit( iced::widget::text_editor::Edit::Enter, @@ -49,21 +78,19 @@ fn the_three_states_share_one_conversation() { assert_eq!( agent.draft.line_count(), 2, - "多行:一条 Edit(Enter) 经过 `edit()` 之后草稿变成两行" + "多行:一条 Edit(Enter) 之后成两行" ); - let turns = agent.transcript.len(); let draft = agent.draft.text(); agent.detach(); assert_eq!(agent.rail, RailState::Detached); - assert_eq!(agent.transcript.len(), turns, "拆出去不丢会话"); assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿"); agent.dock(); assert_eq!(agent.rail, RailState::Expanded); assert_eq!(agent.draft.text(), draft); - // 收起 / Esc:只改档位,不清未读记录(下一批消息仍会点亮) + // 收起 / Esc:只改档位 agent.collapse(); assert_eq!(agent.rail, RailState::Collapsed); // 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock) @@ -72,59 +99,22 @@ fn the_three_states_share_one_conversation() { assert_eq!(agent.rail, RailState::Expanded); } -/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。 +/// 草稿只有两个写口:`edit`(打字)与 `clear_draft`(发送成功之后)。 #[test] -fn submit_and_stop_leave_a_visible_notice() { - let catalog = Catalog::of(Lang::En); +fn sending_clears_the_draft_and_blank_is_trimmed() { let mut agent = AgentUi::new(); - agent.expand(); - assert!(agent.draft_is_blank()); - assert!( - !agent.submit(catalog), - "空草稿发不出去(发送键同时是禁用态)" - ); - + agent.draft = iced::widget::text_editor::Content::with_text(" "); + assert!(agent.draft_is_blank(), "只有空白也算空(发送键是禁用态)"); agent.draft = iced::widget::text_editor::Content::with_text(" hello "); - let before = agent.transcript.len(); - assert!(agent.submit(catalog)); - assert_eq!(agent.transcript.len(), before + 2, "一条「你」+ 一条提示"); - assert_eq!(agent.transcript[before].speaker, Speaker::You); - assert_eq!(agent.transcript[before].text, "hello", "两侧空白被剪掉"); - assert_eq!(agent.transcript[before + 1].speaker, Speaker::Notice); - assert_eq!( - agent.transcript[before + 1].text, - catalog.text(Key::AgentNotePending) - ); + assert!(!agent.draft_is_blank()); + agent.clear_draft(); assert!(agent.draft_is_blank(), "发出去后草稿清空"); - - // 流式中:发送键禁用、中断键出现;中断也留一条提示 - agent.pretend_streaming(); - assert!(agent.busy); - agent.draft = iced::widget::text_editor::Content::with_text("second"); - assert!(!agent.submit(catalog), "流式中不许再发"); - let before = agent.transcript.len(); - agent.stop(catalog); - assert!(!agent.busy); - assert_eq!(agent.transcript.len(), before + 1); - assert_eq!( - agent.transcript[before].text, - catalog.text(Key::AgentNoteStopped) - ); - - // 没在流式时按中断是 no-op(不刷屏) - let before = agent.transcript.len(); - agent.stop(catalog); - assert_eq!(agent.transcript.len(), before); } /// 空会话:零消息 + 一句引导文案(任务第 7 条)。 #[test] fn the_empty_session_has_a_guidance_line() { - let empty = AgentUi::empty(); - assert!(empty.transcript.is_empty(), "空会话 = 零消息"); - assert_eq!(empty.unread, 0); - let catalog = Catalog::of(Lang::En); assert!(!catalog.text(Key::AgentEmptyTitle).is_empty()); assert!( @@ -165,12 +155,17 @@ fn both_hosts_render_the_same_conversation() { ); assert!( !hosts.contains("scrollable("), - "滚动区的装配只许在 conversation.rs 里" + "滚动区的装配只许在 conversation/transcript.rs 里" ); assert!( composer.contains("text_editor(") || composer.contains("editor(spec.draft)"), "输入框就在 composer 里" ); + // M119:会话内容的唯一来源是 presenter 的 rail ⇒ 宿主把 rail 借进来,而不是自己造 + assert!( + !hosts.contains("Vec") && !hosts.contains("Vec"), + "宿主里不许出现自持的会话容器" + ); } /// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。 @@ -190,6 +185,13 @@ fn every_action_has_a_readable_label() { Key::AgentStripLabel, Key::AgentResize, Key::AgentWidthLabel, + // M119 新增:确认条的两个动作 + 失败横幅的标题 + Key::AgentConfirmAllow, + Key::AgentConfirmDeny, + Key::AgentConfirmRequest, + Key::AgentErrorTitle, + Key::AgentErrorUnavailable, + Key::AgentErrorNotImplemented, ] { assert!( !catalog.text(key).trim().is_empty(), @@ -201,6 +203,9 @@ fn every_action_has_a_readable_label() { for source in [ include_str!("conversation.rs"), include_str!("conversation/composer.rs"), + include_str!("conversation/banners.rs"), + include_str!("conversation/transcript.rs"), + include_str!("strip.rs"), include_str!("mod.rs"), ] { for literal in ["Button::new(\"", "Button::disabled(\""] { @@ -217,7 +222,7 @@ fn every_action_has_a_readable_label() { // 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」) assert!( - include_str!("mod.rs").contains("Key::AgentStripLabel"), + include_str!("strip.rs").contains("Key::AgentStripLabel"), "折叠条必须有可读的条上标签" ); } @@ -249,7 +254,7 @@ fn the_focus_ring_walks_the_controls_that_exist() { Focus::Composer ); - // 流式中多一档中断键 + // 流式中多一档中断键(`busy` 由 presenter 的 rail 给) assert_eq!( Focus::Send.step(false, RailState::Expanded, true), Focus::Stop @@ -270,50 +275,78 @@ fn the_focus_ring_walks_the_controls_that_exist() { // `Tab` 真的把键盘巡航打开(`Enter` 的闸门) let mut agent = AgentUi::new(); assert!(!agent.keyboard_mode); - agent.step_focus(false); + agent.step_focus(false, false); assert!(agent.keyboard_mode); } -/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。 +/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。 /// -/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。 +/// 抓得住:有人在 `spec()` 里换掉/派生一份会话(那就会有两份真相)。 +/// 抓不住:`Conversation` 字段在视图里怎么用(那要人读 conversation.rs)。 +#[test] +fn the_spec_borrows_the_presenter_rail_verbatim() { + let mut agent = AgentUi::new(); + agent.expand(); + let rail = conversation_rail(); + let spec = super::spec(&agent, &rail, None, 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()); +} + +/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。 +/// +/// 抓得住:某档位下 `view` 的某条分支 panic(例如空消息 + 非零未读、确认条 + 失败横幅同时出现)。 /// 抓不住:像素级观感(出图另算)。 #[test] fn every_state_builds_in_both_hosts() { let ui = ui(); let catalog = Catalog::of(Lang::En); + let empty = AgentRail::new(); + let full = conversation_rail(); + let busy = rail(conversation_rail().messages, 0, true); + let failed = AgentRail { + error: Some("provider 断流:read timeout".to_string()), + ..conversation_rail() + }; + let mut confirm_rail = conversation_rail(); + confirm_rail.confirm = Some(ConfirmRequest { + id: "req-1".to_string(), + tool: "set_config".to_string(), + summary: "把下载并发 64 → 16".to_string(), + details: Default::default(), + sensitivity: Sensitivity::High, + }); + let failure = Failure { + title: Key::AgentErrorTitle, + body: "The assistant backend is unreachable.", + }; for window in [880.0, 900.0, 1180.0, 2560.0] { - for rail in RailState::ALL { - for empty in [false, true] { - let mut agent = if empty { - AgentUi::empty() - } else { - AgentUi::new() - }; - agent.rail = rail; - let _: iced::Element<'_, Message> = page(&ui, &agent, window); - - let spec = Conversation { - transcript: &agent.transcript, - draft: &agent.draft, - busy: agent.busy, - unread: agent.unread, - blank: agent.draft_is_blank(), - rail: agent.rail, - host: Host::Rail, - focus: agent.keyboard_mode.then_some(agent.focus), - }; - let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog); - let _ = docked(&ui, &agent, window); + for rail in [&empty, &full, &busy, &failed, &confirm_rail] { + for state in RailState::ALL { + let mut agent = AgentUi::new(); + agent.rail = state; + for shown in [None, Some(failure)] { + let _: iced::Element<'_, Message> = page(&ui, &agent, rail, shown, window); + let _ = docked(&ui, &agent, rail, shown, window); + let spec = Conversation { + messages: &rail.messages, + draft: &agent.draft, + busy: rail.busy, + unread: rail.unread, + blank: agent.draft_is_blank(), + confirm: rail.confirm.as_ref(), + failure: shown, + rail: agent.rail, + host: Host::Rail, + focus: agent.keyboard_mode.then_some(agent.focus), + }; + let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog); + } } } } } - -/// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。 -#[test] -fn a_turn_is_only_a_speaker_and_a_body() { - let turn = Turn::new(Speaker::You, "hi"); - assert_eq!((turn.speaker, turn.text.as_str()), (Speaker::You, "hi")); -} diff --git a/crates/app-iced/src/pages/agent/tests/drag.rs b/crates/app-iced/src/pages/agent/tests/drag.rs index c5993c5..d4e2de9 100644 --- a/crates/app-iced/src/pages/agent/tests/drag.rs +++ b/crates/app-iced/src/pages/agent/tests/drag.rs @@ -3,6 +3,8 @@ //! //! 拆的位置是「指针/几何」这一族:拖拽三条收尾路径、吸附宽度写回、缝的预算、缝与折叠条的标签。 +use unlucky_presenter::agent::AgentRail; + use super::super::metrics::{layout_for, PRESETS, RESIZE_GUTTER}; use super::super::state::{AgentUi, RailState}; @@ -30,7 +32,7 @@ fn the_detached_state_frees_the_rail_slot() { let ui = ui(); let mut agent = AgentUi::new(); agent.detach(); - assert!(docked(&ui, &agent, 1180.0).is_none()); + assert!(docked(&ui, &agent, &AgentRail::new(), None, 1180.0).is_none()); assert_eq!(agent.rail_width(1180.0), 0.0); assert_eq!(agent.content_width(1180.0), 1180.0); } @@ -127,11 +129,10 @@ fn the_drag_recovers_when_a_release_is_missed() { #[test] fn the_gutter_and_strip_are_labelled() { let hosts = include_str!("../mod.rs"); - for key in [ - "Key::AgentResize", - "Key::AgentWidthLabel", - "Key::AgentStripLabel", - ] { + let strip = include_str!("../strip.rs"); + for key in ["Key::AgentResize", "Key::AgentWidthLabel"] { assert!(hosts.contains(key), "视图里没用上 {key}"); } + // M119:折叠条整段搬去了 `strip.rs`(`mod.rs` 顶到 400 行) + assert!(strip.contains("Key::AgentStripLabel"), "折叠条上没画标签"); } diff --git a/crates/app-iced/src/presenter/agent.rs b/crates/app-iced/src/presenter/agent.rs new file mode 100644 index 0000000..4f8c775 --- /dev/null +++ b/crates/app-iced/src/presenter/agent.rs @@ -0,0 +1,264 @@ +//! AI 助手栏的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。 +//! +//! ## 它替掉的是什么 +//! +//! M104 交付的壳是**自带样本会话**的(`pages/agent/demo.rs` 的 `transcript()`,`Submit` 只往 +//! 本地数组里追加一条「没发给模型」的提示)。M119 把这些全部删掉:会话内容的唯一来源是 +//! [`Presenter::agent_rail`],壳里的 `Submit` / `Stop` / `Confirm` / 标记已读**全部**翻成 +//! presenter 的那四个签名——**壳里一行会话状态都不留**。 +//! +//! | 壳里的动作 | 落到哪 | +//! |---|---| +//! | 输入框打字 | [`AgentHost::set_draft`] → `Presenter::agent_set_draft`(切形态不丢) | +//! | 发送 | [`AgentHost::submit`] → `Presenter::agent_submit` | +//! | 打开栏 / 停在独立页 | [`AgentHost::mark_read`] → `Presenter::agent_mark_read` | +//! | 允许/拒绝写操作 | [`AgentHost::confirm`] → `Presenter::agent_confirm` | +//! | 事件回流 | [`AgentHost::apply`] → `Presenter::apply`(唯一状态写入口) | +//! | 栏可见性 | [`AgentHost::sync_visibility`] → `Presenter::navigate` + `prefs_mut` | +//! +//! ## 为什么句柄里还要持一份队列与失败槽 +//! +//! presenter 不持有定时器,也不认识 iced(crate 文档 §4)。异步后端的事件只能落在**进程内队列** +//! 里由 UI 抽;失败原因同理(`AgentTransport::submit` 只回一个 `String`,而 UI 要按**原因码** +//! 选 i18n 文案,不能去匹配错误串)。两个 `Arc>` 就这两件东西,和 +//! [`super::transport::RigTransport`] 共享,**transport 只写、UI 只读**。 +//! +//! ## 未读口径为什么依赖两行同步 +//! +//! presenter 判「栏在用户眼前」用的是它自己那份 `router` + `prefs.ai_collapsed` +//! (`AppState::agent_rail_visible`),而路由与档位的真源在 `App`。所以每次 `refresh_view` +//! 都把这两个事实按**窄入口**(`navigate` / `prefs_mut`)写回去——这是 presenter 目前对 +//! 「栏可见吗」唯一开放的接口。更好的形状是给 presenter 加一个 `agent_set_visible(bool)` +//! 窄入口(已按 finding 报给塔台),那样这两行同步就可以删。 + +use unlucky_i18n::Key; +use unlucky_presenter::agent::{AgentRail, Presenter}; +use unlucky_presenter::dto::AppEvent; +use unlucky_presenter::router::{AiPanelMode, Route}; + +use super::transport::{EventQueue, FailureSlot, RigTransport, Script}; + +/// 一个**具名**的失败原因(UI 据码选 catalog 文案;后端诊断原文只进日志)。 +/// +/// 为什么不是 `String`:M114 复审 r1 的 p1 就是这么来的——后端诊断是中文人话,直接上屏会让 +/// 英文界面里出现整句中文。原因码是 ascii 且不随语言变(日志 / 测试 / 用户报障都靠它对齐)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentFailure { + /// 端点不可达(连接失败 / 超时)。 + Unreachable, + /// 这条请求的路由还没接线(真实 LLM 后端未装配)。 + NotImplemented, +} + +impl AgentFailure { + /// 稳定 ascii 码(日志与测试断言用)。 + pub(crate) const fn code(self) -> &'static str { + match self { + Self::Unreachable => "unreachable", + Self::NotImplemented => "not_implemented", + } + } + + /// 横幅正文的 catalog 键。 + pub(crate) const fn body_key(self) -> Key { + match self { + Self::Unreachable => Key::AgentErrorUnavailable, + Self::NotImplemented => Key::AgentErrorNotImplemented, + } + } +} + +/// 一次提交的结局。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SubmitOutcome { + /// 交给后端了(事件稍后从队列里回来)。 + Sent, + /// 后端明确失败:UI 要显示具名错误。 + Failed(AgentFailure), + /// presenter 拒收(空文本 / 上一轮还在跑 / 有写操作待确认)。 + /// + /// 不进横幅:这三种情形正常情况下**够不着**(发送键在空白/忙碌时是禁用的,确认框没关时 + /// 输入区也不该能发),走到这里说明某处闸门漏了 ⇒ 留一行日志,别拿一句话糊在界面上。 + Rejected, +} + +/// AI 助手栏的 presenter 句柄。 +pub(crate) struct AgentHost { + presenter: Presenter, + queue: EventQueue, + failure: FailureSlot, + /// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。 + last_failure: Option, + /// 上一次写回 presenter 的「栏可见性」(独立页?, 折叠?),相同就不重复写(`navigate` 有副作用)。 + synced: Option<(bool, bool)>, +} + +impl AgentHost { + /// 指定脚本(单测与取证开关都走它)。 + pub(crate) fn with_script(script: Script) -> Self { + let transport = RigTransport::new(script); + let queue = transport.queue(); + let failure = transport.failure(); + Self { + presenter: Presenter::new(Box::new(transport)), + queue, + failure, + last_failure: None, + synced: None, + } + } + + /// 按 `ULL_AGENT_SCRIPT` 选脚本(没设 = `qa`)。 + pub(crate) fn from_env() -> Self { + Self::with_script(Script::from_env()) + } + + /// 当前会话视图模型(**唯一**读入口;两形态共享同一份)。 + pub(crate) fn rail(&self) -> &AgentRail { + self.presenter.agent_rail() + } + + /// 最近一次提交失败的具名原因。 + pub(crate) fn last_failure(&self) -> Option { + self.last_failure + } + + /// 抽干事件队列(UI 在心跳里调;返回的事件**只**经 `Message::Agent(Events)` 回流)。 + pub(crate) fn drain(&mut self) -> Vec { + let mut drained = Vec::new(); + if let Ok(mut queue) = self.queue.lock() { + drained.extend(queue.drain(..)); + } + drained + } + + /// 喂一条事件(纯转发给 `AppState::apply`)。 + pub(crate) fn apply(&mut self, event: AppEvent) { + self.presenter.apply(event); + } + + /// 提交一句话。 + pub(crate) fn submit(&mut self, text: String) -> SubmitOutcome { + match self.presenter.agent_submit(text) { + Ok(()) => { + self.last_failure = None; + SubmitOutcome::Sent + } + Err(detail) => { + // presenter 已经把错误串脱敏(`agent/tests_red_line.rs` 钉住),这里只进日志。 + eprintln!("[agent] submit 失败:{detail}"); + match self.failure.lock().ok().and_then(|mut slot| slot.take()) { + Some(failure) => { + self.last_failure = Some(failure); + SubmitOutcome::Failed(failure) + } + None => SubmitOutcome::Rejected, + } + } + } + } + + /// 未读清零(用户打开栏 / 停在独立页)。 + pub(crate) fn mark_read(&mut self) { + self.presenter.agent_mark_read(); + } + + /// 对当前待确认的写操作做决定。 + pub(crate) fn confirm(&mut self, accept: bool) { + self.presenter.agent_confirm(accept); + } + + /// 输入框草稿(用户打字时同步进来,切形态不丢)。 + pub(crate) fn set_draft(&mut self, text: String) { + self.presenter.agent_set_draft(text); + } + + /// 把「用户此刻看得见这段会话吗」写回 presenter(未读口径的输入)。 + /// + /// 只在两个事实变化时写:`Presenter::navigate(Route::Agent)` 会顺手展开栏偏好并清未读, + /// 每次 `refresh_view` 都写一遍会把用户攒的未读吃掉。 + pub(crate) fn sync_visibility(&mut self, on_agent_page: bool, collapsed: bool) { + if self.synced == Some((on_agent_page, collapsed)) { + return; + } + self.synced = Some((on_agent_page, collapsed)); + match (on_agent_page, self.presenter.state().ai_mode()) { + (true, AiPanelMode::Docked) => self.presenter.navigate(Route::Agent), + (false, AiPanelMode::Page) => self.presenter.navigate(Route::Instances), + _ => {} + } + // 顺序不能反:`navigate(Route::Agent)` 内部会把 `ai_collapsed` 置 false。 + self.presenter.prefs_mut().ai_collapsed = collapsed; + } + + /// 本进程内的裁决记录条数(取证/测试用;权威审计在 agent 侧)。 + #[cfg(test)] + pub(crate) fn audit_len(&self) -> usize { + self.presenter.audit().len() + } +} + +impl std::fmt::Debug for AgentHost { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AgentHost") + .field("rail", self.rail()) + .field("last_failure", &self.last_failure) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_failure_code_is_stable_ascii_and_has_its_own_body_key() { + assert_eq!(AgentFailure::Unreachable.code(), "unreachable"); + assert_eq!(AgentFailure::NotImplemented.code(), "not_implemented"); + assert_ne!( + AgentFailure::Unreachable.body_key(), + AgentFailure::NotImplemented.body_key(), + "两种失败要两句话,不能都糊成一句" + ); + } + + #[test] + fn a_submit_only_reaches_the_backend_once_per_turn_while_busy() { + let mut host = AgentHost::with_script(Script::Streaming); + assert_eq!(host.rail().messages.len(), 0); + assert_eq!(host.submit("hello".to_string()), SubmitOutcome::Sent); + // 队列里的这一批要经 `apply` 才进 rail(app 层由 `Message::Agent(Events)` 做同一件事) + for event in host.drain() { + host.apply(event); + } + assert!(host.rail().busy, "流式没收敛前输入要禁用"); + // 第二轮被 presenter 拒收(不是服务器错误,所以不进横幅) + assert_eq!(host.submit("again".to_string()), SubmitOutcome::Rejected); + assert_eq!(host.last_failure(), None); + } + + #[test] + fn an_unreachable_endpoint_is_named_not_stringified() { + let mut host = AgentHost::with_script(Script::Unreachable); + assert_eq!( + host.submit("hello".to_string()), + SubmitOutcome::Failed(AgentFailure::Unreachable) + ); + assert_eq!(host.last_failure(), Some(AgentFailure::Unreachable)); + assert!(host.drain().is_empty()); + } + + #[test] + fn visibility_is_written_back_once_per_change() { + let mut host = AgentHost::with_script(Script::Qa); + host.sync_visibility(false, true); + host.sync_visibility(false, true); + assert!(host.presenter.state().prefs.ai_collapsed); + host.sync_visibility(true, false); + assert_eq!(host.presenter.state().ai_mode(), AiPanelMode::Page); + assert!(!host.presenter.state().prefs.ai_collapsed); + host.sync_visibility(false, false); + assert_eq!(host.presenter.state().ai_mode(), AiPanelMode::Docked); + } +} diff --git a/crates/app-iced/src/presenter/mod.rs b/crates/app-iced/src/presenter/mod.rs new file mode 100644 index 0000000..7bb925b --- /dev/null +++ b/crates/app-iced/src/presenter/mod.rs @@ -0,0 +1,41 @@ +//! iced 侧的 presenter 接线层(M119)。 +//! +//! ## 为什么单独一个目录 +//! +//! presenter 已经把「AI 会话怎么攒、未读怎么算、YOLO 怎么裁决」做完了(M103),壳层只该 +//! **读视图模型 + 把点击翻成四个方法**。这份「只做翻译」的胶水需要地方放,而且要能和 presenter +//! 自己的契约分开审:所以是独立目录,不塞进 `app/` 的 update 分支里。 +//! +//! | 文件 | 职责 | 不负责 | +//! |---|---|---| +//! | [`agent`] | [`AgentHost`]:presenter 句柄(四契约)+ 事件队列 + 具名错误 | 画、算脱敏 | +//! | [`transport`] | [`AgentTransport`](unlucky_presenter::agent::AgentTransport) 的 iced 侧实现(脚本化 rig + 三种失败注入) | 真实模型调用 | +//! | `rig`(仅测试) | 账户轨的 `Backend` 实现:`AppCommand` → host 形状的 `CoreEvent` | 真 AccountStore 落盘 | +//! +//! ## 逻辑路径与物理路径 +//! +//! 物理目录是 `crates/app-iced/src/presenter/`,逻辑名字是 `crate::app::state::presenter`——由 +//! `app/state.rs` 用 `#[path = "../presenter/mod.rs"]` 挂载。理由与 `shell::agent` +//! (`crates/app-iced/src/pages/agent/` → `crate::shell::agent`)完全相同:本 mission 的 scope +//! 不含 `lib.rs` 与 `app/mod.rs`,用 `#[path]` 挂载既不碰共用声明文件,也不产生第二份实现。 +//! +//! ## 线程/通道边界(本轮只验同步形状,异步未验) +//! +//! ```text +//! on_agent(Submit) ──► AgentHost::submit ──► Presenter::agent_submit ──► Transport::submit +//! │ 事件进队列 +//! 右栏(读 rag.agent_rail())◄── AgentHost::apply ◄── Message::Agent(Events) ◄── App::drain_agent +//! ``` +//! +//! 队列是 `Arc>>`:**transport 侧只入队、UI 侧只出队**,中间不共享状态。 +//! 真实异步后端落地时换掉的是「谁往队列里 push」,这条形状不动。本轮 transport 是同步实现 +//! (`submit` 当场把整批事件入队),所以「跨线程时序 / 乱序 / 并发提交」**没有验过**。 + +pub(crate) mod agent; +// 账户轨的 UI 侧 rig(`AppCommand` → host 形状的 `CoreEvent`)。只在测试里存在:截图通道的 +// 账户矩阵仍走 presenter 自带的 `MockBackend`(`ULL_ACCOUNT_STATE`)。 +#[cfg(test)] +pub(crate) mod rig; +pub(crate) mod transport; + +pub(crate) use agent::{AgentFailure, AgentHost}; diff --git a/crates/app-iced/src/presenter/rig.rs b/crates/app-iced/src/presenter/rig.rs new file mode 100644 index 0000000..52d64ae --- /dev/null +++ b/crates/app-iced/src/presenter/rig.rs @@ -0,0 +1,211 @@ +//! 账户轨的 UI 侧 rig(**只在测试里存在**):把 `AppCommand` 翻成 host 形状的 `CoreEvent`。 +//! +//! ## 它替的是谁、替到什么程度 +//! +//! 真 host 的账户分支在 `presenter/host/dispatch.rs`:`AddOfflineAccount` → `accounts::add_offline` +//! (`core::auth::AccountStore` 落盘)→ `AccountsChanged`;`SwitchAccount` → 只改内存里的 +//! `active_account` → `AccountsChanged`;失败一律走 `AccountNoticeChanged(结构化码)`。 +//! +//! 这里**照同一条形状**回事件,但不碰磁盘、不起线程: +//! +//! | 真 host | 本 rig | 差在哪 | +//! |---|---|---| +//! | `AccountStore` 落盘 + 读回 | 内存 `Vec` | 落盘/读回由 presenter 自己的 +//! `host::accounts::offline_add_list_remove_round_trips` 覆盖,本轮**没有**跨 crate 复跑 | +//! | `notice_failure` 带原因码 | 同名三个码(`OfflineNameInvalid` / `NotFound` / `Added`…) | 一致 | +//! | 设备码:起后台线程等用户 | 直接给 Waiting 态 | 「进行中可见 + 可取消」验得到,**真实网络不验** | +//! +//! 为什么不能直接用 `CoreHost`:`host::accounts` 是**私有**模块(outside 本 mission 的 scope), +//! 而 `CoreHost::new` 会 `refresh_all`(版本清单网络线程 + Java 探测)。测试要的是确定性。 + +use std::collections::VecDeque; + +use unlucky_presenter::dto::{ + valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, AppCommand, + AppEvent, Backend, CoreEvent, DeviceLoginView, +}; + +/// 账户轨的假后端(形状与真 host 一致,数据在内存里)。 +#[derive(Debug)] +pub(crate) struct AccountRig { + events: VecDeque, + accounts: Vec, + next_id: u32, + active: Option, +} + +impl Default for AccountRig { + fn default() -> Self { + Self::new() + } +} + +impl AccountRig { + /// 空账户表。 + pub(crate) fn new() -> Self { + Self { + events: VecDeque::new(), + accounts: Vec::new(), + next_id: 0, + active: None, + } + } + + fn emit(&mut self, event: CoreEvent) { + self.events.push_back(AppEvent::Core(event)); + } + + /// 当前列表(`active` 标记按内存里的当前账户算)。 + fn snapshot(&mut self) { + self.emit(CoreEvent::AccountsChanged(self.accounts.clone())); + } + + fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) { + self.emit(CoreEvent::AccountNoticeChanged(Some(AccountNotice::new( + code, subject, detail, + )))); + } + + fn add_offline(&mut self, name: &str) { + let name = name.trim(); + if !valid_offline_name(name) { + // 与 host 同码:名字不合法是**结构化**失败,不是静默 + self.notice( + AccountNoticeCode::OfflineNameInvalid, + name, + "长度/字符集不符", + ); + return; + } + if self.accounts.iter().any(|account| account.name == name) { + self.notice(AccountNoticeCode::AccountExists, name, "同名账户已存在"); + return; + } + self.next_id += 1; + let id = format!("account:rig-{}", self.next_id); + self.accounts.push(AccountView { + id, + name: name.to_string(), + kind: AccountKind::Offline, + profile_id: format!("{:08x}", self.next_id), + login_name: String::new(), + created: "2026-11-05 00:00".to_string(), + active: false, + server_url: String::new(), + skin: None, + }); + self.notice(AccountNoticeCode::Added, name, ""); + self.snapshot(); + } + + fn remove(&mut self, key: &str) { + let Some(index) = self + .accounts + .iter() + .position(|account| account.id == key || account.name == key) + else { + self.notice(AccountNoticeCode::NotFound, key, "找不到这个账户"); + return; + }; + let removed = self.accounts.remove(index); + if self.active.as_deref() == Some(removed.id.as_str()) { + self.active = None; + } + self.notice(AccountNoticeCode::Removed, &removed.name, ""); + self.snapshot(); + } + + fn switch(&mut self, id: &str) { + if !self.accounts.iter().any(|account| account.id == id) { + self.notice(AccountNoticeCode::NotFound, id, "找不到这个账户"); + return; + } + let name = self + .accounts + .iter() + .find(|account| account.id == id) + .map(|account| account.name.clone()) + .unwrap_or_default(); + self.active = Some(id.to_string()); + for account in &mut self.accounts { + account.active = account.id == id; + } + self.notice(AccountNoticeCode::Switched, &name, ""); + self.snapshot(); + } +} + +impl Backend for AccountRig { + fn drain_events(&mut self) -> Vec { + self.events.drain(..).collect() + } + + fn dispatch(&mut self, command: AppCommand) { + match command { + AppCommand::RefreshAll => self.snapshot(), + AppCommand::AddOfflineAccount(name) => self.add_offline(&name), + AppCommand::RemoveAccount(key) => self.remove(&key), + AppCommand::SwitchAccount(id) => self.switch(&id), + AppCommand::DismissAccountNotice => self.emit(CoreEvent::AccountNoticeChanged(None)), + // 两条登录流:都给「等用户」态(真 host 在这里起后台线程 + 浏览器) + AppCommand::StartDeviceLogin | AppCommand::StartBrowserLogin => { + self.emit(CoreEvent::DeviceLoginChanged(DeviceLoginView::waiting( + "ABCD-EFGH", + "https://www.microsoft.com/link", + 900, + "", + ))); + } + // 取消:两条轨都收回 Idle(真 host 的 `CancelDeviceLogin` 就是这么做的) + AppCommand::CancelDeviceLogin => { + self.emit(CoreEvent::DeviceLoginChanged(DeviceLoginView::new())); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_rig_answers_add_switch_remove_with_host_shaped_events() { + let mut rig = AccountRig::new(); + rig.dispatch(AppCommand::AddOfflineAccount("Steve".to_string())); + let events = rig.drain_events(); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::Core(CoreEvent::AccountsChanged(rows)) if rows.len() == 1 + ))); + + let id = match &events[1] { + AppEvent::Core(CoreEvent::AccountsChanged(rows)) => rows[0].id.clone(), + _ => unreachable!(), + }; + rig.dispatch(AppCommand::SwitchAccount(id.clone())); + assert!(matches!( + rig.drain_events().first(), + Some(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(notice)))) + if notice.code == AccountNoticeCode::Switched + )); + + rig.dispatch(AppCommand::RemoveAccount(id)); + let events = rig.drain_events(); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::Core(CoreEvent::AccountsChanged(rows)) if rows.is_empty() + ))); + } + + #[test] + fn an_invalid_offline_name_is_a_structured_failure_not_silence() { + let mut rig = AccountRig::new(); + rig.dispatch(AppCommand::AddOfflineAccount("ab".to_string())); + assert!(matches!( + rig.drain_events().first(), + Some(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(notice)))) + if notice.code == AccountNoticeCode::OfflineNameInvalid + )); + } +} diff --git a/crates/app-iced/src/presenter/transport.rs b/crates/app-iced/src/presenter/transport.rs new file mode 100644 index 0000000..603fdfa --- /dev/null +++ b/crates/app-iced/src/presenter/transport.rs @@ -0,0 +1,335 @@ +//! [`AgentTransport`] 的 iced 侧实现:脚本化 rig + 三类失败注入(M119)。 +//! +//! ## 它是什么、不是什么 +//! +//! 它是 M103 契约 [`AgentTransport`] 的一个**实现**,不是契约的改写:`submit` / `confirm` 的 +//! 签名一行没动。真实的 rig 后端(`crates/agent` + rig)还没装配进 host(M103 报告 §4 登记过), +//! 所以本轮用它跑端到端。 +//! +//! **事件走队列,不当场返回**:`submit` 把整批事件 push 进 [`EventQueue`] 后返回 `Ok(vec![])`, +//! UI 在心跳里抽。这条形状才是异步后端落地时**不会变**的那部分——换掉的只有「谁往队列里 push」 +//! (真实后端在后台线程里逐帧推流)。抓得住:事件经 Message 通道回流的整条链路; +//! 抓不住:跨线程时序、乱序、并发提交(本轮 transport 是同步的,见报告「没验的部分」)。 +//! +//! ## 失败注入(任务「失败态可见」的那两条 AI 侧) +//! +//! | `ULL_AGENT_SCRIPT` | `submit` 行为 | +//! |---|---| +//! | `qa`(默认) | 一轮正常问答:用户气泡 + 流式助手消息(三条增量)+ 收敛 | +//! | `streaming` | 同上但**永不收敛**(`busy` 一直亮着:中断键与禁用态的可视图) | +//! | `fails` | 流到一半断流:已到的增量保留,整轮以 `Failed` 收尾 | +//! | `write` | 需确认的写操作(High 级 `ConfirmRequested`) | +//! | `unreachable` | **端点不可达**:`Err`,一个事件也不产生 | +//! | `unimplemented` | **未实现路由**:`Err`,同上 | +//! +//! 两种 `Err` 都先把**具名原因**写进 [`FailureSlot`] 再返回错误串——UI 据原因码选 catalog 文案, +//! 错误串本身(后端诊断)只进日志,不上屏(M114 立的口径)。 + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use unlucky_presenter::agent::{AgentRail, AgentTransport}; +use unlucky_presenter::dto::{ + AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, +}; + +use super::AgentFailure; + +/// 事件队列(transport 侧 push、UI 侧 drain)。 +pub(crate) type EventQueue = Arc>>; + +/// 失败原因槽(transport 侧写、UI 侧读后清)。 +pub(crate) type FailureSlot = Arc>>; + +/// 脚本(一轮输入会产生哪种剧情)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Script { + /// 正常问答。 + Qa, + /// 流式永不收敛。 + Streaming, + /// 流式中途断流。 + FailsMidStream, + /// 需确认的写操作。 + WriteOp, + /// 端点不可达(`Err`)。 + Unreachable, + /// 未实现路由(`Err`)。 + NotImplemented, +} + +/// 脚本环境变量(取证开关,与 `ULL_AGENT_RAIL` / `ULL_APPEARANCE_*` 同一口径)。 +pub(crate) const SCRIPT_ENV: &str = "ULL_AGENT_SCRIPT"; + +impl Script { + /// 全部脚本(`from_name` 的遍历表)。 + pub(crate) const ALL: [Script; 6] = [ + Script::Qa, + Script::Streaming, + Script::FailsMidStream, + Script::WriteOp, + Script::Unreachable, + Script::NotImplemented, + ]; + + /// 稳定名字(`ULL_AGENT_SCRIPT` 的取值 / 日志)。 + pub(crate) const fn name(self) -> &'static str { + match self { + Script::Qa => "qa", + Script::Streaming => "streaming", + Script::FailsMidStream => "fails", + Script::WriteOp => "write", + Script::Unreachable => "unreachable", + Script::NotImplemented => "unimplemented", + } + } + + /// 反向解析(取证开关用;未知值忽略)。 + pub(crate) fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|script| script.name() == name) + } + + /// 读 `ULL_AGENT_SCRIPT`;没设 / 解析不出来 = [`Script::Qa`]。 + pub(crate) fn from_env() -> Self { + match std::env::var(SCRIPT_ENV) { + Ok(raw) => match Self::from_name(raw.trim()) { + Some(script) => script, + None => { + eprintln!( + "[agent] {SCRIPT_ENV}={raw:?} 不是脚本之一,按 qa 处理(可选:{})", + Self::ALL.map(Self::name).join("/") + ); + Script::Qa + } + }, + Err(_) => Script::Qa, + } + } +} + +/// 脚本化 transport:事件入队、失败入槽。 +pub(crate) struct RigTransport { + script: Script, + /// 已发起过几轮(用来算消息下标 / 确认请求 id)。 + turns: usize, + /// 当前待确认的写操作。 + pending: Option, + queue: EventQueue, + failure: FailureSlot, +} + +impl RigTransport { + /// 新 transport + 它与 UI 共享的两个句柄。 + pub(crate) fn new(script: Script) -> Self { + Self { + script, + turns: 0, + pending: None, + queue: EventQueue::default(), + failure: FailureSlot::default(), + } + } + + /// 与 UI 共享的事件队列(克隆一个给 [`super::AgentHost`])。 + pub(crate) fn queue(&self) -> EventQueue { + Arc::clone(&self.queue) + } + + /// 与 UI 共享的失败原因槽。 + pub(crate) fn failure(&self) -> FailureSlot { + Arc::clone(&self.failure) + } + + /// 入队一批事件(transport 唯一的输出方向)。 + fn emit(&self, events: Vec) { + if events.is_empty() { + return; + } + if let Ok(mut queue) = self.queue.lock() { + queue.extend(events); + } + } + + /// 注入一次失败:原因入槽(UI 据它选 catalog 文案)、错误串回给 presenter(只进日志)。 + fn fail(&self, failure: AgentFailure, detail: &str) -> Result, String> { + if let Ok(mut slot) = self.failure.lock() { + *slot = Some(failure); + } + Err(format!("{}:{detail}", failure.code())) + } +} + +impl AgentTransport for RigTransport { + fn submit(&mut self, text: &str, history: &AgentRail) -> Result, String> { + match self.script { + Script::Unreachable => { + return self.fail( + AgentFailure::Unreachable, + "assistant endpoint http://127.0.0.1:8080 连不上(connection refused)", + ); + } + Script::NotImplemented => { + return self.fail( + AgentFailure::NotImplemented, + "真实 LLM 路由还没装配进 host(M103 §4 的欠账)", + ); + } + _ => {} + } + + self.turns += 1; + // 后端自己算下标:用户气泡先到,助手流式占位紧随其后。 + let assistant = history.messages.len() + 1; + let mut events = vec![ + agent_event(AgentEvent::MessageAppended(ChatMessage::user(text))), + agent_event(AgentEvent::MessageAppended(ChatMessage::assistant_stream())), + ]; + match self.script { + Script::Qa | Script::Streaming => { + for delta in ["先确认版本:", "装完再加 Fabric。"] { + events.push(agent_event(AgentEvent::MessageDelta { + index: assistant, + delta: delta.to_string(), + })); + } + if self.script == Script::Qa { + events.push(agent_event(AgentEvent::MessageFinished { + index: assistant, + })); + } + } + Script::FailsMidStream => { + events.push(agent_event(AgentEvent::MessageDelta { + index: assistant, + delta: "我先读一下最近的日志".to_string(), + })); + events.push(agent_event(AgentEvent::Failed { + message: "provider 断流:read timeout".to_string(), + })); + } + Script::WriteOp => { + events.push(agent_event(AgentEvent::MessageFinished { + index: assistant, + })); + events.push(agent_event(AgentEvent::ToolCallStarted { + name: "set_config".to_string(), + summary: "修改 download.concurrency".to_string(), + })); + let request = ConfirmRequest { + id: format!("req-{}", self.turns), + tool: "set_config".to_string(), + summary: "把下载并发 64 → 16".to_string(), + // 不引 serde_json(app-iced 没有这个依赖):`Value::default()` = Null, + // 确认框只画 `summary`,`details` 的结构化 diff 留给后续 mission。 + details: Default::default(), + sensitivity: Sensitivity::High, + }; + self.pending = Some(request.clone()); + events.push(agent_event(AgentEvent::ConfirmRequested { request })); + } + // 三支失败脚本在上面的 `match` 里已经 `return`,这里只为穷尽 + Script::Unreachable | Script::NotImplemented => {} + } + self.emit(events); + // 契约允许「事件分片由 presenter 顺序 apply」,也允许走事件通道——本实现走通道(见模块文档)。 + Ok(Vec::new()) + } + + fn confirm(&mut self, request_id: &str, accept: bool) -> Vec { + self.pending = None; + let (ok, summary, reply) = if accept { + (true, "已写入 download.concurrency = 16", "改好了。") + } else { + (false, "用户拒绝,未写入", "那就不改了。") + }; + let events = vec![ + agent_event(AgentEvent::ToolCallFinished { + name: "set_config".to_string(), + ok, + summary: summary.to_string(), + }), + agent_event(AgentEvent::ConfirmResolved { + id: request_id.to_string(), + }), + agent_event(AgentEvent::MessageAppended(ChatMessage { + role: ChatRole::Assistant, + text: reply.to_string(), + tool: None, + streaming: false, + })), + ]; + self.emit(events.clone()); + // 与 `submit` 同一条口径:事件走队列,这里返回空(避免同一批事件被 apply 两遍)。 + Vec::new() + } +} + +/// 包一层 [`AppEvent::Agent`](事件队列只认顶层事件)。 +fn agent_event(event: AgentEvent) -> AppEvent { + AppEvent::Agent(event) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_script_name_round_trips_and_the_env_default_is_qa() { + for script in Script::ALL { + assert_eq!(Script::from_name(script.name()), Some(script)); + } + assert_eq!(Script::from_name("nope"), None); + // 环境变量在这条测试里不可靠地存在(并行测试共享进程),只验默认分支的返回值 + assert_eq!(Script::from_name("qa"), Some(Script::Qa)); + } + + #[test] + fn a_normal_turn_queues_the_whole_batch_and_returns_no_inline_events() { + let mut transport = RigTransport::new(Script::Qa); + let queue = transport.queue(); + let inline = transport + .submit("hello", &AgentRail::new()) + .expect("qa 不会失败"); + assert!(inline.is_empty(), "本实现走队列,不走进内联分片"); + assert_eq!( + queue.lock().expect("queue").len(), + 5, + "user + 流式占位 + 两片增量 + 收敛" + ); + } + + #[test] + fn the_two_failing_scripts_return_a_named_reason_and_queue_nothing() { + for (script, expected) in [ + (Script::Unreachable, AgentFailure::Unreachable), + (Script::NotImplemented, AgentFailure::NotImplemented), + ] { + let mut transport = RigTransport::new(script); + let queue = transport.queue(); + let failure = transport.failure(); + let error = transport + .submit("hello", &AgentRail::new()) + .expect_err("这两支必失败"); + assert!(error.contains(expected.code()), "错误串要带原因码:{error}"); + assert_eq!(*failure.lock().expect("slot"), Some(expected)); + assert!(queue.lock().expect("queue").is_empty(), "失败不产生事件"); + } + } + + #[test] + fn confirm_queues_the_tool_result_and_the_reply() { + let mut transport = RigTransport::new(Script::WriteOp); + let queue = transport.queue(); + transport + .submit("改并发", &AgentRail::new()) + .expect("写操作"); + queue.lock().expect("queue").clear(); + let inline = transport.confirm("req-1", true); + assert!(inline.is_empty()); + assert_eq!( + queue.lock().expect("queue").len(), + 3, + "工具收尾 + 确认收敛 + 回复" + ); + } +}