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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user