feat(client): load local digital plan templates
This commit is contained in:
@@ -31,6 +31,7 @@ declare global {
|
|||||||
getRuntimeRecords?: () => Promise<QimingclawRuntimeRecords>;
|
getRuntimeRecords?: () => Promise<QimingclawRuntimeRecords>;
|
||||||
getUiState?: () => Promise<AdapterState>;
|
getUiState?: () => Promise<AdapterState>;
|
||||||
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
|
getSkillCapabilities?: () => Promise<QimingclawSkillCapabilities>;
|
||||||
|
getPlanTemplates?: () => Promise<QimingclawPlanTemplateList>;
|
||||||
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
saveUiState?: (update: { state: AdapterState; action?: string; date?: string; metadata?: Record<string, unknown> }) => Promise<AdapterState>;
|
||||||
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
getSyncStatus?: () => Promise<QimingclawSyncStatus>;
|
||||||
flushSync?: () => Promise<QimingclawSyncStatus>;
|
flushSync?: () => Promise<QimingclawSyncStatus>;
|
||||||
@@ -123,6 +124,19 @@ interface QimingclawSnapshot {
|
|||||||
sync?: QimingclawSyncStatus | null;
|
sync?: QimingclawSyncStatus | null;
|
||||||
runtime?: QimingclawRuntimeRecords | null;
|
runtime?: QimingclawRuntimeRecords | null;
|
||||||
uiState?: AdapterState | null;
|
uiState?: AdapterState | null;
|
||||||
|
planTemplates?: QimingclawPlanTemplateRecord[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QimingclawPlanTemplateList {
|
||||||
|
generatedAt: string;
|
||||||
|
templateDir: string;
|
||||||
|
templates: QimingclawPlanTemplateRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QimingclawPlanTemplateRecord extends DebugPlan {
|
||||||
|
source: 'qimingclaw-plan-template';
|
||||||
|
template_path: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QimingclawGovernanceCommandRecord {
|
interface QimingclawGovernanceCommandRecord {
|
||||||
@@ -475,12 +489,13 @@ async function readQimingclawSnapshot(): Promise<QimingclawSnapshot | null> {
|
|||||||
try {
|
try {
|
||||||
const snapshot = await window.QimingClawBridge?.digital?.getSnapshot?.() ?? null;
|
const snapshot = await window.QimingClawBridge?.digital?.getSnapshot?.() ?? null;
|
||||||
if (!snapshot) return null;
|
if (!snapshot) return null;
|
||||||
const [sync, runtime, uiState] = await Promise.all([
|
const [sync, runtime, uiState, planTemplates] = await Promise.all([
|
||||||
window.QimingClawBridge?.digital?.getSyncStatus?.().catch(() => null) ?? null,
|
window.QimingClawBridge?.digital?.getSyncStatus?.().catch(() => null) ?? null,
|
||||||
window.QimingClawBridge?.digital?.getRuntimeRecords?.().catch(() => null) ?? null,
|
window.QimingClawBridge?.digital?.getRuntimeRecords?.().catch(() => null) ?? null,
|
||||||
readBridgeUiState(),
|
readBridgeUiState(),
|
||||||
|
window.QimingClawBridge?.digital?.getPlanTemplates?.().catch(() => null) ?? null,
|
||||||
]);
|
]);
|
||||||
return { ...snapshot, sync, runtime, uiState };
|
return { ...snapshot, sync, runtime, uiState, planTemplates: planTemplates?.templates ?? [] };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -842,9 +857,53 @@ function runtimeDebugPlans(snapshot: QimingclawSnapshot | null): DebugPlan[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function templatePlans(snapshot: QimingclawSnapshot | null): DebugPlan[] {
|
||||||
|
const customTemplates = snapshot?.planTemplates ?? [];
|
||||||
|
const byId = new Map<string, DebugPlan>();
|
||||||
|
for (const plan of [...customTemplates, ...PLANS]) {
|
||||||
|
if (!byId.has(plan.plan_id)) byId.set(plan.plan_id, plan);
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergedDebugPlans(snapshot: QimingclawSnapshot | null): DebugPlan[] {
|
||||||
|
const byId = new Map<string, DebugPlan>();
|
||||||
|
for (const plan of [...runtimeDebugPlans(snapshot), ...templatePlans(snapshot).map((plan) => withRuntimePlan(plan, snapshot))]) {
|
||||||
|
if (!byId.has(plan.plan_id)) byId.set(plan.plan_id, plan);
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function planTemplateJob(plan: DebugPlan, snapshot: QimingclawSnapshot | null): CronJob {
|
||||||
|
const normalizedPlan = withRuntimePlan(plan, snapshot);
|
||||||
|
const schedule = PLAN_SCHEDULES[plan.plan_id] ?? PLAN_SCHEDULES['qimingclaw-client-readiness']!;
|
||||||
|
return {
|
||||||
|
id: `schedule-${plan.plan_id}`,
|
||||||
|
name: plan.title,
|
||||||
|
expression: schedule.expression,
|
||||||
|
command: `digital-employee:${plan.plan_id}`,
|
||||||
|
prompt: plan.objective ?? null,
|
||||||
|
job_type: 'plan_template',
|
||||||
|
schedule: { expression: schedule.expression, template_id: plan.plan_id },
|
||||||
|
enabled: true,
|
||||||
|
delivery: { mode: 'plan_template', template_id: plan.plan_id },
|
||||||
|
template_id: plan.plan_id,
|
||||||
|
trigger_kind: 'scheduled',
|
||||||
|
step_count: plan.steps?.length ?? 0,
|
||||||
|
skill_ids: [...new Set((plan.steps ?? []).flatMap((step) => step.preferred_skills ?? []))],
|
||||||
|
delete_after_run: false,
|
||||||
|
created_at: plan.created_at,
|
||||||
|
next_run: schedule.nextRun,
|
||||||
|
last_run: normalizedPlan.status === 'running' ? todayAt(9, 5) : null,
|
||||||
|
last_status: normalizedPlan.status === 'running' ? 'running' : null,
|
||||||
|
last_output: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildSchedulerJobs(snapshot: QimingclawSnapshot | null = null): CronJob[] {
|
function buildSchedulerJobs(snapshot: QimingclawSnapshot | null = null): CronJob[] {
|
||||||
|
const templateJobs = templatePlans(snapshot).map((plan) => planTemplateJob(plan, snapshot));
|
||||||
if (hasRuntimeRecords(snapshot)) {
|
if (hasRuntimeRecords(snapshot)) {
|
||||||
return runtimeDebugPlans(snapshot).map((plan) => ({
|
const runtimeJobs = runtimeDebugPlans(snapshot).map((plan) => ({
|
||||||
id: `runtime-${plan.plan_id}`,
|
id: `runtime-${plan.plan_id}`,
|
||||||
name: plan.title,
|
name: plan.title,
|
||||||
expression: '',
|
expression: '',
|
||||||
@@ -865,31 +924,13 @@ function buildSchedulerJobs(snapshot: QimingclawSnapshot | null = null): CronJob
|
|||||||
last_status: plan.status,
|
last_status: plan.status,
|
||||||
last_output: null,
|
last_output: null,
|
||||||
}));
|
}));
|
||||||
|
const runtimeIds = new Set<string | null | undefined>(runtimeJobs.map((job) => job.template_id));
|
||||||
|
return [
|
||||||
|
...runtimeJobs,
|
||||||
|
...templateJobs.filter((job) => !runtimeIds.has(job.template_id)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return PLANS.map((plan) => {
|
return templateJobs;
|
||||||
const schedule = PLAN_SCHEDULES[plan.plan_id] ?? PLAN_SCHEDULES['qimingclaw-client-readiness']!;
|
|
||||||
return {
|
|
||||||
id: `schedule-${plan.plan_id}`,
|
|
||||||
name: plan.title,
|
|
||||||
expression: schedule.expression,
|
|
||||||
command: `digital-employee:${plan.plan_id}`,
|
|
||||||
prompt: plan.objective ?? null,
|
|
||||||
job_type: 'plan_template',
|
|
||||||
schedule: { expression: schedule.expression, template_id: plan.plan_id },
|
|
||||||
enabled: true,
|
|
||||||
delivery: { mode: 'plan_template', template_id: plan.plan_id },
|
|
||||||
template_id: plan.plan_id,
|
|
||||||
trigger_kind: 'scheduled',
|
|
||||||
step_count: plan.steps?.length ?? 0,
|
|
||||||
skill_ids: [...new Set((plan.steps ?? []).flatMap((step) => step.preferred_skills ?? []))],
|
|
||||||
delete_after_run: false,
|
|
||||||
created_at: plan.created_at,
|
|
||||||
next_run: schedule.nextRun,
|
|
||||||
last_run: plan.status === 'running' ? todayAt(9, 5) : null,
|
|
||||||
last_status: plan.status === 'running' ? 'running' : null,
|
|
||||||
last_output: null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDuties(state: AdapterState, date: string, snapshot: QimingclawSnapshot | null): DigitalEmployeeDuty[] {
|
function buildDuties(state: AdapterState, date: string, snapshot: QimingclawSnapshot | null): DigitalEmployeeDuty[] {
|
||||||
@@ -934,9 +975,23 @@ function buildDuties(state: AdapterState, date: string, snapshot: QimingclawSnap
|
|||||||
result_text: task.syncError || `同步状态:${task.syncStatus}`,
|
result_text: task.syncError || `同步状态:${task.syncStatus}`,
|
||||||
conversation_id: task.id,
|
conversation_id: task.id,
|
||||||
}));
|
}));
|
||||||
return [...planDuties, ...standaloneDuties];
|
const runtimeDutyIds = new Set([...planDuties, ...standaloneDuties].map((duty) => duty.id));
|
||||||
|
return [
|
||||||
|
...planDuties,
|
||||||
|
...standaloneDuties,
|
||||||
|
...templateDuties(state, date, snapshot).filter((duty) => !runtimeDutyIds.has(duty.id)),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return PLANS.map((rawPlan) => {
|
return templateDuties(state, date, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateDuties(
|
||||||
|
state: AdapterState,
|
||||||
|
date: string,
|
||||||
|
snapshot: QimingclawSnapshot | null,
|
||||||
|
): DigitalEmployeeDuty[] {
|
||||||
|
const selected = selectedSetForDate(state, date);
|
||||||
|
return templatePlans(snapshot).map((rawPlan) => {
|
||||||
const plan = withRuntimePlan(rawPlan, snapshot);
|
const plan = withRuntimePlan(rawPlan, snapshot);
|
||||||
const schedule = PLAN_SCHEDULES[plan.plan_id] ?? PLAN_SCHEDULES['qimingclaw-client-readiness']!;
|
const schedule = PLAN_SCHEDULES[plan.plan_id] ?? PLAN_SCHEDULES['qimingclaw-client-readiness']!;
|
||||||
return {
|
return {
|
||||||
@@ -975,15 +1030,25 @@ function planResultText(plan: DebugPlan, snapshot: QimingclawSnapshot | null): s
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTasks(planId?: string | null, snapshot: QimingclawSnapshot | null = null): DebugTask[] {
|
function buildTasks(planId?: string | null, snapshot: QimingclawSnapshot | null = null): DebugTask[] {
|
||||||
if (hasRuntimeRecords(snapshot)) {
|
const templateTasks = templatePlans(snapshot)
|
||||||
return runtimeTasks(snapshot)
|
|
||||||
.filter((task) => !planId || task.planId === planId || task.id === planId)
|
|
||||||
.map(runtimeTaskToDebugTask);
|
|
||||||
}
|
|
||||||
return PLANS
|
|
||||||
.filter((plan) => !planId || plan.plan_id === planId)
|
.filter((plan) => !planId || plan.plan_id === planId)
|
||||||
.map((plan) => withRuntimePlan(plan, snapshot))
|
.map((plan) => withRuntimePlan(plan, snapshot))
|
||||||
.flatMap((plan) => (plan.steps ?? []).flatMap((step) => (step.tasks ?? []).map((task) => ({
|
.flatMap(templatePlanTasks);
|
||||||
|
if (hasRuntimeRecords(snapshot)) {
|
||||||
|
const runtimeResult = runtimeTasks(snapshot)
|
||||||
|
.filter((task) => !planId || task.planId === planId || task.id === planId)
|
||||||
|
.map(runtimeTaskToDebugTask);
|
||||||
|
const runtimeTaskIds = new Set(runtimeResult.map((task) => task.task_id));
|
||||||
|
return [
|
||||||
|
...runtimeResult,
|
||||||
|
...templateTasks.filter((task) => !runtimeTaskIds.has(task.task_id)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return templateTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templatePlanTasks(plan: DebugPlan): DebugTask[] {
|
||||||
|
return (plan.steps ?? []).flatMap((step) => (step.tasks ?? []).map((task) => ({
|
||||||
task_id: task.task_id,
|
task_id: task.task_id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
status: plan.status === 'running' && task.status === 'pending' ? 'running' : task.status,
|
status: plan.status === 'running' && task.status === 'pending' ? 'running' : task.status,
|
||||||
@@ -995,7 +1060,7 @@ function buildTasks(planId?: string | null, snapshot: QimingclawSnapshot | null
|
|||||||
updated_at: plan.status === 'running' ? new Date().toISOString() : plan.created_at,
|
updated_at: plan.status === 'running' ? new Date().toISOString() : plan.created_at,
|
||||||
started_at: task.status === 'running' ? todayAt(9, 5) : null,
|
started_at: task.status === 'running' ? todayAt(9, 5) : null,
|
||||||
finished_at: task.status === 'completed' ? todayAt(9, 10) : null,
|
finished_at: task.status === 'completed' ? todayAt(9, 10) : null,
|
||||||
}))));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRuns(tasks: DebugTask[]): DebugRun[] {
|
function buildRuns(tasks: DebugTask[]): DebugRun[] {
|
||||||
@@ -1392,9 +1457,7 @@ function slugifyIdPart(value: string): string {
|
|||||||
|
|
||||||
function filterPlans(query?: string | null, snapshot: QimingclawSnapshot | null = null): DebugPlan[] {
|
function filterPlans(query?: string | null, snapshot: QimingclawSnapshot | null = null): DebugPlan[] {
|
||||||
const normalized = (query ?? '').trim().toLowerCase();
|
const normalized = (query ?? '').trim().toLowerCase();
|
||||||
const plans = hasRuntimeRecords(snapshot)
|
const plans = mergedDebugPlans(snapshot);
|
||||||
? runtimeDebugPlans(snapshot)
|
|
||||||
: PLANS.map((plan) => withRuntimePlan(plan, snapshot));
|
|
||||||
if (!normalized) return plans;
|
if (!normalized) return plans;
|
||||||
return plans.filter((plan) => (
|
return plans.filter((plan) => (
|
||||||
plan.title.toLowerCase().includes(normalized)
|
plan.title.toLowerCase().includes(normalized)
|
||||||
@@ -1743,7 +1806,7 @@ async function handleGovernanceCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.command_kind === 'activate_plan' || body.command_kind === 'run_schedule_now') {
|
if (body.command_kind === 'activate_plan' || body.command_kind === 'run_schedule_now') {
|
||||||
const targetPlanId = planId || runtimeDebugPlans(snapshot)[0]?.plan_id || PLANS[0]?.plan_id || 'qimingclaw-client-readiness';
|
const targetPlanId = planId || runtimeDebugPlans(snapshot)[0]?.plan_id || templatePlans(snapshot)[0]?.plan_id || 'qimingclaw-client-readiness';
|
||||||
const dispatch = await dispatchPlan(targetPlanId, snapshot);
|
const dispatch = await dispatchPlan(targetPlanId, snapshot);
|
||||||
const response = governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
|
const response = governanceAccepted(commandId, correlationId, '已映射为 qimingclaw 本地任务激活。', {
|
||||||
plan_id: targetPlanId,
|
plan_id: targetPlanId,
|
||||||
@@ -1865,7 +1928,7 @@ function buildPlanFlow(planId: string, snapshot: QimingclawSnapshot | null = nul
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const plan = withRuntimePlan(PLANS.find((candidate) => candidate.plan_id === planId) ?? PLANS[0]!, snapshot);
|
const plan = withRuntimePlan(templatePlans(snapshot).find((candidate) => candidate.plan_id === planId) ?? templatePlans(snapshot)[0]!, snapshot);
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
plan_id: plan.plan_id,
|
plan_id: plan.plan_id,
|
||||||
@@ -1976,21 +2039,49 @@ function buildTaskFlow(taskId: string, snapshot: QimingclawSnapshot | null = nul
|
|||||||
async function dispatchPlan(planId: string, snapshot: QimingclawSnapshot | null = null): Promise<PlanDispatchResponse> {
|
async function dispatchPlan(planId: string, snapshot: QimingclawSnapshot | null = null): Promise<PlanDispatchResponse> {
|
||||||
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
||||||
const date = todayInputDate();
|
const date = todayInputDate();
|
||||||
|
const plan = mergedDebugPlans(snapshot).find((candidate) => candidate.plan_id === planId);
|
||||||
|
const activatedAt = Date.now();
|
||||||
|
const correlationId = `dispatch-${planId}-${activatedAt}`;
|
||||||
const selected = new Set(state.selectedDutyIdsByDate[date] ?? []);
|
const selected = new Set(state.selectedDutyIdsByDate[date] ?? []);
|
||||||
selected.add(planId);
|
selected.add(planId);
|
||||||
state.selectedDutyIdsByDate[date] = [...selected];
|
state.selectedDutyIdsByDate[date] = [...selected];
|
||||||
state.confirmedDates[date] = state.confirmedDates[date] ?? new Date().toISOString();
|
state.confirmedDates[date] = state.confirmedDates[date] ?? new Date().toISOString();
|
||||||
await saveState(state, 'confirm_workday', date);
|
await saveState(state, 'confirm_workday', date);
|
||||||
|
const dispatchedTasks = buildTasks(planId, snapshot).map((task) => ({
|
||||||
|
task_id: task.task_id,
|
||||||
|
title: task.title,
|
||||||
|
status: task.status === 'pending' ? 'running' : task.status,
|
||||||
|
}));
|
||||||
|
await recordGovernanceCommand(
|
||||||
|
{
|
||||||
|
command_kind: 'activate_plan',
|
||||||
|
context_refs: { plan_id: planId },
|
||||||
|
payload: {
|
||||||
|
plan_id: planId,
|
||||||
|
title: plan?.title,
|
||||||
|
objective: plan?.objective,
|
||||||
|
requested_action: 'dispatch_plan',
|
||||||
|
},
|
||||||
|
correlation_id: correlationId,
|
||||||
|
},
|
||||||
|
governanceAccepted(
|
||||||
|
`qimingclaw-digital-dispatch-${slugifyIdPart(planId)}-${activatedAt}`,
|
||||||
|
correlationId,
|
||||||
|
'已将数字员工模板执行写入 qimingclaw 本地运行记录。',
|
||||||
|
{
|
||||||
|
plan_id: planId,
|
||||||
|
status: 'active',
|
||||||
|
dispatched_tasks: dispatchedTasks,
|
||||||
|
source: 'qimingclaw-adapter',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
plan_id: planId,
|
plan_id: planId,
|
||||||
source_plan_id: planId,
|
source_plan_id: planId,
|
||||||
dispatched_tasks: buildTasks(planId, snapshot).map((task) => ({
|
dispatched_tasks: dispatchedTasks,
|
||||||
task_id: task.task_id,
|
|
||||||
title: task.title,
|
|
||||||
status: task.status === 'pending' ? 'running' : task.status,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2011,7 +2102,7 @@ function buildPlanMessages(planId: string, snapshot: QimingclawSnapshot | null =
|
|||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
const plan = PLANS.find((candidate) => candidate.plan_id === planId) ?? PLANS[0]!;
|
const plan = templatePlans(snapshot).find((candidate) => candidate.plan_id === planId) ?? templatePlans(snapshot)[0]!;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
|||||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="./assets/index-DDA_E4Qy.js"></script>
|
<script type="module" crossorigin src="./assets/index-BVS02Noc.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
flushDigitalEmployeeSyncOutbox,
|
flushDigitalEmployeeSyncOutbox,
|
||||||
getDigitalEmployeeSyncStatus,
|
getDigitalEmployeeSyncStatus,
|
||||||
} from "../services/digitalEmployee/syncService";
|
} from "../services/digitalEmployee/syncService";
|
||||||
|
import { listDigitalEmployeePlanTemplates } from "../services/digitalEmployee/planTemplateService";
|
||||||
|
|
||||||
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
||||||
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
|
ipcMain.handle("digitalEmployee:getSnapshot", async () => {
|
||||||
@@ -54,6 +55,10 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
return buildSkillCapabilities();
|
return buildSkillCapabilities();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:getPlanTemplates", async () => {
|
||||||
|
return listDigitalEmployeePlanTemplates();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"digitalEmployee:saveUiState",
|
"digitalEmployee:saveUiState",
|
||||||
async (_, update: DigitalEmployeeUiStateUpdate) => {
|
async (_, update: DigitalEmployeeUiStateUpdate) => {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockElectron = vi.hoisted(() => ({
|
||||||
|
homeDir: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("electron", () => ({
|
||||||
|
app: {
|
||||||
|
getPath: (key: string) => {
|
||||||
|
if (key === "home") return mockElectron.homeDir;
|
||||||
|
return mockElectron.homeDir;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("electron-log", () => ({
|
||||||
|
default: {
|
||||||
|
warn: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("digital employee plan template service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockElectron.homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "qiming-plan-templates-"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(mockElectron.homeDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when the template directory is missing", async () => {
|
||||||
|
const { listDigitalEmployeePlanTemplates } = await import("./planTemplateService");
|
||||||
|
|
||||||
|
const result = listDigitalEmployeePlanTemplates();
|
||||||
|
|
||||||
|
expect(result.templates).toEqual([]);
|
||||||
|
expect(result.templateDir).toBe(path.join(mockElectron.homeDir, ".qimingclaw", "plan_templates"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads JSON and TOML plan templates from qimingclaw home", async () => {
|
||||||
|
const templateDir = path.join(mockElectron.homeDir, ".qimingclaw", "plan_templates");
|
||||||
|
fs.mkdirSync(templateDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(templateDir, "customer-followup.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
plan_id: "customer-followup",
|
||||||
|
title: "客户跟进",
|
||||||
|
objective: "汇总客户待办并生成跟进计划",
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
step_id: "collect",
|
||||||
|
title: "收集待办",
|
||||||
|
preferred_skills: ["qimingclaw-mcp-tools"],
|
||||||
|
tasks: [{ task_id: "collect-crm", title: "读取 CRM 待办" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(templateDir, "daily-report.toml"),
|
||||||
|
`id = "daily-report"\n` +
|
||||||
|
`title = "日报模板"\n` +
|
||||||
|
`objective = "生成今日业务日报"\n` +
|
||||||
|
`[[steps]]\n` +
|
||||||
|
`id = "write"\n` +
|
||||||
|
`title = "撰写日报"\n` +
|
||||||
|
`skills = ["qimingclaw-artifact-report"]\n` +
|
||||||
|
`[[steps.tasks]]\n` +
|
||||||
|
`id = "write-report"\n` +
|
||||||
|
`title = "整理日报正文"\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { listDigitalEmployeePlanTemplates } = await import("./planTemplateService");
|
||||||
|
|
||||||
|
const result = listDigitalEmployeePlanTemplates();
|
||||||
|
|
||||||
|
expect(result.templates).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
plan_id: "customer-followup",
|
||||||
|
title: "客户跟进",
|
||||||
|
steps: [
|
||||||
|
expect.objectContaining({
|
||||||
|
step_id: "collect",
|
||||||
|
preferred_skills: ["qimingclaw-mcp-tools"],
|
||||||
|
tasks: [expect.objectContaining({ task_id: "collect-crm" })],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
plan_id: "daily-report",
|
||||||
|
title: "日报模板",
|
||||||
|
steps: [
|
||||||
|
expect.objectContaining({
|
||||||
|
step_id: "write",
|
||||||
|
preferred_skills: ["qimingclaw-artifact-report"],
|
||||||
|
tasks: [expect.objectContaining({ task_id: "write-report" })],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
@@ -104,6 +104,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async getSkillCapabilities() {
|
async getSkillCapabilities() {
|
||||||
return ipcRenderer.invoke("digitalEmployee:getSkillCapabilities");
|
return ipcRenderer.invoke("digitalEmployee:getSkillCapabilities");
|
||||||
},
|
},
|
||||||
|
async getPlanTemplates() {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:getPlanTemplates");
|
||||||
|
},
|
||||||
async saveUiState(update: unknown) {
|
async saveUiState(update: unknown) {
|
||||||
return ipcRenderer.invoke("digitalEmployee:saveUiState", update);
|
return ipcRenderer.invoke("digitalEmployee:saveUiState", update);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -760,6 +760,23 @@ Plan
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
PlanTemplate.toml
|
PlanTemplate.toml
|
||||||
|
PlanTemplate.json
|
||||||
|
```
|
||||||
|
|
||||||
|
当前客户端实现先读取本机目录,不依赖 sgRobot daemon。main process 通过
|
||||||
|
`digitalEmployee:getPlanTemplates` IPC 暴露模板列表,preload bridge 提供
|
||||||
|
`window.QimingClawBridge.digital.getPlanTemplates()` 给 embedded digital 页面使用。
|
||||||
|
|
||||||
|
模板字段支持:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plan_id / id / template_id
|
||||||
|
title / name
|
||||||
|
objective / description
|
||||||
|
status
|
||||||
|
steps[].step_id / steps[].id
|
||||||
|
steps[].preferred_skills / steps[].skills
|
||||||
|
steps[].tasks[].task_id / steps[].tasks[].id
|
||||||
```
|
```
|
||||||
|
|
||||||
### API
|
### API
|
||||||
@@ -781,6 +798,14 @@ POST /api/plan-templates/:id/activate
|
|||||||
- 一键执行
|
- 一键执行
|
||||||
- 设置定时
|
- 设置定时
|
||||||
|
|
||||||
|
页面融合位置:
|
||||||
|
|
||||||
|
- 任务设置:模板作为可选业务流程展示。
|
||||||
|
- 任务中心:模板步骤和任务合并到本地运行视图。
|
||||||
|
- 调度:模板生成 `plan_template` 类型 job。
|
||||||
|
- 详情/聊天:无运行态时以模板内容生成计划流和消息。
|
||||||
|
- 执行:点击模板执行会写入正式 `activate_plan` governance command,生成 Plan / Task / Run / Event / outbox,同步给管理后端。
|
||||||
|
|
||||||
### 产出
|
### 产出
|
||||||
|
|
||||||
- qimingclaw 具备业务流程沉淀能力。
|
- qimingclaw 具备业务流程沉淀能力。
|
||||||
@@ -789,7 +814,9 @@ POST /api/plan-templates/:id/activate
|
|||||||
### 验收
|
### 验收
|
||||||
|
|
||||||
- 放入一个 `PlanTemplate.toml` 后,数字员工页面能展示。
|
- 放入一个 `PlanTemplate.toml` 后,数字员工页面能展示。
|
||||||
|
- 放入一个 `PlanTemplate.json` 后,数字员工页面能展示。
|
||||||
- 点击执行后生成 Plan / Task / Run。
|
- 点击执行后生成 Plan / Task / Run。
|
||||||
|
- 执行记录进入 `digital_sync_outbox`,后续由本地同步链路上报。
|
||||||
|
|
||||||
## Phase 6:Skill Catalog 融合
|
## Phase 6:Skill Catalog 融合
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user