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
@@ -0,0 +1,264 @@
|
||||
//! 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,
|
||||
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)),
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! iced 侧的 presenter 接线层(M119)。
|
||||
//!
|
||||
//! ## 为什么单独一个目录
|
||||
//!
|
||||
//! presenter 已经把「AI 会话怎么攒、未读怎么算、YOLO 怎么裁决」做完了(M103),壳层只该
|
||||
//! **读视图模型 + 把点击翻成四个方法**。这份「只做翻译」的胶水需要地方放,而且要能和 presenter
|
||||
//! 自己的契约分开审:所以是独立目录,不塞进 `app/` 的 update 分支里。
|
||||
//!
|
||||
//! | 文件 | 职责 | 不负责 |
|
||||
//! |---|---|---|
|
||||
//! | [`agent`] | [`AgentHost`]:presenter 句柄(四契约)+ 事件队列 + 具名错误 | 画、算脱敏 |
|
||||
//! | [`transport`] | [`AgentTransport`](unlucky_presenter::agent::AgentTransport) 的 iced 侧实现(脚本化 rig + 三种失败注入) | 真实模型调用 |
|
||||
//! | `rig`(仅测试) | 账户轨的 `Backend` 实现:`AppCommand` → host 形状的 `CoreEvent` | 真 AccountStore 落盘 |
|
||||
//!
|
||||
//! ## 逻辑路径与物理路径
|
||||
//!
|
||||
//! 物理目录是 `crates/app-iced/src/presenter/`,逻辑名字是 `crate::app::state::presenter`——由
|
||||
//! `app/state.rs` 用 `#[path = "../presenter/mod.rs"]` 挂载。理由与 `shell::agent`
|
||||
//! (`crates/app-iced/src/pages/agent/` → `crate::shell::agent`)完全相同:本 mission 的 scope
|
||||
//! 不含 `lib.rs` 与 `app/mod.rs`,用 `#[path]` 挂载既不碰共用声明文件,也不产生第二份实现。
|
||||
//!
|
||||
//! ## 线程/通道边界(本轮只验同步形状,异步未验)
|
||||
//!
|
||||
//! ```text
|
||||
//! on_agent(Submit) ──► AgentHost::submit ──► Presenter::agent_submit ──► Transport::submit
|
||||
//! │ 事件进队列
|
||||
//! 右栏(读 rag.agent_rail())◄── AgentHost::apply ◄── Message::Agent(Events) ◄── App::drain_agent
|
||||
//! ```
|
||||
//!
|
||||
//! 队列是 `Arc<Mutex<VecDeque<..>>>`:**transport 侧只入队、UI 侧只出队**,中间不共享状态。
|
||||
//! 真实异步后端落地时换掉的是「谁往队列里 push」,这条形状不动。本轮 transport 是同步实现
|
||||
//! (`submit` 当场把整批事件入队),所以「跨线程时序 / 乱序 / 并发提交」**没有验过**。
|
||||
|
||||
pub(crate) mod agent;
|
||||
// 账户轨的 UI 侧 rig(`AppCommand` → host 形状的 `CoreEvent`)。只在测试里存在:截图通道的
|
||||
// 账户矩阵仍走 presenter 自带的 `MockBackend`(`ULL_ACCOUNT_STATE`)。
|
||||
#[cfg(test)]
|
||||
pub(crate) mod rig;
|
||||
pub(crate) mod transport;
|
||||
|
||||
pub(crate) use agent::{AgentFailure, AgentHost};
|
||||
@@ -0,0 +1,211 @@
|
||||
//! 账户轨的 UI 侧 rig(**只在测试里存在**):把 `AppCommand` 翻成 host 形状的 `CoreEvent`。
|
||||
//!
|
||||
//! ## 它替的是谁、替到什么程度
|
||||
//!
|
||||
//! 真 host 的账户分支在 `presenter/host/dispatch.rs`:`AddOfflineAccount` → `accounts::add_offline`
|
||||
//! (`core::auth::AccountStore` 落盘)→ `AccountsChanged`;`SwitchAccount` → 只改内存里的
|
||||
//! `active_account` → `AccountsChanged`;失败一律走 `AccountNoticeChanged(结构化码)`。
|
||||
//!
|
||||
//! 这里**照同一条形状**回事件,但不碰磁盘、不起线程:
|
||||
//!
|
||||
//! | 真 host | 本 rig | 差在哪 |
|
||||
//! |---|---|---|
|
||||
//! | `AccountStore` 落盘 + 读回 | 内存 `Vec<AccountView>` | 落盘/读回由 presenter 自己的
|
||||
//! `host::accounts::offline_add_list_remove_round_trips` 覆盖,本轮**没有**跨 crate 复跑 |
|
||||
//! | `notice_failure` 带原因码 | 同名三个码(`OfflineNameInvalid` / `NotFound` / `Added`…) | 一致 |
|
||||
//! | 设备码:起后台线程等用户 | 直接给 Waiting 态 | 「进行中可见 + 可取消」验得到,**真实网络不验** |
|
||||
//!
|
||||
//! 为什么不能直接用 `CoreHost`:`host::accounts` 是**私有**模块(outside 本 mission 的 scope),
|
||||
//! 而 `CoreHost::new` 会 `refresh_all`(版本清单网络线程 + Java 探测)。测试要的是确定性。
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
valid_offline_name, AccountKind, AccountNotice, AccountNoticeCode, AccountView, AppCommand,
|
||||
AppEvent, Backend, CoreEvent, DeviceLoginView,
|
||||
};
|
||||
|
||||
/// 账户轨的假后端(形状与真 host 一致,数据在内存里)。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AccountRig {
|
||||
events: VecDeque<AppEvent>,
|
||||
accounts: Vec<AccountView>,
|
||||
next_id: u32,
|
||||
active: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AccountRig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountRig {
|
||||
/// 空账户表。
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
events: VecDeque::new(),
|
||||
accounts: Vec::new(),
|
||||
next_id: 0,
|
||||
active: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, event: CoreEvent) {
|
||||
self.events.push_back(AppEvent::Core(event));
|
||||
}
|
||||
|
||||
/// 当前列表(`active` 标记按内存里的当前账户算)。
|
||||
fn snapshot(&mut self) {
|
||||
self.emit(CoreEvent::AccountsChanged(self.accounts.clone()));
|
||||
}
|
||||
|
||||
fn notice(&mut self, code: AccountNoticeCode, subject: &str, detail: &str) {
|
||||
self.emit(CoreEvent::AccountNoticeChanged(Some(AccountNotice::new(
|
||||
code, subject, detail,
|
||||
))));
|
||||
}
|
||||
|
||||
fn add_offline(&mut self, name: &str) {
|
||||
let name = name.trim();
|
||||
if !valid_offline_name(name) {
|
||||
// 与 host 同码:名字不合法是**结构化**失败,不是静默
|
||||
self.notice(
|
||||
AccountNoticeCode::OfflineNameInvalid,
|
||||
name,
|
||||
"长度/字符集不符",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.accounts.iter().any(|account| account.name == name) {
|
||||
self.notice(AccountNoticeCode::AccountExists, name, "同名账户已存在");
|
||||
return;
|
||||
}
|
||||
self.next_id += 1;
|
||||
let id = format!("account:rig-{}", self.next_id);
|
||||
self.accounts.push(AccountView {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
kind: AccountKind::Offline,
|
||||
profile_id: format!("{:08x}", self.next_id),
|
||||
login_name: String::new(),
|
||||
created: "2026-11-05 00:00".to_string(),
|
||||
active: false,
|
||||
server_url: String::new(),
|
||||
skin: None,
|
||||
});
|
||||
self.notice(AccountNoticeCode::Added, name, "");
|
||||
self.snapshot();
|
||||
}
|
||||
|
||||
fn remove(&mut self, key: &str) {
|
||||
let Some(index) = self
|
||||
.accounts
|
||||
.iter()
|
||||
.position(|account| account.id == key || account.name == key)
|
||||
else {
|
||||
self.notice(AccountNoticeCode::NotFound, key, "找不到这个账户");
|
||||
return;
|
||||
};
|
||||
let removed = self.accounts.remove(index);
|
||||
if self.active.as_deref() == Some(removed.id.as_str()) {
|
||||
self.active = None;
|
||||
}
|
||||
self.notice(AccountNoticeCode::Removed, &removed.name, "");
|
||||
self.snapshot();
|
||||
}
|
||||
|
||||
fn switch(&mut self, id: &str) {
|
||||
if !self.accounts.iter().any(|account| account.id == id) {
|
||||
self.notice(AccountNoticeCode::NotFound, id, "找不到这个账户");
|
||||
return;
|
||||
}
|
||||
let name = self
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|account| account.id == id)
|
||||
.map(|account| account.name.clone())
|
||||
.unwrap_or_default();
|
||||
self.active = Some(id.to_string());
|
||||
for account in &mut self.accounts {
|
||||
account.active = account.id == id;
|
||||
}
|
||||
self.notice(AccountNoticeCode::Switched, &name, "");
|
||||
self.snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for AccountRig {
|
||||
fn drain_events(&mut self) -> Vec<AppEvent> {
|
||||
self.events.drain(..).collect()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, command: AppCommand) {
|
||||
match command {
|
||||
AppCommand::RefreshAll => self.snapshot(),
|
||||
AppCommand::AddOfflineAccount(name) => self.add_offline(&name),
|
||||
AppCommand::RemoveAccount(key) => self.remove(&key),
|
||||
AppCommand::SwitchAccount(id) => self.switch(&id),
|
||||
AppCommand::DismissAccountNotice => self.emit(CoreEvent::AccountNoticeChanged(None)),
|
||||
// 两条登录流:都给「等用户」态(真 host 在这里起后台线程 + 浏览器)
|
||||
AppCommand::StartDeviceLogin | AppCommand::StartBrowserLogin => {
|
||||
self.emit(CoreEvent::DeviceLoginChanged(DeviceLoginView::waiting(
|
||||
"ABCD-EFGH",
|
||||
"https://www.microsoft.com/link",
|
||||
900,
|
||||
"",
|
||||
)));
|
||||
}
|
||||
// 取消:两条轨都收回 Idle(真 host 的 `CancelDeviceLogin` 就是这么做的)
|
||||
AppCommand::CancelDeviceLogin => {
|
||||
self.emit(CoreEvent::DeviceLoginChanged(DeviceLoginView::new()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_rig_answers_add_switch_remove_with_host_shaped_events() {
|
||||
let mut rig = AccountRig::new();
|
||||
rig.dispatch(AppCommand::AddOfflineAccount("Steve".to_string()));
|
||||
let events = rig.drain_events();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::AccountsChanged(rows)) if rows.len() == 1
|
||||
)));
|
||||
|
||||
let id = match &events[1] {
|
||||
AppEvent::Core(CoreEvent::AccountsChanged(rows)) => rows[0].id.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
rig.dispatch(AppCommand::SwitchAccount(id.clone()));
|
||||
assert!(matches!(
|
||||
rig.drain_events().first(),
|
||||
Some(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(notice))))
|
||||
if notice.code == AccountNoticeCode::Switched
|
||||
));
|
||||
|
||||
rig.dispatch(AppCommand::RemoveAccount(id));
|
||||
let events = rig.drain_events();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::Core(CoreEvent::AccountsChanged(rows)) if rows.is_empty()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_offline_name_is_a_structured_failure_not_silence() {
|
||||
let mut rig = AccountRig::new();
|
||||
rig.dispatch(AppCommand::AddOfflineAccount("ab".to_string()));
|
||||
assert!(matches!(
|
||||
rig.drain_events().first(),
|
||||
Some(AppEvent::Core(CoreEvent::AccountNoticeChanged(Some(notice))))
|
||||
if notice.code == AccountNoticeCode::OfflineNameInvalid
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! [`AgentTransport`] 的 iced 侧实现:脚本化 rig + 三类失败注入(M119)。
|
||||
//!
|
||||
//! ## 它是什么、不是什么
|
||||
//!
|
||||
//! 它是 M103 契约 [`AgentTransport`] 的一个**实现**,不是契约的改写:`submit` / `confirm` 的
|
||||
//! 签名一行没动。真实的 rig 后端(`crates/agent` + rig)还没装配进 host(M103 报告 §4 登记过),
|
||||
//! 所以本轮用它跑端到端。
|
||||
//!
|
||||
//! **事件走队列,不当场返回**:`submit` 把整批事件 push 进 [`EventQueue`] 后返回 `Ok(vec![])`,
|
||||
//! UI 在心跳里抽。这条形状才是异步后端落地时**不会变**的那部分——换掉的只有「谁往队列里 push」
|
||||
//! (真实后端在后台线程里逐帧推流)。抓得住:事件经 Message 通道回流的整条链路;
|
||||
//! 抓不住:跨线程时序、乱序、并发提交(本轮 transport 是同步的,见报告「没验的部分」)。
|
||||
//!
|
||||
//! ## 失败注入(任务「失败态可见」的那两条 AI 侧)
|
||||
//!
|
||||
//! | `ULL_AGENT_SCRIPT` | `submit` 行为 |
|
||||
//! |---|---|
|
||||
//! | `qa`(默认) | 一轮正常问答:用户气泡 + 流式助手消息(三条增量)+ 收敛 |
|
||||
//! | `streaming` | 同上但**永不收敛**(`busy` 一直亮着:中断键与禁用态的可视图) |
|
||||
//! | `fails` | 流到一半断流:已到的增量保留,整轮以 `Failed` 收尾 |
|
||||
//! | `write` | 需确认的写操作(High 级 `ConfirmRequested`) |
|
||||
//! | `unreachable` | **端点不可达**:`Err`,一个事件也不产生 |
|
||||
//! | `unimplemented` | **未实现路由**:`Err`,同上 |
|
||||
//!
|
||||
//! 两种 `Err` 都先把**具名原因**写进 [`FailureSlot`] 再返回错误串——UI 据原因码选 catalog 文案,
|
||||
//! 错误串本身(后端诊断)只进日志,不上屏(M114 立的口径)。
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use unlucky_presenter::agent::{AgentRail, AgentTransport};
|
||||
use unlucky_presenter::dto::{
|
||||
AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity,
|
||||
};
|
||||
|
||||
use super::AgentFailure;
|
||||
|
||||
/// 事件队列(transport 侧 push、UI 侧 drain)。
|
||||
pub(crate) type EventQueue = Arc<Mutex<VecDeque<AppEvent>>>;
|
||||
|
||||
/// 失败原因槽(transport 侧写、UI 侧读后清)。
|
||||
pub(crate) type FailureSlot = Arc<Mutex<Option<AgentFailure>>>;
|
||||
|
||||
/// 脚本(一轮输入会产生哪种剧情)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Script {
|
||||
/// 正常问答。
|
||||
Qa,
|
||||
/// 流式永不收敛。
|
||||
Streaming,
|
||||
/// 流式中途断流。
|
||||
FailsMidStream,
|
||||
/// 需确认的写操作。
|
||||
WriteOp,
|
||||
/// 端点不可达(`Err`)。
|
||||
Unreachable,
|
||||
/// 未实现路由(`Err`)。
|
||||
NotImplemented,
|
||||
}
|
||||
|
||||
/// 脚本环境变量(取证开关,与 `ULL_AGENT_RAIL` / `ULL_APPEARANCE_*` 同一口径)。
|
||||
pub(crate) const SCRIPT_ENV: &str = "ULL_AGENT_SCRIPT";
|
||||
|
||||
impl Script {
|
||||
/// 全部脚本(`from_name` 的遍历表)。
|
||||
pub(crate) const ALL: [Script; 6] = [
|
||||
Script::Qa,
|
||||
Script::Streaming,
|
||||
Script::FailsMidStream,
|
||||
Script::WriteOp,
|
||||
Script::Unreachable,
|
||||
Script::NotImplemented,
|
||||
];
|
||||
|
||||
/// 稳定名字(`ULL_AGENT_SCRIPT` 的取值 / 日志)。
|
||||
pub(crate) const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Script::Qa => "qa",
|
||||
Script::Streaming => "streaming",
|
||||
Script::FailsMidStream => "fails",
|
||||
Script::WriteOp => "write",
|
||||
Script::Unreachable => "unreachable",
|
||||
Script::NotImplemented => "unimplemented",
|
||||
}
|
||||
}
|
||||
|
||||
/// 反向解析(取证开关用;未知值忽略)。
|
||||
pub(crate) fn from_name(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|script| script.name() == name)
|
||||
}
|
||||
|
||||
/// 读 `ULL_AGENT_SCRIPT`;没设 / 解析不出来 = [`Script::Qa`]。
|
||||
pub(crate) fn from_env() -> Self {
|
||||
match std::env::var(SCRIPT_ENV) {
|
||||
Ok(raw) => match Self::from_name(raw.trim()) {
|
||||
Some(script) => script,
|
||||
None => {
|
||||
eprintln!(
|
||||
"[agent] {SCRIPT_ENV}={raw:?} 不是脚本之一,按 qa 处理(可选:{})",
|
||||
Self::ALL.map(Self::name).join("/")
|
||||
);
|
||||
Script::Qa
|
||||
}
|
||||
},
|
||||
Err(_) => Script::Qa,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本化 transport:事件入队、失败入槽。
|
||||
pub(crate) struct RigTransport {
|
||||
script: Script,
|
||||
/// 已发起过几轮(用来算消息下标 / 确认请求 id)。
|
||||
turns: usize,
|
||||
/// 当前待确认的写操作。
|
||||
pending: Option<ConfirmRequest>,
|
||||
queue: EventQueue,
|
||||
failure: FailureSlot,
|
||||
}
|
||||
|
||||
impl RigTransport {
|
||||
/// 新 transport + 它与 UI 共享的两个句柄。
|
||||
pub(crate) fn new(script: Script) -> Self {
|
||||
Self {
|
||||
script,
|
||||
turns: 0,
|
||||
pending: None,
|
||||
queue: EventQueue::default(),
|
||||
failure: FailureSlot::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 UI 共享的事件队列(克隆一个给 [`super::AgentHost`])。
|
||||
pub(crate) fn queue(&self) -> EventQueue {
|
||||
Arc::clone(&self.queue)
|
||||
}
|
||||
|
||||
/// 与 UI 共享的失败原因槽。
|
||||
pub(crate) fn failure(&self) -> FailureSlot {
|
||||
Arc::clone(&self.failure)
|
||||
}
|
||||
|
||||
/// 入队一批事件(transport 唯一的输出方向)。
|
||||
fn emit(&self, events: Vec<AppEvent>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut queue) = self.queue.lock() {
|
||||
queue.extend(events);
|
||||
}
|
||||
}
|
||||
|
||||
/// 注入一次失败:原因入槽(UI 据它选 catalog 文案)、错误串回给 presenter(只进日志)。
|
||||
fn fail(&self, failure: AgentFailure, detail: &str) -> Result<Vec<AppEvent>, String> {
|
||||
if let Ok(mut slot) = self.failure.lock() {
|
||||
*slot = Some(failure);
|
||||
}
|
||||
Err(format!("{}:{detail}", failure.code()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentTransport for RigTransport {
|
||||
fn submit(&mut self, text: &str, history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||
match self.script {
|
||||
Script::Unreachable => {
|
||||
return self.fail(
|
||||
AgentFailure::Unreachable,
|
||||
"assistant endpoint http://127.0.0.1:8080 连不上(connection refused)",
|
||||
);
|
||||
}
|
||||
Script::NotImplemented => {
|
||||
return self.fail(
|
||||
AgentFailure::NotImplemented,
|
||||
"真实 LLM 路由还没装配进 host(M103 §4 的欠账)",
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
self.turns += 1;
|
||||
// 后端自己算下标:用户气泡先到,助手流式占位紧随其后。
|
||||
let assistant = history.messages.len() + 1;
|
||||
let mut events = vec![
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage::user(text))),
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage::assistant_stream())),
|
||||
];
|
||||
match self.script {
|
||||
Script::Qa | Script::Streaming => {
|
||||
for delta in ["先确认版本:", "装完再加 Fabric。"] {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant,
|
||||
delta: delta.to_string(),
|
||||
}));
|
||||
}
|
||||
if self.script == Script::Qa {
|
||||
events.push(agent_event(AgentEvent::MessageFinished {
|
||||
index: assistant,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Script::FailsMidStream => {
|
||||
events.push(agent_event(AgentEvent::MessageDelta {
|
||||
index: assistant,
|
||||
delta: "我先读一下最近的日志".to_string(),
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::Failed {
|
||||
message: "provider 断流:read timeout".to_string(),
|
||||
}));
|
||||
}
|
||||
Script::WriteOp => {
|
||||
events.push(agent_event(AgentEvent::MessageFinished {
|
||||
index: assistant,
|
||||
}));
|
||||
events.push(agent_event(AgentEvent::ToolCallStarted {
|
||||
name: "set_config".to_string(),
|
||||
summary: "修改 download.concurrency".to_string(),
|
||||
}));
|
||||
let request = ConfirmRequest {
|
||||
id: format!("req-{}", self.turns),
|
||||
tool: "set_config".to_string(),
|
||||
summary: "把下载并发 64 → 16".to_string(),
|
||||
// 不引 serde_json(app-iced 没有这个依赖):`Value::default()` = Null,
|
||||
// 确认框只画 `summary`,`details` 的结构化 diff 留给后续 mission。
|
||||
details: Default::default(),
|
||||
sensitivity: Sensitivity::High,
|
||||
};
|
||||
self.pending = Some(request.clone());
|
||||
events.push(agent_event(AgentEvent::ConfirmRequested { request }));
|
||||
}
|
||||
// 三支失败脚本在上面的 `match` 里已经 `return`,这里只为穷尽
|
||||
Script::Unreachable | Script::NotImplemented => {}
|
||||
}
|
||||
self.emit(events);
|
||||
// 契约允许「事件分片由 presenter 顺序 apply」,也允许走事件通道——本实现走通道(见模块文档)。
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent> {
|
||||
self.pending = None;
|
||||
let (ok, summary, reply) = if accept {
|
||||
(true, "已写入 download.concurrency = 16", "改好了。")
|
||||
} else {
|
||||
(false, "用户拒绝,未写入", "那就不改了。")
|
||||
};
|
||||
let events = vec![
|
||||
agent_event(AgentEvent::ToolCallFinished {
|
||||
name: "set_config".to_string(),
|
||||
ok,
|
||||
summary: summary.to_string(),
|
||||
}),
|
||||
agent_event(AgentEvent::ConfirmResolved {
|
||||
id: request_id.to_string(),
|
||||
}),
|
||||
agent_event(AgentEvent::MessageAppended(ChatMessage {
|
||||
role: ChatRole::Assistant,
|
||||
text: reply.to_string(),
|
||||
tool: None,
|
||||
streaming: false,
|
||||
})),
|
||||
];
|
||||
self.emit(events.clone());
|
||||
// 与 `submit` 同一条口径:事件走队列,这里返回空(避免同一批事件被 apply 两遍)。
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 包一层 [`AppEvent::Agent`](事件队列只认顶层事件)。
|
||||
fn agent_event(event: AgentEvent) -> AppEvent {
|
||||
AppEvent::Agent(event)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_script_name_round_trips_and_the_env_default_is_qa() {
|
||||
for script in Script::ALL {
|
||||
assert_eq!(Script::from_name(script.name()), Some(script));
|
||||
}
|
||||
assert_eq!(Script::from_name("nope"), None);
|
||||
// 环境变量在这条测试里不可靠地存在(并行测试共享进程),只验默认分支的返回值
|
||||
assert_eq!(Script::from_name("qa"), Some(Script::Qa));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_turn_queues_the_whole_batch_and_returns_no_inline_events() {
|
||||
let mut transport = RigTransport::new(Script::Qa);
|
||||
let queue = transport.queue();
|
||||
let inline = transport
|
||||
.submit("hello", &AgentRail::new())
|
||||
.expect("qa 不会失败");
|
||||
assert!(inline.is_empty(), "本实现走队列,不走进内联分片");
|
||||
assert_eq!(
|
||||
queue.lock().expect("queue").len(),
|
||||
5,
|
||||
"user + 流式占位 + 两片增量 + 收敛"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_failing_scripts_return_a_named_reason_and_queue_nothing() {
|
||||
for (script, expected) in [
|
||||
(Script::Unreachable, AgentFailure::Unreachable),
|
||||
(Script::NotImplemented, AgentFailure::NotImplemented),
|
||||
] {
|
||||
let mut transport = RigTransport::new(script);
|
||||
let queue = transport.queue();
|
||||
let failure = transport.failure();
|
||||
let error = transport
|
||||
.submit("hello", &AgentRail::new())
|
||||
.expect_err("这两支必失败");
|
||||
assert!(error.contains(expected.code()), "错误串要带原因码:{error}");
|
||||
assert_eq!(*failure.lock().expect("slot"), Some(expected));
|
||||
assert!(queue.lock().expect("queue").is_empty(), "失败不产生事件");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_queues_the_tool_result_and_the_reply() {
|
||||
let mut transport = RigTransport::new(Script::WriteOp);
|
||||
let queue = transport.queue();
|
||||
transport
|
||||
.submit("改并发", &AgentRail::new())
|
||||
.expect("写操作");
|
||||
queue.lock().expect("queue").clear();
|
||||
let inline = transport.confirm("req-1", true);
|
||||
assert!(inline.is_empty());
|
||||
assert_eq!(
|
||||
queue.lock().expect("queue").len(),
|
||||
3,
|
||||
"工具收尾 + 确认收敛 + 回复"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user