Merge branch 'feat/m119-apiscan-wire-presenter-accounts-and'

This commit is contained in:
AskaEth committed 2026-11-05 11:09:56 +08:00
commit 4a212760b6
31 files changed
+3466 -1125

No files matched your search

+31 -243
View File
@@ -12,26 +12,32 @@
//! 它在**状态真的变了**时重建([`App::view_dirty`] 门控,M90 r2/P2-1 起),不是每拍重建——
//! 重建要把 N 个实例的名字/版本串克隆一遍,30 Hz 空转没有意义。缓存成字段是为了让
//! `view(&self)` 能只借 `&self`:iced 的 `view` 签名不允许返回一棵借用局部变量的树。
//!
//! ## 本文件只管「有哪些字段」
//!
//! M119 起按功能拆出去(`state.rs` 当时正好 400 行、零余量):
//!
//! | 子文件 | 内容 |
//! |---|---|
//! | [`construct`] | 两个构造器 + 字段初始化表 |
//! | [`refresh`] | [`App::refresh_view`](快照 → 视图模型的唯一转换点) |
//! | [`theme`] | `Ui` 重算 / 主题档 / [`App::has_screenshot`] |
//! | [`boot_probe`] | 启动探针(`ULL_*` 取证开关与首帧日志) |
use std::time::Instant;
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
use unlucky_presenter::toast::ToastKind;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use crate::ui::lang::LangState;
use super::WINDOW_SIZE;
/// presenter 的 TICK 节拍(33 ms ≈ 30 fps)——**忙碌态**的节拍。
///
/// 与 Slint 版的 `TICK_MS` 同值:`backend.tick(..)` 的时间推进、toast 过期、
@@ -58,8 +64,17 @@ pub(super) const IDLE_TICK: std::time::Duration = std::time::Duration::from_secs
pub(super) const WATCH_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
mod boot_probe;
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;
use boot_probe::app_options;
pub(crate) use boot_probe::{log_first_frame, STARTED};
/// 应用状态:UI 状态 + presenter 三件 + 展示转换缓存。
@@ -130,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<Mutex<..>>`)。
///
/// **只留 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` 整棵重建,
@@ -162,239 +186,3 @@ pub struct App {
/// [`crate::platform::win32::corner::CornerApplied`]。
pub(super) corner_applied: Option<crate::platform::win32::corner::CornerApplied>,
}
impl App {
/// 生产构造:按启动参数选后端、打开设置桥。
///
/// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面,
/// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时
/// [`build_backend`] 自己退回 mock)。
pub(super) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
let backend = build_backend(&app_options(&options));
// P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()`
// (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程
// 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的
// `Some(root) => SettingsBridge::with_path(config_path(root))`。
let bridge = match &options.data_dir {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
Self::with_presenter(options, families, flavor, backend, bridge)
}
/// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。
pub(super) fn with_presenter(
options: Options,
families: Families,
flavor: SchemeFlavor,
backend: Box<dyn Backend>,
bridge: SettingsBridge,
) -> Self {
let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height;
let theme_mode = bridge.load().1;
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
//
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
(
unlucky_presenter::prefs::AppearancePrefs::factory(),
unlucky_presenter::prefs::AppearanceBase::factory(),
)
} else {
(bridge.load().0, bridge.load_base())
};
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::backend::effective_backend(options.backend);
let mut appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
// 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(options.screenshot.is_some())
{
appearance.selected_group = index;
}
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
Some(pages::Screen::Instances.nav()),
);
view.lang = lang.lang();
let mut app = Self {
ui: Ui::new(flavor, families),
families,
options,
backend,
bridge,
state: AppState::new(),
ticks: 0,
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
theme_mode,
lang,
last_tick: Instant::now(),
width,
view,
view_dirty: false,
watch_until: None,
window: None,
frames: 0,
refreshes: 0,
auto_selected: false,
height,
appearance,
appearance_prefs,
hwnd: None,
settings: pages::settings::Groups::default(),
// 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
// 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串
download_draft: pages::InstallDraft::default(),
install_cancelled_seen: 0,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
&AppState::new(),
&pages::account::Form::new(),
&mut pages::account::avatar::Cache::default(),
unlucky_i18n::Lang::En,
width,
),
avatars: pages::account::avatar::Cache::default(),
corner_applied: None,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
app
}
/// 本次进程是否走截图通道。
pub(super) fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
/// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
///
/// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。
/// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。
pub(super) fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor && self.appearance.flavor() == flavor {
return;
}
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
);
}
/// 按「配色来源是否取色成功」重建 [`Ui`]。
///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
pub(super) fn rebuild_ui(&mut self) {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
Ui::from_scheme(*scheme, self.families)
}
None => Ui::new(self.appearance.flavor(), self.families),
}
.with_panel_alpha(panel_alpha);
}
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
///
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
pub(super) fn refresh_view(&mut self) {
// **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。
// 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前,
// 是为了让模型拿到的 `content_width` 与页面头部读到的档位来自同一份状态。
self.sync_agent_route();
// M105:取消安装的提示语在**这里**弹(`refresh_view` 是事件 apply 之后必经的那一处)。
// 文案由页面层求(`pages::cancelled_toast`),presenter 只发信号 ——
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
if self.state.install_cancelled != self.install_cancelled_seen {
self.install_cancelled_seen = self.state.install_cancelled;
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文,
// 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。
let text = pages::cancelled_toast(self.lang());
self.state.toasts.push(ToastKind::Info, text);
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
let draft = self.download_draft.clone();
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::view::model(
&self.state,
width,
self.options.force_state,
category,
&draft,
self.lang(),
&self.content,
);
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
self.view_dirty = false;
self.refreshes += 1;
}
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
///
/// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor`
/// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。
///
/// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在
/// `Message::SystemTheme` 里落地)。
pub(super) fn apply_config_theme(&mut self) {
if self.has_screenshot() || self.options.theme.is_some() {
return;
}
let mode = self.bridge.load().1;
self.theme_mode = mode;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
ThemeMode::Auto => {}
}
}
/// 当前令牌求值器(测试与截图日志用)。
pub(super) fn ui(&self) -> &Ui {
&self.ui
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `App` 的两个构造器(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! | 构造器 | 谁用 | 碰不碰磁盘 |
//! |---|---|---|
//! | [`App::new`](super::App::new) | 生产(`main` → `boot`) | 按启动参数选后端 + 打开设置桥 |
//! | [`App::with_presenter`](super::App::with_presenter) | 单测(后端与桥都由调用方给) | **不碰** |
//!
//! 两个都走 `with_presenter`:生产路径只是先造好后端与桥。**字段初始化表**也在这一个文件里
//! (构造 = 字段表 + 三件外部依赖),加字段时只改这一处。
use iced::window;
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::backend::{build_backend, config_path};
use unlucky_presenter::dto::Backend;
use unlucky_presenter::settings_bridge::SettingsBridge;
use unlucky_presenter::state::AppState;
use crate::cli::Options;
use crate::pages;
use crate::theme::{Families, Ui};
use crate::ui::lang::LangState;
use super::super::WINDOW_SIZE;
use super::boot_probe::app_options;
use super::App;
impl App {
/// 生产构造:按启动参数选后端、打开设置桥。
///
/// **截图通道强制 mock**:出图要可复现(D30),真 host 会按本机盘上的实例清单画面,
/// 同一份代码在两台机器上出不了同一张图。交互模式走真 host(数据根解析不出来时
/// [`build_backend`] 自己退回 mock)。
pub(in crate::app) fn new(options: Options, families: Families, flavor: SchemeFlavor) -> Self {
let backend = build_backend(&app_options(&options));
// P2-2:设置桥必须与后端指向**同一个数据根**。早先这里无条件 `SettingsBridge::open()`
// (按便携配置解析),于是 `--data-dir X` 会出现「实例来自 X、主题/设置读写默认根」的一进程
// 两个数据根。Slint 版是同一条口径:`app/src/ui_bind.rs` 的
// `Some(root) => SettingsBridge::with_path(config_path(root))`。
let bridge = match &options.data_dir {
Some(root) => SettingsBridge::with_path(config_path(root)),
None => SettingsBridge::open(),
};
Self::with_presenter(options, families, flavor, backend, bridge)
}
/// 注入式构造(单测用:后端与桥都由调用方给,**不碰磁盘**)。
pub(in crate::app) fn with_presenter(
options: Options,
families: Families,
flavor: SchemeFlavor,
backend: Box<dyn Backend>,
bridge: SettingsBridge,
) -> Self {
let width = WINDOW_SIZE.width;
let height = WINDOW_SIZE.height;
let theme_mode = bridge.load().1;
// 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键
// (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。
//
// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图):外观一律取出厂值,
// 矩阵需要的值由 `ULL_APPEARANCE_*` 显式灌进来。
let (appearance_prefs, appearance_base) = if options.screenshot.is_some() {
(
unlucky_presenter::prefs::AppearancePrefs::factory(),
unlucky_presenter::prefs::AppearanceBase::factory(),
)
} else {
(bridge.load().0, bridge.load_base())
};
// 面板 α / 玻璃的降级判据要的是**实际**后端,不是 `--renderer` 的请求值(复审 r1 P2-2):
// 默认 `Auto` + 无独显时请求是 Auto、实际是 tiny-skia,只看请求值就会漏掉 D36 的强制项。
let backend_tier = super::super::backend::effective_backend(options.backend);
let mut appearance = pages::appearance::State::new(
&appearance_prefs,
&appearance_base,
backend_tier,
flavor,
);
// 取证开关(D30):`ULL_SETTINGS_GROUP=<下标>` 直达设置页的某一组(出图要看见语言行)。
if let Some(index) =
crate::ui::lang::screenshot_settings_group(options.screenshot.is_some())
{
appearance.selected_group = index;
}
// 界面语言(M106):**M92 已经探测对了,问题是一直没人用**——`pages::Model.lang` 此前在
// `view::model` 里被写死成 `Lang::En`,于是中文系统上开出来的是全英文界面。
let lang = LangState::boot(&bridge, options.screenshot.is_some());
let mut view = pages::Model::new(
pages::Screen::Instances,
width,
Some(pages::Screen::Instances.nav()),
);
view.lang = lang.lang();
let mut app = Self {
ui: Ui::new(flavor, families),
families,
options,
backend,
bridge,
state: AppState::new(),
ticks: 0,
config_due_at: unlucky_presenter::theme::CONFIG_POLL_TICKS,
theme_mode,
lang,
last_tick: std::time::Instant::now(),
width,
view,
view_dirty: false,
watch_until: None,
window: None::<window::Id>,
frames: 0,
refreshes: 0,
auto_selected: false,
height,
appearance,
appearance_prefs,
hwnd: None,
settings: pages::settings::Groups::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,
content: pages::content::PageState::default(),
account_form: pages::account::Form::new(),
account: pages::account::Page::build(
&AppState::new(),
&pages::account::Form::new(),
&mut pages::account::avatar::Cache::default(),
unlucky_i18n::Lang::En,
width,
),
avatars: pages::account::avatar::Cache::default(),
corner_applied: None,
};
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
app.rebuild_ui();
// 两行日志:①档位 + 探测结论 + 生效语言 ②中文策略(D28 缺字体不许静默)。
// 下一个人据此分得清「探测错了」还是「探测对了但没用上」。
app.lang.log();
app.lang.log_cjk();
app
}
}
+61
View File
@@ -0,0 +1,61 @@
//! 快照 → 视图模型的重建口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! 全仓只有 [`App::refresh_view`] 这一个「presenter 快照 → 页面要画的东西」的转换点,
//! 由 [`App::view_dirty`](super::App) 门控(M90 r2/P2-1 起):只有真的改了状态的事情才置位,
//! 30 Hz 空转不重建(重建要把 N 个实例的名字/版本串克隆一遍)。
use unlucky_presenter::toast::ToastKind;
use crate::pages;
use super::App;
impl App {
/// 把 presenter 的快照转换成页面视图模型(**展示转换在 UI 侧做**)。
///
/// 代价是「克隆 N 个实例的名字/版本串」,所以只在 [`App::view_dirty`] 置位时调用(P2-1)。
pub(in crate::app) fn refresh_view(&mut self) {
// **不变式先对齐再算宽度**(M104 / 复审 r1 P2-1):`Route::Agent` ⟺ `RailState::Detached`。
// 每一次路由变化都会走到这里,所以它是「档位 ↔ 路由」的唯一点;放在算宽度之前,
// 是为了让模型拿到的 `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 只发信号 ——
// 否则会出现「选项区英文、取消弹窗中文」的同屏两语(复审 P2-2)。
if self.state.install_cancelled != self.install_cancelled_seen {
self.install_cancelled_seen = self.state.install_cancelled;
// M106:提示语跟着**生效语言**走(M105 留的 `view::ui_lang()` 占位已删——它恒英文,
// 中文界面会弹出英文提示,那正是 M105 r1 复审 P2-2 要堵的同屏两语)。
let text = pages::cancelled_toast(self.lang());
self.state.toasts.push(ToastKind::Info, text);
}
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
let category = self.view.download.category;
// 草稿同理(M105):它也是页面自己持有的输入,重建模型时带回去,
// 否则每来一条事件(连安装进度都算)用户填的加载器就被抹回「原版」。
let draft = self.download_draft.clone();
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
let width = self.agent.content_width(self.width);
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
self.view = super::super::view::model(
&self.state,
width,
self.options.force_state,
category,
&draft,
self.lang(),
&self.content,
);
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
// 皮肤解码走缓存,所以重复重建不重解图)。
self.rebuild_account_page(width);
self.view_dirty = false;
self.refreshes += 1;
}
}
+86
View File
@@ -0,0 +1,86 @@
//! 主题与 `Ui` 的重算口径(从 `state.rs` 搬出来守 400 行硬线;M119 拆,纯搬不改)。
//!
//! 三件事在这里:①`Ui` 什么时候重建([`App::rebuild_ui`])②主题档怎么定
//! ([`App::set_flavor`] / [`App::apply_config_theme`])③读当前求值器([`App::ui`])。
//!
//! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。
use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::prefs::ThemeMode;
use crate::theme::Ui;
use super::App;
impl App {
/// 本次进程是否走截图通道。
pub(in crate::app) fn has_screenshot(&self) -> bool {
self.options.screenshot.is_some()
}
/// 切主题:重建 [`Ui`](= 重建 Style),**不动任何 UI 状态**。
///
/// M93 起 `Ui` 有两个来源:配色来源取到了图(①②级)就用解析出的方案,否则用令牌 baseline。
/// 所以这里改叫「重算 `Ui`」:先让外观状态记住新深浅(配色解析要它),再重建。
pub(in crate::app) fn set_flavor(&mut self, flavor: SchemeFlavor) {
if self.ui.flavor() == flavor && self.appearance.flavor() == flavor {
return;
}
self.appearance.set_flavor(flavor);
let previous = self.ui.name();
self.rebuild_ui();
eprintln!(
"[theme] {} -> {} (Theme::name 变化触发缓存重算)",
previous,
self.ui.name()
);
}
/// 按「配色来源是否取色成功」重建 [`Ui`]。
///
/// 色卡档(③)刻意继续走 `Scheme::baseline`:`resolve_source` 的色卡档返回的是
/// `DEFAULT_CARD`(天蓝卡)方案,与令牌 baseline 逐色不同,应用它会把**全部既有验收图的
/// 像素**改掉。这条偏离登记在 `pages::appearance` 的偏离表 #4。
pub(in crate::app) fn rebuild_ui(&mut self) {
// 面板 α(D36)在这里注入 `Ui`:全仓所有表面色都从 `Ui::layer` 出去,所以这是**唯一**
// 生效点(外壳 / 三页卡片 / 外观分组卡片 / 空态图标块自动同源,调用点没有机会各处自乘)。
let panel_alpha = self.appearance.effective_panel_alpha();
self.ui = match self.appearance.derived_scheme() {
Some(scheme) => {
eprintln!(
"[appearance] palette={} → Ui::from_scheme(取色方案接管 49 个角色)",
self.appearance.palette_level()
);
Ui::from_scheme(*scheme, self.families)
}
None => Ui::new(self.appearance.flavor(), self.families),
}
.with_panel_alpha(panel_alpha);
}
/// 按设置文件里的主题模式切一次外观;`--theme` 显式给了就不动。
///
/// **截图通道直接跳过**:`--screenshot` 的主题唯一取值口是 `Options::capture_flavor`
/// (D30 可复现),读本机 `launcher.json` 会让同一份代码在两台机器上出不同的图。
///
/// `ThemeMode::Auto` 不在这里处理:那要看系统主题(`iced::system::theme()` 的结果在
/// `Message::SystemTheme` 里落地)。
pub(in crate::app) fn apply_config_theme(&mut self) {
if self.has_screenshot() || self.options.theme.is_some() {
return;
}
let mode = self.bridge.load().1;
self.theme_mode = mode;
match mode {
ThemeMode::Light => self.set_flavor(SchemeFlavor::Light),
ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark),
ThemeMode::Auto => {}
}
}
/// 当前令牌求值器(测试与截图日志用)。
pub(in crate::app) fn ui(&self) -> &Ui {
&self.ui
}
}
+6 -4
View File
@@ -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)));
+3 -114
View File
@@ -25,7 +25,7 @@
//!
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
use std::time::{Duration, Instant};
use std::time::Instant;
use iced::{window, Task};
@@ -33,19 +33,19 @@ use unlucky_design::color::SchemeFlavor;
use unlucky_presenter::dto::AppCommand;
use unlucky_presenter::prefs::ThemeMode;
use unlucky_presenter::router::Route;
use unlucky_presenter::theme::CONFIG_POLL_TICKS;
use crate::pages;
use crate::renderer::Kind;
use super::message::{route_of, Message};
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
use super::state::{App, IDLE_TICK, TICK, WATCH_WINDOW};
use super::CAPTURE_AT_FRAME;
mod account;
mod agent;
mod appearance;
mod boot_env;
mod clock;
mod content;
mod download;
mod page_messages;
@@ -249,117 +249,6 @@ impl App {
}
}
/// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。
///
/// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。
pub(super) fn advance(&mut self, elapsed: Duration) {
self.backend.tick(elapsed);
self.pump();
self.state
.toasts
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
// 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动,
// 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true;
}
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1);
let bucket_before = self.ticks / 30;
self.ticks += ticks;
let bucket_after = self.ticks / 30;
// 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。
//
// 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同
// (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了**
// ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是
// `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。
let poll_due = self.ticks >= self.config_due_at;
if poll_due {
// 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞
self.config_due_at = self.ticks + CONFIG_POLL_TICKS;
}
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance();
}
if self.view_dirty {
self.refresh_view();
}
// 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!(
"[tick] n={} frames={} refreshes={} clock={}",
self.ticks,
self.frames,
self.refreshes,
if self.needs_frames() {
"frames"
} else {
"idle-1hz"
}
);
}
}
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
pub(super) fn dispatch(&mut self, command: AppCommand) {
self.backend.dispatch(command);
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
self.watch_until = Some(Instant::now() + WATCH_WINDOW);
self.pump();
}
/// 抽干事件队列并逐条 `apply`(**唯一**的状态写入口)。
pub(super) fn pump(&mut self) {
let events = self.backend.drain_events();
if events.is_empty() {
return;
}
for event in events {
self.state.apply(event);
}
self.view_dirty = true;
self.auto_select();
self.refresh_view();
}
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
///
/// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。
/// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」
/// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。
fn auto_select(&mut self) {
if self.auto_selected || self.state.route() != &Route::Instances {
return;
}
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
self.auto_selected = true;
self.state.navigate(Route::Detail(first));
self.view_dirty = true;
}
/// 截图回来了:落盘 + 打一行取证日志,然后退出。
fn on_captured(&mut self, screenshot: &window::Screenshot) -> Task<Message> {
let Some(path) = self.options.screenshot.clone() else {
+1 -120
View File
@@ -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;
@@ -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);
}
+189 -26
View File
@@ -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<Message> {
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,55 @@ 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 rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(shift, busy, pending);
}
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),
}
// 待确认请求出现/消失时,把键盘焦点挪到(或挪离)确认条的按钮上
self.sync_confirm_focus();
// 档位 / 路由变了就要把「栏可见吗」写回 presenter(未读口径的输入)
self.sync_agent_presenter();
if geometry_changed {
self.view_dirty = true;
self.refresh_view();
@@ -83,6 +125,92 @@ 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<agent::Failure<'_>> {
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,
})
}
/// 待确认请求出现 ⇒ 焦点直接落在「允许」;消失 ⇒ 收回输入框。
///
/// **只挪焦点,不替用户打开键盘巡航**(M119 r2 / 复审 P2-2):r1 在这里顺手把
/// `keyboard_mode` 置真,而 `sync_confirm_focus` 是 `on_agent` 的无条件尾巴、`Events` 又由心跳
/// 驱动——于是只要冒出待确认,全局键盘闸就被打开;用户发完那句话接着打下一句,第一个空格
/// 就成了「批准写操作」。现在那道闸只由真实的键盘表态打开(`Tab`,见 `AgentUi::step_focus`;
/// 或者 `on_agent_key` 把按键送进来时用户本来就在键盘巡航)。
///
/// 键盘用户怎么走到确认条:待确认时焦点已经在这两个按钮上,**按一次 `Tab` 点亮焦点环即是
/// 表态**(`step_focus` 里那条 arming 规则:这一次不移动焦点),之后 `Enter`/`Space` 就能裁决。
///
/// 唯一写入口:`on_agent` 末尾(任何会改动 rail 的分支之后都要经过它)。
fn sync_confirm_focus(&mut self) {
let pending = self.agent_host.rail().confirm.is_some();
let on_confirm = matches!(self.agent.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
if pending && !on_confirm {
self.agent.focus = Focus::ConfirmAllow;
eprintln!("[agent] 写操作待确认 → 焦点落到 Allow(Tab 点亮焦点环后 Enter/Space 裁决)");
} else if !pending && on_confirm {
self.agent.focus = Focus::Composer;
self.agent.keyboard_mode = false;
}
}
/// **不变式**:`route() == Route::Agent` ⟺ `agent.rail == RailState::Detached`(唯一同步点)。
///
/// 为什么需要它(复审 r1 的 P2-1,实测复现):`agent.rail` 只在自己发 `Detach`/`Dock` 时变,
@@ -106,12 +234,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 +251,33 @@ 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::Armed => {
// 先跑一轮(写操作脚本会挂一个待确认),再按一次 `Tab`:
// 待确认时这一下只 arm、不移动焦点(见 `AgentUi::step_focus` 的 arming 规则)
let _ = self.agent_host.submit(demo::DEMO_QUESTION.to_string());
self.drain_agent();
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
}
Fixture::Focused => {
let rail = self.agent_host.rail();
let (busy, pending) = (rail.busy, rail.confirm.is_some());
self.agent.step_focus(false, busy, pending);
}
}
}
if self.agent.rail == RailState::Detached && self.state.route() != &Route::Agent {
eprintln!("[agent] rail=detached → Route::Agent");
self.state.navigate(Route::Agent);
@@ -149,7 +306,11 @@ impl App {
},
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
Key::Named(key::Named::Enter) => agent::Message::Activate,
//
// M119 r1 / P2-2:`Space` 与 `Enter` 等价(键盘用户对确认条的习惯动作是空格)。
// 它不会抢输入框里的空格:`activate_focused` 头一行就是 `keyboard_mode` 闸,
// 而用鼠标点进输入框时那道闸是关的(`AgentUi::edit` 的 `Click` 分支)。
Key::Named(key::Named::Enter | key::Named::Space) => agent::Message::Activate,
_ => return Task::none(),
};
@@ -195,6 +356,8 @@ impl App {
agent::Message::Detach
})
}
(_, Focus::ConfirmAllow) => Some(agent::Message::Confirm(true)),
(_, Focus::ConfirmDeny) => Some(agent::Message::Confirm(false)),
(_, Focus::Send) => Some(agent::Message::Submit),
(_, Focus::Stop) => Some(agent::Message::Stop),
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
@@ -0,0 +1,352 @@
//! **树测试①(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 iced::keyboard::key::{Code, Named, Physical};
use iced::keyboard::{key, Location, Modifiers};
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::{Focus, 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 那边)。
/// **P2-1**:默认配置下后端是脚本化 rig ⇒ 视图必须知道「这是模拟」(界面据此常驻标记)。
///
/// 抓得住:`is_simulated` 被写死成 `false`、或者视图拿不到这个事实(用户就会以为模型接上了)。
/// 抓不住:标记在像素上长什么样(那要出图,见 `DevDocs/artifacts/m119/08-*`)。
#[test]
fn the_default_backend_reports_itself_as_simulated() {
let app = app_with(Script::Qa);
assert!(
app.agent_host.is_simulated(),
"脚本化 rig 必须自报「模拟」,否则界面就能假装模型接上了"
);
// 空会话(最容易被误读成一屏)也要能拿到:视图入参 `Session.simulated` 就是它
let session = crate::shell::agent::Session {
agent: &app.agent,
rail: app.agent_host.rail(),
failure: app.agent_failure(),
simulated: app.agent_host.is_simulated(),
};
assert!(session.simulated);
}
#[test]
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 侧也清空");
}
/// 手搓一条按键事件(订阅层搬过来的就是它;`on_agent_key` 只看 `KeyPressed`)。
fn press(named: key::Named) -> iced::keyboard::Event {
let key = key::Key::Named(named);
iced::keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key: Physical::Code(Code::Enter),
location: Location::Standard,
modifiers: Modifiers::default(),
text: None,
repeat: false,
}
}
/// **P2-2 的回归闸**:不碰鼠标、只用键盘消息,能完成一次写操作确认。
///
/// 为什么这条重要:确认条原本只有鼠标路径,而待确认时 `busy` 锁着发送、`Stop` 又走主后端
/// (清不掉 presenter 的 pending)——键盘用户会在这一步**永久卡死**。
///
/// 抓得住:①焦点没主动落到确认条(`Enter` 够不着);②`Enter` / `Space` 没映射成 `Activate`;
/// ③`Tab` 环里没有确认条那两格;④裁决完焦点没收回输入框(下一句话打不进去)。
/// 抓不住:焦点环在像素上的样子(那要出图,见 `DevDocs/artifacts/m119/09-*`)。
#[test]
fn a_blocking_confirm_can_be_decided_with_the_keyboard_alone() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
assert!(
app.agent_host.rail().confirm.is_some(),
"先要有一个待确认请求"
);
// ① 焦点主动落在「允许」(阻塞态),但**键盘闸不替用户打开**(复审 r2 / P2-2)
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"焦点要落在可操作元素上"
);
assert!(
!app.agent.keyboard_mode,
"塔台 r2:不许自动 arm——自动 arm 会让用户随手敲的空格变成「批准」"
);
// ② 第一次 Tab = 「我要用键盘」的表态:只点亮焦点环,**不移动**焦点
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
assert!(app.agent.keyboard_mode, "Tab 之后才算 armed");
assert_eq!(
app.agent.focus,
Focus::ConfirmAllow,
"arming 的那一次不移动焦点(否则 Enter 会反着裁决)"
);
// ③ Enter = 允许
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
assert!(
app.agent_host.rail().confirm.is_none(),
"Enter 必须真的裁决掉"
);
assert_eq!(app.agent_host.audit_len(), 1, "裁决进审计");
assert_eq!(app.agent.focus, Focus::Composer, "裁决完焦点收回输入框");
assert!(!app.agent.keyboard_mode, "阻塞态结束就不再亮焦点环");
// ④ 第二轮走「Tab 点环 → Tab 走到拒绝 → Space 触发」:键盘用户不必背 Enter 是哪一个
submit(&mut app, "那把并发改回来");
assert_eq!(app.agent.focus, Focus::ConfirmAllow);
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
let _ = app.update(Message::Agent(AgentMessage::Tab { shift: false }));
assert_eq!(app.agent.focus, Focus::ConfirmDeny, "Tab 要能走到「拒绝」");
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
assert!(app.agent_host.rail().confirm.is_none(), "Space 也要能裁决");
assert_eq!(app.agent_host.audit_len(), 2);
}
/// **复审 r2 / P2-2 的反向回归**:没 armed 的时候,空格与回车都不许裁决写操作。
///
/// 复审用临时用例复现的路径:WriteOp 提交后(r1 无条件 arm),用户接着打下一句,第一个空格就成了
/// 「允许」。真实场景里编辑器仍持 iced 焦点,所以这条不是理论问题。
///
/// 抓得住:谁把 `sync_confirm_focus` 里的 `keyboard_mode = true` 加回来;谁把 `Space`/`Enter` 的
/// `Activate` 绕到 `keyboard_mode` 闸之外。
/// 抓不住:iced 组件树里空格到底有没有进输入框(那要真窗口,出图另算)——这里只断言「没裁决」。
#[test]
fn a_stray_space_or_enter_never_approves_a_pending_write() {
let mut app = app_with(Script::WriteOp);
submit(&mut app, "把下载并发改小");
assert!(app.agent_host.rail().confirm.is_some());
assert!(!app.agent.keyboard_mode, "待确认不该自动 arm");
// 用户接着打下一句:先敲一个字符(编辑器自己消化),再敲空格
let _ = app.update(Message::Agent(AgentMessage::Edit(
text_editor::Action::Edit(text_editor::Edit::Insert('a')),
)));
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Space))));
assert!(
app.agent_host.rail().confirm.is_some(),
"未 armed 时空格不许批准写操作"
);
assert_eq!(app.agent_host.audit_len(), 0, "也不许留下审计");
// 回车同理:未 armed 时它归编辑器(发送键),不归确认条
let _ = app.update(Message::Agent(AgentMessage::Key(press(Named::Enter))));
assert!(
app.agent_host.rail().confirm.is_some(),
"未 armed 时回车不许批准写操作"
);
assert_eq!(app.agent_host.audit_len(), 0);
}
+143
View File
@@ -0,0 +1,143 @@
//! 节拍:推进一拍 / 派发指令 / 抽干事件(M119 从 `update.rs` 拆出来守 400 行硬线;纯搬不改)。
//!
//! ## 为什么这三件必须在一起
//!
//! `advance` → `pump` → `apply` → `refresh_view` 是同一条链:时间推进、事件抽干、状态落地、
//! 视图重建。拆开看每一段都无害,合起来才是「presenter 的异步事件在 UI 侧怎么被消费」的答案。
//!
//! ## 两个时钟的相位(M90 r3 / P2-2 的老账,代码里有细节)
//!
//! | 场景 | 时钟 | 周期 |
//! |---|---|---|
//! | 截图 / 有 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;
use unlucky_presenter::theme::CONFIG_POLL_TICKS;
use super::super::state::TICK_MS;
use super::*;
impl App {
/// 推进一拍:后端消化时间 → 抽干事件 → 过期 toast → 偶发重读配置文件。
///
/// `elapsed` 是真实流逝时间(毫秒级抖动是正常的:`MockBackend` 的脚本按累计时长走)。
pub(in crate::app) fn advance(&mut self, elapsed: Duration) {
self.backend.tick(elapsed);
self.pump();
self.state
.toasts
.expire(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
// 设备码/授权码的倒计时(M114):等待态每秒都要重画一次,否则「剩 900s」永远不动,
// 用户会以为卡死了——这正是任务里「不得只转圈」的那一条。
if self.state.device_login.phase == unlucky_presenter::dto::DeviceLoginPhase::Waiting {
self.view_dirty = true;
}
// `ticks` 的单位是**33 ms 的一拍**,不是「advance 被调了几次」:空闲心跳一次跨 1 s,
// 要记 30 拍,这样 `theme::config_poll_due`(30 拍 ≈ 1 秒)在两种时钟下语义一致。
let ticks = elapsed.as_millis().div_euclid(u128::from(TICK_MS));
let ticks = u64::try_from(ticks).unwrap_or(u64::MAX).max(1);
let bucket_before = self.ticks / 30;
self.ticks += ticks;
let bucket_after = self.ticks / 30;
// 外部(AI / CLI)改了配置文件:主题跟着换,其余外观项等玻璃/背景 mission。
//
// 判据用**跨越**而不是 presenter 的 `config_poll_due(ticks)`(整除):两个时钟的相位不同
// (帧节拍按 33 ms 累加、空闲心跳按 30 拍累加),整除判定一旦错过一次就**再也命中不了**
// ——实测 ticks=137 之后每拍 +30 永远不整除,配置轮询静默死掉。间隔真源仍是
// `CONFIG_POLL_TICKS`(30 拍 × 33 ms ≈ 1 秒)。截图通道不轮询(D30 可复现)。
let poll_due = self.ticks >= self.config_due_at;
if poll_due {
// 追到「当前 ticks 之后的第一个间隔点」,避免心跳把相位拖飞
self.config_due_at = self.ticks + CONFIG_POLL_TICKS;
}
let reloaded =
!self.has_screenshot() && poll_due && self.bridge.reload_if_changed().is_some();
if reloaded {
eprintln!("[settings] 配置文件变了,重读主题 + 外观");
self.apply_config_theme();
// M93:外观也热切(面板 α / 背景图 / 配色来源 / 整窗 α 都在里面)。
self.reload_appearance();
}
if self.view_dirty {
self.refresh_view();
}
// 取证开关(`ULL_TICK_TRACE=1`):每跨过 30 拍(≈1 秒)打一行。
// 忙碌态由帧节拍跨桶(≈1 行/秒),空闲态由心跳一次 +30 跨桶(**也是 1 行/秒**)——
// 两种时钟都用同一把尺子,所以 trace 的密度本身就是「时钟在跑」的证据。
if bucket_after != bucket_before && std::env::var_os("ULL_TICK_TRACE").is_some() {
eprintln!(
"[tick] n={} frames={} refreshes={} clock={}",
self.ticks,
self.frames,
self.refreshes,
if self.needs_frames() {
"frames"
} else {
"idle-1hz"
}
);
}
}
/// 派发一条指令并**立刻**抽干它带出来的事件(不本地回显,等价于 Slint 版的 `dispatch`)。
pub(in crate::app) fn dispatch(&mut self, command: AppCommand) {
self.backend.dispatch(command);
// 观察窗:命令发出后 3 秒内保持帧订阅,等后端把第一条事件交回来
// (真 host 是异步的,派发瞬间状态还没变;不设这个窗口,空闲态的帧订阅永远不会打开)。
self.watch_until = Some(Instant::now() + WATCH_WINDOW);
self.pump();
}
/// 抽干事件队列并逐条 `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;
}
for event in events {
self.state.apply(event);
}
self.view_dirty = true;
self.auto_select();
self.refresh_view();
}
/// 首屏快照到位后**自动选中第一行**(桌面 list-detail 的惯例:右栏不该开局就是空的)。
///
/// 只做一次:用户点「返回」回到列表后,再刷新实例列表也不会把他弹回详情页。
/// 选中这件事走的是 `AppState::navigate`(路由栈),不是本地选中字段——于是「当前选中」
/// 永远等于「路由栈顶」,用户点返回时栈语义自然成立。
pub(in crate::app) fn auto_select(&mut self) {
if self.auto_selected || self.state.route() != &Route::Instances {
return;
}
let Some(first) = self
.state
.tiles()
.first()
.map(|cell| cell.instance.id.clone())
else {
return;
};
self.auto_selected = true;
self.state.navigate(Route::Detail(first));
self.view_dirty = true;
}
}
+13 -2
View File
@@ -56,6 +56,17 @@ 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 键 + 已脱敏正文)。壳在这里不做任何脱敏。
// 界面语言的真值源是 `App::lang`(M106):右栏的静态文案也走它,不再有 `const LANG`
let agent_catalog = self.lang().catalog();
// 会话入参:rail 借 presenter、失败横幅由 app 层备好、`simulated` 是后端的事实
let session = agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
};
let account = self.rail_account();
match pages::Screen::of(self.state.route()) {
@@ -79,7 +90,7 @@ impl App {
self.language(),
)
.map(Message::Page),
agent::docked(&self.ui, &self.agent, self.width)
agent::docked(&self.ui, session, agent_catalog, self.width)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
@@ -99,7 +110,7 @@ impl App {
},
APP_TITLE,
background,
agent::page(&self.ui, &self.agent, self.width).map(Message::Agent),
agent::page(&self.ui, session, agent_catalog, self.width).map(Message::Agent),
None,
overlay,
self.options.force_state,
+13 -2
View File
@@ -52,8 +52,19 @@ 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,
agent::Session {
agent: &self.agent,
rail: self.agent_host.rail(),
failure: self.agent_failure(),
simulated: self.agent_host.is_simulated(),
},
self.lang().catalog(),
self.width,
)
.map(|element| element.map(Message::Agent)),
overlay,
self.options.force_state,
self.lang(),
+94 -139
View File
@@ -3,24 +3,26 @@
//! ## 输入契约(纯数据入、消息出)
//!
//! ```text
//! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element<Message>
//! Conversation { messages, busy, unread, confirm, failure, draft, blank, rail, host, focus }
//! ──view()──▶ Element<Message>
//! ```
//!
//! - **入**:全部是**借用**的纯数据(`&[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::{Disclaimer, 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,26 @@ 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<Failure<'a>>,
/// 「这是模拟助手」的**四处文案**(副标题 / 占位符 / 空态正文 / 标题胶囊),
/// 全部由 [`Disclaimer::of`] 一次算好——视图里没有第二种取词路径。
///
/// M119 r1 / r2 复审 P2-1:生产默认后端是脚本化 rig,界面上曾**没有任何模拟标识**;
/// 上一轮又只有标题胶囊认这个开关,另外三处会反方向地留在「没接真实模型」上。
pub note: Disclaimer<'a>,
/// 三档状态(决定头部画哪组动作)。
pub rail: RailState,
/// 宿主。
@@ -94,41 +109,50 @@ 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, spec.note.empty_body)
} else {
transcript::list(ui, spec, catalog)
});
// 确认条贴着输入区(动作在下面,读完就能按)。
if let Some(request) = spec.confirm {
body = body.push(banners::confirm(ui, request, catalog, spec.focus));
}
body.push(divider(ui))
.push(composer::composer(ui, spec, catalog))
.into()
}
// ---------------------------------------------------------------- 头部
fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let title = row![
text_role(
ui,
TypeRole::TitleLarge,
true,
catalog.text(Key::AgentTitle),
ui.color(Role::OnSurface),
Length::Shrink,
),
unread_badge(ui, spec.unread, catalog),
]
let mut title = row![text_role(
ui,
TypeRole::TitleLarge,
true,
catalog.text(Key::AgentTitle),
ui.color(Role::OnSurface),
Length::Shrink,
)]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
// 常驻标记:与标题同一行,不点开、不 hover、不藏在设置里(P2-1 的判据)
if spec.note.simulated {
title = title.push(simulated_chip(ui, catalog));
}
title = title.push(unread_badge(ui, spec.unread, catalog));
let titles = column![
title,
@@ -136,7 +160,7 @@ fn header<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentSubtitle),
spec.note.subtitle,
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
@@ -244,7 +268,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 {
@@ -252,6 +276,28 @@ fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, M
}
}
/// 「模拟」标记:`SurfaceVariant` / `OnSurfaceVariant` 的 tonal 小胶囊(中性信息,不是告警)。
fn simulated_chip<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
let style = ui.style(TypeRole::LabelMedium);
container(
text(catalog.text(Key::AgentSimulated))
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSurfaceVariant)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SurfaceVariant))),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0),
..Border::default()
},
..container::Style::default()
})
.into()
}
fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> {
if unread == 0 {
return space::horizontal().into();
@@ -279,97 +325,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::<Theme>(ui.metrics().divider_thickness)
@@ -0,0 +1,212 @@
//! 会话里的两条**横幅**(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::state::Focus;
use super::super::{Failure, Message};
use super::focused_state;
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,
focus: Option<Focus>,
) -> Element<'a, Message> {
// 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示,
// 否则用户是在盲签。句框走 catalog,摘要本身是正文。
let title = catalog.render(
Key::AgentConfirmRequest,
&[("summary", request.summary.as_str())],
);
// 两个按钮都在 Tab 环上(`Focus::ConfirmAllow` / `Focus::ConfirmDeny`):键盘用户
// 用 Enter/Space 就能裁决,不必去找鼠标(P2-2 的那条死路)
let deny = focus == Some(Focus::ConfirmDeny);
let allow = focus == Some(Focus::ConfirmAllow);
let actions: Vec<Element<'a, Message>> = vec![
ring(
ui,
buttons::outlined(
ui,
focused_state(
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
.leading(IconKind::Close),
deny,
),
),
deny,
),
ring(
ui,
buttons::filled(
ui,
focused_state(
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
.leading(IconKind::Check),
allow,
),
),
allow,
),
];
banner(ui, title, String::new(), actions)
}
/// 焦点环:`Primary` 的 2dp 描边(与输入框聚焦同一套语言)。
///
/// 为什么不能只用 `Interaction::Focused`(10% 状态层):在**实心主色**按钮上那层几乎看不出来
/// ——M119 r1 复审要求「焦点确实落在可操作元素上」且要能出图证明,10% 层在浅色图里等于没有。
/// 所以键盘焦点在这两个按钮上额外加一圈描边(鼠标用户的界面不受影响:只有 `focused` 才画)。
fn ring<'a>(ui: &'a Ui, button: Element<'a, Message>, focused: bool) -> Element<'a, Message> {
// 外圈留 3px 空气:环与按钮之间不贴边,才看得成「一圈」而不是「两条重合的描边」。
// 两个按钮**都**加这层 padding(`focused` 与否一样),所以焦点变化不移动布局。
let gap = ui.space(Space::X1) * 0.75;
let outer = ui.metrics().control_height + gap * 2.0;
container(button)
.padding(gap)
.style(move |_theme| container::Style {
border: if focused {
iced::Border {
color: ui.color(Role::Primary),
width: crate::theme::STROKE_PX * 2.0,
// 与按钮同一种「药丸」:`Full` 的半径 = 边长 / 2(这里用加过 padding 的边长)
radius: ui.radius(ShapeScale::Full, outer),
}
} else {
iced::Border::default()
},
..container::Style::default()
})
.into()
}
/// 横幅本体:图标块 + 标题/正文 + 右侧动作。
///
/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边,
/// `ErrorContainer` / `OnErrorContainer` 只作左侧图标块的强调,不是通栏红条)。
fn banner<'a>(
ui: &'a Ui,
title: String,
body: String,
actions: Vec<Element<'a, Message>>,
) -> 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,
));
}
// 文字行与动作**分行**:右栗只有 280~360,chip + 两个按钮已占掉大半宽,
// 把文字挤在同一条 Row 里会被压成一条竖缝(M119 实测:320 宽下标题整段看不见)。
let head = Row::new()
.spacing(ui.space(Space::X3))
.align_y(Alignment::Center)
.width(Length::Fill)
.push(chip)
.push(container(lines).width(Length::Fill));
let mut content = column![Element::from(head)].spacing(ui.space(Space::X2));
if !actions.is_empty() {
content = content.push(
container(
row(actions)
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
)
.width(Length::Fill)
.align_x(Alignment::End),
);
}
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()
}
@@ -37,7 +37,7 @@ pub(super) fn composer<'a>(
let style = ui.style(TypeRole::BodyMedium);
let field = editor(spec.draft)
.placeholder(catalog.text(Key::AgentPlaceholder))
.placeholder(spec.note.placeholder)
.on_action(Message::Edit)
.key_binding(binding_for)
.size(style.size_px)
@@ -120,7 +120,38 @@ pub(super) fn binding_for(press: KeyPress) -> Option<Binding<Message>> {
}
}
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
/// 一档文字(**借来的**正文)。
///
/// 与 `pages::frame::text_role` 同源,但那边返回的是 `Element<pages::Message>`,助手栏要自己的
/// 消息类型——这是 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,
@@ -0,0 +1,122 @@
//! 对话本体:空态 + 消息气泡(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};
/// 空会话:引导空态(零条消息时替代气泡列表)。
///
/// `body` 由调用方从 [`super::super::Disclaimer`] 取(模拟态/真实态两句话),这里不再自己取词。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog, body: &'a str) -> Element<'a, Message> {
container(empty_state(
ui,
EmptyState::new(IconKind::Ai, catalog.text(Key::AgentEmptyTitle), body),
))
.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()
}
+72 -55
View File
@@ -1,54 +1,59 @@
//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `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<Turn> {
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,
/// 跑一轮真提交,然后**按一次 `Tab`**(M119 r2 出图用)。
///
/// 复现的是键盘用户走到确认条的路径:待确认出现 ⇒ 焦点已落在「允许」但**键盘闸没开**
/// (图 `05`,看不到环);按一次 `Tab` 才算表态(图 `09`,环亮出来且焦点不动)。
Armed,
}
/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。
///
/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
pub fn from_env() -> Option<AgentUi> {
/// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容,
/// 不进 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 +62,7 @@ pub fn from_env() -> Option<AgentUi> {
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::<f32>() {
@@ -70,13 +71,9 @@ pub fn from_env() -> Option<AgentUi> {
}
}
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 +87,44 @@ pub fn from_env() -> Option<AgentUi> {
},
}
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("armed") => Fixture::Armed,
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);
}
}
+155 -165
View File
@@ -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,40 +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 unlucky_i18n::{Catalog, Key};
use unlucky_presenter::agent::AgentRail;
use unlucky_presenter::dto::AppEvent;
use crate::app::components::buttons::{self, IconVariant};
use crate::pages::{self, layout};
use crate::theme::Ui;
use conversation::{Conversation, Host};
use metrics::{COLLAPSED_WIDTH, RESIZE_GUTTER};
/// 界面语言(与产品其它页面同一口径:i18n 接线前是英文基线,D28 的「英文环境零中文负担」)。
const LANG: Lang = Lang::En;
use metrics::RESIZE_GUTTER;
/// 助手栏消息(**唯一**的消息类型;`app/view.rs` 用 `Message::Agent(..)` 包一层)。
#[derive(Debug, Clone)]
@@ -86,8 +83,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<AppEvent>),
/// 按下拖拽缝。
DragStart,
/// 拖拽中:窗口坐标里的指针 x。
@@ -107,30 +112,115 @@ 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,
}
/// 「这是模拟助手」的**四处文案**(M119 r2 / 复审 P2-1)。
///
/// ## 为什么是一个结构体而不是四个 `if`
///
/// 上一轮只有标题胶囊由 `simulated` 驱动,另外三处(副标题 / 输入框占位符 / 空态正文)是
/// **无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后,这三句会
/// 反方向地留着(`simulated == false` 时界面上还在说「没接真实模型」)。复审把这条列为 P2。
///
/// 现在四处**只认这一个开关**:`Disclaimer::of(catalog, simulated)` 一次算好,视图没有第二种
/// 取词路径。`pages/agent/tests/disclaimer.rs` 钉住「四处一起出现 / 一起消失」。
#[derive(Debug, Clone, Copy)]
pub struct Disclaimer<'a> {
/// 这套后端是不是模拟的(标题旁的胶囊只在 `true` 时画)。
pub simulated: bool,
/// 标题下的副标题。
pub subtitle: &'a str,
/// 输入框占位符。
pub placeholder: &'a str,
/// 空会话正文。
pub empty_body: &'a str,
}
impl<'a> Disclaimer<'a> {
/// 按 `simulated` 选模拟态或真实态那一套文案。
#[must_use]
pub fn of(catalog: Catalog, simulated: bool) -> Self {
Self {
simulated,
subtitle: if simulated {
catalog.text(Key::AgentSubtitle)
} else {
catalog.text(Key::AgentSubtitleReal)
},
placeholder: if simulated {
catalog.text(Key::AgentPlaceholder)
} else {
catalog.text(Key::AgentPlaceholderReal)
},
empty_body: if simulated {
catalog.text(Key::AgentEmptyBody)
} else {
catalog.text(Key::AgentEmptyBodyReal)
},
}
}
}
/// 两个宿主共用的**会话入参**(`Session` = 从 presenter 借来的那一份 + 两个展示事实)。
///
/// 为什么是一个结构体而不是继续加形参:`docked`/`page` 已经收到 6 个形参,这一轮还要加
/// 「是不是模拟后端」——那会变成 7 个,正是塔台登记过的可维护性观察项。收成结构体之后两个宿主
/// 各 3 个形参,且以后再加展示事实只改这个结构体。
#[derive(Clone, Copy)]
pub struct Session<'a> {
/// 三档/草稿/焦点(纯 UI 态)。
pub agent: &'a AgentUi,
/// 会话内容 / 未读 / busy / 待确认(presenter 的唯一真相)。
pub rail: &'a AgentRail,
/// 失败横幅的展示输入。
pub failure: Option<Failure<'a>>,
/// 这套后端是不是模拟的(M119 r1 / 复审 P2-1)。`true` ⇒ 头部常驻「模拟」标记,
/// 且三处文案走模拟态(见 [`Disclaimer`]:**四处一起变**,只认这一个开关)。
pub simulated: bool,
}
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
///
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
pub fn docked<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Option<Element<'a, Message>> {
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match agent.effective_rail(window_width) {
match session.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, session.rail, catalog)),
RailState::Expanded => Some(panel(ui, session, catalog, 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,
session: Session<'a>,
catalog: Catalog,
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(session, catalog, Host::Page), catalog);
container(
container(body)
@@ -178,137 +268,33 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
)
}
// ---------------------------------------------------------------- 折叠档: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> {
let width = agent.rail_width(window_width);
let catalog = Catalog::of(LANG);
fn panel<'a>(
ui: &'a Ui,
session: Session<'a>,
catalog: Catalog,
window_width: f32,
) -> Element<'a, Message> {
let width = session.agent.rail_width(window_width);
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(session, catalog, Host::Rail),
catalog,
))
.width(Length::Fixed(width))
.height(Length::Fill)
.padding(Padding {
top: ui.space(Space::X6),
right: ui.space(Space::X4),
bottom: ui.space(Space::X4),
left: ui.space(Space::X4),
})
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))),
..container::Style::default()
});
row![splitter(ui, width, catalog), body]
.width(Length::Shrink)
@@ -366,16 +352,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>(session: Session<'a>, catalog: Catalog, host: Host) -> Conversation<'a> {
Conversation {
transcript: &agent.transcript,
draft: &agent.draft,
busy: agent.busy,
unread: agent.unread,
blank: agent.draft_is_blank(),
rail: agent.rail,
// 会话内容、未读、busy、待确认:**全部**来自 presenter(M119)
messages: &session.rail.messages,
busy: session.rail.busy,
unread: session.rail.unread,
confirm: session.rail.confirm.as_ref(),
failure: session.failure,
note: Disclaimer::of(catalog, session.simulated),
draft: &session.agent.draft,
blank: session.agent.draft_is_blank(),
rail: session.agent.rail,
host,
// 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮)
focus: agent.keyboard_mode.then_some(agent.focus),
focus: session.agent.keyboard_mode.then_some(session.agent.focus),
}
}
+58 -113
View File
@@ -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<Mutex<..>>`(当时 `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<String>) -> Self {
Self {
speaker,
text: text.into(),
}
}
}
/// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
@@ -101,6 +72,13 @@ pub enum Focus {
Collapse,
/// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。
Detach,
/// 确认条的「允许」(只在有待确认写操作时存在于环上)。
///
/// M119 r1 / 复审 P2-2:确认条原本只有鼠标路径——键盘用户在写操作确认这一步**卡死**
/// (`busy` 锁住发送,`Stop` 走的是主后端、清不掉 presenter 的待确认)。这两格就是那条键盘通路。
ConfirmAllow,
/// 确认条的「拒绝」。
ConfirmDeny,
/// 发送。
Send,
/// 中断(只在流式中出现)。
@@ -115,14 +93,19 @@ impl Focus {
match self {
Focus::Collapse => "collapse",
Focus::Detach => "detach",
Focus::ConfirmAllow => "confirm-allow",
Focus::ConfirmDeny => "confirm-deny",
Focus::Send => "send",
Focus::Stop => "stop",
Focus::Composer => "composer",
}
}
/// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。
fn order(state: RailState, busy: bool) -> Vec<Focus> {
/// Tab 环上「这一档真正存在的控件」。
///
/// `confirm_pending` = 有待确认的写操作(那时条上多两格:允许 / 拒绝)。`busy` 在等确认时
/// 也是真(presenter 的 `busy = streaming || confirm.is_some()`),所以环上还会多一格中断。
fn order(state: RailState, busy: bool, confirm_pending: bool) -> Vec<Focus> {
let mut order = Vec::new();
match state {
RailState::Collapsed => order.push(Focus::Collapse),
@@ -132,6 +115,10 @@ impl Focus {
}
RailState::Detached => order.push(Focus::Detach),
}
if confirm_pending {
order.push(Focus::ConfirmAllow);
order.push(Focus::ConfirmDeny);
}
if state != RailState::Collapsed {
order.push(Focus::Send);
if busy {
@@ -143,8 +130,10 @@ impl Focus {
}
/// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。
pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus {
let order = Self::order(state, busy);
///
/// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。
pub fn step(self, shift: bool, state: RailState, busy: bool, confirm_pending: bool) -> Focus {
let order = Self::order(state, busy, confirm_pending);
let Some(at) = order.iter().position(|focus| *focus == self) else {
return if shift { order.last() } else { order.first() }
.copied()
@@ -165,14 +154,8 @@ pub struct AgentUi {
pub rail: RailState,
/// 展开档的吸附宽度(三档共享一个值:收起再展开回到同一宽度)。
expanded: f32,
/// 会话内容(占位样本数据,真模型由 M103 接)。
pub transcript: Vec<Turn>,
/// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。
pub draft: text_editor::Content,
/// 是否在流式回复(`false` = 没有中断键、发送键可用)。
pub busy: bool,
/// 未读数(折叠条画点 + 一行可读文字;展开/拆出即清零)。
pub unread: usize,
/// 键盘软焦点。
pub focus: Focus,
/// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。
@@ -186,7 +169,7 @@ pub struct AgentUi {
}
impl AgentUi {
/// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。
/// 出厂态:**折叠** + 空草稿 + 无未读(未读由 presenter 按事件累加,M119)。
///
/// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够,
/// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。
@@ -194,24 +177,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 +221,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 +233,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 +251,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 +258,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 +270,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,10 +299,21 @@ 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` / `confirm_pending` 都由 presenter 的 rail 给。
///
/// **待确认的那一次 Tab 只点亮焦点环、不移动焦点**:待确认出现时 app 层已经把焦点放到
/// 「允许」上(阻塞态,见 `App::sync_confirm_focus`),但**不会**替用户把键盘巡航打开
/// (M119 r2 / 复审 P2-2:自动 arm 会让用户随手敲的空格变成「批准」)。所以第一次 `Tab`
/// 是「我要用键盘」的显式表态,此时焦点原地不动——否则第一次 Tab 会把「允许」挪到「拒绝」,
/// 用户按 `Enter` 就是反着裁决。
pub fn step_focus(&mut self, shift: bool, busy: bool, confirm_pending: bool) {
let arming = !self.keyboard_mode
&& confirm_pending
&& matches!(self.focus, Focus::ConfirmAllow | Focus::ConfirmDeny);
self.keyboard_mode = true;
if !arming {
self.focus = self.focus.step(shift, self.rail, busy, confirm_pending);
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//! 折叠档: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, catalog: Catalog) -> Element<'a, Message> {
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()
}
+175 -96
View File
@@ -1,17 +1,24 @@
//! 三档状态机、两个宿主、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 disclaimer;
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 +31,47 @@ pub(super) fn ui() -> Ui {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
}
/// 三档迁移:会话与草稿跟着走、未读按「打开即读」清零。
/// 一条只给视图用的 rail(会话内容由 presenter 生产,这里只造**渲染输入**)。
fn rail(messages: Vec<ChatMessage>, 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 +79,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 +100,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 +156,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<ChatMessage>") && !hosts.contains("Vec<Turn>"),
"宿主里不许出现自持的会话容器"
);
}
/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
@@ -190,6 +186,16 @@ 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,
// M119 r1 / P2-1:「这是模拟」的常驻标记也必须有词(用户不点就要能看到)
Key::AgentSimulated,
Key::AgentSubtitle,
] {
assert!(
!catalog.text(key).trim().is_empty(),
@@ -201,6 +207,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 +226,7 @@ fn every_action_has_a_readable_label() {
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
assert!(
include_str!("mod.rs").contains("Key::AgentStripLabel"),
include_str!("strip.rs").contains("Key::AgentStripLabel"),
"折叠条必须有可读的条上标签"
);
}
@@ -229,7 +238,7 @@ fn the_focus_ring_walks_the_controls_that_exist() {
let mut focus = Focus::Collapse;
let mut walked = vec![focus];
for _ in 0..4 {
focus = focus.step(false, RailState::Expanded, false);
focus = focus.step(false, RailState::Expanded, false, false);
walked.push(focus);
}
assert_eq!(
@@ -245,75 +254,145 @@ fn the_focus_ring_walks_the_controls_that_exist() {
// 反向:Collapse 往回走是 Composer
assert_eq!(
Focus::Collapse.step(true, RailState::Expanded, false),
Focus::Collapse.step(true, RailState::Expanded, false, false),
Focus::Composer
);
// 流式中多一档中断键
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
assert_eq!(
Focus::Send.step(false, RailState::Expanded, true),
Focus::Send.step(false, RailState::Expanded, true, false),
Focus::Stop
);
// 独立页只有「收回 + 输入区」
assert_eq!(
Focus::Detach.step(false, RailState::Detached, false),
Focus::Detach.step(false, RailState::Detached, false, false),
Focus::Send
);
// 折叠档没有会话动作:环上只有「展开」一格
assert_eq!(
Focus::Collapse.step(false, RailState::Collapsed, false),
Focus::Collapse.step(false, RailState::Collapsed, false, false),
Focus::Collapse
);
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
let mut agent = AgentUi::new();
assert!(!agent.keyboard_mode);
agent.step_focus(false);
agent.step_focus(false, false, false);
assert!(agent.keyboard_mode);
// M119 r1 / P2-2:有待确认写操作时,环上多两格(允许 / 拒绝),且**排在发送之前**——
// 阻塞态该先被看见。这也是键盘用户唯一能推进这一轮的路径。
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, true, true),
Focus::ConfirmAllow
);
assert_eq!(
Focus::ConfirmAllow.step(false, RailState::Expanded, true, true),
Focus::ConfirmDeny
);
assert_eq!(
Focus::ConfirmDeny.step(false, RailState::Expanded, true, true),
Focus::Send
);
// 没有待确认时这两格不在环上(鼠标用户不该 Tab 到不存在的按钮)
assert_eq!(
Focus::Detach.step(false, RailState::Expanded, false, false),
Focus::Send
);
}
/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 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 session = Session {
agent: &agent,
rail: &rail,
failure: None,
simulated: true,
};
let spec = super::spec(session, Catalog::of(Lang::En), Host::Rail);
assert_eq!(spec.messages.len(), rail.messages.len());
assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息");
assert_eq!(spec.unread, rail.unread);
assert_eq!(spec.busy, rail.busy);
assert!(spec.failure.is_none());
assert!(
spec.note.simulated,
"模拟事实要原样传给视图(标记不是硬编码)"
);
assert!(
spec.note.subtitle.to_lowercase().contains("simulated"),
"副标题也归这个开关管:{}",
spec.note.subtitle
);
}
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 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 session = Session {
agent: &agent,
rail,
failure: shown,
simulated: true,
};
let _: iced::Element<'_, Message> = page(&ui, session, catalog, window);
let _ = docked(&ui, session, catalog, 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,
note: Disclaimer::of(catalog, true),
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"));
}
@@ -0,0 +1,96 @@
//! 「这是模拟助手」的四处文案必须**一起**变(M119 r2 / 复审 P2-1 的验收标准)。
//!
//! ## 为什么这个文件存在
//!
//! 复审 r2 的 P2-1 抓得很准:`simulated` 是真字段,但 r1 只有**标题胶囊**一个消费者,另外三处
//! (副标题 / 输入框占位符 / 空态正文)是**无条件取词**,而它们的译文里写着「模拟 / 没接真实模型」。
//! 于是「接上真 transport 之后四处一起消失」只是一句声明——没有测试钉住它。
//!
//! 现在四处都从 [`Disclaimer`] 取词(`Disclaimer::of(catalog, simulated)`),本文件就是那句声明的
//! 证据:`simulated == true` 四处都提模拟;`simulated == false` 四处**都不提**。
//!
//! 抓得住:谁把某一处改回无条件取词(那就会出现「字段说真、界面说模拟」的反向假话)。
//! 抓不住:文案本身翻得好不好读(那要人看),也不管视图有没有真的用这几个字段
//! (那是 `tests.rs::the_spec_borrows_the_presenter_rail_verbatim` 与出图的活)。
use unlucky_i18n::{Catalog, Key, Lang};
use super::super::Disclaimer;
/// 这句话里有没有「模拟 / 没接真实模型」的意思(中英各一套写法)。
fn mentions_simulated(text: &str) -> bool {
let lower = text.to_lowercase();
lower.contains("simulated") || lower.contains("no real model") || text.contains("模拟")
}
/// **四处一起出现**(`simulated == true`):副标题 / 占位符 / 空态正文 / 标题胶囊。
#[test]
fn all_four_disclaimers_appear_when_the_backend_is_simulated() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
let note = Disclaimer::of(catalog, true);
assert!(note.simulated, "{}:开关要传下去", lang.tag());
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
// 第四处是标题胶囊:只在 `simulated` 时渲染,文案来自这条 key
("标题胶囊", catalog.text(Key::AgentSimulated)),
] {
assert!(
mentions_simulated(text),
"{} 的{place}没有说清「这是模拟」:{text:?}",
lang.tag()
);
}
}
}
/// **四处一起消失**(`simulated == false`):三处文案都不许再提模拟,胶囊也不该被画。
///
/// 这条就是复审要的那一句验收:接上真 transport 那天,界面不会再反向地声明「没接真实模型」。
#[test]
fn no_disclaimer_survives_when_a_real_backend_is_wired() {
for lang in [Lang::En, Lang::ZhCn] {
let catalog = Catalog::of(lang);
let note = Disclaimer::of(catalog, false);
assert!(!note.simulated, "{}:开关要传下去", lang.tag());
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
] {
assert!(
!mentions_simulated(text),
"{} 的{place}在真实态还在说模拟:{text:?}",
lang.tag()
);
}
// 真实态的三句也得是**有内容的话**,不能靠空串糊过去
for (place, text) in [
("副标题", note.subtitle),
("输入框占位符", note.placeholder),
("空态正文", note.empty_body),
] {
assert!(!text.trim().is_empty(), "{} 的{place}是空的", lang.tag());
}
}
}
/// 两态必须是**两套不同的文案**(同一个 key 两边都用就退化成「永远说模拟」或「永远不说」)。
#[test]
fn the_two_states_use_different_keys() {
let catalog = Catalog::of(Lang::En);
assert_ne!(
catalog.text(Key::AgentSubtitle),
catalog.text(Key::AgentSubtitleReal)
);
assert_ne!(
catalog.text(Key::AgentPlaceholder),
catalog.text(Key::AgentPlaceholderReal)
);
assert_ne!(
catalog.text(Key::AgentEmptyBody),
catalog.text(Key::AgentEmptyBodyReal)
);
}
+19 -6
View File
@@ -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,19 @@ 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());
let session = super::super::Session {
agent: &agent,
rail: &AgentRail::new(),
failure: None,
simulated: true,
};
assert!(docked(
&ui,
session,
unlucky_i18n::Catalog::of(unlucky_i18n::Lang::En),
1180.0
)
.is_none());
assert_eq!(agent.rail_width(1180.0), 0.0);
assert_eq!(agent.content_width(1180.0), 1180.0);
}
@@ -127,11 +141,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"), "折叠条上没画标签");
}
+279
View File
@@ -0,0 +1,279 @@
//! 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<Mutex<..>>` 就这两件东西,和
//! [`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,
/// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。
///
/// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport`
/// 的那一天,这里传 `false`——**四处「模拟」措辞会一起消失**(标题胶囊 / 副标题 / 输入框占位符 /
/// 空态正文,全部出自 `pages::agent::Disclaimer::of(catalog, simulated)`)。
/// 标记不是硬编码在视图里的,它是这个事实字段的渲染;「四处一起变」由
/// `pages/agent/tests/disclaimer.rs` 钉住(r1 时只有一处认它,那句声明是空的)。
simulated: bool,
queue: EventQueue,
failure: FailureSlot,
/// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。
last_failure: Option<AgentFailure>,
/// 上一次写回 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)),
// 脚本化 rig ⇒ 一定是模拟的(见字段文档)
simulated: true,
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 is_simulated(&self) -> bool {
self.simulated
}
/// 最近一次提交失败的具名原因。
pub(crate) fn last_failure(&self) -> Option<AgentFailure> {
self.last_failure
}
/// 抽干事件队列(UI 在心跳里调;返回的事件**只**经 `Message::Agent(Events)` 回流)。
pub(crate) fn drain(&mut self) -> Vec<AppEvent> {
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);
}
}
+41
View File
@@ -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<Mutex<VecDeque<..>>>`:**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};
+211
View File
@@ -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<AccountView>` | 落盘/读回由 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<AppEvent>,
accounts: Vec<AccountView>,
next_id: u32,
active: Option<String>,
}
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<AppEvent> {
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
));
}
}
+339
View File
@@ -0,0 +1,339 @@
//! [`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<Mutex<VecDeque<AppEvent>>>;
/// 失败原因槽(transport 侧写、UI 侧读后清)。
pub(crate) type FailureSlot = Arc<Mutex<Option<AgentFailure>>>;
/// 脚本(一轮输入会产生哪种剧情)。
#[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> {
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<ConfirmRequest>,
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<AppEvent>) {
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<Vec<AppEvent>, 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<Vec<AppEvent>, 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::MessageDelta {
index: assistant,
delta: "我准备把下载并发从 64 改成 16,改之前先问一句。".to_string(),
}));
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<AppEvent> {
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,
"工具收尾 + 确认收敛 + 回复"
);
}
}
+33
View File
@@ -126,6 +126,39 @@ mod logs;
mod nav;
mod settings;
/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
///
/// ## 它是什么
///
/// M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
/// 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
/// 再原封不动抄进来(生成方式见 `tests.rs` 里 `split_keeps_every_translation` 的注释)。
/// M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
/// (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
///
/// ## 为什么不是「数条数」
///
/// 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
/// 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
///
/// | 坏法 | 红在哪 |
/// |---|---|
/// | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
/// | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
/// | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
/// | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
///
/// **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
///
/// ## M108 更新了三条、新增了一条(版本管理改名)
///
/// 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
/// [`Key::NavDownload`] / [`Key::DownloadTitle`] / [`Key::DownloadSubtitle`]
/// 三条译文按令改掉,并**新增** [`Key::NavDownloadExpanded`](240 展开态的全标签,
/// 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
///
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
/// 其余一字未动」,不再是「整表一字未动」。
#[cfg(test)]
mod golden;
+29 -3
View File
@@ -14,7 +14,10 @@ macro_rules! agent_entries {
$($acc)*
// ── AI 助手(右栗 + 独立页共用)
AgentTitle => ("Assistant", "助手"),
AgentSubtitle => ("Conversation shell · no backend yet", "会话外壳 · 后端未接"),
// M119 r1 复审 P2-1:这条一度被改成中性的「会话」,理由是「跑什么后端由端点标签说」——
// 但端点标签在壳里根本不存在,于是默认配置下用户看到的是一个像是接上了模型的助手。
// 现在把诚实的话写回来,而且它是**常驻**的(标题下就一行,不点也能看到)。
AgentSubtitle => ("Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
AgentCollapse => ("Collapse", "收起"),
AgentDetach => ("Pop out", "拆出"),
AgentDock => ("Dock back", "收回"),
@@ -22,9 +25,11 @@ macro_rules! agent_entries {
AgentStripLabel => ("AI", "助手"),
AgentSend => ("Send", "发送"),
AgentStop => ("Stop", "中断"),
AgentPlaceholder => ("Message the assistant…", "给助手发消息…"),
// 输入框上再说一次:用户真要动手的位置就在这儿
AgentPlaceholder => ("Message the simulated assistant…", "给模拟助手发消息…"),
AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"),
AgentEmptyBody => ("Ask about instances, downloads or logs. No model is wired up yet.", "可以问实例、下载或日志。模型还没接上。"),
// 空会话是最容易被误读的一屏(没有人说过话,很容易以为是「连上了但还没问」)
AgentEmptyBody => ("Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"),
AgentUnread => ("{count} unread", "{count} 条未读"),
@@ -33,6 +38,27 @@ macro_rules! agent_entries {
AgentRoleYou => ("You", "你"),
AgentRoleAssistant => ("Assistant", "助手"),
AgentRoleNotice => ("Notice", "提示"),
// ── M119:失败态与写操作确认(接线后才有;`AgentRail::error` / `rail.confirm`)
//
// 为什么必须成 key:任务要求「presenter 出错时 UI 显示**具名**错误」,
// 而 M106 立的规矩是界面文案一律走 catalog——没有键就只能硬编码中英。
AgentErrorTitle => ("Assistant error", "助手出错"),
AgentErrorUnavailable => ("Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
AgentErrorNotImplemented => ("This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
AgentConfirmRequest => ("The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
AgentConfirmAllow => ("Allow", "允许"),
AgentConfirmDeny => ("Deny", "拒绝"),
// 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」
AgentSimulated => ("Simulated", "模拟"),
// ── M119 r2:三处文案的**真实态**配对(模拟态用上面那三条已有的 key)
//
// 复审 r2 的 P2-1:上一轮只有标题胶囊由 `simulated` 驱动,副标题/占位符/空态正文是
// 无条件取词,而那三条译文里写着「模拟 / 没接真实模型」——接上真 transport 之后
// 这三句会**反方向**地留着。现在配成对:`simulated == true` 走模拟态,
// `false` 走这三条中性话(`pages/agent/tests/disclaimer.rs` 钉住四处一起变)。
AgentSubtitleReal => ("Conversation", "会话"),
AgentPlaceholderReal => ("Message the assistant…", "给助手发消息…"),
AgentEmptyBodyReal => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
} }
};
}
+16 -35
View File
@@ -1,36 +1,7 @@
//! 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 的逐字快照)。
//!
//! ## 它是什么
//!
//! M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
//! 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
//! 再原封不动抄进来(生成方式见 `catalog/tests.rs` 里 `split_keeps_every_translation` 的注释)。
//! M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
//! (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
//!
//! ## 为什么不是「数条数」
//!
//! 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
//! 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
//!
//! | 坏法 | 红在哪 |
//! |---|---|
//! | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
//! | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
//! | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
//! | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
//!
//! **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
//!
//! ## M108 更新了三条、新增了一条(版本管理改名)
//!
//! 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
//! [`super::Key::NavDownload`] / [`super::Key::DownloadTitle`] / [`super::Key::DownloadSubtitle`]
//! 三条译文按令改掉,并**新增** [`super::Key::NavDownloadExpanded`](240 展开态的全标签,
//! 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
//!
//! 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
//! 其余一字未动」,不再是「整表一字未动」。
//! 「为什么有这么一张表、它抓得住什么」写在 `catalog.rs` 中 `mod golden;` 的文档里——
//! M119 r2 把它搬到那儿,是为了给这张表腾出追加新 key 的行数(400 行硬线),一个字的说明都没删。
/// 拆分前 `Key::ALL` 的顺序 × (en, zh-CN)。第一列是变体名(`Key::name()` 的返回值)。
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
@@ -124,7 +95,7 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
("LogsEmptyAction", "Choose an instance", "选择实例"),
("AgentTitle", "Assistant", "助手"),
("AgentSubtitle", "Conversation shell · no backend yet", "会话外壳 · 后端未接"),
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
("AgentCollapse", "Collapse", "收起"),
("AgentDetach", "Pop out", "拆出"),
("AgentDock", "Dock back", "收回"),
@@ -132,9 +103,9 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentStripLabel", "AI", "助手"),
("AgentSend", "Send", "发送"),
("AgentStop", "Stop", "中断"),
("AgentPlaceholder", "Message the assistant…", "给助手发消息…"),
("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"),
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
("AgentEmptyBody", "Ask about instances, downloads or logs. No model is wired up yet.", "可以问实例、下载或日志。模型还没接上。"),
("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
("AgentUnread", "{count} unread", "{count} 条未读"),
@@ -143,6 +114,16 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AgentRoleYou", "You", "你"),
("AgentRoleAssistant", "Assistant", "助手"),
("AgentRoleNotice", "Notice", "提示"),
("AgentErrorTitle", "Assistant error", "助手出错"),
("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
("AgentConfirmAllow", "Allow", "允许"),
("AgentConfirmDeny", "Deny", "拒绝"),
("AgentSimulated", "Simulated", "模拟"),
("AgentSubtitleReal", "Conversation", "会话"),
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
// ── M106 `I18NFOLLOW`:General 分组真控件(语言选择器 + 其余三行)。**只追加在链尾**,
// 上一行到 `LogsEmptyAction` 的既有顺序一字未动(金表要证明的正是这件事)。
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),