Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/conversation.rs
T
AskaEth c3f20784a8 feat(app-iced): M104 AGENTRAIL —— 右栗三档 + 布局数学 + 独立页复用同一会话视图
用户定案(D16):右侧常驻可折叠、允许拆分成独立页、中栗宽度可调(磁铁吸附)。
本 mission 只做壳:三档状态 + 布局数学 + 一份会话视图;真数据仍归 M103,一行 presenter 都不调。

- 右栗三档:Collapsed(48) / Expanded(280~360,默认 320,锚=令牌 ai_panel_width) / Detached(Route::Agent);
  会话、草稿、未读在切档时一条不丢(拆出去再收回,话说了一半还在)
- 布局数学:中栗 = 窗口 − 右栗 − 拖拽缝(8),下限 472(= 行宽上限 424 + 两侧页边距);
  右栗一展开,页面按更窄的宽度重新排版(收缩而不是溢出);拖拽磁铁吸附到 280/320/360,超上限夹紧
- 独立页与右栗共用 conversation::view 这一份实现(输入=纯数据,输出=agent::Message)
- 控件:多行输入框(Enter 发送 / Shift+Enter 换行,走 text_editor::key_binding 的 modifiers 分流)、
  发送禁用态、流式中断、未读点 + 可读计数、⌄ 收起 / ⇥ 拆出 / ⇤ 收回;Esc 收栗、Tab 焦点环、Enter 激活
- 文案全部进 crates/i18n 的新域 catalog/agent.rs(+ golden 追加 20 行),零硬编码中文
- 取证:ULL_AGENT_RAIL / ULL_AGENT_FIXTURE / ULL_AGENT_WIDTH(截图走产品 bin,标题栏与三键可见)
- 消息不走 pages::Message(外壳级视图),走 app::Message::Agent(..) → app/update/agent.rs
- 删掉 M68 骨架回落(shell::view + shell/fallback.rs):Route::Agent 现在有真页面

门禁:fmt 0 / clippy(workspace,-D warnings) 0 / pages_demo 0 / app-iced lib 418 条全绿
2026-11-04 00:21:32 +08:00

368 lines
13 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.
//! **会话视图组件**:右栗面板与 `Route::Agent` 独立页的**同一份**对话 UI。
//!
//! ## 输入契约(纯数据入、消息出)
//!
//! ```text
//! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element<Message>
//! ```
//!
//! - **入**:全部是**借用**的纯数据(`&[Turn]` / `&text_editor::Content` / `bool` / 枚举),
//! 组件自己不持有会话状态、不读环境、不碰 presenter(任务里「不许提前调用 M103 的 API」);
//! - **出**:控件树只发 [`super::Message`](`Collapse` / `Detach` / `Dock` / `Submit` / `Stop` /
//! `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被两个宿主
//! 摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。
//!
//! ## 两个宿主只差「外壳」
//!
//! | 宿主 | 谁调用 | 给的宽度 | 差别 |
//! |---|---|---|---|
//! | [`Host::Rail`] | [`super::rail`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 |
//! | [`Host::Page`] | [`super::page`](独立页) | 行宽上限 424 | 头部动作是 `⇤` 收回;标题是页级标题 |
//!
//! **判据不是「长得像」而是「同一个函数」**:`tests::both_hosts_render_the_same_conversation`
//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`mod.rs` 里不许出现第二份气泡/输入框装配。
//!
//! ## 控件与可达性(任务第 4 条逐项)
//!
//! | 要求 | 落点 |
//! |---|---|
//! | 输入框多行 | `text_editor`(真多行,不是 `text_input` 的单行) |
//! | 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」胶囊 |
//! | `⌄` / `⇥` / `⇤` | 头部两个文字按钮,图标只是**前导**,标签一律可读 |
//! | 键盘 | `Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键(订阅在 `app/subscription.rs`) |
use iced::widget::text_editor;
use iced::widget::{column, container, row, rule, scrollable, 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 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;
mod composer;
pub(super) use composer::text_role;
/// 会话视图落在哪种外壳里(只影响头部动作与标题密度,**不影响**对话本体)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Host {
/// 右侧栏(280~360)。
Rail,
/// 独立页(列宽 ≤424)。
Page,
}
/// 会话视图的**输入契约**(见模块文档;全部是借用的纯数据)。
#[derive(Clone, Copy)]
pub struct Conversation<'a> {
/// 会话内容(零条 = 空会话,画引导空态)。
pub transcript: &'a [Turn],
/// 输入框内容(iced 的 `text_editor` 自己持有编辑状态)。
pub draft: &'a text_editor::Content,
/// 是否在流式回复(决定 `Stop` 是否出现、`Send` 是否禁用)。
pub busy: bool,
/// 未读数(>0 时头部画胶囊)。
pub unread: usize,
/// 草稿是否空白(发送键的禁用判据,由 `AgentUi::draft_is_blank` 给)。
pub blank: bool,
/// 三档状态(决定头部画哪组动作)。
pub rail: RailState,
/// 宿主。
pub host: Host,
/// 键盘软焦点(`None` = 用户在用鼠标,**不画焦点环**)。
///
/// 为什么不直接给 `Focus`:焦点环是键盘巡航的可视证据,鼠标操作时还挂着 10% 状态层会让人
/// 以为那是一个 tonal 按钮(r1 出图实测)。`AgentUi::keyboard_mode` 就是这道闸。
pub focus: Option<Focus>,
}
/// 画会话视图(**右栗与独立页的唯一实现**)。
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)
};
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()
}
// ---------------------------------------------------------------- 头部
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),
]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center);
let titles = column![
title,
text_role(
ui,
TypeRole::LabelMedium,
false,
catalog.text(Key::AgentSubtitle),
ui.color(Role::OnSurfaceVariant),
Length::Fill,
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill);
// 独立页的头部与页面同一套(标题在左、动作在右);右栗太窄,动作单独一行,免得挤成两截。
match spec.host {
Host::Rail => column![
titles,
row![actions(ui, spec, catalog)]
.width(Length::Fill)
.spacing(ui.space(Space::X2)),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.into(),
Host::Page => row![
titles,
row![actions(ui, spec, catalog)]
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center),
]
.width(Length::Fill)
.align_y(iced::Alignment::Center)
.into(),
}
}
/// 头部动作:`⌄` 收起 / `⇥` 拆出(停靠时)或 `⇤` 收回(独立页)。
///
/// 每个按钮都带**文字标签**(图标只做前导):任务要求「不得只有图标」。焦点环用
/// `Interaction::Focused`(组件的 10% 状态层),键序见 [`Focus`]。
fn actions<'a>(ui: &'a Ui, spec: Conversation<'a>, catalog: Catalog) -> Element<'a, Message> {
let mut items: Vec<Element<'a, Message>> = Vec::new();
match spec.rail {
RailState::Collapsed => items.push(label_button(
ui,
catalog.text(Key::AgentOpen),
IconKind::ChevronRight,
Some(Message::Expand),
spec.focus == Some(Focus::Collapse),
)),
RailState::Expanded => {
items.push(label_button(
ui,
catalog.text(Key::AgentCollapse),
IconKind::ChevronDown,
Some(Message::Collapse),
spec.focus == Some(Focus::Collapse),
));
items.push(label_button(
ui,
catalog.text(Key::AgentDetach),
IconKind::Expand,
Some(Message::Detach),
spec.focus == Some(Focus::Detach),
));
}
RailState::Detached => items.push(label_button(
ui,
catalog.text(Key::AgentDock),
IconKind::ChevronLeft,
Some(Message::Dock),
spec.focus == Some(Focus::Detach),
)),
}
row(items)
.spacing(ui.space(Space::X2))
.align_y(iced::Alignment::Center)
.into()
}
fn label_button<'a>(
ui: &'a Ui,
label: &'a str,
icon: IconKind,
on_press: Option<Message>,
focused: bool,
) -> Element<'a, Message> {
let button = match on_press {
Some(message) => Button::new(label, message).leading(icon),
None => Button::disabled(label).leading(icon),
};
buttons::text(ui, focused_state(button, focused))
}
/// 焦点环:键盘走到这一格时叠 10% 的 `Focused` 状态层(组件库的取证状态也是它)。
fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> {
if focused {
button.state(Interaction::Focused)
} else {
button
}
}
fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a, Message> {
if unread == 0 {
return space::horizontal().into();
}
let count = unread.to_string();
let label = catalog.render(Key::AgentUnread, &[("count", count.as_str())]);
let style = ui.style(TypeRole::LabelMedium);
container(
text(label)
.size(style.size_px)
.font(ui.font_of(style))
.color(ui.color(Role::OnSecondaryContainer)),
)
.padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)]))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::SecondaryContainer))),
border: Border {
radius: ui.radius(ShapeScale::Full, ui.metrics().control_height / 2.0),
..Border::default()
},
..container::Style::default()
})
.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)
.style(move |_theme| rule::Style {
color: ui.color(Role::OutlineVariant),
radius: ui.radius_dp(ShapeScale::None),
fill_mode: rule::FillMode::Full,
snap: true,
})
.into()
}