From a8d4cce3e26218fbedf412825c6f44d54fc2d805 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 3 Nov 2026 20:40:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(presenter):=20M103=20AI=20=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E6=A0=8F=E8=A7=86=E5=9B=BE=E6=A8=A1=E5=9E=8B=E4=B8=8E?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 crates/presenter/src/agent.rs(Presenter 门面:agent_rail / agent_submit / agent_mark_read / agent_confirm + AgentTransport 端口 + 裁决审计镜像) - 新增 agent/rail.rs:AgentRail(messages / unread / busy / draft / confirm / streaming / yolo / endpoint_label / error),四类 AgentEvent 的投影与唯一脱敏闸门 - 新增 agent/mock.rs:脚本化后端(正常问答 / 流式中途出错 / 需确认的写操作) - dto/agent.rs:ConfirmRequest / Sensitivity + AgentEvent::{Failed, ConfirmRequested, ConfirmResolved};AgentEvent 去掉 Eq(details 含 serde_json::Value) - state.rs:AiSession 迁成 AgentRail,apply_agent 走 rail.ingest + 未读规则(可见性 = 独立页或栏展开);navigate/toggle_ai 展开时清未读 - 测试:agent/tests.rs 14 条(四条路径 + JWT 红线 + YOLO 语义 + 提交前置条件), state_tests.rs 补 4 条(Failed / ConfirmRequested / 未读可见性 / 用户消息不计未读) - 两行声明补进 lib.rs(pub mod agent)与 dto/mod.rs(重导出 ConfirmRequest/Sensitivity) --- crates/presenter/src/agent.rs | 256 +++++++++++++++++++++ crates/presenter/src/agent/mock.rs | 165 ++++++++++++++ crates/presenter/src/agent/rail.rs | 308 +++++++++++++++++++++++++ crates/presenter/src/agent/tests.rs | 341 ++++++++++++++++++++++++++++ crates/presenter/src/dto/agent.rs | 86 ++++++- crates/presenter/src/dto/mod.rs | 2 +- crates/presenter/src/lib.rs | 2 + crates/presenter/src/state.rs | 127 +++-------- crates/presenter/src/state_tests.rs | 107 ++++++++- 9 files changed, 1295 insertions(+), 99 deletions(-) create mode 100644 crates/presenter/src/agent.rs create mode 100644 crates/presenter/src/agent/mock.rs create mode 100644 crates/presenter/src/agent/rail.rs create mode 100644 crates/presenter/src/agent/tests.rs diff --git a/crates/presenter/src/agent.rs b/crates/presenter/src/agent.rs new file mode 100644 index 0000000..3231e0c --- /dev/null +++ b/crates/presenter/src/agent.rs @@ -0,0 +1,256 @@ +//! AI 助手(D16 双形态)的 presenter 门面:视图模型 + 提交/确认 + 裁决审计。 +//! +//! # 1. 这一层为什么单独存在 +//! +//! UI 壳(右侧常驻栏 = AGENTRAIL)只做两件事:读 [`AgentRail`] 画、把点击翻成这五个方法。 +//! 会话怎么攒、未读怎么算、YOLO 怎么裁决、审计记什么,全在这里与 +//! [`crate::state::AppState::apply_agent`],壳里不重复实现——否则两形态(栏 / 页)会各写一套 +//! 而漂移。 +//! +//! ```text +//! UI 壳 ──agent_submit/agent_confirm/agent_mark_read──► Presenter ──AgentTransport──► 后端 +//! ▲ │ +//! └────────── agent_rail()(只读视图模型)◄──────────────┘ events → AppState::apply +//! ``` +//! +//! # 2. 与 UI 壳的契约(签名已定,改名要先报 tower) +//! +//! ```text +//! Presenter::agent_rail(&self) -> &AgentRail +//! Presenter::agent_submit(&mut self, text: String) -> Result<(), String> +//! Presenter::agent_mark_read(&mut self) +//! Presenter::agent_confirm(&mut self, accept: bool) +//! ``` +//! +//! # 3. 铁律落点(AGENTS §6) +//! +//! - **AI 永不接触账户凭据**:事件里没有凭据字段;文本一律过 [`rail::scrub_text`](第二道防线)。 +//! - **写配置受 schema 白名单约束**:白名单在 agent 侧工具层,presenter 只负责「把确认问出来」。 +//! - **敏感键分级确认,YOLO 只跳确认且审计照记**:[`Presenter::agent_confirm`] 在 YOLO 下 +//! 一律放行,但**照样**记一条 [`AuditEntry`](`yolo = true, confirmed = false`)——不静默跳过。 +//! +//! # 4. 没做的事(P1 再接) +//! +//! - 真实后端:本模块只认 [`AgentTransport`] 端口,`rig` 那侧由 host 装配时实现; +//! - 落盘审计:agent 侧 `brain::exec` → `audit` 才是权威记录,这里的 [`AuditEntry`] 只是 +//! 本进程内、给 UI 看的裁决镜像(`presenter.audit()`); +//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。 + +use crate::dto::{AppEvent, Sensitivity}; +use crate::state::AppState; +use rail::scrub_text; + +pub mod mock; +pub mod rail; + +pub use rail::AgentRail; + +#[cfg(test)] +mod tests; + +/// 裁决记录上限(只留最近若干条;权威审计不在这里)。 +pub const MAX_AUDIT_ENTRIES: usize = 256; + +/// 一次确认的结局。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmVerdict { + /// 放行(用户点了允许,或 YOLO 自动放行) + Approved, + /// 拒绝(工具调用会带「用户拒绝」回到模型) + Denied, +} + +impl ConfirmVerdict { + /// 是否放行。 + #[must_use] + pub fn is_approved(self) -> bool { + matches!(self, Self::Approved) + } +} + +/// 一条确认裁决的审计记录。 +/// +/// `yolo` 与 `confirmed` 必须分开记:`yolo = true, confirmed = false`(自动放行)与 +/// `yolo = false, confirmed = true`(用户亲手点的允许)在事故复盘里是完全不同的两件事。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuditEntry { + /// 请求 id(与 `AgentEvent::ConfirmRequested` 同一个) + pub request_id: String, + /// 工具名 + pub tool: String, + /// 敏感等级 + pub sensitivity: Sensitivity, + /// 结局 + pub verdict: ConfirmVerdict, + /// 是否运行在 YOLO 模式 + pub yolo: bool, + /// 是否经过用户确认(YOLO 下恒为 false) + pub confirmed: bool, +} + +/// agent 后端端口:presenter 不认 `rig` / 异步运行时的具体类型(依赖方向见 crate 文档)。 +/// +/// 方法同步返回事件分片是刻意的:真实后端在后台线程里跑,事件经 +/// [`crate::dto::Backend::drain_events`] 回流;端口这层只表达「一轮输入会产生哪些事件」, +/// 这样单测可以不碰定时器与网络。 +pub trait AgentTransport { + /// 用户提交一句话。 + /// + /// `history` 是当前 rail 的只读快照(真实后端要靠它拼上下文,mock 靠它算消息下标)。 + /// 返回的事件分片由 [`Presenter`] 顺序 `apply`;`Err` 表示这一轮根本没起来(不产生事件)。 + fn submit(&mut self, text: &str, history: &AgentRail) -> Result, String>; + + /// 用户对写操作做了决定(`accept = false` 表示拒绝)。 + fn confirm(&mut self, request_id: &str, accept: bool) -> Vec; +} + +/// AI 助手的 presenter 门面:持有一份 [`AppState`] 与一个后端端口。 +/// +/// 事件仍走 [`AppState::apply`](唯一状态变更入口)——[`Presenter::apply`] 只是转发, +/// 不在这一层另做投影。 +pub struct Presenter { + state: AppState, + transport: Box, + audit: Vec, +} + +impl std::fmt::Debug for Presenter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Presenter") + .field("rail", self.agent_rail()) + .field("audit_len", &self.audit.len()) + .finish_non_exhaustive() + } +} + +impl Presenter { + /// 空状态 + 指定后端端口。 + #[must_use] + pub fn new(transport: Box) -> Self { + Self::with_state(AppState::new(), transport) + } + + /// 接管一份已有状态(UI 壳先建 `AppState` 再交给 presenter 时用)。 + #[must_use] + pub fn with_state(state: AppState, transport: Box) -> Self { + Self { + state, + transport, + audit: Vec::new(), + } + } + + /// 只读状态(UI 画其它页面用)。 + #[must_use] + pub fn state(&self) -> &AppState { + &self.state + } + + /// 可写状态(导航、偏好、其它页面事件;agent 相关动作请走本类的方法)。 + pub fn state_mut(&mut self) -> &mut AppState { + &mut self.state + } + + /// 喂一条事件(纯转发给 [`AppState::apply`])。 + pub fn apply(&mut self, event: AppEvent) { + self.state.apply(event); + } + + /// 当前会话的视图模型(唯一读入口;两形态共享同一份)。 + #[must_use] + pub fn agent_rail(&self) -> &AgentRail { + &self.state.ai + } + + /// 提交一句话。 + /// + /// 拒绝的三种情形都不改状态(草稿保留,用户能接着改): + /// 空文本、上一轮还在跑、有写操作待确认。 + /// + /// 成功时清空草稿并顺序投影后端返回的事件分片。用户气泡**由事件产生** + /// (不在本地乐观回显,见 `lib.rs` §3)。 + pub fn agent_submit(&mut self, text: String) -> Result<(), String> { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err("请输入内容".to_string()); + } + if self.state.ai.confirm.is_some() { + return Err("有写操作待确认:先允许或拒绝,再继续对话".to_string()); + } + if self.state.ai.busy { + return Err("上一轮回复还没结束".to_string()); + } + // 用户输入也过脱敏:他可能顺手把 token 粘进对话框,那段文本会进会话历史、 + // 也会进后续 prompt 与导出。 + let text = scrub_text(trimmed); + let events = self + .transport + .submit(&text, &self.state.ai) + .map_err(|error| scrub_text(&error))?; + self.state.ai.draft.clear(); + self.state.ai.error = None; + for event in events { + self.state.apply(event); + } + Ok(()) + } + + /// 未读清零(UI 在「用户打开栏 / 停在独立页」时调用)。 + pub fn agent_mark_read(&mut self) { + self.state.ai.mark_read(); + } + + /// 对当前待确认的写操作做决定。 + /// + /// - 没有待确认:no-op(不记审计,不 panic); + /// - YOLO 下:`accept` 被忽略,一律放行,审计记 `yolo = true, confirmed = false`; + /// - 非 YOLO:按 `accept` 裁决,审计记 `confirmed = true`。 + /// + /// 两种情形都走同一条事件回流(后端返回的工具收尾事件),所以会话里看到的工具条目 + /// 与审计记录不会各行其是。 + /// + /// # UI 壳的义务 + /// + /// 看到 `rail.confirm.is_some()` 就要给出决定:非 YOLO 弹确认框,YOLO 直接调 + /// `agent_confirm(true)`(不弹窗,但**必须**调——否则 `busy` 一直锁着输入)。 + /// 真实后端在 YOLO 下不会发 `ConfirmRequested`(agent 侧 ConfirmGate 已自动放行), + /// 这条路径是防御性的;另一条收敛途径是后端发 `AgentEvent::ConfirmResolved`。 + pub fn agent_confirm(&mut self, accept: bool) { + let Some(request) = self.state.ai.take_confirm() else { + return; + }; + let yolo = self.state.ai.yolo; + let verdict = if yolo || accept { + ConfirmVerdict::Approved + } else { + ConfirmVerdict::Denied + }; + self.record(AuditEntry { + request_id: request.id.clone(), + tool: request.tool.clone(), + sensitivity: request.sensitivity, + verdict, + yolo, + confirmed: !yolo, + }); + let events = self.transport.confirm(&request.id, verdict.is_approved()); + for event in events { + self.state.apply(event); + } + } + + /// 本进程内的裁决记录(最旧在前;权威审计在 agent 侧)。 + #[must_use] + pub fn audit(&self) -> &[AuditEntry] { + &self.audit + } + + /// 记一条裁决,超出 [`MAX_AUDIT_ENTRIES`] 时丢最旧的。 + fn record(&mut self, entry: AuditEntry) { + if self.audit.len() == MAX_AUDIT_ENTRIES { + self.audit.remove(0); + } + self.audit.push(entry); + } +} diff --git a/crates/presenter/src/agent/mock.rs b/crates/presenter/src/agent/mock.rs new file mode 100644 index 0000000..8a78d5b --- /dev/null +++ b/crates/presenter/src/agent/mock.rs @@ -0,0 +1,165 @@ +//! mock 版 agent 后端:四条路径的脚本化事件流(正常问答 / 流式中途出错 / 需确认的写操作 / +//! 未读计数)。 +//! +//! 与 [`crate::mock::MockBackend`] 的分工:那个是**整机演示后端**(按 tick 推进剧情,带定时器); +//! 这个是 [`crate::agent::AgentTransport`] 的最小实现,**同步返回整轮事件分片**,专供 presenter +//! 单测与 UI 壳的回归预览(没有真实 LLM 也能把栏画全)。 +//! +//! 抓得住什么:事件顺序、投影结果(消息/工具/确认/未读/审计)、脱敏闸门。 +//! 抓不住什么:真实时序(这里一次性返回,真实后端是逐帧推流)、真实模型的措辞与工具选择、 +//! 网络/超时/取消语义——那些要接 rig 之后跑回放回归。 + +use crate::agent::{AgentRail, AgentTransport}; +use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity}; + +/// 脚本:一轮输入会产生哪种剧情。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Script { + /// 正常问答:流式增量收敛成一条完整助手消息 + Qa, + /// 流式中途断流:已到的增量保留,整轮以 `Failed` 收尾 + FailsMidStream, + /// 需确认的写操作:工具调用开始后挂一个 High 级确认请求 + WriteOp, +} + +/// 脚本化后端。 +#[derive(Debug, Clone)] +pub struct MockAgentTransport { + script: Script, + /// 已发起过几轮(mock 用它算消息下标;真实后端自己维护会话) + turns: usize, + /// 当前待确认的写操作(`None` = 没有) + pending: Option, +} + +impl MockAgentTransport { + /// 指定脚本。 + #[must_use] + pub fn new(script: Script) -> Self { + Self { + script, + turns: 0, + pending: None, + } + } + + /// 正常问答。 + #[must_use] + pub fn qa() -> Self { + Self::new(Script::Qa) + } + + /// 流式中途出错。 + #[must_use] + pub fn fails_mid_stream() -> Self { + Self::new(Script::FailsMidStream) + } + + /// 需确认的写操作(改下载并发,High 级)。 + #[must_use] + pub fn write_op() -> Self { + Self::new(Script::WriteOp) + } + + /// 当前待确认请求(测试断言用)。 + #[must_use] + pub fn pending(&self) -> Option<&ConfirmRequest> { + self.pending.as_ref() + } +} + +impl AgentTransport for MockAgentTransport { + fn submit(&mut self, text: &str, history: &AgentRail) -> Result, String> { + self.turns += 1; + // 后端自己算下标:用户气泡先到,助手流式占位紧随其后。 + let assistant_index = 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 => { + for delta in [ + "先确认版本:", + "官方 1.20.4 是正式版,", + "装完再加 Fabric。", + ] { + events.push(agent_event(AgentEvent::MessageDelta { + index: assistant_index, + delta: delta.to_string(), + })); + } + events.push(agent_event(AgentEvent::MessageFinished { + index: assistant_index, + })); + } + Script::FailsMidStream => { + events.push(agent_event(AgentEvent::MessageDelta { + index: assistant_index, + 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_index, + delta: "我准备把下载并发从 64 改成 16。".to_string(), + })); + events.push(agent_event(AgentEvent::MessageFinished { + index: assistant_index, + })); + 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(), + details: serde_json::json!({ + "path": "download.concurrency", + "old": 64, + "new": 16, + }), + sensitivity: Sensitivity::High, + }; + self.pending = Some(request.clone()); + events.push(agent_event(AgentEvent::ConfirmRequested { request })); + } + } + Ok(events) + } + + fn confirm(&mut self, request_id: &str, accept: bool) -> Vec { + self.pending = None; + let (ok, summary, reply) = if accept { + (true, "已写入 download.concurrency = 16", "改好了。") + } else { + (false, "用户拒绝,未写入", "那就不改了。") + }; + 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, + })), + ] + } +} + +/// 包一层 [`AppEvent::Agent`](presenter 的事件队列只认顶层事件)。 +fn agent_event(event: AgentEvent) -> AppEvent { + AppEvent::Agent(event) +} diff --git a/crates/presenter/src/agent/rail.rs b/crates/presenter/src/agent/rail.rs new file mode 100644 index 0000000..06af545 --- /dev/null +++ b/crates/presenter/src/agent/rail.rs @@ -0,0 +1,308 @@ +//! AI 助手栏(D16 双形态:右侧常驻栏 / 独立页)的**唯一**视图模型。 +//! +//! 两形态共用同一份 [`AgentRail`]:折叠/展开、停靠/拆页都只是换渲染位置,切形态不丢会话。 +//! +//! # 1. 事件从哪里来、到哪里去 +//! +//! ```text +//! agent/host 后端 ──AgentEvent──► AppState::apply ──► AgentRail::ingest ──► 字段 +//! └──► unread(栏不可见时累加) +//! ``` +//! +//! 投影规则(四类事件各归各位): +//! +//! | 事件 | 落到哪 | +//! | ---- | ------ | +//! | `MessageAppended` | 追加一条消息;`streaming` 的置 `rail.streaming` | +//! | `MessageDelta` | 追加到最后一条 assistant 消息的正文(**累积在一条上**,不是每条一个气泡) | +//! | `MessageFinished` | 清掉该条(以及全部)流式标记,`streaming` 由消息重算 | +//! | `ToolCallStarted` / `ToolCallFinished` | 生成 / 收敛 [`ToolCallView`] 条目(D4:工具调用会话可见) | +//! | `Failed` | 收敛流式 + 立横幅 + 追加系统条目(错误也要能回翻) | +//! | `ConfirmRequested` / `ConfirmResolved` | `rail.confirm` 置位 / 清除 | +//! +//! # 2. 红线:进入 rail 的文本一律脱敏 +//! +//! [`AgentRail::ingest`] 是所有 agent 文本进入 rail 的**唯一闸门**,它逐字段过 +//! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` / +//! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是 +//! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,rail 上也不会出现原文。 +//! 复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」规则),不另写一套正则。 +//! +//! # 3. 刻意不做的事 +//! +//! - **不裁剪 `messages`**:`MessageDelta` 用下标寻址,从头丢弃会让下标漂移。会话长度由后端 +//! 的轮次上限控制;这里只保证下标稳定。 +//! - **不持有后端**:rail 是纯数据 + 投影,提交/确认在 [`crate::agent::Presenter`]。 + +use serde_json::{Map, Value}; +use unlucky_core::launch::redact::{Redactor, TOKEN_PLACEHOLDER}; + +use crate::dto::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, ToolCallView}; + +/// 键名命中即整值打码(与 `unlucky_agent::redact::is_sensitive_key` 同一份名单)。 +/// +/// 为什么在这里再写一遍:presenter 不依赖 `unlucky-agent`(那会把 rig / tokio / rmcp 整棵树 +/// 拖进共用宿主层)。名单漂移的风险已记进 M103 报告,长期该由 core 出一份公共实现。 +const SENSITIVE_KEY_NEEDLES: [&str; 8] = [ + "api_key", + "apikey", + "token", + "password", + "passwd", + "secret", + "authorization", + "credential", +]; + +/// 文本是否像凭据键名(`access_token` / `refresh_token` / `microsoft.password` …)。 +#[must_use] +pub fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + SENSITIVE_KEY_NEEDLES + .iter() + .any(|needle| lower.contains(needle)) +} + +/// 文本脱敏:JWT 形状(带点 token)/ ≥64 不透明串由 core 的规则打码。 +/// +/// 不登记主目录(rail 不持有数据根):`` 掩码是 host 侧的职责。 +#[must_use] +pub fn scrub_text(text: &str) -> String { + Redactor::new().scrub(text).into_owned() +} + +/// 结构化细节脱敏:先按键名掩码,再按内容掩码(两道都要,见模块文档 §2)。 +#[must_use] +pub fn scrub_details(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut out = Map::with_capacity(map.len()); + for (key, child) in map { + let masked = if is_sensitive_key(key) { + Value::String(TOKEN_PLACEHOLDER.to_string()) + } else { + scrub_details(child) + }; + out.insert(key.clone(), masked); + } + Value::Object(out) + } + Value::Array(items) => Value::Array(items.iter().map(scrub_details).collect()), + Value::String(text) => Value::String(scrub_text(text)), + other => other.clone(), + } +} + +/// AI 助手栏视图模型(UI 壳只读它,不自己攒会话状态)。 +#[derive(Debug, Clone, PartialEq)] +pub struct AgentRail { + /// 会话消息(user / assistant / tool / system),下标稳定 + pub messages: Vec, + /// 栏不可见期间攒下的未读条数(`agent_mark_read` 清零) + pub unread: usize, + /// 是否该禁用输入(一轮回复进行中,或有写操作待确认) + pub busy: bool, + /// 输入框草稿(切形态不丢) + pub draft: String, + /// 待确认的写操作(`Some` = 弹确认框;非 YOLO 下 `busy` 必为 true) + pub confirm: Option, + /// 是否有流式消息在跑(Slint 壳读它切换「发送/停止」;新壳用 `busy` 即可) + pub streaming: bool, + /// YOLO 开关(跳过确认,审计照记) + pub yolo: bool, + /// 端点标签(栏顶显示) + pub endpoint_label: String, + /// 最近一次错误(横幅;下一次提交或 ingest 到新消息时清掉) + pub error: Option, +} + +impl Default for AgentRail { + fn default() -> Self { + Self::new() + } +} + +impl AgentRail { + /// 空会话。默认端点标签是 P0 的本地 llama.cpp(P1 由设置页决定)。 + #[must_use] + pub fn new() -> Self { + Self { + messages: Vec::new(), + unread: 0, + busy: false, + draft: String::new(), + confirm: None, + streaming: false, + yolo: false, + endpoint_label: "llama.cpp · 本地".to_string(), + error: None, + } + } + + /// 流式收敛:清掉全部流式标记并按消息重算 `streaming` / `busy`。 + pub fn finish_stream(&mut self) { + for message in &mut self.messages { + message.streaming = false; + } + self.streaming = false; + self.sync_busy(); + } + + /// 清零未读(用户正在看 / 主动点掉红点)。 + pub fn mark_read(&mut self) { + self.unread = 0; + } + + /// 取走待确认请求(`confirm` 置空并按需重算 `busy`);没有则返回 `None`。 + pub fn take_confirm(&mut self) -> Option { + let request = self.confirm.take(); + self.sync_busy(); + request + } + + /// 投影一条 agent 事件。 + /// + /// 返回 `true` 表示这是一条「用户不在栏前也会想知道」的新内容(未读计数的原料): + /// 助手/工具消息、错误、确认请求算;用户自己说的话与纯增量不算。 + pub fn ingest(&mut self, event: &AgentEvent) -> bool { + match event { + AgentEvent::MessageAppended(message) => { + let message = scrub_message(message); + let fresh = matches!(message.role, ChatRole::Assistant | ChatRole::Tool); + if message.streaming { + self.streaming = true; + } + if fresh { + self.error = None; + } + self.messages.push(message); + self.sync_busy(); + fresh + } + AgentEvent::MessageDelta { index, delta } => { + // 只追加正文,不动流式标记:迟到的尾增量(`Failed` 之后)不该把 + // 「一轮在跑」重新点亮,否则输入框会一直禁用。 + if let Some(message) = self.messages.get_mut(*index) { + message.text.push_str(&scrub_text(delta)); + } + self.sync_busy(); + false + } + AgentEvent::MessageFinished { index } => { + if let Some(message) = self.messages.get_mut(*index) { + message.streaming = false; + } + // 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄 + self.streaming = self.messages.iter().any(|message| message.streaming); + self.sync_busy(); + false + } + AgentEvent::ToolCallStarted { name, summary } => { + let summary = scrub_text(summary); + self.streaming = true; + self.error = None; + self.messages.push(ChatMessage { + role: ChatRole::Tool, + text: summary.clone(), + tool: Some(ToolCallView { + name: scrub_text(name), + ok: false, + summary, + }), + streaming: true, + }); + self.sync_busy(); + true + } + AgentEvent::ToolCallFinished { name, ok, summary } => { + let name = scrub_text(name); + let summary = scrub_text(summary); + // 优先收敛「还没结束的同名调用」(并发同名工具时按最近一条), + // 找不到再退回最近一条同名条目(重放/重复结束事件)。 + let pending = self + .messages + .iter() + .rposition(|message| message.streaming && tool_named(message, &name)); + let target = pending.or_else(|| { + self.messages + .iter() + .rposition(|message| tool_named(message, &name)) + }); + if let Some(index) = target { + let message = &mut self.messages[index]; + message.streaming = false; + message.text.clone_from(&summary); + if let Some(tool) = &mut message.tool { + tool.ok = *ok; + tool.summary = summary; + } + } + self.streaming = self.messages.iter().any(|message| message.streaming); + self.sync_busy(); + false + } + AgentEvent::Failed { message } => { + let text = scrub_text(message); + self.finish_stream(); + self.error = Some(text.clone()); + self.messages.push(ChatMessage::system(text)); + true + } + AgentEvent::ConfirmRequested { request } => { + self.confirm = Some(scrub_confirm(request)); + self.sync_busy(); + true + } + AgentEvent::ConfirmResolved { id } => { + if self + .confirm + .as_ref() + .is_some_and(|pending| &pending.id == id) + { + self.confirm = None; + } + self.sync_busy(); + false + } + } + } + + /// `busy` 的唯一写入口:`busy == streaming || confirm.is_some()`。 + /// + /// UI 靠 `busy` 禁用输入框,所以「等确认」必须算忙——否则用户能在一轮写操作中间再发一句, + /// 两个工具调用会踩同一份配置。 + fn sync_busy(&mut self) { + self.busy = self.streaming || self.confirm.is_some(); + } +} + +/// 消息脱敏(正文 + 工具摘要;工具名也过一遍,防止实现方把凭据塞进名字)。 +fn scrub_message(message: &ChatMessage) -> ChatMessage { + let tool = message.tool.as_ref().map(|tool| ToolCallView { + name: scrub_text(&tool.name), + ok: tool.ok, + summary: scrub_text(&tool.summary), + }); + ChatMessage { + role: message.role, + text: scrub_text(&message.text), + tool, + streaming: message.streaming, + } +} + +/// 确认请求脱敏:摘要按内容、细节按键名 + 内容双道。 +fn scrub_confirm(request: &ConfirmRequest) -> ConfirmRequest { + ConfirmRequest { + id: request.id.clone(), + tool: scrub_text(&request.tool), + summary: scrub_text(&request.summary), + details: scrub_details(&request.details), + sensitivity: request.sensitivity, + } +} + +/// 这条消息是不是指定名字的工具条目。 +fn tool_named(message: &ChatMessage, name: &str) -> bool { + message.tool.as_ref().is_some_and(|tool| tool.name == name) +} diff --git a/crates/presenter/src/agent/tests.rs b/crates/presenter/src/agent/tests.rs new file mode 100644 index 0000000..9de30a1 --- /dev/null +++ b/crates/presenter/src/agent/tests.rs @@ -0,0 +1,341 @@ +//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+ +//! 红线(凭据形状的串不得进 rail)+ YOLO 语义 + 提交前置条件。 +//! +//! 断言口径照 M103 的要求写清「抓得住什么 / 抓不住什么」:这些测试盯的是**投影与裁决**, +//! 不盯真实模型的措辞、真实时序与网络错误——那些要接 rig 之后跑回放回归。 + +use super::mock::{MockAgentTransport, Script}; +use super::{AgentTransport, ConfirmVerdict, Presenter}; +use crate::agent::rail::scrub_text; +use crate::agent::AgentRail; +use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity}; + +/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。 +const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g"; + +fn presenter(script: Script) -> Presenter { + Presenter::new(Box::new(MockAgentTransport::new(script))) +} + +// ───────────────────────── 路径 1:正常问答 ───────────────────────── + +#[test] +fn normal_answer_accumulates_into_one_assistant_message() { + let mut presenter = presenter(Script::Qa); + assert!(presenter.agent_submit("怎么装 1.20.4".to_string()).is_ok()); + + let rail = presenter.agent_rail(); + // 抓得住:用户气泡 + 一条助手消息(增量累积在同一条上,不是一个字一个气泡) + assert_eq!(rail.messages.len(), 2); + assert_eq!(rail.messages[0].role, ChatRole::User); + assert_eq!(rail.messages[0].text, "怎么装 1.20.4"); + assert_eq!(rail.messages[1].role, ChatRole::Assistant); + assert_eq!( + rail.messages[1].text, + "先确认版本:官方 1.20.4 是正式版,装完再加 Fabric。" + ); + assert!(!rail.messages[1].streaming); + assert!(!rail.streaming); + assert!(!rail.busy); + assert!(rail.confirm.is_none()); + assert!(rail.error.is_none()); + // 抓不住:不校验助手措辞是否正确(那是工具/模型的事) +} + +// ─────────────────── 路径 2:流式中途出错 ─────────────────── + +#[test] +fn mid_stream_error_keeps_partial_text_and_flags_the_turn() { + let mut presenter = presenter(Script::FailsMidStream); + assert!(presenter.agent_submit("日志里有错吗".to_string()).is_ok()); + + let rail = presenter.agent_rail(); + // 抓得住:已到的增量不丢、流式收敛(不会永远转圈)、错误进横幅也进会话历史 + assert_eq!(rail.messages[1].role, ChatRole::Assistant); + assert_eq!(rail.messages[1].text, "我先读一下最近的日志"); + assert!(!rail.streaming); + assert!(!rail.busy, "出错后输入框必须重新可用"); + assert_eq!(rail.error.as_deref(), Some("provider 断流:read timeout")); + assert_eq!(rail.messages[2].role, ChatRole::System); + assert_eq!(rail.messages[2].text, "provider 断流:read timeout"); + // 抓不住:不判断这是网络问题还是模型问题(归因在 core::diagnose) +} + +// ───────────── 路径 3a/3b:需确认的写操作(接受 / 拒绝)───────────── + +#[test] +fn write_op_accept_applies_and_audits() { + let mut presenter = presenter(Script::WriteOp); + assert!(presenter.agent_submit("并发调小点".to_string()).is_ok()); + + // 确认之前:rail 挂着请求,且 UI 靠 busy 禁用输入 + { + let rail = presenter.agent_rail(); + assert!(rail.busy); + let request = rail.confirm.as_ref().expect("应当有待确认请求"); + assert_eq!(request.tool, "set_config"); + assert_eq!(request.details["path"], "download.concurrency"); + assert!(request.sensitivity.needs_confirm()); + assert_eq!(rail.messages.len(), 3); // user / assistant / tool + assert_eq!(rail.messages[2].role, ChatRole::Tool); + } + + presenter.agent_confirm(true); + let rail = presenter.agent_rail(); + assert!(rail.confirm.is_none()); + assert!(!rail.busy); + assert_eq!(rail.messages[2].text, "已写入 download.concurrency = 16"); + assert!(rail.messages[2].tool.as_ref().is_some_and(|tool| tool.ok)); + assert_eq!( + rail.messages.last().map(|message| message.role), + Some(ChatRole::Assistant) + ); + + let audit = presenter.audit(); + assert_eq!(audit.len(), 1); + assert_eq!(audit[0].verdict, ConfirmVerdict::Approved); + assert!(!audit[0].yolo && audit[0].confirmed, "用户亲手点的允许"); + assert_eq!(audit[0].sensitivity, Sensitivity::High); +} + +#[test] +fn write_op_reject_denies_and_audits() { + let mut presenter = presenter(Script::WriteOp); + assert!(presenter.agent_submit("并发调小点".to_string()).is_ok()); + presenter.agent_confirm(false); + + let rail = presenter.agent_rail(); + assert!(rail.confirm.is_none()); + assert!(!rail.busy); + assert_eq!(rail.messages[2].text, "用户拒绝,未写入"); + assert!(rail.messages[2].tool.as_ref().is_some_and(|tool| !tool.ok)); + assert_eq!( + rail.messages.last().map(|text| text.text.clone()), + Some("那就不改了。".to_string()) + ); + + let audit = presenter.audit(); + assert_eq!(audit[0].verdict, ConfirmVerdict::Denied); + assert!(!audit[0].yolo && audit[0].confirmed); +} + +// ─────────────────────── 路径 4:未读计数 ─────────────────────── + +#[test] +fn unread_grows_while_collapsed_and_mark_read_clears() { + let mut presenter = presenter(Script::WriteOp); + presenter.state_mut().prefs.ai_collapsed = true; // 收成 icon 轨 = 用户离开了栏 + presenter + .agent_submit("并发调小点".to_string()) + .expect("提交应当成功"); + + // 抓得住:助手消息 + 工具条目 + 待确认各算一条;用户自己的话与纯增量不算 + assert_eq!(presenter.agent_rail().unread, 3); + + presenter.agent_confirm(true); + // 工具收尾不是新内容,收尾的助手消息算一条 + assert_eq!(presenter.agent_rail().unread, 4); + + presenter.agent_mark_read(); + assert_eq!(presenter.agent_rail().unread, 0); +} + +// ───────────────────────── YOLO 语义 ───────────────────────── + +#[test] +fn yolo_auto_accepts_but_still_records_the_verdict() { + let mut presenter = presenter(Script::WriteOp); + presenter.state_mut().ai.yolo = true; + presenter + .agent_submit("并发调小点".to_string()) + .expect("提交应当成功"); + + // 用户点了「拒绝」,但 YOLO 下自动放行 + presenter.agent_confirm(false); + + let rail = presenter.agent_rail(); + assert!(rail.confirm.is_none()); + assert_eq!(rail.messages[2].text, "已写入 download.concurrency = 16"); + + // 不得静默跳过:审计里必须有这一条,且两种标记分开记 + let audit = presenter.audit(); + assert_eq!(audit.len(), 1); + assert_eq!(audit[0].verdict, ConfirmVerdict::Approved); + assert!(audit[0].yolo, "YOLO 自动放行要留痕"); + assert!(!audit[0].confirmed, "自动放行不是「用户确认」"); +} + +#[test] +fn pending_confirm_forces_busy_without_yolo() { + let mut presenter = presenter(Script::WriteOp); + assert!(!presenter.agent_rail().yolo); + presenter + .agent_submit("并发调小点".to_string()) + .expect("提交应当成功"); + + let rail = presenter.agent_rail(); + assert!(rail.confirm.is_some()); + assert!(rail.busy, "非 YOLO 下待确认必须算忙,UI 靠它禁用输入"); +} + +// ───────────────────── 红线:凭据形状的串 ───────────────────── + +#[test] +fn jwt_shaped_text_never_reaches_the_rail() { + let mut presenter = presenter(Script::Qa); + // 五个入口各注入一次:用户提交、增量、工具摘要、错误、确认请求细节 + presenter + .agent_submit(format!("我的 token 是 {JWT}")) + .expect("提交应当成功"); + presenter.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 1, + delta: format!("顺手贴一个 {JWT}"), + })); + presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted { + name: "read_game_log".to_string(), + summary: format!("Authorization: Bearer {JWT}"), + })); + presenter.apply(AppEvent::Agent(AgentEvent::Failed { + message: format!("401: access_token={JWT}"), + })); + presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested { + request: ConfirmRequest { + id: "req-jwt".to_string(), + tool: "set_config".to_string(), + summary: format!("把 access_token 改成 {JWT}"), + details: serde_json::json!({ "access_token": JWT, "note": JWT }), + sensitivity: Sensitivity::High, + }, + })); + + let rail = presenter.agent_rail(); + let text = rail_text(rail); + assert!( + !text.contains("eyJhbGci"), + "rail 上不得出现 JWT 原文:{text}" + ); + assert!( + text.contains(""), + "应当被脱敏成占位符:{text}" + ); + // 凭据键名的值整段打码(不只是靠内容规则) + let details = &rail.confirm.as_ref().expect("确认请求仍在").details; + assert_eq!(details["access_token"], ""); + assert!(!details["note"] + .as_str() + .unwrap_or_default() + .contains("eyJhbGci")); +} + +#[test] +fn scrub_text_is_idempotent_on_already_redacted_text() { + let once = scrub_text(JWT); + assert_eq!(scrub_text(&once), once); +} + +// ─────────────────────── 提交前置条件 ─────────────────────── + +#[test] +fn submit_rejects_empty_busy_and_pending_confirm() { + let mut presenter = presenter(Script::Qa); + assert!(presenter.agent_submit(" \n ".to_string()).is_err()); + assert!(presenter.agent_rail().messages.is_empty()); + + // 上一轮还在跑 + presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + assert!(presenter.agent_submit("再说一句".to_string()).is_err()); + + // 有写操作待确认 + presenter.apply(AppEvent::Agent(AgentEvent::Failed { + message: "断流".to_string(), + })); + presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested { + request: ConfirmRequest { + id: "req-1".to_string(), + tool: "set_config".to_string(), + summary: "改内存".to_string(), + details: serde_json::json!({}), + sensitivity: Sensitivity::High, + }, + })); + let error = presenter.agent_submit("再说一句".to_string()); + assert!(error.is_err(), "待确认时不许再提交"); +} + +#[test] +fn submit_keeps_the_draft_when_the_backend_fails() { + let mut presenter = Presenter::new(Box::new(BrokenTransport)); + presenter.state_mut().ai.draft = "还没发出去的话".to_string(); + assert!(presenter + .agent_submit("还没发出去的话".to_string()) + .is_err()); + // 抓得住:起不来的一轮不产生事件、草稿保留(用户能改完再发) + assert!(presenter.agent_rail().messages.is_empty()); + assert_eq!(presenter.agent_rail().draft, "还没发出去的话"); +} + +#[test] +fn draft_is_cleared_only_on_successful_submit() { + let mut presenter = presenter(Script::Qa); + presenter.state_mut().ai.draft = " 怎么装 ".to_string(); + presenter + .agent_submit(" 怎么装 ".to_string()) + .expect("提交应当成功"); + assert!(presenter.agent_rail().draft.is_empty()); +} + +#[test] +fn confirm_without_pending_is_a_noop() { + let mut presenter = presenter(Script::Qa); + presenter.agent_confirm(true); + presenter.agent_confirm(false); + assert!(presenter.audit().is_empty()); + assert!(presenter.agent_rail().messages.is_empty()); +} + +#[test] +fn apply_forwards_every_event_to_app_state() { + let mut presenter = presenter(Script::Qa); + presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + assert_eq!(presenter.state().ai.messages.len(), 1); + assert_eq!(presenter.agent_rail(), &presenter.state().ai); +} + +// ───────────────────────── 测试替身 ───────────────────────── + +/// 起不来的后端(返回 `Err`,不产生任何事件)。 +struct BrokenTransport; + +impl AgentTransport for BrokenTransport { + fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result, String> { + Err("本地模型还没加载".to_string()) + } + + fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec { + Vec::new() + } +} + +/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。 +fn rail_text(rail: &AgentRail) -> String { + let mut out = String::new(); + for message in &rail.messages { + out.push_str(&message.text); + if let Some(tool) = &message.tool { + out.push_str(&tool.name); + out.push_str(&tool.summary); + } + } + out.push_str(rail.error.as_deref().unwrap_or_default()); + out.push_str(&rail.endpoint_label); + if let Some(confirm) = &rail.confirm { + out.push_str(&confirm.tool); + out.push_str(&confirm.summary); + out.push_str(&confirm.details.to_string()); + } + out +} diff --git a/crates/presenter/src/dto/agent.rs b/crates/presenter/src/dto/agent.rs index 979afe4..3dd6e28 100644 --- a/crates/presenter/src/dto/agent.rs +++ b/crates/presenter/src/dto/agent.rs @@ -1,4 +1,8 @@ -//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。 +//! AI 会话 DTO:消息、工具调用可视化、确认请求与 agent 事件。 +//! +//! 与 agent 侧的对应关系(`crates/agent/src/event.rs` / `confirm.rs`):这里是**扁平镜像**, +//! 不 import `unlucky_agent::*`(dto 是 IPC 边界,见 `dto/mod.rs` 的纪律)。字段口径刻意逐字 +//! 对齐,翻译在接线层做:`AgentEvent::ConfirmRequested` ← `confirm::ConfirmRequest`。 use serde::{Deserialize, Serialize}; @@ -59,10 +63,70 @@ impl ChatMessage { streaming: true, } } + + /// 系统提示(弱化展示;错误横幅也进会话历史,用户回翻时看得到)。 + pub fn system(text: impl Into) -> Self { + Self { + role: ChatRole::System, + text: text.into(), + tool: None, + streaming: false, + } + } +} + +/// 敏感等级(与 agent 侧 `confirm::Sensitivity` 逐字一致)。 +/// +/// | 等级 | 语义 | +/// |------|------| +/// | `Low` | 静默生效 | +/// | `Medium` | 静默生效,审计里能查到 | +/// | `High` | 必须用户确认(YOLO 可跳过确认,但**仍受白名单约束**) | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Sensitivity { + /// 静默生效。 + #[default] + Low, + /// 静默生效但审计标记。 + Medium, + /// 必须用户确认。 + High, +} + +impl Sensitivity { + /// 是否会触发确认弹窗(非 YOLO 时)。 + #[must_use] + pub fn needs_confirm(self) -> bool { + self == Self::High + } +} + +/// 一次待确认的写操作(确认弹窗的原料;与 agent 侧 `confirm::ConfirmRequest` 对齐)。 +/// +/// 构造时**不要**放凭据:`details` 会按键名与内容双道脱敏([`crate::agent::rail`]), +/// 但白名单之外的键本来就不该出现在这里。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConfirmRequest { + /// 请求 id(编排层生成,UI 回填决定时原样带回) + pub id: String, + /// 工具名(与 ToolSpec 注册表同名) + pub tool: String, + /// 一句话请求(「AI 请求把下载并发 64 改为 16」) + pub summary: String, + /// 结构化细节(`{"path":…, "old":…, "new":…}`),UI 画 diff 用 + pub details: serde_json::Value, + /// 敏感等级 + pub sensitivity: Sensitivity, } /// agent 侧事件。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// 四类都投到 [`crate::agent::AgentRail`] 上:流式增量 → 最后一条 assistant 消息;工具调用 → +/// 会话里可见的 tool 条目(D4);错误 → 系统条目 + 横幅;确认请求 → `rail.confirm`。 +/// +/// 注意这里**没有** `Eq`:`ConfirmRequest::details` 是 `serde_json::Value`(可含浮点)。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum AgentEvent { /// 追加一条完整消息 MessageAppended(ChatMessage), @@ -94,4 +158,22 @@ pub enum AgentEvent { /// 结果摘要 summary: String, }, + /// 循环级错误(provider 断流、AI 被禁用、工具内部异常)。 + /// + /// 与 `ToolCallFinished { ok: false }` 的分工:那个是**某一次工具调用**失败,对话继续; + /// 这个是**这一轮回复**中止,rail 要收敛掉所有流式标记并立一个错误横幅。 + Failed { + /// 人类可读原因 + message: String, + }, + /// 需要用户确认(分级确认:写操作先问再改)。 + ConfirmRequested { + /// 待确认请求 + request: ConfirmRequest, + }, + /// 确认请求已收敛(重放/超时/别处已裁决):把 rail 上同 id 的待确认清掉,避免僵尸弹窗。 + ConfirmResolved { + /// 请求 id + id: String, + }, } diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 4bd5e6a..1d58ce4 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -26,7 +26,7 @@ mod settings; mod versions; pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView}; -pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView}; +pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use instances::{ diff --git a/crates/presenter/src/lib.rs b/crates/presenter/src/lib.rs index d9e96b1..937ee7a 100644 --- a/crates/presenter/src/lib.rs +++ b/crates/presenter/src/lib.rs @@ -25,6 +25,7 @@ //! | [`dto`] | [`dto::Backend`] / [`dto::AppCommand`] / [`dto::AppEvent`] / 各 `*View` 快照 | 派发指令、消费事件、把快照字段映射成视图模型 | //! | [`host`] | [`host::CoreHost`](实现 [`dto::Backend`])、[`host::HostEnv`] | 进程启动时构造一次,之后只当 `Box` 用 | //! | [`state`] | [`state::AppState`](`apply` / `navigate` / 各页面字段) | **唯一**状态入口:事件进来先 `apply`,再从字段取值画 | +//! | [`agent`] | `Presenter`(`agent_rail` / `agent_submit` / `agent_mark_read` / `agent_confirm`)、`AgentRail` | AI 助手的唯一状态入口:读 rail 画、把点击翻成这四个方法 | //! | [`prefs`] | `UiPrefs` / `ThemeMode` / `TileSize` / `AppearancePrefs` / `keys` | 读字段做展示;不自己写盘 | //! | [`settings_bridge`] | `SettingsBridge`(`open` / `load` / `persist` / `reload_if_changed`) | 启动读一次、变更时写回、每拍问一次「变了吗」 | //! | [`theme`] | `CONFIG_POLL_TICKS` / `BACKGROUND_KEY` 一类纯判据、`tile_viewport` / `system_theme_value` | 「这一拍该不该重读/重建」由它回答,UI 只执行 | @@ -102,6 +103,7 @@ // 这里沿用同一条策略统一放开,接线完成后再删掉本行暴露真死码。 #![allow(dead_code)] +pub mod agent; pub mod backend; pub mod dialogs; pub mod dto; diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index c928460..a84135a 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -3,59 +3,18 @@ //! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → UI 属性。 //! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。 +use crate::agent::AgentRail; use crate::dialogs::DialogQueue; use crate::dto::{ - AccountView, AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DeviceLoginView, - DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine, - LogSession, NoticeView, Progress, SettingGroupView, ToolCallView, VersionSummary, + AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport, + InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LogLine, LogSession, + NoticeView, Progress, SettingGroupView, VersionSummary, }; use crate::prefs::UiPrefs; use crate::router::{AiPanelMode, Route, Router}; use crate::toast::{ToastCenter, ToastKind}; use crate::wizard::WizardState; -/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AiSession { - /// 会话消息(user / assistant / tool / system) - pub messages: Vec, - /// 是否有流式消息在跑 - pub streaming: bool, - /// YOLO 开关(跳过确认,审计照记) - pub yolo: bool, - /// 输入框草稿(切形态不丢) - pub draft: String, - /// 端点标签(显示在栏顶) - pub endpoint_label: String, -} - -impl Default for AiSession { - fn default() -> Self { - Self::new() - } -} - -impl AiSession { - /// 默认端点标签(P0 是本地 llama.cpp;P1 由设置页决定)。 - pub fn new() -> Self { - Self { - messages: Vec::new(), - streaming: false, - yolo: false, - draft: String::new(), - endpoint_label: "llama.cpp · 本地".to_string(), - } - } - - /// 流式收敛:清掉全部流式标记。 - pub fn finish_stream(&mut self) { - self.streaming = false; - for message in &mut self.messages { - message.streaming = false; - } - } -} - /// 磁贴网格单元:位置由 [`grid_positions`] 算好,UI 侧只消费 row/col。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct TileCell<'a> { @@ -99,8 +58,8 @@ pub struct AppState { pub diagnosis: Option, /// 选中的日志会话 pub selected_session: Option, - /// AI 会话状态 - pub ai: AiSession, + /// AI 会话状态(右侧栏与独立页共享同一份,切换不丢) + pub ai: AgentRail, /// 对话框队列(一次一条) pub dialogs: DialogQueue, /// Toast 中心 @@ -149,7 +108,7 @@ impl AppState { log_lines: Vec::new(), diagnosis: None, selected_session: None, - ai: AiSession::new(), + ai: AgentRail::new(), dialogs: DialogQueue::new(), toasts: ToastCenter::new(), instance_query: String::new(), @@ -176,6 +135,8 @@ impl AppState { pub fn navigate(&mut self, route: Route) { if route == Route::Agent { self.prefs.ai_collapsed = false; + // 用户正在看 AI 页:攒下的未读立刻算已读(红点不该在眼前亮着) + self.ai.mark_read(); } self.router.navigate(route); } @@ -262,57 +223,31 @@ impl AppState { } } - /// agent 事件(AI 会话的流式消息与工具调用)。 + /// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。 + /// + /// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`; + /// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。 pub fn apply_agent(&mut self, event: AgentEvent) { - match event { - AgentEvent::MessageAppended(message) => { - // 流式占位到达即视为「一轮回复进行中」:UI 的发送键/停止键据此切换 - if message.streaming { - self.ai.streaming = true; - } - self.ai.messages.push(message); - } - AgentEvent::MessageDelta { index, delta } => { - if let Some(message) = self.ai.messages.get_mut(index) { - message.text.push_str(&delta); - } - } - AgentEvent::MessageFinished { index } => { - if let Some(message) = self.ai.messages.get_mut(index) { - message.streaming = false; - } - self.ai.streaming = false; - } - AgentEvent::ToolCallStarted { name, summary } => { - self.ai.streaming = true; - self.ai.messages.push(ChatMessage { - role: ChatRole::Tool, - text: summary.clone(), - tool: Some(ToolCallView { - name, - ok: false, - summary, - }), - streaming: true, - }); - } - AgentEvent::ToolCallFinished { name, ok, summary } => { - let target = - self.ai.messages.iter_mut().rev().find(|message| { - message.tool.as_ref().is_some_and(|tool| tool.name == name) - }); - if let Some(message) = target { - message.text.clone_from(&summary); - message.streaming = false; - if let Some(tool) = &mut message.tool { - tool.ok = ok; - tool.summary = summary; - } - } + let visible = self.agent_rail_visible(); + let fresh = self.ai.ingest(&event); + if fresh { + if visible { + self.ai.mark_read(); + } else { + self.ai.unread = self.ai.unread.saturating_add(1); } } } + /// AI 栏此刻是否「在用户眼前」。 + /// + /// 独立页形态(栈顶是 `Route::Agent`)必然可见;停靠形态下只有收成 icon 轨才算离开—— + /// 右侧常驻栏在任何一级页面都挂着,用户扫一眼就能看到。 + #[must_use] + pub fn agent_rail_visible(&self) -> bool { + self.ai_mode() == AiPanelMode::Page || !self.prefs.ai_collapsed + } + /// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。 fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) { if self @@ -386,6 +321,10 @@ impl AppState { return; } self.prefs.ai_collapsed = !self.prefs.ai_collapsed; + if !self.prefs.ai_collapsed { + // 展开常驻栏 = 用户在看它 + self.ai.mark_read(); + } } } diff --git a/crates/presenter/src/state_tests.rs b/crates/presenter/src/state_tests.rs index 6001569..7d589fb 100644 --- a/crates/presenter/src/state_tests.rs +++ b/crates/presenter/src/state_tests.rs @@ -1,6 +1,7 @@ //! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。 -use super::{grid_positions, AiSession, AppState}; +use super::{grid_positions, AppState}; +use crate::agent::AgentRail; use crate::dialogs::{DialogKind, DialogRequest, DialogSource}; use crate::dto::{ AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport, @@ -159,12 +160,13 @@ fn ai_stream_deltas_accumulate_and_finish() { #[test] fn ai_session_finish_stream_clears_every_flag() { - let mut session = AiSession::new(); + let mut session = AgentRail::new(); session.messages.push(ChatMessage::user("hi")); session.messages.push(ChatMessage::assistant_stream()); session.finish_stream(); assert!(session.messages.iter().all(|message| !message.streaming)); assert!(!session.streaming); + assert!(!session.busy); } #[test] @@ -239,6 +241,107 @@ fn toggling_ai_from_the_page_form_docks_it_again() { assert!(state.prefs.ai_collapsed); } +#[test] +fn failed_event_settles_streaming_and_keeps_the_evidence() { + let mut state = AppState::new(); + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + state.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 0, + delta: "我先读一下".to_string(), + })); + state.apply(AppEvent::Agent(AgentEvent::Failed { + message: "provider 断流:read timeout".to_string(), + })); + + // 抓得住:已到的增量不丢、流式标记收敛、错误进横幅也进会话历史 + assert_eq!(state.ai.messages[0].text, "我先读一下"); + assert!(!state.ai.messages[0].streaming); + assert!(!state.ai.streaming); + assert!(!state.ai.busy); + assert_eq!( + state.ai.error.as_deref(), + Some("provider 断流:read timeout") + ); + assert_eq!(state.ai.messages[1].role, ChatRole::System); + // 抓不住:这里不判断错误归因(那是 core::diagnose 的事) +} + +#[test] +fn confirm_request_keeps_the_shell_busy_until_it_resolves() { + let mut state = AppState::new(); + state.apply(AppEvent::Agent(AgentEvent::ConfirmRequested { + request: confirm_request("req-1"), + })); + assert!(state.ai.busy, "有待确认时 UI 必须禁用输入"); + assert_eq!( + state.ai.confirm.as_ref().map(|request| request.id.as_str()), + Some("req-1") + ); + + // 别处已裁决(重放/超时):僵尸弹窗必须收掉 + state.apply(AppEvent::Agent(AgentEvent::ConfirmResolved { + id: "req-1".to_string(), + })); + assert!(!state.ai.busy); + assert!(state.ai.confirm.is_none()); +} + +#[test] +fn unread_counts_only_while_the_rail_is_out_of_sight() { + let mut state = AppState::new(); + // 展开的常驻栏在任何页面都挂着:到达即已读 + assert!(state.agent_rail_visible()); + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::assistant_stream(), + ))); + assert_eq!(state.ai.unread, 0); + + // 收成 icon 轨 = 用户看不见 + state.prefs.ai_collapsed = true; + assert!(!state.agent_rail_visible()); + state.apply(AppEvent::Agent(AgentEvent::MessageDelta { + index: 0, + delta: "长回复".to_string(), + })); + assert_eq!(state.ai.unread, 0, "增量属于同一条消息,不重复计数"); + state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted { + name: "read_game_log".to_string(), + summary: "读取日志".to_string(), + })); + assert_eq!(state.ai.unread, 1); + + // 展开栏 = 用户在看它,未读立刻归零 + state.toggle_ai(); + assert_eq!(state.ai.unread, 0); + + // 独立页形态永远算可见 + state.navigate(Route::Agent); + assert!(state.agent_rail_visible()); +} + +#[test] +fn user_messages_never_count_as_unread() { + let mut state = AppState::new(); + state.prefs.ai_collapsed = true; + state.apply(AppEvent::Agent(AgentEvent::MessageAppended( + ChatMessage::user("我自己说的话"), + ))); + assert_eq!(state.ai.unread, 0); +} + +/// 待确认请求样例(High 级写操作)。 +fn confirm_request(id: &str) -> crate::dto::ConfirmRequest { + crate::dto::ConfirmRequest { + id: id.to_string(), + tool: "set_config".to_string(), + summary: "把下载并发 64 → 16".to_string(), + details: serde_json::json!({ "path": "download.concurrency" }), + sensitivity: crate::dto::Sensitivity::High, + } +} + #[test] fn toggle_nav_flips_the_pref() { let mut state = AppState::new();