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 条全绿
This commit is contained in:
1 parent
875ac159ee
commit
c3f20784a8
20 files changed
+2463
-153
No files matched your search
@@ -42,6 +42,12 @@ pub enum Message {
|
||||
WindowHandle(Option<isize>),
|
||||
/// 页面消息(导航 / 启动 / 停止 / 占位动作)。
|
||||
Page(pages::Message),
|
||||
/// **AI 助手栏 / 独立页**的消息(M104:三档状态、输入框、拖拽、键盘)。
|
||||
///
|
||||
/// 为什么不走 `pages::Message`:助手栏是**外壳级视图**(它挂在任何页面右边),不是某一页的
|
||||
/// 消息;塞进页面枚举还要动 `app/update/page_messages.rs` 的穷尽 match(那个文件正被未合的
|
||||
/// M101 改),绕开它就少一处 rebase 面。落点是 `app/update/agent.rs`。
|
||||
Agent(crate::shell::agent::Message),
|
||||
/// 外壳导航轨点选(`src/shell/nav.rs` 的回调仍然发这一条;收到就翻成同一个路由跳转)。
|
||||
///
|
||||
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
|
||||
|
||||
@@ -125,6 +125,11 @@ pub struct App {
|
||||
/// 本字段与 `app/view.rs` 都不用动(`settings::States` 只看 `&Groups`,不看任何一组的字段)。
|
||||
/// 放在字段列表**末尾**:后续 mission 若也要往这里加字段,冲突面最小。
|
||||
pub(super) settings: pages::settings::Groups,
|
||||
/// AI 助手栏的 UI 状态(M104):右栗三档 + 展开宽度 + 会话/草稿/未读。
|
||||
///
|
||||
/// 挂在 `App` 上而不是全局:这棵树在 `view(&self)` 里只读、在 `update(&mut self)` 里改,
|
||||
/// 状态跟着 App 走才能 per-app、可测(tower 明确否掉了 `OnceLock<Mutex<..>>`)。
|
||||
pub(super) agent: crate::shell::agent::AgentUi,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -209,6 +214,8 @@ impl App {
|
||||
appearance_prefs,
|
||||
hwnd: None,
|
||||
settings: pages::settings::Groups::default(),
|
||||
// 取证开关 `ULL_AGENT_*`(三态矩阵 / 空会话)在这里落地;不设变量 = 出厂态
|
||||
agent: crate::shell::agent::demo::from_env().unwrap_or_default(),
|
||||
};
|
||||
// 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。
|
||||
app.rebuild_ui();
|
||||
@@ -268,7 +275,9 @@ impl App {
|
||||
// 分类是**页面自己持有的过滤条件**(真 host 丢弃 `ListVersions` 的分类参数):
|
||||
// 重建视图模型时带回去,否则每次事件都会把 chip 打回「全部」(复审 P2-1)。
|
||||
let category = self.view.download.category;
|
||||
self.view = super::view::model(&self.state, self.width, self.options.force_state, category);
|
||||
// 右栗占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
|
||||
let width = self.agent.content_width(self.width);
|
||||
self.view = super::view::model(&self.state, width, self.options.force_state, category);
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,19 @@ impl App {
|
||||
window::events().map(|(_, event)| Message::Window(event))
|
||||
};
|
||||
|
||||
Subscription::batch([frames, events])
|
||||
// 键盘(M104):`Esc` 收栗 / `Tab` 走焦点环 / `Enter` 激活焦点键。
|
||||
//
|
||||
// 为什么订阅整条键盘流而不是自己挑键:`iced::keyboard::listen()` 是唯一的全窗键盘入口,
|
||||
// 挑键与「什么状态下该做什么」的解释权放在 `app/update/agent.rs`(一眼看完),
|
||||
// 订阅表只负责搬运。截图通道不开它——D30 要可复现,键盘事件一个都不该进图。
|
||||
let keys = if self.has_screenshot() {
|
||||
Subscription::none()
|
||||
} else {
|
||||
iced::keyboard::listen()
|
||||
.map(|event| Message::Agent(crate::shell::agent::Message::Key(event)))
|
||||
};
|
||||
|
||||
Subscription::batch([frames, events, keys])
|
||||
}
|
||||
|
||||
/// 是否订阅帧事件(= 截图模式要帧数闸门)。
|
||||
|
||||
@@ -203,20 +203,61 @@ fn idle_ticks_do_not_rebuild_the_view_model() {
|
||||
assert!(app.refreshes > before, "导航之后必须重建一次");
|
||||
}
|
||||
/// 视图模型跟着窗口宽度走(交互模式下 600 / 840 两条断点靠 resize 事件生效)。
|
||||
///
|
||||
/// M104 起 `view.window_width` 装的是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝),所以这里按三档
|
||||
/// 各钉一个**字面量**:48 的折叠条与 320 的面板各自的减法都得对。
|
||||
/// (断言里不调 `agent.content_width(..)`——那是同义反复,实现错一处它照样绿。)
|
||||
#[test]
|
||||
fn resizing_the_window_rebuilds_the_view_model() {
|
||||
let mut app = headless(Options::default(), SchemeFlavor::Light);
|
||||
let _ = app.update(Message::Boot);
|
||||
|
||||
// 出厂是 Collapsed(48) 档:中栗 = 窗口 − 48
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(1180.0, 760.0),
|
||||
)));
|
||||
assert!((app.view.window_width - 1180.0).abs() < f32::EPSILON);
|
||||
assert!(
|
||||
(app.view.window_width - 1132.0).abs() < f32::EPSILON,
|
||||
"1180 − 48(折叠条)= 1132,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(700.0, 600.0),
|
||||
)));
|
||||
assert!((app.view.window_width - 700.0).abs() < f32::EPSILON);
|
||||
assert!(
|
||||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||||
"700 − 48(折叠条)= 652,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Expanded(320) 档:中栗 = 窗口 − 320(面板)− 8(拖拽缝)= 852
|
||||
app.agent.expand();
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
(app.view.window_width - 652.0).abs() < f32::EPSILON,
|
||||
"700 上展开档不成立(上限 700 − 88 − 472 − 8 = 132 < 280)⇒ 实际按折叠条 48 算: 700 − 48 = 652,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
let _ = app.update(Message::Window(iced::window::Event::Resized(
|
||||
iced::Size::new(1180.0, 760.0),
|
||||
)));
|
||||
assert!(
|
||||
(app.view.window_width - 852.0).abs() < f32::EPSILON,
|
||||
"1180 − 320 − 8 = 852,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
|
||||
// Detached 档:右栗不占宽,中栗 = 窗口 = 1180
|
||||
app.agent.detach();
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
assert!(
|
||||
(app.view.window_width - 1180.0).abs() < f32::EPSILON,
|
||||
"拆出后右栗不占宽:1180 − 0 = 1180,实际 {}",
|
||||
app.view.window_width
|
||||
);
|
||||
// 页面照旧能构图(窄窗退化到单列,不 panic)
|
||||
let _ = app.view();
|
||||
}
|
||||
|
||||
@@ -40,8 +40,9 @@ use crate::renderer::Kind;
|
||||
|
||||
use super::message::{route_of, Message};
|
||||
use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW};
|
||||
use super::CAPTURE_AT_FRAME;
|
||||
use super::{CAPTURE_AT_FRAME, MIN_SIZE, WINDOW_SIZE};
|
||||
|
||||
mod agent;
|
||||
mod download;
|
||||
mod page_messages;
|
||||
mod settings;
|
||||
@@ -100,6 +101,7 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
Message::Page(message) => self.on_page(message),
|
||||
Message::Agent(message) => self.on_agent(message),
|
||||
// 外壳导航轨(`src/shell/nav.rs`)发来的点选:翻成同一次路由跳转
|
||||
Message::Select(nav) => {
|
||||
self.state.navigate(route_of(nav));
|
||||
@@ -158,6 +160,16 @@ impl App {
|
||||
// 「重新扫描实例目录 + 全部页面快照」:真 host 在这里发首批事件,mock 在构造时已预置
|
||||
self.dispatch(AppCommand::RefreshAll);
|
||||
|
||||
// 窗口尺寸的**唯一真源**是 `boot.rs` 开窗时用的那一个(`ULL_WINDOW_SIZE` + 最小尺寸钳位)。
|
||||
// `App::with_presenter` 只认识常量 1180×760,所以这里 adopt 一次:不 adopt 的话,验收矩阵
|
||||
// 开 900 或 1520 的窗时页面还按 1180 排版(图上就是「窄窗里内容被裁掉」)。
|
||||
let (window_size, _source) = crate::renderer::acceptance::window::window_size(
|
||||
(WINDOW_SIZE.width, WINDOW_SIZE.height),
|
||||
(MIN_SIZE.width, MIN_SIZE.height),
|
||||
);
|
||||
self.width = window_size.0;
|
||||
self.height = window_size.1;
|
||||
|
||||
// 取证开关 `ULL_START_ROUTE`:截图/冒烟直接落到某一页(`cli.rs` 不在本 mission 的 scope,
|
||||
// 而「外观」分组只在设置页上,出矩阵必须能直达)。未知值忽略。
|
||||
if let Some(route) = env_start_route() {
|
||||
@@ -167,6 +179,16 @@ impl App {
|
||||
self.auto_selected = true;
|
||||
}
|
||||
|
||||
// M104:三档状态若从「拆出」开始(`ULL_AGENT_RAIL=detached` 或将来持久化的偏好),
|
||||
// 会话就在独立页里——直接落到那一页,别再被「自动选中第一个实例」拉回详情页。
|
||||
if self.agent.rail == crate::shell::agent::RailState::Detached
|
||||
&& self.state.route() != &Route::Agent
|
||||
{
|
||||
eprintln!("[agent] rail=detached → Route::Agent");
|
||||
self.state.navigate(Route::Agent);
|
||||
self.auto_selected = true;
|
||||
}
|
||||
|
||||
self.auto_select();
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
@@ -209,6 +231,7 @@ impl App {
|
||||
if !self.wants_frames() || self.frames < CAPTURE_AT_FRAME {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match self.window {
|
||||
Some(id) => window::screenshot(id).map(Message::Captured),
|
||||
None => Task::none(),
|
||||
@@ -365,6 +388,8 @@ fn env_start_route() -> Option<Route> {
|
||||
"download" => Some(Route::Download),
|
||||
"logs" => Some(Route::Logs),
|
||||
"settings" => Some(Route::Settings),
|
||||
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
|
||||
"agent" => Some(Route::Agent),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 助手栏消息的落点(M104):三档状态迁移 / 输入 / 拖拽 / 键盘。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `app/update.rs` 已经贴着 400 行硬上限(M94 也因此把下载页四支拆到 `update/download.rs`)。
|
||||
//! 助手栏有 11 个变体,塞回主文件必破线。任务也只允许主文件加一行 `mod agent;`。
|
||||
//!
|
||||
//! ## 分工:视图态 vs 业务命令
|
||||
//!
|
||||
//! | 消息 | 落到哪 |
|
||||
//! |---|---|
|
||||
//! | `Expand` / `Collapse` / `Dock` / `Escape` | **只改视图态**(`AgentUi`)+ 路由栈(离开/进入 `Route::Agent`) |
|
||||
//! | `Detach` | 视图态 + `Route::Agent`(压栈,`back()` 能回去) |
|
||||
//! | `Edit` / `Submit` / `Stop` | 改视图态:草稿、追加一条占位提示 |
|
||||
//! | `DragStart` / `DragTo` / `DragEnd` | 视图态:吸附宽度 |
|
||||
//! | `Key` / `Tab` / `Activate` | 键盘路径 |
|
||||
//!
|
||||
//! **没有任何 `AppCommand` 派发**:本 mission 不接 AI 后端(M103 的四个签名一行都不调)。
|
||||
//! `Submit` 不假装有回复——它只把草稿记进会话 + 追加一条「占位外壳、没发给模型」的提示,
|
||||
//! `Stop` 同理。真接线时这两个分支各加一次 `dispatch`,视图不用动。
|
||||
|
||||
use iced::keyboard::{key, Key};
|
||||
use iced::Task;
|
||||
|
||||
use unlucky_i18n::{Catalog, Lang};
|
||||
|
||||
use crate::shell::agent::{self, Focus, RailState};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl App {
|
||||
/// 助手栏消息(`app::Message::Agent(..)` 的唯一落点)。
|
||||
pub(super) fn on_agent(&mut self, message: agent::Message) -> Task<Message> {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
|
||||
match message {
|
||||
agent::Message::Expand => self.agent.expand(),
|
||||
agent::Message::Collapse | agent::Message::Escape => self.collapse_agent(),
|
||||
agent::Message::Detach => {
|
||||
self.agent.detach();
|
||||
self.state.navigate(Route::Agent);
|
||||
}
|
||||
agent::Message::Dock => {
|
||||
self.agent.dock();
|
||||
self.leave_agent_page();
|
||||
}
|
||||
agent::Message::DragStart => self.agent.drag_start(),
|
||||
agent::Message::DragTo(x) => self.agent.drag_to(self.width, x),
|
||||
agent::Message::DragEnd => self.agent.drag_end(),
|
||||
agent::Message::Edit(action) => self.agent.edit(action),
|
||||
agent::Message::Submit => {
|
||||
if self.agent.submit(catalog) {
|
||||
eprintln!("[agent] submit → 占位(没有接后端)");
|
||||
}
|
||||
}
|
||||
agent::Message::Stop => self.agent.stop(catalog),
|
||||
agent::Message::Tab { shift } => self.agent.step_focus(shift),
|
||||
agent::Message::Activate => self.activate_focused(catalog),
|
||||
agent::Message::Key(event) => return self.on_agent_key(event),
|
||||
}
|
||||
|
||||
// 右栗的宽度/档位都会改「页面能用多宽」,所以视图模型必须重建(不是只重绘)。
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// 键盘订阅来的按键(`app/subscription.rs` 把整条键盘事件搬过来,解释权在这一层)。
|
||||
///
|
||||
/// 为什么不让订阅层直接翻成三个枚举:那样 `Escape`/`Tab`/`Enter` 的语义(什么状态下该做什么)
|
||||
/// 会散在订阅表里;这里一眼能看完。
|
||||
fn on_agent_key(&mut self, event: iced::keyboard::Event) -> Task<Message> {
|
||||
let iced::keyboard::Event::KeyPressed { key, modifiers, .. } = event else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let message = match key {
|
||||
Key::Named(key::Named::Escape) => agent::Message::Escape,
|
||||
// 折叠档没有会话动作可巡航,`Tab` 不该被助手栏吃掉
|
||||
Key::Named(key::Named::Tab) if self.agent.rail == RailState::Collapsed => {
|
||||
return Task::none();
|
||||
}
|
||||
Key::Named(key::Named::Tab) => agent::Message::Tab {
|
||||
shift: modifiers.shift(),
|
||||
},
|
||||
// `Activate` 只在软焦点落在**按钮**上时才动手(输入框的 Enter 由
|
||||
// `text_editor::key_binding` 分流成「发送」,不能在这里重复发一次)。
|
||||
Key::Named(key::Named::Enter) => agent::Message::Activate,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
|
||||
self.on_agent(message)
|
||||
}
|
||||
|
||||
/// `Esc` / `⌄`:展开 → 收起;独立页 → 收回右栗并离开 `Route::Agent`。
|
||||
fn collapse_agent(&mut self) {
|
||||
let was_detached = self.agent.rail == RailState::Detached;
|
||||
self.agent.collapse();
|
||||
if was_detached {
|
||||
self.leave_agent_page();
|
||||
}
|
||||
}
|
||||
|
||||
/// 离开 AI 独立页:能回退就回退(`Detach` 是压栈的),否则回实例页。
|
||||
fn leave_agent_page(&mut self) {
|
||||
if self.state.route() != &Route::Agent {
|
||||
return;
|
||||
}
|
||||
if !self.state.back() {
|
||||
self.state.navigate(Route::Instances);
|
||||
}
|
||||
}
|
||||
|
||||
/// 键盘 `Enter`:激活软焦点上的按钮(`Tab` 环的顺序见 [`Focus`])。
|
||||
///
|
||||
/// 焦点在 [`Focus::Composer`] 上时**什么都不做**:那一条路径由输入框自己的 `key_binding`
|
||||
/// 负责(按 `Tab` 走到输入框并不等于真的把 iced 焦点交过去,两次发送会打架)。
|
||||
fn activate_focused(&mut self, catalog: Catalog) {
|
||||
// 鼠标刚点过输入框(或从没按过 `Tab`)⇒ 这个 Enter 不归助手栏管
|
||||
if !self.agent.keyboard_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let intent = match (self.agent.rail, self.agent.focus) {
|
||||
(RailState::Collapsed, Focus::Collapse) => Some(agent::Message::Expand),
|
||||
(RailState::Expanded, Focus::Collapse) => Some(agent::Message::Collapse),
|
||||
(RailState::Expanded, Focus::Detach) | (RailState::Detached, Focus::Detach) => {
|
||||
Some(if self.agent.rail == RailState::Detached {
|
||||
agent::Message::Dock
|
||||
} else {
|
||||
agent::Message::Detach
|
||||
})
|
||||
}
|
||||
(_, Focus::Send) => Some(agent::Message::Submit),
|
||||
(_, Focus::Stop) => Some(agent::Message::Stop),
|
||||
// 折叠档没有「拆出」、独立页没有「收起」:焦点环按 `Focus::order` 不会落到这两格,
|
||||
// 显式列成 None 只是为了让编译器确认这份 intent 表是穷尽的。
|
||||
(RailState::Collapsed, Focus::Detach)
|
||||
| (RailState::Detached, Focus::Collapse)
|
||||
| (_, Focus::Composer) => None,
|
||||
};
|
||||
|
||||
if let Some(message) = intent {
|
||||
let _ = self.on_agent(message);
|
||||
} else {
|
||||
// 「不得静默无反应」:焦点在输入框上按 Enter 由输入框自己处理,这里只留一行日志。
|
||||
eprintln!(
|
||||
"[agent] Enter on focus={} handled by the composer",
|
||||
self.agent.focus.name()
|
||||
);
|
||||
let _ = catalog;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use unlucky_presenter::state::AppState;
|
||||
use crate::app::components::interaction::Interaction;
|
||||
use crate::cli::Screen;
|
||||
use crate::pages::{self, Detail, Meta, Row};
|
||||
use crate::shell::{self, NavItem};
|
||||
use crate::shell::{self, agent, NavItem};
|
||||
|
||||
use super::message::{nav_item_of, Message};
|
||||
use super::state::{log_first_frame, App};
|
||||
@@ -57,6 +57,13 @@ impl App {
|
||||
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
|
||||
.map(|element| element.map(Message::Page));
|
||||
|
||||
// 右栗(M104):`Detached` 档返回 `None`(会话已经在独立页里);拖拽期间另有一层
|
||||
// 铺满窗口的取指针层,所以两件东西分开传。
|
||||
let right = agent::docked(&self.ui, &self.agent, self.width)
|
||||
.map(|element| element.map(Message::Agent));
|
||||
let overlay =
|
||||
agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
|
||||
shell::view_with_background(
|
||||
&self.ui,
|
||||
nav_item_of(self.state.route()),
|
||||
@@ -73,19 +80,31 @@ impl App {
|
||||
},
|
||||
)
|
||||
.map(Message::Page),
|
||||
right,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
)
|
||||
}
|
||||
// `Route::Agent`:AI 独立页(M104)。它与右栗**共用**同一个会话视图
|
||||
// (`agent::page` 内部调的是 `conversation::view`),这里只是把会话摆进页面槽位。
|
||||
None => {
|
||||
let background =
|
||||
pages::appearance::layer(&self.ui, &self.appearance, (self.width, self.height))
|
||||
.map(|element| element.map(Message::Page));
|
||||
let overlay =
|
||||
agent::drag_overlay(&self.agent).map(|element| element.map(Message::Agent));
|
||||
|
||||
shell::view_with_background(
|
||||
&self.ui,
|
||||
NavItem::Ai,
|
||||
APP_TITLE,
|
||||
background,
|
||||
agent::page(&self.ui, &self.agent, self.width).map(Message::Agent),
|
||||
None,
|
||||
overlay,
|
||||
self.options.force_state,
|
||||
)
|
||||
}
|
||||
// `Route::Agent`:AI 独立页还没有页面实现(P1),回退到 M68 的外壳卡(同一套装饰)。
|
||||
None => shell::view(
|
||||
&self.ui,
|
||||
NavItem::Ai,
|
||||
APP_TITLE,
|
||||
NavItem::Ai.label(),
|
||||
"The assistant page is not built yet (P1). Chat lives in the docked panel once \
|
||||
the AI mission lands; this screen is the M68 skeleton fallback.",
|
||||
self.options.force_state,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +114,10 @@ impl App {
|
||||
/// 把 presenter 的快照转成页面视图模型(**唯一**的展示转换点)。
|
||||
///
|
||||
/// `forced` 是 `--force-state` 的落点(P3-1):它只影响**画出来的**状态层,不改任何状态机。
|
||||
///
|
||||
/// `width` 是**中栗可用宽度**(窗口 − 右栗 − 拖拽缝,由 `AgentUi::content_width` 算),不是窗口宽:
|
||||
/// 页面布局系统仍按「一个窗口宽」扣左轨与页边距,所以右栗占掉的那一段必须在这里先减掉——
|
||||
/// 否则助手栏一展开,页面还按 1180 排版,内容就顶到面板底下了(任务里的「收缩而不是溢出」)。
|
||||
pub(super) fn model(
|
||||
state: &AppState,
|
||||
width: f32,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//! **会话视图组件**:右栗面板与 `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()
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! 输入区(多行输入框 + 发送/中断):会话视图的**下半截**。
|
||||
//!
|
||||
//! 从 `conversation.rs` 拆出来的理由只有一条:那边贴着 400 行硬上限(AGENTS.md §3-1)。
|
||||
//! 拆的位置是「输入区」这一整块 + 一个排版小工具,语义边界与文件边界一致。
|
||||
//!
|
||||
//! ## Enter / Shift+Enter 的分流(任务第 4 条)
|
||||
//!
|
||||
//! `text_editor` 的默认绑定把两种 Enter 都折成 `Binding::Enter`,所以必须自己接管
|
||||
//! (见 [`composer`] 里的 `key_binding`):`modifiers.shift()` 决定「换行」还是「发送」。
|
||||
//! **只在输入框聚焦时生效**——`KeyPress.status` 不是 `Focused` 时原样交回默认绑定,
|
||||
//! 于是别处的 Enter 不会被助手栏抢走。
|
||||
|
||||
use iced::widget::text_editor::{self, Binding};
|
||||
use iced::widget::{column, row, text, text_editor as editor};
|
||||
use iced::{keyboard, Background, Border, Element, Length, Pixels, 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::theme::{Ui, STROKE_PX};
|
||||
|
||||
use super::super::state::Focus;
|
||||
use super::super::Message;
|
||||
use super::{focused_state, Conversation};
|
||||
|
||||
pub(super) fn composer<'a>(
|
||||
ui: &'a Ui,
|
||||
spec: Conversation<'a>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, Message> {
|
||||
let style = ui.style(TypeRole::BodyMedium);
|
||||
|
||||
let field = editor(spec.draft)
|
||||
.placeholder(catalog.text(Key::AgentPlaceholder))
|
||||
.on_action(Message::Edit)
|
||||
.key_binding(|press| {
|
||||
// Enter = 发送 / Shift+Enter = 换行(任务第 4 条)。
|
||||
//
|
||||
// 走 `key_binding` 而不是全局键盘订阅:`KeyPress` 直接带 `modifiers`,而 iced 的
|
||||
// 默认绑定把两种 Enter 都折成 `Binding::Enter`(所以**必须**在这里分流)。
|
||||
// 未聚焦时一律交回默认绑定(它自己不响应),于是别处的 Enter 不会被助手栏抢走。
|
||||
let enter = matches!(
|
||||
press.modified_key.as_ref(),
|
||||
keyboard::Key::Named(keyboard::key::Named::Enter)
|
||||
);
|
||||
if !enter || !matches!(press.status, text_editor::Status::Focused { .. }) {
|
||||
return Binding::from_key_press(press);
|
||||
}
|
||||
if press.modifiers.shift() {
|
||||
Some(Binding::Enter)
|
||||
} else {
|
||||
Some(Binding::Custom(Message::Submit))
|
||||
}
|
||||
})
|
||||
.size(style.size_px)
|
||||
.font(ui.font_of(style))
|
||||
.height(Length::Fixed(
|
||||
ui.space(Space::X8) + ui.space(Space::X8) + ui.space(Space::X1),
|
||||
))
|
||||
.padding(ui.space(Space::X2))
|
||||
.style(move |_theme: &Theme, status| {
|
||||
let focused = matches!(status, text_editor::Status::Focused { .. });
|
||||
text_editor::Style {
|
||||
background: Background::Color(ui.layer(MaterialLayer::Card)),
|
||||
border: if focused {
|
||||
Border {
|
||||
color: ui.color(Role::Primary),
|
||||
width: STROKE_PX * 2.0,
|
||||
radius: ui.radius_dp(ShapeScale::Small),
|
||||
}
|
||||
} else {
|
||||
ui.outlined(ShapeScale::Small)
|
||||
},
|
||||
placeholder: ui.color(Role::OnSurfaceVariant),
|
||||
value: ui.color(Role::OnSurface),
|
||||
selection: ui.color(Role::PrimaryContainer),
|
||||
}
|
||||
});
|
||||
|
||||
let send_label = catalog.text(Key::AgentSend);
|
||||
let send = if spec.blank || spec.busy {
|
||||
Button::disabled(send_label)
|
||||
} else {
|
||||
Button::new(send_label, Message::Submit)
|
||||
};
|
||||
// 键盘焦点在「发送」上时用 `Interaction::Focused`(10% 状态层),与头部动作同一套焦点语言。
|
||||
let send = focused_state(
|
||||
send.leading(IconKind::Send),
|
||||
spec.focus == Some(Focus::Send),
|
||||
);
|
||||
|
||||
let mut actions = row![]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(iced::Alignment::Center);
|
||||
if spec.busy {
|
||||
actions = actions.push(buttons::outlined(
|
||||
ui,
|
||||
focused_state(
|
||||
Button::new(catalog.text(Key::AgentStop), Message::Stop).leading(IconKind::Close),
|
||||
spec.focus == Some(Focus::Stop),
|
||||
),
|
||||
));
|
||||
}
|
||||
actions = actions.push(buttons::filled(ui, send));
|
||||
|
||||
column![field, actions]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一档文字(字号/行高/字重来自令牌;`shell::label` 同口径,但这里要自己持消息类型)。
|
||||
pub(crate) fn text_role<'a>(
|
||||
ui: &'a Ui,
|
||||
role: TypeRole,
|
||||
emphasized: bool,
|
||||
content: &'a str,
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `ULL_AGENT_*` 取证开关。
|
||||
//!
|
||||
//! ## 为什么是内置样本而不是 presenter
|
||||
//!
|
||||
//! 本 mission 只做**壳**:真数据由 M103(`presenter::agent::AgentRail` + 四个签名)供,
|
||||
//! 这里一行 presenter 调用都没有(任务里点名「不许提前调用」)。样本的职责只有两个:
|
||||
//! ①让三档状态在图上都有内容;②让「空会话」有对照([`FIXTURE_ENV`])。
|
||||
//!
|
||||
//! 样本正文是**演示数据**,与 `pages/instances/data.rs` 的 demo 同一性质:它不是界面文案,
|
||||
//! 不进 i18n 词表(界面上会随真模型一起消失)。界面自己的文案(标题/按钮/空态/提示)全部走 `Key`。
|
||||
//!
|
||||
//! ## 取证开关(只影响这一次进程,语义与 `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 一致)
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) |
|
||||
//! | [`FIXTURE_ENV`] | `empty` / `streaming` / `unread` / `focused` | 空会话(零消息)/ 流式中(中断键可见)/ 展开但带未读(单独看徽标)/ 键盘焦点环(`Tab` 走过一次的样子) |
|
||||
//! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) |
|
||||
|
||||
use super::metrics::PRESETS;
|
||||
use super::state::{AgentUi, RailState, Speaker, Turn};
|
||||
|
||||
/// 启动档位。
|
||||
pub const RAIL_ENV: &str = "ULL_AGENT_RAIL";
|
||||
/// 会话样本(`empty` / `streaming`)。
|
||||
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.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。
|
||||
///
|
||||
/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
|
||||
pub fn from_env() -> Option<AgentUi> {
|
||||
let rail = std::env::var(RAIL_ENV).ok();
|
||||
let fixture = std::env::var(FIXTURE_ENV).ok();
|
||||
let width = std::env::var(WIDTH_ENV).ok();
|
||||
|
||||
if rail.is_none() && fixture.is_none() && width.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fixture = fixture.as_deref().map(str::trim);
|
||||
let mut ui = match fixture {
|
||||
Some("empty") => AgentUi::empty(),
|
||||
_ => AgentUi::new(),
|
||||
};
|
||||
|
||||
if let Some(raw) = width.as_deref().map(str::trim) {
|
||||
match raw.parse::<f32>() {
|
||||
Ok(value) if PRESETS.contains(&value) => ui.set_expanded(value),
|
||||
_ => eprintln!("[agent] {WIDTH_ENV}={raw:?} 不是预设档位 {PRESETS:?} 之一,忽略"),
|
||||
}
|
||||
}
|
||||
|
||||
if fixture == Some("streaming") {
|
||||
ui.pretend_streaming();
|
||||
}
|
||||
|
||||
// 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义
|
||||
// (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。
|
||||
// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(见模块文档的表)。
|
||||
match rail.as_deref().map(str::trim) {
|
||||
None => ui.expand(),
|
||||
Some(name) => match RailState::from_name(name) {
|
||||
Some(RailState::Collapsed) => ui.collapse(),
|
||||
Some(RailState::Detached) => ui.detach(),
|
||||
Some(RailState::Expanded) => ui.expand(),
|
||||
None => eprintln!(
|
||||
"[agent] {RAIL_ENV}={name:?} 不是三档之一({}),忽略",
|
||||
RailState::ALL.map(RailState::name).join("/")
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
if fixture == Some("unread") {
|
||||
ui.unread = 3;
|
||||
}
|
||||
|
||||
if fixture == Some("focused") {
|
||||
// 键盘巡航一次的样子(焦点环 = 10% 状态层)。真窗口里按一次 `Tab` 就会到这一态,
|
||||
// 但截图通道收不到键盘输入,所以只能这样取证。
|
||||
ui.step_focus(false);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[agent] rail={} width={} turns={} busy={} unread={}",
|
||||
ui.rail.name(),
|
||||
ui.expanded(),
|
||||
ui.transcript.len(),
|
||||
ui.busy,
|
||||
ui.unread
|
||||
);
|
||||
|
||||
Some(ui)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! 右侧助手栏的**布局数学**:三档宽度、磁铁吸附、中栗下限。纯函数 + 常量,不碰 iced 控件树。
|
||||
//!
|
||||
//! ## 一屏的横向预算
|
||||
//!
|
||||
//! ```text
|
||||
//! 窗口宽 = 左轨 88 + [中栗] + [拖拽缝 8(仅展开档)] + [右栗]
|
||||
//! ```
|
||||
//!
|
||||
//! - 右栗宽度 = [`rail_width`]:折叠 48 / 展开 280~360(吸附值)/ 拆出 0;
|
||||
//! - 中栗宽度 = 窗口 − 右栗 − 拖拽缝([`content_width`],页面层再扣左轨与页边距);
|
||||
//! - **中栗下限** [`min_content_width`] = 行宽上限 424 + 两侧页边距 48 = **472**:
|
||||
//! 再窄正文列就装不下一行满宽文,所以拖拽被夹在这里(不是「拖到 0 再说」)。
|
||||
//!
|
||||
//! 「展开时中栗**收缩而不是溢出**」这句话的落点就是 [`content_width`]:页面布局系统仍以
|
||||
//! 「一个窗口宽」为输入(它内部扣 rail 与页边距),所以右栗占掉的那一段必须在这里先减掉。
|
||||
//! 四个对照宽度的实际数值由 `metrics/tests.rs` 的表钉住(报告里贴的是同一张表)。
|
||||
//!
|
||||
//! ## 三档预设的取值理由(tower 要求写明,不许默默定)
|
||||
//!
|
||||
//! | 档位 | 值 | 依据 |
|
||||
//! |---|---|---|
|
||||
//! | 折叠 | **48** | `LayoutMetrics::touch_target`:M3 无障碍下限,图标按钮 40 + 未读点要放得下 |
|
||||
//! | 展开(锚) | **320** | 令牌 `ai_panel_width`:M69 起这个令牌就叫「AI 面板宽度」,改锚等于改令牌语义 |
|
||||
//! | 展开 ±1 档 | **280 / 360** | 在 4px 栅格上、离锚正好 40(`Space::X8 + Space::X2`);280 = 官方 supporting-pane 侧列下限 240 + 两份 20 内边距,仍能排 40 字符/行 |
|
||||
//!
|
||||
//! **不取 420**:900 宽窗上右栗上限只有 340([`max_rail_width`]),420 这个预设永远点不到 = 假档位。
|
||||
//! **不取 240**:减去两侧 16 内边距只剩 208,一行不到 35 字符,会话气泡会碎成柱状。
|
||||
|
||||
use unlucky_design::spacing::{snap_to_grid, LayoutMetrics, Space};
|
||||
|
||||
use crate::pages::layout;
|
||||
|
||||
/// 折叠态图标条宽 = M3 触控目标 **48**(`LayoutMetrics::touch_target`,不是新字面量)。
|
||||
pub const COLLAPSED_WIDTH: f32 = LayoutMetrics::DESKTOP.touch_target;
|
||||
|
||||
/// 展开态面板宽 = 令牌 `ai_panel_width` = **320**。
|
||||
pub const DEFAULT_EXPANDED_WIDTH: f32 = LayoutMetrics::DESKTOP.ai_panel_width;
|
||||
|
||||
/// 磁铁档位间距 = 一个页级留白(32)+ 一个小留白(8)= **40**。
|
||||
pub const PRESET_STEP: f32 = Space::X8.dp() + Space::X2.dp();
|
||||
|
||||
/// 三个预设档位:**280 / 320 / 360**(理由见模块文档)。
|
||||
pub const PRESETS: [f32; 3] = [
|
||||
DEFAULT_EXPANDED_WIDTH - PRESET_STEP,
|
||||
DEFAULT_EXPANDED_WIDTH,
|
||||
DEFAULT_EXPANDED_WIDTH + PRESET_STEP,
|
||||
];
|
||||
|
||||
/// 磁铁半径(`Space::X6` = 24):离预设档位 24px 以内就吸过去,否则按 4px 栅格落地。
|
||||
///
|
||||
/// 24 > 档位间距的一半(20),所以 280~360 之间**任何**位置都会被吸到某一档;360 以上到上限
|
||||
/// 之间不再有档位,那段才走 4px 栅格(用户想要 500 宽的面板也给)。
|
||||
pub const MAGNET_RADIUS: f32 = Space::X6.dp();
|
||||
|
||||
/// 面板与中栗之间的可拖拽缝隙宽(`Space::X2` = 8)。
|
||||
///
|
||||
/// 它既是分隔线(1px `OutlineVariant` 居中)也是命中区:不是「看起来细就抓不到」。
|
||||
pub const RESIZE_GUTTER: f32 = Space::X2.dp();
|
||||
|
||||
/// 中栗的最小可用宽度 = 行宽上限 424 + 两侧页边距 24×2 = **472**。
|
||||
///
|
||||
/// 依据是布局系统自己的判据([`layout::measure_cap_px`]):1180 展开 320 后中栗 **852**,
|
||||
/// 880(进程最小窗宽)展开 320 后中栗 **552** —— 都还在 472 之上。
|
||||
pub fn min_content_width() -> f32 {
|
||||
layout::measure_cap_px() + 2.0 * Space::X6.dp()
|
||||
}
|
||||
|
||||
/// 右栗能占的最大宽度:中栗不许低于 [`min_content_width`](拖拽缝也算右栗的开销)。
|
||||
///
|
||||
/// 下限取 [`COLLAPSED_WIDTH`](窗口再窄也不把右栗夹成负数)。
|
||||
pub fn max_rail_width(window_width: f32) -> f32 {
|
||||
(window_width - layout::rail_width() - min_content_width() - RESIZE_GUTTER).max(COLLAPSED_WIDTH)
|
||||
}
|
||||
|
||||
/// 展开宽度的合法化:夹到可用范围。低于 280 就吸到 280(不是变细)。
|
||||
///
|
||||
/// 窗口窄到连 280 都不成立时返回 [`COLLAPSED_WIDTH`]——那时「展开」这件事本身不成立,
|
||||
/// 画一条 280 的栏会把中栗压到 472 以下。
|
||||
pub fn clamp_width(window_width: f32, requested: f32) -> f32 {
|
||||
let ceiling = max_rail_width(window_width);
|
||||
if ceiling < PRESETS[0] {
|
||||
return COLLAPSED_WIDTH;
|
||||
}
|
||||
requested.clamp(PRESETS[0], ceiling)
|
||||
}
|
||||
|
||||
/// 拖拽落点 → 吸附后的右栗宽度(磁铁 → 4px 栅格 → 夹紧)。
|
||||
pub fn magnet_width(window_width: f32, raw: f32) -> f32 {
|
||||
let nearest = PRESETS
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|left, right| (left - raw).abs().total_cmp(&(right - raw).abs()))
|
||||
.unwrap_or(DEFAULT_EXPANDED_WIDTH);
|
||||
|
||||
let settled = if (nearest - raw).abs() <= MAGNET_RADIUS {
|
||||
nearest
|
||||
} else {
|
||||
snap_to_grid(raw)
|
||||
};
|
||||
|
||||
clamp_width(window_width, settled)
|
||||
}
|
||||
|
||||
/// 窗口窄到连最小展开档都放不下时,`Expanded` **事实上**就是折叠档。
|
||||
///
|
||||
/// 不是「自动帮用户收起」的一时兴起:`clamp_width` 在这种情况下返回的就是 [`COLLAPSED_WIDTH`],
|
||||
/// 若仍报 `Expanded`,宿主会画一条 48 宽的面板、还白扣 8px 拖拽缝。
|
||||
pub fn effective_state(
|
||||
window_width: f32,
|
||||
state: crate::shell::agent::RailState,
|
||||
expanded: f32,
|
||||
) -> crate::shell::agent::RailState {
|
||||
use crate::shell::agent::RailState;
|
||||
match state {
|
||||
RailState::Expanded if clamp_width(window_width, expanded) <= COLLAPSED_WIDTH => {
|
||||
RailState::Collapsed
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一屏的横向预算(窗口 → 左轨 / 中栗 / 右栗)。纯数据,供单测与日志用。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct RailLayout {
|
||||
/// 窗口宽度(输入)。
|
||||
pub window: f32,
|
||||
/// 左侧导航轨宽度(令牌 `nav_rail_width` = 88)。
|
||||
pub left: f32,
|
||||
/// 右栗面板宽度(0 / 48 / 280~360)。
|
||||
pub rail: f32,
|
||||
/// 右栗前面的拖拽缝宽度(只有展开档有,其余 0)。
|
||||
pub gutter: f32,
|
||||
/// 中栗宽度 = 窗口 − 右栗 − 拖拽缝(页面层再扣左轨与页边距)。
|
||||
pub content: f32,
|
||||
}
|
||||
|
||||
/// 算一屏的横向预算(档位先经 [`effective_state`] 归一)。
|
||||
pub fn layout_for(
|
||||
window_width: f32,
|
||||
state: crate::shell::agent::RailState,
|
||||
expanded: f32,
|
||||
) -> RailLayout {
|
||||
use crate::shell::agent::RailState;
|
||||
|
||||
let (rail, gutter) = match effective_state(window_width, state, expanded) {
|
||||
RailState::Collapsed => (COLLAPSED_WIDTH, 0.0),
|
||||
RailState::Expanded => (clamp_width(window_width, expanded), RESIZE_GUTTER),
|
||||
RailState::Detached => (0.0, 0.0),
|
||||
};
|
||||
|
||||
RailLayout {
|
||||
window: window_width,
|
||||
left: layout::rail_width(),
|
||||
rail,
|
||||
gutter,
|
||||
content: (window_width - rail - gutter).max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 右栗面板宽度(`App::view` 与 shell 都用这一个入口,避免两处各算一套)。
|
||||
pub fn rail_width(window_width: f32, state: crate::shell::agent::RailState, expanded: f32) -> f32 {
|
||||
layout_for(window_width, state, expanded).rail
|
||||
}
|
||||
|
||||
/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝。
|
||||
pub fn content_width(
|
||||
window_width: f32,
|
||||
state: crate::shell::agent::RailState,
|
||||
expanded: f32,
|
||||
) -> f32 {
|
||||
layout_for(window_width, state, expanded).content
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "metrics/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,178 @@
|
||||
//! 布局数学的数值判据(M104 任务第 2 条那张表就长在这里)。
|
||||
//!
|
||||
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数能钉住算式,**钉不住**像素与手感。
|
||||
|
||||
use crate::pages::layout::{self, PaneKit};
|
||||
|
||||
use crate::shell::agent::RailState;
|
||||
|
||||
use super::{
|
||||
clamp_width, content_width, effective_state, layout_for, magnet_width, max_rail_width,
|
||||
min_content_width, COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH, MAGNET_RADIUS, PRESETS,
|
||||
RESIZE_GUTTER,
|
||||
};
|
||||
|
||||
/// **布局数学的核心表**(1180 / 1024 / 900 / 700,任务第 2 条点名要的四个宽度)。
|
||||
///
|
||||
/// 抓得住:右栗占宽之后中栗是**收缩**的(`content = window − rail − gutter`),以及页面布局系统
|
||||
/// 拿到的列宽确实跟着变(1180 展开时实例页详情列 424 → 356)。
|
||||
/// 抓不住:真实的像素宽度(那要量图),也抓不住「右栗和外框之间有没有缝」这种观感问题。
|
||||
#[test]
|
||||
fn the_column_table_matches_the_mission_widths() {
|
||||
// (窗口, 档位, 右栗面板, 拖拽缝, 中栗, 实例详情列, 单列页列宽)
|
||||
let expect = [
|
||||
(1180.0, RailState::Detached, 0.0, 0.0, 1180.0, 424.0, 424.0),
|
||||
(
|
||||
1180.0,
|
||||
RailState::Collapsed,
|
||||
48.0,
|
||||
0.0,
|
||||
1132.0,
|
||||
424.0,
|
||||
424.0,
|
||||
),
|
||||
(1180.0, RailState::Expanded, 320.0, 8.0, 852.0, 356.0, 424.0),
|
||||
(1024.0, RailState::Detached, 0.0, 0.0, 1024.0, 424.0, 424.0),
|
||||
(1024.0, RailState::Collapsed, 48.0, 0.0, 976.0, 424.0, 424.0),
|
||||
// 1024 展开:中栗 696 掉到 medium 档 ⇒ 实例页退化成单列(不再两列硬挤)
|
||||
(1024.0, RailState::Expanded, 320.0, 8.0, 696.0, 424.0, 424.0),
|
||||
// 900 收起:中栗 852 仍够 list-detail(几何门槛 816),详情列 356
|
||||
(900.0, RailState::Collapsed, 48.0, 0.0, 852.0, 356.0, 424.0),
|
||||
(900.0, RailState::Expanded, 320.0, 8.0, 572.0, 424.0, 424.0),
|
||||
// 700 < 进程最小窗宽 880:展开档不成立(中栗会被压到 472 以下),退回收起档
|
||||
(700.0, RailState::Expanded, 48.0, 0.0, 652.0, 424.0, 424.0),
|
||||
];
|
||||
|
||||
for (window, rail, width, gutter, content, detail, single) in expect {
|
||||
let laid_out = layout_for(window, rail, DEFAULT_EXPANDED_WIDTH);
|
||||
assert_eq!(
|
||||
(laid_out.rail, laid_out.gutter, laid_out.content),
|
||||
(width, gutter, content),
|
||||
"{window} / {} 的横向预算不对",
|
||||
rail.name()
|
||||
);
|
||||
assert_eq!(laid_out.left, layout::rail_width(), "左轨宽度只有一个真源");
|
||||
|
||||
// 页面布局系统吃的就是这个 content 宽:实例页(list-detail)与单列页各算一次
|
||||
let instances = layout::frame_metrics(PaneKit::ListDetail, content);
|
||||
assert_eq!(
|
||||
instances.column,
|
||||
detail,
|
||||
"{window} / {} 的实例详情列宽不对",
|
||||
rail.name()
|
||||
);
|
||||
let page = layout::frame_metrics(PaneKit::Single, content);
|
||||
assert_eq!(
|
||||
page.column,
|
||||
single,
|
||||
"{window} / {} 的单列页列宽不对",
|
||||
rail.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **不溢出**的硬判据:窗口 ≥ 最小窗宽时,任何档位、任何预设,中栗都不低于 472。
|
||||
///
|
||||
/// 抓得住:有人把 [`min_content_width`] 调小、或让 [`content_width`] 忘了扣右栗/拖拽缝。
|
||||
/// 抓不住:`row!` 的兄弟顺序写错(那要出图看)。
|
||||
#[test]
|
||||
fn the_middle_column_never_drops_below_the_usable_width() {
|
||||
assert_eq!(
|
||||
min_content_width(),
|
||||
472.0,
|
||||
"472 = 行宽上限 424 + 两侧页边距 48"
|
||||
);
|
||||
|
||||
for window in [880.0, 900.0, 1024.0, 1180.0, 1440.0, 2560.0] {
|
||||
for rail in RailState::ALL {
|
||||
for preset in PRESETS {
|
||||
let width = content_width(window, rail, preset);
|
||||
assert!(
|
||||
width >= 472.0,
|
||||
"{window} / {} / 预设 {preset} 把中栗压到 {width}",
|
||||
rail.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 边界:最小窗宽 880 上,展开档被夹到上限 312(880 − 88 − 472 − 8),中栗留 560
|
||||
assert_eq!(max_rail_width(880.0), 312.0);
|
||||
assert_eq!(content_width(880.0, RailState::Expanded, 320.0), 560.0);
|
||||
}
|
||||
|
||||
/// 拖拽:磁铁吸附 + 夹紧。
|
||||
///
|
||||
/// 抓得住:①拖动结果落在预设档或 4px 栅格上(不会一像素一像素抖);②离预设 24px 内被吸住;
|
||||
/// ③往左拖过头会被夹在 [`max_rail_width`],而不是把中栗压扁。
|
||||
/// 抓不住:真实指针手感(`mouse_area::on_move` 在真窗口里的采样率只能人试)。
|
||||
#[test]
|
||||
fn the_drag_magnetises_and_refuses_to_squeeze_the_middle() {
|
||||
// 1180 窗口:上限 = 1180 − 88(左轨)− 472(中栗下限)− 8(拖拽缝)= 612
|
||||
assert_eq!(max_rail_width(1180.0), 612.0);
|
||||
|
||||
// 离 320 只有 6px ⇒ 吸到 320
|
||||
assert_eq!(magnet_width(1180.0, 326.0), 320.0);
|
||||
// 两档之间(310):离 320 只有 10px ⇒ 还是吸住
|
||||
assert_eq!(magnet_width(1180.0, 310.0), 320.0);
|
||||
// 360 之上再没有档位 ⇒ 走 4px 栅格
|
||||
assert_eq!(magnet_width(1180.0, 500.0), 500.0);
|
||||
assert_eq!(magnet_width(1180.0, 497.0), 496.0);
|
||||
// 低于最小档 ⇒ 吸到 280(不是变细)
|
||||
assert_eq!(magnet_width(1180.0, 120.0), 280.0);
|
||||
// 超过上限 ⇒ 夹在 612(中栗正好 472)
|
||||
assert_eq!(magnet_width(1180.0, 900.0), 612.0);
|
||||
assert_eq!(
|
||||
content_width(1180.0, RailState::Expanded, 612.0),
|
||||
1180.0 - 612.0 - 8.0
|
||||
);
|
||||
|
||||
// 900 窗口:上限 = 900 − 88 − 472 − 8 = 332 ⇒ 360 档被夹到 332
|
||||
assert_eq!(max_rail_width(900.0), 332.0);
|
||||
assert_eq!(magnet_width(900.0, 360.0), 332.0);
|
||||
// 窄到连 280 都放不下(700):展开档不成立,返回折叠宽
|
||||
assert_eq!(clamp_width(700.0, 320.0), COLLAPSED_WIDTH);
|
||||
assert_eq!(
|
||||
effective_state(700.0, RailState::Expanded, 320.0),
|
||||
RailState::Collapsed
|
||||
);
|
||||
assert_eq!(
|
||||
effective_state(1180.0, RailState::Expanded, 320.0),
|
||||
RailState::Expanded
|
||||
);
|
||||
}
|
||||
|
||||
/// 三段常量必须互相自洽(改一个忘一个,图上就是「面板 320 但内容按 280 排」)。
|
||||
#[test]
|
||||
fn the_rail_constants_stay_consistent() {
|
||||
assert_eq!(COLLAPSED_WIDTH, 48.0, "折叠条 = M3 触控目标");
|
||||
assert_eq!(
|
||||
DEFAULT_EXPANDED_WIDTH, 320.0,
|
||||
"展开档 = 令牌 ai_panel_width"
|
||||
);
|
||||
assert_eq!(PRESETS, [280.0, 320.0, 360.0]);
|
||||
assert_eq!(RESIZE_GUTTER, 8.0);
|
||||
assert_eq!(MAGNET_RADIUS, 24.0);
|
||||
|
||||
assert!(
|
||||
PRESETS.contains(&DEFAULT_EXPANDED_WIDTH),
|
||||
"锚档必须在预设里"
|
||||
);
|
||||
|
||||
// 下面两条用**变量**而不是常量表达式:clippy 的 `assertions_on_constants` 会把
|
||||
// 「拿常量断言常量」判成无用断言,而这里断的正是两个常量之间的关系。
|
||||
let shortest_rail = COLLAPSED_WIDTH;
|
||||
let narrowest_panel = PRESETS[0];
|
||||
assert!(shortest_rail < narrowest_panel, "折叠比最小展开还窄");
|
||||
|
||||
// 磁铁半径 > 档位间距的一半 ⇒ 280~360 之间没有「吸不住」的空档
|
||||
let magnet = MAGNET_RADIUS;
|
||||
let half_step = (PRESETS[1] - PRESETS[0]) / 2.0;
|
||||
assert!(magnet > half_step);
|
||||
|
||||
// 三档名字是取证开关的取值,改名等于改命令行契约
|
||||
for rail in RailState::ALL {
|
||||
assert_eq!(RailState::from_name(rail.name()), Some(rail));
|
||||
}
|
||||
assert_eq!(RailState::from_name("open"), None);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! # 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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! 右栗的三档状态机 + 会话数据 + 键盘焦点环 + [`AgentUi`](挂在 `App` 上的 UI 状态)。
|
||||
//!
|
||||
//! 布局数学(宽度 / 磁铁吸附 / 中栗下限)在 [`super::metrics`];这里只管「状态怎么变」。
|
||||
//!
|
||||
//! ## 三档状态(用户定案 D16)
|
||||
//!
|
||||
//! | 档位 | 右栗宽度 | 会话视图在哪 | 切法 |
|
||||
//! |---|---|---|---|
|
||||
//! | [`RailState::Collapsed`] | 48(图标条 + 未读点) | 不画 | `⌄` 收起 / `Esc` |
|
||||
//! | [`RailState::Expanded`] | 280~360(默认 320) | 右栗面板内 | 点图标条 / 收回 |
|
||||
//! | [`RailState::Detached`] | 0 | `Route::Agent` 独立页 | `⇥` 拆出 |
|
||||
//!
|
||||
//! 三档是**同一个会话**的三种呈现:切档只改 [`RailState`] 与路由栈,`transcript` / `draft` /
|
||||
//! `busy` / `unread` 一条都不丢(「拆出去再接回来,话说了一半还在」)。
|
||||
//!
|
||||
//! ## 为什么状态是 `App` 的字段
|
||||
//!
|
||||
//! 这棵树在 `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`] 就是那两个落点。
|
||||
|
||||
use iced::widget::text_editor;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
|
||||
use super::metrics::{
|
||||
content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH,
|
||||
};
|
||||
|
||||
/// 三档状态(顺序 = 展开程度,测试与文件名按它遍历)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RailState {
|
||||
/// 48px 图标条。
|
||||
Collapsed,
|
||||
/// 280~360 的会话面板。
|
||||
Expanded,
|
||||
/// 收进 `Route::Agent` 独立页(右栗消失)。
|
||||
Detached,
|
||||
}
|
||||
|
||||
impl RailState {
|
||||
/// 三档全序(截图矩阵按它铺)。
|
||||
pub const ALL: [RailState; 3] = [
|
||||
RailState::Collapsed,
|
||||
RailState::Expanded,
|
||||
RailState::Detached,
|
||||
];
|
||||
|
||||
/// 稳定名字(文件名 / `ULL_AGENT_RAIL` 的取值 / 失败信息)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
RailState::Collapsed => "collapsed",
|
||||
RailState::Expanded => "expanded",
|
||||
RailState::Detached => "detached",
|
||||
}
|
||||
}
|
||||
|
||||
/// 反向解析(取证开关用)。
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|state| state.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话里的一个说话人。
|
||||
#[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 {
|
||||
/// `⌄` 收起。
|
||||
Collapse,
|
||||
/// `⇥` 拆出(独立页上则是 `⇤` 收回,同一个焦点位)。
|
||||
Detach,
|
||||
/// 发送。
|
||||
Send,
|
||||
/// 中断(只在流式中出现)。
|
||||
Stop,
|
||||
/// 输入框(真实焦点由 iced 的 `text_editor` 持有,这里只记「轮到它了」)。
|
||||
Composer,
|
||||
}
|
||||
|
||||
impl Focus {
|
||||
/// 稳定名字(日志 / 测试)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Focus::Collapse => "collapse",
|
||||
Focus::Detach => "detach",
|
||||
Focus::Send => "send",
|
||||
Focus::Stop => "stop",
|
||||
Focus::Composer => "composer",
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab 环上「这一档真正存在的控件」(流式中才多一个中断键;折叠档没有会话动作)。
|
||||
fn order(state: RailState, busy: bool) -> Vec<Focus> {
|
||||
let mut order = Vec::new();
|
||||
match state {
|
||||
RailState::Collapsed => order.push(Focus::Collapse),
|
||||
RailState::Expanded => {
|
||||
order.push(Focus::Collapse);
|
||||
order.push(Focus::Detach);
|
||||
}
|
||||
RailState::Detached => order.push(Focus::Detach),
|
||||
}
|
||||
if state != RailState::Collapsed {
|
||||
order.push(Focus::Send);
|
||||
if busy {
|
||||
order.push(Focus::Stop);
|
||||
}
|
||||
order.push(Focus::Composer);
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// 下一个焦点(`shift` = 反向)。当前焦点不在环上时从头/尾进。
|
||||
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 {
|
||||
return if shift { order.last() } else { order.first() }
|
||||
.copied()
|
||||
.unwrap_or(Focus::Collapse);
|
||||
};
|
||||
let next = if shift {
|
||||
(at + order.len() - 1) % order.len()
|
||||
} else {
|
||||
(at + 1) % order.len()
|
||||
};
|
||||
order[next]
|
||||
}
|
||||
}
|
||||
|
||||
/// 助手栏的 UI 状态(挂在 `App` 上;**唯一**的写入口是 `app/update/agent.rs`)。
|
||||
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)。
|
||||
///
|
||||
/// 为什么需要它:`Enter` 全局订阅与输入框自己的 `key_binding` 共用同一个键。若不加这道闸,
|
||||
/// 用户在**别的页面**用鼠标点过发送键之后,随手按 Enter 也会把草稿发出去。只有真的按过
|
||||
/// `Tab`(= 用户在用键盘巡航助手栏)才让 `Activate` 生效。
|
||||
pub keyboard_mode: bool,
|
||||
/// 拖拽中的指针 x(`Some` = 正在拖)。
|
||||
pointer: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentUi {
|
||||
/// 出厂态:**折叠** + 一段占位样本会话 + 2 条未读。
|
||||
///
|
||||
/// 为什么默认折叠:1180 下展开 320 会把实例页挤成单列(中栗 852 < 816 的两列门槛之外还够,
|
||||
/// 但 1024 就掉档了),用户第一次打开启动器不该先被一个助手栏改掉主页面版面。
|
||||
pub fn new() -> Self {
|
||||
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
|
||||
}
|
||||
|
||||
/// 直接指定展开宽度(只给取证开关用:三档预设值之一)。
|
||||
pub fn set_expanded(&mut self, width: f32) {
|
||||
self.expanded = width;
|
||||
}
|
||||
|
||||
/// 是否正在拖拽(shell 据此铺一层取指针的透明层)。
|
||||
pub fn is_dragging(&self) -> bool {
|
||||
self.pointer.is_some()
|
||||
}
|
||||
|
||||
/// 这一窗宽下**实际**生效的档位(窄窗里 `Expanded` 会退化成 `Collapsed`)。
|
||||
pub fn effective_rail(&self, window_width: f32) -> RailState {
|
||||
effective_state(window_width, self.rail, self.expanded)
|
||||
}
|
||||
|
||||
/// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。
|
||||
pub fn width(&self, window_width: f32) -> f32 {
|
||||
rail_width(window_width, self.rail, self.expanded)
|
||||
}
|
||||
|
||||
/// **交给页面布局系统的宽度**(窗口 − 右栗 − 拖拽缝)。
|
||||
///
|
||||
/// `refresh_view` 用它算视图模型:面板展开后页面按更窄的宽度排版,内容才会「收缩」而不是
|
||||
/// 顶到面板底下。
|
||||
pub fn content_width(&self, window_width: f32) -> f32 {
|
||||
content_width(window_width, self.rail, self.expanded)
|
||||
}
|
||||
|
||||
/// 收起(`⌄` / `Esc`)。未读不清零:收起后新消息仍要能显示成点。
|
||||
pub fn collapse(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
if self.rail == RailState::Expanded {
|
||||
self.rail = RailState::Collapsed;
|
||||
self.focus = Focus::Collapse;
|
||||
} else if self.rail == RailState::Detached {
|
||||
self.dock();
|
||||
}
|
||||
}
|
||||
|
||||
/// 展开(点图标条)。展开即视为读过:未读清零(与主流聊天客户端的「打开即读」同口径)。
|
||||
pub fn expand(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
self.rail = RailState::Expanded;
|
||||
self.unread = 0;
|
||||
self.focus = Focus::Collapse;
|
||||
}
|
||||
|
||||
/// 拆成独立页(`⇥`)。会话状态一条不动。
|
||||
pub fn detach(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
self.rail = RailState::Detached;
|
||||
self.unread = 0;
|
||||
self.focus = Focus::Detach;
|
||||
}
|
||||
|
||||
/// 收回右栗(`⇤` / 独立页上的 `Esc`)。
|
||||
pub fn dock(&mut self) {
|
||||
self.keyboard_mode = false;
|
||||
self.rail = RailState::Expanded;
|
||||
self.unread = 0;
|
||||
self.focus = Focus::Collapse;
|
||||
}
|
||||
|
||||
/// 输入框内容变化(`text_editor::Action` 由 app 层转过来)。
|
||||
pub fn edit(&mut self, action: text_editor::Action) {
|
||||
use iced::widget::text_editor::{Action, Edit};
|
||||
|
||||
// Enter:**发送**(`Shift+Enter` 才换行)。这条语义在 `conversation::composer` 的
|
||||
// `key_binding` 里已经分好流,走到这里的 `Enter` 只可能是「换行」那一支。
|
||||
if let Action::Edit(Edit::Enter) = action {
|
||||
self.draft.perform(Action::Edit(Edit::Enter));
|
||||
return;
|
||||
}
|
||||
if matches!(action, Action::Click(_)) {
|
||||
// 鼠标点了输入框 = 用户切回鼠标,键盘巡航结束(见 `keyboard_mode` 的理由)
|
||||
self.focus = Focus::Composer;
|
||||
self.keyboard_mode = false;
|
||||
}
|
||||
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),
|
||||
));
|
||||
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;
|
||||
}
|
||||
|
||||
/// 输入框是否为空(发送键的禁用判据)。
|
||||
pub fn draft_is_blank(&self) -> bool {
|
||||
self.draft.text().trim().is_empty()
|
||||
}
|
||||
|
||||
/// 拖拽开始(按下分隔条)。`0.0` 只是占位:第一次 `drag_to` 就会换成真实指针位置。
|
||||
pub fn drag_start(&mut self) {
|
||||
self.pointer = Some(0.0);
|
||||
}
|
||||
|
||||
/// 拖拽中:`x` = 指针在**窗口**里的横坐标(0 = 窗口左缘)。
|
||||
pub fn drag_to(&mut self, window_width: f32, x: f32) {
|
||||
if self.pointer.is_none() {
|
||||
return;
|
||||
}
|
||||
self.pointer = Some(x);
|
||||
self.expanded = magnet_width(window_width, window_width - x);
|
||||
}
|
||||
|
||||
/// 拖拽结束(松手)。
|
||||
pub fn drag_end(&mut self) {
|
||||
self.pointer = None;
|
||||
}
|
||||
|
||||
/// Tab 走一格(走一次就把键盘巡航打开)。
|
||||
pub fn step_focus(&mut self, shift: bool) {
|
||||
self.focus = self.focus.step(shift, self.rail, self.busy);
|
||||
self.keyboard_mode = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentUi {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! 三档状态机、两个宿主、a11y 文案的判据(数值表与磁铁在 `metrics/tests.rs`)。
|
||||
//!
|
||||
//! 每条断言都写清「抓得住什么 / 抓不住什么」——纯函数与源码扫描能钉住语义,**钉不住**像素。
|
||||
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::typography::Platform;
|
||||
use unlucky_i18n::{Catalog, Key, Lang};
|
||||
|
||||
use crate::theme::{Families, Ui};
|
||||
|
||||
use super::conversation::{Conversation, Host};
|
||||
use super::metrics::{layout_for, PRESETS, RESIZE_GUTTER};
|
||||
use super::state::{AgentUi, Focus, RailState, Speaker, Turn};
|
||||
use super::*;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn ui() -> Ui {
|
||||
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
|
||||
}
|
||||
|
||||
/// 三档迁移:会话、草稿、未读的语义。
|
||||
///
|
||||
/// 抓得住:拆出去再收回,话说了一半还在(transcript + draft 不丢);未读只在收起态累积。
|
||||
/// 抓不住:切换时有没有一次「闪一下」的重排(那要出图看)。
|
||||
#[test]
|
||||
fn the_three_states_share_one_conversation() {
|
||||
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, "展开即读");
|
||||
|
||||
agent.draft = iced::widget::text_editor::Content::with_text("kick off Survival");
|
||||
agent.draft.perform(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Enter,
|
||||
));
|
||||
assert_eq!(
|
||||
agent.draft.line_count(),
|
||||
2,
|
||||
"多行: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:只改档位,不清未读记录(下一批消息仍会点亮)
|
||||
agent.collapse();
|
||||
assert_eq!(agent.rail, RailState::Collapsed);
|
||||
// 独立页上的 Esc = 收回(`collapse` 对 Detached 等价于 dock)
|
||||
agent.rail = RailState::Detached;
|
||||
agent.collapse();
|
||||
assert_eq!(agent.rail, RailState::Expanded);
|
||||
}
|
||||
|
||||
/// 发送 / 中断:**只做视图态**(没有后端),但绝不静默——两条都会在会话里留一条提示。
|
||||
#[test]
|
||||
fn submit_and_stop_leave_a_visible_notice() {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
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(" 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.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!(
|
||||
catalog.text(Key::AgentEmptyBody).split_whitespace().count() >= 5,
|
||||
"空态要有一句能读懂下一步的引导,而不是一个词"
|
||||
);
|
||||
// 中文也得有且不同(D28:两套目录一行一个 key,缺译会红)
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
assert_ne!(
|
||||
zh.text(Key::AgentEmptyBody),
|
||||
catalog.text(Key::AgentEmptyBody)
|
||||
);
|
||||
}
|
||||
|
||||
/// **单实现判据**:右栗与独立页都只通过 `conversation::view` 画对话。
|
||||
///
|
||||
/// 抓得住:有人把「独立页要更宽」当成理由另写一份气泡/输入框装配(复制粘贴是这类任务最常见的坏法)。
|
||||
/// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。
|
||||
#[test]
|
||||
fn both_hosts_render_the_same_conversation() {
|
||||
let hosts = include_str!("mod.rs");
|
||||
let conversation = include_str!("conversation.rs");
|
||||
let composer = include_str!("conversation/composer.rs");
|
||||
|
||||
assert_eq!(
|
||||
conversation.matches("pub fn view<").count(),
|
||||
1,
|
||||
"会话视图只能有一个 `view`"
|
||||
);
|
||||
assert_eq!(
|
||||
hosts.matches("conversation::view(").count(),
|
||||
2,
|
||||
"右栗与独立页各调用一次同一个 `conversation::view`(不多不少)"
|
||||
);
|
||||
assert!(
|
||||
!hosts.contains("text_editor("),
|
||||
"输入框的装配只许在 conversation/composer.rs 里(宿主不许自己拼一份)"
|
||||
);
|
||||
assert!(
|
||||
!hosts.contains("scrollable("),
|
||||
"滚动区的装配只许在 conversation.rs 里"
|
||||
);
|
||||
assert!(
|
||||
composer.contains("text_editor(") || composer.contains("editor(spec.draft)"),
|
||||
"输入框就在 composer 里"
|
||||
);
|
||||
}
|
||||
|
||||
/// a11y:所有动作按钮的标签都来自词表,折叠条也不是「只有图标」。
|
||||
///
|
||||
/// 抓得住:有人写死一句英文/中文在视图里("Cancel"/"发送"),或给折叠条只留一个图标按钮。
|
||||
/// 抓不住:译文本身好不好读(那要人看)。
|
||||
#[test]
|
||||
fn every_action_has_a_readable_label() {
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
for key in [
|
||||
Key::AgentCollapse,
|
||||
Key::AgentDetach,
|
||||
Key::AgentDock,
|
||||
Key::AgentOpen,
|
||||
Key::AgentSend,
|
||||
Key::AgentStop,
|
||||
Key::AgentStripLabel,
|
||||
Key::AgentResize,
|
||||
] {
|
||||
assert!(
|
||||
!catalog.text(key).trim().is_empty(),
|
||||
"{} 没有可读标签",
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
|
||||
for source in [
|
||||
include_str!("conversation.rs"),
|
||||
include_str!("conversation/composer.rs"),
|
||||
include_str!("mod.rs"),
|
||||
] {
|
||||
for literal in ["Button::new(\"", "Button::disabled(\""] {
|
||||
assert!(
|
||||
!source.contains(literal),
|
||||
"按钮标签不许写死在视图里:{literal}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!source.contains("text(\"Assistant\""),
|
||||
"文案不许写死在视图里(走 i18n 的 Key)"
|
||||
);
|
||||
}
|
||||
|
||||
// 折叠条的 48px 条上,图标下面必须有文字标签(否则就是「只有图标」)
|
||||
assert!(
|
||||
include_str!("mod.rs").contains("Key::AgentStripLabel"),
|
||||
"折叠条必须有可读的条上标签"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab 环:只在**真正存在**的控件之间走,且顺序自左向右可预期。
|
||||
#[test]
|
||||
fn the_focus_ring_walks_the_controls_that_exist() {
|
||||
// 展开且空闲:收起 → 拆出 → 发送 → 输入框(没有中断键)
|
||||
let mut focus = Focus::Collapse;
|
||||
let mut walked = vec![focus];
|
||||
for _ in 0..4 {
|
||||
focus = focus.step(false, RailState::Expanded, false);
|
||||
walked.push(focus);
|
||||
}
|
||||
assert_eq!(
|
||||
walked,
|
||||
vec![
|
||||
Focus::Collapse,
|
||||
Focus::Detach,
|
||||
Focus::Send,
|
||||
Focus::Composer,
|
||||
Focus::Collapse,
|
||||
]
|
||||
);
|
||||
|
||||
// 反向:Collapse 往回走是 Composer
|
||||
assert_eq!(
|
||||
Focus::Collapse.step(true, RailState::Expanded, false),
|
||||
Focus::Composer
|
||||
);
|
||||
|
||||
// 流式中多一档中断键
|
||||
assert_eq!(
|
||||
Focus::Send.step(false, RailState::Expanded, true),
|
||||
Focus::Stop
|
||||
);
|
||||
|
||||
// 独立页只有「收回 + 输入区」
|
||||
assert_eq!(
|
||||
Focus::Detach.step(false, RailState::Detached, false),
|
||||
Focus::Send
|
||||
);
|
||||
|
||||
// 折叠档没有会话动作:环上只有「展开」一格
|
||||
assert_eq!(
|
||||
Focus::Collapse.step(false, RailState::Collapsed, false),
|
||||
Focus::Collapse
|
||||
);
|
||||
|
||||
// `Tab` 真的把键盘巡航打开(`Enter` 的闸门)
|
||||
let mut agent = AgentUi::new();
|
||||
assert!(!agent.keyboard_mode);
|
||||
agent.step_focus(false);
|
||||
assert!(agent.keyboard_mode);
|
||||
}
|
||||
|
||||
/// 两个宿主都能构图(含空会话 / 折叠 / 流式中),且能吃 880~2560 的窗口宽度。
|
||||
///
|
||||
/// 抓得住:某档位下 `view` 的某条分支 panic(例如空 transcript + 空未读)。
|
||||
/// 抓不住:像素级观感(出图另算)。
|
||||
#[test]
|
||||
fn every_state_builds_in_both_hosts() {
|
||||
let ui = ui();
|
||||
let catalog = Catalog::of(Lang::En);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 拖拽层只在拖拽期间存在(否则它会吃掉整窗的点击)。
|
||||
#[test]
|
||||
fn the_drag_overlay_only_exists_while_dragging() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
assert!(drag_overlay(&agent).is_none());
|
||||
|
||||
agent.drag_start();
|
||||
assert!(agent.is_dragging());
|
||||
assert!(drag_overlay(&agent).is_some());
|
||||
|
||||
agent.drag_end();
|
||||
assert!(drag_overlay(&agent).is_none());
|
||||
}
|
||||
|
||||
/// 拆出档右栗必须**不占地**(否则会话会在两个地方各画一份)。
|
||||
#[test]
|
||||
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_eq!(agent.width(1180.0), 0.0);
|
||||
assert_eq!(agent.content_width(1180.0), 1180.0);
|
||||
}
|
||||
|
||||
/// 拖拽把「展开宽度」写进状态(松手后保有该宽度)。
|
||||
#[test]
|
||||
fn the_drag_writes_back_the_snapped_width() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
agent.drag_start();
|
||||
agent.drag_to(1180.0, 1180.0 - 280.0);
|
||||
agent.drag_end();
|
||||
assert_eq!(agent.expanded(), 280.0);
|
||||
assert!(!agent.is_dragging());
|
||||
|
||||
// 没按下就收到的 DragTo(例如上一步松手后的残留)不该改宽度
|
||||
let before = agent.expanded();
|
||||
agent.drag_to(1180.0, 100.0);
|
||||
assert_eq!(agent.expanded(), before);
|
||||
|
||||
// 三档预设都能被拖到(1180 下上限 612 > 360)
|
||||
for preset in PRESETS {
|
||||
agent.drag_start();
|
||||
agent.drag_to(1180.0, 1180.0 - preset);
|
||||
agent.drag_end();
|
||||
assert_eq!(agent.expanded(), preset);
|
||||
}
|
||||
}
|
||||
|
||||
/// 面板与中栗之间的缝在布局里被真的留出来了(宽度常量与 `layout_for` 同源)。
|
||||
#[test]
|
||||
fn the_resize_gutter_is_part_of_the_budget() {
|
||||
let mut agent = AgentUi::new();
|
||||
agent.expand();
|
||||
assert_eq!(
|
||||
agent.width(1180.0) + RESIZE_GUTTER,
|
||||
1180.0 - agent.content_width(1180.0)
|
||||
);
|
||||
|
||||
// 折叠与拆出档没有缝
|
||||
let collapsed = layout_for(1180.0, RailState::Collapsed, 320.0);
|
||||
assert_eq!(collapsed.gutter, 0.0);
|
||||
let detached = layout_for(1180.0, RailState::Detached, 320.0);
|
||||
assert_eq!(detached.gutter, 0.0);
|
||||
}
|
||||
|
||||
/// 会话数据结构不许在手边偷偷长字段(`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"));
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! M68 骨架卡:**只给 `Route::Agent` 的回落路径用**(AI 独立页 P1 才落地)。
|
||||
//!
|
||||
//! 内容区 = 页头(`TitleLarge`)+ 一张 `OutlinedCard`("Shell ready" + 一段说明文字)。
|
||||
//! 它原本住在 `shell/mod.rs`,M90 r2 为了让 `mod.rs` 回到 400 行以内搬到这里;
|
||||
//! 行为一个字没改(`shell::view` 的输出与拆前逐字节相同)。
|
||||
//!
|
||||
//! 为什么不能直接删:删了 `shell::view` 就没有调用点,而 `mod shell` 是私有的 ——
|
||||
//! 私有模块里的 `pub fn` 无人使用会被 `dead_code` 判死,`-D warnings` 直接红。
|
||||
|
||||
use iced::widget::{column, container};
|
||||
use iced::{Alignment, Background, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::label;
|
||||
|
||||
// ---------------------------------------------------------------- 内容区
|
||||
|
||||
pub(super) fn content<'a>(ui: &'a Ui, title: &'a str, body: &'a str) -> Element<'a, Message> {
|
||||
let metrics = ui.metrics();
|
||||
// 卡片档位由组件表决定(圆角/字级),组件层不自己挑
|
||||
let card_spec = Component::OutlinedCard.spec();
|
||||
let body_role = card_spec.support.unwrap_or(card_spec.text);
|
||||
|
||||
let header = container(label(
|
||||
ui,
|
||||
TypeRole::TitleLarge,
|
||||
true,
|
||||
title,
|
||||
ui.color(Role::OnSurface),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(metrics.page_header_height)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding::from([0.0, ui.space(Space::X6)]));
|
||||
|
||||
let card = container(
|
||||
column![
|
||||
label(
|
||||
ui,
|
||||
card_spec.text,
|
||||
true,
|
||||
"Shell ready",
|
||||
ui.color(Role::OnSurface),
|
||||
),
|
||||
label(ui, body_role, false, body, ui.color(Role::OnSurfaceVariant)),
|
||||
]
|
||||
.spacing(ui.space(Space::X2)),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(metrics.card_padding)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||||
border: ui.outlined(card_spec.shape),
|
||||
..container::Style::default()
|
||||
});
|
||||
|
||||
let body_area = container(card)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.padding(ui.space(Space::X6))
|
||||
.align_y(Alignment::Start);
|
||||
|
||||
column![header, body_area]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区(页头 + 一张卡片)。
|
||||
//! 首屏骨架(**不做业务**):自绘标题栏 + 左侧导航轨 + 内容区 + **右侧 AI 助手栏**(M104)。
|
||||
//!
|
||||
//! ## 与参考判据的对应(`research/12b-component-fidelity.md`)
|
||||
//!
|
||||
@@ -15,9 +15,18 @@
|
||||
//! 一屏只出现 4 档字号(22 / 16 / 14 / 12),且**没有实心 accent**(rubric K2:一屏最多一个实心
|
||||
//! Primary,骨架期一个都不要)。阴影只给浮层,骨架里没有浮层,所以**全屏零阴影**(rubric K3)。
|
||||
|
||||
mod fallback;
|
||||
mod nav;
|
||||
|
||||
/// AI 助手栏与 AI 独立页(M104):**物理目录是 `crates/app-iced/src/pages/agent/`**,
|
||||
/// 用 `#[path]` 挂到外壳的命名空间。
|
||||
///
|
||||
/// 为什么是 `#[path]` 而不是在 `pages/mod.rs` 加一行 `mod agent;`:`pages/mod.rs` 是四条页面线
|
||||
/// 共用的声明文件(本 mission 的 scope 没给它),而右栗是**外壳的一部分**(它挂在任何页面右边)。
|
||||
/// 先例是 `app/mod.rs` 挂 `../components/mod.rs`。物理路径只有一份,逻辑名字有两个用途:
|
||||
/// 宿主(右栗)归外壳,视图(会话组件)归页面目录。
|
||||
#[path = "../pages/agent/mod.rs"]
|
||||
pub(crate) mod agent;
|
||||
|
||||
pub use nav::NavItem;
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
@@ -36,34 +45,7 @@ use crate::cli::ForcedState;
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
/// 整棵树(**内容槽位版**:外壳画标题栏 / 缩放热区 / 导航轨,中间那块交给调用方)。
|
||||
///
|
||||
/// 这是产品页面走的入口(M90 r2 / P1-1)。为什么要它:`decorations: false` 的无边框窗口里,
|
||||
/// 拖动 / 缩放 / 最小化 / 关闭这四条消息的构造点**只有本模块**,页面树自己画的「标题栏留白带」
|
||||
/// 没有任何控件,于是产品页会变成「拖不动、缩不了、点不到关闭」。
|
||||
///
|
||||
/// 分工写死:
|
||||
///
|
||||
/// - 本模块负责:标题栏(拖动面 = **整条 48px 栏里从窗口左缘到第一个 caption** 的 `mouse_area`,
|
||||
/// 没有单独的 8px 热区;三个 caption 键各 46×48)、东/南两条缩放热区(各 `Space::X1` = 4px)、
|
||||
/// 导航轨、根容器底色;
|
||||
/// - 调用方负责:`content`(页面内容区)。它拿到的可用宽度是 `窗口宽 − rail 88`,
|
||||
/// 而页面自己的 `layout::frame_metrics` 仍以**窗口宽**为输入(它内部会再减 rail 与页边距),
|
||||
/// 两边的账不会算串。
|
||||
///
|
||||
/// 面板 α(D36 / M93)不从这里传:它是**表面色**的属性,唯一施加点是 `Ui::layer`
|
||||
/// (`crate::theme`)。外壳的根容器 / 标题栏 / 导航轨都走 `ui.layer(..)`,因此自动同源。
|
||||
pub fn view_with_content<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
app_title: &'a str,
|
||||
content: Element<'a, Message>,
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
view_with_background(ui, selected, app_title, None, content, forced)
|
||||
}
|
||||
|
||||
/// 同 [`view_with_content`],但可以在整棵树**最底下**垫一层背景(M93 的真图背景层)。
|
||||
/// 整棵树(**内容槽位版**):外壳画标题栏 / 缩放热区 / 导航轨 / 右栗,中间那块交给调用方。
|
||||
///
|
||||
/// `background` 由 `pages::appearance::layer` 产出(已与 `Message::Page` 映射过);垫在 `stack!`
|
||||
/// 最底层,于是「面板 α < 1」时能真的看见它——这就是 D34 在像素上的落点。
|
||||
@@ -73,39 +55,24 @@ pub fn view_with_background<'a>(
|
||||
app_title: &'a str,
|
||||
background: Option<Element<'a, Message>>,
|
||||
content: Element<'a, Message>,
|
||||
right: Option<Element<'a, Message>>,
|
||||
overlay: Option<Element<'a, Message>>,
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
let work_area = column![
|
||||
titlebar(ui, app_title, forced),
|
||||
row![nav::rail(ui, selected, forced), content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill),
|
||||
]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
// 右栗(M104)是 `row` 的**第三个兄弟**而不是叠层:它是布局宽度的一部分,压在上面会让
|
||||
// 中栗以为自己还有那么宽(那就是任务里禁止的「溢出」)。它的宽度由 `agent::docked` 自己声明。
|
||||
let mut work = row![nav::rail(ui, selected, forced), content]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
if let Some(right) = right {
|
||||
work = work.push(right);
|
||||
}
|
||||
|
||||
window_frame(ui, background, work_area.into())
|
||||
}
|
||||
let work_area = column![titlebar(ui, app_title, forced), work]
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
|
||||
/// 整棵树(M68 骨架版:内容区是「页头 + 一张占位卡」)。
|
||||
///
|
||||
/// **保留给 `Route::Agent` 回落**(AI 独立页 P1 才落地)。删掉它会连带
|
||||
/// `content` / `label` / `nav::rail` 变成死代码,而 `mod shell` 是私有的 ⇒ `-D warnings` 直接红。
|
||||
pub fn view<'a>(
|
||||
ui: &'a Ui,
|
||||
selected: NavItem,
|
||||
app_title: &'a str,
|
||||
page_title: &'a str,
|
||||
body: &'a str,
|
||||
forced: Option<ForcedState>,
|
||||
) -> Element<'a, Message> {
|
||||
view_with_content(
|
||||
ui,
|
||||
selected,
|
||||
app_title,
|
||||
fallback::content(ui, page_title, body),
|
||||
forced,
|
||||
)
|
||||
window_frame(ui, background, work_area.into(), overlay)
|
||||
}
|
||||
|
||||
/// 无边框窗口的「外壳壳子」:背景层 + 工作区 + 两条缩放热区 + 根容器底色。
|
||||
@@ -114,6 +81,7 @@ fn window_frame<'a>(
|
||||
ui: &'a Ui,
|
||||
background: Option<Element<'a, Message>>,
|
||||
work_area: Element<'a, Message>,
|
||||
overlay: Option<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
// 无边框窗口没有系统缩放边框 → 自己给两条热区(东/南)。
|
||||
// **必须叠层而不是兄弟列**:当兄弟列会吃掉 4px 布局宽(右留白变 28、caption 按钮离窗角 4px,
|
||||
@@ -148,10 +116,14 @@ fn window_frame<'a>(
|
||||
// 背景在最底层(M93 / D34)。有背景层时**根容器不再盖实色画布**:否则那张不透明的
|
||||
// `Canvas` 会把背景图整块挡住(r2 实测:panel α=1 时背景完全看不见)。
|
||||
// 各面板(标题栏 / 导航轨 / 卡片 / 页头带)自己画底色,空隙里露出的就是背景图。
|
||||
// 拖拽层(M104)压在最上面:它只在「按下右栗分隔条」期间存在,作用是把指针 x 变成拖拽消息
|
||||
// (`mouse_area::on_move` 只在自己的边界内回报坐标,所以这一层必须铺满整窗)。
|
||||
let has_background = background.is_some();
|
||||
let shell = match background {
|
||||
Some(layer) => stack![layer, work_area, east, south],
|
||||
None => stack![work_area, east, south],
|
||||
let shell = match (background, overlay) {
|
||||
(Some(layer), Some(overlay)) => stack![layer, work_area, east, south, overlay],
|
||||
(Some(layer), None) => stack![layer, work_area, east, south],
|
||||
(None, Some(overlay)) => stack![work_area, east, south, overlay],
|
||||
(None, None) => stack![work_area, east, south],
|
||||
};
|
||||
|
||||
container(shell)
|
||||
|
||||
@@ -111,6 +111,7 @@ macro_rules! finish {
|
||||
({ $($acc:tt)* }) => { catalog! { $($acc)* } };
|
||||
}
|
||||
|
||||
mod agent;
|
||||
mod core_text;
|
||||
mod download;
|
||||
mod font;
|
||||
@@ -127,6 +128,7 @@ mod golden;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use agent::agent_entries;
|
||||
use core_text::core_text_entries;
|
||||
use download::download_entries;
|
||||
use font::font_entries;
|
||||
@@ -155,6 +157,7 @@ nav_entries! {
|
||||
core_text_entries,
|
||||
download_entries,
|
||||
logs_entries,
|
||||
agent_entries,
|
||||
finish,
|
||||
{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! AI 助手栏 / AI 独立页的文案(M104 `AGENTRAIL`)。
|
||||
//!
|
||||
//! 这两处**共用同一套会话视图**(`crates/app-iced/src/pages/agent/`),所以文案也只在这里出现一次:
|
||||
//! 栏上的折叠/拆出、页上的收回、两处都要的发送/中断/空会话引导。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后,
|
||||
//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。
|
||||
//!
|
||||
//! **新增 key 时按顺序追加在最后**:金表是「拆分前那份表的逐字快照」,重排既有行会让它红。
|
||||
|
||||
macro_rules! agent_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── AI 助手(右栗 + 独立页共用)
|
||||
AgentTitle => ("Assistant", "助手"),
|
||||
AgentSubtitle => ("Conversation shell · no backend yet", "会话外壳 · 后端未接"),
|
||||
AgentCollapse => ("Collapse", "收起"),
|
||||
AgentDetach => ("Pop out", "拆出"),
|
||||
AgentDock => ("Dock back", "收回"),
|
||||
AgentOpen => ("Open assistant", "打开助手"),
|
||||
AgentStripLabel => ("AI", "AI"),
|
||||
AgentSend => ("Send", "发送"),
|
||||
AgentStop => ("Stop", "中断"),
|
||||
AgentPlaceholder => ("Message the assistant…", "给助手发消息…"),
|
||||
AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"),
|
||||
AgentEmptyBody => ("Ask about instances, downloads or logs. No model is wired up yet.", "可以问实例、下载或日志。模型还没接上。"),
|
||||
AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
|
||||
AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"),
|
||||
AgentUnread => ("{count} unread", "{count} 条未读"),
|
||||
AgentResize => ("Drag to resize the assistant rail", "拖动调整助手栏宽度"),
|
||||
AgentWidthLabel => ("Width {width}", "宽度 {width}"),
|
||||
AgentRoleYou => ("You", "你"),
|
||||
AgentRoleAssistant => ("Assistant", "助手"),
|
||||
AgentRoleNotice => ("Notice", "提示"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use agent_entries;
|
||||
@@ -94,4 +94,24 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("LogsEmptyTitle", "No output yet", "还没有输出"),
|
||||
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
|
||||
("LogsEmptyAction", "Choose an instance", "选择实例"),
|
||||
("AgentTitle", "Assistant", "助手"),
|
||||
("AgentSubtitle", "Conversation shell · no backend yet", "会话外壳 · 后端未接"),
|
||||
("AgentCollapse", "Collapse", "收起"),
|
||||
("AgentDetach", "Pop out", "拆出"),
|
||||
("AgentDock", "Dock back", "收回"),
|
||||
("AgentOpen", "Open assistant", "打开助手"),
|
||||
("AgentStripLabel", "AI", "AI"),
|
||||
("AgentSend", "Send", "发送"),
|
||||
("AgentStop", "Stop", "中断"),
|
||||
("AgentPlaceholder", "Message the assistant…", "给助手发消息…"),
|
||||
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
|
||||
("AgentEmptyBody", "Ask about instances, downloads or logs. No model is wired up yet.", "可以问实例、下载或日志。模型还没接上。"),
|
||||
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
|
||||
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
|
||||
("AgentUnread", "{count} unread", "{count} 条未读"),
|
||||
("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"),
|
||||
("AgentWidthLabel", "Width {width}", "宽度 {width}"),
|
||||
("AgentRoleYou", "You", "你"),
|
||||
("AgentRoleAssistant", "Assistant", "助手"),
|
||||
("AgentRoleNotice", "Notice", "提示"),
|
||||
];
|
||||
Reference in new issue
Block a user