Skip to main content

spool/desktop/
service.rs

1//! `DesktopService` 工作流入口。桌面 / Tauri / 桌面 smoke 统一走这条路径。
2
3use super::dto::{
4    DesktopContextResponse, DesktopHistoryResponse, DesktopImportSessionRequest,
5    DesktopImportSessionResponse, DesktopLifecycleActionDto, DesktopMemoryActionRequest,
6    DesktopMemoryDraftRequest, DesktopPromptOptimizeRequest, DesktopPromptOptimizeResponse,
7    DesktopRecordLookupRequest, DesktopRecordResponse, DesktopRouteRequest,
8    DesktopSessionActionRequest, DesktopSessionActionResponse, DesktopSessionBrowserRequest,
9    DesktopSessionBrowserResponse, DesktopSessionDetailRequest, DesktopSessionDetailResponse,
10    DesktopStatusRequest, DesktopWakeupRequest, DesktopWakeupResponse, DesktopWikiIndexRequest,
11    DesktopWikiIndexResponse, DesktopWikiLintRequest, DesktopWikiLintResponse,
12    DesktopWorkbenchRequest, DesktopWorkbenchResponse, DesktopWriteResponse,
13};
14use super::errors::{DesktopResult, input_error, runtime_error};
15use super::helpers::{
16    build_continue_command, delete_session_file, launch_terminal_command, lifecycle_read_options,
17    load_session_index, load_session_index_filtered, store_from_config_path,
18};
19use super::validate::{
20    validate_config_path, validate_memory_action_request, validate_memory_draft_request,
21    validate_record_lookup_request, validate_route_inputs, validate_route_request,
22    validate_session_action_request, validate_session_detail_request,
23};
24use crate::app;
25use crate::daemon::{read_history, read_record, read_workbench};
26use crate::desktop_status::{DesktopStatusResponse, collect_status};
27use crate::lifecycle_service::{LifecycleService, available_actions};
28use crate::lifecycle_store::latest_state_entries;
29use crate::lifecycle_summary;
30use crate::memory_gateway;
31use crate::memory_importer;
32use crate::session_sources::{
33    ProviderSessionMessages, entry_session_refs, load_provider_messages, load_provider_sessions,
34    raw_session_id,
35};
36
37#[derive(Debug, Default, Clone, Copy)]
38pub struct DesktopService;
39
40impl DesktopService {
41    pub fn new() -> Self {
42        Self
43    }
44
45    pub fn run_context(
46        self,
47        request: DesktopRouteRequest,
48    ) -> DesktopResult<DesktopContextResponse> {
49        validate_route_request(&request).map_err(input_error)?;
50        let config_path = request.config_path.clone();
51        let result = app::run_with_overrides(
52            config_path.as_path(),
53            request.to_route_input(),
54            Some(request.format),
55            request.vault_root_override.as_deref(),
56        )
57        .map_err(runtime_error)?;
58        Ok(DesktopContextResponse {
59            rendered: result.rendered,
60            explain: result.explain,
61            used_format: result.used_format,
62            used_vault_root: result.used_vault_root,
63            bundle: result.bundle,
64        })
65    }
66
67    pub fn build_wakeup(
68        self,
69        request: DesktopWakeupRequest,
70    ) -> DesktopResult<DesktopWakeupResponse> {
71        validate_route_inputs(
72            request.config_path.as_path(),
73            request.cwd.as_path(),
74            &request.task,
75            request.vault_root_override.as_deref(),
76        )
77        .map_err(input_error)?;
78        let gateway_request =
79            memory_gateway::wakeup_request(request.to_route_input(), request.profile);
80        let response = memory_gateway::execute(
81            request.config_path.as_path(),
82            gateway_request,
83            request.vault_root_override.as_deref(),
84        )
85        .map_err(runtime_error)?;
86        let packet = response.wakeup_packet.ok_or_else(|| {
87            runtime_error(anyhow::anyhow!(
88                "wakeup packet missing from gateway response"
89            ))
90        })?;
91        Ok(DesktopWakeupResponse {
92            used_vault_root: response.used_vault_root,
93            bundle: response.bundle,
94            packet,
95        })
96    }
97
98    pub fn optimize_prompt(
99        self,
100        request: DesktopPromptOptimizeRequest,
101    ) -> DesktopResult<DesktopPromptOptimizeResponse> {
102        validate_route_inputs(
103            request.config_path.as_path(),
104            request.cwd.as_path(),
105            &request.task,
106            request.vault_root_override.as_deref(),
107        )
108        .map_err(input_error)?;
109        memory_gateway::execute_prompt_optimize(
110            request.config_path.as_path(),
111            memory_gateway::prompt_optimize_request(
112                request.to_route_input(),
113                request.profile,
114                request.provider,
115                request.session_id,
116                false,
117            ),
118            request.vault_root_override.as_deref(),
119        )
120        .map_err(runtime_error)
121    }
122
123    pub fn load_workbench(
124        self,
125        request: DesktopWorkbenchRequest,
126    ) -> DesktopResult<DesktopWorkbenchResponse> {
127        validate_config_path(request.config_path.as_path()).map_err(input_error)?;
128        let snapshot = read_workbench(
129            request.config_path.as_path(),
130            &lifecycle_read_options(request.daemon.as_ref()),
131        )
132        .map_err(runtime_error)?;
133        Ok(DesktopWorkbenchResponse {
134            payload: serde_json::json!({
135                "pending_review": lifecycle_summary::queue_payload(&snapshot.pending_review, "pending_review"),
136                "wakeup_ready": lifecycle_summary::queue_payload(&snapshot.wakeup_ready, "wakeup_ready"),
137            }),
138            snapshot,
139        })
140    }
141
142    pub fn get_record(
143        self,
144        request: DesktopRecordLookupRequest,
145    ) -> DesktopResult<Option<DesktopRecordResponse>> {
146        validate_record_lookup_request(&request).map_err(input_error)?;
147        let record = read_record(
148            request.config_path.as_path(),
149            &request.record_id,
150            &lifecycle_read_options(request.daemon.as_ref()),
151        )
152        .map_err(runtime_error)?;
153        Ok(record.map(|record| DesktopRecordResponse {
154            payload: lifecycle_summary::record_payload(&record),
155            rendered: lifecycle_summary::render_record_text(&record, false, true),
156            available_actions: available_actions(&record.record)
157                .iter()
158                .copied()
159                .map(DesktopLifecycleActionDto::from_lifecycle_action)
160                .collect(),
161            record,
162        }))
163    }
164
165    pub fn get_history(
166        self,
167        request: DesktopRecordLookupRequest,
168    ) -> DesktopResult<DesktopHistoryResponse> {
169        validate_record_lookup_request(&request).map_err(input_error)?;
170        let history = read_history(
171            request.config_path.as_path(),
172            &request.record_id,
173            &lifecycle_read_options(request.daemon.as_ref()),
174        )
175        .map_err(runtime_error)?;
176        Ok(DesktopHistoryResponse {
177            payload: lifecycle_summary::history_payload(&request.record_id, &history),
178            rendered: lifecycle_summary::render_history_text(&request.record_id, &history, false),
179            record_id: request.record_id,
180            history,
181        })
182    }
183
184    pub fn record_manual(
185        self,
186        request: DesktopMemoryDraftRequest,
187    ) -> DesktopResult<DesktopWriteResponse> {
188        validate_memory_draft_request(&request).map_err(input_error)?;
189        let service = LifecycleService::new();
190        let result = service
191            .record_manual(request.config_path.as_path(), request.to_record_request())
192            .map_err(runtime_error)?;
193        crate::vault_writer::writeback_from_config(request.config_path.as_path(), &result.entry);
194        Ok(DesktopWriteResponse {
195            payload: lifecycle_summary::create_payload(
196                "record_manual",
197                &result.entry,
198                &result.snapshot,
199            ),
200            entry: result.entry,
201        })
202    }
203
204    pub fn propose_memory(
205        self,
206        request: DesktopMemoryDraftRequest,
207    ) -> DesktopResult<DesktopWriteResponse> {
208        validate_memory_draft_request(&request).map_err(input_error)?;
209        let service = LifecycleService::new();
210        let result = service
211            .propose_ai(request.config_path.as_path(), request.to_propose_request())
212            .map_err(runtime_error)?;
213        crate::vault_writer::writeback_from_config(request.config_path.as_path(), &result.entry);
214        Ok(DesktopWriteResponse {
215            payload: lifecycle_summary::create_payload("propose", &result.entry, &result.snapshot),
216            entry: result.entry,
217        })
218    }
219
220    pub fn apply_memory_action(
221        self,
222        request: DesktopMemoryActionRequest,
223    ) -> DesktopResult<DesktopWriteResponse> {
224        validate_memory_action_request(&request).map_err(input_error)?;
225        let service = LifecycleService::new();
226        let action = request.action.into_lifecycle_action();
227        let result = service
228            .apply_action_with_metadata(
229                request.config_path.as_path(),
230                &request.record_id,
231                action,
232                request.metadata.into_transition_metadata(),
233            )
234            .map_err(runtime_error)?;
235        crate::vault_writer::writeback_from_config(request.config_path.as_path(), &result.entry);
236        Ok(DesktopWriteResponse {
237            payload: lifecycle_summary::action_payload(&result.entry, &result.snapshot, action),
238            entry: result.entry,
239        })
240    }
241
242    pub fn browse_sessions(
243        self,
244        request: DesktopSessionBrowserRequest,
245    ) -> DesktopResult<DesktopSessionBrowserResponse> {
246        validate_config_path(request.config_path.as_path()).map_err(input_error)?;
247        let sessions =
248            load_session_index_filtered(request.config_path.as_path(), request.provider.as_deref())
249                .map_err(runtime_error)?;
250        let page = request.page.max(1);
251        let per_page = request.per_page.max(1);
252        let total = sessions.len();
253        let start = (page - 1) * per_page;
254        let paged = sessions
255            .into_iter()
256            .skip(start)
257            .take(per_page)
258            .collect::<Vec<_>>();
259        let has_more = start + paged.len() < total;
260
261        Ok(DesktopSessionBrowserResponse {
262            sessions: paged,
263            page,
264            per_page,
265            total,
266            has_more,
267        })
268    }
269
270    pub fn get_session(
271        self,
272        request: DesktopSessionDetailRequest,
273    ) -> DesktopResult<Option<DesktopSessionDetailResponse>> {
274        validate_session_detail_request(&request).map_err(input_error)?;
275        let entries = latest_state_entries(&store_from_config_path(request.config_path.as_path()))
276            .map_err(runtime_error)?;
277        let sessions = load_session_index(request.config_path.as_path()).map_err(runtime_error)?;
278        let session = sessions
279            .iter()
280            .find(|item| item.session_id == request.session_id)
281            .cloned();
282        let Some(session) = session else {
283            return Ok(None);
284        };
285
286        let mut records: Vec<_> = entries
287            .into_iter()
288            .filter(|entry| {
289                entry_session_refs(entry).contains(&raw_session_id(&request.session_id))
290            })
291            .collect();
292        records.sort_by(|left, right| right.recorded_at.cmp(&left.recorded_at));
293
294        let offset = request.message_offset.unwrap_or(0);
295        let limit = request.message_limit.unwrap_or(0);
296        let ProviderSessionMessages {
297            messages,
298            total_messages,
299            has_more_messages,
300        } = load_provider_messages(&session, offset, limit).map_err(runtime_error)?;
301        let latest_user_message = messages
302            .iter()
303            .rev()
304            .find(|message| message.role == "user")
305            .map(|message| message.content.clone());
306
307        Ok(Some(DesktopSessionDetailResponse {
308            session,
309            records,
310            total_messages,
311            showing_recent_messages: messages.len(),
312            has_more_messages,
313            messages,
314            latest_user_message,
315        }))
316    }
317
318    pub fn continue_session(
319        self,
320        request: DesktopSessionActionRequest,
321    ) -> DesktopResult<DesktopSessionActionResponse> {
322        validate_session_action_request(&request).map_err(input_error)?;
323        let sessions = load_session_index(request.config_path.as_path()).map_err(runtime_error)?;
324        let session = sessions
325            .into_iter()
326            .find(|item| item.session_id == request.session_id)
327            .ok_or_else(|| input_error(format!("未找到会话:{}", request.session_id)))?;
328        let command = build_continue_command(&session).ok_or_else(|| {
329            input_error(format!(
330                "当前 provider 暂不支持继续会话:{}",
331                session.provider
332            ))
333        })?;
334
335        launch_terminal_command(&command)
336            .map_err(|error| runtime_error(anyhow::anyhow!("继续对话失败:{error}")))?;
337
338        Ok(DesktopSessionActionResponse {
339            session_id: session.session_id,
340            provider: session.provider,
341            command: Some(command),
342            message: "已在终端中打开继续对话命令。".to_string(),
343        })
344    }
345
346    pub fn delete_session(
347        self,
348        request: DesktopSessionActionRequest,
349    ) -> DesktopResult<DesktopSessionActionResponse> {
350        validate_session_action_request(&request).map_err(input_error)?;
351        let sessions = load_session_index(request.config_path.as_path()).map_err(runtime_error)?;
352        let session = sessions
353            .into_iter()
354            .find(|item| item.session_id == request.session_id)
355            .ok_or_else(|| input_error(format!("未找到会话:{}", request.session_id)))?;
356        delete_session_file(&session)
357            .map_err(|error| runtime_error(anyhow::anyhow!("删除会话失败:{error}")))?;
358
359        Ok(DesktopSessionActionResponse {
360            session_id: session.session_id,
361            provider: session.provider,
362            command: None,
363            message: "已删除本地会话文件。".to_string(),
364        })
365    }
366
367    pub fn collect_status(
368        self,
369        request: DesktopStatusRequest,
370    ) -> DesktopResult<DesktopStatusResponse> {
371        validate_config_path(request.config_path.as_path()).map_err(input_error)?;
372        let provider_sessions = load_provider_sessions(None).map_err(runtime_error)?;
373        Ok(collect_status(
374            request.config_path.as_path(),
375            request.cwd.as_path(),
376            request.vault_root_override.as_deref(),
377            provider_sessions.len(),
378        ))
379    }
380
381    pub fn import_session(
382        self,
383        request: DesktopImportSessionRequest,
384    ) -> DesktopResult<DesktopImportSessionResponse> {
385        validate_config_path(request.config_path.as_path()).map_err(input_error)?;
386        if request.session_id.trim().is_empty() {
387            return Err(input_error("session_id 不能为空".to_string()));
388        }
389        let provider = memory_importer::ImportProvider::parse(request.provider.as_str())
390            .map_err(|err| input_error(err.to_string()))?;
391        let response = memory_importer::import_session(
392            request.config_path.as_path(),
393            provider,
394            &request.session_id,
395            request.apply,
396            request.actor.clone(),
397        )
398        .map_err(runtime_error)?;
399        Ok(response)
400    }
401
402    pub fn wiki_lint(
403        self,
404        request: DesktopWikiLintRequest,
405    ) -> DesktopResult<DesktopWikiLintResponse> {
406        validate_config_path(request.config_path.as_path()).map_err(input_error)?;
407        let vault_root = resolve_vault_root(request.config_path.as_path())?;
408        let report = crate::wiki_lint::run_lint_from_config(request.config_path.as_path())
409            .map_err(runtime_error)?;
410        let markdown = crate::wiki_lint::render_lint_markdown(&report);
411        Ok(DesktopWikiLintResponse {
412            used_vault_root: vault_root,
413            report,
414            markdown,
415        })
416    }
417
418    pub fn read_wiki_index(
419        self,
420        request: DesktopWikiIndexRequest,
421    ) -> DesktopResult<DesktopWikiIndexResponse> {
422        validate_config_path(request.config_path.as_path()).map_err(input_error)?;
423        let vault_root = resolve_vault_root(request.config_path.as_path())?;
424        let markdown =
425            crate::wiki_index::load_index_section(&vault_root, request.project_id.as_deref());
426        Ok(DesktopWikiIndexResponse {
427            used_vault_root: vault_root,
428            markdown,
429        })
430    }
431}
432
433fn resolve_vault_root(config_path: &std::path::Path) -> DesktopResult<std::path::PathBuf> {
434    let config = app::load(config_path).map_err(runtime_error)?;
435    app::resolve_override_path(&config.vault.root, config_path).map_err(runtime_error)
436}