Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/conversation/transcript.rs
T
AskaEth d4fd675935 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 与失败横幅。
2026-11-05 08:02:58 +08:00

125 lines
4.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 对话本体:空态 + 消息气泡(M119 从 `conversation.rs` 拆出来守 400 行硬线)。
//!
//! ## 数据从哪来
//!
//! 直接画 presenter 的 `AgentRail::messages`(`ChatMessage`):`role` 决定角色小标与气泡容器,
//! `text` 是正文(**已经过 presenter 的第二道脱敏闸门** `scrub_text`),`tool` 是工具调用条目
//! (D4:工具调用会话可见)。壳里不再有 `Speaker`/`Turn` 那种自己的会话类型——那是一份要跟
//! presenter 对齐的镜像,M119 已经删掉。
use iced::widget::{column, container, scrollable};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use unlucky_presenter::dto::{ChatMessage, ChatRole};
use crate::app::components::feedback::empty_state::{empty_state, EmptyState};
use crate::pages::frame;
use crate::theme::Ui;
use super::super::Message;
use super::{text_role, text_role_owned, Conversation};
/// 空会话:引导空态(零条消息时替代气泡列表)。
pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, Message> {
container(empty_state(
ui,
EmptyState::new(
IconKind::Ai,
catalog.text(Key::AgentEmptyTitle),
catalog.text(Key::AgentEmptyBody),
),
))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
/// 气泡列表(可滚动)。
pub(super) fn list<'a>(
ui: &'a Ui,
spec: Conversation<'a>,
catalog: Catalog,
) -> Element<'a, Message> {
let mut items = column![].spacing(ui.space(Space::X3)).width(Length::Fill);
for message in spec.messages {
items = items.push(bubble(ui, message, catalog));
}
scrollable(container(items).padding(Padding::from([ui.space(Space::X1), 0.0])))
.width(Length::Fill)
.height(Length::Fill)
.style(frame::scroll_style(ui))
.into()
}
/// 一条消息:角色小标 + 正文。三种容器(用户 = `SecondaryContainer`、助手/工具 = 卡片层、
/// 系统提示 = 无容器只留文字),气泡整体右/左对齐。
fn bubble<'a>(ui: &'a Ui, message: &'a ChatMessage, catalog: Catalog) -> Element<'a, Message> {
let (role, fill, ink, align) = match message.role {
ChatRole::User => (
Key::AgentRoleYou,
Some(ui.color(Role::SecondaryContainer)),
ui.color(Role::OnSecondaryContainer),
Alignment::End,
),
// 工具条目也挂在助手名下:它是助手这一轮做的事,不是第三方说话人
ChatRole::Assistant | ChatRole::Tool => (
Key::AgentRoleAssistant,
Some(ui.layer(MaterialLayer::Card)),
ui.color(Role::OnSurface),
Alignment::Start,
),
ChatRole::System => (
Key::AgentRoleNotice,
None,
ui.color(Role::OnSurfaceVariant),
Alignment::Start,
),
};
// 工具条目:正文是结果摘要,名字前缀一行(`tool.name`,与 ToolSpec 注册表同名)。
// 两者都来自 presenter 的事件,已过脱敏;这里只拼展示串。
let body = match &message.tool {
Some(tool) => format!("{} · {}", tool.name, tool.summary),
None => message.text.clone(),
};
let inner = column![
text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(role),
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
text_role_owned(ui, TypeRole::BodyMedium, false, body, ink, Length::Fill),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
let card = container(inner)
.width(Length::Fill)
.padding(Padding::from([ui.space(Space::X2), ui.space(Space::X3)]))
.style(move |_theme| container::Style {
background: fill.map(Background::Color),
border: if fill.is_some() {
ui.outlined(ShapeScale::Medium)
} else {
Border::default()
},
..container::Style::default()
});
container(card).width(Length::Fill).align_x(align).into()
}