复审 r2 的两条 p2 都是「上一轮各差一步」:字段/焦点都真的做了,但**另有三处没接上**、
**键盘闸是代码自己打开的**。
## P2-1:四处文案只认一个开关
r1 只有标题胶囊由 `simulated` 驱动;副标题 / 输入框占位符 / 空态正文是**无条件取词**,
而它们的译文里写着「模拟 / 没接真实模型」——接上真 transport 之后这三句会**反向**留着
(`simulated == false` 时界面还在声明「没接真实模型」)。而且没有一条测试钉得住那个声明。
现在:
- 新增 `pages/agent::Disclaimer { simulated, subtitle, placeholder, empty_body }`,
`Disclaimer::of(catalog, simulated)` 一次算好;`Conversation.simulated: bool` 换成
`Conversation.note: Disclaimer`,header / composer / transcript 三处各自从它取词——
**视图里没有第二种取词路径**;
- i18n 配成对:模拟态用既有的 `AgentSubtitle` / `AgentPlaceholder` / `AgentEmptyBody`,
真实态新增 `AgentSubtitleReal` / `AgentPlaceholderReal` / `AgentEmptyBodyReal`(中性话)。
(没有把三句改成中性话:那句告知本身有价值,接真模型后它才该消失。)
- 证据:`crates/app-iced/src/pages/agent/tests/disclaimer.rs`
—— `all_four_disclaimers_appear_when_the_backend_is_simulated`(四处都提模拟)、
`no_disclaimer_survives_when_a_real_backend_is_wired`(四处都不提,且不许用空串糊)、
`the_two_states_use_different_keys`(两态不能共用同一个 key)。
**golden.rs 的 400 行边界**:+3 条会破线。按「按功能拆、不压注释」处理——把这张表的
**说明文档**(35 行)原样搬到 `catalog.rs` 里 `mod golden;` 的文档上(一个字的说明都没删),
`golden.rs` 只留表数据 + 3 行指针。现在 `golden.rs` 374 行、`catalog.rs` 328 行。
## P2-2:键盘闸不许由代码替用户打开
r1 在 `sync_confirm_focus`(`on_agent` 的无条件尾巴,`Events` 又由心跳驱动)里顺手
`keyboard_mode = true`——于是**只要冒出待确认,全局键盘闸就开了**;用户发完那句话接着打下一句,
第一个空格就成了「批准写操作」。复审用临时用例复现过(`rail.confirm` 变 `None`)。
现在:
- `sync_confirm_focus` **只挪焦点**到「允许」,不再碰 `keyboard_mode`(字段文档里写清了为什么);
- 键盘用户的表态是 `Tab`:`AgentUi::step_focus` 加了一条 **arming 规则**——待确认、焦点已在
确认条上、但键盘闸还关着时,这一次 `Tab` **只点亮焦点环、不移动焦点**(否则第一次 Tab 会把
「允许」挪到「拒绝」,用户按 Enter 就是反着裁决);之后再 Tab 才逐格走;
- 反向回归:`a_stray_space_or_enter_never_approves_a_pending_write`(打字 → 空格 / 回车都不得裁决,
审计也不许留);`a_blocking_confirm_can_be_decided_with_the_keyboard_alone` 改成
「确认到达时未 armed → Tab 点亮 → Enter 允许;第二轮 Tab·Tab 到拒绝 → Space」。
## 出图
`DevDocs/artifacts/m119/` 18 张全部重出(新增取证夹具 `ULL_AGENT_FIXTURE=armed`:
跑一轮真提交后再按一次 Tab,专供「焦点环可见」那张图):
- `08-agent-simulated-{light,dark}`:`simulated = true` 下四处「模拟」同时可见;
- `09-agent-confirm-focus-{light,dark}`:**按过 Tab 之后**的确认条(焦点环在「允许」上);
- `05-agent-confirm-{light,dark}`:确认条刚出现、**还没 arm**(无焦点环)——与 09 成对,
正好把 r2 的行为差异拍下来。
## 门禁(真实退出码,最终树)
| 命令 | 退出码 |
|---|---|
| `cargo fmt --all --check` | 0 |
| `cargo clippy -p unlucky-app-iced -p unlucky-presenter -p unlucky-agent -p unlucky-i18n --all-targets -- -D warnings` | 0(0 warning)|
| `cargo test -p unlucky-app-iced --lib` | 0(605 passed / 0 failed / 2 ignored)|
| `cargo test --workspace --exclude unlucky-app` | 0(58 套 2281 passed / 0 failed / 11 ignored)|
全树受检文件无 >400(`golden.rs` 374、`catalog.rs` 328、`pages/agent/mod.rs` 371)。
280 lines
12 KiB
Rust
280 lines
12 KiB
Rust
//! AI 助手栏的 presenter 句柄(M119):四个契约 + 事件队列 + 具名错误。
|
||
//!
|
||
//! ## 它替掉的是什么
|
||
//!
|
||
//! M104 交付的壳是**自带样本会话**的(`pages/agent/demo.rs` 的 `transcript()`,`Submit` 只往
|
||
//! 本地数组里追加一条「没发给模型」的提示)。M119 把这些全部删掉:会话内容的唯一来源是
|
||
//! [`Presenter::agent_rail`],壳里的 `Submit` / `Stop` / `Confirm` / 标记已读**全部**翻成
|
||
//! presenter 的那四个签名——**壳里一行会话状态都不留**。
|
||
//!
|
||
//! | 壳里的动作 | 落到哪 |
|
||
//! |---|---|
|
||
//! | 输入框打字 | [`AgentHost::set_draft`] → `Presenter::agent_set_draft`(切形态不丢) |
|
||
//! | 发送 | [`AgentHost::submit`] → `Presenter::agent_submit` |
|
||
//! | 打开栏 / 停在独立页 | [`AgentHost::mark_read`] → `Presenter::agent_mark_read` |
|
||
//! | 允许/拒绝写操作 | [`AgentHost::confirm`] → `Presenter::agent_confirm` |
|
||
//! | 事件回流 | [`AgentHost::apply`] → `Presenter::apply`(唯一状态写入口) |
|
||
//! | 栏可见性 | [`AgentHost::sync_visibility`] → `Presenter::navigate` + `prefs_mut` |
|
||
//!
|
||
//! ## 为什么句柄里还要持一份队列与失败槽
|
||
//!
|
||
//! presenter 不持有定时器,也不认识 iced(crate 文档 §4)。异步后端的事件只能落在**进程内队列**
|
||
//! 里由 UI 抽;失败原因同理(`AgentTransport::submit` 只回一个 `String`,而 UI 要按**原因码**
|
||
//! 选 i18n 文案,不能去匹配错误串)。两个 `Arc<Mutex<..>>` 就这两件东西,和
|
||
//! [`super::transport::RigTransport`] 共享,**transport 只写、UI 只读**。
|
||
//!
|
||
//! ## 未读口径为什么依赖两行同步
|
||
//!
|
||
//! presenter 判「栏在用户眼前」用的是它自己那份 `router` + `prefs.ai_collapsed`
|
||
//! (`AppState::agent_rail_visible`),而路由与档位的真源在 `App`。所以每次 `refresh_view`
|
||
//! 都把这两个事实按**窄入口**(`navigate` / `prefs_mut`)写回去——这是 presenter 目前对
|
||
//! 「栏可见吗」唯一开放的接口。更好的形状是给 presenter 加一个 `agent_set_visible(bool)`
|
||
//! 窄入口(已按 finding 报给塔台),那样这两行同步就可以删。
|
||
|
||
use unlucky_i18n::Key;
|
||
use unlucky_presenter::agent::{AgentRail, Presenter};
|
||
use unlucky_presenter::dto::AppEvent;
|
||
use unlucky_presenter::router::{AiPanelMode, Route};
|
||
|
||
use super::transport::{EventQueue, FailureSlot, RigTransport, Script};
|
||
|
||
/// 一个**具名**的失败原因(UI 据码选 catalog 文案;后端诊断原文只进日志)。
|
||
///
|
||
/// 为什么不是 `String`:M114 复审 r1 的 p1 就是这么来的——后端诊断是中文人话,直接上屏会让
|
||
/// 英文界面里出现整句中文。原因码是 ascii 且不随语言变(日志 / 测试 / 用户报障都靠它对齐)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum AgentFailure {
|
||
/// 端点不可达(连接失败 / 超时)。
|
||
Unreachable,
|
||
/// 这条请求的路由还没接线(真实 LLM 后端未装配)。
|
||
NotImplemented,
|
||
}
|
||
|
||
impl AgentFailure {
|
||
/// 稳定 ascii 码(日志与测试断言用)。
|
||
pub(crate) const fn code(self) -> &'static str {
|
||
match self {
|
||
Self::Unreachable => "unreachable",
|
||
Self::NotImplemented => "not_implemented",
|
||
}
|
||
}
|
||
|
||
/// 横幅正文的 catalog 键。
|
||
pub(crate) const fn body_key(self) -> Key {
|
||
match self {
|
||
Self::Unreachable => Key::AgentErrorUnavailable,
|
||
Self::NotImplemented => Key::AgentErrorNotImplemented,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一次提交的结局。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum SubmitOutcome {
|
||
/// 交给后端了(事件稍后从队列里回来)。
|
||
Sent,
|
||
/// 后端明确失败:UI 要显示具名错误。
|
||
Failed(AgentFailure),
|
||
/// presenter 拒收(空文本 / 上一轮还在跑 / 有写操作待确认)。
|
||
///
|
||
/// 不进横幅:这三种情形正常情况下**够不着**(发送键在空白/忙碌时是禁用的,确认框没关时
|
||
/// 输入区也不该能发),走到这里说明某处闸门漏了 ⇒ 留一行日志,别拿一句话糊在界面上。
|
||
Rejected,
|
||
}
|
||
|
||
/// AI 助手栏的 presenter 句柄。
|
||
pub(crate) struct AgentHost {
|
||
presenter: Presenter,
|
||
/// 这套后端是不是**模拟**的(M119 r1 / 复审 P2-1)。
|
||
///
|
||
/// 今天恒 `true`:transport 只有 `RigTransport` 一种(脚本化,没有真模型)。接真 `AgentTransport`
|
||
/// 的那一天,这里传 `false`——**四处「模拟」措辞会一起消失**(标题胶囊 / 副标题 / 输入框占位符 /
|
||
/// 空态正文,全部出自 `pages::agent::Disclaimer::of(catalog, simulated)`)。
|
||
/// 标记不是硬编码在视图里的,它是这个事实字段的渲染;「四处一起变」由
|
||
/// `pages/agent/tests/disclaimer.rs` 钉住(r1 时只有一处认它,那句声明是空的)。
|
||
simulated: bool,
|
||
queue: EventQueue,
|
||
failure: FailureSlot,
|
||
/// 最近一次提交失败(具名;下一次成功提交或重试用尽时清掉)。
|
||
last_failure: Option<AgentFailure>,
|
||
/// 上一次写回 presenter 的「栏可见性」(独立页?, 折叠?),相同就不重复写(`navigate` 有副作用)。
|
||
synced: Option<(bool, bool)>,
|
||
}
|
||
|
||
impl AgentHost {
|
||
/// 指定脚本(单测与取证开关都走它)。
|
||
pub(crate) fn with_script(script: Script) -> Self {
|
||
let transport = RigTransport::new(script);
|
||
let queue = transport.queue();
|
||
let failure = transport.failure();
|
||
Self {
|
||
presenter: Presenter::new(Box::new(transport)),
|
||
// 脚本化 rig ⇒ 一定是模拟的(见字段文档)
|
||
simulated: true,
|
||
queue,
|
||
failure,
|
||
last_failure: None,
|
||
synced: None,
|
||
}
|
||
}
|
||
|
||
/// 按 `ULL_AGENT_SCRIPT` 选脚本(没设 = `qa`)。
|
||
pub(crate) fn from_env() -> Self {
|
||
Self::with_script(Script::from_env())
|
||
}
|
||
|
||
/// 当前会话视图模型(**唯一**读入口;两形态共享同一份)。
|
||
pub(crate) fn rail(&self) -> &AgentRail {
|
||
self.presenter.agent_rail()
|
||
}
|
||
|
||
/// 这套后端是不是模拟的(界面据此常驻「模拟」标记;见字段文档)。
|
||
pub(crate) fn is_simulated(&self) -> bool {
|
||
self.simulated
|
||
}
|
||
|
||
/// 最近一次提交失败的具名原因。
|
||
pub(crate) fn last_failure(&self) -> Option<AgentFailure> {
|
||
self.last_failure
|
||
}
|
||
|
||
/// 抽干事件队列(UI 在心跳里调;返回的事件**只**经 `Message::Agent(Events)` 回流)。
|
||
pub(crate) fn drain(&mut self) -> Vec<AppEvent> {
|
||
let mut drained = Vec::new();
|
||
if let Ok(mut queue) = self.queue.lock() {
|
||
drained.extend(queue.drain(..));
|
||
}
|
||
drained
|
||
}
|
||
|
||
/// 喂一条事件(纯转发给 `AppState::apply`)。
|
||
pub(crate) fn apply(&mut self, event: AppEvent) {
|
||
self.presenter.apply(event);
|
||
}
|
||
|
||
/// 提交一句话。
|
||
pub(crate) fn submit(&mut self, text: String) -> SubmitOutcome {
|
||
match self.presenter.agent_submit(text) {
|
||
Ok(()) => {
|
||
self.last_failure = None;
|
||
SubmitOutcome::Sent
|
||
}
|
||
Err(detail) => {
|
||
// presenter 已经把错误串脱敏(`agent/tests_red_line.rs` 钉住),这里只进日志。
|
||
eprintln!("[agent] submit 失败:{detail}");
|
||
match self.failure.lock().ok().and_then(|mut slot| slot.take()) {
|
||
Some(failure) => {
|
||
self.last_failure = Some(failure);
|
||
SubmitOutcome::Failed(failure)
|
||
}
|
||
None => SubmitOutcome::Rejected,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 未读清零(用户打开栏 / 停在独立页)。
|
||
pub(crate) fn mark_read(&mut self) {
|
||
self.presenter.agent_mark_read();
|
||
}
|
||
|
||
/// 对当前待确认的写操作做决定。
|
||
pub(crate) fn confirm(&mut self, accept: bool) {
|
||
self.presenter.agent_confirm(accept);
|
||
}
|
||
|
||
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
||
pub(crate) fn set_draft(&mut self, text: String) {
|
||
self.presenter.agent_set_draft(text);
|
||
}
|
||
|
||
/// 把「用户此刻看得见这段会话吗」写回 presenter(未读口径的输入)。
|
||
///
|
||
/// 只在两个事实变化时写:`Presenter::navigate(Route::Agent)` 会顺手展开栏偏好并清未读,
|
||
/// 每次 `refresh_view` 都写一遍会把用户攒的未读吃掉。
|
||
pub(crate) fn sync_visibility(&mut self, on_agent_page: bool, collapsed: bool) {
|
||
if self.synced == Some((on_agent_page, collapsed)) {
|
||
return;
|
||
}
|
||
self.synced = Some((on_agent_page, collapsed));
|
||
match (on_agent_page, self.presenter.state().ai_mode()) {
|
||
(true, AiPanelMode::Docked) => self.presenter.navigate(Route::Agent),
|
||
(false, AiPanelMode::Page) => self.presenter.navigate(Route::Instances),
|
||
_ => {}
|
||
}
|
||
// 顺序不能反:`navigate(Route::Agent)` 内部会把 `ai_collapsed` 置 false。
|
||
self.presenter.prefs_mut().ai_collapsed = collapsed;
|
||
}
|
||
|
||
/// 本进程内的裁决记录条数(取证/测试用;权威审计在 agent 侧)。
|
||
#[cfg(test)]
|
||
pub(crate) fn audit_len(&self) -> usize {
|
||
self.presenter.audit().len()
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Debug for AgentHost {
|
||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
formatter
|
||
.debug_struct("AgentHost")
|
||
.field("rail", self.rail())
|
||
.field("last_failure", &self.last_failure)
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn each_failure_code_is_stable_ascii_and_has_its_own_body_key() {
|
||
assert_eq!(AgentFailure::Unreachable.code(), "unreachable");
|
||
assert_eq!(AgentFailure::NotImplemented.code(), "not_implemented");
|
||
assert_ne!(
|
||
AgentFailure::Unreachable.body_key(),
|
||
AgentFailure::NotImplemented.body_key(),
|
||
"两种失败要两句话,不能都糊成一句"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_submit_only_reaches_the_backend_once_per_turn_while_busy() {
|
||
let mut host = AgentHost::with_script(Script::Streaming);
|
||
assert_eq!(host.rail().messages.len(), 0);
|
||
assert_eq!(host.submit("hello".to_string()), SubmitOutcome::Sent);
|
||
// 队列里的这一批要经 `apply` 才进 rail(app 层由 `Message::Agent(Events)` 做同一件事)
|
||
for event in host.drain() {
|
||
host.apply(event);
|
||
}
|
||
assert!(host.rail().busy, "流式没收敛前输入要禁用");
|
||
// 第二轮被 presenter 拒收(不是服务器错误,所以不进横幅)
|
||
assert_eq!(host.submit("again".to_string()), SubmitOutcome::Rejected);
|
||
assert_eq!(host.last_failure(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unreachable_endpoint_is_named_not_stringified() {
|
||
let mut host = AgentHost::with_script(Script::Unreachable);
|
||
assert_eq!(
|
||
host.submit("hello".to_string()),
|
||
SubmitOutcome::Failed(AgentFailure::Unreachable)
|
||
);
|
||
assert_eq!(host.last_failure(), Some(AgentFailure::Unreachable));
|
||
assert!(host.drain().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn visibility_is_written_back_once_per_change() {
|
||
let mut host = AgentHost::with_script(Script::Qa);
|
||
host.sync_visibility(false, true);
|
||
host.sync_visibility(false, true);
|
||
assert!(host.presenter.state().prefs.ai_collapsed);
|
||
host.sync_visibility(true, false);
|
||
assert_eq!(host.presenter.state().ai_mode(), AiPanelMode::Page);
|
||
assert!(!host.presenter.state().prefs.ai_collapsed);
|
||
host.sync_visibility(false, false);
|
||
assert_eq!(host.presenter.state().ai_mode(), AiPanelMode::Docked);
|
||
}
|
||
}
|