319 lines
9.5 KiB
TypeScript
319 lines
9.5 KiB
TypeScript
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<string, unknown> {
|
|
const root: Record<string, unknown> = {};
|
|
let current: Record<string, unknown> = root;
|
|
let currentStep: Record<string, unknown> | 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<string, unknown> = {};
|
|
tasks.push(task);
|
|
current = task;
|
|
}
|
|
continue;
|
|
}
|
|
if (line.startsWith("[") && line.endsWith("]")) {
|
|
const key = line.slice(1, -1).trim();
|
|
const section: Record<string, unknown> = 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<string, unknown>, 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<string, unknown> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function safeId(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "") || "plan-template";
|
|
}
|