import * as fs from "fs"; import * as path from "path"; import { app } from "electron"; import log from "electron-log"; import { APP_DATA_DIR_NAME } from "../constants"; const PLAN_TEMPLATES_DIR_NAME = "plan_templates"; const SUPPORTED_EXTENSIONS = new Set([".json", ".toml"]); export interface DigitalEmployeePlanTemplateTask { task_id: string; title: string; status: string; } export interface DigitalEmployeePlanTemplateStep { step_id: string; title: string; depends_on: string[]; preferred_skills: string[]; tasks: DigitalEmployeePlanTemplateTask[]; } export interface DigitalEmployeePlanTemplateRecord { plan_id: string; title: string; objective?: string; status: string; created_at: string; updated_at: string; source: "qimingclaw-plan-template"; template_path: string; steps: DigitalEmployeePlanTemplateStep[]; } export interface DigitalEmployeePlanTemplateList { generatedAt: string; templateDir: string; templates: DigitalEmployeePlanTemplateRecord[]; } export function listDigitalEmployeePlanTemplates(): DigitalEmployeePlanTemplateList { const templateDir = path.join( app.getPath("home"), APP_DATA_DIR_NAME, PLAN_TEMPLATES_DIR_NAME, ); const templates = readTemplateFiles(templateDir) .flatMap((filePath) => parseTemplateFile(filePath)) .sort((left, right) => left.title.localeCompare(right.title, "zh-CN")); return { generatedAt: new Date().toISOString(), templateDir, templates, }; } function readTemplateFiles(templateDir: string): string[] { try { if (!fs.existsSync(templateDir)) return []; return fs.readdirSync(templateDir, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => path.join(templateDir, entry.name)) .filter((filePath) => SUPPORTED_EXTENSIONS.has(path.extname(filePath).toLowerCase())); } catch (error) { log.warn("[DigitalEmployeePlanTemplate] Failed to read template dir:", error); return []; } } function parseTemplateFile(filePath: string): DigitalEmployeePlanTemplateRecord[] { try { const content = fs.readFileSync(filePath, "utf8"); const ext = path.extname(filePath).toLowerCase(); const parsed = ext === ".json" ? JSON.parse(content) : parseSimpleToml(content); const rawTemplates = Array.isArray(parsed) ? parsed : [parsed]; return rawTemplates .map((raw, index) => normalizeTemplate(raw, filePath, index)) .filter((template): template is DigitalEmployeePlanTemplateRecord => Boolean(template)); } catch (error) { log.warn(`[DigitalEmployeePlanTemplate] Failed to parse ${filePath}:`, error); return []; } } function normalizeTemplate( raw: unknown, filePath: string, index: number, ): DigitalEmployeePlanTemplateRecord | null { const record = objectRecord(raw); if (!record) return null; const basename = path.basename(filePath, path.extname(filePath)); const planId = stringValue(record.plan_id ?? record.id ?? record.template_id) || safeId(index > 0 ? `${basename}-${index + 1}` : basename); const title = stringValue(record.title ?? record.name) || planId; const createdAt = stringValue(record.created_at ?? record.createdAt) || new Date().toISOString(); return { plan_id: planId, title, objective: stringValue(record.objective ?? record.description) || undefined, status: stringValue(record.status) || "pending", created_at: createdAt, updated_at: stringValue(record.updated_at ?? record.updatedAt) || createdAt, source: "qimingclaw-plan-template", template_path: filePath, steps: normalizeSteps(record.steps, planId, title), }; } function normalizeSteps( value: unknown, planId: string, planTitle: string, ): DigitalEmployeePlanTemplateStep[] { const steps = unknownArray(value) .map((step, index) => normalizeStep(step, planId, index)) .filter((step): step is DigitalEmployeePlanTemplateStep => Boolean(step)); if (steps.length > 0) return steps; return [ { step_id: `${planId}:default-step`, title: planTitle, depends_on: [], preferred_skills: ["qimingclaw-acp-session"], tasks: [ { task_id: `${planId}:default-task`, title: planTitle, status: "pending", }, ], }, ]; } function normalizeStep( value: unknown, planId: string, index: number, ): DigitalEmployeePlanTemplateStep | null { const record = objectRecord(value); if (!record) return null; const stepId = stringValue(record.step_id ?? record.id) || `${planId}:step-${index + 1}`; const title = stringValue(record.title ?? record.name) || `步骤 ${index + 1}`; return { step_id: stepId, title, depends_on: stringArray(record.depends_on ?? record.dependsOn), preferred_skills: stringArray(record.preferred_skills ?? record.preferredSkills ?? record.skills), tasks: normalizeTasks(record.tasks, planId, stepId, title), }; } function normalizeTasks( value: unknown, planId: string, stepId: string, stepTitle: string, ): DigitalEmployeePlanTemplateTask[] { const tasks = unknownArray(value) .map((task, index) => normalizeTask(task, planId, stepId, index)) .filter((task): task is DigitalEmployeePlanTemplateTask => Boolean(task)); if (tasks.length > 0) return tasks; return [ { task_id: `${stepId}:task-1`, title: stepTitle, status: "pending", }, ]; } function normalizeTask( value: unknown, planId: string, stepId: string, index: number, ): DigitalEmployeePlanTemplateTask | null { const record = objectRecord(value); if (!record) return null; return { task_id: stringValue(record.task_id ?? record.id) || `${stepId}:task-${index + 1}`, title: stringValue(record.title ?? record.name) || `任务 ${index + 1}`, status: stringValue(record.status) || "pending", }; } function parseSimpleToml(content: string): Record { const root: Record = {}; let current: Record = root; let currentStep: Record | null = null; for (const rawLine of content.split(/\r?\n/)) { const line = stripComment(rawLine).trim(); if (!line) continue; if (line.startsWith("[[") && line.endsWith("]]")) { const section = line.slice(2, -2).trim(); if (section === "steps") { const steps = ensureArray(root, "steps"); currentStep = {}; steps.push(currentStep); current = currentStep; } else if (section === "steps.tasks") { if (!currentStep) { const steps = ensureArray(root, "steps"); currentStep = {}; steps.push(currentStep); } const tasks = ensureArray(currentStep, "tasks"); const task: Record = {}; tasks.push(task); current = task; } continue; } if (line.startsWith("[") && line.endsWith("]")) { const key = line.slice(1, -1).trim(); const section: Record = objectRecord(root[key]) ?? {}; root[key] = section; current = section; continue; } const separator = line.indexOf("="); if (separator < 0) continue; const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); current[key] = parseTomlValue(value); } return root; } function parseTomlValue(value: string): unknown { if (value.startsWith("[") && value.endsWith("]")) { const inner = value.slice(1, -1).trim(); if (!inner) return []; return splitArray(inner).map(parseTomlValue); } if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { return value.slice(1, -1).replace(/\\"/g, '"'); } if (value === "true") return true; if (value === "false") return false; const numeric = Number(value); return Number.isFinite(numeric) ? numeric : value; } function splitArray(value: string): string[] { const items: string[] = []; let current = ""; let quote: string | null = null; for (const char of value) { if ((char === '"' || char === "'") && !quote) { quote = char; } else if (char === quote) { quote = null; } if (char === "," && !quote) { items.push(current.trim()); current = ""; } else { current += char; } } if (current.trim()) items.push(current.trim()); return items; } function stripComment(value: string): string { let quote: string | null = null; for (let index = 0; index < value.length; index += 1) { const char = value[index]; if ((char === '"' || char === "'") && !quote) quote = char; else if (char === quote) quote = null; else if (char === "#" && !quote) return value.slice(0, index); } return value; } function ensureArray(record: Record, key: string): unknown[] { const existing = record[key]; if (Array.isArray(existing)) return existing; const next: unknown[] = []; record[key] = next; return next; } function stringArray(value: unknown): string[] { return unknownArray(value) .map((item) => stringValue(item)) .filter(Boolean); } function unknownArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function stringValue(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } function objectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function safeId(value: string): string { return value .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") || "plan-template"; }