feat(presenter): M103 AI 助手栏视图模型与事件投影

- 新增 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)
This commit is contained in:
AskaEth committed 2026-11-04 00:21:19 +08:00
1 parent 15eec1160d
commit a8d4cce3e2
9 files changed
+1295 -99

No files matched your search

+256
View File
@@ -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<Vec<AppEvent>, String>;
/// 用户对写操作做了决定(`accept = false` 表示拒绝)。
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent>;
}
/// AI 助手的 presenter 门面:持有一份 [`AppState`] 与一个后端端口。
///
/// 事件仍走 [`AppState::apply`](唯一状态变更入口)——[`Presenter::apply`] 只是转发,
/// 不在这一层另做投影。
pub struct Presenter {
state: AppState,
transport: Box<dyn AgentTransport>,
audit: Vec<AuditEntry>,
}
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<dyn AgentTransport>) -> Self {
Self::with_state(AppState::new(), transport)
}
/// 接管一份已有状态(UI 壳先建 `AppState` 再交给 presenter 时用)。
#[must_use]
pub fn with_state(state: AppState, transport: Box<dyn AgentTransport>) -> 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);
}
}