feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)

D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate,
Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。

- 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`),
  workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、
  iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。
- `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、
  prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、
  mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等),
  内部实现零改动。
- 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS /
  SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport /
  system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。
- crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge|
  toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*`
  (64 处),backend.rs 的 config_path/build_backend 提为 pub。
- crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。
- prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
This commit is contained in:
AskaEth committed 2026-11-02 09:29:31 +08:00
1 parent 00f132d8ce
commit f159529936
69 files changed
+441 -129

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。
//!
//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、
//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换
//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。
//! 假数据本体在 [`crate::mock_data`]。
use std::collections::VecDeque;
use std::time::Duration;
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
use crate::dto::{
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
InstanceRuntime, LaunchStage, Progress,
};
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
use crate::toast::{Toast, ToastKind};
/// 启动脚本:按时间推进 download → verify → launch → running。
#[derive(Debug)]
struct LaunchScript {
id: InstanceId,
elapsed_ms: u64,
}
/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。
#[derive(Debug)]
struct ChatScript {
elapsed_ms: u64,
chunks: VecDeque<String>,
/// 流式助手消息在 app 会话里的序号
message_index: usize,
}
/// 假后端。
#[derive(Debug, Default)]
pub struct MockBackend {
events: VecDeque<AppEvent>,
launch: Option<LaunchScript>,
chat: Option<ChatScript>,
/// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号)
emitted_messages: usize,
next_dialog: u64,
}
impl MockBackend {
/// 预置一批假数据事件(实例/版本/日志会话)。
pub fn new() -> Self {
let mut backend = Self::default();
backend.seed();
backend
}
fn seed(&mut self) {
self.events.push_back(AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
));
self.events
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
mock_versions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
mock_log_sessions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
}
fn emit(&mut self, event: AppEvent) {
self.events.push_back(event);
}
fn next_dialog_id(&mut self) -> u64 {
self.next_dialog += 1;
self.next_dialog
}
fn script_launch(&mut self, id: InstanceId) {
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id: id.clone(),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 0,
total: 1_500,
},
},
}));
self.launch = Some(LaunchScript { id, elapsed_ms: 0 });
}
fn step_launch(&mut self, elapsed: Duration) {
let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| {
script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
(script.id.clone(), script.elapsed_ms)
}) else {
return;
};
// 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中
let runtime = if elapsed_ms < 1_500 {
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: elapsed_ms,
total: 1_500,
},
}
} else if elapsed_ms < 2_500 {
InstanceRuntime::Busy {
stage: LaunchStage::Verify,
progress: Progress {
done: elapsed_ms - 1_500,
total: 1_000,
},
}
} else if elapsed_ms < 3_000 {
InstanceRuntime::Busy {
stage: LaunchStage::Launch,
progress: Progress {
done: elapsed_ms - 2_500,
total: 500,
},
}
} else {
self.launch = None;
self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: "游戏已启动".to_string(),
remaining_ms: 0,
}));
InstanceRuntime::Running { pid: 12_345 }
};
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id,
runtime,
}));
}
fn script_chat(&mut self, text: String) {
let user_index = self.emitted_messages;
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::user(text),
)));
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
self.emitted_messages += 2;
self.chat = Some(ChatScript {
elapsed_ms: 0,
chunks: VecDeque::from(vec![
"先确认版本:".to_string(),
"官方 1.20.4 是正式版,".to_string(),
"装完再用 Fabric 加加载器。".to_string(),
]),
message_index: user_index + 1,
});
}
fn step_chat(&mut self, elapsed: Duration) {
let Some(chat) = self.chat.as_mut() else {
return;
};
chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
if chat.elapsed_ms < 300 {
return;
}
chat.elapsed_ms = 0;
let index = chat.message_index;
let chunk = chat.chunks.pop_front();
if let Some(delta) = chunk {
self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta }));
return;
}
// 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息
self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "read_game_log".to_string(),
summary: "读取最近一次崩溃日志".to_string(),
}));
self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "read_game_log".to_string(),
ok: true,
summary: "读到 128 行,无 ERROR".to_string(),
}));
self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index }));
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
role: ChatRole::Assistant,
text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(),
tool: None,
streaming: false,
})));
self.emitted_messages += 2;
self.chat = None;
}
fn script_install(&mut self, version: String) {
let id = self.next_dialog_id();
self.emit(AppEvent::DialogRequested(DialogRequest {
id,
source: DialogSource::BackgroundTask,
title: format!("正在安装 {version}"),
body: String::new(),
kind: DialogKind::Progress {
label: "下载资源".to_string(),
progress: 0.0,
},
}));
self.emit(AppEvent::DialogClosed(id));
self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: format!("{version} 安装完成"),
remaining_ms: 0,
}));
}
}
impl Backend for MockBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.events.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
match command {
AppCommand::LaunchInstance(id) => self.script_launch(id),
AppCommand::StopInstance(id) => {
self.launch = None;
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id,
runtime: InstanceRuntime::Idle,
}));
}
AppCommand::AskAgent(text) => self.script_chat(text),
AppCommand::InstallVersion(version) => self.script_install(version),
AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Info,
text: if enabled {
"YOLO 已开启(审计照记)"
} else {
"YOLO 已关闭"
}
.to_string(),
remaining_ms: 0,
})),
AppCommand::ListVersions(category) => {
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
crate::mock_data::mock_versions_for(category),
)));
}
AppCommand::OpenLogSession(id) => {
// P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见)
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
self.emit(AppEvent::Core(CoreEvent::DiagnosisReady(
crate::mock_data::mock_diagnosis(id),
)));
}
AppCommand::RefreshAll | AppCommand::RefreshLogSessions | AppCommand::StopAgent => {}
// 其余指令(安装向导/账户/Java/设置/通知)由真实 host 承接:mock 不模拟
_ => {}
}
}
fn tick(&mut self, elapsed: Duration) {
self.step_launch(elapsed);
self.step_chat(elapsed);
}
}
#[cfg(test)]
mod tests {
use super::MockBackend;
use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime};
#[test]
fn seeds_instances_and_versions() {
let mut backend = MockBackend::new();
let events = backend.drain_events();
assert_eq!(events.len(), 4);
assert!(events
.iter()
.all(|event| matches!(event, AppEvent::Core(_))));
assert!(backend.drain_events().is_empty());
}
#[test]
fn launching_emits_progress_then_running() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::LaunchInstance("survival".to_string()));
let first = backend.drain_events();
assert!(matches!(
first[0],
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
));
backend.tick(std::time::Duration::from_millis(1_600));
let verify = backend.drain_events();
match &verify[0] {
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Busy { stage, .. },
..
}) => assert_eq!(*stage, crate::dto::LaunchStage::Verify),
other => panic!("unexpected event: {other:?}"),
}
backend.tick(std::time::Duration::from_millis(2_000));
let done = backend.drain_events();
assert!(done.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Running { .. },
..
})
)));
}
#[test]
fn stopping_cancels_the_launch_script() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::LaunchInstance("a".to_string()));
backend.drain_events();
backend.dispatch(AppCommand::StopInstance("a".to_string()));
let events = backend.drain_events();
assert!(matches!(
events[0],
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Idle,
..
})
));
// 脚本已取消:再推进时间也不会再产出事件
backend.tick(std::time::Duration::from_millis(5_000));
assert!(backend.drain_events().is_empty());
}
#[test]
fn asking_the_agent_streams_and_finishes() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string()));
let start = backend.drain_events();
assert_eq!(start.len(), 2);
let mut deltas = 0;
for _ in 0..6 {
backend.tick(std::time::Duration::from_millis(400));
let events = backend.drain_events();
deltas += events
.iter()
.filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. })))
.count();
}
assert_eq!(deltas, 3);
}
}