feat(client): project digital employee runtime into UI

This commit is contained in:
baiyanyun
2026-06-07 17:46:07 +08:00
parent 8ff1c57b83
commit b07f133ed6
15 changed files with 2639 additions and 252 deletions

View File

@@ -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> {
return apiFetch<PlanDispatchResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/dispatch`, {
method: 'POST',

View File

@@ -432,6 +432,18 @@ function categoryFor(skillId: string): string {
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 {
const description = str(rawDescription).trim();
if (description && hasChinese(description)) return description;
@@ -450,8 +462,8 @@ export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
version: s.version ? str(s.version) : null,
enabled: Boolean(s.enabled),
description: zhDescription(sid, s.description, zhName(sid, str(s.name, sid))),
category: categoryFor(sid),
capabilityLevel: 'standard' as const,
category: s.category ? str(s.category, categoryFor(sid)) : categoryFor(sid),
capabilityLevel: capabilityLevelFor(s.capability_level ?? s.capabilityLevel),
source: 'skill-api' as const,
};
});

View File

@@ -4,7 +4,6 @@ import { Clipboard, Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-
import Avatar3D from '@/components/digital/Avatar3D';
import { basePath } from '@/lib/basePath';
import {
digitalEmployeeReportPdfUrl,
dispatchPlanNow,
getDigitalEmployeeWorkday,
getSchedulerJobs,
@@ -14,6 +13,7 @@ import {
import { isTauri } from '@/lib/tauri';
import type {
CronJob,
DigitalEmployeeDailyReport,
DigitalEmployeeDecision,
DigitalEmployeeDuty,
DigitalEmployeeRunDetail,
@@ -590,8 +590,8 @@ function syncFailureDiagnosticFileName(failure: ManagementSyncFailure): string {
return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`;
}
function downloadTextFile(filename: string, content: string): void {
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
function downloadTextFile(filename: string, content: string, mimeType = 'application/json;charset=utf-8'): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -602,6 +602,47 @@ function downloadTextFile(filename: string, content: string): void {
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> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getSyncStatus) return null;
@@ -734,8 +775,6 @@ function resolveEmployeeVisualState(args: {
return 'skill';
}
const DECISION_CHAIN_TODO_TEXT = '真实审批链路待接入';
function readGuideDismissedDate(): string | null {
if (typeof window === 'undefined') return null;
try {
@@ -1207,7 +1246,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
: await getDigitalEmployeeWorkday(selectedDate);
if (!mountedRef.current) return;
setWorkdayProjection(latestProjection);
setActionNotice('日报已生成,可下载 PDF。');
setActionNotice('日报已生成,可下载本地文本。');
if (latestProjection.daily_report?.status === 'ready') {
setReportPreviewOpen(true);
}
@@ -1220,18 +1259,31 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [reportScheduleTime, selectedDate, selectedDutyIds, selectedSubmissionDutyIds]);
const downloadDailyReportPdf = useCallback(() => {
window.open(digitalEmployeeReportPdfUrl(selectedDate), '_blank', 'noopener,noreferrer');
}, [selectedDate]);
const downloadDailyReport = useCallback(() => {
if (!dailyReport || dailyReport.status !== 'ready') return;
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) => {
setActionBusy(`${decision.id}:${actionId}`);
setActionError(null);
setActionNotice(null);
try {
const projection = await postDigitalEmployeeWorkdayAction({
action: 'decide_approval',
date: selectedDate,
decision_id: decision.id,
decision: actionId,
});
if (!mountedRef.current) return;
const actionLabel = decision.actions.find((action) => action.id === actionId)?.label ?? actionId;
setActionNotice(`待实现:已触发“${actionLabel}”演示效果,${DECISION_CHAIN_TODO_TEXT},未改动后端审批或执行状态。`);
setWorkdayProjection(projection);
setActionNotice(`已记录“${actionLabel}”处理结果,并写入 qimingclaw 本地运行状态。`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '处理决策失败');
@@ -1239,7 +1291,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, []);
}, [selectedDate]);
const openDuty = useCallback((planItem: DigitalPlanItem) => {
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
@@ -1804,8 +1856,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
type="button"
className="de-btn-secondary de-btn-sm de-report-download-btn"
disabled={!dailyReport?.pdf_available}
onClick={downloadDailyReportPdf}
title={dailyReport?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
onClick={downloadDailyReport}
title={dailyReport?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
>
<Download size={14} aria-hidden="true" />
@@ -1859,7 +1911,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
type="button"
className="de-btn-secondary de-btn-sm de-report-download-btn"
disabled={!dailyReport.pdf_available}
onClick={downloadDailyReportPdf}
onClick={downloadDailyReport}
>
<Download size={14} aria-hidden="true" />
@@ -2392,8 +2444,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
<p>{decision.scenario}</p>
<div className="de-decision-chain-note">
<span className="de-badge de-badge-warning"></span>
<span>{DECISION_CHAIN_TODO_TEXT}</span>
<span className="de-badge de-badge-success"></span>
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
</div>
<div className="de-workbench-actions">
{decision.actions.map((action) => (

View File

@@ -3,7 +3,6 @@ import { Download } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
digitalEmployeeReportPdfUrl,
getDigitalEmployeeWorkday,
postDigitalEmployeeWorkdayAction,
} 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[] }) {
if (sections.length === 0) {
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;
setProjection(data);
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
setMessage('日报已生成,可下载 PDF。');
setMessage('日报已生成,可下载本地文本。');
} catch (error) {
if (!mountedRef.current) return;
setMessage(error instanceof Error ? error.message : '日报生成失败');
@@ -218,9 +270,10 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
}
}, [isLiveProjection, projection, reportScheduleTime, today]);
const downloadDailyReportPdf = useCallback(() => {
window.open(digitalEmployeeReportPdfUrl(today), '_blank', 'noopener,noreferrer');
}, [today]);
const downloadDailyReport = useCallback(() => {
if (!report || report.status !== 'ready') return;
downloadTextFile(reportFileName(today), buildReportDownloadText(today, report, taskSections, runDetails));
}, [report, runDetails, taskSections, today]);
return (
<div style={{ padding: '14px 18px' }}>
@@ -271,8 +324,8 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
type="button"
className="de-btn-secondary de-btn-sm de-report-download-btn"
disabled={!report?.pdf_available}
onClick={downloadDailyReportPdf}
title={report?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
onClick={downloadDailyReport}
title={report?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
>
<Download size={14} aria-hidden="true" />

View File

@@ -15,18 +15,9 @@ import {
import { domSafeId } from './navigation';
const REQUIRED_MISSION_IDS = [
'whitecollar-day',
'whitecollar-day-0830-browser-portal-check',
'whitecollar-day-0900-browser-inbox-triage',
'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',
'qimingclaw-client-readiness',
'qimingclaw-remote-task-ingress',
'qimingclaw-daily-report',
];
const DELEGATION_FILTERS = [
@@ -332,34 +323,7 @@ export default function MissionCenter({
.sort((a, b) => planSortTime(b) - planSortTime(a));
const orderedPlans = [...recentPlans, ...requiredPlans];
if (orderedPlans.length > 0) {
const existingIds = new Set(orderedPlans.map((plan) => typeof plan.plan_id === 'string' ? plan.plan_id : ''));
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]);
const nextMissions = adaptMissions(orderedPlans);
setDemoMode(false);
setMissions(nextMissions);
return nextMissions;
@@ -625,7 +589,7 @@ export default function MissionCenter({
<div className="de-card-body">
{demoMode && (
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 12 }}>
使
qimingclaw
</div>
)}
{actionError && (
@@ -679,13 +643,13 @@ export default function MissionCenter({
) : displayMissions.length === 0 ? (
<div className="de-mission-empty-panel">
<strong></strong>
<p></p>
<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>
@@ -717,7 +681,7 @@ export default function MissionCenter({
<div className="de-card-body">
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
<strong></strong>
<p></p>
<p> qimingclaw Plan / Task / Run / Event payload 线</p>
</div>
</div>
</GlassCard>