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:
1 parent
6505493d4e
commit
d4fd675935
25 files changed
+2390
-698
No files matched your search
@@ -3,24 +3,26 @@
|
||||
//! ## 输入契约(纯数据入、消息出)
|
||||
//!
|
||||
//! ```text
|
||||
//! Conversation { transcript, draft, busy, unread, blank, rail, host, focus } ──view()──▶ Element<Message>
|
||||
//! Conversation { messages, busy, unread, confirm, failure, draft, blank, rail, host, focus }
|
||||
//! ──view()──▶ Element<Message>
|
||||
//! ```
|
||||
//!
|
||||
//! - **入**:全部是**借用**的纯数据(`&[Turn]` / `&text_editor::Content` / `bool` / 枚举),
|
||||
//! 组件自己不持有会话状态、不读环境、不碰 presenter(任务里「不许提前调用 M103 的 API」);
|
||||
//! - **入**:全部是**借用**的纯数据。会话三件(`messages` / `busy` / `unread` / `confirm`)
|
||||
//! 直接从 presenter 的 `AgentRail` 借来(M119),组件自己不持有会话状态;`failure` 是 app 层
|
||||
//! 备好的**展示输入**(标题是 catalog 键、正文是已脱敏串),见 `super::Failure`。
|
||||
//! - **出**:控件树只发 [`super::Message`](`Collapse` / `Detach` / `Dock` / `Submit` / `Stop` /
|
||||
//! `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被两个宿主
|
||||
//! 摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。
|
||||
//! `Confirm` / `Edit` / `Expand`),app 层再包一层 `Message::Agent(..)`。于是同一份视图可以被
|
||||
//! 两个宿主摆在不同宽度里,而「点了发生什么」只有一处实现(`app/update/agent.rs`)。
|
||||
//!
|
||||
//! ## 两个宿主只差「外壳」
|
||||
//!
|
||||
//! | 宿主 | 谁调用 | 给的宽度 | 差别 |
|
||||
//! |---|---|---|---|
|
||||
//! | [`Host::Rail`] | [`super::rail`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 |
|
||||
//! | [`Host::Rail`] | [`super::docked`](右栗面板) | 280~360(本组件吃 Fill) | 头部动作是 `⌄` 收起 + `⇥` 拆出 |
|
||||
//! | [`Host::Page`] | [`super::page`](独立页) | 行宽上限 424 | 头部动作是 `⇤` 收回;标题是页级标题 |
|
||||
//!
|
||||
//! **判据不是「长得像」而是「同一个函数」**:`tests::both_hosts_render_the_same_conversation`
|
||||
//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`mod.rs` 里不许出现第二份气泡/输入框装配。
|
||||
//! 用源码扫描钉住「两个宿主都只通过 [`view`] 画对话」,`super::mod` 里不许出现第二份气泡/输入框装配。
|
||||
//!
|
||||
//! ## 控件与可达性(任务第 4 条逐项)
|
||||
//!
|
||||
@@ -30,34 +32,37 @@
|
||||
//! | 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」胶囊 |
|
||||
//! | 未读点 | 折叠条上的圆点([`super::strip`])+ 面板头部的「N unread」胶囊 |
|
||||
//! | 失败态 | [`banners::failure`]:具名标题(catalog)+ 已脱敏正文(M119) |
|
||||
//! | 写操作确认 | [`banners::confirm`]:请求摘要 + Allow / Deny 两个文字按钮(M119) |
|
||||
//! | `⌄` / `⇥` / `⇤` | 头部两个文字按钮,图标只是**前导**,标签一律可读 |
|
||||
//! | 键盘 | `Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键(订阅在 `app/subscription.rs`) |
|
||||
|
||||
use iced::widget::text_editor;
|
||||
use iced::widget::{column, container, row, rule, scrollable, space, text};
|
||||
use iced::widget::{column, container, row, rule, 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 unlucky_presenter::dto::{ChatMessage, ConfirmRequest};
|
||||
|
||||
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;
|
||||
use super::state::{Focus, RailState};
|
||||
use super::{Failure, Message};
|
||||
|
||||
mod banners;
|
||||
mod composer;
|
||||
mod transcript;
|
||||
|
||||
pub(super) use composer::text_role;
|
||||
pub(super) use composer::{text_role, text_role_owned};
|
||||
|
||||
/// 会话视图落在哪种外壳里(只影响头部动作与标题密度,**不影响**对话本体)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -71,16 +76,20 @@ pub enum Host {
|
||||
/// 会话视图的**输入契约**(见模块文档;全部是借用的纯数据)。
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Conversation<'a> {
|
||||
/// 会话内容(零条 = 空会话,画引导空态)。
|
||||
pub transcript: &'a [Turn],
|
||||
/// 会话内容(presenter 的 `AgentRail::messages`,零条 = 空会话,画引导空态)。
|
||||
pub messages: &'a [ChatMessage],
|
||||
/// 输入框内容(iced 的 `text_editor` 自己持有编辑状态)。
|
||||
pub draft: &'a text_editor::Content,
|
||||
/// 是否在流式回复(决定 `Stop` 是否出现、`Send` 是否禁用)。
|
||||
/// 是否在流式回复 / 有待确认写操作(来自 rail 的 `busy`:决定 `Stop` 出现与 `Send` 禁用)。
|
||||
pub busy: bool,
|
||||
/// 未读数(>0 时头部画胶囊)。
|
||||
/// 未读数(>0 时头部画胶囊;来自 rail 的 `unread`)。
|
||||
pub unread: usize,
|
||||
/// 草稿是否空白(发送键的禁用判据,由 `AgentUi::draft_is_blank` 给)。
|
||||
pub blank: bool,
|
||||
/// 待确认的写操作(`Some` = 画确认条)。
|
||||
pub confirm: Option<&'a ConfirmRequest>,
|
||||
/// 失败横幅(`Some` = 画具名错误;见 [`Failure`])。
|
||||
pub failure: Option<Failure<'a>>,
|
||||
/// 三档状态(决定头部画哪组动作)。
|
||||
pub rail: RailState,
|
||||
/// 宿主。
|
||||
@@ -94,23 +103,30 @@ pub struct Conversation<'a> {
|
||||
|
||||
/// 画会话视图(**右栗与独立页的唯一实现**)。
|
||||
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)
|
||||
};
|
||||
let mut body = column![header(ui, spec, catalog), divider(ui)]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
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()
|
||||
// 失败横幅放在会话**上方**:错误是这一轮的结果,比历史更需要先看见(M119)。
|
||||
if let Some(failure) = spec.failure {
|
||||
body = body.push(banners::failure(ui, failure, catalog));
|
||||
}
|
||||
|
||||
body = body.push(if spec.messages.is_empty() {
|
||||
transcript::empty(ui, catalog)
|
||||
} else {
|
||||
transcript::list(ui, spec, catalog)
|
||||
});
|
||||
|
||||
// 确认条贴着输入区(动作在下面,读完就能按)。
|
||||
if let Some(request) = spec.confirm {
|
||||
body = body.push(banners::confirm(ui, request, catalog));
|
||||
}
|
||||
|
||||
body.push(divider(ui))
|
||||
.push(composer::composer(ui, spec, catalog))
|
||||
.into()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 头部
|
||||
@@ -244,7 +260,7 @@ fn label_button<'a>(
|
||||
}
|
||||
|
||||
/// 焦点环:键盘走到这一格时叠 10% 的 `Focused` 状态层(组件库的取证状态也是它)。
|
||||
fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> {
|
||||
pub(super) fn focused_state<'a>(button: Button<'a, Message>, focused: bool) -> Button<'a, Message> {
|
||||
if focused {
|
||||
button.state(Interaction::Focused)
|
||||
} else {
|
||||
@@ -279,97 +295,6 @@ fn unread_badge<'a>(ui: &'a Ui, unread: usize, catalog: Catalog) -> Element<'a,
|
||||
.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)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 会话里的两条**横幅**(M119 从 `conversation.rs` 拆出来守 400 行硬线):
|
||||
//! 失败态(具名错误)与写操作确认。
|
||||
//!
|
||||
//! ## 为什么必须存在
|
||||
//!
|
||||
//! 任务书第 4 条:「presenter 返回错误时 UI 必须显示**具名**错误而不是静默无语」。M104 的壳没有
|
||||
//! 任何错误位——`Submit` 只在本地数组追加一句「没发给模型」的提示。现在两条来源都有了落点:
|
||||
//!
|
||||
//! | 来源 | 触发 | 画什么 |
|
||||
//! |---|---|---|
|
||||
//! | `AgentHost::last_failure` | `agent_submit` 返回 `Err`(端点不可达 / 路由未实现) | 标题 = [`Key::AgentErrorTitle`],正文 = 原因码对应的 catalog 译文 |
|
||||
//! | `AgentRail::error` | 后端发 `AgentEvent::Failed`(这一轮回复中止) | 标题同上,正文 = presenter 已脱敏的那句原因 |
|
||||
//!
|
||||
//! 两者都不把后端诊断原文直接上屏:诊断串只进日志(M114 复审 r1 立的口径)。
|
||||
//!
|
||||
//! ## 确认条
|
||||
//!
|
||||
//! `rail.confirm` 非空 ⇒ 一轮写操作在等用户裁决,而 `busy` 会一直锁着输入(presenter 的
|
||||
//! `sync_busy` 语义:`busy = streaming || confirm.is_some()`)。所以这段 UI 不是装饰:
|
||||
//! **不按就不动**。两个按钮都带文字标签(M104 的「不得只有图标」同一条规矩)。
|
||||
|
||||
use iced::widget::{column, container, row, Row};
|
||||
use iced::{Alignment, Background, Element, Length};
|
||||
|
||||
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::ConfirmRequest;
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::{Failure, Message};
|
||||
use super::text_role_owned;
|
||||
|
||||
/// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。
|
||||
pub(super) fn failure<'a>(
|
||||
ui: &'a Ui,
|
||||
failure: Failure<'a>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
banner(
|
||||
ui,
|
||||
catalog.text(failure.title).to_string(),
|
||||
failure.body.to_string(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 写操作确认条:一行请求摘要 + Allow / Deny。
|
||||
pub(super) fn confirm<'a>(
|
||||
ui: &'a Ui,
|
||||
request: &'a ConfirmRequest,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
// 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示,
|
||||
// 否则用户是在盲签。句框走 catalog,摘要本身是正文。
|
||||
let title = catalog.render(
|
||||
Key::AgentConfirmRequest,
|
||||
&[("summary", request.summary.as_str())],
|
||||
);
|
||||
let actions: Vec<Element<'a, Message>> = vec![
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
|
||||
.leading(IconKind::Close),
|
||||
),
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
|
||||
.leading(IconKind::Check),
|
||||
),
|
||||
];
|
||||
banner(ui, title, String::new(), actions)
|
||||
}
|
||||
|
||||
/// 横幅本体:图标块 + 标题/正文 + 右侧动作。
|
||||
///
|
||||
/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边,
|
||||
/// `ErrorContainer` / `OnErrorContainer` 只作左侧图标块的强调,不是通栏红条)。
|
||||
fn banner<'a>(
|
||||
ui: &'a Ui,
|
||||
title: String,
|
||||
body: String,
|
||||
actions: Vec<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let side = ui.space(Space::X8);
|
||||
let glyph_px = ui.space(Space::X6);
|
||||
let chip = container(crate::icons::view_at(
|
||||
IconKind::Error,
|
||||
ui.color(Role::Error),
|
||||
glyph_px,
|
||||
))
|
||||
.width(Length::Fixed(side))
|
||||
.height(Length::Fixed(side))
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
|
||||
border: iced::Border {
|
||||
radius: ui.radius(ShapeScale::Full, side),
|
||||
..iced::Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
let mut lines = column![text_role_owned(
|
||||
ui,
|
||||
TypeRole::TitleSmall,
|
||||
true,
|
||||
title,
|
||||
ui.color(Role::OnSurface),
|
||||
Length::Fill,
|
||||
)]
|
||||
.spacing(ui.space(Space::X1));
|
||||
if !body.is_empty() {
|
||||
lines = lines.push(text_role_owned(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
body,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
Length::Fill,
|
||||
));
|
||||
}
|
||||
|
||||
let mut content = Row::new()
|
||||
.spacing(ui.space(Space::X3))
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill)
|
||||
.push(chip)
|
||||
.push(Element::from(container(lines).width(Length::Fill)));
|
||||
if !actions.is_empty() {
|
||||
content = content.push(Element::from(
|
||||
row(actions)
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),
|
||||
));
|
||||
}
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.padding(ui.metrics().card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: ui.outlined(ShapeScale::Medium),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -120,7 +120,38 @@ pub(super) fn binding_for(press: KeyPress) -> Option<Binding<Message>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
|
||||
/// 一档文字(**借来的**正文)。
|
||||
///
|
||||
/// 与 `pages::frame::text_role` 同源,但那边返回的是 `Element<pages::Message>`,助手栏要自己的
|
||||
/// 消息类型——这是 M104 起就有的分工,不是重复实现。
|
||||
/// 一档文字(**拼出来的**正文:工具条目 `name · summary`、横幅文案都走它)。
|
||||
///
|
||||
/// 为什么不能直接用借用版:正文是 `String`,而 `Element` 不允许借用局部变量。
|
||||
pub(crate) fn text_role_owned<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: String,
|
||||
color: iced::Color,
|
||||
width: Length,
|
||||
) -> Element<'a, Message> {
|
||||
let style = if emphasized {
|
||||
ui.emphasized(role)
|
||||
} else {
|
||||
ui.style(role)
|
||||
};
|
||||
|
||||
text(content)
|
||||
.size(style.size_px)
|
||||
.line_height(iced::widget::text::LineHeight::Absolute(Pixels(
|
||||
style.line_height_px,
|
||||
)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color)
|
||||
.width(width)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(crate) fn text_role<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 对话本体:空态 + 消息气泡(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()
|
||||
}
|
||||
@@ -1,54 +1,54 @@
|
||||
//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `ULL_AGENT_*` 取证开关。
|
||||
//! 取证开关(`ULL_AGENT_*`):三档档位 / 展开宽度 / 会话夹具。
|
||||
//!
|
||||
//! ## 为什么是内置样本而不是 presenter
|
||||
//! ## 与 M104 的区别:这里不再有「样本会话」
|
||||
//!
|
||||
//! 本 mission 只做**壳**:真数据由 M103(`presenter::agent::AgentRail` + 四个签名)供,
|
||||
//! 这里一行 presenter 调用都没有(任务里点名「不许提前调用」)。样本的职责只有两个:
|
||||
//! ①让三档状态在图上都有内容;②让「空会话」有对照([`FIXTURE_ENV`])。
|
||||
//! M104 的 `demo::transcript()` 是**内置会话样本**,右栗出厂就画着四条假对话——那是「壳先搭好、
|
||||
//! 等 M103 接」的过渡态。M119 接线之后会话的唯一来源是 presenter,于是样本只能以**真链路**的
|
||||
//! 方式进来:夹具不造消息,而是**走一次真的提交**(`agent_submit` → transport → 事件队列 →
|
||||
//! `Message::Agent(Events)` → `apply`)。跑什么脚本由 `ULL_AGENT_SCRIPT`(见
|
||||
//! [`crate::app::state::presenter`])决定,夹具只决定「要不要在启动时跑这一轮」。
|
||||
//!
|
||||
//! 样本正文是**演示数据**,与 `pages/instances/data.rs` 的 demo 同一性质:它不是界面文案,
|
||||
//! 不进 i18n 词表(界面上会随真模型一起消失)。界面自己的文案(标题/按钮/空态/提示)全部走 `Key`。
|
||||
//! 于是出厂态是**空会话**(空态引导卡)——这才是没有模型的启动器该有的样子。
|
||||
//!
|
||||
//! ## 取证开关(只影响这一次进程,语义与 `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 一致)
|
||||
//! ## 取值表
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) |
|
||||
//! | [`FIXTURE_ENV`] | `empty` / `streaming` / `unread` / `focused` | 空会话(零消息)/ 流式中(中断键可见)/ 展开但带未读(单独看徽标)/ 键盘焦点环(`Tab` 走过一次的样子) |
|
||||
//! | [`FIXTURE_ENV`] | `turn` / `unread` / `focused` / `empty` | 启动时跑一轮真提交 / 折叠态跑(留未读)/ 键盘焦点环 / 什么都不做 |
|
||||
//! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) |
|
||||
//!
|
||||
//! 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
|
||||
|
||||
use super::metrics::PRESETS;
|
||||
use super::state::{AgentUi, RailState, Speaker, Turn};
|
||||
use super::state::{AgentUi, RailState};
|
||||
|
||||
/// 启动档位。
|
||||
pub const RAIL_ENV: &str = "ULL_AGENT_RAIL";
|
||||
/// 会话样本(`empty` / `streaming`)。
|
||||
/// 会话夹具(`turn` / `unread` / `focused` / `empty`)。
|
||||
pub const FIXTURE_ENV: &str = "ULL_AGENT_FIXTURE";
|
||||
/// 展开档宽度(只接受三档预设值)。
|
||||
pub const WIDTH_ENV: &str = "ULL_AGENT_WIDTH";
|
||||
|
||||
/// 内置会话样本(4 条,两类说话人 + 一条工具提示)。
|
||||
pub fn transcript() -> Vec<Turn> {
|
||||
vec![
|
||||
Turn::new(Speaker::You, "Which instance should I start for a 1.20.4 test?"),
|
||||
Turn::new(
|
||||
Speaker::Assistant,
|
||||
"Survival is on 1.20.4 with Fabric 0.16.5 and its files are present. Redstone Lab is the same \
|
||||
version but its folder is missing a jar.",
|
||||
),
|
||||
Turn::new(Speaker::You, "Use Survival then. Anything I should know first?"),
|
||||
Turn::new(
|
||||
Speaker::Assistant,
|
||||
"Java 21 is already adopted for that instance, and the last launch exited normally. \
|
||||
Say the word and I will queue it.",
|
||||
),
|
||||
]
|
||||
/// 夹具:启动时对**真 presenter** 做什么。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Fixture {
|
||||
/// 什么都不做(出厂态:空会话)。`ULL_AGENT_FIXTURE=empty` 显式写出来也一样。
|
||||
None,
|
||||
/// 跑一轮真提交(脚本由 `ULL_AGENT_SCRIPT` 决定:问答 / 断流 / 写操作 / 两种失败)。
|
||||
Turn,
|
||||
/// 折叠态跑一轮真提交:于是未读会真的被 presenter 累加(不是硬编码的数字)。
|
||||
Unread,
|
||||
/// 键盘巡航一次的样子(焦点环 = 10% 状态层)。
|
||||
Focused,
|
||||
}
|
||||
|
||||
/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。
|
||||
///
|
||||
/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
|
||||
pub fn from_env() -> Option<AgentUi> {
|
||||
/// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容,
|
||||
/// 不进 i18n 词表——接上真模型之后它就不存在了)。
|
||||
pub const DEMO_QUESTION: &str = "Which instance should I start for a 1.20.4 test?";
|
||||
|
||||
/// 按环境变量构造一份 UI 状态 + 夹具(没设任何变量 = `None`,调用方用出厂态)。
|
||||
pub fn from_env() -> Option<(AgentUi, Fixture)> {
|
||||
let rail = std::env::var(RAIL_ENV).ok();
|
||||
let fixture = std::env::var(FIXTURE_ENV).ok();
|
||||
let width = std::env::var(WIDTH_ENV).ok();
|
||||
@@ -57,11 +57,7 @@ pub fn from_env() -> Option<AgentUi> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fixture = fixture.as_deref().map(str::trim);
|
||||
let mut ui = match fixture {
|
||||
Some("empty") => AgentUi::empty(),
|
||||
_ => AgentUi::new(),
|
||||
};
|
||||
let mut ui = AgentUi::new();
|
||||
|
||||
if let Some(raw) = width.as_deref().map(str::trim) {
|
||||
match raw.parse::<f32>() {
|
||||
@@ -70,13 +66,9 @@ pub fn from_env() -> Option<AgentUi> {
|
||||
}
|
||||
}
|
||||
|
||||
if fixture == Some("streaming") {
|
||||
ui.pretend_streaming();
|
||||
}
|
||||
|
||||
// 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义
|
||||
// (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。
|
||||
// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(见模块文档的表)。
|
||||
// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(折叠态跑一轮)。
|
||||
match rail.as_deref().map(str::trim) {
|
||||
None => ui.expand(),
|
||||
Some(name) => match RailState::from_name(name) {
|
||||
@@ -90,24 +82,43 @@ pub fn from_env() -> Option<AgentUi> {
|
||||
},
|
||||
}
|
||||
|
||||
if fixture == Some("unread") {
|
||||
ui.unread = 3;
|
||||
}
|
||||
|
||||
if fixture == Some("focused") {
|
||||
// 键盘巡航一次的样子(焦点环 = 10% 状态层)。真窗口里按一次 `Tab` 就会到这一态,
|
||||
// 但截图通道收不到键盘输入,所以只能这样取证。
|
||||
ui.step_focus(false);
|
||||
}
|
||||
let fixture = match fixture.as_deref().map(str::trim) {
|
||||
Some("turn") => Fixture::Turn,
|
||||
Some("unread") => Fixture::Unread,
|
||||
Some("focused") => Fixture::Focused,
|
||||
Some("empty") | None => Fixture::None,
|
||||
Some(other) => {
|
||||
eprintln!("[agent] {FIXTURE_ENV}={other:?} 不是夹具之一,按 empty 处理");
|
||||
Fixture::None
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[agent] rail={} width={} turns={} busy={} unread={}",
|
||||
"[agent] rail={} width={} fixture={fixture:?}",
|
||||
ui.rail.name(),
|
||||
ui.expanded(),
|
||||
ui.transcript.len(),
|
||||
ui.busy,
|
||||
ui.unread
|
||||
);
|
||||
|
||||
Some(ui)
|
||||
Some((ui, fixture))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_demo_question_is_plain_ascii_so_a_clean_ci_has_the_glyphs() {
|
||||
assert!(
|
||||
DEMO_QUESTION.is_ascii(),
|
||||
"夹具正文不带非 ascii(干净 CI 无中文字体)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_rail_state_name_round_trips_through_the_env_parser() {
|
||||
for state in RailState::ALL {
|
||||
assert_eq!(RailState::from_name(state.name()), Some(state));
|
||||
}
|
||||
assert_eq!(RailState::from_name("nope"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,27 @@
|
||||
//! # AI 助手(右栗三档 + `Route::Agent` 独立页)
|
||||
//!
|
||||
//! 用户定案(D16):「右侧常驻可折叠,允许拆分成独立页」+「中栗宽度可调(磁铁吸附)」。
|
||||
//! 本模块交付的是**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。真数据来自
|
||||
//! M103(`presenter::agent::AgentRail`,四个签名见任务上下文)——本 mission **一行都不调**,
|
||||
//! 会话内容用的是内置样本([`demo`])。
|
||||
//! 本模块交付**壳**:三档状态、布局数学、两个宿主共用的一份会话视图。
|
||||
//!
|
||||
//! **会话数据来自 presenter**(M119):唯一来源是 `app/state.rs` 里的 `agent_host` →
|
||||
//! `AgentRail`。壳里不再有样本会话、也不再自己数未读——M104 当时的 `demo::transcript()`
|
||||
//! 只剩「截图取证要不要自动跑一轮」这一件事(见 [`demo`])。
|
||||
//!
|
||||
//! ## 文件与职责
|
||||
//!
|
||||
//! | 文件 | 职责 |
|
||||
//! |---|---|
|
||||
//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的状态) |
|
||||
//! | [`state`] | 三档状态机 + **布局数学**(纯函数,可脱机单测)+ [`AgentUi`](挂在 `App` 上的纯 UI 态) |
|
||||
//! | `conversation` | **会话视图组件**:右栗与独立页唯一的对话 UI(输入契约见该文件) |
|
||||
//! | [`demo`] | 占位样本会话 + `ULL_AGENT_*` 取证开关 |
|
||||
//! | [`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(报告里登记)。
|
||||
//! M104 的 scope 给了 `pages/agent/**`,但**没给** `pages/mod.rs`(那是四条页面线共用的声明文件)。
|
||||
//! 于是 `shell/mod.rs` 用 `#[path = "../pages/agent/mod.rs"]` 挂载它——与 `app/mod.rs` 挂
|
||||
//! `../components/mod.rs` 是同一个先例。**没有第二份实现**:物理目录只有这一处。
|
||||
//!
|
||||
//! ## 三档状态([`RailState`])
|
||||
//!
|
||||
@@ -36,37 +34,39 @@
|
||||
//! ## 为什么消息不走 `pages::Message`
|
||||
//!
|
||||
//! 助手栏是**外壳级视图**(挂在任何页面右边),不是某一页的消息:塞进 `pages::Message` 要动
|
||||
//! `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的 M101 改),语义也不对。
|
||||
//! 所以是一等公民 [`Message`] + `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。
|
||||
//! `app/update/page_messages.rs` 的穷尽 match,语义也不对。所以是一等公民 [`Message`] +
|
||||
//! `app::Message::Agent(..)` 一次映射,落点 `app/update/agent.rs`。
|
||||
|
||||
pub(crate) mod demo;
|
||||
mod metrics;
|
||||
mod state;
|
||||
|
||||
mod conversation;
|
||||
mod strip;
|
||||
|
||||
#[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::widget::{column, container, mouse_area, row, rule, space, 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 unlucky_presenter::agent::AgentRail;
|
||||
use unlucky_presenter::dto::AppEvent;
|
||||
|
||||
use crate::pages::{self, layout};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use conversation::{Conversation, Host};
|
||||
use metrics::{COLLAPSED_WIDTH, RESIZE_GUTTER};
|
||||
use metrics::RESIZE_GUTTER;
|
||||
|
||||
/// 界面语言(与产品其它页面同一口径:i18n 接线前是英文基线,D28 的「英文环境零中文负担」)。
|
||||
const LANG: Lang = Lang::En;
|
||||
@@ -86,8 +86,16 @@ pub enum Message {
|
||||
Edit(iced::widget::text_editor::Action),
|
||||
/// 发送(`Send` 键 / 输入框里按 Enter)。
|
||||
Submit,
|
||||
/// 中断流式回复。
|
||||
/// 中断流式回复(`AppCommand::StopAgent`;收敛靠后端事件)。
|
||||
Stop,
|
||||
/// 允许 / 拒绝当前待确认的写操作(`rails.confirm` 非空时才有按钮)。
|
||||
Confirm(bool),
|
||||
/// **事件回流**(M119):presenter 侧产生的 `AppEvent` 整批回到 `update` 的那条通道。
|
||||
///
|
||||
/// 为什么是消息而不是直接改状态:异步后端在后台线程里产事件,UI 只能在节拍里抽;
|
||||
/// 抽出来的这一批必须和「用户点击」走同一条漏斗(`on_agent`),否则两处各改一次状态,
|
||||
/// 未读/流式标记会打架。落点见 `app/update/agent.rs` 的 `Events` 分支。
|
||||
Events(Vec<AppEvent>),
|
||||
/// 按下拖拽缝。
|
||||
DragStart,
|
||||
/// 拖拽中:窗口坐标里的指针 x。
|
||||
@@ -107,30 +115,56 @@ pub enum Message {
|
||||
Key(iced::keyboard::Event),
|
||||
}
|
||||
|
||||
/// 错误横幅的**展示输入**(M119「失败态可见」的唯一落点)。
|
||||
///
|
||||
/// 为什么要包一层而不是把 `String` 传进来:标题必须是 catalog 键(`lang` 说哪种语言就哪种),
|
||||
/// 正文则有两种合法来源——具名原因码对应的 catalog 译文(端不可达 / 未实现),或 presenter
|
||||
/// 已经脱敏过的这一轮错误原文(`AgentRail::error`)。两者都是「已 sanitize 的展示串」,
|
||||
/// 页面不再加工(UI 层不复制脱敏逻辑)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Failure<'a> {
|
||||
/// 横幅标题的 catalog 键。
|
||||
pub title: Key,
|
||||
/// 横幅正文(已脱敏 / 已是译文)。
|
||||
pub body: &'a str,
|
||||
}
|
||||
|
||||
/// 停靠档的右栗(`None` = 已拆成独立页,右栗不占地)。
|
||||
///
|
||||
/// 两档共用同一条 `Element` 通道交给外壳的 `row!`:折叠是 48 的条,展开是「8 拖拽缝 + 面板」。
|
||||
pub fn docked<'a>(
|
||||
ui: &'a Ui,
|
||||
agent: &'a AgentUi,
|
||||
rail: &'a AgentRail,
|
||||
failure: Option<Failure<'a>>,
|
||||
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)),
|
||||
RailState::Collapsed => Some(strip::strip(ui, rail)),
|
||||
RailState::Expanded => Some(panel(ui, agent, rail, failure, 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> {
|
||||
pub fn page<'a>(
|
||||
ui: &'a Ui,
|
||||
agent: &'a AgentUi,
|
||||
rail: &'a AgentRail,
|
||||
failure: Option<Failure<'a>>,
|
||||
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));
|
||||
let body = conversation::view(
|
||||
ui,
|
||||
spec(agent, rail, failure, Host::Page),
|
||||
Catalog::of(LANG),
|
||||
);
|
||||
|
||||
container(
|
||||
container(body)
|
||||
@@ -178,137 +212,35 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option<Element<'a, Message>> {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 折叠档: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> {
|
||||
fn panel<'a>(
|
||||
ui: &'a Ui,
|
||||
agent: &'a AgentUi,
|
||||
rail: &'a AgentRail,
|
||||
failure: Option<Failure<'a>>,
|
||||
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()
|
||||
});
|
||||
let body = container(conversation::view(
|
||||
ui,
|
||||
spec(agent, rail, failure, 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)
|
||||
@@ -366,12 +298,20 @@ fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message
|
||||
}
|
||||
|
||||
/// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。
|
||||
fn spec<'a>(agent: &'a AgentUi, host: Host) -> Conversation<'a> {
|
||||
fn spec<'a>(
|
||||
agent: &'a AgentUi,
|
||||
rail: &'a AgentRail,
|
||||
failure: Option<Failure<'a>>,
|
||||
host: Host,
|
||||
) -> Conversation<'a> {
|
||||
Conversation {
|
||||
transcript: &agent.transcript,
|
||||
// 会话内容、未读、busy、待确认:**全部**来自 presenter(M119)
|
||||
messages: &rail.messages,
|
||||
busy: rail.busy,
|
||||
unread: rail.unread,
|
||||
confirm: rail.confirm.as_ref(),
|
||||
failure,
|
||||
draft: &agent.draft,
|
||||
busy: agent.busy,
|
||||
unread: agent.unread,
|
||||
blank: agent.draft_is_blank(),
|
||||
rail: agent.rail,
|
||||
host,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`AgentUi`](挂在 `App` 上的 UI 状态)。
|
||||
//! 右栗的**纯 UI 状态**:三档状态机 + 展开宽度 + 草稿 + 键盘焦点环 + [`AgentUi`]。
|
||||
//!
|
||||
//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「状态怎么变」。
|
||||
//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「UI 状态怎么变」。
|
||||
//!
|
||||
//! ## 会话内容为什么不在这个文件里(M119)
|
||||
//!
|
||||
//! M104 的 `AgentUi` 自带 `transcript` / `busy` / `unread` 三个字段,数据来自内置样本
|
||||
//! ([`super::demo`])——那是「壳先搭好、等 M103 接」的过渡态。M119 把这三个字段**删掉**:
|
||||
//! 会话的唯一来源是 presenter 的 `AgentRail`(`app/state.rs` 的 `agent_host`),右栗与独立页
|
||||
//! 直接读它。于是在这个文件里再放一份就是第二份真相,而两份真相必然漂。
|
||||
//!
|
||||
//! ## 三档状态(用户定案 D16)
|
||||
//!
|
||||
@@ -10,21 +17,15 @@
|
||||
//! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 |
|
||||
//! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 |
|
||||
//!
|
||||
//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,`transcript` / `draft` /
|
||||
//! `busy` / `unread` 一条都不丢(「拆出去再接回来,话说了一半还在」)。
|
||||
//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,草稿与焦点一条都不丢
|
||||
//! (会话本身在 presenter 上,三档共享得比原来更彻底)。
|
||||
//!
|
||||
//! ## 为什么状态是 `App` 的字段
|
||||
//! ## 未读 / busy 从哪来
|
||||
//!
|
||||
//! 这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,状态挂在 `App` 上最自然。
|
||||
//! 早先想过 `OnceLock<Mutex<..>>`(当时 `App` 字段不在 scope),tower 明确否掉:那要测试串行化、
|
||||
//! 而且不是 per-App。
|
||||
//!
|
||||
//! **持久化欠账**:`presenter::prefs::UiPrefs` 里的键不在本 mission 的 scope,所以三档与吸附宽度
|
||||
//! **只在本次进程内有效**(重启回到折叠 / 320)。接手点:`UiPrefs` 加两个键 + `App::new` 读一次,
|
||||
//! 本文件的 [`AgentUi::rail`] / [`AgentUi::set_expanded`] 就是那两个落点。
|
||||
//! 都在 `AgentRail` 上:未读由 presenter 按「栏可见吗」累加、`agent_mark_read()` 清零;
|
||||
//! `busy` 由「有流式消息 || 有写操作待确认」算出来。壳里**不再自己数**未读。
|
||||
|
||||
use iced::widget::text_editor;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use super::metrics::{
|
||||
content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH,
|
||||
@@ -64,36 +65,6 @@ impl RailState {
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话里的一个说话人。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Speaker {
|
||||
/// 用户。
|
||||
You,
|
||||
/// 助手(占位样本数据;真模型由 M103 的 presenter 供)。
|
||||
Assistant,
|
||||
/// 系统提示(占位说明 / 中断记录)。
|
||||
Notice,
|
||||
}
|
||||
|
||||
/// 一条会话消息(**纯数据**:视图只读它,不改它)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Turn {
|
||||
/// 说话人。
|
||||
pub speaker: Speaker,
|
||||
/// 正文(多行;空会话时不会有 `Turn`)。
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Turn {
|
||||
/// 造一条。
|
||||
pub fn new(speaker: Speaker, text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
speaker,
|
||||
text: text.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘软焦点顺序(Tab 环)。顺序 = 屏幕上的阅读顺序:头部动作 → 输入区动作 → 输入框。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Focus {
|
||||
@@ -143,6 +114,8 @@ impl Focus {
|
||||
}
|
||||
|
||||
/// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。
|
||||
///
|
||||
/// `busy` 由 presenter 的 rail 给(壳里不自己数):流式中环上多一格 `Stop`。
|
||||
pub fn step(self, shift: bool, state: RailState, busy: bool) -> Focus {
|
||||
let order = Self::order(state, busy);
|
||||
let Some(at) = order.iter().position(|focus| *focus == self) else {
|
||||
@@ -165,14 +138,8 @@ pub struct AgentUi {
|
||||
pub rail: RailState,
|
||||
/// 展开档的吸附宽度(三档共享一个值:收起再展开回到同一宽度)。
|
||||
expanded: f32,
|
||||
/// 会话内容(占位样本数据,真模型由 M103 接)。
|
||||
pub transcript: Vec<Turn>,
|
||||
/// 输入框(多行;iced 的 `text_editor` 自己持有编辑状态,这里只放内容)。
|
||||
pub draft: text_editor::Content,
|
||||
/// 是否在流式回复(`false` = 没有中断键、发送键可用)。
|
||||
pub busy: bool,
|
||||
/// 未读数(折叠条画点 + 一行可读文字;展开/拆出即清零)。
|
||||
pub unread: usize,
|
||||
/// 键盘软焦点。
|
||||
pub focus: Focus,
|
||||
/// 焦点环是否「被键盘走过」(`Tab` 置 true,鼠标点输入框 / 换档置 false)。
|
||||
@@ -186,7 +153,7 @@ pub struct AgentUi {
|
||||
}
|
||||
|
||||
impl AgentUi {
|
||||
/// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。
|
||||
/// 出厂态:**折叠** + 空草稿 + 无未读(未读由 presenter 按事件累加,M119)。
|
||||
///
|
||||
/// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够,
|
||||
/// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。
|
||||
@@ -194,24 +161,13 @@ impl AgentUi {
|
||||
Self {
|
||||
rail: RailState::Collapsed,
|
||||
expanded: DEFAULT_EXPANDED_WIDTH,
|
||||
transcript: super::demo::transcript(),
|
||||
draft: text_editor::Content::new(),
|
||||
busy: false,
|
||||
unread: 2,
|
||||
focus: Focus::Composer,
|
||||
keyboard_mode: false,
|
||||
pointer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 空会话(出图矩阵的「零消息」那一张:清空 transcript 与未读)。
|
||||
pub fn empty() -> Self {
|
||||
let mut ui = Self::new();
|
||||
ui.transcript.clear();
|
||||
ui.unread = 0;
|
||||
ui
|
||||
}
|
||||
|
||||
/// 当前展开宽度(吸附后的值)。
|
||||
pub fn expanded(&self) -> f32 {
|
||||
self.expanded
|
||||
@@ -249,7 +205,8 @@ impl AgentUi {
|
||||
content_width(window_width, self.rail, self.expanded)
|
||||
}
|
||||
|
||||
/// 收起(`⌄` / `Esc`)。未读不清零:收起后新消息仍要能显示成点。
|
||||
/// 收起(`⌄` / `Esc`)。未读**不**清零:收起后新消息仍要能显示成点(清零由 app 层在
|
||||
/// 「打开栏」时调 `agent_mark_read` 做——那是 presenter 的语义,不是 UI 的)。
|
||||
pub fn collapse(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
if self.rail == RailState::Expanded {
|
||||
@@ -260,19 +217,17 @@ impl AgentUi {
|
||||
}
|
||||
}
|
||||
|
||||
/// 展开(点图标条)。展开即视为读过:未读清零(与主流聊天客户端的「打开即读」同口径)。
|
||||
/// 展开(点图标条)。展开即视为读过——**清零动作在 app 层**(`mark_read`),这里只改档位。
|
||||
pub fn expand(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
self.rail = RailState::Expanded;
|
||||
self.unread = 0;
|
||||
self.focus = Focus::Collapse;
|
||||
}
|
||||
|
||||
/// 拆成独立页(`⇥`)。会话状态一条不动。
|
||||
/// 拆成独立页(`⇥`)。UI 状态一条不动(会话在 presenter 上,本来就不归这里管)。
|
||||
pub fn detach(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
self.rail = RailState::Detached;
|
||||
self.unread = 0;
|
||||
self.focus = Focus::Detach;
|
||||
}
|
||||
|
||||
@@ -280,7 +235,6 @@ impl AgentUi {
|
||||
pub fn dock(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
self.rail = RailState::Expanded;
|
||||
self.unread = 0;
|
||||
self.focus = Focus::Collapse;
|
||||
}
|
||||
|
||||
@@ -288,8 +242,7 @@ impl AgentUi {
|
||||
///
|
||||
/// 这里**不**分 Enter / Shift+Enter:分流在 `conversation::composer::binding_for`(非 shift 的
|
||||
/// Enter 根本不会变成 `Action`,它直接发 `Message::Submit`)。所以走到这里的 `Edit(Enter)`
|
||||
/// 一定是「Shift+Enter 换行」那一支,`perform` 就够——早先这里有一段与之等价的 `if` 分支,
|
||||
/// 读起来像关键拦截,实际是冗余(复审 P3-2 指出后删掉)。
|
||||
/// 一定是「Shift+Enter 换行」那一支,`perform` 就够。
|
||||
pub fn edit(&mut self, action: text_editor::Action) {
|
||||
use iced::widget::text_editor::Action;
|
||||
|
||||
@@ -301,44 +254,9 @@ impl AgentUi {
|
||||
self.draft.perform(action);
|
||||
}
|
||||
|
||||
/// 发送:把草稿记成一条「你」的消息 + 一条占位说明。
|
||||
///
|
||||
/// **不接后端**(M103 的 presenter API 不在本 mission 里调用):所以既不改 `busy`,
|
||||
/// 也不假装有回复——只追加一条**说明自己没接模型**的提示。返回是否真的发了。
|
||||
pub fn submit(&mut self, catalog: Catalog) -> bool {
|
||||
let text = self.draft.text();
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() || self.busy {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.transcript
|
||||
.push(Turn::new(Speaker::You, trimmed.to_owned()));
|
||||
self.transcript.push(Turn::new(
|
||||
Speaker::Notice,
|
||||
catalog.text(Key::AgentNotePending),
|
||||
));
|
||||
/// 清空输入框(发送成功之后调:草稿已经交给 presenter 了)。
|
||||
pub fn clear_draft(&mut self) {
|
||||
self.draft = text_editor::Content::new();
|
||||
true
|
||||
}
|
||||
|
||||
/// 中断流式回复(`Stop`)。占位期只在**视图态**上停:记一条提示,不派任何业务命令。
|
||||
pub fn stop(&mut self, catalog: Catalog) {
|
||||
if !self.busy {
|
||||
return;
|
||||
}
|
||||
self.busy = false;
|
||||
self.transcript.push(Turn::new(
|
||||
Speaker::Notice,
|
||||
catalog.text(Key::AgentNoteStopped),
|
||||
));
|
||||
}
|
||||
|
||||
/// 一键置为「流式中」(出图矩阵的 `streaming` 态与 `Stop` 键要靠它;真流式由 M103 驱动)。
|
||||
pub fn pretend_streaming(&mut self) {
|
||||
self.busy = true;
|
||||
self.rail = RailState::Expanded;
|
||||
self.unread = 0;
|
||||
}
|
||||
|
||||
/// 输入框是否为空(发送键的禁用判据)。
|
||||
@@ -365,9 +283,9 @@ impl AgentUi {
|
||||
self.pointer = None;
|
||||
}
|
||||
|
||||
/// Tab 走一格(走一次就把键盘巡航打开)。
|
||||
pub fn step_focus(&mut self, shift: bool) {
|
||||
self.focus = self.focus.step(shift, self.rail, self.busy);
|
||||
/// Tab 走一格(走一次就把键盘巡航打开)。`busy` 由 presenter 的 rail 给。
|
||||
pub fn step_focus(&mut self, shift: bool, busy: bool) {
|
||||
self.focus = self.focus.step(shift, self.rail, busy);
|
||||
self.keyboard_mode = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! 折叠档:48px 图标条(M119 从 `mod.rs` 拆出来守 400 行硬线)。
|
||||
//!
|
||||
//! 条上三行:AI 图标(带未读点)+ 小字标签 + 未读计数。为什么未读既有点又有数字:48px 宽
|
||||
//! 装不下 `2 unread` 那 8 个字符(实测贴到窗角被裁),所以条上只放「有几条」,完整那句
|
||||
//! (`{count} unread`)在面板头部的胶囊里。零未读时给一个等高留白,免得条上元素上下跳。
|
||||
//!
|
||||
//! 未读数来自 presenter 的 `AgentRail::unread`(M119 起壳里不再自己数)。
|
||||
|
||||
use iced::widget::{column, container, space, stack, text, tooltip};
|
||||
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::agent::AgentRail;
|
||||
|
||||
use crate::app::components::buttons::{self, IconVariant};
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::metrics::COLLAPSED_WIDTH;
|
||||
use super::{conversation, Message};
|
||||
|
||||
/// 折叠条。
|
||||
pub(super) fn strip<'a>(ui: &'a Ui, rail: &'a AgentRail) -> Element<'a, Message> {
|
||||
let catalog = Catalog::of(super::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 rail.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, rail.unread),
|
||||
])
|
||||
.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()
|
||||
}
|
||||
|
||||
/// 未读计数(零 = 等高留白,免得条上元素上下跳)。
|
||||
fn unread_text<'a>(ui: &'a Ui, unread: usize) -> 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()
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
//! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。
|
||||
//!
|
||||
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。
|
||||
//!
|
||||
//! M119 起这里的重心变了:会话内容不再由本模块持有,所以「提交会怎样」这类端到端断言搬到了
|
||||
//! `app/update/agent/tests.rs`(那里能拿到 presenter 句柄)。这里留的是**纯 UI 态**与**视图契约**。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use unlucky_presenter::agent::AgentRail;
|
||||
use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
mod drag;
|
||||
|
||||
use super::conversation::{Conversation, Host};
|
||||
use super::state::{AgentUi, Focus, RailState, Speaker, Turn};
|
||||
use super::state::{AgentUi, Focus, RailState};
|
||||
use super::*;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
@@ -24,24 +30,47 @@ pub(super) fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 三档迁移:会话与草稿跟着走、未读按「打开即读」清零。
|
||||
/// 一条只给视图用的 rail(会话内容由 presenter 生产,这里只造**渲染输入**)。
|
||||
fn rail(messages: Vec<ChatMessage>, unread: usize, busy: bool) -> AgentRail {
|
||||
AgentRail {
|
||||
messages,
|
||||
unread,
|
||||
busy,
|
||||
..AgentRail::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_rail() -> AgentRail {
|
||||
rail(
|
||||
vec![
|
||||
ChatMessage::user("Which instance should I start?"),
|
||||
ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: "Survival is on 1.20.4.".to_string(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
},
|
||||
],
|
||||
2,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// 三档迁移:**草稿与焦点**跟着走(会话在 presenter 上,本来就不归这里管)。
|
||||
///
|
||||
/// 抓得住:拆出去再收回,**transcript 与 draft** 不丢(`busy` 不在这条测试的断言里);
|
||||
/// `expand()/detach()/dock()` 都把 `unread` 清零;`collapse()` 不清(收起后新消息还要能点亮)。
|
||||
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看),也不测 `busy` 的迁移。
|
||||
/// 抓得住:`expand()/detach()/dock()` 把档位改对且不清草稿;`collapse()` 对 Detached 等价于 `dock()`。
|
||||
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。
|
||||
#[test]
|
||||
fn the_three_states_share_one_conversation() {
|
||||
fn the_three_states_share_one_draft() {
|
||||
let mut agent = AgentUi::new();
|
||||
assert_eq!(agent.rail, RailState::Collapsed, "出厂是折叠档");
|
||||
assert_eq!(agent.unread, 2, "出厂带两条未读(折叠条上要有点)");
|
||||
|
||||
agent.expand();
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
assert_eq!(agent.unread, 0, "展开即读");
|
||||
|
||||
// 走 `AgentUi::edit`(= app 层真正的落点),而不是直接操作 iced 的 `Content`:
|
||||
// 走到这里的 `Edit(Enter)` 只可能是 Shift+Enter(非 shift 的 Enter 在 composer 的
|
||||
// `key_binding` 里就变成 `Message::Submit` 了,见 composer 的 `enter_sends_and_shift_enter_breaks_the_line`)。
|
||||
// `key_binding` 里就变成 `Message::Submit` 了)。
|
||||
agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival");
|
||||
agent.edit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Enter,
|
||||
@@ -49,21 +78,19 @@ fn the_three_states_share_one_conversation() {
|
||||
assert_eq!(
|
||||
agent.draft.line_count(),
|
||||
2,
|
||||
"多行:一条 Edit(Enter) 经过 `edit()` 之后草稿变成两行"
|
||||
"多行:一条 Edit(Enter) 之后成两行"
|
||||
);
|
||||
|
||||
let turns = agent.transcript.len();
|
||||
let draft = agent.draft.text();
|
||||
agent.detach();
|
||||
assert_eq!(agent.rail, RailState::Detached);
|
||||
assert_eq!(agent.transcript.len(), turns, "拆出去不丢会话");
|
||||
assert_eq!(agent.draft.text(), draft, "拆出去不丢草稿");
|
||||
|
||||
agent.dock();
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
assert_eq!(agent.draft.text(), draft);
|
||||
|
||||
// 收起 / Esc:只改档位,不清未读记录(下一批消息仍会点亮)
|
||||
// 收起 / Esc:只改档位
|
||||
agent.collapse();
|
||||
assert_eq!(agent.rail, RailState::Collapsed);
|
||||
// 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock)
|
||||
@@ -72,59 +99,22 @@ fn the_three_states_share_one_conversation() {
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
}
|
||||
|
||||
/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。
|
||||
/// 草稿只有两个写口:`edit`(打字)与 `clear_draft`(发送成功之后)。
|
||||
#[test]
|
||||
fn submit_and_stop_leave_a_visible_notice() {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
fn sending_clears_the_draft_and_blank_is_trimmed() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
|
||||
assert!(agent.draft_is_blank());
|
||||
assert!(
|
||||
!agent.submit(catalog),
|
||||
"空草稿发不出去(发送键同时是禁用态)"
|
||||
);
|
||||
|
||||
agent.draft = iced::widget::text_editor::Content::with_text(" ");
|
||||
assert!(agent.draft_is_blank(), "只有空白也算空(发送键是禁用态)");
|
||||
agent.draft = iced::widget::text_editor::Content::with_text(" hello ");
|
||||
let before = agent.transcript.len();
|
||||
assert!(agent.submit(catalog));
|
||||
assert_eq!(agent.transcript.len(), before + 2, "一条「你」+ 一条提示");
|
||||
assert_eq!(agent.transcript[before].speaker, Speaker::You);
|
||||
assert_eq!(agent.transcript[before].text, "hello", "两侧空白被剪掉");
|
||||
assert_eq!(agent.transcript[before + 1].speaker, Speaker::Notice);
|
||||
assert_eq!(
|
||||
agent.transcript[before + 1].text,
|
||||
catalog.text(Key::AgentNotePending)
|
||||
);
|
||||
assert!(!agent.draft_is_blank());
|
||||
agent.clear_draft();
|
||||
assert!(agent.draft_is_blank(), "发出去后草稿清空");
|
||||
|
||||
// 流式中:发送键禁用、中断键出现;中断也留一条提示
|
||||
agent.pretend_streaming();
|
||||
assert!(agent.busy);
|
||||
agent.draft = iced::widget::text_editor::Content::with_text("second");
|
||||
assert!(!agent.submit(catalog), "流式中不许再发");
|
||||
let before = agent.transcript.len();
|
||||
agent.stop(catalog);
|
||||
assert!(!agent.busy);
|
||||
assert_eq!(agent.transcript.len(), before + 1);
|
||||
assert_eq!(
|
||||
agent.transcript[before].text,
|
||||
catalog.text(Key::AgentNoteStopped)
|
||||
);
|
||||
|
||||
// 没在流式时按中断是 no-op(不刷屏)
|
||||
let before = agent.transcript.len();
|
||||
agent.stop(catalog);
|
||||
assert_eq!(agent.transcript.len(), before);
|
||||
}
|
||||
|
||||
/// 空会话:零消息 + 一句引导文案(任务第 7 条)。
|
||||
#[test]
|
||||
fn the_empty_session_has_a_guidance_line() {
|
||||
let empty = AgentUi::empty();
|
||||
assert!(empty.transcript.is_empty(), "空会话 = 零消息");
|
||||
assert_eq!(empty.unread, 0);
|
||||
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
assert!(!catalog.text(Key::AgentEmptyTitle).is_empty());
|
||||
assert!(
|
||||
@@ -165,12 +155,17 @@ fn both_hosts_render_the_same_conversation() {
|
||||
);
|
||||
assert!(
|
||||
!hosts.contains("scrollable("),
|
||||
"滚动区的装配只许在 conversation.rs 里"
|
||||
"滚动区的装配只许在 conversation/transcript.rs 里"
|
||||
);
|
||||
assert!(
|
||||
composer.contains("text_editor(") || composer.contains("editor(spec.draft)"),
|
||||
"输入框就在 composer 里"
|
||||
);
|
||||
// M119:会话内容的唯一来源是 presenter 的 rail ⇒ 宿主把 rail 借进来,而不是自己造
|
||||
assert!(
|
||||
!hosts.contains("Vec<ChatMessage>") && !hosts.contains("Vec<Turn>"),
|
||||
"宿主里不许出现自持的会话容器"
|
||||
);
|
||||
}
|
||||
|
||||
/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
|
||||
@@ -190,6 +185,13 @@ fn every_action_has_a_readable_label() {
|
||||
Key::AgentStripLabel,
|
||||
Key::AgentResize,
|
||||
Key::AgentWidthLabel,
|
||||
// M119 新增:确认条的两个动作 + 失败横幅的标题
|
||||
Key::AgentConfirmAllow,
|
||||
Key::AgentConfirmDeny,
|
||||
Key::AgentConfirmRequest,
|
||||
Key::AgentErrorTitle,
|
||||
Key::AgentErrorUnavailable,
|
||||
Key::AgentErrorNotImplemented,
|
||||
] {
|
||||
assert!(
|
||||
!catalog.text(key).trim().is_empty(),
|
||||
@@ -201,6 +203,9 @@ fn every_action_has_a_readable_label() {
|
||||
for source in [
|
||||
include_str!("conversation.rs"),
|
||||
include_str!("conversation/composer.rs"),
|
||||
include_str!("conversation/banners.rs"),
|
||||
include_str!("conversation/transcript.rs"),
|
||||
include_str!("strip.rs"),
|
||||
include_str!("mod.rs"),
|
||||
] {
|
||||
for literal in ["Button::new(\"", "Button::disabled(\""] {
|
||||
@@ -217,7 +222,7 @@ fn every_action_has_a_readable_label() {
|
||||
|
||||
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
|
||||
assert!(
|
||||
include_str!("mod.rs").contains("Key::AgentStripLabel"),
|
||||
include_str!("strip.rs").contains("Key::AgentStripLabel"),
|
||||
"折叠条必须有可读的条上标签"
|
||||
);
|
||||
}
|
||||
@@ -249,7 +254,7 @@ fn the_focus_ring_walks_the_controls_that_exist() {
|
||||
Focus::Composer
|
||||
);
|
||||
|
||||
// 流式中多一档中断键
|
||||
// 流式中多一档中断键(`busy` 由 presenter 的 rail 给)
|
||||
assert_eq!(
|
||||
Focus::Send.step(false, RailState::Expanded, true),
|
||||
Focus::Stop
|
||||
@@ -270,50 +275,78 @@ fn the_focus_ring_walks_the_controls_that_exist() {
|
||||
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
|
||||
let mut agent = AgentUi::new();
|
||||
assert!(!agent.keyboard_mode);
|
||||
agent.step_focus(false);
|
||||
agent.step_focus(false, false);
|
||||
assert!(agent.keyboard_mode);
|
||||
}
|
||||
|
||||
/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。
|
||||
/// 视图契约:`spec()` 把 presenter 的三件事**原样借**给视图(零拷贝、不复制)。
|
||||
///
|
||||
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。
|
||||
/// 抓得住:有人在 `spec()` 里换掉/派生一份会话(那就会有两份真相)。
|
||||
/// 抓不住:`Conversation` 字段在视图里怎么用(那要人读 conversation.rs)。
|
||||
#[test]
|
||||
fn the_spec_borrows_the_presenter_rail_verbatim() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
let rail = conversation_rail();
|
||||
let spec = super::spec(&agent, &rail, None, Host::Rail);
|
||||
assert_eq!(spec.messages.len(), rail.messages.len());
|
||||
assert_eq!(spec.messages[0].text, rail.messages[0].text, "同一条消息");
|
||||
assert_eq!(spec.unread, rail.unread);
|
||||
assert_eq!(spec.busy, rail.busy);
|
||||
assert!(spec.failure.is_none());
|
||||
}
|
||||
|
||||
/// 两个宿主都能构图(含空会话 / 有会话 / 流式中 / 待确认 / 失败横幅),且吃 880~2560 的宽度。
|
||||
///
|
||||
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空消息 + 非零未读、确认条 + 失败横幅同时出现)。
|
||||
/// 抓不住:像素级观感(出图另算)。
|
||||
#[test]
|
||||
fn every_state_builds_in_both_hosts() {
|
||||
let ui = ui();
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
let empty = AgentRail::new();
|
||||
let full = conversation_rail();
|
||||
let busy = rail(conversation_rail().messages, 0, true);
|
||||
let failed = AgentRail {
|
||||
error: Some("provider 断流:read timeout".to_string()),
|
||||
..conversation_rail()
|
||||
};
|
||||
let mut confirm_rail = conversation_rail();
|
||||
confirm_rail.confirm = Some(ConfirmRequest {
|
||||
id: "req-1".to_string(),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "把下载并发 64 → 16".to_string(),
|
||||
details: Default::default(),
|
||||
sensitivity: Sensitivity::High,
|
||||
});
|
||||
let failure = Failure {
|
||||
title: Key::AgentErrorTitle,
|
||||
body: "The assistant backend is unreachable.",
|
||||
};
|
||||
|
||||
for window in [880.0, 900.0, 1180.0, 2560.0] {
|
||||
for rail in RailState::ALL {
|
||||
for empty in [false, true] {
|
||||
let mut agent = if empty {
|
||||
AgentUi::empty()
|
||||
} else {
|
||||
AgentUi::new()
|
||||
};
|
||||
agent.rail = rail;
|
||||
let _: iced::Element<'_, Message> = page(&ui, &agent, window);
|
||||
|
||||
let spec = Conversation {
|
||||
transcript: &agent.transcript,
|
||||
draft: &agent.draft,
|
||||
busy: agent.busy,
|
||||
unread: agent.unread,
|
||||
blank: agent.draft_is_blank(),
|
||||
rail: agent.rail,
|
||||
host: Host::Rail,
|
||||
focus: agent.keyboard_mode.then_some(agent.focus),
|
||||
};
|
||||
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
|
||||
let _ = docked(&ui, &agent, window);
|
||||
for rail in [&empty, &full, &busy, &failed, &confirm_rail] {
|
||||
for state in RailState::ALL {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.rail = state;
|
||||
for shown in [None, Some(failure)] {
|
||||
let _: iced::Element<'_, Message> = page(&ui, &agent, rail, shown, window);
|
||||
let _ = docked(&ui, &agent, rail, shown, window);
|
||||
let spec = Conversation {
|
||||
messages: &rail.messages,
|
||||
draft: &agent.draft,
|
||||
busy: rail.busy,
|
||||
unread: rail.unread,
|
||||
blank: agent.draft_is_blank(),
|
||||
confirm: rail.confirm.as_ref(),
|
||||
failure: shown,
|
||||
rail: agent.rail,
|
||||
host: Host::Rail,
|
||||
focus: agent.keyboard_mode.then_some(agent.focus),
|
||||
};
|
||||
let _: iced::Element<'_, Message> = conversation::view(&ui, spec, catalog);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话数据结构不许在手边偷偷长字段(`Turn` 只有说话人 + 正文)。
|
||||
#[test]
|
||||
fn a_turn_is_only_a_speaker_and_a_body() {
|
||||
let turn = Turn::new(Speaker::You, "hi");
|
||||
assert_eq!((turn.speaker, turn.text.as_str()), (Speaker::You, "hi"));
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//!
|
||||
//! 拆的位置是「指针/几何」这一族:拖拽三条收尾路径、吸附宽度写回、缝的预算、缝与折叠条的标签。
|
||||
|
||||
use unlucky_presenter::agent::AgentRail;
|
||||
|
||||
use super::super::metrics::{layout_for, PRESETS, RESIZE_GUTTER};
|
||||
use super::super::state::{AgentUi, RailState};
|
||||
|
||||
@@ -30,7 +32,7 @@ fn the_detached_state_frees_the_rail_slot() {
|
||||
let ui = ui();
|
||||
let mut agent = AgentUi::new();
|
||||
agent.detach();
|
||||
assert!(docked(&ui, &agent, 1180.0).is_none());
|
||||
assert!(docked(&ui, &agent, &AgentRail::new(), None, 1180.0).is_none());
|
||||
assert_eq!(agent.rail_width(1180.0), 0.0);
|
||||
assert_eq!(agent.content_width(1180.0), 1180.0);
|
||||
}
|
||||
@@ -127,11 +129,10 @@ fn the_drag_recovers_when_a_release_is_missed() {
|
||||
#[test]
|
||||
fn the_gutter_and_strip_are_labelled() {
|
||||
let hosts = include_str!("../mod.rs");
|
||||
for key in [
|
||||
"Key::AgentResize",
|
||||
"Key::AgentWidthLabel",
|
||||
"Key::AgentStripLabel",
|
||||
] {
|
||||
let strip = include_str!("../strip.rs");
|
||||
for key in ["Key::AgentResize", "Key::AgentWidthLabel"] {
|
||||
assert!(hosts.contains(key), "视图里没用上 {key}");
|
||||
}
|
||||
// M119:折叠条整段搬去了 `strip.rs`(`mod.rs` 顶到 400 行)
|
||||
assert!(strip.contains("Key::AgentStripLabel"), "折叠条上没画标签");
|
||||
}
|
||||
Reference in new issue
Block a user