Merge branch 'feat/m119-apiscan-wire-presenter-accounts-and'
This commit is contained in:
commit
4a212760b6
31 files changed
+3466
-1125
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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"), "折叠条上没画标签");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
"工具收尾 + 确认收敛 + 回复"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.", "可以问实例、下载或日志。"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", "跟随系统"),
|
||||
|
||||
Reference in new issue
Block a user