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,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<ConfirmRequest>,
|
||||
}
|
||||
|
||||
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<Vec<AppEvent>, 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<AppEvent> {
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user