use crate::app;
use crate::domain::{MemoryScope, OutputFormat, RouteInput, TargetTool};
use crate::lifecycle_format;
use crate::lifecycle_service::{LifecycleAction, LifecycleService, available_actions};
use crate::lifecycle_store::{
LedgerEntry, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
lifecycle_root_from_config,
};
use eframe::egui;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_CONFIG_PATH: &str = "spool.example.toml";
const SAMPLE_TASK: &str =
"请基于当前仓库,帮我理解 wakeup packet 的生成链路,以及 GUI 覆盖入口应该优先看哪些文件。";
const SAMPLE_FILES: &str = "src/gui/app_shell.rs\nsrc/app.rs\nsrc/wakeup.rs";
pub struct GuiApp {
config_path: String,
vault_root_override: String,
cwd: String,
task: String,
files: String,
target: TargetTool,
format: OutputFormat,
context_output: String,
explain_output: String,
review_output: String,
wakeup_ready_output: String,
review_entries: Vec<LedgerEntry>,
wakeup_ready_entries: Vec<LedgerEntry>,
history_output: String,
selected_record_id: Option<String>,
draft_mode: MemoryDraftMode,
draft_title: String,
draft_summary: String,
draft_memory_type: String,
draft_scope: MemoryScope,
draft_source_ref: String,
draft_project_id: String,
draft_user_id: String,
draft_sensitivity: String,
draft_actor: String,
draft_reason: String,
draft_evidence_refs: String,
status: String,
status_detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MemoryDraftMode {
Manual,
Proposal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FailureKind {
Input,
Config,
Routing,
Scan,
Runtime,
}
struct FailurePresentation {
status: String,
detail: String,
explain: String,
}
impl Default for GuiApp {
fn default() -> Self {
Self::new("GUI 使用系统默认字体。".to_string())
}
}
impl GuiApp {
pub fn new(font_status: String) -> Self {
Self {
config_path: DEFAULT_CONFIG_PATH.to_string(),
vault_root_override: String::new(),
cwd: current_dir_display(),
task: String::new(),
files: String::new(),
target: TargetTool::Claude,
format: OutputFormat::Prompt,
context_output: String::new(),
explain_output: String::new(),
review_output: String::new(),
wakeup_ready_output: String::new(),
review_entries: Vec::new(),
wakeup_ready_entries: Vec::new(),
history_output: String::new(),
selected_record_id: None,
draft_mode: MemoryDraftMode::Manual,
draft_title: String::new(),
draft_summary: String::new(),
draft_memory_type: "preference".to_string(),
draft_scope: MemoryScope::User,
draft_source_ref: String::new(),
draft_project_id: String::new(),
draft_user_id: String::new(),
draft_sensitivity: String::new(),
draft_actor: String::new(),
draft_reason: String::new(),
draft_evidence_refs: String::new(),
status: "就绪:请填写输入后运行。".to_string(),
status_detail: format!(
"默认使用配置文件中的 vault.root;如需临时切换,可填写下方覆盖路径。{}",
font_status
),
}
}
fn apply_sample_inputs(&mut self) {
self.config_path = DEFAULT_CONFIG_PATH.to_string();
self.vault_root_override.clear();
self.cwd = current_dir_display();
self.task = SAMPLE_TASK.to_string();
self.files = SAMPLE_FILES.to_string();
self.draft_title = "简洁输出".to_string();
self.draft_summary = "用户偏好简洁直接的回复。".to_string();
self.draft_memory_type = "preference".to_string();
self.draft_scope = MemoryScope::User;
self.draft_source_ref = "manual:gui".to_string();
self.draft_user_id = "long".to_string();
self.draft_actor = "codex".to_string();
self.draft_reason = "captured from GUI example flow".to_string();
self.draft_evidence_refs = "session:example,obsidian://preferences".to_string();
self.status = "已填入示例。".to_string();
self.status_detail =
"现在可以直接点“生成上下文”,或先按你的场景微调 Task / Files。".to_string();
}
fn apply_failure(&mut self, failure: FailurePresentation) {
self.status = failure.status;
self.status_detail = failure.detail;
self.explain_output = failure.explain;
}
fn refresh_lifecycle_views(&mut self, config_path: &Path) {
match LifecycleService::new().load_workbench(config_path) {
Ok(snapshot) => {
self.review_entries = snapshot.pending_review;
self.wakeup_ready_entries = snapshot.wakeup_ready;
self.review_output =
render_lifecycle_entries("Pending review", &self.review_entries);
self.wakeup_ready_output =
render_lifecycle_entries("Wakeup-ready", &self.wakeup_ready_entries);
if self.selected_entry().is_none() {
self.selected_record_id = None;
}
self.refresh_selected_history(config_path);
}
Err(error) => {
self.review_entries.clear();
self.wakeup_ready_entries.clear();
self.history_output = format!("无法加载 history:{error}");
self.selected_record_id = None;
self.review_output = format!("无法加载 review 队列:{error}");
self.wakeup_ready_output = format!("无法加载 wakeup-ready 列表:{error}");
}
}
}
fn selected_entry(&self) -> Option<&LedgerEntry> {
let record_id = self.selected_record_id.as_deref()?;
self.review_entries
.iter()
.chain(self.wakeup_ready_entries.iter())
.find(|entry| entry.record_id == record_id)
}
fn refresh_selected_history(&mut self, config_path: &Path) {
let Some(record_id) = self.selected_record_id.clone() else {
self.history_output = "未选择记忆。".to_string();
return;
};
match LifecycleService::new().get_history(config_path, &record_id) {
Ok(entries) => {
self.history_output = render_lifecycle_history(&record_id, &entries);
}
Err(error) => {
self.history_output = format!("无法加载 history:{error}");
}
}
}
fn apply_lifecycle_action(&mut self, action: LifecycleAction) {
let Some(record_id) = self.selected_record_id.clone() else {
self.status = "未选择记忆。".to_string();
self.status_detail =
"请先在 Pending review 或 Wakeup-ready 中选择一条记忆。".to_string();
return;
};
let config_path = PathBuf::from(self.config_path.trim());
match LifecycleService::new().apply_action(config_path.as_path(), &record_id, action) {
Ok(result) => {
self.review_entries = result.snapshot.pending_review;
self.wakeup_ready_entries = result.snapshot.wakeup_ready;
self.review_output =
render_lifecycle_entries("Pending review", &self.review_entries);
self.wakeup_ready_output =
render_lifecycle_entries("Wakeup-ready", &self.wakeup_ready_entries);
if self
.review_entries
.iter()
.chain(self.wakeup_ready_entries.iter())
.any(|entry| entry.record_id == record_id)
{
self.selected_record_id = Some(record_id.clone());
} else {
self.selected_record_id = None;
}
self.refresh_selected_history(config_path.as_path());
self.status = format!("已执行 {}。", action_button_label(action));
self.status_detail = format!(
"record_id={};当前状态={:?}。",
result.entry.record_id, result.entry.record.state
);
}
Err(error) => {
self.status = format!("{} 失败。", action_button_label(action));
self.status_detail = error.to_string();
}
}
}
fn submit_memory_draft(&mut self) {
let config_path = PathBuf::from(self.config_path.trim());
match validate_memory_form(
self.draft_title.trim(),
self.draft_summary.trim(),
self.draft_memory_type.trim(),
self.draft_source_ref.trim(),
) {
Err(message) => {
self.status = "记忆写入未通过。".to_string();
self.status_detail = message;
}
Ok(()) => {
let service = LifecycleService::new();
let metadata = TransitionMetadata {
actor: optional_trimmed(&self.draft_actor),
reason: optional_trimmed(&self.draft_reason),
evidence_refs: parse_evidence_refs(&self.draft_evidence_refs),
};
let result = match self.draft_mode {
MemoryDraftMode::Manual => service.record_manual(
config_path.as_path(),
RecordMemoryRequest {
title: self.draft_title.trim().to_string(),
summary: self.draft_summary.trim().to_string(),
memory_type: self.draft_memory_type.trim().to_string(),
scope: self.draft_scope,
source_ref: self.draft_source_ref.trim().to_string(),
project_id: optional_trimmed(&self.draft_project_id),
user_id: optional_trimmed(&self.draft_user_id),
sensitivity: optional_trimmed(&self.draft_sensitivity),
metadata,
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
),
MemoryDraftMode::Proposal => service.propose_ai(
config_path.as_path(),
ProposeMemoryRequest {
title: self.draft_title.trim().to_string(),
summary: self.draft_summary.trim().to_string(),
memory_type: self.draft_memory_type.trim().to_string(),
scope: self.draft_scope,
source_ref: self.draft_source_ref.trim().to_string(),
project_id: optional_trimmed(&self.draft_project_id),
user_id: optional_trimmed(&self.draft_user_id),
sensitivity: optional_trimmed(&self.draft_sensitivity),
metadata,
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
),
};
match result {
Ok(result) => {
self.review_entries = result.snapshot.pending_review;
self.wakeup_ready_entries = result.snapshot.wakeup_ready;
self.review_output =
render_lifecycle_entries("Pending review", &self.review_entries);
self.wakeup_ready_output =
render_lifecycle_entries("Wakeup-ready", &self.wakeup_ready_entries);
self.selected_record_id = Some(result.entry.record_id.clone());
self.refresh_selected_history(config_path.as_path());
self.status = format!("已写入 {}。", draft_mode_label(self.draft_mode));
self.status_detail = format!(
"record_id={};state={:?}。",
result.entry.record_id, result.entry.record.state
);
}
Err(error) => {
self.status = format!("{} 失败。", draft_mode_label(self.draft_mode));
self.status_detail = error.to_string();
}
}
}
}
}
}
fn render_lifecycle_entries(title: &str, entries: &[LedgerEntry]) -> String {
crate::lifecycle_summary::render_queue_text(title, entries, false, false)
}
fn render_lifecycle_detail(entry: &LedgerEntry) -> String {
crate::lifecycle_summary::render_record_text(entry, false, false)
}
fn render_lifecycle_history(record_id: &str, entries: &[LedgerEntry]) -> String {
crate::lifecycle_summary::render_history_text(record_id, entries, false)
}
fn lifecycle_list_label(entry: &LedgerEntry) -> String {
lifecycle_format::render_gui_list_label(entry)
}
fn action_button_label(action: LifecycleAction) -> &'static str {
lifecycle_format::action_button_label(action)
}
fn draft_mode_label(mode: MemoryDraftMode) -> &'static str {
match mode {
MemoryDraftMode::Manual => "Manual memory",
MemoryDraftMode::Proposal => "AI proposal",
}
}
fn optional_trimmed(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
fn parse_evidence_refs(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect()
}
fn validate_memory_form(
title: &str,
summary: &str,
memory_type: &str,
source_ref: &str,
) -> Result<(), String> {
if title.is_empty() {
return Err("请先填写记忆标题。".to_string());
}
if summary.is_empty() {
return Err("请先填写记忆摘要。".to_string());
}
if memory_type.is_empty() {
return Err("请先填写 memory_type。".to_string());
}
if source_ref.is_empty() {
return Err("请先填写 source_ref。".to_string());
}
Ok(())
}
fn lifecycle_root_hint(config_path: &Path) -> String {
lifecycle_root_from_config(config_path.parent().unwrap_or_else(|| Path::new(".")))
.display()
.to_string()
}
fn update_success_status(app: &mut GuiApp, result: &crate::app::AppResult, config_path: &Path) {
let candidate_count = result.bundle.route.candidates.len();
let note_count = result.bundle.route.debug.note_count;
let scan_roots = if result.bundle.route.debug.scan_roots.is_empty() {
"vault 根目录".to_string()
} else {
result.bundle.route.debug.scan_roots.join(", ")
};
app.context_output = result.rendered.clone();
app.explain_output = result.explain.clone();
app.refresh_lifecycle_views(config_path);
app.status = format!(
"完成:format={},候选 {} 条,扫描 {} 条笔记。",
result.used_format.as_str(),
candidate_count,
note_count
);
app.status_detail = format!(
"vault root={};scan roots={};lifecycle root={}。",
result.used_vault_root.display(),
scan_roots,
lifecycle_root_hint(config_path)
);
}
fn clear_outputs(app: &mut GuiApp) {
app.context_output.clear();
app.explain_output.clear();
app.review_output.clear();
app.wakeup_ready_output.clear();
app.history_output.clear();
app.review_entries.clear();
app.wakeup_ready_entries.clear();
app.selected_record_id = None;
app.status = "已清空输出。".to_string();
app.status_detail = "输入内容会保留,方便继续调整后重新运行。".to_string();
}
fn copy_with_status(ui: &mut egui::Ui, text: &str, status: &str, detail: &str, app: &mut GuiApp) {
ui.ctx().copy_text(text.to_string());
app.status = status.to_string();
app.status_detail = detail.to_string();
}
impl eframe::App for GuiApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
ui.vertical(|ui| {
ui.horizontal_wrapped(|ui| {
ui.heading("spool");
ui.separator();
ui.label("实验 GUI");
ui.separator();
ui.strong(&self.status);
});
ui.label(&self.status_detail);
});
});
egui::SidePanel::left("controls")
.resizable(true)
.default_width(380.0)
.show(ctx, |ui| {
ui.heading("输入");
ui.group(|ui| {
ui.label("快速上手");
ui.small("1. 确认 Config 路径;2. CWD 填当前仓库;3. 输入任务;4. 需要时填写 vault root 覆盖;5. 点击“生成上下文”。");
ui.add_space(6.0);
if ui.button("填入示例").clicked() {
self.apply_sample_inputs();
}
});
ui.add_space(8.0);
ui.label("Config 路径");
ui.text_edit_singleline(&mut self.config_path);
ui.small("支持相对路径;相对路径按当前启动目录解析。默认值是仓库内的 spool.example.toml。 ");
ui.add_space(8.0);
ui.label("Obsidian vault root(可选覆盖)");
ui.text_edit_singleline(&mut self.vault_root_override);
ui.small("留空则使用配置文件里的 vault.root。填写后仅影响本次 GUI 运行,不会改写配置文件。相对路径按 config 文件所在目录解析。 ");
ui.add_space(8.0);
ui.label("CWD");
ui.text_edit_singleline(&mut self.cwd);
ui.small("用于匹配 project.repo_paths。通常填当前仓库根目录。 ");
ui.add_space(8.0);
ui.label("Task");
ui.add(
egui::TextEdit::multiline(&mut self.task)
.desired_rows(4)
.hint_text("例如:为 wakeup packet 增加 GUI 配置覆盖入口"),
);
ui.add_space(8.0);
ui.label("Files(逗号或换行分隔)");
ui.add(
egui::TextEdit::multiline(&mut self.files)
.desired_rows(4)
.hint_text("例如:src/gui/app_shell.rs, src/app.rs"),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Target");
egui::ComboBox::from_id_salt("target")
.selected_text(match self.target {
TargetTool::Claude => "Claude",
TargetTool::Codex => "Codex",
TargetTool::Opencode => "OpenCode",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.target, TargetTool::Claude, "Claude");
ui.selectable_value(&mut self.target, TargetTool::Codex, "Codex");
ui.selectable_value(&mut self.target, TargetTool::Opencode, "OpenCode");
});
});
ui.horizontal(|ui| {
ui.label("Format");
egui::ComboBox::from_id_salt("format")
.selected_text(match self.format {
OutputFormat::Prompt => "prompt",
OutputFormat::Markdown => "markdown",
OutputFormat::Json => "json",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.format, OutputFormat::Prompt, "prompt");
ui.selectable_value(&mut self.format, OutputFormat::Markdown, "markdown");
ui.selectable_value(&mut self.format, OutputFormat::Json, "json");
});
});
ui.add_space(12.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Memory write");
ui.small("用最小表单写入 manual memory 或 AI proposal。");
ui.horizontal(|ui| {
ui.label("Mode");
egui::ComboBox::from_id_salt("draft_mode")
.selected_text(draft_mode_label(self.draft_mode))
.show_ui(ui, |ui| {
ui.selectable_value(
&mut self.draft_mode,
MemoryDraftMode::Manual,
"Manual memory",
);
ui.selectable_value(
&mut self.draft_mode,
MemoryDraftMode::Proposal,
"AI proposal",
);
});
});
ui.label("Title");
ui.text_edit_singleline(&mut self.draft_title);
ui.label("Summary");
ui.add(
egui::TextEdit::multiline(&mut self.draft_summary)
.desired_rows(3)
.hint_text("例如:用户偏好简洁直接的回复"),
);
ui.label("memory_type");
ui.text_edit_singleline(&mut self.draft_memory_type);
ui.horizontal(|ui| {
ui.label("Scope");
egui::ComboBox::from_id_salt("draft_scope")
.selected_text(format!("{:?}", self.draft_scope))
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.draft_scope, MemoryScope::User, "User");
ui.selectable_value(
&mut self.draft_scope,
MemoryScope::Project,
"Project",
);
ui.selectable_value(
&mut self.draft_scope,
MemoryScope::Workspace,
"Workspace",
);
ui.selectable_value(&mut self.draft_scope, MemoryScope::Team, "Team");
ui.selectable_value(&mut self.draft_scope, MemoryScope::Agent, "Agent");
});
});
ui.label("source_ref");
ui.text_edit_singleline(&mut self.draft_source_ref);
ui.label("project_id(可选)");
ui.text_edit_singleline(&mut self.draft_project_id);
ui.label("user_id(可选)");
ui.text_edit_singleline(&mut self.draft_user_id);
ui.label("sensitivity(可选)");
ui.text_edit_singleline(&mut self.draft_sensitivity);
ui.label("actor(可选)");
ui.text_edit_singleline(&mut self.draft_actor);
ui.label("reason(可选)");
ui.text_edit_singleline(&mut self.draft_reason);
ui.label("evidence_refs(可选,逗号分隔)");
ui.text_edit_singleline(&mut self.draft_evidence_refs);
ui.add_space(10.0);
if ui.button("生成上下文").clicked() {
self.run_context();
}
if ui
.button(match self.draft_mode {
MemoryDraftMode::Manual => "写入 Manual memory",
MemoryDraftMode::Proposal => "写入 AI proposal",
})
.clicked()
{
self.submit_memory_draft();
}
if ui.button("清空输出").clicked() {
clear_outputs(self);
}
});
egui::TopBottomPanel::bottom("lifecycle_panel")
.resizable(true)
.default_height(320.0)
.show(ctx, |ui| {
ui.columns(3, |columns| {
columns[0].horizontal(|ui| {
ui.heading("Pending review");
if ui.button("复制").clicked() {
let text = self.review_output.clone();
copy_with_status(
ui,
&text,
"已复制 Pending review。",
"可用于人工审核候选记忆。",
self,
);
}
});
columns[0].label("当前仍需人工审核的候选记忆。 ");
let mut clicked_review: Option<String> = None;
egui::ScrollArea::vertical().show(&mut columns[0], |ui| {
if self.review_entries.is_empty() {
ui.label("- none");
}
for entry in &self.review_entries {
let selected = self.selected_record_id.as_deref()
== Some(entry.record_id.as_str());
if ui
.selectable_label(selected, lifecycle_list_label(entry))
.clicked()
{
clicked_review = Some(entry.record_id.clone());
}
}
});
if let Some(record_id) = clicked_review {
self.selected_record_id = Some(record_id);
let config_path = PathBuf::from(self.config_path.trim());
self.refresh_selected_history(config_path.as_path());
}
columns[1].horizontal(|ui| {
ui.heading("Wakeup-ready");
if ui.button("复制").clicked() {
let text = self.wakeup_ready_output.clone();
copy_with_status(
ui,
&text,
"已复制 Wakeup-ready。",
"可用于查看已稳定、可进入 wakeup 的记忆。",
self,
);
}
});
columns[1].label("当前已稳定、可被 wakeup 使用的记忆。 ");
let mut clicked_ready: Option<String> = None;
egui::ScrollArea::vertical().show(&mut columns[1], |ui| {
if self.wakeup_ready_entries.is_empty() {
ui.label("- none");
}
for entry in &self.wakeup_ready_entries {
let selected = self.selected_record_id.as_deref()
== Some(entry.record_id.as_str());
if ui
.selectable_label(selected, lifecycle_list_label(entry))
.clicked()
{
clicked_ready = Some(entry.record_id.clone());
}
}
});
if let Some(record_id) = clicked_ready {
self.selected_record_id = Some(record_id);
let config_path = PathBuf::from(self.config_path.trim());
self.refresh_selected_history(config_path.as_path());
}
columns[2].horizontal(|ui| {
ui.heading("Detail");
if ui.button("复制").clicked() {
let text = self
.selected_entry()
.map(render_lifecycle_detail)
.unwrap_or_else(|| "未选择记忆。".to_string());
copy_with_status(
ui,
&text,
"已复制 Detail。",
"可用于查看当前选中记忆的完整字段。",
self,
);
}
});
columns[2].label("选择一条记忆后,可查看详情并执行最小动作流。 ");
if let Some(detail) = self.selected_entry().cloned() {
columns[2].monospace(render_lifecycle_detail(&detail));
columns[2].add_space(8.0);
columns[2].horizontal_wrapped(|ui| {
for action in available_actions(&detail.record) {
if ui.button(action_button_label(*action)).clicked() {
self.apply_lifecycle_action(*action);
}
}
if available_actions(&detail.record).is_empty() {
ui.small("当前状态没有可执行动作。");
}
});
columns[2].add_space(10.0);
columns[2].separator();
columns[2].horizontal(|ui| {
ui.heading("History");
if ui.button("复制").clicked() {
let text = self.history_output.clone();
copy_with_status(
ui,
&text,
"已复制 History。",
"可用于查看该记忆的完整事件历史。",
self,
);
}
});
egui::ScrollArea::vertical().max_height(150.0).show(
&mut columns[2],
|ui| {
ui.monospace(&self.history_output);
},
);
} else {
columns[2].small("未选择记忆。");
}
});
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.columns(2, |columns| {
columns[0].horizontal(|ui| {
ui.heading("Context");
if ui.button("复制").clicked() {
ui.ctx().copy_text(self.context_output.clone());
self.status = "已复制 Context。".to_string();
self.status_detail = "可以直接粘贴到终端或对话输入框。".to_string();
}
});
columns[0].label("给模型的主输出。可直接复制到终端或对话中。 ");
columns[0].add(
egui::TextEdit::multiline(&mut self.context_output)
.desired_rows(28)
.desired_width(f32::INFINITY),
);
columns[1].horizontal(|ui| {
ui.heading("Explain");
if ui.button("复制").clicked() {
ui.ctx().copy_text(self.explain_output.clone());
self.status = "已复制 Explain。".to_string();
self.status_detail = "可用于排查路由命中原因。".to_string();
}
});
columns[1].label("路由解释与候选来源,便于排查为什么命中了这些笔记。 ");
columns[1].add(
egui::TextEdit::multiline(&mut self.explain_output)
.desired_rows(28)
.desired_width(f32::INFINITY),
);
});
});
}
}
impl GuiApp {
fn run_context(&mut self) {
let config_path = PathBuf::from(self.config_path.trim());
let cwd = PathBuf::from(self.cwd.trim());
let files = parse_files(&self.files);
let vault_root_override = self.normalized_override_path();
if let Err(message) = validate_inputs(
config_path.as_path(),
cwd.as_path(),
self.task.trim(),
vault_root_override.as_deref(),
) {
self.apply_failure(present_validation_failure(&message));
return;
}
self.status = "运行中…".to_string();
self.status_detail = "正在加载配置、匹配项目并扫描笔记。".to_string();
let input = RouteInput {
task: self.task.clone(),
cwd,
files,
target: self.target,
format: self.format,
};
match app::run_with_overrides(
config_path.as_path(),
input,
Some(self.format),
vault_root_override.as_deref(),
) {
Ok(result) => {
update_success_status(self, &result, config_path.as_path());
}
Err(error) => {
self.apply_failure(present_runtime_failure(&error.to_string()));
}
}
}
fn normalized_override_path(&self) -> Option<PathBuf> {
let trimmed = self.vault_root_override.trim();
if trimmed.is_empty() {
return None;
}
let override_path = Path::new(trimmed);
let config_path = Path::new(self.config_path.trim());
Some(
app::resolve_override_path(override_path, config_path)
.unwrap_or_else(|_| override_path.to_path_buf()),
)
}
}
fn current_dir_display() -> String {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.display()
.to_string()
}
fn parse_files(raw: &str) -> Vec<String> {
raw.split(|ch| ch == ',' || ch == '\n')
.map(str::trim)
.filter(|item| !item.is_empty())
.map(ToString::to_string)
.collect()
}
fn validate_inputs(
config_path: &Path,
cwd: &Path,
task: &str,
vault_root_override: Option<&Path>,
) -> Result<(), String> {
if config_path.as_os_str().is_empty() {
return Err("请先填写 Config 路径。".to_string());
}
if !config_path.exists() {
return Err(format!("Config 不存在:{}", config_path.display()));
}
if !config_path.is_file() {
return Err(format!("Config 不是文件:{}", config_path.display()));
}
if cwd.as_os_str().is_empty() {
return Err("请先填写 CWD。".to_string());
}
if !cwd.exists() {
return Err(format!("CWD 不存在:{}", cwd.display()));
}
if !cwd.is_dir() {
return Err(format!("CWD 不是目录:{}", cwd.display()));
}
if task.is_empty() {
return Err("请先填写 Task,至少用一句话说明你要什么上下文。".to_string());
}
if let Some(vault_root_override) = vault_root_override {
let resolved_override = app::resolve_override_path(vault_root_override, config_path)
.map_err(|error| format!("vault root 覆盖路径解析失败:{}", error))?;
match fs::metadata(&resolved_override) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => {
return Err(format!(
"vault root 覆盖路径不是目录:{}",
resolved_override.display()
));
}
Err(_) => {
return Err(format!(
"vault root 覆盖路径不存在:{}",
resolved_override.display()
));
}
}
}
Ok(())
}
fn classify_runtime_failure(message: &str) -> FailureKind {
let lowered = message.to_ascii_lowercase();
if message.contains("no project matched cwd") {
FailureKind::Routing
} else if message.contains("configured note_root")
|| message.contains("vault scan exceeded")
|| message.contains("markdown file exceeds")
|| message.contains("failed to read markdown file")
|| message.contains("failed to stat markdown file")
|| message.contains("failed to canonicalize vault root")
{
FailureKind::Scan
} else if lowered.contains("toml")
|| lowered.contains("config")
|| lowered.contains("missing field")
|| lowered.contains("unknown field")
|| lowered.contains("invalid type")
{
FailureKind::Config
} else {
FailureKind::Runtime
}
}
fn present_validation_failure(message: &str) -> FailurePresentation {
present_failure(
FailureKind::Input,
message,
"请先修正输入字段或路径,再重新运行。",
)
}
fn present_runtime_failure(message: &str) -> FailurePresentation {
let kind = classify_runtime_failure(message);
let hint = match kind {
FailureKind::Input => "请先修正输入字段或路径,再重新运行。",
FailureKind::Config => "请检查 config 路径、TOML 内容以及 vault.root / note_roots 配置。",
FailureKind::Routing => {
"请确认 CWD 位于某个 project.repo_paths 下,且该项目已配置 note_roots。"
}
FailureKind::Scan => {
"请检查 vault root、note_roots、Markdown 文件体积限制,以及相关目录是否可读。"
}
FailureKind::Runtime => "请查看下方 Explain 中的原始错误,并结合输入与配置继续排查。",
};
present_failure(kind, message, hint)
}
fn present_failure(kind: FailureKind, message: &str, hint: &str) -> FailurePresentation {
let (status, heading) = match kind {
FailureKind::Input => ("输入未通过。", "输入校验失败"),
FailureKind::Config => ("配置有误。", "配置错误"),
FailureKind::Routing => ("项目未命中。", "项目路由失败"),
FailureKind::Scan => ("笔记扫描失败。", "Vault 扫描失败"),
FailureKind::Runtime => ("运行失败。", "运行时错误"),
};
FailurePresentation {
status: status.to_string(),
detail: format!("{} 原因:{}", hint, message),
explain: format!("{}\n\n建议:{}\n\n原始错误:{}", heading, hint, message),
}
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_CONFIG_PATH, FailureKind, GuiApp, SAMPLE_FILES, action_button_label,
classify_runtime_failure, clear_outputs, parse_evidence_refs, parse_files,
present_runtime_failure, render_lifecycle_detail, render_lifecycle_history,
validate_inputs, validate_memory_form,
};
use crate::domain::{MemoryScope, MemorySourceKind};
use crate::lifecycle_service::{LifecycleAction, available_actions};
use crate::lifecycle_store::LedgerEntry;
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn parse_files_accepts_commas_and_newlines() {
assert_eq!(
parse_files("a.rs, b.rs\n c.rs\n\n,d.rs"),
vec!["a.rs", "b.rs", "c.rs", "d.rs"]
);
}
#[test]
fn validate_inputs_rejects_empty_task() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, "[vault]\nroot='/tmp'\n").unwrap();
let error = validate_inputs(config_path.as_path(), temp.path(), "", None).unwrap_err();
assert!(error.contains("请先填写 Task"));
}
#[test]
fn sample_button_fills_useful_defaults() {
let mut app = GuiApp::default();
app.apply_sample_inputs();
assert_eq!(app.config_path, DEFAULT_CONFIG_PATH);
assert!(app.vault_root_override.is_empty());
assert_eq!(app.files, SAMPLE_FILES);
assert!(!app.task.trim().is_empty());
assert_eq!(app.draft_actor, "codex");
assert!(app.draft_reason.contains("GUI example flow"));
assert_eq!(app.status, "已填入示例。");
}
#[test]
fn normalized_override_path_should_resolve_relative_to_config_dir() {
let mut app = GuiApp::default();
app.config_path = "/tmp/demo/configs/spool.toml".to_string();
app.vault_root_override = "../vault".to_string();
assert_eq!(
app.normalized_override_path(),
Some(PathBuf::from("/tmp/demo/vault"))
);
}
#[test]
fn validate_inputs_resolves_relative_override_against_config_dir() {
let temp = tempdir().unwrap();
let config_dir = temp.path().join("config");
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&config_dir).unwrap();
fs::create_dir_all(&vault_dir).unwrap();
let config_path = config_dir.join("spool.toml");
fs::write(&config_path, "[vault]\nroot='../vault'\n").unwrap();
validate_inputs(
config_path.as_path(),
temp.path(),
"resume spool",
Some(std::path::Path::new("../vault")),
)
.unwrap();
}
#[test]
fn clear_outputs_should_reset_all_panels() {
let mut app = GuiApp::default();
app.context_output = "context".to_string();
app.explain_output = "explain".to_string();
app.review_output = "review".to_string();
app.wakeup_ready_output = "ready".to_string();
app.history_output = "history".to_string();
app.selected_record_id = Some("record-1".to_string());
app.review_entries.push(sample_entry());
clear_outputs(&mut app);
assert!(app.context_output.is_empty());
assert!(app.explain_output.is_empty());
assert!(app.review_output.is_empty());
assert!(app.wakeup_ready_output.is_empty());
assert!(app.history_output.is_empty());
assert!(app.review_entries.is_empty());
assert!(app.selected_record_id.is_none());
}
#[test]
fn render_lifecycle_entries_shows_none_for_empty_list() {
let rendered = super::render_lifecycle_entries("Pending review", &[]);
assert!(rendered.contains("- none"));
}
#[test]
fn classify_runtime_failure_detects_routing_errors() {
assert_eq!(
classify_runtime_failure("no project matched cwd: /tmp/demo"),
FailureKind::Routing
);
}
#[test]
fn classify_runtime_failure_detects_scan_errors() {
assert_eq!(
classify_runtime_failure("configured note_root does not exist: /tmp/vault/10-Projects"),
FailureKind::Scan
);
}
#[test]
fn runtime_failure_presentation_includes_hint_and_raw_error() {
let failure = present_runtime_failure("no project matched cwd: /tmp/demo");
assert_eq!(failure.status, "项目未命中。");
assert!(failure.detail.contains("project.repo_paths"));
assert!(
failure
.explain
.contains("原始错误:no project matched cwd: /tmp/demo")
);
}
#[test]
fn selected_record_actions_follow_current_state() {
let entry = sample_entry();
assert_eq!(
available_actions(&entry.record),
&[LifecycleAction::Accept, LifecycleAction::Archive]
);
assert_eq!(
action_button_label(LifecycleAction::PromoteToCanonical),
"Promote"
);
}
#[test]
fn render_lifecycle_detail_includes_record_id_and_summary() {
let rendered = render_lifecycle_detail(&sample_entry());
assert!(rendered.contains("record_id: record-1"));
assert!(rendered.contains("summary: 先 smoke 再收口"));
assert!(rendered.contains("source_kind: AiProposal"));
}
#[test]
fn render_lifecycle_history_includes_event_count() {
let rendered = render_lifecycle_history("record-1", &[sample_entry()]);
assert!(rendered.contains("events: 1"));
assert!(rendered.contains("record_id: record-1"));
}
#[test]
fn validate_memory_form_rejects_missing_title() {
let error = validate_memory_form("", "summary", "preference", "manual:gui").unwrap_err();
assert!(error.contains("记忆标题"));
}
#[test]
fn parse_evidence_refs_should_split_and_trim() {
assert_eq!(
parse_evidence_refs("session:1, obsidian://preferences ,, session:2"),
vec!["session:1", "obsidian://preferences", "session:2"]
);
}
fn sample_entry() -> LedgerEntry {
LedgerEntry {
schema_version: "memory-ledger.v1".to_string(),
recorded_at: "unix:1".to_string(),
record_id: "record-1".to_string(),
scope_key: "long".to_string(),
action: crate::domain::MemoryLedgerAction::ProposeAi,
source_kind: MemorySourceKind::AiProposal,
metadata: crate::lifecycle_store::TransitionMetadata::default(),
record: crate::domain::MemoryRecord::new_ai_proposal(
"测试偏好",
"先 smoke 再收口",
"workflow",
MemoryScope::User,
"session:1",
)
.with_user_id("long"),
}
}
}