feat(app-iced): M119 把 presenter 的 agent 四契约与账户窄入口接进 iced 壳
M103 把 AI 会话的投影(`AgentRail` + `submit/mark_read/confirm`)做完了,M114 把账户三轨做完,
但 iced 壳一直没接:M103 复审确认 `Presenter::` 零非测试消费者,M104 的右栏会话数据来自内置样本。
这一个提交就是那一步——**右栏与账户页吃 presenter 的真实状态**。
## 接线层(新增 `crates/app-iced/src/presenter/`)
| 文件 | 是什么 |
|---|---|
| `agent.rs` | `AgentHost`:presenter 句柄(四契约)+ 事件队列 + **具名**失败(`AgentFailure`:`unreachable` / `not_implemented`)|
| `transport.rs` | `AgentTransport` 的 iced 侧实现(脚本化 rig + 五种剧情 + 两种失败注入)|
| `rig.rs`(仅测试) | 账户轨的 `Backend`:`AppCommand` → host 形状的 `CoreEvent` |
| `mod.rs` | 逻辑路径说明 + **线程/通道边界** |
物理目录 `src/presenter/`,逻辑名字 `crate::app::state::presenter`——由 `app/state.rs` 用
`#[path]` 挂载(与 `shell::agent` 挂 `pages/agent/` 同一个先例:scope 不含 `lib.rs`)。
## 事件回流
`transport` 只**入队**、UI 只**出队**:`advance`→`pump` 里同拍抽干,抽出来的这批经
**既有 `Message::Agent(..)` 通道**(新变体 `Events(Vec<AppEvent>)`)回到 `on_agent`,再由
`Presenter::apply` 落地。同一条漏斗,所以用户点击与后端事件不会各改一次状态。
## 会话内容只留一份
`AgentUi` 里的 `transcript/busy/unread` **删掉**,右栏与独立页直接读 `agent_rail()`
(`pages/agent::spec` 零拷贝借)。M104 的样本会话(`demo::transcript()`)一并删除:出厂就是
空会话,取证夹具要内容就**跑一轮真提交**(`ULL_AGENT_FIXTURE=turn`),消息只能由 presenter 产生。
## 视图
- 气泡按 `ChatMessage.role` 画(User/Assistant/Tool/System),没有任何壳自己的会话类型;
- 新增两条横幅(`conversation/banners.rs`):**失败态**(catalog 标题 + 已脱敏正文)与
**写操作确认**(摘要 + Allow/Deny → `agent_confirm`);
- 拖动缝与折叠条按 400 行硬线拆到 `mod.rs` 之外(`strip.rs`、`conversation/{transcript,banners}.rs`)。
## 未读口径
presenter 判「栏可见吗」用的是它自己的 router + `prefs.ai_collapsed`,而这两件的真源在 `App`。
`refresh_view` 用 presenter 的两个**窄入口**(`navigate` / `prefs_mut().ai_collapsed`)单点写回,
带幂等闸(相同就不重复写,避免 `navigate(Route::Agent)` 顺手 mark_read 吃掉用户攒的未读)。
## 账户页
账户列表与登录事件仍走 `AppCommand`/`CoreEvent`(M114 的形状没动),本轮把**登录指令与
列表消费**纳入同一套树测试:添加离线档案 → 列表出现 → 切换 → 删除;设备码「进行中可见 + 可取消」;
失败态(名字不合法 / 凭据失效)都给具名错误。
## 树测试(9 条,`cargo test -p unlucky-app-iced --lib` 全绿:599 passed / 0 failed)
- `app/update/agent/tests.rs`:提交 → 事件回流 → 右栏出现该轮 + `busy` 翻转 + 标记已读归零;
确认条可裁决且记审计;三种失败各给具名横幅;草稿逐字同步。
- `app/update/account/tests.rs`:增/切/删端到端;设备码进行中与取消;两种失败态;
M114 的三条回归闸(原内联测试搬出来)。
- `pages/agent/tests.rs`:视图契约(`spec` 原样借 rail)、a11y 标签、焦点环、两种宿主全态构图。
顺带:`app/tests.rs` 里三条 `agent.unread` 断言跟着 rail 走(出厂 0 未读),`view/account_page.rs`
的 `docked` 调用点补上 rail 与失败横幅。
This commit is contained in:
1 parent
6505493d4e
commit
d4fd675935
25 files changed
+2390
-698
No files matched your search
@@ -1,54 +1,54 @@
|
||||
//! 占位样本数据:一段内置会话 + 出图矩阵要用的 `ULL_AGENT_*` 取证开关。
|
||||
//! 取证开关(`ULL_AGENT_*`):三档档位 / 展开宽度 / 会话夹具。
|
||||
//!
|
||||
//! ## 为什么是内置样本而不是 presenter
|
||||
//! ## 与 M104 的区别:这里不再有「样本会话」
|
||||
//!
|
||||
//! 本 mission 只做**壳**:真数据由 M103(`presenter::agent::AgentRail` + 四个签名)供,
|
||||
//! 这里一行 presenter 调用都没有(任务里点名「不许提前调用」)。样本的职责只有两个:
|
||||
//! ①让三档状态在图上都有内容;②让「空会话」有对照([`FIXTURE_ENV`])。
|
||||
//! M104 的 `demo::transcript()` 是**内置会话样本**,右栗出厂就画着四条假对话——那是「壳先搭好、
|
||||
//! 等 M103 接」的过渡态。M119 接线之后会话的唯一来源是 presenter,于是样本只能以**真链路**的
|
||||
//! 方式进来:夹具不造消息,而是**走一次真的提交**(`agent_submit` → transport → 事件队列 →
|
||||
//! `Message::Agent(Events)` → `apply`)。跑什么脚本由 `ULL_AGENT_SCRIPT`(见
|
||||
//! [`crate::app::state::presenter`])决定,夹具只决定「要不要在启动时跑这一轮」。
|
||||
//!
|
||||
//! 样本正文是**演示数据**,与 `pages/instances/data.rs` 的 demo 同一性质:它不是界面文案,
|
||||
//! 不进 i18n 词表(界面上会随真模型一起消失)。界面自己的文案(标题/按钮/空态/提示)全部走 `Key`。
|
||||
//! 于是出厂态是**空会话**(空态引导卡)——这才是没有模型的启动器该有的样子。
|
||||
//!
|
||||
//! ## 取证开关(只影响这一次进程,语义与 `ULL_APPEARANCE_*` / `ULL_START_ROUTE` 一致)
|
||||
//! ## 取值表
|
||||
//!
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | [`RAIL_ENV`] | `collapsed` / `expanded` / `detached` | 启动时直接落到某一档(三态矩阵) |
|
||||
//! | [`FIXTURE_ENV`] | `empty` / `streaming` / `unread` / `focused` | 空会话(零消息)/ 流式中(中断键可见)/ 展开但带未读(单独看徽标)/ 键盘焦点环(`Tab` 走过一次的样子) |
|
||||
//! | [`FIXTURE_ENV`] | `turn` / `unread` / `focused` / `empty` | 启动时跑一轮真提交 / 折叠态跑(留未读)/ 键盘焦点环 / 什么都不做 |
|
||||
//! | [`WIDTH_ENV`] | `280` / `320` / `360` | 展开档宽度(磁铁档位的对照图) |
|
||||
//!
|
||||
//! 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
|
||||
|
||||
use super::metrics::PRESETS;
|
||||
use super::state::{AgentUi, RailState, Speaker, Turn};
|
||||
use super::state::{AgentUi, RailState};
|
||||
|
||||
/// 启动档位。
|
||||
pub const RAIL_ENV: &str = "ULL_AGENT_RAIL";
|
||||
/// 会话样本(`empty` / `streaming`)。
|
||||
/// 会话夹具(`turn` / `unread` / `focused` / `empty`)。
|
||||
pub const FIXTURE_ENV: &str = "ULL_AGENT_FIXTURE";
|
||||
/// 展开档宽度(只接受三档预设值)。
|
||||
pub const WIDTH_ENV: &str = "ULL_AGENT_WIDTH";
|
||||
|
||||
/// 内置会话样本(4 条,两类说话人 + 一条工具提示)。
|
||||
pub fn transcript() -> Vec<Turn> {
|
||||
vec![
|
||||
Turn::new(Speaker::You, "Which instance should I start for a 1.20.4 test?"),
|
||||
Turn::new(
|
||||
Speaker::Assistant,
|
||||
"Survival is on 1.20.4 with Fabric 0.16.5 and its files are present. Redstone Lab is the same \
|
||||
version but its folder is missing a jar.",
|
||||
),
|
||||
Turn::new(Speaker::You, "Use Survival then. Anything I should know first?"),
|
||||
Turn::new(
|
||||
Speaker::Assistant,
|
||||
"Java 21 is already adopted for that instance, and the last launch exited normally. \
|
||||
Say the word and I will queue it.",
|
||||
),
|
||||
]
|
||||
/// 夹具:启动时对**真 presenter** 做什么。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Fixture {
|
||||
/// 什么都不做(出厂态:空会话)。`ULL_AGENT_FIXTURE=empty` 显式写出来也一样。
|
||||
None,
|
||||
/// 跑一轮真提交(脚本由 `ULL_AGENT_SCRIPT` 决定:问答 / 断流 / 写操作 / 两种失败)。
|
||||
Turn,
|
||||
/// 折叠态跑一轮真提交:于是未读会真的被 presenter 累加(不是硬编码的数字)。
|
||||
Unread,
|
||||
/// 键盘巡航一次的样子(焦点环 = 10% 状态层)。
|
||||
Focused,
|
||||
}
|
||||
|
||||
/// 按环境变量构造一份状态(没设变量 = `None`,调用方用出厂态)。
|
||||
///
|
||||
/// 解析失败一律**忽略**(取证开关不该让进程起不来),并在 stderr 留一行,方便对照日志读图。
|
||||
pub fn from_env() -> Option<AgentUi> {
|
||||
/// 夹具里的那句用户消息(**演示数据**,不是界面文案:它跟实例名一样属于样本内容,
|
||||
/// 不进 i18n 词表——接上真模型之后它就不存在了)。
|
||||
pub const DEMO_QUESTION: &str = "Which instance should I start for a 1.20.4 test?";
|
||||
|
||||
/// 按环境变量构造一份 UI 状态 + 夹具(没设任何变量 = `None`,调用方用出厂态)。
|
||||
pub fn from_env() -> Option<(AgentUi, Fixture)> {
|
||||
let rail = std::env::var(RAIL_ENV).ok();
|
||||
let fixture = std::env::var(FIXTURE_ENV).ok();
|
||||
let width = std::env::var(WIDTH_ENV).ok();
|
||||
@@ -57,11 +57,7 @@ pub fn from_env() -> Option<AgentUi> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let fixture = fixture.as_deref().map(str::trim);
|
||||
let mut ui = match fixture {
|
||||
Some("empty") => AgentUi::empty(),
|
||||
_ => AgentUi::new(),
|
||||
};
|
||||
let mut ui = AgentUi::new();
|
||||
|
||||
if let Some(raw) = width.as_deref().map(str::trim) {
|
||||
match raw.parse::<f32>() {
|
||||
@@ -70,13 +66,9 @@ pub fn from_env() -> Option<AgentUi> {
|
||||
}
|
||||
}
|
||||
|
||||
if fixture == Some("streaming") {
|
||||
ui.pretend_streaming();
|
||||
}
|
||||
|
||||
// 档位走**真迁移**(`expand` / `detach` / `collapse`)而不是直接写字段:这样未读的语义
|
||||
// (展开即读)在取证图里也成立,不会出现「展开着还挂着 2 条未读」这种自相矛盾的画面。
|
||||
// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(见模块文档的表)。
|
||||
// 唯一要单独看徽标的情形用 `ULL_AGENT_FIXTURE=unread` 显式造(折叠态跑一轮)。
|
||||
match rail.as_deref().map(str::trim) {
|
||||
None => ui.expand(),
|
||||
Some(name) => match RailState::from_name(name) {
|
||||
@@ -90,24 +82,43 @@ pub fn from_env() -> Option<AgentUi> {
|
||||
},
|
||||
}
|
||||
|
||||
if fixture == Some("unread") {
|
||||
ui.unread = 3;
|
||||
}
|
||||
|
||||
if fixture == Some("focused") {
|
||||
// 键盘巡航一次的样子(焦点环 = 10% 状态层)。真窗口里按一次 `Tab` 就会到这一态,
|
||||
// 但截图通道收不到键盘输入,所以只能这样取证。
|
||||
ui.step_focus(false);
|
||||
}
|
||||
let fixture = match fixture.as_deref().map(str::trim) {
|
||||
Some("turn") => Fixture::Turn,
|
||||
Some("unread") => Fixture::Unread,
|
||||
Some("focused") => Fixture::Focused,
|
||||
Some("empty") | None => Fixture::None,
|
||||
Some(other) => {
|
||||
eprintln!("[agent] {FIXTURE_ENV}={other:?} 不是夹具之一,按 empty 处理");
|
||||
Fixture::None
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"[agent] rail={} width={} turns={} busy={} unread={}",
|
||||
"[agent] rail={} width={} fixture={fixture:?}",
|
||||
ui.rail.name(),
|
||||
ui.expanded(),
|
||||
ui.transcript.len(),
|
||||
ui.busy,
|
||||
ui.unread
|
||||
);
|
||||
|
||||
Some(ui)
|
||||
Some((ui, fixture))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_demo_question_is_plain_ascii_so_a_clean_ci_has_the_glyphs() {
|
||||
assert!(
|
||||
DEMO_QUESTION.is_ascii(),
|
||||
"夹具正文不带非 ascii(干净 CI 无中文字体)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_rail_state_name_round_trips_through_the_env_parser() {
|
||||
for state in RailState::ALL {
|
||||
assert_eq!(RailState::from_name(state.name()), Some(state));
|
||||
}
|
||||
assert_eq!(RailState::from_name("nope"), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user