Files
Unlucky-Launcher/crates/app-iced/src/pages/agent/mod.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

348 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.
//! # AI 助手(右栗三档 + `Route::Agent` 独立页)
//!
//! 用户定案(D16):「右侧常驻可折叠,允许拆分成独立页」+「中栗宽度可调(磁铁吸附)」。
//! 本模块交付的是**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。真数据来自
//! M103(`presenter::agent::AgentRail`,四个签名见任务上下文)——本 mission **一行都不调**,
//! 会话内容用的是内置样本([`demo`])。
//!
//! ## 文件与职责
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的状态) |
//! | `conversation` | **会话视图组件**:右栗与独立页唯一的对话 UI(输入契约见该文件) |
//! | [`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(报告里登记)。
//!
//! ## 三档状态([`RailState`])
//!
//! | 档位 | 右栗 | 会话在哪 | 切法 |
//! |---|---|---|---|
//! | `Collapsed` | 48 图标条 + 未读点 | 不画 | 点条上的 AI 图标 / `Esc` |
//! | `Expanded` | 280~360(默认 320)+ 8 拖拽缝 | 面板内 | 条上图标 / `⌄` 收起 / 拆出后收回 |
//! | `Detached` | 0 | `Route::Agent` 独立页 | `⇥` 拆出 / 导航第五项 `Assistant` |
//!
//! ## 为什么消息不走 `pages::Message`
//!
//! 助手栏是**外壳级视图**(挂在任何页面右边),不是某一页的消息:塞进 `pages::Message` 要动
//! `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的 M101 改),语义也不对。
//! 所以是一等公民 [`Message`] + `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。
pub(crate) mod demo;
mod metrics;
mod state;
mod conversation;
#[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::{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 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;
/// 助手栏消息(**唯一**的消息类型;`app/view.rs` 用 `Message::Agent(..)` 包一层)。
#[derive(Debug, Clone)]
pub enum Message {
/// 折叠条 → 展开。
Expand,
/// `⌄` 收起 / 独立页上 `Esc`。
Collapse,
/// `⇥` 拆成独立页(同时压一层 `Route::Agent`)。
Detach,
/// `⇤` 收回右栗(离开 `Route::Agent`)。
Dock,
/// 输入框的编辑动作(由 `text_editor::on_action` 来)。
Edit(iced::widget::text_editor::Action),
/// 发送(`Send` 键 / 输入框里按 Enter)。
Submit,
/// 中断流式回复。
Stop,
/// 按下拖拽缝。
DragStart,
/// 拖拽中:窗口坐标里的指针 x。
DragTo(f32),
/// 松手。
DragEnd,
/// 键盘:`Esc`(收栗 / 独立页收回)。
Escape,
/// 键盘:`Tab`(走焦点环;`shift` = 反向)。
Tab { shift: bool },
/// 键盘:`Enter`(激活软焦点上的按钮)。
Activate,
/// 原始键盘事件(`app/subscription.rs` 的 `keyboard::listen()` 搬过来的)。
///
/// 订阅层只负责搬运,**解释权**在 `app/update/agent.rs` 的 `on_agent_key`:
/// `Esc` / `Tab` / `Enter` 各自在什么状态下做什么,集中在一个函数里一眼看完。
Key(iced::keyboard::Event),
}
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
///
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
pub fn docked<'a>(
ui: &'a Ui,
agent: &'a AgentUi,
window_width: f32,
) -> Option<Element<'a, Message>> {
// 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条
// (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。
match agent.effective_rail(window_width) {
RailState::Detached => None,
RailState::Collapsed => Some(strip(ui, agent)),
RailState::Expanded => Some(panel(ui, agent, 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> {
let metrics = layout::frame_metrics(pages::PaneKit::Single, window_width);
let body = conversation::view(ui, spec(agent, Host::Page), Catalog::of(LANG));
container(
container(body)
.width(Length::Fixed(metrics.column))
.height(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(metrics.gutter)
.align_x(Alignment::Center)
.align_y(Alignment::Start)
.into()
}
/// 拖拽层:拖拽期间铺满窗口,把指针 x 变成 [`Message::DragTo`]。
///
/// 为什么铺满整窗:`mouse_area::on_move` 只在**它自己的边界内**回报坐标,而拖拽时指针一定会离开
/// 那 8px 的缝隙。铺满整窗后 `Point.x` 就是窗口坐标 ⇒ `目标宽 = 窗口宽 − x`。
/// 松手由同一层的 `on_release` 收尾(否则会一直跟着指针走)。
pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
if !agent.is_dragging() {
return None;
}
Some(
mouse_area(
container(space::horizontal())
.width(Length::Fill)
.height(Length::Fill),
)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_move(|point| Message::DragTo(point.x))
.on_release(Message::DragEnd)
.into(),
)
}
// ---------------------------------------------------------------- 折叠档: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.width(window_width);
let body = container(conversation::view(
ui,
spec(agent, Host::Rail),
Catalog::of(LANG),
))
.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), body]
.width(Length::Shrink)
.height(Length::Fill)
.into()
}
/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖。
fn splitter<'a>(ui: &'a Ui) -> Element<'a, Message> {
let line = container(
rule::vertical::<IcedTheme>(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,
}
}),
)
.width(Length::Fixed(RESIZE_GUTTER))
.height(Length::Fill)
.align_x(Alignment::Center);
mouse_area(line)
.interaction(mouse::Interaction::ResizingHorizontally)
.on_press(Message::DragStart)
.into()
}
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
fn spec<'a>(agent: &'a AgentUi, 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,
host,
// 只有键盘巡航过才画焦点环(鼠标操作时那是噪音,而且会被误读成 tonal 按钮)
focus: agent.keyboard_mode.then_some(agent.focus),
}
}