mirror of
https://github.com/yan-istart/IStart-Note-AI-Plugin.git
synced 2026-07-22 06:51:37 +00:00
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.
This commit is contained in:
parent
e262d6c0ad
commit
3cf38ba59e
3 changed files with 159 additions and 25 deletions
|
|
@ -115,13 +115,25 @@ export const ALL_ACTIONS: ActionDef[] = [
|
|||
id: "view-pending-plans",
|
||||
label: "查看待确认计划",
|
||||
icon: "clipboard-list",
|
||||
description: "打开 Knowledge/_ExecutionPlans 中最新的计划草稿",
|
||||
description: "打开最新的待确认执行计划",
|
||||
domain: "execution",
|
||||
section: "plan",
|
||||
when: { always: true },
|
||||
showIn: ["panel"],
|
||||
run: (ctx) => { ctx.plugin.openPendingPlans(); },
|
||||
},
|
||||
{
|
||||
id: "confirm-execute-plan",
|
||||
label: "确认执行此计划",
|
||||
icon: "play",
|
||||
description: "执行当前打开的待确认计划",
|
||||
domain: "execution",
|
||||
section: "plan",
|
||||
when: { fileType: ["execution-plan"] },
|
||||
showIn: ["panel", "editor-menu"],
|
||||
risk: "medium",
|
||||
run: (ctx) => { void ctx.plugin.confirmAndExecutePlan(); },
|
||||
},
|
||||
{
|
||||
id: "view-execution-logs",
|
||||
label: "查看执行日志",
|
||||
|
|
|
|||
|
|
@ -3,16 +3,26 @@ import { ExecutionPlan } from "./types";
|
|||
import { SCHEMA_VERSION, todayIso } from "../schema";
|
||||
|
||||
/**
|
||||
* PlanDraftStore — persists an ExecutionPlan as a "pending" draft note.
|
||||
* PlanDraftStore — persists an ExecutionPlan as a "pending" draft note
|
||||
* and stores the raw plan data separately for programmatic recovery.
|
||||
*
|
||||
* The draft is NOT executed; it's stored for the user to review and manually confirm.
|
||||
* This is the correct behavior for `create-plan-only` safety level.
|
||||
* 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);
|
||||
|
|
@ -26,16 +36,54 @@ export class PlanDraftStore {
|
|||
}
|
||||
|
||||
const content = this.renderDraft(plan);
|
||||
return await this.app.vault.create(path, content);
|
||||
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 type = op.type;
|
||||
const target = "path" in op ? (op as { path: string }).path : (op as { from: string }).from;
|
||||
return `${i + 1}. \`${type}\` → \`${target}\``;
|
||||
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}
|
||||
|
|
@ -50,28 +98,44 @@ created_at: ${plan.createdAt}
|
|||
# 📋 待确认计划:${plan.title}
|
||||
|
||||
> [!warning] 此计划尚未执行
|
||||
> 请审阅后手动确认执行,或删除此文件取消。
|
||||
> 请审阅后在命令面板中选择「确认执行此计划」,或删除此文件取消。
|
||||
|
||||
## 风险等级
|
||||
## 概览
|
||||
|
||||
${plan.riskLevel === "high" ? "🔴 高" : plan.riskLevel === "medium" ? "🟡 中" : "🟢 低"}
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| 来源 | ${plan.source} |
|
||||
| 风险等级 | ${riskLabel} |
|
||||
| 操作数 | ${plan.operations.length} |
|
||||
| 影响文件数 | ${affectedFiles.length} |
|
||||
| 创建时间 | ${plan.createdAt} |
|
||||
|
||||
## 操作列表
|
||||
## 将执行的操作
|
||||
|
||||
${ops}
|
||||
|
||||
## 预览
|
||||
## 影响文件
|
||||
|
||||
${fileList}
|
||||
|
||||
## 详细预览
|
||||
|
||||
${plan.previewMarkdown}
|
||||
|
||||
## Raw Plan
|
||||
|
||||
\`\`\`json
|
||||
${JSON.stringify(plan, null, 2)}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
70
src/main.ts
70
src/main.ts
|
|
@ -695,16 +695,15 @@ ${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`;
|
|||
|
||||
/** 打开待确认计划列表 */
|
||||
openPendingPlans() {
|
||||
const folder = "Knowledge/_ExecutionPlans";
|
||||
const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder));
|
||||
const { PlanDraftStore } = require("./core/execution") as { PlanDraftStore: new (app: import("obsidian").App) => import("./core/execution").PlanDraftStore };
|
||||
const store = new PlanDraftStore(this.app);
|
||||
const files = store.getPendingPlans();
|
||||
if (files.length === 0) {
|
||||
new Notice("暂无待确认计划");
|
||||
return;
|
||||
}
|
||||
// Open the folder in file explorer, or open the most recent plan
|
||||
const sorted = files.sort((a, b) => b.stat.mtime - a.stat.mtime);
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
void leaf.openFile(sorted[0]);
|
||||
void leaf.openFile(files[0]);
|
||||
if (files.length > 1) {
|
||||
new Notice(`共 ${files.length} 个待确认计划,已打开最新`);
|
||||
}
|
||||
|
|
@ -713,7 +712,7 @@ ${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`;
|
|||
/** 打开执行日志列表 */
|
||||
openExecutionLogs() {
|
||||
const folder = "Knowledge/_Executions";
|
||||
const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder));
|
||||
const files = this.app.vault.getMarkdownFiles().filter((f) => f.path.startsWith(folder + "/"));
|
||||
if (files.length === 0) {
|
||||
new Notice("暂无执行日志");
|
||||
return;
|
||||
|
|
@ -726,6 +725,65 @@ ${selection ? `用户当前选中的文字:\n${selection}\n` : ""}`;
|
|||
}
|
||||
}
|
||||
|
||||
/** 确认并执行当前打开的待确认计划 */
|
||||
async confirmAndExecutePlan() {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) { new Notice("请先打开一个待确认计划文件"); return; }
|
||||
|
||||
const meta = this.app.metadataCache.getFileCache(activeFile);
|
||||
const fm = meta?.frontmatter;
|
||||
if (fm?.type !== "execution-plan" || fm?.status !== "pending") {
|
||||
new Notice("当前文件不是待确认计划(需要 type: execution-plan, status: pending)");
|
||||
return;
|
||||
}
|
||||
|
||||
const planId = fm.plan_id as string;
|
||||
if (!planId) { new Notice("计划文件缺少 plan_id"); return; }
|
||||
|
||||
// Try to get plan from cache
|
||||
const { PlanDraftStore, PlanExecutor } = await import("./core/execution");
|
||||
const store = new PlanDraftStore(this.app);
|
||||
const plan = store.getPlan(planId);
|
||||
|
||||
if (!plan) {
|
||||
new Notice("该计划的执行数据已过期(Obsidian 重启后缓存会丢失)。请重新生成计划。");
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm
|
||||
const riskLabel = plan.riskLevel === "high" ? "高风险" : plan.riskLevel === "medium" ? "中风险" : "低风险";
|
||||
const confirmed = await this.confirmAction(
|
||||
`确认执行「${plan.title}」?\n\n${plan.operations.length} 项操作,${riskLabel},将影响 ${new Set(plan.operations.map(op => "path" in op ? (op as {path:string}).path : (op as {from:string}).from)).size} 个文件。`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const notice = new Notice("⏳ 正在执行计划...", 0);
|
||||
const executor = new PlanExecutor(this.app);
|
||||
const record = await executor.execute(plan);
|
||||
notice.hide();
|
||||
|
||||
if (record.success) {
|
||||
await store.markExecuted(activeFile);
|
||||
new Notice(`✅ 计划执行成功:${record.affectedPaths.length} 个文件已更新`);
|
||||
} else {
|
||||
new Notice(`❌ 执行失败:${record.error ?? "未知错误"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple confirmation dialog using Obsidian Modal. */
|
||||
private confirmAction(message: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const { Modal, Setting } = require("obsidian") as typeof import("obsidian");
|
||||
const modal = new Modal(this.app);
|
||||
modal.titleEl.setText("确认执行");
|
||||
modal.contentEl.createEl("p", { text: message, attr: { style: "white-space: pre-wrap;" } });
|
||||
new Setting(modal.contentEl)
|
||||
.addButton((btn) => btn.setButtonText("确认执行").setCta().onClick(() => { modal.close(); resolve(true); }))
|
||||
.addButton((btn) => btn.setButtonText("取消").onClick(() => { modal.close(); resolve(false); }));
|
||||
modal.open();
|
||||
});
|
||||
}
|
||||
|
||||
/** 查看定时任务状态 */
|
||||
openScheduledTasks() {
|
||||
new Notice("定时任务运行时在 v2.0 默认关闭,将在 v2.1 通过设置页启用。");
|
||||
|
|
|
|||
Loading…
Reference in a new issue