//! # 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> { // 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 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`。 /// /// **三条收尾路径**(复审 P3-7:`mouse_area` 在 `!cursor.is_over` 时直接 return,窗**外**松手 /// 的 `on_release` 永远不来,于是叠层会留在最上层、右栗继续跟着指针缩放): /// /// | 路径 | 触发 | 语义 | /// |---|---|---| /// | `on_release` | 窗内松手 | 正常结束 | /// | `on_exit` | 指针离开窗口 | 「拖到窗外」= 收在当前值(不是把指针丢掉继续跟) | /// | `on_press` | 拖拽中再按一次 | 兜底清态(万一上两条都没来,一次点击就能恢复) | pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { 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) .on_press(Message::DragEnd) .on_exit(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.rail_width(window_width); let catalog = Catalog::of(LANG); 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() }); row![splitter(ui, width, catalog), body] .width(Length::Shrink) .height(Length::Fill) .into() } /// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。 /// /// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key /// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出 /// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。 fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message> { let line = container( rule::vertical::(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); let grab = mouse_area(line) .interaction(mouse::Interaction::ResizingHorizontally) .on_press(Message::DragStart); let tip_style = ui.style(TypeRole::BodySmall); let pixels = format!("{width:.0}"); let tip = container(column![ text(catalog.text(Key::AgentResize)) .size(tip_style.size_px) .font(ui.font_of(tip_style)) .color(ui.color(Role::OnSurface)), text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())])) .size(tip_style.size_px) .font(ui.font_of(tip_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.layer(MaterialLayer::Overlay))), border: Border { radius: ui.radius_dp(ShapeScale::Small), ..Border::default() }, ..container::Style::default() }); tooltip(grab, tip, tooltip::Position::Left).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), } }