From 9ab909d01cbc132f91abd9e4c1c6d11820f2bf1c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 13:44:20 +0800 Subject: [PATCH] =?UTF-8?q?refactor(presenter):=20=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E7=BA=AF=E6=90=AC=E7=A7=BB=E8=85=BE=E5=87=BA=20400=20=E8=A1=8C?= =?UTF-8?q?=E4=BD=99=E9=87=8F=EF=BC=88events=20=E7=94=A8=E4=BE=8B=20/=20AI?= =?UTF-8?q?=20=E6=A0=8F=E4=BA=8B=E4=BB=B6=E6=8A=95=E9=80=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit events.rs 399 行、state.rs 400 行(AGENTS §3.1 与 §3.9 的零余量贴线文件)。M223 要往 AppCommand/CoreEvent 各加一条「导入已有版本」的变体、往 AppState 加一份事件态,都得先腾地方。 纯搬移、一行未改: - events.rs 尾部 #[cfg(test)] mod tests(80 行)→ dto/events/tests.rs(399 → 321); - state.rs 的 apply_agent + agent_rail_visible(28 行)→ state/agent.rs(400 → 376)。 方法路径不变(impl 写在子模块、pub use 无需新增),断言一条未动,搬移 diff 可逐行对。 --- crates/presenter/src/dto/events.rs | 80 +--------------------- crates/presenter/src/dto/events/tests.rs | 87 ++++++++++++++++++++++++ crates/presenter/src/state.rs | 28 +------- crates/presenter/src/state/agent.rs | 39 +++++++++++ 4 files changed, 129 insertions(+), 105 deletions(-) create mode 100644 crates/presenter/src/dto/events/tests.rs create mode 100644 crates/presenter/src/state/agent.rs diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index d611b2e..7fcd695 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -318,82 +318,4 @@ pub trait Backend { } #[cfg(test)] -mod tests { - use super::*; - use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage}; - - #[test] - fn events_are_serializable_ipc_contracts() { - let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView { - stage: InstallStage::Downloading, - files_done: 3, - files_total: 9, - ..InstallProgressView::new() - }))); - let text = serde_json::to_string(&event).expect("序列化"); - let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, event); - - let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView { - id: "account:1".to_string(), - name: "Steve".to_string(), - kind: AccountKind::Offline, - profile_id: "0f8f".to_string(), - login_name: String::new(), - created: "2026-10-31 20:12".to_string(), - active: true, - server_url: String::new(), - skin: None, - }])); - let text = serde_json::to_string(&account).expect("序列化"); - let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, account); - // 凭据永不跨界:序列化文本里没有 token 类字段 - assert!(!text.to_lowercase().contains("token")); - } - - #[test] - fn commands_round_trip() { - let command = AppCommand::CreateInstance(CreateRequest { - version: "1.20.4".to_string(), - loader: crate::dto::LoaderKind::Fabric, - loader_version: "0.16.5".to_string(), - name: "生存".to_string(), - }); - let text = serde_json::to_string(&command).expect("序列化"); - let back: AppCommand = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, command); - - let login = AppCommand::StartDeviceLogin; - assert_eq!( - serde_json::from_str::(&serde_json::to_string(&login).expect("序列化")) - .expect("反序列化"), - login - ); - assert!(DeviceLoginPhase::Waiting.busy()); - - // 第三方轨(M114):请求进、状态出,两边都不带 token 字段 - let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new( - "https://littleskin.cn/api/yggdrasil/", - "me@example.com", - "hunter2", - )); - let text = serde_json::to_string(&authlib).expect("序列化"); - assert_eq!( - serde_json::from_str::(&text).expect("反序列化"), - authlib - ); - - let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable( - "https://littleskin.cn/api/yggdrasil/", - "LittleSkin", - ))); - let text = serde_json::to_string(&view).expect("序列化"); - let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); - assert_eq!(back, view); - assert!( - !text.to_lowercase().contains("token"), - "回程视图里不该有 token" - ); - } -} +mod tests; diff --git a/crates/presenter/src/dto/events/tests.rs b/crates/presenter/src/dto/events/tests.rs new file mode 100644 index 0000000..8853d68 --- /dev/null +++ b/crates/presenter/src/dto/events/tests.rs @@ -0,0 +1,87 @@ +//! `dto::events` 的 IPC 契约用例(M223 从 `events.rs` 尾部**纯搬移**,一行未改)。 +//! +//! 为什么搬:M223 要给 `AppCommand` / `CoreEvent` 各加一条变体(「导入已有版本」的指令与事件), +//! 而 `events.rs` 当时正好 399 行——加两行变体 + 两行 import 就顶破 400 行硬线(AGENTS §3.1)。 +//! 拆分口径与仓库其它拆分一致:**纯搬移、一行不改**,`mod tests;` 只是把位置挪到 +//! `dto/events/tests.rs`(`events.rs` 是非 `mod.rs` 文件,子模块目录就是 `events/`)。 +//! +//! 搬移后 `events.rs` 只剩契约本体(枚举 + 构造器 + `Backend` trait),本文件的断言一条没动, +//! 所以「搬移零行为变化」可以用逐行 diff 对。 + +use super::*; +use crate::dto::{AccountKind, DeviceLoginPhase, InstallStage}; + +#[test] +fn events_are_serializable_ipc_contracts() { + let event = AppEvent::Core(CoreEvent::InstallProgress(Some(InstallProgressView { + stage: InstallStage::Downloading, + files_done: 3, + files_total: 9, + ..InstallProgressView::new() + }))); + let text = serde_json::to_string(&event).expect("序列化"); + let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, event); + + let account = AppEvent::Core(CoreEvent::AccountsChanged(vec![AccountView { + id: "account:1".to_string(), + name: "Steve".to_string(), + kind: AccountKind::Offline, + profile_id: "0f8f".to_string(), + login_name: String::new(), + created: "2026-10-31 20:12".to_string(), + active: true, + server_url: String::new(), + skin: None, + }])); + let text = serde_json::to_string(&account).expect("序列化"); + let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, account); + // 凭据永不跨界:序列化文本里没有 token 类字段 + assert!(!text.to_lowercase().contains("token")); +} + +#[test] +fn commands_round_trip() { + let command = AppCommand::CreateInstance(CreateRequest { + version: "1.20.4".to_string(), + loader: crate::dto::LoaderKind::Fabric, + loader_version: "0.16.5".to_string(), + name: "生存".to_string(), + }); + let text = serde_json::to_string(&command).expect("序列化"); + let back: AppCommand = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, command); + + let login = AppCommand::StartDeviceLogin; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&login).expect("序列化")) + .expect("反序列化"), + login + ); + assert!(DeviceLoginPhase::Waiting.busy()); + + // 第三方轨(M114):请求进、状态出,两边都不带 token 字段 + let authlib = AppCommand::AddAuthlibAccount(AuthlibRequest::new( + "https://littleskin.cn/api/yggdrasil/", + "me@example.com", + "hunter2", + )); + let text = serde_json::to_string(&authlib).expect("序列化"); + assert_eq!( + serde_json::from_str::(&text).expect("反序列化"), + authlib + ); + + let view = AppEvent::Core(CoreEvent::AuthlibLoginChanged(AuthlibLoginView::reachable( + "https://littleskin.cn/api/yggdrasil/", + "LittleSkin", + ))); + let text = serde_json::to_string(&view).expect("序列化"); + let back: AppEvent = serde_json::from_str(&text).expect("反序列化"); + assert_eq!(back, view); + assert!( + !text.to_lowercase().contains("token"), + "回程视图里不该有 token" + ); +} diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index d0187ed..94463e3 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -8,7 +8,6 @@ use crate::dialogs::DialogQueue; use crate::dto::{ // 主线(M105 的 `LoaderKind` / M113 的 `PackState`)与 M114 的 `AuthlibLoginView` 并集。 AccountView, - AgentEvent, AppEvent, AuthlibLoginView, CoreEvent, @@ -42,6 +41,8 @@ use crate::wizard::WizardState; // 所以这里没有 `mod ai;` / `pub use ai::AiSession;`——M114 r1 曾把旧结构搬到 `state/ai.rs`, // 该文件已随 rebase 删除,**以 main 的 agent 契约为准**。 mod accounts; +// M223:AI 栏的事件投递与可见性(纯搬移,守 400 行硬线) +mod agent; mod live; mod tiles; @@ -304,31 +305,6 @@ impl AppState { } } - /// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。 - /// - /// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`; - /// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。 - pub fn apply_agent(&mut self, event: AgentEvent) { - 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 - } - /// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。 pub fn tiles(&self) -> Vec> { let query = self.instance_query.trim().to_lowercase(); diff --git a/crates/presenter/src/state/agent.rs b/crates/presenter/src/state/agent.rs new file mode 100644 index 0000000..8392325 --- /dev/null +++ b/crates/presenter/src/state/agent.rs @@ -0,0 +1,39 @@ +//! AI 栏的事件投递与可见性判定(M223 从 `state.rs` **纯搬移**,一行未改)。 +//! +//! 为什么搬:M223 要往 `AppState` 加「导入已有版本」的状态字段与一条 `CoreEvent::Adopt` 分支, +//! 而 `state.rs` 当时正好 400 行(零余量,AGENTS §3.9)。拆分口径与 [`super::tiles`] 一致: +//! **纯搬移、一行不改**;`impl AppState` 写在子模块里,方法路径 +//! `AppState::apply_agent` / `AppState::agent_rail_visible` 对调用方完全不变 +//! (子模块看得见父模块的私有字段,所以这里直接用 `self.ai` / `self.prefs`)。 + +use crate::dto::AgentEvent; +use crate::router::AiPanelMode; + +use super::AppState; + +impl AppState { + /// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。 + /// + /// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`; + /// 用户自己说的话与纯增量不算。栏可见时到达的内容直接算已读(红点不该在眼前亮着)。 + pub fn apply_agent(&mut self, event: AgentEvent) { + 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 + } +}