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:
1 parent
15eec1160d
commit
a8d4cce3e2
9 files changed
+1295
-99
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user