Files
qiming/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/MissionCenter.tsx

694 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import MissionCard from '@/components/digital/MissionCard';
import FlowDetailPanel from '@/components/digital/FlowDetailPanel';
import { adaptMissions, type Mission } from '@/lib/consoleDataAdapter';
import { getDebugPlans, governanceCommand, searchDebugPlans } from '@/lib/api';
import type { GovernanceCommandKind, GovernanceCommandResponse } from '@/types/api';
import { t } from '@/lib/i18n';
import {
completeDemoMissionOnce,
getDemoMissions,
runDemoMissionOnce,
} from './demoScenario';
import { domSafeId } from './navigation';
const REQUIRED_MISSION_IDS = [
'qimingclaw-client-readiness',
'qimingclaw-remote-task-ingress',
'qimingclaw-daily-report',
];
const DELEGATION_FILTERS = [
{ key: 'all', label: '全部委托' },
{ key: 'daily', label: '日常任务' },
{ key: 'weekly', label: '周任务' },
{ key: 'monthly', label: '月度任务' },
{ key: 'adhoc', label: '临时任务' },
];
type MissionNotice = {
kind: 'selected' | 'created' | 'dispatched';
missionId: string;
title: string;
planId?: string;
};
function planSortTime(plan: Record<string, unknown>): number {
const value = typeof plan.updated_at === 'string'
? plan.updated_at
: typeof plan.created_at === 'string'
? plan.created_at
: '';
const time = value ? new Date(value).getTime() : 0;
return Number.isFinite(time) ? time : 0;
}
function dateKey(value?: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
function todayDateKey(): string {
return dateKey(new Date().toISOString());
}
function shiftDateKey(value: string, days: number): string {
const date = value ? new Date(`${value}T12:00:00`) : new Date();
if (Number.isNaN(date.getTime())) return todayDateKey();
date.setDate(date.getDate() + days);
return dateKey(date.toISOString());
}
function plannedClockFromMission(mission: Mission): { text: string; minutes: number } | null {
const match = `${mission.title} ${mission.id}`.match(/(?:^|\D)(\d{1,2})[:](\d{2})(?:\D|$)/);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) return null;
return {
text: `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
minutes: hour * 60 + minute,
};
}
function missionTaskTimes(mission: Mission): string[] {
return mission.steps.flatMap((step) => (
step.tasks.flatMap((task) => [task.startedAt, task.finishedAt])
)).filter((value): value is string => Boolean(value));
}
function missionMatchesDate(mission: Mission, selectedDate: string): boolean {
const taskDates = missionTaskTimes(mission).map(dateKey).filter(Boolean);
if (taskDates.includes(selectedDate)) return true;
if (plannedClockFromMission(mission)) return true;
return [mission.updatedAt, mission.createdAt].map(dateKey).includes(selectedDate);
}
function missionDateKey(mission: Mission): string {
const planned = plannedClockFromMission(mission);
if (!planned) return mission.id;
return `${mission.title}`.trim().toLowerCase() || mission.id;
}
function missionHasActualWorkOnDate(mission: Mission, selectedDate: string): boolean {
return missionTaskTimes(mission).some((value) => dateKey(value) === selectedDate);
}
function missionDedupScore(mission: Mission, selectedDate: string): number {
const actualScore = missionHasActualWorkOnDate(mission, selectedDate) ? 10_000 : 0;
const requiredScore = REQUIRED_MISSION_IDS.includes(mission.id) ? 1_000 : 0;
const selectedSyncScore = [mission.updatedAt, mission.createdAt].map(dateKey).includes(selectedDate) ? 100 : 0;
return actualScore + requiredScore + selectedSyncScore + planSortTime({
updated_at: mission.updatedAt || '',
created_at: mission.createdAt || '',
}) / 10_000_000_000_000;
}
function dedupeMissionsForDate(missions: Mission[], selectedDate: string): Mission[] {
const byKey = new Map<string, Mission>();
for (const mission of missions) {
const key = missionDateKey(mission);
const previous = byKey.get(key);
if (!previous || missionDedupScore(mission, selectedDate) > missionDedupScore(previous, selectedDate)) {
byKey.set(key, mission);
}
}
return Array.from(byKey.values());
}
function missionDateSortValue(mission: Mission, selectedDate: string): number {
const planned = plannedClockFromMission(mission);
if (planned) return planned.minutes;
const taskTimes = missionTaskTimes(mission)
.filter((value) => dateKey(value) === selectedDate)
.sort();
if (taskTimes[0]) {
const date = new Date(taskTimes[0]);
return date.getHours() * 60 + date.getMinutes();
}
return 24 * 60;
}
function missionDelegationGroup(mission: Mission): string {
const text = `${mission.id} ${mission.title} ${mission.objective}`.toLowerCase();
if (text.includes('month') || text.includes('monthly') || text.includes('月')) return 'monthly';
if (text.includes('week') || text.includes('weekly') || text.includes('周')) return 'weekly';
if (text.includes('临时') || text.includes('adhoc') || text.includes('hotlist') || text.includes('monitor')) return 'adhoc';
return 'daily';
}
async function sendMissionGovernanceCommand(
commandKind: GovernanceCommandKind,
contextRefs: Record<string, unknown>,
payload: Record<string, unknown> = {},
): Promise<GovernanceCommandResponse> {
const stamp = Date.now();
const response = await governanceCommand({
command_kind: commandKind,
context_refs: contextRefs,
payload: { source: 'digital-mission-center', ...payload },
correlation_id: `mission-${commandKind}-${stamp}`,
idempotency_key: `mission-${commandKind}-${Object.values(contextRefs).join('-') || 'new'}-${stamp}`,
});
if (!response.accepted) {
throw new Error(response.message || `command 未被接受:${response.command_id || commandKind}`);
}
return response;
}
async function sendMissionPlanCommand(
planId: string,
commandKind: 'submit_plan' | 'approve_plan' | 'activate_plan' | 'amend_plan',
payload: Record<string, unknown> = {},
): Promise<GovernanceCommandResponse> {
return sendMissionGovernanceCommand(commandKind, { plan_id: planId }, payload);
}
async function dispatchMissionPlan(mission: Mission): Promise<string | null> {
const planId = mission.sourceIds.planId;
if (!planId) return null;
const status = mission.status.toLowerCase();
if (status === 'draft') {
await sendMissionPlanCommand(planId, 'submit_plan', { requested_action: 'mission_dispatch' });
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_dispatch' });
} else if (status === 'reviewing') {
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_dispatch' });
} else if (!['approved', 'active', 'running'].includes(status)) {
await sendMissionPlanCommand(planId, 'amend_plan', {
requested_action: 'reopen_for_execution',
summary: '数字员工任务中心请求重新执行任务',
});
return null;
}
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_dispatch' });
return planId;
}
function restartStepsForMission(mission: Mission) {
const steps = mission.steps.map((step, index) => {
const skills = new Set<string>();
step.preferredSkills.forEach((skill) => {
if (skill.trim()) skills.add(skill);
});
step.tasks.forEach((task) => {
if (task.assignedSkillId?.trim()) skills.add(task.assignedSkillId);
});
const description = [
step.description,
...step.tasks.map((task) => task.description),
step.title,
mission.objective,
mission.title,
].find((value) => typeof value === 'string' && value.trim());
return {
title: step.title || `${mission.title}${index + 1}`,
description: description || step.title || mission.objective || mission.title,
skills: Array.from(skills),
depends_on: index === 0 ? [] : [`step-${index}`],
};
}).filter((step) => step.skills.length > 0);
if (steps.length === 0) {
throw new Error('当前任务没有可复用的 skill不能创建真实重新执行计划。');
}
return steps;
}
function restartMissionTitle(title: string): string {
const base = title.replace(/(\s*)+$/u, '').trim();
return `${base || title} 重新执行`;
}
async function createRestartPlan(mission: Mission): Promise<string> {
const response = await sendMissionGovernanceCommand('create_plan', {
session_id: 'digital-mission-center',
plan_id: mission.sourceIds.planId || mission.id,
}, {
requested_action: 'mission_restart',
title: restartMissionTitle(mission.title),
description: mission.objective || mission.title,
steps: restartStepsForMission(mission),
original_plan_id: mission.sourceIds.planId || mission.id,
});
const planId = typeof response.result?.plan_id === 'string' ? response.result.plan_id : '';
if (!planId) {
throw new Error('重新执行计划已提交,但后端没有返回 plan_id。');
}
const status = typeof response.result?.status === 'string' ? response.result.status.toLowerCase() : '';
if (status === 'draft') {
await sendMissionPlanCommand(planId, 'submit_plan', { requested_action: 'mission_restart' });
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_restart' });
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_restart' });
} else if (status === 'reviewing') {
await sendMissionPlanCommand(planId, 'approve_plan', { requested_action: 'mission_restart' });
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_restart' });
} else if (status === 'approved') {
await sendMissionPlanCommand(planId, 'activate_plan', { requested_action: 'mission_restart' });
}
return planId;
}
export default function MissionCenter({
focusMissionId,
focusTaskId,
focusDate,
}: {
focusMissionId?: string | null;
focusTaskId?: string | null;
focusDate?: string | null;
}) {
const initialMissionDate = focusDate || todayDateKey();
const [missions, setMissions] = useState<Mission[]>([]);
const [selected, setSelected] = useState<Mission | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [filter, setFilter] = useState<string>('all');
const [dateFilter, setDateFilter] = useState<string>(() => initialMissionDate);
const [showAllDates, setShowAllDates] = useState(false);
const [runningMissionId, setRunningMissionId] = useState<string | null>(null);
const [runningAll, setRunningAll] = useState(false);
const [demoMode, setDemoMode] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [recentMissionId, setRecentMissionId] = useState<string | null>(null);
const [missionNotice, setMissionNotice] = useState<MissionNotice | null>(null);
const detailRef = useRef<HTMLDivElement | null>(null);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const fetchMissions = useCallback(async (): Promise<Mission[]> => {
setLoading(true); setError(false);
try {
const data = await getDebugPlans();
if (!mountedRef.current) return [];
const planMap = new Map<string, Record<string, unknown>>();
const addPlan = (plan: Record<string, unknown>) => {
const id = typeof plan.plan_id === 'string' ? plan.plan_id : '';
if (id && !planMap.has(id)) planMap.set(id, plan);
};
(Array.isArray(data) ? data as Record<string, unknown>[] : []).forEach(addPlan);
const missing = REQUIRED_MISSION_IDS.filter((id) => !planMap.has(id));
if (missing.length > 0) {
const extraResults = await Promise.allSettled(
missing.map(async (id) => ({ id, plans: await searchDebugPlans(id, 20) })),
);
for (const result of extraResults) {
if (result.status === 'fulfilled') {
const exact = result.value.plans.find((plan) => plan.plan_id === result.value.id);
if (exact) {
addPlan(exact as Record<string, unknown>);
}
}
}
}
const requiredPlans = REQUIRED_MISSION_IDS
.map((id) => planMap.get(id))
.filter((plan): plan is Record<string, unknown> => Boolean(plan));
const recentPlans = [...planMap.entries()]
.filter(([id]) => !REQUIRED_MISSION_IDS.includes(id))
.map(([, plan]) => plan)
.sort((a, b) => planSortTime(b) - planSortTime(a));
const orderedPlans = [...recentPlans, ...requiredPlans];
if (orderedPlans.length > 0) {
const nextMissions = adaptMissions(orderedPlans);
setDemoMode(false);
setMissions(nextMissions);
return nextMissions;
} else {
const demoMissions = getDemoMissions();
setDemoMode(true);
setMissions(demoMissions);
return demoMissions;
}
} catch (err) {
if (!mountedRef.current) return [];
console.error('[MissionCenter] fetch failed:', err);
const demoMissions = getDemoMissions();
setDemoMode(true);
setMissions(demoMissions);
setError(false);
return demoMissions;
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => { fetchMissions(); }, [fetchMissions]);
useEffect(() => {
setDateFilter(focusDate || todayDateKey());
setShowAllDates(false);
}, [focusDate]);
const focusMission = useCallback((mission: Mission) => {
setSelected(mission);
setRecentMissionId(mission.id);
window.setTimeout(() => {
detailRef.current?.scrollIntoView({ block: 'start', inline: 'nearest', behavior: 'smooth' });
}, 40);
}, []);
const selectMission = useCallback((mission: Mission) => {
focusMission(mission);
setMissionNotice({
kind: 'selected',
missionId: mission.id,
title: mission.title,
planId: mission.sourceIds.planId,
});
}, [focusMission]);
useEffect(() => {
if (!focusMissionId && !focusTaskId) return;
const matched = missions.find((mission) => (
mission.id === focusMissionId
|| mission.sourceIds.planId === focusMissionId
|| (focusTaskId ? mission.sourceIds.taskIds.includes(focusTaskId) : false)
));
if (!matched) return;
setFilter('all');
selectMission(matched);
window.setTimeout(() => {
document.getElementById(`mission-card-${domSafeId(matched.id)}`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
}, 80);
}, [focusMissionId, focusTaskId, missions, selectMission]);
const runMission = useCallback(async (mission: Mission) => {
setRunningMissionId(mission.id);
setError(false);
setActionError(null);
if (demoMode || !mission.sourceIds.planId) {
setMissions((current) => runDemoMissionOnce(current, mission.id));
setSelected((current) => {
if (current?.id !== mission.id) return current;
const next = runDemoMissionOnce([current], mission.id);
return next[0] ?? current;
});
window.setTimeout(() => {
if (!mountedRef.current) return;
setMissions((current) => completeDemoMissionOnce(current, mission.id));
setSelected((current) => {
if (current?.id !== mission.id) return current;
const next = completeDemoMissionOnce([current], mission.id);
return next[0] ?? current;
});
setRunningMissionId(null);
}, 1200);
return;
}
try {
const newPlanId = await dispatchMissionPlan(mission);
const nextMissions = await fetchMissions();
setFilter('all');
if (newPlanId) {
const selectedMission = nextMissions.find((item) => item.id === newPlanId || item.sourceIds.planId === newPlanId);
if (selectedMission) {
focusMission(selectedMission);
setMissionNotice({
kind: 'dispatched',
missionId: selectedMission.id,
title: selectedMission.title,
planId: newPlanId,
});
} else {
const plans = await searchDebugPlans(newPlanId, 5);
const fallback = adaptMissions(plans as unknown as Record<string, unknown>[]).find((item) => item.id === newPlanId);
if (fallback) {
focusMission(fallback);
setMissionNotice({
kind: 'dispatched',
missionId: fallback.id,
title: fallback.title,
planId: newPlanId,
});
}
else setSelected(null);
}
} else {
setSelected((current) => current?.id === mission.id ? null : current);
}
} catch (err) {
console.error('[MissionCenter] dispatch failed:', err);
setError(false);
setActionError(err instanceof Error ? err.message : '任务委托失败');
} finally {
setRunningMissionId(null);
}
}, [demoMode, fetchMissions, focusMission]);
const restartMission = useCallback(async (mission: Mission) => {
setRunningMissionId(mission.id);
setError(false);
setActionError(null);
if (demoMode || !mission.sourceIds.planId) {
await runMission(mission);
return;
}
try {
const newPlanId = await createRestartPlan(mission);
const nextMissions = await fetchMissions();
setFilter('all');
const selectedMission = nextMissions.find((item) => item.id === newPlanId || item.sourceIds.planId === newPlanId);
if (selectedMission) {
focusMission(selectedMission);
setMissionNotice({
kind: 'created',
missionId: selectedMission.id,
title: selectedMission.title,
planId: newPlanId,
});
} else {
setRecentMissionId(newPlanId);
setMissionNotice({
kind: 'created',
missionId: newPlanId,
title: restartMissionTitle(mission.title),
planId: newPlanId,
});
}
} catch (err) {
console.error('[MissionCenter] restart failed:', err);
setError(false);
setActionError(err instanceof Error ? err.message : '重新执行失败');
} finally {
setRunningMissionId(null);
}
}, [demoMode, fetchMissions, focusMission, runMission]);
const selectedDate = dateFilter || todayDateKey();
const filteredByDate = dedupeMissionsForDate(
showAllDates ? missions : missions.filter((mission) => missionMatchesDate(mission, selectedDate)),
selectedDate,
);
const runnableMissions = filteredByDate.filter((mission) => (
mission.sourceIds.planId
&& ['draft', 'reviewing', 'approved'].includes(mission.status.toLowerCase())
));
const runAllMissions = useCallback(async () => {
if (runnableMissions.length === 0) return;
setRunningAll(true);
setError(false);
if (demoMode) {
let delay = 0;
for (const mission of runnableMissions.slice(0, 4)) {
window.setTimeout(() => {
if (!mountedRef.current) return;
setRunningMissionId(mission.id);
setMissions((current) => runDemoMissionOnce(current, mission.id));
}, delay);
delay += 400;
window.setTimeout(() => {
if (!mountedRef.current) return;
setMissions((current) => completeDemoMissionOnce(current, mission.id));
}, delay + 900);
}
window.setTimeout(() => {
if (!mountedRef.current) return;
setRunningMissionId(null);
setRunningAll(false);
}, delay + 1200);
return;
}
try {
for (const mission of runnableMissions) {
setRunningMissionId(mission.id);
await dispatchMissionPlan(mission);
}
await fetchMissions();
setSelected(null);
} catch (err) {
console.error('[MissionCenter] batch dispatch failed:', err);
setError(false);
setActionError(err instanceof Error ? err.message : '批量委托失败');
} finally {
setRunningMissionId(null);
setRunningAll(false);
}
}, [demoMode, fetchMissions, runnableMissions]);
const filtered = filter === 'all'
? filteredByDate
: filteredByDate.filter(m => missionDelegationGroup(m) === filter);
const displayMissions = filtered
.map((mission, index) => ({ mission, index }))
.sort((a, b) => {
const priority = (mission: Mission) => (
(mission.id === recentMissionId ? 2 : 0)
+ (mission.id === selected?.id ? 1 : 0)
);
return priority(b.mission) - priority(a.mission)
|| missionDateSortValue(a.mission, selectedDate) - missionDateSortValue(b.mission, selectedDate)
|| a.index - b.index;
})
.map(({ mission }) => mission);
const noticeTitle = missionNotice?.kind === 'created'
? '刚创建重新执行任务'
: missionNotice?.kind === 'dispatched'
? '刚委托执行任务'
: '当前查看任务';
const focusLabelForMission = (mission: Mission): string | null => {
if (mission.id !== recentMissionId) return null;
if (missionNotice?.missionId === mission.id && missionNotice.kind === 'created') return '刚创建';
if (missionNotice?.missionId === mission.id && missionNotice.kind === 'dispatched') return '刚委托';
return '当前查看';
};
return (
<div className="de-mission-center-page">
<div className="de-mission-center-layout">
<div className="de-mc-list">
<GlassCard>
<CardTitleBar title="任务委托">
<button
onClick={runAllMissions}
className="de-btn-primary de-btn-sm"
disabled={runningAll || runnableMissions.length === 0}
>
{runningAll ? '委托中...' : `委托可执行任务(${runnableMissions.length})`}
</button>
<button onClick={fetchMissions} className="de-btn-secondary de-btn-sm">{t('console.action.refresh')}</button>
</CardTitleBar>
<div className="de-card-body">
{demoMode && (
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 12 }}>
qimingclaw
</div>
)}
{actionError && (
<div className="de-schedule-result de-schedule-result--error" style={{ marginBottom: 12 }}>
{actionError}
</div>
)}
{missionNotice && (
<div className="de-mission-focus-notice">
<strong>{noticeTitle}</strong>
<span>{missionNotice.title}</span>
{missionNotice.planId && <code>{missionNotice.planId}</code>}
</div>
)}
<div className="de-date-filter-row">
<label className="de-date-filter-field">
<span></span>
<input
type="date"
value={selectedDate}
onChange={(event) => {
setShowAllDates(false);
setDateFilter(event.target.value || todayDateKey());
}}
/>
</label>
<div className="de-date-filter-actions" aria-label="日期切换">
<button type="button" className="de-filter-chip" onClick={() => { setShowAllDates(false); setDateFilter(shiftDateKey(selectedDate, -1)); }}></button>
<button type="button" className="de-filter-chip active" onClick={() => { setShowAllDates(false); setDateFilter(todayDateKey()); }}></button>
<button type="button" className="de-filter-chip" onClick={() => { setShowAllDates(false); setDateFilter(shiftDateKey(selectedDate, 1)); }}></button>
</div>
<span className="de-date-filter-summary">
{showAllDates ? `全部 ${filteredByDate.length} 项任务` : `${filteredByDate.length} 项任务`}
</span>
</div>
<div className="de-filter-row">
{DELEGATION_FILTERS.map((item) => (
<button
key={item.key}
className={`de-filter-chip ${filter === item.key ? 'active' : ''}`}
onClick={() => setFilter(item.key)}
>
{item.label}
</button>
))}
</div>
{loading ? (
<div className="de-loading-skeleton" style={{ height: 300 }} />
) : error ? (
<p className="de-empty-text">{t('console.state.backendDown')}</p>
) : displayMissions.length === 0 ? (
<div className="de-mission-empty-panel">
<strong></strong>
<p> qimingclaw </p>
<div className="de-mission-empty-actions">
<button type="button" className="de-btn-primary de-btn-sm" onClick={() => { setShowAllDates(true); setFilter('all'); }}>
</button>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => { setShowAllDates(false); setDateFilter(focusDate || todayDateKey()); }}>
</button>
</div>
</div>
) : (
<div className="de-mc-card-list">
{displayMissions.map(m => (
<MissionCard
key={m.id}
mission={m}
selected={selected?.id === m.id}
focusLabel={focusLabelForMission(m)}
dateFilter={selectedDate}
running={runningMissionId === m.id}
onSelect={selectMission}
onRun={runMission}
onRestart={restartMission}
/>
))}
</div>
)}
</div>
</GlassCard>
</div>
<div className="de-mc-detail" ref={detailRef}>
{selected ? (
<FlowDetailPanel mission={selected} onClose={() => setSelected(null)} />
) : (
<GlassCard>
<div className="de-card-body">
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
<strong></strong>
<p> qimingclaw Plan / Task / Run / Event payload 线</p>
</div>
</div>
</GlassCard>
)}
</div>
</div>
</div>
);
}