吸收数字员工PlanStep跨任务编排执行
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user