Files
Unlucky-Launcher/crates/app-iced/src/presenter/transport.rs
T
AskaEth b5e96c61ac fix(app-iced): M119 出图抓到的两处:右栏文案没跟 lang 走、确认条文字被压成竖缝
两张验收图(`DevDocs/artifacts/m119/`)暴露的问题,都是测试抓不住、只有图能看出来的那类:

1. **同屏两语**:`pages/agent/mod.rs` 里还留着 M104 的 `const LANG: Lang = Lang::En`,所以右栏的
   标题/按钮恒英文,而 M119 新加的失败横幅走 `App::lang()`(这台机器是 zh-CN)⇒ 一屏里
   「Assistant / Collapse」和「连不上助手后端」并排。现在删掉那个常量,`docked`/`page` 收一个
   `Catalog` 形参,由 `App::lang().catalog()` 给——与设置页/账户页同一条口径(M106 的真值源)。

2. **确认条没有字**:`chip + 文字 + 两个按钮` 挤在同一条 `Row` 里,而右栗只有 280~360:320 宽下
   文字列被压成一条竖缝,标题整段看不见(图 05 第一版就是这个样子)。改成文字行与动作**分行**,
   动作右对齐——窄栏里才是可读的 M3 告警形态。

顺带:`Script::WriteOp` 补一句增量,免得那条助手气泡是空壳(出图更接近真后端)。

`cargo test -p unlucky-app-iced --lib`:599 passed / 0 failed;14 张图重出后逐张看过。
2026-11-05 08:11:14 +08:00

340 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! [`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::MessageDelta {
index: assistant,
delta: "我准备把下载并发从 64 改成 16,改之前先问一句。".to_string(),
}));
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,
"工具收尾 + 确认收敛 + 回复"
);
}
}