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:
AskaEth committed 2026-11-04 00:21:32 +08:00
1 parent 875ac159ee
commit c3f20784a8
20 files changed
+2463 -153

No files matched your search

+113
View File
@@ -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)
}