feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳

M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。

## 接线层(新增 `crates/app-iced/src/presenter/`)

| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |

物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。

## 事件回流

`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。

## 会话内容只留一份

`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。

## 视图

- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
  **写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。

## 未读口径

presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。

## 账户页

账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。

## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)

- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
  确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
  M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。

顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
This commit is contained in:
AskaEth committed 2026-11-05 08:02:58 +08:00
1 parent 6505493d4e
commit d4fd675935
25 files changed
+2390 -698

No files matched your search

+264
View File
@@ -0,0 +1,264 @@
//! AI 助手栏的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。
//!
//! ## 它替掉的是什么
//!
//! M104 交付的壳是**自带样本会话**的(`pages/agent/demo.rs` 的 `transcript()`,`Submit` 只往
//! 本地数组里追加一条「没发给模型」的提示)。M119 把这些全部删掉:会话内容的唯一来源是
//! [`Presenter::agent_rail`],壳里的 `Submit` / `Stop` / `Confirm` / 标记已读**全部**翻成
//! presenter 的那四个签名——**壳里一行会话状态都不留**。
//!
//! | 壳里的动作 | 落到哪 |
//! |---|---|
//! | 输入框打字 | [`AgentHost::set_draft`] → `Presenter::agent_set_draft`(切形态不丢) |
//! | 发送 | [`AgentHost::submit`] → `Presenter::agent_submit` |
//! | 打开栏 / 停在独立页 | [`AgentHost::mark_read`] → `Presenter::agent_mark_read` |
//! | 允许/拒绝写操作 | [`AgentHost::confirm`] → `Presenter::agent_confirm` |
//! | 事件回流 | [`AgentHost::apply`] → `Presenter::apply`(唯一状态写入口) |
//! | 栏可见性 | [`AgentHost::sync_visibility`] → `Presenter::navigate` + `prefs_mut` |
//!
//! ## 为什么句柄里还要持一份队列与失败槽
//!
//! presenter 不持有定时器,也不认识 iced(crate 文档 §4)。异步后端的事件只能落在**进程内队列**
//! 里由 UI 抽;失败原因同理(`AgentTransport::submit` 只回一个 `String`,而 UI 要按**原因码**
//! 选 i18n 文案,不能去匹配错误串)。两个 `Arc<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,
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)),
queue,
failure,
last_failure: None,
synced: None,
}
}
/// 按 `ULL_AGENT_SCRIPT` 选脚本(没设 = `qa`)。
pub(crate) fn from_env() -> Self {
Self::with_script(Script::from_env())
}
/// 当前会话视图模型(**唯一**读入口;两形态共享同一份)。
pub(crate) fn rail(&self) -> &AgentRail {
self.presenter.agent_rail()
}
/// 最近一次提交失败的具名原因。
pub(crate) fn last_failure(&self) -> Option<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);
}
}