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

+394
View File
@@ -0,0 +1,394 @@
//! UI 状态与 reducer:UI 框架只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。
//!
//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → UI 属性。
//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。
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,
};
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> {
/// 实例数据
pub instance: &'a InstanceSummary,
/// 网格行号(0 起)
pub row: usize,
/// 网格列号(0 起)
pub col: usize,
}
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
///
/// 特意放在 Rust 侧:声明式 UI 的表达式里做整数除法容易引出隐式浮点,且这段逻辑可以单测。
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
let columns = columns.max(1);
(0..count)
.map(|index| (index / columns, index % columns))
.collect()
}
/// 实时日志最多保留的行数(滚动窗口;更多行由日志页的筛选/历史会话承担)。
pub const MAX_LIVE_LINES: usize = 2_000;
/// 应用全局状态。
#[derive(Debug)]
pub struct AppState {
/// UI 偏好(记忆项)
pub prefs: UiPrefs,
/// 导航栈
pub router: Router,
/// 实例列表快照
pub instances: Vec<InstanceSummary>,
/// 当前分类的版本列表
pub versions: Vec<VersionSummary>,
/// 日志会话列表
pub log_sessions: Vec<LogSession>,
/// 当前会话日志行
pub log_lines: Vec<LogLine>,
/// 最近一次诊断结论
pub diagnosis: Option<DiagnosisReport>,
/// 选中的日志会话
pub selected_session: Option<String>,
/// AI 会话状态
pub ai: AiSession,
/// 对话框队列(一次一条)
pub dialogs: DialogQueue,
/// Toast 中心
pub toasts: ToastCenter,
/// 实例页搜索词
pub instance_query: String,
/// 下载页「仅已装」过滤
pub installed_only: bool,
/// 下载页版本号筛选词
pub download_query: String,
/// 账户列表(三轨统一视图;凭据不在其中)
pub accounts: Vec<AccountView>,
/// 微软登录状态
pub device_login: DeviceLoginView,
/// Java 页快照(发行版 / 回退链 / 运行时)
pub java: JavaPageView,
/// 安装进度(None = 没有安装任务)
pub install: Option<InstallProgressView>,
/// 设置分组(设置页右栏)
pub settings_groups: Vec<SettingGroupView>,
/// 当前选中的设置分组(download / java / data / ai / mcp)
pub settings_tab: String,
/// 横幅通知
pub notices: Vec<NoticeView>,
/// 新建实例向导
pub wizard: WizardState,
/// 正在实时尾随的会话
pub live_session: Option<String>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
/// 空状态(数据由后端事件填充,P0 是 mock)。
pub fn new() -> Self {
Self {
prefs: UiPrefs::default(),
router: Router::new(),
instances: Vec::new(),
versions: Vec::new(),
log_sessions: Vec::new(),
log_lines: Vec::new(),
diagnosis: None,
selected_session: None,
ai: AiSession::new(),
dialogs: DialogQueue::new(),
toasts: ToastCenter::new(),
instance_query: String::new(),
installed_only: false,
download_query: String::new(),
accounts: Vec::new(),
device_login: DeviceLoginView::new(),
java: JavaPageView::new(),
install: None,
settings_groups: Vec::new(),
settings_tab: "download".to_string(),
notices: Vec::new(),
wizard: WizardState::new(),
live_session: None,
}
}
/// 当前页面。
pub fn route(&self) -> &Route {
self.router.route()
}
/// 跳转(一级页替换、子页面压栈;去 AI 独立页时自动展开 AI 栏偏好)。
pub fn navigate(&mut self, route: Route) {
if route == Route::Agent {
self.prefs.ai_collapsed = false;
}
self.router.navigate(route);
}
/// 回退一层。
pub fn back(&mut self) -> bool {
self.router.back()
}
/// AI 栏当前形态(跟着路由走)。
pub fn ai_mode(&self) -> AiPanelMode {
self.router.ai_mode()
}
/// 应用一条事件(唯一状态变更入口)。
pub fn apply(&mut self, event: AppEvent) {
match event {
AppEvent::Core(core) => self.apply_core(core),
AppEvent::Agent(agent) => self.apply_agent(agent),
AppEvent::DialogRequested(request) => {
self.dialogs.push_request(request);
}
AppEvent::DialogClosed(id) => {
self.dialogs.close(id);
}
AppEvent::Toast(toast) => {
self.toasts.push_request(toast);
}
}
}
/// core 事件。
pub fn apply_core(&mut self, event: CoreEvent) {
match event {
CoreEvent::InstancesChanged(instances) => self.instances = instances,
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
instance.runtime = runtime;
}
}
CoreEvent::VersionListChanged(versions) => self.versions = versions,
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
CoreEvent::LogLinesChanged(lines) => {
self.log_lines = lines;
self.live_session = None;
}
CoreEvent::LogAppended(batch) => self.append_live_lines(&batch),
CoreEvent::LaunchFinished(finished) => {
self.live_session = None;
self.selected_session = Some(finished.session.clone());
let kind = if finished.outcome == crate::dto::LogOutcome::Ok {
ToastKind::Success
} else {
ToastKind::Error
};
self.toasts.push(kind, finished.summary);
}
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
CoreEvent::AccountsChanged(accounts) => self.accounts = accounts,
CoreEvent::DeviceLoginChanged(view) => {
if view.phase == crate::dto::DeviceLoginPhase::Succeeded {
self.toasts
.push(ToastKind::Success, format!("已登录:{}", view.account_name));
}
if view.phase == crate::dto::DeviceLoginPhase::Failed {
self.toasts.push(ToastKind::Error, view.message.clone());
}
self.device_login = view;
}
CoreEvent::JavaChanged(page) => self.java = page,
CoreEvent::InstallProgress(progress) => match progress {
Some(progress) => {
let terminal = progress.stage.is_terminal();
self.install = Some(progress);
if terminal {
self.wizard.open = false;
self.wizard.submitting = false;
}
}
None => self.install = None,
},
CoreEvent::SettingsChanged(groups) => self.settings_groups = groups,
CoreEvent::NoticesChanged(notices) => self.notices = notices,
}
}
/// agent 事件(AI 会话的流式消息与工具调用)。
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;
}
}
}
}
}
/// 实时日志增量:只追加,封顶 [`MAX_LIVE_LINES`] 行(滚动窗口)。
fn append_live_lines(&mut self, batch: &crate::dto::LiveLogBatch) {
if self
.live_session
.as_deref()
.is_some_and(|session| session != batch.session)
{
// 换了一个会话在跑:清掉上一场的行,避免两场日志混在一起
self.log_lines.clear();
}
self.live_session = batch.running.then(|| batch.session.clone());
self.selected_session = Some(batch.session.clone());
self.log_lines.extend(batch.lines.iter().cloned());
if self.log_lines.len() > MAX_LIVE_LINES {
let drop = self.log_lines.len() - MAX_LIVE_LINES;
self.log_lines.drain(..drop);
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
let filtered: Vec<&InstanceSummary> = self
.instances
.iter()
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
.collect();
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
filtered
.into_iter()
.zip(positions)
.map(|(instance, (row, col))| TileCell { instance, row, col })
.collect()
}
/// 运行中的实例(实例页顶部迷你状态条)。
pub fn running(&self) -> Vec<&InstanceSummary> {
self.instances
.iter()
.filter(|instance| {
matches!(
instance.runtime,
InstanceRuntime::Running { .. } | InstanceRuntime::Busy { .. }
)
})
.collect()
}
/// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。
pub fn set_progress(&mut self, id: &str, progress: Progress) {
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
if let InstanceRuntime::Busy {
progress: current, ..
} = &mut instance.runtime
{
*current = progress;
}
}
}
/// 折叠/展开导航栏。
pub fn toggle_nav(&mut self) {
self.prefs.nav_collapsed = !self.prefs.nav_collapsed;
}
/// 折叠/展开 AI 栏;若正处在独立页形态则先收回侧栏。
pub fn toggle_ai(&mut self) {
if self.route() == &Route::Agent {
self.router.back();
self.prefs.ai_collapsed = false;
return;
}
self.prefs.ai_collapsed = !self.prefs.ai_collapsed;
}
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod tests;