yan-istart_IStart-Note-AI-P.../src/core/execution/PlanDraftStore.ts
Yan 3cf38ba59e fix(execution): plan drafts are now human-readable, confirm-execute flow added
Before:
  - Plan draft notes contained raw JSON at the bottom
  - No way to execute a pending plan from the UI
  - User saw technical payload with no clear next step

After:
  - Plan drafts are clean markdown: summary table + operation list + affected
    files + detailed preview. No JSON visible.
  - Raw plan data cached in PlanDraftStore memory (not in note)
  - New action: '确认执行此计划' (icon: play)
    - Only visible when current file has type: execution-plan
    - Shows confirmation dialog with risk level and file count
    - Executes via PlanExecutor, marks draft as 'executed'
    - On failure: shows error, draft stays pending
  - PlanDraftStore.getPendingPlans() for listing
  - PlanDraftStore.markExecuted() updates note status

Execution flow now:
  1. Generate plan → save as readable draft note
  2. User opens and reviews the plan
  3. User triggers '确认执行此计划' from command panel or right-click
  4. Confirmation dialog → execute → log written → draft marked done

Limitation: plan cache is in-memory only. If Obsidian restarts between
plan creation and confirmation, user must regenerate. This is acceptable
for v2.0; persistent plan storage comes in v2.1.
2026-05-20 22:15:33 +08:00

144 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, TFile, normalizePath } from "obsidian";
import { ExecutionPlan } from "./types";
import { SCHEMA_VERSION, todayIso } from "../schema";
/**
* PlanDraftStore — persists an ExecutionPlan as a "pending" draft note
* and stores the raw plan data separately for programmatic recovery.
*
* The draft note is human-readable. The JSON is stored in plugin data
* (not in the note) so users don't see raw technical payload.
*
* Execution flow:
* 1. persistDraft(plan) → saves note + stores plan in memory/plugin data
* 2. User reviews the note
* 3. User triggers "确认执行此计划" → loadPlan(planId) → PlanExecutor.execute()
* 4. Draft note status updated to "executed" or deleted
*/
export class PlanDraftStore {
private folder = "Knowledge/_ExecutionPlans";
/** In-memory plan cache, keyed by plan_id. */
private planCache: Map<string, ExecutionPlan> = new Map();
constructor(private app: App) {}
/** Save a plan draft note and cache the raw plan for later execution. */
async persistDraft(plan: ExecutionPlan): Promise<TFile> {
const folder = normalizePath(this.folder);
await this.ensureFolder(folder);
const safeName = plan.title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40);
let path = normalizePath(`${folder}/${todayIso()}-${safeName}.md`);
let suffix = 2;
while (this.app.vault.getAbstractFileByPath(path)) {
path = normalizePath(`${folder}/${todayIso()}-${safeName}-${suffix}.md`);
suffix++;
}
const content = this.renderDraft(plan);
const file = await this.app.vault.create(path, content);
// Cache plan for later execution
this.planCache.set(plan.id, plan);
return file;
}
/** Retrieve a cached plan by ID (for "confirm and execute" flow). */
getPlan(planId: string): ExecutionPlan | undefined {
return this.planCache.get(planId);
}
/** Mark a draft as executed by updating its frontmatter status. */
async markExecuted(file: TFile): Promise<void> {
let content = await this.app.vault.read(file);
content = content.replace("status: pending", "status: executed");
content = content.replace(
"> [!warning] 此计划尚未执行\n> 请审阅后在命令面板中选择「确认执行此计划」,或删除此文件取消。",
"> [!success] 此计划已执行\n> 执行记录已保存到 Knowledge/_Executions/"
);
await this.app.vault.modify(file, content);
}
/** List all pending plan files. */
getPendingPlans(): TFile[] {
const folder = normalizePath(this.folder);
return this.app.vault.getMarkdownFiles()
.filter((f) => f.path.startsWith(folder + "/"))
.sort((a, b) => b.stat.mtime - a.stat.mtime);
}
private renderDraft(plan: ExecutionPlan): string {
const riskLabel = plan.riskLevel === "high" ? "🔴 高风险"
: plan.riskLevel === "medium" ? "🟡 中风险"
: "🟢 低风险";
const ops = plan.operations.map((op, i) => {
const desc = this.describeOp(op);
return `${i + 1}. ${desc}`;
}).join("\n");
const affectedFiles = [...new Set(plan.operations.map((op) => {
return "path" in op ? (op as { path: string }).path : (op as { from: string }).from;
}))];
const fileList = affectedFiles.map((f) => `- \`${f}\``).join("\n");
return `---
type: execution-plan
schema_version: ${SCHEMA_VERSION}
status: pending
plan_id: ${plan.id}
source: ${plan.source}
risk_level: ${plan.riskLevel}
operations_count: ${plan.operations.length}
created_at: ${plan.createdAt}
---
# 📋 待确认计划:${plan.title}
> [!warning] 此计划尚未执行
> 请审阅后在命令面板中选择「确认执行此计划」,或删除此文件取消。
## 概览
| 项目 | 值 |
| --- | --- |
| 来源 | ${plan.source} |
| 风险等级 | ${riskLabel} |
| 操作数 | ${plan.operations.length} |
| 影响文件数 | ${affectedFiles.length} |
| 创建时间 | ${plan.createdAt} |
## 将执行的操作
${ops}
## 影响文件
${fileList}
## 详细预览
${plan.previewMarkdown}
`;
}
private describeOp(op: ExecutionPlan["operations"][number]): string {
switch (op.type) {
case "create-file": return `创建文件 \`${op.path}\``;
case "modify-file": return `修改文件 \`${op.path}\`${op.description ? `${op.description}` : ""}`;
case "append-section": return `追加到 \`${op.path}\` 的 §${op.section}`;
case "replace-selection": return `替换 \`${op.path}\` 中的文本`;
case "move-file": return `移动 \`${op.from}\`\`${op.to}\``;
case "create-link": return `\`${op.path}\` 中添加链接 → \`${op.target}\``;
case "update-frontmatter": return `更新 \`${op.path}\` 的 frontmatter${Object.keys(op.fields).join(", ")}`;
}
}
private async ensureFolder(path: string): Promise<void> {
if (!this.app.vault.getAbstractFileByPath(path)) {
await this.app.vault.createFolder(path);
}
}
}