feat(client): project digital employee runtime into UI
This commit is contained in:
@@ -613,10 +613,6 @@ export function postDigitalEmployeeWorkdayAction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function digitalEmployeeReportPdfUrl(date: string): string {
|
|
||||||
return `${apiOrigin()}${apiBase}/api/digital-employee/reports/${encodeURIComponent(date)}/pdf`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
|
export function dispatchPlanNow(planId: string): Promise<PlanDispatchResponse> {
|
||||||
return apiFetch<PlanDispatchResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/dispatch`, {
|
return apiFetch<PlanDispatchResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/dispatch`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -432,6 +432,18 @@ function categoryFor(skillId: string): string {
|
|||||||
return '综合';
|
return '综合';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function capabilityLevelFor(value: unknown): SkillSummary['capabilityLevel'] {
|
||||||
|
if (
|
||||||
|
value === 'core' ||
|
||||||
|
value === 'standard' ||
|
||||||
|
value === 'specialized' ||
|
||||||
|
value === 'experimental'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'standard';
|
||||||
|
}
|
||||||
|
|
||||||
function zhDescription(skillId: string, rawDescription: unknown, name: string): string {
|
function zhDescription(skillId: string, rawDescription: unknown, name: string): string {
|
||||||
const description = str(rawDescription).trim();
|
const description = str(rawDescription).trim();
|
||||||
if (description && hasChinese(description)) return description;
|
if (description && hasChinese(description)) return description;
|
||||||
@@ -450,8 +462,8 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
|
|||||||
version: s.version ? str(s.version) : null,
|
version: s.version ? str(s.version) : null,
|
||||||
enabled: Boolean(s.enabled),
|
enabled: Boolean(s.enabled),
|
||||||
description: zhDescription(sid, s.description, zhName(sid, str(s.name, sid))),
|
description: zhDescription(sid, s.description, zhName(sid, str(s.name, sid))),
|
||||||
category: categoryFor(sid),
|
category: s.category ? str(s.category, categoryFor(sid)) : categoryFor(sid),
|
||||||
capabilityLevel: 'standard' as const,
|
capabilityLevel: capabilityLevelFor(s.capability_level ?? s.capabilityLevel),
|
||||||
source: 'skill-api' as const,
|
source: 'skill-api' as const,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import { Clipboard, Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-
|
|||||||
import Avatar3D from '@/components/digital/Avatar3D';
|
import Avatar3D from '@/components/digital/Avatar3D';
|
||||||
import { basePath } from '@/lib/basePath';
|
import { basePath } from '@/lib/basePath';
|
||||||
import {
|
import {
|
||||||
digitalEmployeeReportPdfUrl,
|
|
||||||
dispatchPlanNow,
|
dispatchPlanNow,
|
||||||
getDigitalEmployeeWorkday,
|
getDigitalEmployeeWorkday,
|
||||||
getSchedulerJobs,
|
getSchedulerJobs,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
import { isTauri } from '@/lib/tauri';
|
import { isTauri } from '@/lib/tauri';
|
||||||
import type {
|
import type {
|
||||||
CronJob,
|
CronJob,
|
||||||
|
DigitalEmployeeDailyReport,
|
||||||
DigitalEmployeeDecision,
|
DigitalEmployeeDecision,
|
||||||
DigitalEmployeeDuty,
|
DigitalEmployeeDuty,
|
||||||
DigitalEmployeeRunDetail,
|
DigitalEmployeeRunDetail,
|
||||||
@@ -590,8 +590,8 @@ function syncFailureDiagnosticFileName(failure: ManagementSyncFailure): string {
|
|||||||
return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`;
|
return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadTextFile(filename: string, content: string): void {
|
function downloadTextFile(filename: string, content: string, mimeType = 'application/json;charset=utf-8'): void {
|
||||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
const blob = new Blob([content], { type: mimeType });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -602,6 +602,47 @@ function downloadTextFile(filename: string, content: string): void {
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dailyReportFileName(date: string): string {
|
||||||
|
return `qimingclaw-digital-report-${date}.txt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDailyReportDownloadText(
|
||||||
|
dateLabel: string,
|
||||||
|
report: DigitalEmployeeDailyReport,
|
||||||
|
summaries: DigitalEmployeeTaskExecutionSummary[],
|
||||||
|
details: DigitalEmployeeRunDetail[],
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
report.title || `${dateLabel} 数字员工日报`,
|
||||||
|
`日期:${dateLabel}`,
|
||||||
|
`生成时间:${report.generated_at ? formatBeijingDateTime(report.generated_at) : '暂未生成'}`,
|
||||||
|
`生成来源:${report.generated_by === 'ai' ? 'AI' : 'qimingclaw adapter'}`,
|
||||||
|
'',
|
||||||
|
'## 摘要',
|
||||||
|
report.summary || '暂无摘要。',
|
||||||
|
'',
|
||||||
|
'## 指标',
|
||||||
|
`任务总数:${report.totals.task_count}`,
|
||||||
|
`执行次数:${report.totals.execution_count}`,
|
||||||
|
`成功次数:${report.totals.success_count}`,
|
||||||
|
`失败次数:${report.totals.failure_count}`,
|
||||||
|
`运行中:${report.totals.running_count ?? 0}`,
|
||||||
|
'',
|
||||||
|
'## 任务汇总',
|
||||||
|
...(summaries.length > 0
|
||||||
|
? summaries.map((summary) => `- ${summary.title}:${summary.latest_status_label},执行 ${summary.total_count} 次,成功 ${summary.success_count},失败 ${summary.failure_count}。${summary.ai_summary || summary.business_summary || ''}`)
|
||||||
|
: ['暂无任务汇总。']),
|
||||||
|
'',
|
||||||
|
'## 运行明细',
|
||||||
|
...(details.length > 0
|
||||||
|
? details.map((detail) => `- ${detail.time || '--:--'} ${detail.task_name} / ${detail.step_name || detail.operation_name || '任务执行'}:${detail.status_label},${detail.business_detail}`)
|
||||||
|
: ['暂无运行明细。']),
|
||||||
|
'',
|
||||||
|
'## 详情',
|
||||||
|
...((report.detail_lines ?? []).length > 0 ? report.detail_lines : ['暂无详情。']),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
|
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
|
||||||
const bridge = window.QimingClawBridge?.digital;
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
if (!bridge?.getSyncStatus) return null;
|
if (!bridge?.getSyncStatus) return null;
|
||||||
@@ -734,8 +775,6 @@ function resolveEmployeeVisualState(args: {
|
|||||||
return 'skill';
|
return 'skill';
|
||||||
}
|
}
|
||||||
|
|
||||||
const DECISION_CHAIN_TODO_TEXT = '真实审批链路待接入';
|
|
||||||
|
|
||||||
function readGuideDismissedDate(): string | null {
|
function readGuideDismissedDate(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
try {
|
try {
|
||||||
@@ -1207,7 +1246,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
: await getDigitalEmployeeWorkday(selectedDate);
|
: await getDigitalEmployeeWorkday(selectedDate);
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setWorkdayProjection(latestProjection);
|
setWorkdayProjection(latestProjection);
|
||||||
setActionNotice('日报已生成,可下载 PDF。');
|
setActionNotice('日报已生成,可下载本地文本。');
|
||||||
if (latestProjection.daily_report?.status === 'ready') {
|
if (latestProjection.daily_report?.status === 'ready') {
|
||||||
setReportPreviewOpen(true);
|
setReportPreviewOpen(true);
|
||||||
}
|
}
|
||||||
@@ -1220,18 +1259,31 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
}
|
}
|
||||||
}, [reportScheduleTime, selectedDate, selectedDutyIds, selectedSubmissionDutyIds]);
|
}, [reportScheduleTime, selectedDate, selectedDutyIds, selectedSubmissionDutyIds]);
|
||||||
|
|
||||||
const downloadDailyReportPdf = useCallback(() => {
|
const downloadDailyReport = useCallback(() => {
|
||||||
window.open(digitalEmployeeReportPdfUrl(selectedDate), '_blank', 'noopener,noreferrer');
|
if (!dailyReport || dailyReport.status !== 'ready') return;
|
||||||
}, [selectedDate]);
|
downloadTextFile(
|
||||||
|
dailyReportFileName(selectedDate),
|
||||||
|
buildDailyReportDownloadText(selectedDateLabel, dailyReport, taskSummaryCards, visibleRunDetails),
|
||||||
|
'text/plain;charset=utf-8',
|
||||||
|
);
|
||||||
|
setActionNotice('日报文本已下载。');
|
||||||
|
}, [dailyReport, selectedDate, selectedDateLabel, taskSummaryCards, visibleRunDetails]);
|
||||||
|
|
||||||
const resolveDecision = useCallback(async (decision: DigitalEmployeeDecision, actionId: string) => {
|
const resolveDecision = useCallback(async (decision: DigitalEmployeeDecision, actionId: string) => {
|
||||||
setActionBusy(`${decision.id}:${actionId}`);
|
setActionBusy(`${decision.id}:${actionId}`);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
setActionNotice(null);
|
setActionNotice(null);
|
||||||
try {
|
try {
|
||||||
|
const projection = await postDigitalEmployeeWorkdayAction({
|
||||||
|
action: 'decide_approval',
|
||||||
|
date: selectedDate,
|
||||||
|
decision_id: decision.id,
|
||||||
|
decision: actionId,
|
||||||
|
});
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
const actionLabel = decision.actions.find((action) => action.id === actionId)?.label ?? actionId;
|
const actionLabel = decision.actions.find((action) => action.id === actionId)?.label ?? actionId;
|
||||||
setActionNotice(`待实现:已触发“${actionLabel}”演示效果,${DECISION_CHAIN_TODO_TEXT},未改动后端审批或执行状态。`);
|
setWorkdayProjection(projection);
|
||||||
|
setActionNotice(`已记录“${actionLabel}”处理结果,并写入 qimingclaw 本地运行状态。`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
setActionError(error instanceof Error ? error.message : '处理决策失败');
|
setActionError(error instanceof Error ? error.message : '处理决策失败');
|
||||||
@@ -1239,7 +1291,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
} finally {
|
} finally {
|
||||||
if (mountedRef.current) setActionBusy(null);
|
if (mountedRef.current) setActionBusy(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [selectedDate]);
|
||||||
|
|
||||||
const openDuty = useCallback((planItem: DigitalPlanItem) => {
|
const openDuty = useCallback((planItem: DigitalPlanItem) => {
|
||||||
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
|
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
|
||||||
@@ -1804,8 +1856,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
type="button"
|
type="button"
|
||||||
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
||||||
disabled={!dailyReport?.pdf_available}
|
disabled={!dailyReport?.pdf_available}
|
||||||
onClick={downloadDailyReportPdf}
|
onClick={downloadDailyReport}
|
||||||
title={dailyReport?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
|
title={dailyReport?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
|
||||||
>
|
>
|
||||||
<Download size={14} aria-hidden="true" />
|
<Download size={14} aria-hidden="true" />
|
||||||
下载日报
|
下载日报
|
||||||
@@ -1859,7 +1911,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
type="button"
|
type="button"
|
||||||
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
||||||
disabled={!dailyReport.pdf_available}
|
disabled={!dailyReport.pdf_available}
|
||||||
onClick={downloadDailyReportPdf}
|
onClick={downloadDailyReport}
|
||||||
>
|
>
|
||||||
<Download size={14} aria-hidden="true" />
|
<Download size={14} aria-hidden="true" />
|
||||||
下载日报
|
下载日报
|
||||||
@@ -2392,8 +2444,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
</div>
|
</div>
|
||||||
<p>{decision.scenario}</p>
|
<p>{decision.scenario}</p>
|
||||||
<div className="de-decision-chain-note">
|
<div className="de-decision-chain-note">
|
||||||
<span className="de-badge de-badge-warning">待实现</span>
|
<span className="de-badge de-badge-success">本地记录</span>
|
||||||
<span>{DECISION_CHAIN_TODO_TEXT}</span>
|
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="de-workbench-actions">
|
<div className="de-workbench-actions">
|
||||||
{decision.actions.map((action) => (
|
{decision.actions.map((action) => (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Download } from 'lucide-react';
|
|||||||
import GlassCard from '@/components/digital/GlassCard';
|
import GlassCard from '@/components/digital/GlassCard';
|
||||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||||
import {
|
import {
|
||||||
digitalEmployeeReportPdfUrl,
|
|
||||||
getDigitalEmployeeWorkday,
|
getDigitalEmployeeWorkday,
|
||||||
postDigitalEmployeeWorkdayAction,
|
postDigitalEmployeeWorkdayAction,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -74,6 +73,59 @@ function formatGeneratedAt(value?: string | null): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadTextFile(filename: string, content: string): void {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFileName(date: string): string {
|
||||||
|
return `qimingclaw-digital-report-${date}.txt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReportDownloadText(
|
||||||
|
date: string,
|
||||||
|
report: DigitalEmployeeDailyReport,
|
||||||
|
sections: DigitalEmployeeTaskExecutionSummary[],
|
||||||
|
details: DigitalEmployeeRunDetail[],
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
report.title || `${date} 数字员工日报`,
|
||||||
|
`日期:${date}`,
|
||||||
|
`生成时间:${formatGeneratedAt(report.generated_at)}`,
|
||||||
|
`生成来源:${report.generated_by === 'ai' ? 'AI' : 'qimingclaw adapter'}`,
|
||||||
|
'',
|
||||||
|
'## 摘要',
|
||||||
|
report.summary || '暂无摘要。',
|
||||||
|
'',
|
||||||
|
'## 指标',
|
||||||
|
`任务总数:${report.totals.task_count}`,
|
||||||
|
`执行次数:${report.totals.execution_count}`,
|
||||||
|
`成功次数:${report.totals.success_count}`,
|
||||||
|
`失败次数:${report.totals.failure_count}`,
|
||||||
|
`运行中:${report.totals.running_count ?? 0}`,
|
||||||
|
'',
|
||||||
|
'## 任务汇总',
|
||||||
|
...(sections.length > 0
|
||||||
|
? sections.map((section) => `- ${section.title}:${section.latest_status_label},执行 ${section.total_count} 次,成功 ${section.success_count},失败 ${section.failure_count}。${section.ai_summary || section.business_summary || ''}`)
|
||||||
|
: ['暂无任务汇总。']),
|
||||||
|
'',
|
||||||
|
'## 运行明细',
|
||||||
|
...(details.length > 0
|
||||||
|
? details.map((detail) => `- ${detail.time || '--:--'} ${detail.task_name} / ${detail.step_name || detail.operation_name || '任务执行'}:${detail.status_label},${detail.business_detail}`)
|
||||||
|
: ['暂无运行明细。']),
|
||||||
|
'',
|
||||||
|
'## 详情',
|
||||||
|
...((report.detail_lines ?? []).length > 0 ? report.detail_lines : ['暂无详情。']),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function TaskSectionList({ sections }: { sections: DigitalEmployeeTaskExecutionSummary[] }) {
|
function TaskSectionList({ sections }: { sections: DigitalEmployeeTaskExecutionSummary[] }) {
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>今天还没有可汇总的任务。</p>;
|
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>今天还没有可汇总的任务。</p>;
|
||||||
@@ -209,7 +261,7 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
|||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setProjection(data);
|
setProjection(data);
|
||||||
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
|
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
|
||||||
setMessage('日报已生成,可下载 PDF。');
|
setMessage('日报已生成,可下载本地文本。');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
setMessage(error instanceof Error ? error.message : '日报生成失败');
|
setMessage(error instanceof Error ? error.message : '日报生成失败');
|
||||||
@@ -218,9 +270,10 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
|||||||
}
|
}
|
||||||
}, [isLiveProjection, projection, reportScheduleTime, today]);
|
}, [isLiveProjection, projection, reportScheduleTime, today]);
|
||||||
|
|
||||||
const downloadDailyReportPdf = useCallback(() => {
|
const downloadDailyReport = useCallback(() => {
|
||||||
window.open(digitalEmployeeReportPdfUrl(today), '_blank', 'noopener,noreferrer');
|
if (!report || report.status !== 'ready') return;
|
||||||
}, [today]);
|
downloadTextFile(reportFileName(today), buildReportDownloadText(today, report, taskSections, runDetails));
|
||||||
|
}, [report, runDetails, taskSections, today]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '14px 18px' }}>
|
<div style={{ padding: '14px 18px' }}>
|
||||||
@@ -271,8 +324,8 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
|||||||
type="button"
|
type="button"
|
||||||
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
className="de-btn-secondary de-btn-sm de-report-download-btn"
|
||||||
disabled={!report?.pdf_available}
|
disabled={!report?.pdf_available}
|
||||||
onClick={downloadDailyReportPdf}
|
onClick={downloadDailyReport}
|
||||||
title={report?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
|
title={report?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
|
||||||
>
|
>
|
||||||
<Download size={14} aria-hidden="true" />
|
<Download size={14} aria-hidden="true" />
|
||||||
下载日报
|
下载日报
|
||||||
|
|||||||
@@ -15,18 +15,9 @@ import {
|
|||||||
import { domSafeId } from './navigation';
|
import { domSafeId } from './navigation';
|
||||||
|
|
||||||
const REQUIRED_MISSION_IDS = [
|
const REQUIRED_MISSION_IDS = [
|
||||||
'whitecollar-day',
|
'qimingclaw-client-readiness',
|
||||||
'whitecollar-day-0830-browser-portal-check',
|
'qimingclaw-remote-task-ingress',
|
||||||
'whitecollar-day-0900-browser-inbox-triage',
|
'qimingclaw-daily-report',
|
||||||
'whitecollar-day-0930-calendar-brief',
|
|
||||||
'whitecollar-day-1030-browser-crm-followup',
|
|
||||||
'whitecollar-day-1130-browser-expense-approval',
|
|
||||||
'whitecollar-day-1330-sales-snapshot',
|
|
||||||
'whitecollar-day-1430-report-export',
|
|
||||||
'whitecollar-day-1530-browser-knowledge-check',
|
|
||||||
'whitecollar-day-1630-task-board-sync',
|
|
||||||
'whitecollar-day-1730-browser-day-close',
|
|
||||||
'zhihu-hotlist-monitor',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const DELEGATION_FILTERS = [
|
const DELEGATION_FILTERS = [
|
||||||
@@ -332,34 +323,7 @@ export default function MissionCenter({
|
|||||||
.sort((a, b) => planSortTime(b) - planSortTime(a));
|
.sort((a, b) => planSortTime(b) - planSortTime(a));
|
||||||
const orderedPlans = [...recentPlans, ...requiredPlans];
|
const orderedPlans = [...recentPlans, ...requiredPlans];
|
||||||
if (orderedPlans.length > 0) {
|
if (orderedPlans.length > 0) {
|
||||||
const existingIds = new Set(orderedPlans.map((plan) => typeof plan.plan_id === 'string' ? plan.plan_id : ''));
|
const nextMissions = adaptMissions(orderedPlans);
|
||||||
const demoSupplement = getDemoMissions()
|
|
||||||
.filter((mission) => !existingIds.has(mission.id))
|
|
||||||
.map((mission) => ({
|
|
||||||
plan_id: mission.id,
|
|
||||||
title: mission.title,
|
|
||||||
objective: mission.objective,
|
|
||||||
status: mission.status,
|
|
||||||
created_at: mission.createdAt || new Date().toISOString(),
|
|
||||||
updated_at: mission.updatedAt || mission.createdAt || new Date().toISOString(),
|
|
||||||
steps: mission.steps.map((step) => ({
|
|
||||||
step_id: step.id,
|
|
||||||
title: step.title,
|
|
||||||
description: step.description,
|
|
||||||
status: step.status,
|
|
||||||
preferred_skills: step.preferredSkills,
|
|
||||||
tasks: step.tasks.map((task) => ({
|
|
||||||
task_id: task.id,
|
|
||||||
title: task.title,
|
|
||||||
description: task.description,
|
|
||||||
status: task.status,
|
|
||||||
assigned_skill_id: task.assignedSkillId,
|
|
||||||
started_at: task.startedAt,
|
|
||||||
finished_at: task.finishedAt,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
const nextMissions = adaptMissions([...orderedPlans, ...demoSupplement]);
|
|
||||||
setDemoMode(false);
|
setDemoMode(false);
|
||||||
setMissions(nextMissions);
|
setMissions(nextMissions);
|
||||||
return nextMissions;
|
return nextMissions;
|
||||||
@@ -625,7 +589,7 @@ export default function MissionCenter({
|
|||||||
<div className="de-card-body">
|
<div className="de-card-body">
|
||||||
{demoMode && (
|
{demoMode && (
|
||||||
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 12 }}>
|
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 12 }}>
|
||||||
当前展示为客户演示场景。真实任务未加载时,页面会自动使用“一天工作流”示例数据。
|
当前没有加载到 qimingclaw 任务记录,页面暂时展示本地示例数据。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{actionError && (
|
{actionError && (
|
||||||
@@ -679,13 +643,13 @@ export default function MissionCenter({
|
|||||||
) : displayMissions.length === 0 ? (
|
) : displayMissions.length === 0 ? (
|
||||||
<div className="de-mission-empty-panel">
|
<div className="de-mission-empty-panel">
|
||||||
<strong>当前日期暂无任务</strong>
|
<strong>当前日期暂无任务</strong>
|
||||||
<p>这个日期没有匹配到任务记录,演示时可以切回演示日期,或临时查看全部任务。</p>
|
<p>这个日期没有匹配到 qimingclaw 任务记录,可以临时查看全部任务。</p>
|
||||||
<div className="de-mission-empty-actions">
|
<div className="de-mission-empty-actions">
|
||||||
<button type="button" className="de-btn-primary de-btn-sm" onClick={() => { setShowAllDates(true); setFilter('all'); }}>
|
<button type="button" className="de-btn-primary de-btn-sm" onClick={() => { setShowAllDates(true); setFilter('all'); }}>
|
||||||
查看全部任务
|
查看全部任务
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => { setShowAllDates(false); setDateFilter(focusDate || todayDateKey()); }}>
|
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => { setShowAllDates(false); setDateFilter(focusDate || todayDateKey()); }}>
|
||||||
返回演示日期
|
返回当前日期
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -717,7 +681,7 @@ export default function MissionCenter({
|
|||||||
<div className="de-card-body">
|
<div className="de-card-body">
|
||||||
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
|
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
|
||||||
<strong>选择一个任务后,右侧会显示执行步骤、运行记录和交付结果。</strong>
|
<strong>选择一个任务后,右侧会显示执行步骤、运行记录和交付结果。</strong>
|
||||||
<p>后天演示建议先从左侧选择“自动外呼现场人员”或“接收约时工单”,让领导看到数字员工不是静态页面,而是在按流程接活、执行、反馈。</p>
|
<p>任务详情会优先展示 qimingclaw 本地 Plan / Task / Run / Event 记录,以及从执行 payload 中整理出的产物线索。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,7 +13,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-Dfb8OO8K.js"></script>
|
<script type="module" crossorigin src="./assets/index-D4dFc9EL.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import type { HandlerContext } from "@shared/types/ipc";
|
|||||||
import { FEATURES } from "@shared/featureFlags";
|
import { FEATURES } from "@shared/featureFlags";
|
||||||
import { agentService } from "../services/engines/unifiedAgent";
|
import { agentService } from "../services/engines/unifiedAgent";
|
||||||
import { mcpProxyManager } from "../services/packages/mcp";
|
import { mcpProxyManager } from "../services/packages/mcp";
|
||||||
|
import type { McpServerEntry } from "../services/packages/mcp";
|
||||||
import { getComputerServerStatus } from "../services/computerServer";
|
import { getComputerServerStatus } from "../services/computerServer";
|
||||||
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
|
import { getGuiAgentServerStatus } from "../services/packages/guiAgentServer";
|
||||||
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
|
import { getWindowsMcpStatus } from "../services/packages/windowsMcp";
|
||||||
import { isWindows } from "../services/system/shellEnv";
|
import { isWindows } from "../services/system/shellEnv";
|
||||||
import {
|
import {
|
||||||
recordDigitalEmployeeSnapshot,
|
recordDigitalEmployeeSnapshot,
|
||||||
|
readDigitalEmployeeRuntimeRecords,
|
||||||
readDigitalEmployeeState,
|
readDigitalEmployeeState,
|
||||||
|
readDigitalEmployeeUiState,
|
||||||
|
saveDigitalEmployeeUiState,
|
||||||
type DigitalEmployeeServiceStatus,
|
type DigitalEmployeeServiceStatus,
|
||||||
type DigitalEmployeeSnapshot,
|
type DigitalEmployeeSnapshot,
|
||||||
|
type DigitalEmployeeUiStateUpdate,
|
||||||
} from "../services/digitalEmployee/stateService";
|
} from "../services/digitalEmployee/stateService";
|
||||||
import {
|
import {
|
||||||
flushDigitalEmployeeSyncOutbox,
|
flushDigitalEmployeeSyncOutbox,
|
||||||
@@ -35,6 +40,25 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
return readDigitalEmployeeState();
|
return readDigitalEmployeeState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:getRuntimeRecords", async () => {
|
||||||
|
return readDigitalEmployeeRuntimeRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:getUiState", async () => {
|
||||||
|
return readDigitalEmployeeUiState();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("digitalEmployee:getSkillCapabilities", async () => {
|
||||||
|
return buildSkillCapabilities();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"digitalEmployee:saveUiState",
|
||||||
|
async (_, update: DigitalEmployeeUiStateUpdate) => {
|
||||||
|
return saveDigitalEmployeeUiState(update);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ipcMain.handle("digitalEmployee:getSyncStatus", async () => {
|
ipcMain.handle("digitalEmployee:getSyncStatus", async () => {
|
||||||
return getDigitalEmployeeSyncStatus();
|
return getDigitalEmployeeSyncStatus();
|
||||||
});
|
});
|
||||||
@@ -44,6 +68,135 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DigitalEmployeeSkillCapability {
|
||||||
|
skill_id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
capability_level: "core" | "standard" | "specialized" | "experimental";
|
||||||
|
source: "qimingclaw-mcp";
|
||||||
|
server_id: string;
|
||||||
|
transport: "stdio" | "remote";
|
||||||
|
running: boolean;
|
||||||
|
tools?: string[];
|
||||||
|
tool_count?: number;
|
||||||
|
allow_tools?: string[];
|
||||||
|
deny_tools?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type McpServerEntryWithRuntimeFields = McpServerEntry & {
|
||||||
|
enabled?: boolean;
|
||||||
|
discoveredTools?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildSkillCapabilities(): {
|
||||||
|
generatedAt: string;
|
||||||
|
skills: DigitalEmployeeSkillCapability[];
|
||||||
|
} {
|
||||||
|
const status = mcpProxyManager.getStatus();
|
||||||
|
const config = mcpProxyManager.getConfig();
|
||||||
|
const skills: DigitalEmployeeSkillCapability[] = [];
|
||||||
|
|
||||||
|
for (const [serverId, rawEntry] of Object.entries(config.mcpServers || {})) {
|
||||||
|
const entry = rawEntry as McpServerEntryWithRuntimeFields;
|
||||||
|
const enabled = entry.enabled !== false;
|
||||||
|
const transport = "url" in entry ? "remote" : "stdio";
|
||||||
|
const discoveredTools = normalizeToolList(entry.discoveredTools);
|
||||||
|
const allowedTools = normalizeToolList(entry.allowTools);
|
||||||
|
const deniedTools = normalizeToolList(entry.denyTools);
|
||||||
|
const effectiveTools = chooseEffectiveTools(
|
||||||
|
discoveredTools,
|
||||||
|
allowedTools,
|
||||||
|
deniedTools,
|
||||||
|
);
|
||||||
|
|
||||||
|
skills.push({
|
||||||
|
skill_id: `qimingclaw-mcp-server-${slugifySkillPart(serverId)}`,
|
||||||
|
name: `MCP 服务:${serverId}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
enabled,
|
||||||
|
description: effectiveTools.length > 0
|
||||||
|
? `来自 qimingclaw MCP 配置,当前可用于数字员工编排的工具数:${effectiveTools.length}。`
|
||||||
|
: "来自 qimingclaw MCP 配置,可作为数字员工的外部工具服务。",
|
||||||
|
category: "MCP",
|
||||||
|
capability_level: "specialized",
|
||||||
|
source: "qimingclaw-mcp",
|
||||||
|
server_id: serverId,
|
||||||
|
transport,
|
||||||
|
running: status.running,
|
||||||
|
tools: effectiveTools,
|
||||||
|
tool_count: effectiveTools.length,
|
||||||
|
...(allowedTools.length > 0 ? { allow_tools: allowedTools } : {}),
|
||||||
|
...(deniedTools.length > 0 ? { deny_tools: deniedTools } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const toolName of effectiveTools.slice(0, 120)) {
|
||||||
|
skills.push({
|
||||||
|
skill_id: `qimingclaw-mcp-tool-${slugifySkillPart(serverId)}-${slugifySkillPart(toolName)}`,
|
||||||
|
name: `MCP 工具:${toolName}`,
|
||||||
|
version: "1.0.0",
|
||||||
|
enabled,
|
||||||
|
description: `由 ${serverId} 提供,可被 qimingclaw Agent 调用并纳入数字员工任务编排。`,
|
||||||
|
category: "MCP 工具",
|
||||||
|
capability_level: "standard",
|
||||||
|
source: "qimingclaw-mcp",
|
||||||
|
server_id: serverId,
|
||||||
|
transport,
|
||||||
|
running: status.running,
|
||||||
|
tools: [toolName],
|
||||||
|
tool_count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
skills,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return [...new Set(value.filter((item): item is string => (
|
||||||
|
typeof item === "string" && item.trim().length > 0
|
||||||
|
)).map((item) => item.trim()))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseEffectiveTools(
|
||||||
|
discoveredTools: string[],
|
||||||
|
allowedTools: string[],
|
||||||
|
deniedTools: string[],
|
||||||
|
): string[] {
|
||||||
|
const baseTools = discoveredTools.length > 0 ? discoveredTools : allowedTools;
|
||||||
|
if (baseTools.length === 0) return [];
|
||||||
|
if (allowedTools.length > 0) {
|
||||||
|
const allowed = new Set(allowedTools);
|
||||||
|
return baseTools.filter((tool) => allowed.has(tool));
|
||||||
|
}
|
||||||
|
if (deniedTools.length > 0) {
|
||||||
|
const denied = new Set(deniedTools);
|
||||||
|
return baseTools.filter((tool) => !denied.has(tool));
|
||||||
|
}
|
||||||
|
return baseTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugifySkillPart(value: string): string {
|
||||||
|
const slug = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
if (slug) return slug;
|
||||||
|
|
||||||
|
let hash = 0;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||||
|
}
|
||||||
|
return `capability-${hash.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
|
function buildSnapshot(ctx: HandlerContext): DigitalEmployeeSnapshot {
|
||||||
const sessions = agentService.listAllSessionsDetailed();
|
const sessions = agentService.listAllSessionsDetailed();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockState = vi.hoisted(() => ({
|
||||||
|
db: null as TestDb | null,
|
||||||
|
settings: new Map<string, unknown>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../db", () => ({
|
||||||
|
getDb: () => mockState.db,
|
||||||
|
readSetting: (key: string) => mockState.settings.get(key) ?? null,
|
||||||
|
writeSetting: (key: string, value: unknown) => mockState.settings.set(key, value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("digital employee state service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.setSystemTime(new Date("2026-06-07T08:00:00.000Z"));
|
||||||
|
mockState.db = new TestDb();
|
||||||
|
mockState.settings = new Map<string, unknown>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts artifacts and approvals from runtime event payload into syncable records", async () => {
|
||||||
|
const { recordDigitalEmployeeRuntimeEvent } = await import("./stateService");
|
||||||
|
|
||||||
|
recordDigitalEmployeeRuntimeEvent({
|
||||||
|
kind: "tool_result",
|
||||||
|
request_id: "req-1",
|
||||||
|
message: "生成交付产物并等待确认",
|
||||||
|
status: "completed",
|
||||||
|
payload: {
|
||||||
|
artifacts: [
|
||||||
|
{
|
||||||
|
id: "summary-csv",
|
||||||
|
name: "summary.csv",
|
||||||
|
uri: "file:///tmp/summary.csv",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputPath: "/tmp/final-report.txt",
|
||||||
|
approval_requests: [
|
||||||
|
{
|
||||||
|
id: "approval-1",
|
||||||
|
title: "确认发送日报给管理端",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Array.from(mockState.db?.artifacts.values() ?? [])).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "computer-chat-run-req-1:artifact:summary-csv",
|
||||||
|
name: "summary.csv",
|
||||||
|
uri: "file:///tmp/summary.csv",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "computer-chat-run-req-1:artifact:tmp-final-report-txt",
|
||||||
|
name: "final-report.txt",
|
||||||
|
uri: "/tmp/final-report.txt",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(Array.from(mockState.db?.approvals.values() ?? [])).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "computer-chat-run-req-1:approval:approval-1",
|
||||||
|
title: "确认发送日报给管理端",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(mockState.db?.outbox.get("artifact:upsert:computer-chat-run-req-1:artifact:summary-csv")).toMatchObject({
|
||||||
|
entity_type: "artifact",
|
||||||
|
entity_id: "computer-chat-run-req-1:artifact:summary-csv",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
expect(mockState.db?.outbox.get("approval:upsert:computer-chat-run-req-1:approval:approval-1")).toMatchObject({
|
||||||
|
entity_type: "approval",
|
||||||
|
entity_id: "computer-chat-run-req-1:approval:approval-1",
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
interface TestArtifactRow {
|
||||||
|
id: string;
|
||||||
|
plan_id: string;
|
||||||
|
task_id: string;
|
||||||
|
run_id: string;
|
||||||
|
kind: string;
|
||||||
|
name: string | null;
|
||||||
|
uri: string | null;
|
||||||
|
payload: string;
|
||||||
|
sync_status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestApprovalRow {
|
||||||
|
id: string;
|
||||||
|
plan_id: string;
|
||||||
|
task_id: string;
|
||||||
|
run_id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
payload: string;
|
||||||
|
sync_status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestOutboxRow {
|
||||||
|
id: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string;
|
||||||
|
operation: string;
|
||||||
|
payload: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestDb {
|
||||||
|
artifacts = new Map<string, TestArtifactRow>();
|
||||||
|
approvals = new Map<string, TestApprovalRow>();
|
||||||
|
outbox = new Map<string, TestOutboxRow>();
|
||||||
|
|
||||||
|
transaction<T>(callback: () => T): () => T {
|
||||||
|
return callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(sql: string): {
|
||||||
|
all: (...args: unknown[]) => unknown[];
|
||||||
|
get: (...args: unknown[]) => unknown;
|
||||||
|
run: (...args: unknown[]) => unknown;
|
||||||
|
} {
|
||||||
|
const normalized = sql.replace(/\s+/g, " ").trim();
|
||||||
|
return {
|
||||||
|
all: () => [],
|
||||||
|
get: () => ({ count: 0 }),
|
||||||
|
run: (...args: unknown[]) => this.run(normalized, args),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(sql: string, args: unknown[]): unknown {
|
||||||
|
if (sql.startsWith("INSERT INTO digital_plans")) return { changes: 1 };
|
||||||
|
if (sql.startsWith("INSERT INTO digital_tasks")) return { changes: 1 };
|
||||||
|
if (sql.startsWith("INSERT INTO digital_runs")) return { changes: 1 };
|
||||||
|
if (sql.startsWith("UPDATE digital_runs")) return { changes: 1 };
|
||||||
|
if (sql.startsWith("INSERT OR IGNORE INTO digital_events")) return { changes: 1 };
|
||||||
|
|
||||||
|
if (sql.startsWith("INSERT INTO digital_artifacts")) {
|
||||||
|
const [id, planId, taskId, runId, kind, name, uri, payload, createdAt] = args as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string | null,
|
||||||
|
string | null,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
this.artifacts.set(id, {
|
||||||
|
id,
|
||||||
|
plan_id: planId,
|
||||||
|
task_id: taskId,
|
||||||
|
run_id: runId,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
uri,
|
||||||
|
payload,
|
||||||
|
sync_status: "pending",
|
||||||
|
created_at: createdAt,
|
||||||
|
});
|
||||||
|
return { changes: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.startsWith("INSERT INTO digital_approvals")) {
|
||||||
|
const [id, planId, taskId, runId, title, status, payload, createdAt, updatedAt] = args as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
this.approvals.set(id, {
|
||||||
|
id,
|
||||||
|
plan_id: planId,
|
||||||
|
task_id: taskId,
|
||||||
|
run_id: runId,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
payload,
|
||||||
|
sync_status: "pending",
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
});
|
||||||
|
return { changes: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.startsWith("INSERT INTO digital_sync_outbox")) {
|
||||||
|
const [id, entityType, entityId, operation, payload, createdAt, updatedAt] = args as [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
this.outbox.set(id, {
|
||||||
|
id,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId,
|
||||||
|
operation,
|
||||||
|
payload,
|
||||||
|
status: "pending",
|
||||||
|
attempts: 0,
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return { changes: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unhandled run query: ${sql}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getDb, readSetting, writeSetting } from "../../db";
|
import { getDb, readSetting, writeSetting } from "../../db";
|
||||||
|
|
||||||
const STATE_KEY = "digital_employee_state_v1";
|
const STATE_KEY = "digital_employee_state_v1";
|
||||||
|
const UI_STATE_KEY = "digital_employee_ui_state_v1";
|
||||||
const MAX_EVENTS = 200;
|
const MAX_EVENTS = 200;
|
||||||
|
|
||||||
export interface DigitalEmployeeServiceStatus {
|
export interface DigitalEmployeeServiceStatus {
|
||||||
@@ -31,6 +32,131 @@ export interface DigitalEmployeeSnapshot {
|
|||||||
sessions: DigitalEmployeeSessionSummary;
|
sessions: DigitalEmployeeSessionSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeePlanRecord {
|
||||||
|
id: string;
|
||||||
|
remoteId?: string | null;
|
||||||
|
title: string;
|
||||||
|
objective?: string | null;
|
||||||
|
status: string;
|
||||||
|
source: string;
|
||||||
|
payload: unknown;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeTaskRecord {
|
||||||
|
id: string;
|
||||||
|
remoteId?: string | null;
|
||||||
|
planId?: string | null;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
assignedSkillId?: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeRunRecord {
|
||||||
|
id: string;
|
||||||
|
remoteId?: string | null;
|
||||||
|
planId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
status: string;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeEventRecord {
|
||||||
|
id: string;
|
||||||
|
remoteId?: string | null;
|
||||||
|
planId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
kind: string;
|
||||||
|
message: string;
|
||||||
|
payload: unknown;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeArtifactRecord {
|
||||||
|
id: string;
|
||||||
|
remoteId?: string | null;
|
||||||
|
planId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
kind: string;
|
||||||
|
name?: string | null;
|
||||||
|
uri?: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeApprovalRecord {
|
||||||
|
id: string;
|
||||||
|
remoteId?: string | null;
|
||||||
|
planId?: string | null;
|
||||||
|
taskId?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
payload: unknown;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncedAt?: string | null;
|
||||||
|
syncError?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeRuntimeRecords {
|
||||||
|
generatedAt: string;
|
||||||
|
plans: DigitalEmployeePlanRecord[];
|
||||||
|
tasks: DigitalEmployeeTaskRecord[];
|
||||||
|
runs: DigitalEmployeeRunRecord[];
|
||||||
|
events: DigitalEmployeeEventRecord[];
|
||||||
|
artifacts: DigitalEmployeeArtifactRecord[];
|
||||||
|
approvals: DigitalEmployeeApprovalRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeUiState {
|
||||||
|
selectedDutyIdsByDate: Record<string, string[]>;
|
||||||
|
confirmedDates: Record<string, string>;
|
||||||
|
reportScheduleTime: string;
|
||||||
|
reportGeneratedAtByDate: Record<string, string>;
|
||||||
|
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||||
|
profile: {
|
||||||
|
name: string;
|
||||||
|
company: string;
|
||||||
|
position: string;
|
||||||
|
currentStatus: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigitalEmployeeUiStateUpdate {
|
||||||
|
state: DigitalEmployeeUiState;
|
||||||
|
action?: string;
|
||||||
|
date?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DigitalEmployeeStateEvent {
|
export interface DigitalEmployeeStateEvent {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -86,6 +212,102 @@ function defaultState(): DigitalEmployeeState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultUiState(): DigitalEmployeeUiState {
|
||||||
|
return {
|
||||||
|
selectedDutyIdsByDate: {},
|
||||||
|
confirmedDates: {},
|
||||||
|
reportScheduleTime: "18:00",
|
||||||
|
reportGeneratedAtByDate: {},
|
||||||
|
decisionResultsByDate: {},
|
||||||
|
profile: {
|
||||||
|
name: "飞天数字员工",
|
||||||
|
company: "qimingclaw 客户端",
|
||||||
|
position: "客户端任务执行员",
|
||||||
|
currentStatus: "working",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringMap(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.filter((entry): entry is [string, string] =>
|
||||||
|
typeof entry[0] === "string" && typeof entry[1] === "string",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArrayMap(value: unknown): Record<string, string[]> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, list]) => [
|
||||||
|
key,
|
||||||
|
Array.isArray(list)
|
||||||
|
? list.filter((item): item is string => typeof item === "string")
|
||||||
|
: [],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNestedStringMap(value: unknown): Record<string, Record<string, string>> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, map]) => [
|
||||||
|
key,
|
||||||
|
normalizeStringMap(map),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUiState(
|
||||||
|
value: Partial<DigitalEmployeeUiState>,
|
||||||
|
): DigitalEmployeeUiState {
|
||||||
|
const fallback = defaultUiState();
|
||||||
|
const profile: Partial<DigitalEmployeeUiState["profile"]> = value.profile || {};
|
||||||
|
return {
|
||||||
|
selectedDutyIdsByDate: normalizeStringArrayMap(value.selectedDutyIdsByDate),
|
||||||
|
confirmedDates: normalizeStringMap(value.confirmedDates),
|
||||||
|
reportScheduleTime: typeof value.reportScheduleTime === "string" && value.reportScheduleTime.trim()
|
||||||
|
? value.reportScheduleTime
|
||||||
|
: fallback.reportScheduleTime,
|
||||||
|
reportGeneratedAtByDate: normalizeStringMap(value.reportGeneratedAtByDate),
|
||||||
|
decisionResultsByDate: normalizeNestedStringMap(value.decisionResultsByDate),
|
||||||
|
profile: {
|
||||||
|
name: typeof profile.name === "string" && profile.name.trim()
|
||||||
|
? profile.name
|
||||||
|
: fallback.profile.name,
|
||||||
|
company: typeof profile.company === "string" && profile.company.trim()
|
||||||
|
? profile.company
|
||||||
|
: fallback.profile.company,
|
||||||
|
position: typeof profile.position === "string" && profile.position.trim()
|
||||||
|
? profile.position
|
||||||
|
: fallback.profile.position,
|
||||||
|
currentStatus: typeof profile.currentStatus === "string" && profile.currentStatus.trim()
|
||||||
|
? profile.currentStatus
|
||||||
|
: fallback.profile.currentStatus,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function digitalEmployeeUiActionMessage(action: string, date?: string): string {
|
||||||
|
const suffix = date ? `(${date})` : "";
|
||||||
|
switch (action) {
|
||||||
|
case "confirm_workday":
|
||||||
|
return `数字员工今日任务已确认${suffix}`;
|
||||||
|
case "update_profile":
|
||||||
|
return "数字员工资料已更新";
|
||||||
|
case "update_report_schedule":
|
||||||
|
return `数字员工日报时间已更新${suffix}`;
|
||||||
|
case "generate_daily_report":
|
||||||
|
return `数字员工日报已生成${suffix}`;
|
||||||
|
case "decide_approval":
|
||||||
|
return `数字员工待决策事项已处理${suffix}`;
|
||||||
|
default:
|
||||||
|
return `数字员工页面状态已更新${suffix}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function readDigitalEmployeeState(): DigitalEmployeeState {
|
export function readDigitalEmployeeState(): DigitalEmployeeState {
|
||||||
const state = readSetting(STATE_KEY);
|
const state = readSetting(STATE_KEY);
|
||||||
const pendingSyncCount = countPendingSyncItems();
|
const pendingSyncCount = countPendingSyncItems();
|
||||||
@@ -104,6 +326,103 @@ export function writeDigitalEmployeeState(state: DigitalEmployeeState): void {
|
|||||||
writeSetting(STATE_KEY, state);
|
writeSetting(STATE_KEY, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readDigitalEmployeeUiState(): DigitalEmployeeUiState {
|
||||||
|
const state = readSetting(UI_STATE_KEY);
|
||||||
|
if (!state || typeof state !== "object") return defaultUiState();
|
||||||
|
return normalizeUiState(state as Partial<DigitalEmployeeUiState>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDigitalEmployeeUiState(
|
||||||
|
update: DigitalEmployeeUiStateUpdate,
|
||||||
|
): DigitalEmployeeUiState {
|
||||||
|
const state = normalizeUiState(update.state);
|
||||||
|
writeSetting(UI_STATE_KEY, state);
|
||||||
|
if (update.action) {
|
||||||
|
recordDigitalEmployeeRuntimeEvent({
|
||||||
|
kind: `digital_workday_${update.action}`,
|
||||||
|
message: digitalEmployeeUiActionMessage(update.action, update.date),
|
||||||
|
status: "completed",
|
||||||
|
payload: {
|
||||||
|
action: update.action,
|
||||||
|
date: update.date,
|
||||||
|
metadata: update.metadata,
|
||||||
|
state,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDigitalEmployeeRuntimeRecords(
|
||||||
|
limit = 120,
|
||||||
|
): DigitalEmployeeRuntimeRecords {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) {
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
plans: [],
|
||||||
|
tasks: [],
|
||||||
|
runs: [],
|
||||||
|
events: [],
|
||||||
|
artifacts: [],
|
||||||
|
approvals: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const safeLimit = Math.max(1, Math.min(Math.floor(limit), 300));
|
||||||
|
const plans = db.prepare(`
|
||||||
|
SELECT id, remote_id, title, objective, status, source, payload, sync_status,
|
||||||
|
last_synced_at, sync_error, created_at, updated_at
|
||||||
|
FROM digital_plans
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
const tasks = db.prepare(`
|
||||||
|
SELECT id, remote_id, plan_id, title, status, assigned_skill_id, payload,
|
||||||
|
sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||||
|
FROM digital_tasks
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
const runs = db.prepare(`
|
||||||
|
SELECT id, remote_id, plan_id, task_id, status, started_at, finished_at,
|
||||||
|
payload, sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||||
|
FROM digital_runs
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT id, remote_id, plan_id, task_id, run_id, kind, message, payload,
|
||||||
|
sync_status, last_synced_at, sync_error, occurred_at, created_at
|
||||||
|
FROM digital_events
|
||||||
|
ORDER BY occurred_at DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
const artifacts = db.prepare(`
|
||||||
|
SELECT id, remote_id, plan_id, task_id, run_id, kind, name, uri, payload,
|
||||||
|
sync_status, last_synced_at, sync_error, created_at
|
||||||
|
FROM digital_artifacts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
const approvals = db.prepare(`
|
||||||
|
SELECT id, remote_id, plan_id, task_id, run_id, title, status, payload,
|
||||||
|
sync_status, last_synced_at, sync_error, created_at, updated_at
|
||||||
|
FROM digital_approvals
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
plans: plans.map(toPlanRecord),
|
||||||
|
tasks: tasks.map(toTaskRecord),
|
||||||
|
runs: runs.map(toRunRecord),
|
||||||
|
events: events.map(toEventRecord),
|
||||||
|
artifacts: artifacts.map(toArtifactRecord),
|
||||||
|
approvals: approvals.map(toApprovalRecord),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function recordDigitalEmployeeSnapshot(
|
export function recordDigitalEmployeeSnapshot(
|
||||||
snapshot: DigitalEmployeeSnapshot,
|
snapshot: DigitalEmployeeSnapshot,
|
||||||
): DigitalEmployeeState {
|
): DigitalEmployeeState {
|
||||||
@@ -161,6 +480,7 @@ export function recordDigitalEmployeeChatReceived(
|
|||||||
payload: { request },
|
payload: { request },
|
||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
|
persistPayloadArtifactsAndApprovals(ids, request, now);
|
||||||
insertEvent(
|
insertEvent(
|
||||||
{
|
{
|
||||||
event_id: `${ids.runId}:received`,
|
event_id: `${ids.runId}:received`,
|
||||||
@@ -222,6 +542,7 @@ export function recordDigitalEmployeeChatResult(
|
|||||||
now,
|
now,
|
||||||
});
|
});
|
||||||
finishRun(ids.runId, status, now);
|
finishRun(ids.runId, status, now);
|
||||||
|
persistPayloadArtifactsAndApprovals(ids, { request, result }, now);
|
||||||
insertEvent(
|
insertEvent(
|
||||||
{
|
{
|
||||||
event_id: `${ids.runId}:${status}`,
|
event_id: `${ids.runId}:${status}`,
|
||||||
@@ -284,6 +605,7 @@ export function recordDigitalEmployeeRuntimeEvent(
|
|||||||
if (status === "completed" || status === "failed") {
|
if (status === "completed" || status === "failed") {
|
||||||
finishRun(ids.runId, status, now);
|
finishRun(ids.runId, status, now);
|
||||||
}
|
}
|
||||||
|
persistPayloadArtifactsAndApprovals(ids, event.payload ?? event, now);
|
||||||
insertEvent(
|
insertEvent(
|
||||||
{
|
{
|
||||||
event_id: `${ids.runId}:${event.kind}:${Date.now()}`,
|
event_id: `${ids.runId}:${event.kind}:${Date.now()}`,
|
||||||
@@ -365,6 +687,138 @@ function stringify(value: unknown): string {
|
|||||||
return JSON.stringify(value ?? {});
|
return JSON.stringify(value ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringField(row: Record<string, unknown>, key: string): string {
|
||||||
|
const value = row[key];
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalStringField(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): string | null {
|
||||||
|
const value = row[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredPayload(value: unknown): unknown {
|
||||||
|
if (typeof value !== "string" || !value.trim()) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlanRecord(row: Record<string, unknown>): DigitalEmployeePlanRecord {
|
||||||
|
return {
|
||||||
|
id: stringField(row, "id"),
|
||||||
|
remoteId: optionalStringField(row, "remote_id"),
|
||||||
|
title: stringField(row, "title"),
|
||||||
|
objective: optionalStringField(row, "objective"),
|
||||||
|
status: stringField(row, "status"),
|
||||||
|
source: stringField(row, "source"),
|
||||||
|
payload: parseStoredPayload(row.payload),
|
||||||
|
syncStatus: stringField(row, "sync_status"),
|
||||||
|
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||||
|
syncError: optionalStringField(row, "sync_error"),
|
||||||
|
createdAt: stringField(row, "created_at"),
|
||||||
|
updatedAt: stringField(row, "updated_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTaskRecord(row: Record<string, unknown>): DigitalEmployeeTaskRecord {
|
||||||
|
return {
|
||||||
|
id: stringField(row, "id"),
|
||||||
|
remoteId: optionalStringField(row, "remote_id"),
|
||||||
|
planId: optionalStringField(row, "plan_id"),
|
||||||
|
title: stringField(row, "title"),
|
||||||
|
status: stringField(row, "status"),
|
||||||
|
assignedSkillId: optionalStringField(row, "assigned_skill_id"),
|
||||||
|
payload: parseStoredPayload(row.payload),
|
||||||
|
syncStatus: stringField(row, "sync_status"),
|
||||||
|
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||||
|
syncError: optionalStringField(row, "sync_error"),
|
||||||
|
createdAt: stringField(row, "created_at"),
|
||||||
|
updatedAt: stringField(row, "updated_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRunRecord(row: Record<string, unknown>): DigitalEmployeeRunRecord {
|
||||||
|
return {
|
||||||
|
id: stringField(row, "id"),
|
||||||
|
remoteId: optionalStringField(row, "remote_id"),
|
||||||
|
planId: optionalStringField(row, "plan_id"),
|
||||||
|
taskId: optionalStringField(row, "task_id"),
|
||||||
|
status: stringField(row, "status"),
|
||||||
|
startedAt: optionalStringField(row, "started_at"),
|
||||||
|
finishedAt: optionalStringField(row, "finished_at"),
|
||||||
|
payload: parseStoredPayload(row.payload),
|
||||||
|
syncStatus: stringField(row, "sync_status"),
|
||||||
|
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||||
|
syncError: optionalStringField(row, "sync_error"),
|
||||||
|
createdAt: stringField(row, "created_at"),
|
||||||
|
updatedAt: stringField(row, "updated_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEventRecord(row: Record<string, unknown>): DigitalEmployeeEventRecord {
|
||||||
|
return {
|
||||||
|
id: stringField(row, "id"),
|
||||||
|
remoteId: optionalStringField(row, "remote_id"),
|
||||||
|
planId: optionalStringField(row, "plan_id"),
|
||||||
|
taskId: optionalStringField(row, "task_id"),
|
||||||
|
runId: optionalStringField(row, "run_id"),
|
||||||
|
kind: stringField(row, "kind"),
|
||||||
|
message: stringField(row, "message"),
|
||||||
|
payload: parseStoredPayload(row.payload),
|
||||||
|
syncStatus: stringField(row, "sync_status"),
|
||||||
|
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||||
|
syncError: optionalStringField(row, "sync_error"),
|
||||||
|
occurredAt: stringField(row, "occurred_at"),
|
||||||
|
createdAt: stringField(row, "created_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArtifactRecord(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
): DigitalEmployeeArtifactRecord {
|
||||||
|
return {
|
||||||
|
id: stringField(row, "id"),
|
||||||
|
remoteId: optionalStringField(row, "remote_id"),
|
||||||
|
planId: optionalStringField(row, "plan_id"),
|
||||||
|
taskId: optionalStringField(row, "task_id"),
|
||||||
|
runId: optionalStringField(row, "run_id"),
|
||||||
|
kind: stringField(row, "kind"),
|
||||||
|
name: optionalStringField(row, "name"),
|
||||||
|
uri: optionalStringField(row, "uri"),
|
||||||
|
payload: parseStoredPayload(row.payload),
|
||||||
|
syncStatus: stringField(row, "sync_status"),
|
||||||
|
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||||
|
syncError: optionalStringField(row, "sync_error"),
|
||||||
|
createdAt: stringField(row, "created_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApprovalRecord(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
): DigitalEmployeeApprovalRecord {
|
||||||
|
return {
|
||||||
|
id: stringField(row, "id"),
|
||||||
|
remoteId: optionalStringField(row, "remote_id"),
|
||||||
|
planId: optionalStringField(row, "plan_id"),
|
||||||
|
taskId: optionalStringField(row, "task_id"),
|
||||||
|
runId: optionalStringField(row, "run_id"),
|
||||||
|
title: stringField(row, "title"),
|
||||||
|
status: stringField(row, "status"),
|
||||||
|
payload: parseStoredPayload(row.payload),
|
||||||
|
syncStatus: stringField(row, "sync_status"),
|
||||||
|
lastSyncedAt: optionalStringField(row, "last_synced_at"),
|
||||||
|
syncError: optionalStringField(row, "sync_error"),
|
||||||
|
createdAt: stringField(row, "created_at"),
|
||||||
|
updatedAt: stringField(row, "updated_at"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function countPendingSyncItems(): number {
|
function countPendingSyncItems(): number {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db) return 0;
|
if (!db) return 0;
|
||||||
@@ -529,6 +983,91 @@ function finishRun(runId: string, status: string, now: string): void {
|
|||||||
`).run(status, now, now, runId);
|
`).run(status, now, now, runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertArtifact(input: {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
taskId: string;
|
||||||
|
runId: string;
|
||||||
|
kind: string;
|
||||||
|
name: string | null;
|
||||||
|
uri: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
now: string;
|
||||||
|
}): void {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return;
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO digital_artifacts (
|
||||||
|
id, plan_id, task_id, run_id, kind, name, uri, payload, sync_status, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
plan_id = excluded.plan_id,
|
||||||
|
task_id = excluded.task_id,
|
||||||
|
run_id = excluded.run_id,
|
||||||
|
kind = excluded.kind,
|
||||||
|
name = excluded.name,
|
||||||
|
uri = excluded.uri,
|
||||||
|
payload = excluded.payload,
|
||||||
|
sync_status = CASE
|
||||||
|
WHEN digital_artifacts.sync_status = 'synced' THEN 'pending'
|
||||||
|
ELSE digital_artifacts.sync_status
|
||||||
|
END
|
||||||
|
`).run(
|
||||||
|
input.id,
|
||||||
|
input.planId,
|
||||||
|
input.taskId,
|
||||||
|
input.runId,
|
||||||
|
input.kind,
|
||||||
|
input.name,
|
||||||
|
input.uri,
|
||||||
|
stringify(input.payload),
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
markOutbox("artifact", input.id, "upsert", input, input.now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertApproval(input: {
|
||||||
|
id: string;
|
||||||
|
planId: string;
|
||||||
|
taskId: string;
|
||||||
|
runId: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
payload: unknown;
|
||||||
|
now: string;
|
||||||
|
}): void {
|
||||||
|
const db = getDb();
|
||||||
|
if (!db) return;
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO digital_approvals (
|
||||||
|
id, plan_id, task_id, run_id, title, status, payload, sync_status, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
plan_id = excluded.plan_id,
|
||||||
|
task_id = excluded.task_id,
|
||||||
|
run_id = excluded.run_id,
|
||||||
|
title = excluded.title,
|
||||||
|
status = excluded.status,
|
||||||
|
payload = excluded.payload,
|
||||||
|
sync_status = CASE
|
||||||
|
WHEN digital_approvals.sync_status = 'synced' THEN 'pending'
|
||||||
|
ELSE digital_approvals.sync_status
|
||||||
|
END,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(
|
||||||
|
input.id,
|
||||||
|
input.planId,
|
||||||
|
input.taskId,
|
||||||
|
input.runId,
|
||||||
|
input.title,
|
||||||
|
input.status,
|
||||||
|
stringify(input.payload),
|
||||||
|
input.now,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
markOutbox("approval", input.id, "upsert", input, input.now);
|
||||||
|
}
|
||||||
|
|
||||||
function insertEvent(
|
function insertEvent(
|
||||||
event: DigitalEmployeeStateEvent,
|
event: DigitalEmployeeStateEvent,
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -554,6 +1093,189 @@ function insertEvent(
|
|||||||
markOutbox("event", event.event_id, "insert", event, event.occurred_at);
|
markOutbox("event", event.event_id, "insert", event, event.occurred_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistPayloadArtifactsAndApprovals(
|
||||||
|
ids: { planId: string; taskId: string; runId: string },
|
||||||
|
payload: unknown,
|
||||||
|
now: string,
|
||||||
|
): void {
|
||||||
|
const sources = payloadSources(payload);
|
||||||
|
const artifacts = sources.flatMap((source) => extractArtifacts(source));
|
||||||
|
const approvals = sources.flatMap((source) => extractApprovals(source));
|
||||||
|
|
||||||
|
dedupeByKey(artifacts, (artifact) => artifact.key).slice(0, 20).forEach((artifact, index) => {
|
||||||
|
upsertArtifact({
|
||||||
|
id: `${ids.runId}:artifact:${safeId(artifact.key || String(index + 1))}`,
|
||||||
|
planId: ids.planId,
|
||||||
|
taskId: ids.taskId,
|
||||||
|
runId: ids.runId,
|
||||||
|
kind: artifact.kind,
|
||||||
|
name: artifact.name,
|
||||||
|
uri: artifact.uri,
|
||||||
|
payload: artifact.payload,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dedupeByKey(approvals, (approval) => approval.key).slice(0, 20).forEach((approval, index) => {
|
||||||
|
upsertApproval({
|
||||||
|
id: `${ids.runId}:approval:${safeId(approval.key || String(index + 1))}`,
|
||||||
|
planId: ids.planId,
|
||||||
|
taskId: ids.taskId,
|
||||||
|
runId: ids.runId,
|
||||||
|
title: approval.title,
|
||||||
|
status: approval.status,
|
||||||
|
payload: approval.payload,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadSources(payload: unknown): unknown[] {
|
||||||
|
const record = objectRecord(payload);
|
||||||
|
if (!record) return [payload];
|
||||||
|
return [
|
||||||
|
payload,
|
||||||
|
record.result,
|
||||||
|
record.response,
|
||||||
|
record.output,
|
||||||
|
record.payload,
|
||||||
|
].filter((item) => item !== undefined && item !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedArtifact {
|
||||||
|
key: string;
|
||||||
|
kind: string;
|
||||||
|
name: string | null;
|
||||||
|
uri: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractArtifacts(payload: unknown): ExtractedArtifact[] {
|
||||||
|
const record = objectRecord(payload);
|
||||||
|
if (!record) return typeof payload === "string" && looksLikeArtifactUri(payload)
|
||||||
|
? [artifactFromValue(payload, 0)]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
...unknownArray(record.artifacts),
|
||||||
|
...unknownArray(record.outputs),
|
||||||
|
...unknownArray(record.files),
|
||||||
|
...unknownArray(record.attachments),
|
||||||
|
];
|
||||||
|
for (const key of ["artifact", "outputPath", "output_path", "filePath", "file_path", "path", "uri", "url"]) {
|
||||||
|
if (record[key]) values.push(record[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
.map((value, index) => artifactFromValue(value, index))
|
||||||
|
.filter((artifact) => artifact.name || artifact.uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactFromValue(value: unknown, index: number): ExtractedArtifact {
|
||||||
|
const record = objectRecord(value);
|
||||||
|
if (record) {
|
||||||
|
const uri = stringValue(record.uri ?? record.url ?? record.path ?? record.filePath ?? record.file_path ?? record.outputPath ?? record.output_path);
|
||||||
|
const name = stringValue(record.name ?? record.title ?? record.filename) || artifactName(uri) || `产物 ${index + 1}`;
|
||||||
|
const kind = stringValue(record.kind ?? record.type) || "artifact";
|
||||||
|
return {
|
||||||
|
key: stringValue(record.id ?? record.artifact_id) || uri || name,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
uri: uri || null,
|
||||||
|
payload: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
return {
|
||||||
|
key: text,
|
||||||
|
kind: "artifact",
|
||||||
|
name: artifactName(text) || `产物 ${index + 1}`,
|
||||||
|
uri: looksLikeArtifactUri(text) ? text : null,
|
||||||
|
payload: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedApproval {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApprovals(payload: unknown): ExtractedApproval[] {
|
||||||
|
const record = objectRecord(payload);
|
||||||
|
if (!record) return [];
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
...unknownArray(record.approvals),
|
||||||
|
...unknownArray(record.approval_requests),
|
||||||
|
...unknownArray(record.approvalRequests),
|
||||||
|
...unknownArray(record.decisions),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (record.needApproval === true || record.need_approval === true || record.requiresApproval === true) {
|
||||||
|
values.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values.map((value, index) => approvalFromValue(value, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function approvalFromValue(value: unknown, index: number): ExtractedApproval {
|
||||||
|
const record = objectRecord(value);
|
||||||
|
if (!record) {
|
||||||
|
return {
|
||||||
|
key: String(index + 1),
|
||||||
|
title: `待确认事项 ${index + 1}`,
|
||||||
|
status: "pending",
|
||||||
|
payload: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = stringValue(record.title ?? record.message ?? record.reason ?? record.summary)
|
||||||
|
|| `待确认事项 ${index + 1}`;
|
||||||
|
return {
|
||||||
|
key: stringValue(record.id ?? record.approval_id ?? record.key) || title,
|
||||||
|
title,
|
||||||
|
status: stringValue(record.status) || "pending",
|
||||||
|
payload: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unknownArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeArtifactUri(value: string): boolean {
|
||||||
|
return /^(file|https?):\/\//i.test(value) || value.includes("/") || value.includes("\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactName(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const normalized = value.split(/[?#]/)[0] || value;
|
||||||
|
return normalized.split(/[\\/]/).filter(Boolean).pop() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeByKey<T>(items: T[], keyOf: (item: T) => string): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items.filter((item) => {
|
||||||
|
const key = keyOf(item);
|
||||||
|
if (!key || seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function chatIds(request: DigitalEmployeeChatRequestRecord): {
|
function chatIds(request: DigitalEmployeeChatRequestRecord): {
|
||||||
planId: string;
|
planId: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|||||||
@@ -158,10 +158,62 @@ describe("digital employee sync service", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks artifact and approval acknowledgements as synced", async () => {
|
||||||
|
insertEntity("artifact", "artifact-1");
|
||||||
|
insertEntity("approval", "approval-1");
|
||||||
|
insertOutbox("artifact:upsert:artifact-1", "artifact", "artifact-1");
|
||||||
|
insertOutbox("approval:upsert:approval-1", "approval", "approval-1");
|
||||||
|
mockState.fetch.mockResolvedValue(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
acked: [
|
||||||
|
{
|
||||||
|
entity_type: "artifact",
|
||||||
|
entity_id: "artifact-1",
|
||||||
|
remote_id: "remote-artifact-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entity_type: "approval",
|
||||||
|
entity_id: "approval-1",
|
||||||
|
remote_id: "remote-approval-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { flushDigitalEmployeeSyncOutbox } = await import("./syncService");
|
||||||
|
const status = await flushDigitalEmployeeSyncOutbox({ force: true });
|
||||||
|
|
||||||
|
expect(status.pending).toBe(0);
|
||||||
|
expect(status.failed).toBe(0);
|
||||||
|
expect(readOutbox("artifact:upsert:artifact-1")).toMatchObject({
|
||||||
|
status: "synced",
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
expect(readOutbox("approval:upsert:approval-1")).toMatchObject({
|
||||||
|
status: "synced",
|
||||||
|
attempts: 1,
|
||||||
|
});
|
||||||
|
expect(readEntity("artifact", "artifact-1")).toMatchObject({
|
||||||
|
sync_status: "synced",
|
||||||
|
remote_id: "remote-artifact-1",
|
||||||
|
sync_error: null,
|
||||||
|
});
|
||||||
|
expect(readEntity("approval", "approval-1")).toMatchObject({
|
||||||
|
sync_status: "synced",
|
||||||
|
remote_id: "remote-approval-1",
|
||||||
|
sync_error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function insertPlan(id: string): void {
|
function insertPlan(id: string): void {
|
||||||
mockState.db?.plans.set(id, {
|
insertEntity("plan", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEntity(entityType: string, id: string): void {
|
||||||
|
entityMap(entityType)?.set(id, {
|
||||||
id,
|
id,
|
||||||
remote_id: null,
|
remote_id: null,
|
||||||
sync_status: "pending",
|
sync_status: "pending",
|
||||||
@@ -191,7 +243,24 @@ function readOutbox(id: string): Record<string, unknown> | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readPlan(id: string): Record<string, unknown> | undefined {
|
function readPlan(id: string): Record<string, unknown> | undefined {
|
||||||
return mockState.db?.plans.get(id);
|
return readEntity("plan", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntity(
|
||||||
|
entityType: string,
|
||||||
|
id: string,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
return entityMap(entityType)?.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityMap(
|
||||||
|
entityType: string,
|
||||||
|
): Map<string, TestSyncEntityRow> | undefined {
|
||||||
|
if (!mockState.db) return undefined;
|
||||||
|
if (entityType === "plan") return mockState.db.plans;
|
||||||
|
if (entityType === "artifact") return mockState.db.artifacts;
|
||||||
|
if (entityType === "approval") return mockState.db.approvals;
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAttempts(): Array<Record<string, unknown>> {
|
function readAttempts(): Array<Record<string, unknown>> {
|
||||||
@@ -220,7 +289,7 @@ interface TestOutboxRow {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TestPlanRow {
|
interface TestSyncEntityRow {
|
||||||
id: string;
|
id: string;
|
||||||
remote_id: string | null;
|
remote_id: string | null;
|
||||||
sync_status: string;
|
sync_status: string;
|
||||||
@@ -244,7 +313,9 @@ interface TestAttemptRow {
|
|||||||
|
|
||||||
class TestDb {
|
class TestDb {
|
||||||
outbox = new Map<string, TestOutboxRow>();
|
outbox = new Map<string, TestOutboxRow>();
|
||||||
plans = new Map<string, TestPlanRow>();
|
plans = new Map<string, TestSyncEntityRow>();
|
||||||
|
artifacts = new Map<string, TestSyncEntityRow>();
|
||||||
|
approvals = new Map<string, TestSyncEntityRow>();
|
||||||
attempts: TestAttemptRow[] = [];
|
attempts: TestAttemptRow[] = [];
|
||||||
private nextAttemptId = 1;
|
private nextAttemptId = 1;
|
||||||
|
|
||||||
@@ -427,9 +498,9 @@ class TestDb {
|
|||||||
return { changes: before - this.attempts.length };
|
return { changes: before - this.attempts.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sql.startsWith("UPDATE digital_plans SET sync_status = 'synced'")) {
|
if (/^UPDATE digital_(plans|artifacts|approvals) SET sync_status = 'synced'/.test(sql)) {
|
||||||
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
const [remoteId, lastSyncedAt, id] = args as [string | null, string, string];
|
||||||
const row = this.plans.get(id);
|
const row = this.entityRowsForUpdate(sql).get(id);
|
||||||
if (row) {
|
if (row) {
|
||||||
row.sync_status = "synced";
|
row.sync_status = "synced";
|
||||||
row.remote_id = remoteId ?? row.remote_id;
|
row.remote_id = remoteId ?? row.remote_id;
|
||||||
@@ -439,9 +510,9 @@ class TestDb {
|
|||||||
return { changes: row ? 1 : 0 };
|
return { changes: row ? 1 : 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sql.startsWith("UPDATE digital_plans SET sync_status = 'failed'")) {
|
if (/^UPDATE digital_(plans|artifacts|approvals) SET sync_status = 'failed'/.test(sql)) {
|
||||||
const [syncError, id] = args as [string, string];
|
const [syncError, id] = args as [string, string];
|
||||||
const row = this.plans.get(id);
|
const row = this.entityRowsForUpdate(sql).get(id);
|
||||||
if (row) {
|
if (row) {
|
||||||
row.sync_status = "failed";
|
row.sync_status = "failed";
|
||||||
row.sync_error = syncError;
|
row.sync_error = syncError;
|
||||||
@@ -451,6 +522,12 @@ class TestDb {
|
|||||||
|
|
||||||
throw new Error(`Unhandled run query: ${sql}`);
|
throw new Error(`Unhandled run query: ${sql}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private entityRowsForUpdate(sql: string): Map<string, TestSyncEntityRow> {
|
||||||
|
if (sql.startsWith("UPDATE digital_artifacts")) return this.artifacts;
|
||||||
|
if (sql.startsWith("UPDATE digital_approvals")) return this.approvals;
|
||||||
|
return this.plans;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyRow<T extends object>(row: T): T {
|
function copyRow<T extends object>(row: T): T {
|
||||||
|
|||||||
@@ -95,6 +95,18 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
|
|||||||
async getSnapshot() {
|
async getSnapshot() {
|
||||||
return ipcRenderer.invoke("digitalEmployee:getSnapshot");
|
return ipcRenderer.invoke("digitalEmployee:getSnapshot");
|
||||||
},
|
},
|
||||||
|
async getRuntimeRecords() {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:getRuntimeRecords");
|
||||||
|
},
|
||||||
|
async getUiState() {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:getUiState");
|
||||||
|
},
|
||||||
|
async getSkillCapabilities() {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:getSkillCapabilities");
|
||||||
|
},
|
||||||
|
async saveUiState(update: unknown) {
|
||||||
|
return ipcRenderer.invoke("digitalEmployee:saveUiState", update);
|
||||||
|
},
|
||||||
async getSyncStatus() {
|
async getSyncStatus() {
|
||||||
return ipcRenderer.invoke("digitalEmployee:getSyncStatus");
|
return ipcRenderer.invoke("digitalEmployee:getSyncStatus");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -445,6 +445,15 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
|||||||
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
|
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
|
||||||
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
|
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
|
||||||
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
||||||
|
- 数字员工嵌入页会通过 `window.QimingClawBridge.digital.getRuntimeRecords()` 读取本地 SQLite 的 `digital_plans`、`digital_tasks`、`digital_runs`、`digital_events`、`digital_artifacts`、`digital_approvals`,优先用真实本地记录生成 workday、任务中心、任务详情和流程投影;本地记录为空时才回落到 qimingclaw 固定适配任务。
|
||||||
|
- 数字员工页面的任务确认、资料设置、日报状态通过 `getUiState` / `saveUiState` 保存在主进程 SQLite settings;保存时会记录 `digital_workday_*` 本地事件并进入 outbox,避免这些页面操作只留在 webview localStorage。
|
||||||
|
- 数字员工技能库通过 `getSkillCapabilities` 读取 qimingclaw 当前 MCP 配置、server 状态、已发现工具与 allow/deny 限制,并把 MCP server/tool 投射为 `/api/skill` 技能项。
|
||||||
|
- 数字员工调试存储、dashboard、flow 和产物列表通过 qimingclaw adapter 的 `/api/debug/store`、`/api/dashboard`、`/api/debug/plan/:id/flow`、`/api/debug/task/:id/flow`、`/api/artifact` 投射本地 Plan / Task / Run / Event / Artifact / Approval;Run/Event payload 中的 `artifacts`、`outputs`、`files`、`outputPath`、`uri` 等字段会作为正式 artifact 记录为空时的兼容产物入口展示。
|
||||||
|
- qimingclaw 主进程记录 `/computer/chat` 结果和 runtime event 时,会从 payload 的 `artifacts`、`outputs`、`files`、`attachments`、`outputPath`、`uri` 以及 `approvals`、`approval_requests`、`needApproval` 等字段轻量提取正式 `digital_artifacts` / `digital_approvals` 记录,并进入 outbox 同步。
|
||||||
|
- `digital_approvals` 会投射为数字员工 workday 的 `decisions`;首页待解决事项按钮会调用本地 `decide_approval` action,把处理结果写入 SQLite UI state,并生成 `digital_workday_decide_approval` runtime event。
|
||||||
|
- 任务中心的 `/api/governance/command` 已接入 qimingclaw 兼容层:`create_plan`、`submit_plan`、`approve_plan`、`activate_plan`、`create_schedule`、`run_schedule_now` 等命令返回本地可解释结果,其中激活/立即运行会映射到 adapter 的 dispatch/workday 状态。
|
||||||
|
- 日报下载不再打开 sgRobot 风格 PDF 后端直链;生成日报后会基于当前 qimingclaw workday 投影在浏览器内导出本地文本日报。
|
||||||
|
- 任务中心在加载到 qimingclaw 真实计划时不再混入“白领一天”等 demo supplement;只有没有任何真实计划或请求失败时才使用本地示例兜底。
|
||||||
- 同步状态通过 bridge 返回最近失败 outbox 明细,首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
|
- 同步状态通过 bridge 返回最近失败 outbox 明细,首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
|
||||||
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天。
|
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天。
|
||||||
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史,并可复制或导出诊断 JSON;也可触发手动 `flushSync` 立即重试。若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
|
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史,并可复制或导出诊断 JSON;也可触发手动 `flushSync` 立即重试。若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
|
||||||
@@ -455,8 +464,16 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
|||||||
```text
|
```text
|
||||||
digitalEmployee:getSyncStatus
|
digitalEmployee:getSyncStatus
|
||||||
digitalEmployee:flushSync
|
digitalEmployee:flushSync
|
||||||
|
digitalEmployee:getRuntimeRecords
|
||||||
|
digitalEmployee:getUiState
|
||||||
|
digitalEmployee:saveUiState
|
||||||
|
digitalEmployee:getSkillCapabilities
|
||||||
window.QimingClawBridge.digital.getSyncStatus()
|
window.QimingClawBridge.digital.getSyncStatus()
|
||||||
window.QimingClawBridge.digital.flushSync()
|
window.QimingClawBridge.digital.flushSync()
|
||||||
|
window.QimingClawBridge.digital.getRuntimeRecords()
|
||||||
|
window.QimingClawBridge.digital.getUiState()
|
||||||
|
window.QimingClawBridge.digital.saveUiState(update)
|
||||||
|
window.QimingClawBridge.digital.getSkillCapabilities()
|
||||||
```
|
```
|
||||||
|
|
||||||
当前边界:
|
当前边界:
|
||||||
|
|||||||
Reference in New Issue
Block a user