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);
}
}
+165
View File
@@ -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)
}
+308
View File
@@ -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 不持有数据根):`<home>` 掩码是 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<ChatMessage>,
/// 栏不可见期间攒下的未读条数(`agent_mark_read` 清零)
pub unread: usize,
/// 是否该禁用输入(一轮回复进行中,或有写操作待确认)
pub busy: bool,
/// 输入框草稿(切形态不丢)
pub draft: String,
/// 待确认的写操作(`Some` = 弹确认框;非 YOLO 下 `busy` 必为 true)
pub confirm: Option<ConfirmRequest>,
/// 是否有流式消息在跑(Slint 壳读它切换「发送/停止」;新壳用 `busy` 即可)
pub streaming: bool,
/// YOLO 开关(跳过确认,审计照记)
pub yolo: bool,
/// 端点标签(栏顶显示)
pub endpoint_label: String,
/// 最近一次错误(横幅;下一次提交或 ingest 到新消息时清掉)
pub error: Option<String>,
}
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<ConfirmRequest> {
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)
}
+341
View File
@@ -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("<redacted:token>"),
"应当被脱敏成占位符:{text}"
);
// 凭据键名的值整段打码(不只是靠内容规则)
let details = &rail.confirm.as_ref().expect("确认请求仍在").details;
assert_eq!(details["access_token"], "<redacted: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<Vec<AppEvent>, String> {
Err("本地模型还没加载".to_string())
}
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
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
}
+84 -2
View File
@@ -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<String>) -> 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,
},
}
+1 -1
View File
@@ -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::{
+2
View File
@@ -25,6 +25,7 @@
//! | [`dto`] | [`dto::Backend`] / [`dto::AppCommand`] / [`dto::AppEvent`] / 各 `*View` 快照 | 派发指令、消费事件、把快照字段映射成视图模型 |
//! | [`host`] | [`host::CoreHost`](实现 [`dto::Backend`])、[`host::HostEnv`] | 进程启动时构造一次,之后只当 `Box<dyn Backend>` 用 |
//! | [`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;
+33 -94
View File
@@ -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<ChatMessage>,
/// 是否有流式消息在跑
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<DiagnosisReport>,
/// 选中的日志会话
pub selected_session: Option<String>,
/// 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();
}
}
}
+105 -2
View File
@@ -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();