吸收数字员工PlanStep跨任务编排执行

This commit is contained in:
baiyanyun
2026-06-10 21:00:59 +08:00
parent a6e0246418
commit 909f11dae5
17 changed files with 646 additions and 217 deletions

View File

@@ -5,6 +5,7 @@ import Avatar3D from '@/components/digital/Avatar3D';
import { basePath } from '@/lib/basePath';
import {
dispatchPlanNow,
dispatchPlanReadySteps,
getDigitalEmployeeWorkday,
getPlanStepGraph,
getSchedulerJobs,
@@ -12,7 +13,6 @@ import {
postDigitalEmployeeWorkdayAction,
recordApprovalAction,
restartManagedService,
triggerCronCheck,
} from '@/lib/api';
import { isTauri } from '@/lib/tauri';
import type {
@@ -60,10 +60,19 @@ interface DigitalPlanItem {
conversationId?: string | null;
stepCount: number;
taskCount: number;
readyStepCount: number;
runningStepCount: number;
blockedStepCount: number;
schedulerJob?: CronJob | null;
source: 'plan_template';
}
interface PlanStepOrchestrationSummary {
ready: number;
running: number;
blocked: number;
}
interface BlockedPlanStepInputTarget {
step: PlanStepBusinessRow;
dependency: PlanStepDependencyDetail | null;
@@ -405,6 +414,9 @@ function buildPlanTemplateItem(job: CronJob, duties: DigitalEmployeeDuty[], sele
conversationId: linkedDuty?.conversation_id ?? templateId,
stepCount,
taskCount: skillCount,
readyStepCount: 0,
runningStepCount: 0,
blockedStepCount: 0,
schedulerJob: job,
source: 'plan_template',
};
@@ -419,6 +431,26 @@ function planExecutionDisabledReason(planItem: DigitalPlanItem): string {
return '';
}
function planReadyStepActionLabel(planItem: DigitalPlanItem): string {
if (planItem.readyStepCount > 0) return `推进 ${planItem.readyStepCount}`;
return '推进';
}
function planStepOrchestrationSummary(rows: PlanStepBusinessRow[]): Map<string, PlanStepOrchestrationSummary> {
const summary = new Map<string, PlanStepOrchestrationSummary>();
rows.forEach((step) => {
const planId = step.plan_id || '';
if (!planId) return;
const current = summary.get(planId) ?? { ready: 0, running: 0, blocked: 0 };
const status = step.status.toLowerCase();
if (status === 'ready') current.ready += 1;
else if (['running', 'active', 'leased', 'queued'].includes(status)) current.running += 1;
else if (status === 'blocked') current.blocked += 1;
summary.set(planId, current);
});
return summary;
}
function eventMatchesPlanItem(event: DigitalEmployeeWorkEvent, planItem: DigitalPlanItem): boolean {
const haystack = `${event.conversation_id ?? ''} ${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`;
return haystack.includes(planItem.id) || haystack.includes(planItem.title);
@@ -946,6 +978,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const [reportPreviewOpen, setReportPreviewOpen] = useState(false);
const [selectedSyncFailure, setSelectedSyncFailure] = useState<ManagementSyncFailure | null>(null);
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
const [planStepGraphRows, setPlanStepGraphRows] = useState<PlanStepBusinessRow[]>([]);
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
const [blockedInputDraft, setBlockedInputDraft] = useState('');
@@ -1019,14 +1052,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
setManagementSyncStatus(status);
});
const planStepGraphPromise = getPlanStepGraph({ status: 'blocked', page_size: 6 })
const planStepGraphPromise = getPlanStepGraph({ page_size: 80 })
.then((response) => {
if (!mountedRef.current) return;
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked'));
setPlanStepGraphRows(response.items);
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked').slice(0, 6));
setBlockedPlanStepError(null);
})
.catch((error: unknown) => {
if (!mountedRef.current) return;
setPlanStepGraphRows([]);
setBlockedPlanSteps([]);
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
});
@@ -1066,6 +1101,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
[isLiveProjection, workdayProjection?.duties],
);
const planItems = useMemo(() => {
const orchestrationByPlan = planStepOrchestrationSummary(planStepGraphRows);
return schedulerJobs
.slice()
.sort((left, right) => {
@@ -1084,9 +1120,20 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
})
.map((job) => {
const templateId = templateIdForSchedulerJob(job);
return buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
const item = buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
const orchestration = orchestrationByPlan.get(item.planId) ?? orchestrationByPlan.get(templateId);
if (!orchestration) return item;
return {
...item,
readyStepCount: orchestration.ready,
runningStepCount: orchestration.running,
blockedStepCount: orchestration.blocked,
resultText: orchestration.ready > 0 || orchestration.running > 0 || orchestration.blocked > 0
? `Ready ${orchestration.ready} · 运行 ${orchestration.running} · 阻塞 ${orchestration.blocked}`
: item.resultText,
};
});
}, [liveProjectionDuties, schedulerJobs, selectedDutyIds]);
}, [liveProjectionDuties, planStepGraphRows, schedulerJobs, selectedDutyIds]);
const selectedDuties = useMemo(
() => planItems.filter((planItem) => selectedDutyIds.has(planItem.id)),
[planItems, selectedDutyIds],
@@ -1717,13 +1764,14 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
throw new Error(response.error || '执行接口未接受请求');
}
const dispatchedCount = response.dispatched_tasks?.length ?? 0;
const schedulerResult = await triggerCronCheck();
const readyDispatched = schedulerResult.readyStepDispatch?.dispatched ?? 0;
const readyFailed = schedulerResult.readyStepDispatch?.failed ?? 0;
const readyResult = await dispatchPlanReadySteps(planItem.planId);
const readyDispatched = readyResult.dispatched ?? 0;
const readySkipped = readyResult.skipped ?? 0;
const readyFailed = readyResult.failed ?? 0;
setExecutePlanItem(null);
setActionNotice(
readyDispatched > 0 || readyFailed > 0
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤,Ready 步骤自动派发 ${readyDispatched}${readyFailed > 0 ? `,失败 ${readyFailed}` : ''}`
readyDispatched > 0 || readySkipped > 0 || readyFailed > 0
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤,推进 ${readyDispatched}${readySkipped > 0 ? `,跳过 ${readySkipped}` : ''}${readyFailed > 0 ? `,失败 ${readyFailed}` : ''}`
: `已触发执行:${planItem.title},已派发 ${dispatchedCount} 个步骤。`,
);
await fetchProjection();
@@ -1736,6 +1784,32 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const controlPlanReadySteps = useCallback(async (planItem: DigitalPlanItem) => {
if (!isPlanItemExecutable(planItem)) {
setActionError(planExecutionDisabledReason(planItem));
setActionNotice(null);
return;
}
const actionKey = `orchestrate:${planItem.id}`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const result = await dispatchPlanReadySteps(planItem.planId);
if (result.ok === false) {
throw new Error(result.errors?.[0]?.error || '计划推进未被接受');
}
setActionNotice(`已推进:${planItem.title},派发 ${result.dispatched ?? 0} 个,跳过 ${result.skipped ?? 0}${(result.failed ?? 0) > 0 ? `,失败 ${result.failed}` : ''}`);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '计划推进失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const dismissWorkdayGuide = useCallback(() => {
setGuideOpen(false);
setGuideDismissedDate(selectedDate);
@@ -1782,6 +1856,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</button>
<div className="de-duty-card-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented || planItem.readyStepCount === 0 || Boolean(actionBusy)}
title={planItem.readyStepCount > 0 ? '推进此计划的 Ready 步骤' : '暂无可推进 Ready 步骤'}
onClick={() => { void controlPlanReadySteps(planItem); }}
>
<TimerReset size={13} aria-hidden="true" />
<span>{actionBusy === `orchestrate:${planItem.id}` ? '推进中' : planReadyStepActionLabel(planItem)}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
@@ -1807,7 +1891,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
</div>
);
}, [actionBusy, openDuty, selectedDutyIds, toggleDuty]);
}, [actionBusy, controlPlanReadySteps, openDuty, selectedDutyIds, toggleDuty]);
const openEventDetail = useCallback((event: DigitalEmployeeWorkEvent) => {
setSelectedEvent(event);
@@ -2043,6 +2127,16 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span>{planItem.resultText}</span>
</button>
<div className="de-template-item-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented || planItem.readyStepCount === 0 || Boolean(actionBusy)}
title={planItem.readyStepCount > 0 ? '推进此计划的 Ready 步骤' : '暂无可推进 Ready 步骤'}
onClick={() => { void controlPlanReadySteps(planItem); }}
>
<TimerReset size={13} aria-hidden="true" />
<span>{actionBusy === `orchestrate:${planItem.id}` ? '推进中' : '推进'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"