feat(client): vendor digital employee source

This commit is contained in:
baiyanyun
2026-06-05 15:02:42 +08:00
parent c6fdff20e8
commit 2eb9a99da3
66 changed files with 15160 additions and 2646 deletions

View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>数字员工</title>
<script>
window.__QIMINGCLAW_EMBEDDED_DIGITAL__ = true;
try {
window.localStorage.setItem("zeroclaw_token", "qimingclaw_embedded_digital_employee");
window.sessionStorage.setItem("sgrobot_logged_in", "1");
} catch (error) {
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "@qiming-ai/sgrobot-digital-embedded",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.3.0",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.2",
"vite": "^6.0.7"
}
}

View File

@@ -0,0 +1,10 @@
# qimingclaw embedded patches
This directory records qimingclaw-only adaptations around the sgRobot digital employee UI.
- `src/main.tsx` mounts the digital employee page directly at `#/digital`.
- `src/lib/basePath.ts` fixes static assets under `/sgrobot-digital`.
- `src/lib/tauri.ts` disables sgRobot/Tauri gateway detection inside the Electron webview.
- `index.html` injects a local token so copied sgRobot pages do not show the pairing screen.
Run `npm run sync:sgrobot-digital` from `crates/agent-electron-client` after changing the upstream sgRobot digital UI.

View File

@@ -0,0 +1,7 @@
# Digital Employee Static Assets
This directory is owned by the React digital employee frontend.
- `avatars/` keeps the original digital employee character images used by the home page `Avatar3D` stage.
- Do not place the legacy Vue standalone application bundle here.
- If an avatar file is renamed, update `src/pages/digital/CommandDeck.tsx` in the same change.

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,29 @@
import Gem from './Gem';
import Stage from './Stage';
interface Avatar3DProps {
avatarSrc: string;
altText?: string;
statusLabel?: string;
}
export default function Avatar3D({ avatarSrc, altText = '数字员工', statusLabel }: Avatar3DProps) {
return (
<div className="de-avatar-container" aria-hidden="true">
<div className="de-avatar-glass-space">
<div className="de-avatar-roof">
<div className="de-avatar-roof-face" />
<div className="de-avatar-roof-glow" />
</div>
<div className="de-avatar-glass-left" />
<div className="de-avatar-glass-right" />
<Gem className="de-gem-1" />
<Gem className="de-gem-2" />
<Gem className="de-gem-3" />
</div>
{statusLabel && <div className="de-avatar-state-label">{statusLabel}</div>}
<img src={avatarSrc} alt={altText} className="de-avatar-img" />
<Stage />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
interface CardTitleBarProps {
title: string;
children?: ReactNode;
}
export default function CardTitleBar({ title, children }: CardTitleBarProps) {
return (
<div className="de-card-title-bar">
<span className="de-card-title-bar-text">{title}</span>
{children}
</div>
);
}

View File

@@ -0,0 +1,620 @@
import { useEffect, useMemo, useState } from 'react';
import {
buildDemoFlowEvents,
buildDemoPlanMessages,
buildDemoRuns,
buildDemoTasks,
getDemoMissions,
} from '@/pages/digital/demoScenario';
import {
displayBackendMessage,
displayBusinessText,
displayEventKind,
displayStatus,
type Mission,
} from '@/lib/consoleDataAdapter';
import { getDebugPlanFlow, getDebugTasksForPlan, getPlanMessages, type PlanMessage } from '@/lib/api';
import type { DebugRun, DebugTask, FlowResponse } from '@/types/api';
import StatusBadge from './StatusBadge';
interface FlowDetailPanelProps {
mission: Mission;
onClose: () => void;
}
function stepVariant(status: string): 'info' | 'success' | 'warning' | 'danger' {
switch (status) {
case 'succeeded': case 'completed': case 'Succeeded': return 'success';
case 'failed': return 'danger';
case 'Failed': return 'danger';
case 'running': case 'active': case 'Running': return 'info';
default: return 'warning';
}
}
function fmtTime(ts?: string | null): string {
if (!ts) return '未记录';
return new Date(ts).toLocaleString('zh-CN', { hour12: false });
}
function missionPeriodLabel(mission: Mission): string {
const text = `${mission.id} ${mission.title} ${mission.objective}`.toLowerCase();
if (text.includes('month') || text.includes('monthly') || text.includes('月')) return '月度任务';
if (text.includes('week') || text.includes('weekly') || text.includes('周')) return '周任务';
if (text.includes('临时') || text.includes('adhoc') || text.includes('hotlist') || text.includes('monitor')) return '临时任务';
return '日常任务';
}
function isDone(status: string): boolean {
return ['succeeded', 'success', 'finished', 'complete', 'completed'].includes(status.toLowerCase());
}
function isFailed(status: string): boolean {
return ['failed', 'error', 'cancelled', 'canceled', 'aborted', 'rejected', 'deadletter'].includes(status.toLowerCase());
}
function progressText(mission: Mission): string {
const total = mission.steps.length;
if (total === 0) return displayStatus(mission.status);
const done = mission.steps.filter((step) => isDone(step.status)).length;
const failed = mission.steps.filter((step) => isFailed(step.status)).length;
const running = mission.steps.filter((step) => ['running', 'active', 'queued', 'leased'].includes(step.status.toLowerCase())).length;
const parts = [`已完成 ${done}/${total}`];
if (failed > 0) parts.push(`失败 ${failed}`);
if (running > 0 && failed === 0) parts.push(`处理中 ${running}`);
return parts.join('');
}
function businessTimelineMessage(message: string): string {
return message
.replace(/^启动计划:/, '接收委托:')
.replace(/^准备步骤:/, '准备工作:')
.replace(/^创建任务:/, '安排工作:')
.replace(/^任务失败:/, '工作失败:')
.replace(/^开始处理:/, '开始处理:')
.replace(/^完成任务:/, '完成工作:')
.replace(/^调用技能:/, '数字员工处理:')
.replace(/^执行开始:/, '开始执行:')
.replace(/^执行完成:/, '执行完成:')
.replace(/^执行失败:/, '执行失败:')
.replace(/,将使用[^,。]+/, '')
.replace(/,分配给[^,。]+/, '');
}
function isFailureMessage(message: string): boolean {
return /失败|failed|error|timeout|unavailable/i.test(message);
}
function businessKindLabel(kind: string, message = ''): string {
if (isFailureMessage(message)) return '失败';
if (kind === 'artifact') return '产物';
if (kind === 'task_summary') return '结果';
if (kind === 'task_running' || kind === 'run') return '处理中';
if (kind === 'task_done' || kind === 'run_done') return '完成';
return '过程';
}
function messageRoleLabel(role: string): string {
if (role === 'user') return '用户';
if (role === 'assistant') return '数字员工';
if (role === 'system') return '系统';
return '消息';
}
type DetailEvent = {
event_id: string;
kind: string;
occurred_at: string | null;
message: string;
};
function skillLabel(skillId: string | null | undefined): string {
return displayBusinessText(skillId || '', skillId || '自动化技能');
}
function toolBusinessMessage(toolName: string, success: string, elapsedMs: string): string {
const result = success === 'true' ? '成功' : '失败';
if (toolName.includes('zhihu-hotlist') && toolName.includes('extract_hotlist')) {
return `调用技能:知乎热榜采集,结果:${result},耗时 ${elapsedMs}ms`;
}
if (toolName.includes('office-export-xlsx') && toolName.includes('export_xlsx')) {
return `调用技能Excel 导出,结果:${result},耗时 ${elapsedMs}ms`;
}
return `调用技能:${toolName},结果:${result},耗时 ${elapsedMs}ms`;
}
function valueText(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function eventTime(event: DetailEvent): string {
return event.occurred_at || '';
}
function earliestTime(values: Array<string | null | undefined>): string | null {
return values
.filter((value): value is string => Boolean(value))
.sort((a, b) => a.localeCompare(b))[0] || null;
}
function latestByResultSubject(events: DetailEvent[]): DetailEvent[] {
const bySubject = new Map<string, DetailEvent>();
for (const event of events) {
const subject = event.message.split(',结果:')[0]?.split(',状态:')[0] || event.message;
const current = bySubject.get(subject);
if (!current || eventTime(event).localeCompare(eventTime(current)) > 0) {
bySubject.set(subject, event);
}
}
return Array.from(bySubject.values());
}
function objectValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function normalizeOutputPath(value: string): string {
return value.replace(/\\\\/g, '\\');
}
function buildOperationalTimeline(
mission: Mission,
tasks: DebugTask[],
runs: DebugRun[],
messages: PlanMessage[],
flowEvents?: FlowResponse['events'],
): DetailEvent[] {
const events: DetailEvent[] = [];
const add = (kind: string, occurredAt: string | null | undefined, message: string, id: string) => {
if (!message.trim()) return;
events.push({ event_id: id, kind, occurred_at: occurredAt ?? null, message });
};
const addOutputFacts = (rawOutput: string, occurredAt: string | null | undefined, idPrefix: string, sourceName: string) => {
let parsedOutputPath = false;
try {
const parsed = objectValue(JSON.parse(rawOutput));
if (parsed) {
const label = typeof parsed.label === 'string' ? parsed.label.trim() : '';
const value = parsed.value;
if (label && value !== undefined && value !== null) {
add('task_summary', occurredAt, `${label}${String(value)}`, `metric-${idPrefix}-${sourceName}`);
}
const outputPath = typeof parsed.output_path === 'string' ? parsed.output_path.trim() : '';
if (outputPath) {
parsedOutputPath = true;
add('artifact', occurredAt, `产物生成:${normalizeOutputPath(outputPath)}`, `artifact-${idPrefix}-${sourceName}`);
}
}
} catch {
// Some execution messages are human-readable text. Regex extraction below handles those.
}
const rowCountMatch = rawOutput.match(/"row_count"\s*:\s*(\d+)/);
if (rowCountMatch?.[1]) {
add('task_summary', occurredAt, `数据处理完成:共 ${rowCountMatch[1]} 条记录`, `rows-${idPrefix}-${sourceName}`);
}
const pathMatch = rawOutput.match(/"output_path"\s*:\s*"([^"]+\.(?:xlsx|xls|csv|json|html|pdf|png|jpg|jpeg|txt))"/i);
const outputPath = pathMatch?.[1];
if (outputPath && !parsedOutputPath) {
add('artifact', occurredAt, `产物生成:${normalizeOutputPath(outputPath)}`, `artifact-regex-${idPrefix}-${sourceName}`);
}
};
add('plan', mission.createdAt || mission.updatedAt, `启动计划:${mission.title},目标:${mission.objective}`, `plan-${mission.id}`);
if (tasks.length === 0) {
for (const step of mission.steps) {
const skills = step.preferredSkills.map(skillLabel).join('、') || '自动化技能';
add('task', mission.updatedAt || mission.createdAt, `准备步骤:${step.title},将使用 ${skills}`, `step-${step.id}`);
}
}
for (const task of tasks) {
const skill = skillLabel(task.assigned_skill_id);
const taskRuns = runs.filter((run) => run.task_id === task.task_id);
const firstRunAt = earliestTime(taskRuns.map((run) => run.created_at));
const taskCreatedAt = earliestTime([task.created_at, task.started_at, firstRunAt]);
add('task', taskCreatedAt, `创建任务:${task.title || '子任务'},分配给 ${skill}`, `task-create-${task.task_id}`);
if (task.started_at) add('task_running', task.started_at, `开始处理:${task.title || '子任务'}`, `task-start-${task.task_id}`);
if (task.finished_at) {
const taskDoneVerb = isFailed(task.status) ? '任务失败' : '完成任务';
const errorDetail = isFailed(task.status) && task.error_message ? `,原因:${task.error_message}` : '';
add('task_done', task.finished_at, `${taskDoneVerb}${task.title || '子任务'},状态:${displayStatus(task.status)}${errorDetail}`, `task-finish-${task.task_id}`);
}
}
for (const run of runs) {
const task = tasks.find((item) => item.task_id === run.task_id);
const taskTitle = task?.title || '任务执行';
const runSkill = skillLabel(run.skill_id || task?.assigned_skill_id);
const hasSessionToolForTask = messages.some((message) => (message.content || '').includes(`[Task ${run.task_id}] tool_call`));
if (!hasSessionToolForTask) {
add('skill_tool', run.created_at, `调用技能:${runSkill},处理:${taskTitle}`, `run-skill-${run.run_id}`);
}
add('run', run.created_at, `执行开始:${taskTitle},第 ${run.attempt_no || 1}`, `run-start-${run.run_id}`);
if (run.completed_at) {
const runDoneVerb = isFailed(run.lifecycle) ? '执行失败' : '执行完成';
add('run_done', run.completed_at, `${runDoneVerb}${taskTitle},结果:${displayStatus(run.lifecycle)}`, `run-done-${run.run_id}`);
}
for (const [index, runEvent] of (run.events || []).entries()) {
addOutputFacts(
valueText(runEvent.message),
runEvent.occurred_at || run.completed_at || run.created_at,
`run-${run.run_id}-${runEvent.event_id || index}`,
run.skill_id || task?.assigned_skill_id || 'run',
);
}
for (const [index, artifact] of (run.artifacts || []).entries()) {
const uri = valueText(artifact.uri);
const name = valueText(artifact.name) || uri;
if (uri || name) {
add('artifact', run.completed_at || run.created_at, `产物生成:${name}${uri && uri !== name ? `${uri}` : ''}`, `run-artifact-${run.run_id}-${index}`);
}
}
}
for (const message of messages) {
const content = message.content || '';
const toolMatch = content.match(/\[Task ([^\]]+)\] tool_call name=([^\s]+) success=(true|false) elapsed_ms=(\d+) args=(.*?) output=(.*)$/s);
if (toolMatch) {
const taskId = toolMatch[1] || '';
const toolName = toolMatch[2] || '';
const success = toolMatch[3] || 'false';
const elapsedMs = toolMatch[4] || '0';
const output = toolMatch[6] || '';
add('skill_tool', message.created_at, toolBusinessMessage(toolName, success, elapsedMs), `tool-${taskId}-${message.created_at}-${toolName}`);
addOutputFacts(output, message.created_at, `message-${message.created_at}`, toolName);
continue;
}
const hotlistCount = content.match(/共采集到\s*\**\s*(\d+)\s*条/);
if (hotlistCount?.[1]) {
add('task_summary', message.created_at, `采集完成:获得 ${hotlistCount[1]} 条知乎热榜数据`, `summary-hotlist-${message.created_at}`);
continue;
}
if (content.includes('[Task ') && content.includes('Excel')) {
add('task_summary', message.created_at, '导出完成Excel 文件已生成', `summary-excel-${message.created_at}`);
}
}
if (events.length <= 1) {
for (const event of flowEvents || []) {
add(event.kind, event.occurred_at, displayBackendMessage(event.message, event.message), event.event_id);
}
}
return events
.sort((a, b) => (a.occurred_at || '').localeCompare(b.occurred_at || ''))
.slice(-40);
}
export default function FlowDetailPanel({ mission, onClose }: FlowDetailPanelProps) {
const [tasks, setTasks] = useState<DebugTask[]>([]);
const [runs, setRuns] = useState<DebugRun[]>([]);
const [flow, setFlow] = useState<FlowResponse | null>(null);
const [loading, setLoading] = useState(true);
const [sessionMessages, setSessionMessages] = useState<PlanMessage[]>([]);
const [sessionOpen, setSessionOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [sessionLoading, setSessionLoading] = useState(false);
const [sessionError, setSessionError] = useState<string | null>(null);
const planId = mission.sourceIds.planId || mission.id;
useEffect(() => {
let cancelled = false;
const refresh = () => {
setLoading(true);
Promise.allSettled([
getDebugTasksForPlan(planId),
getDebugPlanFlow(planId),
getPlanMessages(planId),
]).then(([tasksResult, flowResult, messagesResult]) => {
if (cancelled) return;
const demoMission = getDemoMissions().find((item) => item.id === planId);
const fallbackTasks = demoMission ? buildDemoTasks([demoMission]) : [];
const fallbackRuns = fallbackTasks.length > 0 ? buildDemoRuns(fallbackTasks) : [];
if (tasksResult.status === 'fulfilled') {
setTasks(tasksResult.value.tasks && tasksResult.value.tasks.length > 0 ? tasksResult.value.tasks : fallbackTasks);
setRuns(tasksResult.value.runs && tasksResult.value.runs.length > 0 ? tasksResult.value.runs : fallbackRuns);
} else {
setTasks(fallbackTasks);
setRuns(fallbackRuns);
}
if (flowResult.status === 'fulfilled' && flowResult.value?.events && flowResult.value.events.length > 0) {
setFlow(flowResult.value);
} else {
setFlow(demoMission ? { events: buildDemoFlowEvents(demoMission) } as FlowResponse : null);
}
if (messagesResult.status === 'fulfilled' && messagesResult.value.messages.length > 0) {
setSessionMessages(messagesResult.value.messages);
} else if (demoMission) {
setSessionMessages(buildDemoPlanMessages(demoMission));
} else {
setSessionMessages([]);
}
}).finally(() => {
if (!cancelled) setLoading(false);
});
};
refresh();
const timer = window.setInterval(refresh, 3000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [planId]);
useEffect(() => {
setSessionMessages([]);
setSessionOpen(false);
setAdvancedOpen(false);
setSessionError(null);
}, [planId]);
const loadSessionMessages = async () => {
if (sessionLoading) return;
setSessionOpen(true);
setSessionLoading(true);
setSessionError(null);
try {
const data = await getPlanMessages(planId);
if (data.messages && data.messages.length > 0) {
setSessionMessages(data.messages);
} else {
const demoMission = getDemoMissions().find((item) => item.id === planId);
setSessionMessages(demoMission ? buildDemoPlanMessages(demoMission) : []);
}
} catch (err) {
const demoMission = getDemoMissions().find((item) => item.id === planId);
if (demoMission) {
setSessionMessages(buildDemoPlanMessages(demoMission));
setSessionError(null);
} else {
setSessionMessages([]);
setSessionError(err instanceof Error ? err.message : '读取会话失败');
}
} finally {
setSessionLoading(false);
}
};
const runsByTask = useMemo(() => {
const grouped = new Map<string, DebugRun[]>();
for (const run of runs) {
const list = grouped.get(run.task_id) || [];
list.push(run);
grouped.set(run.task_id, list);
}
for (const list of grouped.values()) {
list.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
}
return grouped;
}, [runs]);
const eventTimeline = useMemo(
() => buildOperationalTimeline(mission, tasks, runs, sessionMessages, flow?.events),
[flow?.events, mission, runs, sessionMessages, tasks],
);
const businessTimeline = useMemo(
() => eventTimeline
.filter((event) => event.kind !== 'skill_tool')
.slice(-12),
[eventTimeline],
);
const resultEvents = useMemo(
() => {
const facts = eventTimeline.filter((event) => ['artifact', 'task_summary'].includes(event.kind));
const taskResults = eventTimeline.filter((event) => event.kind === 'task_done');
const runResults = latestByResultSubject(eventTimeline.filter((event) => event.kind === 'run_done'));
const terminal = taskResults.length > 0 ? taskResults : runResults;
return [...facts, ...terminal]
.sort((a, b) => eventTime(a).localeCompare(eventTime(b)))
.slice(-6);
},
[eventTimeline],
);
const latestEventAt = useMemo(
() => eventTimeline.reduce<string | null>((latest, event) => {
if (!event.occurred_at) return latest;
if (!latest || event.occurred_at.localeCompare(latest) > 0) return event.occurred_at;
return latest;
}, null),
[eventTimeline],
);
const displayUpdatedAt = mission.updatedAt || latestEventAt;
const openAdvanced = () => {
const nextOpen = !advancedOpen;
setAdvancedOpen(nextOpen);
if (nextOpen && !sessionOpen) {
void loadSessionMessages();
}
};
return (
<div className="de-drawer" role="dialog" aria-label={mission.title}>
<div className="de-drawer-head">
<div className="de-drawer-title-group">
<span className="de-drawer-eyebrow"></span>
<h3>{mission.title}</h3>
</div>
<button className="de-btn-secondary" onClick={onClose} aria-label="关闭"></button>
</div>
<div className="de-drawer-status-line">
<StatusBadge variant={stepVariant(mission.status)} label={displayStatus(mission.status)} />
<span className="de-drawer-meta">{missionPeriodLabel(mission)}</span>
<span className="de-drawer-meta">{progressText(mission)}</span>
</div>
<div className="de-drawer-timeline">
<section className="de-detail-section">
<div className="de-detail-section-head">
<h4></h4>
</div>
<div className="de-detail-kv">
<span></span><strong>{mission.objective || '未填写'}</strong>
<span></span><strong>{missionPeriodLabel(mission)}</strong>
<span></span><strong>{progressText(mission)}</strong>
<span></span><strong>{fmtTime(mission.createdAt)}</strong>
<span></span><strong>{displayStatus(mission.status)}</strong>
<span></span><strong>{fmtTime(displayUpdatedAt)}</strong>
</div>
</section>
<section className="de-detail-section de-execution-chain">
<div className="de-detail-section-head">
<h4></h4>
<span className="de-chain-plan-id">{fmtTime(displayUpdatedAt || mission.createdAt)}</span>
</div>
{loading && businessTimeline.length === 0 ? (
<p className="de-empty-text">...</p>
) : businessTimeline.length === 0 ? (
<p className="de-empty-text"></p>
) : (
<div className="de-chain-list">
{businessTimeline.map((event) => (
<div key={`chain-${event.event_id}`} className={`de-chain-row de-chain-row--${event.kind} ${isFailureMessage(event.message) ? 'de-chain-row--danger' : ''}`}>
<strong>{businessKindLabel(event.kind, event.message)}</strong>
<span>{businessTimelineMessage(event.message)}</span>
<time>{fmtTime(event.occurred_at)}</time>
</div>
))}
</div>
)}
</section>
<section className="de-detail-section">
<h4></h4>
{resultEvents.length > 0 ? (
<div className="de-event-list">
{[...resultEvents].reverse().map((event) => (
<div key={`result-${event.event_id}`} className={`de-event-row ${isFailureMessage(event.message) ? 'de-event-row--danger' : ''}`}>
<strong>{businessKindLabel(event.kind, event.message)}</strong>
<span>{businessTimelineMessage(event.message)}</span>
<time>{fmtTime(event.occurred_at)}</time>
</div>
))}
</div>
) : (
<p className="de-empty-text"></p>
)}
</section>
<section className="de-detail-section">
<h4></h4>
{loading ? <p className="de-empty-text">...</p> : null}
{mission.steps.length === 0 ? (
<p className="de-empty-text"></p>
) : (
mission.steps.map((step, i) => (
<div key={step.id} className="de-step-node">
<div className={`de-step-dot de-step-dot--${step.status}`} />
{i < mission.steps.length - 1 && <div className="de-step-line" />}
<div className="de-step-content">
<strong>{step.title}</strong>
<span>{displayStatus(step.status)}</span>
</div>
</div>
))
)}
</section>
<section className="de-detail-section">
<div className="de-detail-section-head">
<h4></h4>
<button className="de-btn-secondary de-btn-sm" onClick={openAdvanced} disabled={sessionLoading}>
{advancedOpen ? '收起' : '展开'}
</button>
</div>
{advancedOpen ? (
<>
<section className="de-detail-section">
<div className="de-detail-section-head">
<h4></h4>
<span className="de-chain-plan-id"></span>
</div>
{eventTimeline.length > 0 ? (
<div className="de-event-list">
{eventTimeline.slice(-20).reverse().map((event) => (
<div key={event.event_id} className={`de-event-row ${isFailureMessage(event.message) ? 'de-event-row--danger' : ''}`}>
<strong>{displayEventKind(event.kind)}</strong>
<span>{event.message}</span>
<time>{fmtTime(event.occurred_at)}</time>
</div>
))}
</div>
) : (
<p className="de-empty-text"></p>
)}
</section>
<section className="de-detail-section">
<div className="de-detail-section-head">
<h4></h4>
<button className="de-btn-secondary de-btn-sm" onClick={loadSessionMessages} disabled={sessionLoading}>
</button>
</div>
{sessionError ? (
<p className="de-empty-text">{sessionError}</p>
) : sessionLoading ? (
<p className="de-empty-text">...</p>
) : sessionMessages.length === 0 ? (
<p className="de-empty-text"></p>
) : (
<div className="de-session-message-list">
{sessionMessages.map((message, index) => (
<div key={`${message.role}-${index}`} className={`de-session-message de-session-message--${message.role === 'user' ? 'user' : 'agent'}`}>
<div className="de-session-message-role">{messageRoleLabel(message.role)}</div>
<p>{displayBackendMessage(message.content, message.content)}</p>
</div>
))}
</div>
)}
</section>
<section className="de-detail-section">
<h4></h4>
{tasks.length === 0 ? (
<p className="de-empty-text"></p>
) : (
tasks.map((task) => {
const taskRuns = runsByTask.get(task.task_id) || [];
const latestRun = taskRuns[0];
return (
<div key={task.task_id} className="de-task-run-card">
<div className="de-task-run-head">
<span>{task.title || '子任务'}</span>
<StatusBadge variant={stepVariant(task.status)} label={displayStatus(task.status)} />
</div>
<div className="de-task-run-meta">
<span>skill_id{task.assigned_skill_id || '未分配'}</span>
<span>{fmtTime(task.started_at)}</span>
<span>{fmtTime(task.finished_at)}</span>
</div>
<div className="de-task-run-meta">
<span>task_id{task.task_id}</span>
<span>{taskRuns.length} </span>
{latestRun ? <span>{displayStatus(latestRun.lifecycle)}</span> : null}
</div>
{isFailed(task.status) && task.error_message ? (
<div className="de-task-run-error">
<strong></strong>{task.error_message}
</div>
) : null}
</div>
);
})
)}
</section>
</>
) : (
<p className="de-empty-text"></p>
)}
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
interface GemProps {
className?: string;
}
export default function Gem({ className = '' }: GemProps) {
return <div className={`de-gem ${className}`} aria-hidden="true" />;
}

View File

@@ -0,0 +1,15 @@
import type { ReactNode, CSSProperties } from 'react';
interface GlassCardProps {
children: ReactNode;
className?: string;
style?: CSSProperties;
}
export default function GlassCard({ children, className = '', style }: GlassCardProps) {
return (
<div className={`de-card ${className}`} style={style}>
{children}
</div>
);
}

View File

@@ -0,0 +1,238 @@
import StatusBadge from './StatusBadge';
import { displayStatus, type Mission } from '@/lib/consoleDataAdapter';
import { domSafeId } from '@/pages/digital/navigation';
interface MissionCardProps {
mission: Mission;
onSelect: (mission: Mission) => void;
onRun?: (mission: Mission) => void;
onRestart?: (mission: Mission) => void;
dateFilter?: string;
selected?: boolean;
focusLabel?: string | null;
running?: boolean;
}
function statusVariant(status: string): 'info' | 'success' | 'warning' | 'danger' {
switch (status) {
case 'succeeded': case 'completed': return 'success';
case 'failed': case 'error': return 'danger';
case 'running': case 'active': return 'info';
default: return 'warning';
}
}
function canRun(status: string): boolean {
return ['draft', 'reviewing', 'approved'].includes(status.toLowerCase());
}
function missionPeriodLabel(mission: Mission): string {
const text = `${mission.id} ${mission.title} ${mission.objective}`.toLowerCase();
if (text.includes('month') || text.includes('monthly') || text.includes('月')) return '月度任务';
if (text.includes('week') || text.includes('weekly') || text.includes('周')) return '周任务';
if (text.includes('临时') || text.includes('adhoc') || text.includes('hotlist') || text.includes('monitor')) return '临时任务';
return '日常任务';
}
function isDone(status: string): boolean {
return ['succeeded', 'success', 'finished', 'complete', 'completed'].includes(status.toLowerCase());
}
function isFailed(status: string): boolean {
return ['failed', 'error', 'cancelled', 'canceled', 'aborted', 'rejected', 'deadletter'].includes(status.toLowerCase());
}
function progressText(mission: Mission): string {
const total = mission.steps.length;
if (total === 0) return displayStatus(mission.status);
const done = mission.steps.filter((step) => isDone(step.status)).length;
const failed = mission.steps.filter((step) => isFailed(step.status)).length;
const running = mission.steps.filter((step) => ['running', 'active', 'queued', 'leased'].includes(step.status.toLowerCase())).length;
const parts = [`已完成 ${done}/${total}`];
if (failed > 0) parts.push(`失败 ${failed}`);
if (running > 0 && failed === 0) parts.push(`处理中 ${running}`);
return parts.join('');
}
function currentWorkText(mission: Mission): string {
const failedStep = mission.steps.find((step) => isFailed(step.status));
if (failedStep) return `执行失败:${failedStep.title}`;
const runningStep = mission.steps.find((step) => ['running', 'active'].includes(step.status.toLowerCase()));
if (runningStep) return `正在处理:${runningStep.title}`;
const nextStep = mission.steps.find((step) => !isDone(step.status));
if (nextStep) return `准备处理:${nextStep.title}`;
if (isDone(mission.status)) return '本轮任务已完成,等待你查看结果';
if (mission.status.toLowerCase() === 'failed') return '执行遇到异常,等待你检查后重新执行';
return '等待你委托数字员工开始处理';
}
function userNextActionText(mission: Mission, runnable: boolean): string {
const status = mission.status.toLowerCase();
if (status === 'failed' || status === 'error') return '查看详情后重新执行本任务';
if (['running', 'active'].includes(status)) return '等待完成或查看过程';
if (isDone(status)) return '查看产物,必要时重新执行';
if (runnable) return '确认目标后点击委托执行';
return '查看详情确认当前状态';
}
function artifactText(mission: Mission): string {
const text = `${mission.title} ${mission.objective}`.toLowerCase();
if (text.includes('excel') || text.includes('xlsx') || text.includes('导出') || text.includes('报表')) return '导出报表';
if (text.includes('brief') || text.includes('简报') || text.includes('日程')) return '工作简报';
if (text.includes('snapshot') || text.includes('快照') || text.includes('热榜')) return '结果快照';
if (isDone(mission.status)) return '查看完成结果';
return '完成后生成';
}
function dateKey(value?: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
function todayDateKey(): string {
return dateKey(new Date().toISOString());
}
function formatCardClock(value?: string | null): string {
if (!value) return '时间未记录';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '时间未记录';
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function formatSyncTime(value?: string | null): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function plannedClockFromTitle(mission: Mission): string | null {
const match = `${mission.title} ${mission.id}`.match(/(?:^|\D)(\d{1,2})[:](\d{2})(?:\D|$)/);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour > 23 || minute > 59) return null;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
function taskExecutionTimes(mission: Mission): string[] {
return mission.steps.flatMap((step) => (
step.tasks.flatMap((task) => [task.startedAt, task.finishedAt])
)).filter((value): value is string => Boolean(value));
}
function actualExecutionTimeForDate(mission: Mission, selectedDate: string): string | null {
const times = taskExecutionTimes(mission)
.filter((value) => dateKey(value) === selectedDate)
.sort((left, right) => right.localeCompare(left));
return times[0] ?? null;
}
function missionTimeText(mission: Mission, selectedDate: string): string {
const actualTime = actualExecutionTimeForDate(mission, selectedDate);
if (actualTime) return `实际执行 ${formatCardClock(actualTime)}`;
const pendingText = selectedDate === todayDateKey() ? '今日未执行' : '当日未执行';
const plannedClock = plannedClockFromTitle(mission);
return `计划执行 ${plannedClock || '待确认'}${pendingText}`;
}
export default function MissionCard({
mission,
onSelect,
onRun,
onRestart,
dateFilter,
selected,
focusLabel,
running,
}: MissionCardProps) {
const runnable = canRun(mission.status);
const restartMayOverwrite = isDone(mission.status) || mission.steps.some((step) => isDone(step.status));
const selectedDate = dateFilter || todayDateKey();
const syncTime = formatSyncTime(mission.updatedAt || mission.createdAt);
return (
<div
id={`mission-card-${domSafeId(mission.id)}`}
className={`de-mission-card ${selected ? 'de-mission-card--selected' : ''}`}
onClick={() => onSelect(mission)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(mission);
}
}}
role="button"
tabIndex={0}
>
<div className="de-mission-card-header">
<span className="de-mission-card-title">{mission.title}</span>
<div className="de-mission-card-badges">
{focusLabel && <span className="de-mission-card-focus-chip">{focusLabel}</span>}
<StatusBadge variant={statusVariant(mission.status)} label={displayStatus(mission.status)} />
</div>
</div>
{mission.objective && (
<p className="de-mission-card-desc">{mission.objective}</p>
)}
<div className="de-mission-card-meta">
<span>{missionPeriodLabel(mission)}</span>
<span>{progressText(mission)}</span>
{syncTime && <span> {syncTime}</span>}
</div>
<div className="de-mission-card-meta">
<span>{currentWorkText(mission)}</span>
</div>
<div className="de-mission-card-meta">
<span>{userNextActionText(mission, runnable)}</span>
<span>{artifactText(mission)}</span>
</div>
<div className="de-mission-card-actions">
<span className="de-mission-card-time">
{missionTimeText(mission, selectedDate)}
{restartMayOverwrite ? ';重新执行可能覆盖本任务结果' : ''}
</span>
<button
type="button"
className="de-btn-restart"
disabled={running}
onClick={(event) => {
event.stopPropagation();
if (!running) onRestart?.(mission);
}}
title={restartMayOverwrite ? '重新执行本任务,可能覆盖已有结果' : '重新执行本任务'}
>
</button>
<button
type="button"
className="de-btn-run"
disabled={!runnable || running}
onClick={(event) => {
event.stopPropagation();
if (runnable && !running) onRun?.(mission);
}}
>
{running ? '执行中...' : runnable ? '委托执行' : displayStatus(mission.status)}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
interface NavSkewButtonProps {
active?: boolean;
onClick?: () => void;
children: ReactNode;
}
export default function NavSkewButton({ active, onClick, children }: NavSkewButtonProps) {
return (
<button className={`de-nav-btn ${active ? 'active' : ''}`} onClick={onClick}>
<span>{children}</span>
</button>
);
}

View File

@@ -0,0 +1,10 @@
export default function Stage() {
return (
<div className="de-stage" aria-hidden="true">
<div className="de-stage-glow" />
<div className="de-stage-layer de-stage-layer-1" />
<div className="de-stage-layer de-stage-layer-2" />
<div className="de-stage-layer de-stage-layer-3" />
</div>
);
}

View File

@@ -0,0 +1,17 @@
type BadgeVariant = 'info' | 'success' | 'warning' | 'danger';
interface StatusBadgeProps {
variant: BadgeVariant;
label: string;
}
const variantClass: Record<BadgeVariant, string> = {
info: 'de-badge-info',
success: 'de-badge-success',
warning: 'de-badge-warning',
danger: 'de-badge-danger',
};
export default function StatusBadge({ variant, label }: StatusBadgeProps) {
return <span className={`de-badge ${variantClass[variant]}`}>{label}</span>;
}

View File

@@ -0,0 +1,158 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
export type ConsoleRole = 'finance' | 'engineering' | 'executive' | 'hr' | 'sales';
export type MotionLevel = 'none' | 'minimal';
export type Density = 'compact' | 'balanced' | 'presentation';
export type Tone = 'strict' | 'technical' | 'executive' | 'warm-professional';
export interface RoleProfile {
id: ConsoleRole;
labelKey: string;
density: Density;
tone: Tone;
contentPriority: string[];
motion: {
defaultLevel: MotionLevel;
allowParticles: false;
allowCelebrations: false;
allowDecorativeBadges: false;
};
actionPolicy: {
requireConfirmationForDestructive: boolean;
showReversalHint: boolean;
preferOneClickApproval: boolean;
};
staleDataThresholdMs: number;
cssVars: Record<string, string>;
}
export const ROLE_PROFILES: Record<ConsoleRole, RoleProfile> = {
finance: {
id: 'finance',
labelKey: 'console.role.finance',
density: 'compact',
tone: 'strict',
contentPriority: ['audit', 'sources', 'exactNumbers'],
motion: { defaultLevel: 'none', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
staleDataThresholdMs: 30000,
cssVars: {
'--role-accent': '#2563eb',
'--role-accent-soft': 'rgba(37,99,235,0.12)',
'--role-surface-tint': 'rgba(248,250,252,0.78)',
'--role-border': 'rgba(37,99,235,0.22)',
'--role-focus-ring': '#1d4ed8',
},
},
engineering: {
id: 'engineering',
labelKey: 'console.role.engineering',
density: 'compact',
tone: 'technical',
contentPriority: ['alarms', 'timestamps', 'reliability'],
motion: { defaultLevel: 'none', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
staleDataThresholdMs: 15000,
cssVars: {
'--role-accent': '#0f766e',
'--role-accent-soft': 'rgba(15,118,110,0.12)',
'--role-surface-tint': 'rgba(240,253,250,0.72)',
'--role-border': 'rgba(15,118,110,0.24)',
'--role-focus-ring': '#0f766e',
},
},
executive: {
id: 'executive',
labelKey: 'console.role.executive',
density: 'presentation',
tone: 'executive',
contentPriority: ['kpi', 'approvals', 'exactNumbers'],
motion: { defaultLevel: 'minimal', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: true },
staleDataThresholdMs: 60000,
cssVars: {
'--role-accent': '#334155',
'--role-accent-soft': 'rgba(51,65,85,0.10)',
'--role-surface-tint': 'rgba(248,250,252,0.82)',
'--role-border': 'rgba(51,65,85,0.20)',
'--role-focus-ring': '#475569',
},
},
hr: {
id: 'hr',
labelKey: 'console.role.hr',
density: 'balanced',
tone: 'warm-professional',
contentPriority: ['queues', 'timestamps', 'approvals'],
motion: { defaultLevel: 'minimal', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
staleDataThresholdMs: 45000,
cssVars: {
'--role-accent': '#7c3aed',
'--role-accent-soft': 'rgba(124,58,237,0.10)',
'--role-surface-tint': 'rgba(250,245,255,0.72)',
'--role-border': 'rgba(124,58,237,0.20)',
'--role-focus-ring': '#6d28d9',
},
},
sales: {
id: 'sales',
labelKey: 'console.role.sales',
density: 'balanced',
tone: 'warm-professional',
contentPriority: ['pipeline', 'sources', 'exactNumbers'],
motion: { defaultLevel: 'minimal', allowParticles: false, allowCelebrations: false, allowDecorativeBadges: false },
actionPolicy: { requireConfirmationForDestructive: true, showReversalHint: true, preferOneClickApproval: false },
staleDataThresholdMs: 45000,
cssVars: {
'--role-accent': '#b45309',
'--role-accent-soft': 'rgba(180,83,9,0.10)',
'--role-surface-tint': 'rgba(255,251,235,0.70)',
'--role-border': 'rgba(180,83,9,0.20)',
'--role-focus-ring': '#92400e',
},
},
};
interface RoleContextValue {
role: ConsoleRole;
profile: RoleProfile;
setRole: (role: ConsoleRole) => void;
}
const RoleContext = createContext<RoleContextValue | null>(null);
const STORAGE_KEY = 'sgrobot-console-role';
function getStoredRole(): ConsoleRole {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && stored in ROLE_PROFILES) return stored as ConsoleRole;
} catch { /* localStorage blocked */ }
return 'sales';
}
export function RoleProvider({ children }: { children: ReactNode }) {
const [role, setRoleState] = useState<ConsoleRole>(getStoredRole);
const setRole = useCallback((newRole: ConsoleRole) => {
setRoleState(newRole);
try { localStorage.setItem(STORAGE_KEY, newRole); } catch { /* noop */ }
}, []);
const profile = ROLE_PROFILES[role];
return (
<RoleContext.Provider value={{ role, profile, setRole }}>
<div style={profile.cssVars as React.CSSProperties} data-role={role}>
{children}
</div>
</RoleContext.Provider>
);
}
export function useRole(): RoleContextValue {
const ctx = useContext(RoleContext);
if (!ctx) throw new Error('useRole must be used within RoleProvider');
return ctx;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,706 @@
import type {
StatusResponse,
ToolSpec,
CronJob,
CronRun,
Integration,
DiagResult,
MemoryEntry,
CostSummary,
CliTool,
HealthSnapshot,
Session,
ChannelDetail,
BrowserSession,
UserNotification,
DebugStoreResponse,
DebugPlan,
DebugTask,
DebugRun,
DashboardResponse,
FlowResponse,
SkillSpec,
CreatePlanRequest,
RouteDecisionTimelineResponse,
IngressRouteRequest,
IngressRouteResponse,
GovernanceCommandRequest,
GovernanceCommandResponse,
DigitalEmployeeWorkdayProjection,
DigitalEmployeeWorkdayActionRequest,
PlanDispatchResponse,
} from '../types/api';
import { clearToken, getToken, setToken } from './auth';
import { apiOrigin, apiBase } from './basePath';
// ---------------------------------------------------------------------------
// Base fetch wrapper
// ---------------------------------------------------------------------------
const API_FETCH_TIMEOUT_MS = 9000;
const DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS = 20000;
type ApiFetchOptions = RequestInit & {
timeoutMs?: number;
};
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized');
this.name = 'UnauthorizedError';
}
}
export async function apiFetch<T = unknown>(
path: string,
options: ApiFetchOptions = {},
): Promise<T> {
const { timeoutMs = API_FETCH_TIMEOUT_MS, ...fetchOptions } = options;
const token = getToken();
const headers = new Headers(fetchOptions.headers);
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
const externalSignal = fetchOptions.signal;
const abortFromExternal = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
externalSignal.addEventListener('abort', abortFromExternal, { once: true });
}
}
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (
fetchOptions.body &&
typeof fetchOptions.body === 'string' &&
!headers.has('Content-Type')
) {
headers.set('Content-Type', 'application/json');
}
let response: Response;
try {
response = await fetch(`${apiOrigin()}${apiBase}${path}`, {
...fetchOptions,
headers,
signal: controller.signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError' && timedOut) {
throw new Error(`API request timed out after ${timeoutMs / 1000}s: ${path}`);
}
throw error;
} finally {
window.clearTimeout(timeout);
externalSignal?.removeEventListener('abort', abortFromExternal);
}
if (response.status === 401) {
clearToken();
window.dispatchEvent(new Event('zeroclaw-unauthorized'));
throw new UnauthorizedError();
}
if (!response.ok) {
const text = await response.text().catch(() => '');
let message = text || response.statusText;
if (text) {
try {
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
if (typeof parsed.message === 'string' && parsed.message.trim()) {
message = parsed.message.trim();
} else if (typeof parsed.error === 'string' && parsed.error.trim()) {
message = parsed.error.trim();
}
} catch {
message = text;
}
}
throw new Error(`API ${response.status}: ${message}`);
}
// Some endpoints may return 204 No Content
if (response.status === 204) {
return undefined as unknown as T;
}
return response.json() as Promise<T>;
}
function unwrapField<T>(value: T | Record<string, T>, key: string): T {
if (value !== null && typeof value === 'object' && !Array.isArray(value) && key in value) {
const unwrapped = (value as Record<string, T | undefined>)[key];
if (unwrapped !== undefined) {
return unwrapped;
}
}
return value as T;
}
// ---------------------------------------------------------------------------
// Pairing
// ---------------------------------------------------------------------------
export async function pair(code: string): Promise<{ token: string }> {
const response = await fetch(`${apiOrigin()}${apiBase}/pair`, {
method: 'POST',
headers: { 'X-Pairing-Code': code },
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Pairing failed (${response.status}): ${text || response.statusText}`);
}
const data = (await response.json()) as { token: string };
setToken(data.token);
return data;
}
export async function getAdminPairCode(): Promise<{ pairing_code: string | null; pairing_required: boolean }> {
const response = await fetch(`${apiOrigin()}${apiBase}/admin/paircode`);
if (!response.ok) {
throw new Error(`Failed to fetch pairing code (${response.status})`);
}
return response.json() as Promise<{ pairing_code: string | null; pairing_required: boolean }>;
}
// ---------------------------------------------------------------------------
// Public health (no auth required)
// ---------------------------------------------------------------------------
export async function getPublicHealth(): Promise<{ require_pairing: boolean; paired: boolean }> {
const response = await fetch(`${apiOrigin()}${apiBase}/health`);
if (!response.ok) {
throw new Error(`Health check failed (${response.status})`);
}
return response.json() as Promise<{ require_pairing: boolean; paired: boolean }>;
}
// ---------------------------------------------------------------------------
// Status / Health
// ---------------------------------------------------------------------------
export function getStatus(): Promise<StatusResponse> {
return apiFetch<StatusResponse>('/api/status');
}
export function getHealth(): Promise<HealthSnapshot> {
return apiFetch<HealthSnapshot | { health: HealthSnapshot }>('/api/health').then((data) =>
unwrapField(data, 'health'),
);
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
export function getConfig(): Promise<string> {
return apiFetch<string | { format?: string; content: string }>('/api/config').then((data) =>
typeof data === 'string' ? data : data.content,
);
}
export function putConfig(content: string, contentType = 'application/json'): Promise<void> {
return apiFetch<void>('/api/config', {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: content,
});
}
// ---------------------------------------------------------------------------
// Tools
// ---------------------------------------------------------------------------
export function getTools(): Promise<ToolSpec[]> {
return apiFetch<ToolSpec[] | { tools: ToolSpec[] }>('/api/tools').then((data) =>
unwrapField(data, 'tools'),
);
}
// ---------------------------------------------------------------------------
// Cron
// ---------------------------------------------------------------------------
export function getCronJobs(): Promise<CronJob[]> {
return apiFetch<CronJob[] | { jobs: CronJob[] }>('/api/cron').then((data) =>
unwrapField(data, 'jobs'),
);
}
export function getSchedulerJobs(): Promise<CronJob[]> {
return apiFetch<CronJob[] | { jobs: CronJob[] }>('/api/scheduler/jobs').then((data) =>
unwrapField(data, 'jobs'),
);
}
export function addCronJob(body: {
name?: string;
command?: string;
prompt?: string;
job_type?: string;
schedule: string;
enabled?: boolean;
session_target?: string;
model?: string;
allowed_tools?: string[];
delete_after_run?: boolean;
}): Promise<CronJob> {
return apiFetch<CronJob | { status: string; job: CronJob }>('/api/cron', {
method: 'POST',
body: JSON.stringify(body),
}).then((data) => (typeof (data as { job?: CronJob }).job === 'object' ? (data as { job: CronJob }).job : (data as CronJob)));
}
export function deleteCronJob(id: string): Promise<void> {
return apiFetch<void>(`/api/cron/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
export function patchCronJob(
id: string,
patch: { name?: string; schedule?: string; command?: string },
): Promise<CronJob> {
return apiFetch<CronJob | { status: string; job: CronJob }>(
`/api/cron/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify(patch),
},
).then((data) => (typeof (data as { job?: CronJob }).job === 'object' ? (data as { job: CronJob }).job : (data as CronJob)));
}
export function getCronRuns(
jobId: string,
limit: number = 20,
): Promise<CronRun[]> {
const params = new URLSearchParams({ limit: String(limit) });
return apiFetch<CronRun[] | { runs: CronRun[] }>(
`/api/cron/${encodeURIComponent(jobId)}/runs?${params}`,
).then((data) => unwrapField(data, 'runs'));
}
export interface CronSettings {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
}
export function getCronSettings(): Promise<CronSettings> {
return apiFetch<CronSettings>('/api/cron/settings');
}
export function patchCronSettings(
patch: Partial<CronSettings>,
): Promise<CronSettings> {
return apiFetch<CronSettings & { status: string }>('/api/cron/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
export function getIntegrations(): Promise<Integration[]> {
return apiFetch<Integration[] | { integrations: Integration[] }>('/api/integrations').then(
(data) => unwrapField(data, 'integrations'),
);
}
// ---------------------------------------------------------------------------
// Doctor / Diagnostics
// ---------------------------------------------------------------------------
export function runDoctor(): Promise<DiagResult[]> {
return apiFetch<DiagResult[] | { results: DiagResult[]; summary?: unknown }>('/api/doctor', {
method: 'POST',
body: JSON.stringify({}),
}).then((data) => (Array.isArray(data) ? data : data.results));
}
// ---------------------------------------------------------------------------
// Memory
// ---------------------------------------------------------------------------
export function getMemory(
query?: string,
category?: string,
): Promise<MemoryEntry[]> {
const params = new URLSearchParams();
if (query) params.set('query', query);
if (category) params.set('category', category);
const qs = params.toString();
return apiFetch<MemoryEntry[] | { entries: MemoryEntry[] }>(`/api/memory${qs ? `?${qs}` : ''}`).then(
(data) => unwrapField(data, 'entries'),
);
}
export function storeMemory(
key: string,
content: string,
category?: string,
): Promise<void> {
return apiFetch<unknown>('/api/memory', {
method: 'POST',
body: JSON.stringify({ key, content, category }),
}).then(() => undefined);
}
export function deleteMemory(key: string): Promise<void> {
return apiFetch<void>(`/api/memory/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
}
// ---------------------------------------------------------------------------
// Cost
// ---------------------------------------------------------------------------
export function getCost(): Promise<CostSummary> {
return apiFetch<CostSummary | { cost: CostSummary }>('/api/cost').then((data) =>
unwrapField(data, 'cost'),
);
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
export function getSessions(): Promise<Session[]> {
return apiFetch<{ sessions: Session[] }>('/api/sessions').then((data) =>
data.sessions ?? [],
);
}
export function getSession(id: string): Promise<Session> {
return apiFetch<Session>(`/api/sessions/${encodeURIComponent(id)}`);
}
export function renameSession(id: string, name: string): Promise<{ session_id: string; name: string }> {
return apiFetch<{ session_id: string; name: string }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify({ name }),
});
}
// ---------------------------------------------------------------------------
// Channels (detailed)
// ---------------------------------------------------------------------------
export function getChannels(): Promise<ChannelDetail[]> {
return apiFetch<ChannelDetail[] | { channels: ChannelDetail[] }>('/api/channels').then((data) =>
unwrapField(data, 'channels'),
);
}
// ---------------------------------------------------------------------------
// CLI Tools
// ---------------------------------------------------------------------------
export function getCliTools(): Promise<CliTool[]> {
return apiFetch<CliTool[] | { cli_tools: CliTool[] }>('/api/cli-tools').then((data) =>
unwrapField(data, 'cli_tools'),
);
}
// ---------------------------------------------------------------------------
// Browser / SuperRPA
// ---------------------------------------------------------------------------
export interface LaunchBrowserResponse {
session_id: string;
status: string;
}
export interface BrowserStatusResponse {
running: boolean;
sessions: BrowserSession[];
}
export function launchBrowser(url?: string, superrpaPath?: string): Promise<LaunchBrowserResponse> {
return apiFetch<LaunchBrowserResponse>('/api/browser/launch', {
method: 'POST',
body: JSON.stringify({ url: url || '', superrpa_path: superrpaPath || '' }),
});
}
export function getBrowserStatus(): Promise<BrowserStatusResponse> {
return apiFetch<BrowserStatusResponse>('/api/browser/status');
}
export function closeBrowserSession(sessionId: string): Promise<void> {
return apiFetch<void>(`/api/browser/session/${encodeURIComponent(sessionId)}/close`, {
method: 'POST',
});
}
// ---------------------------------------------------------------------------
// Plans
// ---------------------------------------------------------------------------
export function getPlans() {
return apiFetch<{ plans: import('../types/api').PlanSummary[] }>('/api/debug/plans');
}
export interface PlanMessage {
role: string;
content: string;
created_at?: string | null;
}
export function getSessionMessages(sessionId: string): Promise<{ messages: PlanMessage[] }> {
return apiFetch<{ session_id: string; messages: PlanMessage[] }>(
`/api/sessions/${encodeURIComponent(sessionId)}/messages`,
).then((d) => ({ messages: d.messages || [] }));
}
export function getPlanMessages(planId: string): Promise<{ messages: PlanMessage[] }> {
return apiFetch<{ plan_id: string; messages: PlanMessage[] }>(
`/api/plan/${encodeURIComponent(planId)}/messages`,
).then((d) => ({ messages: d.messages || [] }));
}
// ---------------------------------------------------------------------------
// Execution governance
// ---------------------------------------------------------------------------
export function getRouteDecisionTimeline(): Promise<RouteDecisionTimelineResponse> {
return apiFetch<RouteDecisionTimelineResponse>('/api/route-decisions');
}
export function routeIngress(
body: IngressRouteRequest,
): Promise<IngressRouteResponse> {
return apiFetch<IngressRouteResponse>('/api/ingress/route', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function governanceCommand(
body: GovernanceCommandRequest,
): Promise<GovernanceCommandResponse> {
return apiFetch<GovernanceCommandResponse>('/api/governance/command', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function runSchedulerNow(): Promise<GovernanceCommandResponse> {
const stamp = Date.now();
return governanceCommand({
command_kind: 'run_schedule_now',
context_refs: { schedule_id: 'scheduler-manual-check' },
payload: { source: 'scheduler-jobs-page' },
correlation_id: `ui-run-schedule-now-${stamp}`,
idempotency_key: `ui-run-schedule-now-${stamp}`,
});
}
export function getPlanRevisions(planId: string): Promise<any> {
return apiFetch(`/api/plan/${encodeURIComponent(planId)}/revisions`);
}
export function getEventLog(params: {
correlation_id?: string;
plan_id?: string;
task_id?: string;
run_id?: string;
limit?: number;
} = {}): Promise<any> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}`.trim() !== '') {
query.set(key, `${value}`);
}
});
const suffix = query.toString();
return apiFetch(`/api/event-log${suffix ? `?${suffix}` : ''}`);
}
// ---------------------------------------------------------------------------
// Notifications
// ---------------------------------------------------------------------------
export { type UserNotification };
export function getNotifications(
status: string = 'unread',
): Promise<UserNotification[]> {
return apiFetch<{ notifications: UserNotification[] }>(
`/api/notifications?status=${encodeURIComponent(status)}`,
).then((d) => d.notifications ?? []);
}
export function getUnreadNotificationCount(): Promise<number> {
return apiFetch<{ count: number }>('/api/notifications/unread-count').then(
(d) => d.count ?? 0,
);
}
export function markNotificationRead(id: string): Promise<boolean> {
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
method: 'POST',
}).then((d) => d.ok ?? false);
}
export function dismissNotification(id: string): Promise<boolean> {
return apiFetch<{ ok: boolean }>(
`/api/notifications/${encodeURIComponent(id)}/dismiss`,
{ method: 'POST' },
).then((d) => d.ok ?? false);
}
export function replyToNotification(
id: string,
content: string,
): Promise<{ ok: boolean; task_id?: string; status?: string }> {
return apiFetch<{ ok: boolean; task_id?: string; status?: string }>(
`/api/notifications/${encodeURIComponent(id)}/reply`,
{
method: 'POST',
body: JSON.stringify({ content }),
},
);
}
// ---------------------------------------------------------------------------
// Console: Debug Store, Plans, Tasks
// ---------------------------------------------------------------------------
export function getDebugStore(): Promise<DebugStoreResponse> {
return apiFetch<DebugStoreResponse>('/api/debug/store?limit=200');
}
export function getDashboardStats(): Promise<DashboardResponse> {
return apiFetch<DashboardResponse | { tasks: DashboardResponse['tasks'] }>('/api/dashboard')
.then((data) => ('tasks' in data && !('runtime' in data))
? { runtime: { active_tasks: 0, failed_tasks: 0, pending_confirmations: 0 }, tasks: data.tasks, approvals: { total: 0, pending: 0 } }
: data as DashboardResponse);
}
export function getDigitalEmployeeWorkday(date?: string): Promise<DigitalEmployeeWorkdayProjection> {
const query = date ? `?date=${encodeURIComponent(date)}` : '';
return apiFetch<DigitalEmployeeWorkdayProjection>(`/api/digital-employee/workday${query}`, {
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
});
}
export function postDigitalEmployeeWorkdayAction(
body: DigitalEmployeeWorkdayActionRequest,
): Promise<DigitalEmployeeWorkdayProjection> {
return apiFetch<DigitalEmployeeWorkdayProjection>('/api/digital-employee/workday/actions', {
method: 'POST',
body: JSON.stringify(body),
});
}
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',
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
});
}
export function getDebugPlans(): Promise<DebugPlan[]> {
return apiFetch<{ plans: DebugPlan[] }>('/api/debug/plans?limit=80')
.then((data) => data.plans ?? []);
}
export function searchDebugPlans(query: string, limit = 50): Promise<DebugPlan[]> {
const params = new URLSearchParams({ limit: String(limit), q: query });
return apiFetch<{ plans: DebugPlan[] }>(`/api/debug/plans?${params}`)
.then((data) => data.plans ?? []);
}
export function getDebugTasks(): Promise<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }> {
return apiFetch<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }>('/api/debug/tasks?limit=80')
.then((data) => ({ tasks: data.tasks ?? [], runs: data.runs ?? [], total: data.total, truncated: data.truncated }));
}
export function getDebugTasksForPlan(planId: string): Promise<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }> {
const params = new URLSearchParams({ limit: '50', plan_id: planId });
return apiFetch<{ tasks: DebugTask[]; runs: DebugRun[]; total?: number; truncated?: boolean }>(`/api/debug/tasks?${params}`)
.then((data) => ({ tasks: data.tasks ?? [], runs: data.runs ?? [], total: data.total, truncated: data.truncated }));
}
export function getDebugPlanFlow(planId: string): Promise<FlowResponse> {
return apiFetch<FlowResponse>(`/api/debug/plan/${encodeURIComponent(planId)}/flow`);
}
export function getDebugTaskFlow(taskId: string): Promise<FlowResponse> {
return apiFetch<FlowResponse>(`/api/debug/task/${encodeURIComponent(taskId)}/flow`);
}
export function getSkills(): Promise<SkillSpec[]> {
return apiFetch<{ skills: SkillSpec[] }>('/api/skill')
.then((data) => data.skills ?? []);
}
export function setSkillEnabled(skillId: string, enabled: boolean): Promise<{ ok: boolean; skill_id: string; enabled: boolean; error?: string }> {
return apiFetch<{ ok: boolean; skill_id: string; enabled: boolean; error?: string }>(
`/api/skill/${encodeURIComponent(skillId)}/enabled`,
{
method: 'POST',
body: JSON.stringify({ enabled }),
},
);
}
export function createPlan(body: CreatePlanRequest): Promise<{ ok?: boolean; plan_id: string; task_count?: number }> {
return apiFetch('/api/debug/plan/create', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function planAction(planId: string, action: string): Promise<any> {
const endpoint = action === 'submit'
? `/api/plan/${encodeURIComponent(planId)}/submit`
: `/api/debug/plan/${encodeURIComponent(planId)}/${action}`;
return apiFetch(endpoint, { method: 'POST' });
}
export function taskAction(taskId: string, action: string): Promise<any> {
return apiFetch(`/api/task/${encodeURIComponent(taskId)}/${action}`, { method: 'POST' });
}
/**
* Legacy compatibility only.
* Current React pages must trigger scheduler checks through runSchedulerNow()
* so backend governance rejection and command errors are surfaced correctly.
*/
export function triggerCronCheck(): Promise<{ ok?: boolean; activated: number }> {
return apiFetch<{ ok?: boolean; activated: number }>('/api/cron/check', { method: 'POST' });
}
export function getAuditTrace(kind: string, id: string): Promise<any> {
const params = new URLSearchParams({ kind, id });
return apiFetch(`/api/audit?${params}`);
}
export function getArtifacts(): Promise<any[]> {
return apiFetch<{ artifacts: any[] }>('/api/artifact')
.then((data) => data.artifacts ?? []);
}
export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []);
}

View File

@@ -0,0 +1,42 @@
const TOKEN_KEY = 'zeroclaw_token';
/**
* Retrieve the stored authentication token.
*/
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
/**
* Store an authentication token.
*/
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
} catch {
// localStorage may be unavailable (e.g. in some private browsing modes)
}
}
/**
* Remove the stored authentication token.
*/
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
} catch {
// Ignore
}
}
/**
* Returns true if a token is currently stored.
*/
export function isAuthenticated(): boolean {
const token = getToken();
return token !== null && token.length > 0;
}

View File

@@ -0,0 +1,15 @@
declare global {
interface Window {
__SGROBOT_BASE__?: string;
__QIMINGCLAW_DIGITAL_API_BASE__?: string;
}
}
export const basePath = '.';
export const apiBase: string = (() => {
const configured = window.__QIMINGCLAW_DIGITAL_API_BASE__?.replace(/\/+$/, '');
return configured ?? '';
})();
export const apiOrigin = (): string => '';

View File

@@ -0,0 +1,488 @@
// consoleDataAdapter.ts — API 类型 → 业务 ViewModel 映射
// API 类型参考: src/types/api.ts (DebugStoreResponse, DebugPlan, SkillSpec, etc.)
// ── View Models ──
export interface DashboardOverview {
generatedAt: string;
isPartial: boolean;
sources: DataSourceStatus[];
taskTotals: { total: number; queued: number; running: number; succeeded: number; failed: number };
approvals: { total: number; pending: number };
health: { activeTasks: number; failedTasks: number; pendingConfirmations: number };
}
export interface Mission {
id: string;
title: string;
objective: string;
status: string;
createdAt: string | null;
updatedAt: string | null;
steps: MissionStep[];
sourceIds: { planId?: string; taskIds: string[] };
}
export interface MissionStep {
id: string;
title: string;
description?: string;
status: string;
preferredSkills: string[];
tasks: Array<{
id: string;
title: string;
description?: string;
status: string;
assignedSkillId: string | null;
startedAt: string | null;
finishedAt: string | null;
}>;
}
export interface ApprovalItem {
id: string;
missionId: string | null;
taskId: string | null;
status: string;
title: string;
createdAt: string | null;
reversible: boolean;
}
export interface JournalEntry {
id: string;
kind: string;
source: string;
message: string;
occurredAt: string | null;
planId: string | null;
taskId: string | null;
sourceLink?: string;
}
export interface SkillSummary {
id: string;
name: string;
version: string | null;
enabled: boolean;
description: string;
category: string;
capabilityLevel: 'core' | 'standard' | 'specialized' | 'experimental';
source: 'skill-api';
}
export interface DataSourceStatus {
endpoint: string;
ok: boolean;
loadedAt?: string;
errorMessage?: string;
}
// ── 内部 helper ──
type Obj = Record<string, unknown>;
function arr(v: unknown): Obj[] {
return Array.isArray(v) ? (v as Obj[]) : [];
}
function str(v: unknown, fallback = ''): string {
if (typeof v === 'string') return v;
if (typeof v === 'number') return String(v);
return fallback;
}
function hasChinese(v: string): boolean {
return /[\u4e00-\u9fff]/.test(v);
}
const STATUS_ZH: Record<string, string> = {
queued: '排队中',
ready: '就绪',
idle: '待执行',
pending: '待处理',
pending_authorization: '待授权',
waiting_input: '待输入',
draft: '草稿',
reviewing: '审核中',
approved: '已批准',
preparing: '准备中',
running: '执行中',
active: '运行中',
blocked: '已阻塞',
succeeded: '已完成',
success: '成功',
finished: '已完成',
complete: '已完成',
completed: '已完成',
failed: '失败',
error: '错误',
cancelled: '已取消',
canceled: '已取消',
aborted: '已终止',
rejected: '已拒绝',
deferred: '已暂缓',
dispatched: '已下发',
scheduled: '已设置定时',
enabled: '已启用',
disabled: '已停用',
paused: '已暂停',
unknown: '未知',
};
const EVENT_KIND_ZH: Record<string, string> = {
plan: '计划',
task: '任务',
task_running: '处理中',
task_done: '任务完成',
run: '执行开始',
run_done: '执行完成',
skill_tool: '技能调用',
artifact: '产物',
task_summary: '结果',
entered_session: '会话',
start: '目标',
RunStarted: '开始调用',
RequestDispatched: '请求已发出',
ResponseReceived: '收到返回',
RunSkipped: '本次跳过',
RunCompleted: '执行完成',
waiting_for_authorization: '等待授权',
pending_authorization: '等待授权',
authorization_approved: '授权通过',
authorization_rejected: '授权拒绝',
authorization_deferred: '稍后处理',
failed: '执行失败',
completed: '执行完成',
};
export function displayStatus(status: unknown, fallback = '未知'): string {
const raw = str(status).trim();
if (!raw) return fallback;
const mapped = STATUS_ZH[raw] ?? STATUS_ZH[raw.toLowerCase()];
if (mapped) return mapped;
return hasChinese(raw) ? raw : fallback;
}
export function displayEventKind(kind: unknown): string {
const raw = str(kind).trim();
if (!raw) return '事件';
return EVENT_KIND_ZH[raw] ?? EVENT_KIND_ZH[raw.toLowerCase()] ?? (hasChinese(raw) ? raw : '事件');
}
export function displayBackendMessage(message: unknown, fallback = '暂无事件内容'): string {
const raw = str(message).trim();
if (!raw) return fallback;
if (hasChinese(raw)) return raw;
const status = displayStatus(raw, '');
if (status) return status;
const attempt = raw.match(/^(Succeeded|Failed|Running|Completed)\s*\(attempt\s*#(\d+)\)$/i);
if (attempt) {
return `${displayStatus(attempt[1])}(第 ${attempt[2]} 次)`;
}
if (/request failed/i.test(raw)) return '请求失败';
if (/backend unavailable/i.test(raw)) return '后端服务不可达';
if (/not found/i.test(raw)) return '未找到对应资源';
return fallback;
}
export function displayBusinessText(value: unknown, fallback = '未命名内容'): string {
const raw = str(value).trim();
if (!raw) return fallback;
if (/unnamed/i.test(raw)) return fallback;
if (hasChinese(raw)) return raw;
const status = displayStatus(raw, '');
if (status) return status;
const skillName = knownSkillName(raw);
if (skillName) return skillName;
return raw;
}
function isTerminalDoneStatus(status: string): boolean {
return ['succeeded', 'success', 'finished', 'complete', 'completed'].includes(status.toLowerCase());
}
function isTerminalFailedStatus(status: string): boolean {
return ['failed', 'error', 'cancelled', 'canceled', 'aborted', 'rejected', 'deadletter'].includes(status.toLowerCase());
}
function deriveStepStatus(rawStatus: unknown, tasks: MissionStep['tasks']): string {
const status = str(rawStatus).trim().toLowerCase();
if (tasks.length === 0) return status || 'pending';
if (tasks.some((task) => isTerminalFailedStatus(task.status))) return 'failed';
if (tasks.some((task) => ['running', 'active', 'leased'].includes(task.status.toLowerCase()))) return 'running';
if (tasks.some((task) => ['queued', 'ready', 'pending'].includes(task.status.toLowerCase()))) return 'queued';
if (tasks.every((task) => isTerminalDoneStatus(task.status))) return 'succeeded';
return status || 'pending';
}
function deriveMissionStatus(rawStatus: unknown, steps: MissionStep[]): string {
const status = str(rawStatus, 'unknown').trim().toLowerCase();
if (isTerminalDoneStatus(status) || isTerminalFailedStatus(status)) return status;
if (steps.some((step) => isTerminalFailedStatus(step.status))) return 'failed';
if (steps.some((step) => ['running', 'active', 'queued', 'leased'].includes(step.status.toLowerCase()))) return 'running';
if (steps.length > 0 && steps.every((step) => isTerminalDoneStatus(step.status))) return 'succeeded';
return status || 'unknown';
}
// ── 适配函数 ──
/**
* @param storeData — getDebugStore() 返回值 (DebugStoreResponse)
* @param skillsData — getSkills() 返回值 (SkillSpec[])
*
* DebugStoreResponse 形状: { tables: { tasks, runs, approvals, ... } }
* SkillSpec 形状: { skill_id, name, version, enabled, description }
* DebugPlan 形状: { plan_id, title, objective, status, created_at, steps }
*/
export function adaptDashboardOverview(
storeData: Obj | null
): DashboardOverview {
const now = new Date().toISOString();
const tables = (storeData?.tables ?? {}) as Obj;
const tasks = arr(tables.tasks);
const approvals = arr(tables.approvals);
const taskTotals = {
total: tasks.length,
queued: tasks.filter(t => t.status === 'queued').length,
running: tasks.filter(t => t.status === 'running').length,
succeeded: tasks.filter(t => t.status === 'succeeded').length,
failed: tasks.filter(t => t.status === 'failed').length,
};
return {
generatedAt: now,
isPartial: !storeData,
sources: [
{ endpoint: '/api/debug/store', ok: storeData !== null, loadedAt: now },
],
taskTotals,
approvals: {
total: approvals.length,
pending: approvals.filter(a => a.status === 'pending').length,
},
health: {
activeTasks: taskTotals.running,
failedTasks: taskTotals.failed,
pendingConfirmations: approvals.filter(a => a.status === 'pending').length,
},
};
}
export function adaptMissions(plansData: Obj[] | null): Mission[] {
if (!plansData) return [];
return plansData.map((p: Obj) => {
const planId = str(p.plan_id);
const stepsRaw = arr(p.steps);
const steps: MissionStep[] = stepsRaw.map((s: Obj) => {
const taskList = arr(s.tasks);
const tasks = taskList.map((t: Obj) => ({
id: str(t.task_id),
title: displayBusinessText(t.title, '子任务'),
description: str(t.description),
status: str(t.status, 'pending'),
assignedSkillId: t.assigned_skill_id ? str(t.assigned_skill_id) : null,
startedAt: t.started_at ? str(t.started_at) : null,
finishedAt: t.finished_at ? str(t.finished_at) : null,
}));
return {
id: str(s.step_id),
title: displayBusinessText(s.title, '任务步骤'),
description: str(s.description),
status: deriveStepStatus(s.status, tasks),
preferredSkills: Array.isArray(s.preferred_skills) ? (s.preferred_skills as string[]) : [],
tasks,
};
});
const taskIds: string[] = [];
for (const s of steps) {
for (const t of s.tasks) {
taskIds.push(t.id);
}
}
return {
id: planId,
title: displayBusinessText(p.title, '未命名任务'),
objective: displayBusinessText(p.objective || p.description, '暂无任务说明'),
status: deriveMissionStatus(p.status, steps),
createdAt: p.created_at ? str(p.created_at) : null,
updatedAt: p.updated_at ? str(p.updated_at) : null,
steps,
sourceIds: { planId: planId || undefined, taskIds },
};
});
}
const SKILL_ZH: Record<string, string> = {
'decision-analyzer': '决策分析',
'git-log-collector': 'Git 日志采集',
'number-generator': '数据生成',
'office-export-xlsx': 'Excel 导出',
'task-tracker': '任务跟踪',
'weekly-report-writer': '周报撰写',
'zhihu-hotlist-detail': '知乎热榜明细',
'zhihu-hotlist': '知乎热榜',
'tq-lineloss-report': '台区线损报表',
'archive-workorder-grid-push-monitor': '归档工单推送监控',
'available-balance-below-zero-monitor': '可用余额监控',
'command-center-fee-control-monitor': '费用管控监控',
'sgcc-todo-crawler': 'SGCC 待办爬取',
'appointment-workorder-monitor': '约时工单监控',
'fault-location-monitor': '故障定位监控',
'fudian-failure-monitor': '复电失败监控',
'important-service-expiry-monitor': '服务到期预警',
'by-95598-customer-satisfaction-report': '95598 满意率日报',
'by-daily-report-statistics': '日报统计',
'by-fire-fault-monthly-report': '火警故障月报',
'by-risk-control-next-day-plan-report': '风控次日计划',
'by-risk-control-week-plan-report': '风控周计划',
'by-supervision-workorder-detail-report': '督办工单明细',
'age-file-encryption': '文件加解密',
'anonymous-file-upload': '匿名文件上传',
'browser-automation-agent': '浏览器自动化',
'bulk-github-star': '批量仓库标星',
'changelog-generator': '更新日志生成',
'code-reviewer': '代码审查',
'commit-automation': '自动提交',
'crypto-trading-signal': '交易信号分析',
'csv-to-json': 'CSV 转 JSON',
'data-analysis': '数据分析',
'deep-research': '深度调研',
'dependency-updater': '依赖更新',
'deploy-automation': '自动部署',
'dns-lookup': 'DNS 查询',
'document-generator': '文档生成',
'email-automation': '邮件自动化',
'file-converter': '文件格式转换',
'git-history-analyzer': 'Git 历史分析',
'github-issue-manager': 'Issue 管理',
'image-optimizer': '图片优化',
'json-schema-validator': 'JSON Schema 校验',
'log-analyzer': '日志分析',
'markdown-to-pdf': 'Markdown 转 PDF',
'meeting-summarizer': '会议纪要',
'news-aggregator': '新闻聚合',
'pdf-extractor': 'PDF 提取',
'performance-profiler': '性能分析',
'pr-review-bot': 'PR 审查',
'release-notes-generator': '发布说明生成',
'screenshot-automation': '截图自动化',
'search-engine-scraper': '搜索引擎抓取',
'sentiment-analysis': '情感分析',
'shell-command-runner': '命令执行',
'slack-notification': 'Slack 通知',
'spreadsheet-generator': '电子表格生成',
'text-summarizer': '文本摘要',
'torrent-search': '资源搜索',
'translate-text': '文本翻译',
'web-scraper': '网页抓取',
'website-monitor': '网站监控',
'whitepaper-analyzer': '白皮书分析',
'xml-to-json': 'XML 转 JSON',
};
function knownSkillName(skillId: string): string | null {
if (SKILL_ZH[skillId]) return SKILL_ZH[skillId];
for (const [key, val] of Object.entries(SKILL_ZH)) {
if (skillId.includes(key) || key.includes(skillId)) return val;
}
return null;
}
function zhName(skillId: string, fallback: string): string {
const known = knownSkillName(skillId);
if (known) return known;
return hasChinese(fallback) ? fallback : '自动化技能';
}
const SKILL_CATEGORY: Record<string, string> = {
'decision-analyzer': '数字化', 'git-log-collector': '数字化', 'number-generator': '数字化',
'office-export-xlsx': '办公', 'task-tracker': '办公', 'weekly-report-writer': '报表',
'zhihu-hotlist-detail': '数字化', 'zhihu-hotlist': '数字化', 'tq-lineloss-report': '报表',
'archive-workorder-grid-push-monitor': '监控巡检', 'available-balance-below-zero-monitor': '监控巡检',
'command-center-fee-control-monitor': '监控巡检', 'sgcc-todo-crawler': '监控巡检',
'appointment-workorder-monitor': '监控巡检', 'fault-location-monitor': '监控巡检',
'fudian-failure-monitor': '监控巡检', 'important-service-expiry-monitor': '监控巡检',
'by-95598-customer-satisfaction-report': '报表', 'by-daily-report-statistics': '报表',
'by-fire-fault-monthly-report': '报表', 'by-risk-control-next-day-plan-report': '报表',
'by-risk-control-week-plan-report': '报表', 'by-supervision-workorder-detail-report': '报表',
'age-file-encryption': '数字化', 'anonymous-file-upload': '通信', 'browser-automation-agent': '数字化',
'bulk-github-star': '发展', 'changelog-generator': '发展', 'chat-logger': '通信',
'check-crypto-address-balance': '财务', 'city-distance': '发展', 'city-tourism-website-builder': '营销',
'crawl-websites-at-scale': '数字化', 'csv-data-summarizer': '财务', 'd3js-data-visualization': '数字化',
'database-query-and-export': '数字化', 'file-tracker': '综合', 'free-geocoding-and-maps': '发展',
'free-translation-api': '综合', 'free-weather-data': '综合', 'generate-asset-price-chart': '财务',
'generate-qr-code-natively': '数字化', 'get-crypto-price': '财务', 'git-worktree-cli': '发展',
'github-stats': '发展', 'html-to-markdown': '数字化', 'image-compressor': '综合',
'json-to-csv': '数字化', 'llm-council': '数字化', 'markdown-table-generator': '综合',
'multi-format-converter': '综合', 'npm-package-info': '发展', 'pdf-generator': '综合',
'random-number-generator': '综合', 'readability-score': '综合', 'rss-feed-reader': '通信',
'sql-formatter': '数字化', 'text-diff': '综合', 'torrent-search': '通信',
'unit-converter': '综合', 'url-shortener': '通信', 'youtube-transcript': '通信', 'zip-extractor': '综合',
};
function categoryFor(skillId: string): string {
if (SKILL_CATEGORY[skillId]) return SKILL_CATEGORY[skillId];
return '综合';
}
function zhDescription(skillId: string, rawDescription: unknown, name: string): string {
const description = str(rawDescription).trim();
if (description && hasChinese(description)) return description;
if (skillId.includes('monitor')) return `${name}能力,用于按排班自动巡检并提示异常。`;
if (skillId.includes('report')) return `${name}能力,用于自动生成和整理业务报表。`;
return `${name}能力,可由数字员工按需执行或定时处理。`;
}
export function adaptSkillSummaries(skillsData: Obj[] | null): SkillSummary[] {
if (!skillsData) return [];
return skillsData.map((s: Obj) => {
const sid = str(s.skill_id, str(s.id));
return {
id: sid,
name: zhName(sid, str(s.name, sid)),
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,
source: 'skill-api' as const,
};
});
}
export function adaptJournalEntries(storeData: Obj | null): JournalEntry[] {
if (!storeData) return [];
const tables = (storeData.tables ?? {}) as Obj;
const events = arr(tables.canonical_events);
return events.map((e: Obj) => ({
id: str(e.event_id, str(e.id)),
kind: str(e.event_type ?? e.kind, 'unknown'),
source: 'debug-store',
message: str(e.message, ''),
occurredAt: e.occurred_at ? str(e.occurred_at) : e.timestamp ? str(e.timestamp) : null,
planId: e.plan_id ? str(e.plan_id) : null,
taskId: e.task_id ? str(e.task_id) : null,
}));
}
export function adaptApprovals(storeData: Obj | null): ApprovalItem[] {
if (!storeData) return [];
const tables = (storeData.tables ?? {}) as Obj;
const approvals = arr(tables.approvals);
return approvals.map((a: Obj) => ({
id: str(a.approval_id, str(a.id)),
missionId: a.plan_id ? str(a.plan_id) : null,
taskId: a.task_id ? str(a.task_id) : null,
status: str(a.status, 'pending'),
title: displayBusinessText(a.title, '待处理授权'),
createdAt: a.created_at ? str(a.created_at) : null,
reversible: Boolean(a.reversible),
}));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
declare global {
interface Window {
__TAURI__?: unknown;
__ZEROCLAW_GATEWAY__?: string;
__TAURI_INTERNALS__?: unknown;
}
}
export const isTauri = (): boolean => false;
export const tauriGatewayUrl = (): string => '';

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import DigitalEmployeeShell from './pages/digital/DigitalEmployeeShell';
import './index.css';
function App() {
return (
<HashRouter>
<Routes>
<Route path="/digital" element={<DigitalEmployeeShell />} />
<Route path="*" element={<Navigate to="/digital" replace />} />
</Routes>
</HashRouter>
);
}
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,322 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { Download } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
digitalEmployeeReportPdfUrl,
getDigitalEmployeeWorkday,
postDigitalEmployeeWorkdayAction,
} from '@/lib/api';
import type {
DigitalEmployeeDailyReport,
DigitalEmployeeRunDetail,
DigitalEmployeeTaskExecutionSummary,
DigitalEmployeeWorkdayProjection,
} from '@/types/api';
const pageGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: 12,
};
const panelStyle: CSSProperties = {
border: '1px solid var(--de-glass-border-lighter)',
borderRadius: 10,
padding: '12px 14px',
background: 'linear-gradient(180deg, rgba(244,250,255,0.82), rgba(235,246,255,0.58))',
};
type BusyAction = 'update_report_schedule' | 'generate_daily_report' | null;
function todayInputDate(): string {
const date = new Date();
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
function reportStatusLabel(report?: DigitalEmployeeDailyReport | null): string {
if (report?.status === 'ready') return '已生成';
if (report?.status === 'generating') return '生成中';
if (report?.status === 'failed') return '生成失败';
return '待生成';
}
function reportStatusClass(report?: DigitalEmployeeDailyReport | null): string {
return report?.status === 'ready' ? 'de-report-badge--ready' : 'de-report-badge--waiting';
}
function reportSourceText(report?: DigitalEmployeeDailyReport | null): string {
if (!report) return 'AI 将基于当天任务和运行明细生成日报';
if (report.generated_by === 'ai') return `AI 已生成日报${report.model ? `${report.model}` : ''}`;
return 'AI 不可用时已使用规则兜底生成日报';
}
function selectedDutyIds(projection: DigitalEmployeeWorkdayProjection | null): string[] {
if (!projection) return [];
const selected = projection.duties.filter((duty) => duty.selected).map((duty) => duty.id);
if (selected.length > 0) return selected;
return projection.duties.filter((duty) => duty.implemented).map((duty) => duty.id);
}
function formatGeneratedAt(value?: string | null): string {
if (!value) return '暂未生成';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function TaskSectionList({ sections }: { sections: DigitalEmployeeTaskExecutionSummary[] }) {
if (sections.length === 0) {
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>;
}
return (
<div className="de-task-summary-grid" aria-label="日报任务汇总">
{sections.map((section) => (
<div key={section.id} className={`de-task-summary-card de-live-feed-row--${section.latest_status_tone || 'info'}`}>
<span className="de-task-summary-title">{section.title}</span>
<span className="de-badge de-badge-info">{section.latest_status_label}</span>
<strong> {section.total_count}</strong>
<small> {section.success_count} · {section.failure_count}</small>
<em>{section.ai_summary || section.business_summary}</em>
</div>
))}
</div>
);
}
function DetailList({ details }: { details: DigitalEmployeeRunDetail[] }) {
if (details.length === 0) {
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}></p>;
}
return (
<div className="de-run-detail-list de-run-detail-list-scroll" aria-label="日报运行明细">
<div className="de-run-detail-row de-run-detail-row--head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{details.map((detail) => (
<div key={detail.id} className={`de-run-detail-row de-live-feed-row--${detail.status_tone || 'info'}`}>
<span>{detail.time || '--:--'}</span>
<strong>{detail.task_name}</strong>
<em>{detail.step_name || detail.operation_name || '任务执行'}</em>
<i className="de-badge de-badge-info">{detail.status_label}</i>
<span>{detail.business_detail}</span>
</div>
))}
</div>
);
}
export default function DailyReport({ focusSection }: { focusSection?: string | null }) {
const today = useMemo(() => todayInputDate(), []);
const [projection, setProjection] = useState<DigitalEmployeeWorkdayProjection | null>(null);
const [reportScheduleTime, setReportScheduleTime] = useState('18:00');
const [loading, setLoading] = useState(true);
const [busyAction, setBusyAction] = useState<BusyAction>(null);
const [message, setMessage] = useState<string | null>(null);
const mountedRef = useRef(false);
const isLiveProjection = projection?.source === 'live';
const report = isLiveProjection ? (projection?.daily_report ?? null) : null;
const taskSections = isLiveProjection ? (report?.task_sections ?? projection?.execution_summaries ?? []) : [];
const runDetails = isLiveProjection ? (projection?.run_details ?? []) : [];
const sourceNotice = isLiveProjection ? null : (projection?.demo_reason ?? '当前没有真实运行投影,日报区域暂不展示兜底数据。');
const totals = report?.totals ?? {
task_count: taskSections.length,
execution_count: taskSections.reduce((sum, item) => sum + item.total_count, 0),
success_count: taskSections.reduce((sum, item) => sum + item.success_count, 0),
failure_count: taskSections.reduce((sum, item) => sum + item.failure_count, 0),
running_count: taskSections.reduce((sum, item) => sum + item.running_count, 0),
};
const fetchProjection = useCallback(async () => {
setLoading(true);
setMessage(null);
try {
const data = await getDigitalEmployeeWorkday(today);
if (!mountedRef.current) return;
setProjection(data);
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || '18:00') : '18:00');
} catch (error) {
if (!mountedRef.current) return;
setMessage(error instanceof Error ? error.message : '日报数据加载失败');
} finally {
if (mountedRef.current) setLoading(false);
}
}, [today]);
useEffect(() => {
mountedRef.current = true;
void fetchProjection();
return () => { mountedRef.current = false; };
}, [fetchProjection]);
useEffect(() => {
if (!focusSection || loading) return;
window.setTimeout(() => {
document.getElementById(`report-section-${focusSection}`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
}, 80);
}, [focusSection, loading]);
const saveReportSchedule = useCallback(async () => {
setBusyAction('update_report_schedule');
setMessage(null);
try {
const data = await postDigitalEmployeeWorkdayAction({
action: 'update_report_schedule',
date: today,
report_schedule_time: reportScheduleTime,
});
if (!mountedRef.current) return;
setProjection(data);
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
setMessage('日报生成时间已更新。');
} catch (error) {
if (!mountedRef.current) return;
setMessage(error instanceof Error ? error.message : '日报生成时间设置失败');
} finally {
if (mountedRef.current) setBusyAction(null);
}
}, [reportScheduleTime, today]);
const generateDailyReport = useCallback(async () => {
setBusyAction('generate_daily_report');
setMessage(null);
try {
const data = await postDigitalEmployeeWorkdayAction({
action: 'generate_daily_report',
date: today,
duty_ids: isLiveProjection ? selectedDutyIds(projection) : [],
report_schedule_time: reportScheduleTime,
});
if (!mountedRef.current) return;
setProjection(data);
setReportScheduleTime(data.source === 'live' ? (data.daily_report?.report_schedule_time || reportScheduleTime) : reportScheduleTime);
setMessage('日报已生成,可下载 PDF。');
} catch (error) {
if (!mountedRef.current) return;
setMessage(error instanceof Error ? error.message : '日报生成失败');
} finally {
if (mountedRef.current) setBusyAction(null);
}
}, [isLiveProjection, projection, reportScheduleTime, today]);
const downloadDailyReportPdf = useCallback(() => {
window.open(digitalEmployeeReportPdfUrl(today), '_blank', 'noopener,noreferrer');
}, [today]);
return (
<div style={{ padding: '14px 18px' }}>
<GlassCard>
<CardTitleBar title="日报生成">
<span className="de-card-meta-text">{today}</span>
</CardTitleBar>
<div className="de-card-body">
{loading ? (
<div className="de-loading-skeleton" style={{ height: 420 }} />
) : (
<>
<section id="report-section-today" className="de-report-summary" style={{ marginBottom: 12 }}>
<div className="de-report-summary-head">
<div className="de-report-summary-title-row">
<h3>{report?.title || '数字员工日报'}</h3>
<p>{reportSourceText(report)}</p>
</div>
<span className={`de-report-badge ${reportStatusClass(report)}`}>{reportStatusLabel(report)}</span>
</div>
<div className="de-report-summary-body">
<div className="de-report-schedule-row">
<label>
<span></span>
<input
type="time"
value={reportScheduleTime}
onChange={(event) => setReportScheduleTime(event.target.value || '18:00')}
/>
</label>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={busyAction === 'update_report_schedule'}
onClick={() => { void saveReportSchedule(); }}
>
</button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={busyAction === 'generate_daily_report'}
onClick={() => { void generateDailyReport(); }}
>
{busyAction === 'generate_daily_report' ? '生成中...' : '生成日报'}
</button>
<button
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'}
>
<Download size={14} aria-hidden="true" />
</button>
</div>
<div className="de-report-metrics">
<div className="de-report-metric"><span></span><strong>{totals.task_count}</strong></div>
<div className="de-report-metric"><span></span><strong>{totals.execution_count}</strong></div>
<div className="de-report-metric"><span></span><strong>{totals.success_count}</strong></div>
<div className="de-report-metric"><span></span><strong>{totals.failure_count}</strong></div>
</div>
<div className="de-report-meta">
<p>{report?.summary || '点击生成日报后AI 会基于当天所有任务写出总分结构日报。'}</p>
{sourceNotice ? <p>{sourceNotice}</p> : null}
<p>{reportScheduleTime}{formatGeneratedAt(report?.generated_at)}</p>
{message ? <p>{message}</p> : null}
</div>
</div>
</section>
<div style={pageGridStyle}>
<section id="report-section-tasks" style={panelStyle}>
<h3 className="de-settings-heading" style={{ marginTop: 0 }}></h3>
<TaskSectionList sections={taskSections} />
</section>
<section id="report-section-details" style={panelStyle}>
<h3 className="de-settings-heading" style={{ marginTop: 0 }}></h3>
<DetailList details={runDetails} />
</section>
<section id="report-section-summary" style={panelStyle}>
<h3 className="de-settings-heading" style={{ marginTop: 0 }}></h3>
{(report?.detail_lines ?? []).length === 0 ? (
<p className="de-empty-text" style={{ padding: '18px 0 6px' }}> AI </p>
) : (
<div className="de-report-meta">
{(report?.detail_lines ?? []).map((line) => <p key={line}>{line}</p>)}
</div>
)}
</section>
</div>
</>
)}
</div>
</GlassCard>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, Mail, X } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { RoleProvider } from '@/contexts/RoleContext';
import CommandDeck from './CommandDeck';
import MissionCenter from './MissionCenter';
import SkillLibrary from './SkillLibrary';
import DailyReport from './DailyReport';
import OpsSettings from './OpsSettings';
import type { DigitalNavigationTarget, DigitalTab } from './navigation';
const TABS: { id: DigitalTab; label: string }[] = [
{ id: 'home', label: '首页' },
{ id: 'missions', label: '任务中心' },
{ id: 'skills', label: '技能库' },
{ id: 'reports', label: '日报' },
{ id: 'settings', label: '设置' },
];
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'sgrobot:digital-close';
function isDigitalTab(value: string | null): value is DigitalTab {
return Boolean(value && TABS.some(tab => tab.id === value));
}
function tabFromPathname(pathname: string): DigitalTab | null {
const parts = pathname.split('/').filter(Boolean);
const last = parts.length > 0 ? parts[parts.length - 1]! : null;
return isDigitalTab(last) ? last : null;
}
function targetFromLocation(pathname: string, search: string): DigitalNavigationTarget {
const params = new URLSearchParams(search);
const queryTab = params.get('tab');
const tab = isDigitalTab(queryTab) ? queryTab : tabFromPathname(pathname) ?? 'home';
return {
tab,
date: params.get('date'),
missionId: params.get('mission'),
taskId: params.get('task'),
skillId: params.get('skill'),
reportSection: params.get('section'),
};
}
function TabContent({
target,
onNavigate,
}: {
target: DigitalNavigationTarget;
onNavigate: (target: DigitalNavigationTarget) => void;
}) {
const tab = target.tab;
switch (tab) {
case 'home': return <CommandDeck onNavigate={onNavigate} />;
case 'missions': return <MissionCenter focusMissionId={target.missionId} focusTaskId={target.taskId} focusDate={target.date} />;
case 'skills': return <SkillLibrary focusSkillId={target.skillId} />;
case 'reports': return <DailyReport focusSection={target.reportSection} />;
case 'settings': return <OpsSettings />;
}
}
function closeDigitalWindow() {
if (window.parent && window.parent !== window) {
const targetOrigin = window.location.origin === 'null' ? '*' : window.location.origin;
window.parent.postMessage({ type: DIGITAL_EMPLOYEE_CLOSE_MESSAGE }, targetOrigin);
return;
}
window.close();
}
export default function DigitalEmployeeShell() {
const location = useLocation();
const navigate = useNavigate();
const [target, setTarget] = useState<DigitalNavigationTarget>(() => targetFromLocation(location.pathname, location.search));
useEffect(() => {
setTarget(targetFromLocation(location.pathname, location.search));
}, [location.pathname, location.search]);
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
setTarget(nextTarget);
const params = new URLSearchParams();
if (nextTarget.tab !== 'home') params.set('tab', nextTarget.tab);
if (nextTarget.date) params.set('date', nextTarget.date);
if (nextTarget.missionId) params.set('mission', nextTarget.missionId);
if (nextTarget.taskId) params.set('task', nextTarget.taskId);
if (nextTarget.skillId) params.set('skill', nextTarget.skillId);
if (nextTarget.reportSection) params.set('section', nextTarget.reportSection);
navigate({
pathname: location.pathname,
search: params.toString() ? `?${params.toString()}` : '',
}, { replace: false });
}, [location.pathname, navigate]);
return (
<RoleProvider>
<div className="de-shell">
<header className="de-header">
<div className="de-header-content">
<div className="de-header-left">
<div className="de-header-accent-bar" />
<h1 className="de-header-title"></h1>
</div>
<div className="de-header-center">
{TABS.map(tab => (
<button
key={tab.id}
className={`de-nav-btn ${target.tab === tab.id ? 'active' : ''}`}
onClick={() => navigateDigital({ tab: tab.id })}
>
<span>{tab.label}</span>
</button>
))}
</div>
<div className="de-header-right">
<span className="de-header-welcome"></span>
<span className="de-header-username"></span>
<button type="button" className="de-header-icon" title="消息">
<Mail size={16} />
</button>
<button type="button" className="de-header-icon" title="通知">
<Bell size={16} />
</button>
<button type="button" className="de-header-icon de-header-close" title="关闭" onClick={closeDigitalWindow}>
<X size={17} />
</button>
</div>
</div>
</header>
{/* Content */}
<div className="de-shell-body">
<TabContent target={target} onNavigate={navigateDigital} />
</div>
</div>
</RoleProvider>
);
}

View File

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

View File

@@ -0,0 +1,170 @@
import { useState, useEffect, useRef, type CSSProperties } from 'react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { useRole, ROLE_PROFILES, type ConsoleRole } from '@/contexts/RoleContext';
import { t } from '@/lib/i18n';
const ROLES: ConsoleRole[] = ['sales', 'engineering', 'hr', 'finance', 'executive'];
const STORAGE_KEY = 'sgrobot-role-preferences';
type RolePreferences = {
workStart: string;
workEnd: string;
notification: 'system' | 'email' | 'silent';
reportTime: string;
avatarStyle: 'professional' | 'friendly' | 'concise';
reminderLevel: 'important' | 'all' | 'quiet';
};
const defaultPreferences: RolePreferences = {
workStart: '09:00',
workEnd: '18:00',
notification: 'system',
reportTime: '17:30',
avatarStyle: 'professional',
reminderLevel: 'important',
};
const densityLabel: Record<string, string> = {
compact: '紧凑',
balanced: '均衡',
presentation: '汇报',
};
const fieldGridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 12,
};
const fieldStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 6,
border: '1px solid var(--de-glass-border-lighter)',
borderRadius: 10,
padding: '12px 14px',
background: 'linear-gradient(180deg, rgba(244,250,255,0.82), rgba(235,246,255,0.58))',
};
const inputStyle: CSSProperties = {
minHeight: 34,
borderRadius: 8,
border: '1px solid var(--de-glass-border-light)',
background: 'rgba(255,255,255,0.72)',
color: 'var(--de-text-primary)',
padding: '0 10px',
};
function loadPreferences(): RolePreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultPreferences;
return { ...defaultPreferences, ...JSON.parse(raw) };
} catch {
return defaultPreferences;
}
}
export default function OpsSettings() {
const { role, profile, setRole } = useRole();
const [preferences, setPreferences] = useState<RolePreferences>(loadPreferences);
const [saved, setSaved] = useState(false);
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => { if (savedTimerRef.current) clearTimeout(savedTimerRef.current); };
}, []);
const markSaved = () => {
setSaved(true);
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
savedTimerRef.current = setTimeout(() => setSaved(false), 2000);
};
const savePreferences = (next: RolePreferences) => {
setPreferences(next);
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch { /* localStorage blocked */ }
markSaved();
};
const updatePreference = <K extends keyof RolePreferences>(key: K, value: RolePreferences[K]) => {
savePreferences({ ...preferences, [key]: value });
};
const handleRoleChange = (nextRole: ConsoleRole) => {
setRole(nextRole);
markSaved();
};
return (
<div style={{ padding: '14px 18px' }}>
<GlassCard>
<CardTitleBar title="角色偏好">
{saved && <span className="de-card-meta-text"></span>}
</CardTitleBar>
<div className="de-card-body">
<section className="de-settings-section">
<h3 className="de-settings-heading"></h3>
<p className="de-settings-desc"></p>
<div className="de-role-grid">
{ROLES.map((item) => (
<button key={item} className={`de-role-card ${role === item ? 'active' : ''}`} onClick={() => handleRoleChange(item)}>
<span className="de-role-label">{t(ROLE_PROFILES[item].labelKey)}</span>
<span className="de-role-density">{densityLabel[ROLE_PROFILES[item].density] ?? '均衡'}</span>
</button>
))}
</div>
</section>
<section className="de-settings-section">
<h3 className="de-settings-heading"></h3>
<div style={fieldGridStyle}>
<label style={fieldStyle}>
<span className="de-role-label"></span>
<input style={inputStyle} type="time" value={preferences.workStart} onChange={(event) => updatePreference('workStart', event.target.value)} />
</label>
<label style={fieldStyle}>
<span className="de-role-label"></span>
<input style={inputStyle} type="time" value={preferences.workEnd} onChange={(event) => updatePreference('workEnd', event.target.value)} />
</label>
<label style={fieldStyle}>
<span className="de-role-label"></span>
<input style={inputStyle} type="time" value={preferences.reportTime} onChange={(event) => updatePreference('reportTime', event.target.value)} />
</label>
<label style={fieldStyle}>
<span className="de-role-label"></span>
<select style={inputStyle} value={preferences.notification} onChange={(event) => updatePreference('notification', event.target.value as RolePreferences['notification'])}>
<option value="system"></option>
<option value="email"></option>
<option value="silent"></option>
</select>
</label>
<label style={fieldStyle}>
<span className="de-role-label"></span>
<select style={inputStyle} value={preferences.avatarStyle} onChange={(event) => updatePreference('avatarStyle', event.target.value as RolePreferences['avatarStyle'])}>
<option value="professional"></option>
<option value="friendly"></option>
<option value="concise"></option>
</select>
</label>
<label style={fieldStyle}>
<span className="de-role-label"></span>
<select style={inputStyle} value={preferences.reminderLevel} onChange={(event) => updatePreference('reminderLevel', event.target.value as RolePreferences['reminderLevel'])}>
<option value="important"></option>
<option value="all"></option>
<option value="quiet"></option>
</select>
</label>
</div>
</section>
<details className="de-settings-section" style={{ marginBottom: 0 }}>
<summary className="de-settings-heading" style={{ cursor: 'pointer' }}></summary>
<p className="de-settings-desc">使{Math.round(profile.staleDataThresholdMs / 1000)} </p>
</details>
</div>
</GlassCard>
</div>
);
}

View File

@@ -0,0 +1,318 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
import { getSkills, setSkillEnabled } from '@/lib/api';
import { getDemoSkills } from './demoScenario';
import { domSafeId } from './navigation';
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
type SkillSection = {
key: string;
title: string;
hint: string;
skills: SkillSummary[];
emptyText: string;
};
const cadenceOptions: Array<{ value: 'all' | TaskCadence; label: string }> = [
{ value: 'all', label: '全部任务' },
{ value: 'daily', label: '日常任务' },
{ value: 'weekly', label: '周任务' },
{ value: 'monthly', label: '月度任务' },
{ value: 'adhoc', label: '临时任务' },
];
const cadenceLabels: Record<TaskCadence, string> = {
daily: '日常',
weekly: '周',
monthly: '月度',
adhoc: '临时',
};
function getCadence(skill: SkillSummary): TaskCadence {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
if (/weekly|week|周/.test(text)) return 'weekly';
if (/monthly|month|月报|月度/.test(text)) return 'monthly';
if (/monitor|crawler|巡检|监控|待办|日程|日报|日终|收件箱|工作台|客户跟进/.test(text)) return 'daily';
return 'adhoc';
}
function isAdvancedSkill(skill: SkillSummary): boolean {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
return (
skill.capabilityLevel === 'specialized' ||
skill.capabilityLevel === 'experimental' ||
/加密|深度|分析|自动化|部署|代码|审查|调研|批量|converter|crypto|github|deploy|review|research/.test(text)
);
}
function isRecommendedSkill(skill: SkillSummary): boolean {
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
return /日报|周报|报表|待办|日程|巡检|客户|知识库|经营|费用|收件箱|工作台/.test(text);
}
function getOutcome(skill: SkillSummary): string {
const name = skill.name;
const text = `${skill.id} ${skill.name} ${skill.description} ${skill.category}`.toLowerCase();
if (/report|报表|日报|周报|月报|汇报/.test(text)) return `自动整理${name}材料,减少手工汇总和重复填报。`;
if (/monitor|巡检|监控|预警/.test(text)) return `按约定频率完成${name},及时提示异常和遗漏。`;
if (/todo|待办|task|看板|日程/.test(text)) return `帮你梳理${name},把后续处理事项集中呈现。`;
if (/客户|crm|回访|满意/.test(text)) return `汇总客户相关信息,形成可直接跟进的处理建议。`;
if (/知识|文档|制度|模板/.test(text)) return `查找和核对资料内容,减少人工翻找和版本确认。`;
return skill.description;
}
function learningStatus(skill: SkillSummary, recommended: boolean, advanced: boolean): string {
if (skill.enabled) return '已学习';
if (!skill.enabled) return '暂不使用';
if (advanced) return '需确认后学习';
if (recommended) return '建议学习';
return '可学习';
}
function SkillCard({
skill,
recommended = false,
advanced = false,
selected = false,
updating = false,
onSelect,
onToggle,
}: {
skill: SkillSummary;
recommended?: boolean;
advanced?: boolean;
selected?: boolean;
updating?: boolean;
onSelect?: (skill: SkillSummary) => void;
onToggle?: (skill: SkillSummary) => void;
}) {
const cadence = getCadence(skill);
const status = learningStatus(skill, recommended, advanced);
return (
<div
id={`skill-card-${domSafeId(skill.id)}`}
className={`de-skill-card ${selected ? 'de-skill-card--selected' : ''}`}
role="button"
tabIndex={0}
onClick={() => onSelect?.(skill)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect?.(skill);
}
}}
>
<div className="de-skill-card-head">
<span className="de-skill-name">{skill.name}</span>
<span className={`de-skill-status ${skill.enabled ? 'enabled' : 'disabled'}`}>
{status}
</span>
</div>
<p className="de-skill-desc">{getOutcome(skill)}</p>
<div className="de-skill-card-foot">
<span className="de-skill-version">
{cadenceLabels[cadence]}
</span>
<button
className={`de-skill-btn ${skill.enabled ? 'de-skill-btn--danger' : ''}`}
disabled={updating}
title={skill.enabled ? '暂时停用此技能,后续路由不会选择它。' : '恢复使用此技能。'}
onClick={(event) => {
event.stopPropagation();
if (!updating) onToggle?.(skill);
}}
>
{updating ? '处理中...' : skill.enabled ? '暂不使用' : '恢复使用'}
</button>
</div>
</div>
);
}
export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string | null }) {
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [search, setSearch] = useState('');
const [cadenceFilter, setCadenceFilter] = useState<'all' | TaskCadence>('all');
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(focusSkillId ?? null);
const [updatingSkillId, setUpdatingSkillId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const fetchSkills = useCallback(async () => {
setLoading(true); setError(false);
try {
const data = await getSkills();
if (!mountedRef.current) return;
setSkills(
Array.isArray(data) && data.length > 0
? adaptSkillSummaries(data as Record<string, unknown>[])
: getDemoSkills(),
);
} catch (err) {
if (!mountedRef.current) return;
console.error('[SkillLibrary] fetch failed:', err);
setSkills(getDemoSkills());
setError(false);
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => { fetchSkills(); }, [fetchSkills]);
useEffect(() => {
if (!focusSkillId) return;
setSelectedSkillId(focusSkillId);
setCadenceFilter('all');
setSearch('');
window.setTimeout(() => {
document.getElementById(`skill-card-${domSafeId(focusSkillId)}`)?.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
}, 80);
}, [focusSkillId, skills.length]);
const toggleSkill = useCallback(async (skill: SkillSummary) => {
const nextEnabled = !skill.enabled;
setUpdatingSkillId(skill.id);
setActionError(null);
try {
const result = await setSkillEnabled(skill.id, nextEnabled);
if (!result.ok) {
throw new Error(result.error || '技能状态变更失败');
}
setSkills((current) => current.map((item) => (
item.id === skill.id ? { ...item, enabled: nextEnabled } : item
)));
setSelectedSkillId(skill.id);
} catch (err) {
setActionError(err instanceof Error ? err.message : '技能状态变更失败');
} finally {
setUpdatingSkillId(null);
}
}, []);
const filtered = skills
.filter(s => cadenceFilter === 'all' || getCadence(s) === cadenceFilter)
.filter(s => !search || s.name.includes(search) || s.description.includes(search) || s.category.includes(search));
const advancedSkills = filtered.filter(isAdvancedSkill);
const advancedIds = new Set(advancedSkills.map(s => s.id));
const learnedSkills = filtered.filter(s => s.enabled && !advancedIds.has(s.id));
const recommendedPool = filtered.filter(s => !s.enabled && isRecommendedSkill(s) && !isAdvancedSkill(s));
const baseLearnableSkills = filtered.filter(s => !s.enabled && !isAdvancedSkill(s));
const recommendedSkills = (recommendedPool.length > 0 ? recommendedPool : baseLearnableSkills).slice(0, 4);
const recommendedIds = new Set(recommendedSkills.map(s => s.id));
const learnableSkills = baseLearnableSkills.filter(s => !recommendedIds.has(s.id));
const sections: SkillSection[] = [
{
key: 'recommended',
title: '推荐学习',
hint: '优先补齐高频工作能力,适合让数字员工尽快接手重复事务。',
skills: recommendedSkills,
emptyText: '暂无推荐学习项。',
},
{
key: 'learnable',
title: '可学习技能',
hint: '可纳入数字员工日常工作的业务能力,学习入口待接入后启用。',
skills: learnableSkills,
emptyText: '当前没有可学习技能。',
},
{
key: 'learned',
title: '已学技能',
hint: '数字员工已经掌握并可被业务场景调用的能力。',
skills: learnedSkills,
emptyText: '当前还没有已学习技能。',
},
{
key: 'advanced',
title: '高级技能',
hint: '适合有明确边界或需要确认后再交给数字员工处理的能力。',
skills: advancedSkills,
emptyText: '暂无高级技能。',
},
];
const renderSection = (section: SkillSection) => (
<section key={section.key} style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, color: 'var(--de-text-primary)' }}>{section.title}</h3>
<p style={{ margin: '4px 0 0', fontSize: 12, color: 'var(--de-text-muted)' }}>{section.hint}</p>
</div>
<span className="de-capability-badge de-capability-standard">{section.skills.length} </span>
</div>
{section.skills.length === 0 ? (
<p className="de-empty-text" style={{ margin: 0 }}>{section.emptyText}</p>
) : (
<div className="de-skill-grid">
{section.skills.map(s => (
<SkillCard
key={`${section.key}-${s.id}`}
skill={s}
recommended={section.key === 'recommended'}
advanced={section.key === 'advanced'}
selected={selectedSkillId === s.id}
updating={updatingSkillId === s.id}
onSelect={(skill) => setSelectedSkillId(skill.id)}
onToggle={toggleSkill}
/>
))}
</div>
)}
</section>
);
return (
<div style={{ padding: '14px 18px' }}>
<GlassCard>
<CardTitleBar title="学习技能">
<button onClick={fetchSkills} className="de-btn-secondary de-btn-sm"></button>
</CardTitleBar>
<div className="de-card-body">
{actionError && (
<div className="de-schedule-result de-schedule-result--error" style={{ marginBottom: 12 }}>
{actionError}
</div>
)}
<div className="de-skill-toolbar">
<input type="text" className="de-input" placeholder="搜索业务能力..." value={search} onChange={e => setSearch(e.target.value)} />
<div className="de-filter-row">
{cadenceOptions.map(option => (
<button key={option.value} className={`de-filter-chip ${cadenceFilter === option.value ? 'active' : ''}`} onClick={() => setCadenceFilter(option.value)}>
{option.label}
</button>
))}
</div>
</div>
{loading ? (
<div className="de-loading-skeleton" style={{ height: 300 }} />
) : error ? (
<p className="de-empty-text"></p>
) : filtered.length === 0 && skills.length > 0 ? (
<p className="de-empty-text"></p>
) : filtered.length === 0 ? (
<p className="de-empty-text"></p>
) : (
<div style={{ display: 'grid', gap: 18 }}>
{sections.map(renderSection)}
</div>
)}
</div>
</GlassCard>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import type { PlanMessage } from '@/lib/api';
import type {
DashboardOverview,
JournalEntry,
Mission,
MissionStep,
SkillSummary,
} from '@/lib/consoleDataAdapter';
import type { DebugPlan, DebugRun, DebugTask, FlowResponse } from '@/types/api';
function todayAt(hour: number, minute: number): string {
const date = new Date();
date.setHours(hour, minute, 0, 0);
return date.toISOString();
}
function makeTask(
missionId: string,
stepId: string,
title: string,
status: string,
skillId: string,
startedAt: string | null,
finishedAt: string | null,
): MissionStep['tasks'][number] {
return {
id: `${missionId}-${stepId}-task`,
title,
status,
assignedSkillId: skillId,
startedAt,
finishedAt,
};
}
function makeMission(
id: string,
title: string,
objective: string,
status: string,
updatedAt: string,
steps: MissionStep[],
): Mission {
return {
id,
title,
objective,
status,
createdAt: todayAt(8, 0),
updatedAt,
steps,
sourceIds: { planId: id, taskIds: steps.flatMap((step) => step.tasks.map((task) => task.id)) },
};
}
const DEMO_SKILLS: SkillSummary[] = [
{ id: 'whitecollar-day-browser-portal-check', name: '工作台巡检', version: '1.0.0', enabled: true, description: '检查工作台首页、待办数量和重点入口是否可用。', category: '办公', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-browser-inbox-triage', name: '收件箱分拣', version: '1.0.0', enabled: true, description: '读取邮件和通知列表,按紧急程度整理待办。', category: '通信', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-calendar-brief', name: '日程简报', version: '1.0.0', enabled: true, description: '生成当日会议、回访和报表节点的时间提醒。', category: '办公', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-browser-crm-followup', name: '客户跟进', version: '1.0.0', enabled: true, description: '读取客户跟进页面,形成回访摘要和下一步建议。', category: '营销', capabilityLevel: 'specialized', source: 'skill-api' },
{ id: 'whitecollar-day-browser-expense-approval', name: '费用审批核对', version: '1.0.0', enabled: true, description: '检查待审批费用单,筛出异常和超限项。', category: '财务', capabilityLevel: 'specialized', source: 'skill-api' },
{ id: 'whitecollar-day-sales-snapshot', name: '经营快照', version: '1.0.0', enabled: true, description: '汇总客户、项目和当日经营看板。', category: '报表', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-report-export', name: '报表导出', version: '1.0.0', enabled: true, description: '整理并导出面向领导汇报的简版报表。', category: '报表', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-browser-knowledge-check', name: '知识库核查', version: '1.0.0', enabled: true, description: '检索知识库资料,确认制度和模板是否最新。', category: '数字化', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-task-board-sync', name: '任务看板同步', version: '1.0.0', enabled: true, description: '把今日处理结果同步到任务看板和协同清单。', category: '办公', capabilityLevel: 'standard', source: 'skill-api' },
{ id: 'whitecollar-day-browser-day-close', name: '日终收口', version: '1.0.0', enabled: true, description: '汇总今日动作、风险和明日重点。', category: '办公', capabilityLevel: 'specialized', source: 'skill-api' },
];
const DEMO_MISSIONS: Mission[] = [
makeMission(
'whitecollar-day',
'白领一天端到端验收',
'模拟业务人员一天内通过数字员工完成网页采集、客户回访、资料整理和日报输出。',
'active',
todayAt(10, 35),
[
{ id: 'contact', title: '读取供应商联系人', status: 'completed', preferredSkills: ['whitecollar-day-legacy-contact'], tasks: [makeTask('whitecollar-day', 'contact', '整理联系人名单', 'completed', 'whitecollar-day-legacy-contact', todayAt(8, 30), todayAt(8, 33))] },
{ id: 'news', title: '读取行业动态', status: 'completed', preferredSkills: ['whitecollar-day-legacy-news'], tasks: [makeTask('whitecollar-day', 'news', '整理行业动态摘要', 'completed', 'whitecollar-day-legacy-news', todayAt(8, 35), todayAt(8, 41))] },
{ id: 'form', title: '预填客户回访登记', status: 'completed', preferredSkills: ['whitecollar-day-legacy-form'], tasks: [makeTask('whitecollar-day', 'form', '填充回访登记表', 'completed', 'whitecollar-day-legacy-form', todayAt(8, 45), todayAt(8, 51))] },
{ id: 'compare', title: '比对设备方案', status: 'running', preferredSkills: ['whitecollar-day-legacy-compare'], tasks: [makeTask('whitecollar-day', 'compare', '生成方案差异结论', 'running', 'whitecollar-day-legacy-compare', todayAt(10, 28), null)] },
{ id: 'todos', title: '读取待办系统', status: 'pending', preferredSkills: ['whitecollar-day-legacy-todos'], tasks: [makeTask('whitecollar-day', 'todos', '汇总个人待办', 'pending', 'whitecollar-day-legacy-todos', null, null)] },
{ id: 'customers', title: '读取客户清单', status: 'pending', preferredSkills: ['whitecollar-day-legacy-customers'], tasks: [makeTask('whitecollar-day', 'customers', '提取重点客户', 'pending', 'whitecollar-day-legacy-customers', null, null)] },
{ id: 'download', title: '识别最新资料下载链接', status: 'pending', preferredSkills: ['whitecollar-day-legacy-download'], tasks: [makeTask('whitecollar-day', 'download', '识别最新制度材料', 'pending', 'whitecollar-day-legacy-download', null, null)] },
{ id: 'email', title: '生成客户跟进邮件', status: 'pending', preferredSkills: ['whitecollar-day-legacy-email'], tasks: [makeTask('whitecollar-day', 'email', '生成回访邮件草稿', 'pending', 'whitecollar-day-legacy-email', null, null)] },
{ id: 'daily-report', title: '生成工作日报', status: 'pending', preferredSkills: ['whitecollar-day-legacy-daily-report'], tasks: [makeTask('whitecollar-day', 'daily-report', '生成工作日报', 'pending', 'whitecollar-day-legacy-daily-report', null, null)] },
{ id: 'tomorrow-plan', title: '生成明日计划', status: 'pending', preferredSkills: ['whitecollar-day-legacy-tomorrow-plan'], tasks: [makeTask('whitecollar-day', 'tomorrow-plan', '整理明日计划', 'pending', 'whitecollar-day-legacy-tomorrow-plan', null, null)] },
],
),
makeMission('whitecollar-day-0830-browser-portal-check', '白领一天 08:30 工作台巡检', '巡检工作台首页、待办数量和关键入口状态。', 'completed', todayAt(8, 34), [
{ id: 'browser-portal-check', title: '浏览器工作台巡检', status: 'completed', preferredSkills: ['whitecollar-day-browser-portal-check'], tasks: [makeTask('whitecollar-day-0830-browser-portal-check', 'browser-portal-check', '检查门户首页与入口', 'completed', 'whitecollar-day-browser-portal-check', todayAt(8, 30), todayAt(8, 34))] },
]),
makeMission('whitecollar-day-0900-browser-inbox-triage', '白领一天 09:00 收件箱分拣', '识别高优先级通知并形成今日待办。', 'completed', todayAt(9, 6), [
{ id: 'browser-inbox-triage', title: '浏览器收件箱分拣', status: 'completed', preferredSkills: ['whitecollar-day-browser-inbox-triage'], tasks: [makeTask('whitecollar-day-0900-browser-inbox-triage', 'browser-inbox-triage', '分拣通知与邮件', 'completed', 'whitecollar-day-browser-inbox-triage', todayAt(9, 0), todayAt(9, 6))] },
]),
makeMission('whitecollar-day-0930-calendar-brief', '白领一天 09:30 日程简报', '整理会议、回访与汇报节点,形成上午排程。', 'completed', todayAt(9, 32), [
{ id: 'calendar-brief', title: '生成日程简报', status: 'completed', preferredSkills: ['whitecollar-day-calendar-brief'], tasks: [makeTask('whitecollar-day-0930-calendar-brief', 'calendar-brief', '整理今日日程', 'completed', 'whitecollar-day-calendar-brief', todayAt(9, 30), todayAt(9, 32))] },
]),
makeMission('whitecollar-day-1030-browser-crm-followup', '白领一天 10:30 客户跟进', '汇总重点客户情况,生成下一步回访建议。', 'running', todayAt(10, 35), [
{ id: 'browser-crm-followup', title: '浏览器客户跟进', status: 'running', preferredSkills: ['whitecollar-day-browser-crm-followup'], tasks: [makeTask('whitecollar-day-1030-browser-crm-followup', 'browser-crm-followup', '提取客户回访摘要', 'running', 'whitecollar-day-browser-crm-followup', todayAt(10, 30), null)] },
]),
makeMission('whitecollar-day-1130-browser-expense-approval', '白领一天 11:30 费用审批核对', '检查待审批单据并输出异常说明。', 'approved', todayAt(11, 20), [
{ id: 'browser-expense-approval', title: '浏览器费用审批核对', status: 'pending', preferredSkills: ['whitecollar-day-browser-expense-approval'], tasks: [makeTask('whitecollar-day-1130-browser-expense-approval', 'browser-expense-approval', '核对费用审批单', 'pending', 'whitecollar-day-browser-expense-approval', null, null)] },
]),
makeMission('whitecollar-day-1330-sales-snapshot', '白领一天 13:30 经营快照', '汇总客户、项目和经营看板,形成午后快照。', 'draft', todayAt(13, 20), [
{ id: 'sales-snapshot', title: '生成经营快照', status: 'pending', preferredSkills: ['whitecollar-day-sales-snapshot'], tasks: [makeTask('whitecollar-day-1330-sales-snapshot', 'sales-snapshot', '整理经营快照', 'pending', 'whitecollar-day-sales-snapshot', null, null)] },
]),
makeMission('whitecollar-day-1430-report-export', '白领一天 14:30 报表导出', '导出领导汇报所需的简版统计报表。', 'draft', todayAt(14, 10), [
{ id: 'report-export', title: '导出汇报报表', status: 'pending', preferredSkills: ['whitecollar-day-report-export'], tasks: [makeTask('whitecollar-day-1430-report-export', 'report-export', '导出统计报表', 'pending', 'whitecollar-day-report-export', null, null)] },
]),
makeMission('whitecollar-day-1530-browser-knowledge-check', '白领一天 15:30 知识库核查', '核查制度、模板与常见问题资料是否最新。', 'draft', todayAt(15, 15), [
{ id: 'browser-knowledge-check', title: '浏览器知识库核查', status: 'pending', preferredSkills: ['whitecollar-day-browser-knowledge-check'], tasks: [makeTask('whitecollar-day-1530-browser-knowledge-check', 'browser-knowledge-check', '核查知识库资料', 'pending', 'whitecollar-day-browser-knowledge-check', null, null)] },
]),
makeMission('whitecollar-day-1630-task-board-sync', '白领一天 16:30 任务看板同步', '把今日处理结果同步到看板和协同清单。', 'draft', todayAt(16, 10), [
{ id: 'task-board-sync', title: '同步任务看板', status: 'pending', preferredSkills: ['whitecollar-day-task-board-sync'], tasks: [makeTask('whitecollar-day-1630-task-board-sync', 'task-board-sync', '同步任务状态', 'pending', 'whitecollar-day-task-board-sync', null, null)] },
]),
makeMission('whitecollar-day-1730-browser-day-close', '白领一天 17:30 日终收口', '汇总今日成果、风险和明日重点,形成日终总结。', 'draft', todayAt(17, 10), [
{ id: 'task-board-sync', title: '同步任务看板', status: 'pending', preferredSkills: ['whitecollar-day-task-board-sync'], tasks: [makeTask('whitecollar-day-1730-browser-day-close', 'task-board-sync', '同步看板结果', 'pending', 'whitecollar-day-task-board-sync', null, null)] },
{ id: 'browser-day-close', title: '浏览器日终收口', status: 'pending', preferredSkills: ['whitecollar-day-browser-day-close'], tasks: [makeTask('whitecollar-day-1730-browser-day-close', 'browser-day-close', '整理日终收口', 'pending', 'whitecollar-day-browser-day-close', null, null)] },
]),
makeMission('zhihu-hotlist-monitor', '知乎热榜采集', '通过计划模板调用知乎热榜采集技能,导出表格文件并生成当天热点摘要。', 'approved', todayAt(10, 12), [
{ id: 'collect-zhihu', title: '采集知乎热榜', status: 'pending', preferredSkills: ['zhihu-hotlist'], tasks: [makeTask('zhihu-hotlist-monitor', 'collect-zhihu', '采集知乎热榜', 'pending', 'zhihu-hotlist', null, null)] },
{ id: 'export-xlsx', title: '导出表格文件', status: 'pending', preferredSkills: ['office-export-xlsx'], tasks: [makeTask('zhihu-hotlist-monitor', 'export-xlsx', '导出热榜表格', 'pending', 'office-export-xlsx', null, null)] },
]),
];
function cloneMission(mission: Mission): Mission {
return {
...mission,
steps: mission.steps.map((step) => ({
...step,
preferredSkills: [...step.preferredSkills],
tasks: step.tasks.map((task) => ({ ...task })),
})),
sourceIds: { planId: mission.sourceIds.planId, taskIds: [...mission.sourceIds.taskIds] },
};
}
export function getDemoMissions(): Mission[] {
return DEMO_MISSIONS.map(cloneMission);
}
export function getDemoSkills(): SkillSummary[] {
return DEMO_SKILLS.map((skill) => ({ ...skill }));
}
export function buildDemoTasks(missions: Mission[]): DebugTask[] {
return missions.flatMap((mission) => mission.steps.flatMap((step) => step.tasks.map((task) => ({
task_id: task.id,
title: task.title,
status: task.status,
plan_id: mission.id,
step_id: step.id,
assigned_skill_id: task.assignedSkillId || '',
description: `${mission.title} · ${step.title}`,
created_at: mission.createdAt || todayAt(8, 0),
updated_at: mission.updatedAt || mission.createdAt || todayAt(8, 0),
started_at: task.startedAt,
finished_at: task.finishedAt,
}))));
}
export function buildDemoRuns(tasks: DebugTask[]): DebugRun[] {
return tasks
.filter((task) => ['completed', 'running'].includes(task.status))
.map((task, index) => ({
run_id: `demo-run-${index + 1}`,
task_id: task.task_id,
plan_id: task.plan_id,
attempt_no: 1,
lifecycle: task.status === 'running' ? 'Running' : 'Succeeded',
created_at: task.started_at || task.created_at,
completed_at: task.finished_at,
}));
}
export function buildDemoOverview(tasks: DebugTask[]): DashboardOverview {
return {
generatedAt: new Date().toISOString(),
isPartial: true,
sources: [{ endpoint: 'digital-demo', ok: true, loadedAt: new Date().toISOString() }],
taskTotals: {
total: tasks.length,
queued: tasks.filter((task) => task.status === 'pending').length,
running: tasks.filter((task) => task.status === 'running').length,
succeeded: tasks.filter((task) => task.status === 'completed').length,
failed: 0,
},
approvals: { total: 1, pending: 1 },
health: { activeTasks: tasks.filter((task) => task.status === 'running').length, failedTasks: 0, pendingConfirmations: 1 },
};
}
export function buildDemoPlans(missions: Mission[]): DebugPlan[] {
return missions.map((mission) => ({
plan_id: mission.id,
title: mission.title,
objective: mission.objective,
status: mission.status,
created_at: mission.createdAt || todayAt(8, 0),
steps: mission.steps.map((step) => ({
step_id: step.id,
title: step.title,
depends_on: [],
preferred_skills: step.preferredSkills,
tasks: step.tasks.map((task) => ({
task_id: task.id,
title: task.title,
status: task.status,
})),
})),
}));
}
export function buildDemoJournalEntries(missions: Mission[]): JournalEntry[] {
return missions
.slice(0, 8)
.map((mission, index) => ({
id: `demo-journal-${index + 1}`,
kind: mission.status === 'completed' ? 'completed' : mission.status === 'active' || mission.status === 'running' ? 'start' : 'pending_authorization',
source: 'digital-demo',
message: `${mission.title}${mission.objective}`,
occurredAt: mission.updatedAt,
planId: mission.id,
taskId: mission.sourceIds.taskIds[0] || null,
}));
}
export function buildDemoFlowEvents(mission: Mission): NonNullable<FlowResponse['events']> {
const activeStep = mission.steps.find((step) => step.status === 'running') || mission.steps.find((step) => step.status === 'pending') || mission.steps[0];
const events: NonNullable<FlowResponse['events']> = [
{ event_id: `${mission.id}-event-1`, kind: 'entered_session', occurred_at: mission.createdAt || todayAt(8, 0), message: `已接收场景:${mission.title}` },
{ event_id: `${mission.id}-event-2`, kind: 'start', occurred_at: mission.updatedAt || mission.createdAt || todayAt(8, 0), message: `当前目标:${mission.objective}` },
];
if (activeStep) {
events.push({
event_id: `${mission.id}-event-3`,
kind: mission.status === 'completed' ? 'completed' : mission.status === 'approved' || mission.status === 'draft' ? 'pending_authorization' : 'RunStarted',
occurred_at: mission.updatedAt || mission.createdAt || todayAt(8, 0),
message: `${activeStep.title}`,
});
}
if (mission.status === 'completed') {
events.push({
event_id: `${mission.id}-event-4`,
kind: 'RunCompleted',
occurred_at: mission.updatedAt || mission.createdAt || todayAt(8, 0),
message: '已完成本场景执行并回写结果。',
});
}
return events;
}
export function buildDemoPlanMessages(mission: Mission): PlanMessage[] {
const completedSteps = mission.steps.filter((step) => step.status === 'completed').length;
const runningStep = mission.steps.find((step) => step.status === 'running');
const closing =
mission.status === 'completed'
? `当前已完成全部 ${mission.steps.length} 个步骤,结果已整理完毕。`
: runningStep
? `当前正在处理“${runningStep.title}”,已完成 ${completedSteps} 个步骤。`
: `当前场景已准备就绪,可直接发起执行。`;
return [
{ role: 'user', content: `请执行场景:${mission.title}` },
{ role: 'assistant', content: `已接收。业务目标:${mission.objective}` },
{ role: 'assistant', content: closing },
];
}
export function runDemoMissionOnce(missions: Mission[], missionId: string): Mission[] {
return missions.map((mission) => {
if (mission.id !== missionId) return mission;
return {
...mission,
status: 'running',
updatedAt: new Date().toISOString(),
steps: mission.steps.map((step, index) => ({
...step,
status: index === 0 ? 'running' : step.status === 'completed' ? 'completed' : 'pending',
tasks: step.tasks.map((task) => ({
...task,
status: index === 0 ? 'running' : task.status === 'completed' ? 'completed' : 'pending',
startedAt: index === 0 ? new Date().toISOString() : task.startedAt,
finishedAt: index === 0 ? null : task.finishedAt,
})),
})),
};
});
}
export function completeDemoMissionOnce(missions: Mission[], missionId: string): Mission[] {
return missions.map((mission) => {
if (mission.id !== missionId) return mission;
return {
...mission,
status: 'completed',
updatedAt: new Date().toISOString(),
steps: mission.steps.map((step) => ({
...step,
status: 'completed',
tasks: step.tasks.map((task) => ({
...task,
status: 'completed',
startedAt: task.startedAt || new Date().toISOString(),
finishedAt: new Date().toISOString(),
})),
})),
};
});
}

View File

@@ -0,0 +1,14 @@
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'settings';
export type DigitalNavigationTarget = {
tab: DigitalTab;
date?: string | null;
missionId?: string | null;
taskId?: string | null;
skillId?: string | null;
reportSection?: string | null;
};
export function domSafeId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, '_');
}

View File

@@ -0,0 +1,744 @@
export interface StatusResponse {
provider: string | null;
model: string;
temperature: number;
uptime_seconds: number;
gateway_port: number;
locale: string;
memory_backend: string;
paired: boolean;
channels: Record<string, boolean>;
health: HealthSnapshot;
}
export interface HealthSnapshot {
pid: number;
updated_at: string;
uptime_seconds: number;
components: Record<string, ComponentHealth>;
}
export interface ComponentHealth {
status: string;
updated_at: string;
last_ok: string | null;
last_error: string | null;
restart_count: number;
}
export interface ToolSpec {
name: string;
description: string;
parameters: any;
}
export interface CronJob {
id: string;
name: string | null;
expression: string;
command: string;
prompt: string | null;
job_type: string;
schedule: unknown;
enabled: boolean;
delivery: unknown;
template_id?: string | null;
trigger_kind?: string | null;
step_count?: number;
skill_ids?: string[];
delete_after_run: boolean;
created_at: string;
next_run: string;
last_run: string | null;
last_status: string | null;
last_output: string | null;
}
export interface CronRun {
id: number;
job_id: string;
started_at: string;
finished_at: string;
status: string;
output: string | null;
duration_ms: number | null;
}
export interface Integration {
name: string;
description: string;
category: string;
status: 'Available' | 'Active' | 'ComingSoon';
}
export interface DiagResult {
severity: 'ok' | 'warn' | 'error';
category: string;
message: string;
}
export interface MemoryEntry {
id: string;
key: string;
content: string;
category: string;
timestamp: string;
session_id: string | null;
score: number | null;
}
export interface CostSummary {
session_cost_usd: number;
daily_cost_usd: number;
monthly_cost_usd: number;
total_tokens: number;
request_count: number;
by_model: Record<string, ModelStats>;
}
export interface ModelStats {
model: string;
cost_usd: number;
total_tokens: number;
request_count: number;
}
export interface CliTool {
name: string;
path: string;
version: string | null;
category: string;
}
export interface Session {
session_id: string;
kind: 'chat' | 'plan';
created_at: string;
last_activity: string;
message_count: number;
plan_id?: string | null;
plan_title?: string | null;
name?: string | null;
id?: string;
started_at?: string;
channel?: string;
status?: 'active' | 'idle' | 'closed';
}
export interface SessionListResponse {
sessions: Session[];
}
export interface ChannelDetail {
name: string;
type: string;
enabled: boolean;
status: 'active' | 'inactive' | 'error';
message_count: number;
last_message_at: string | null;
health: 'healthy' | 'degraded' | 'down';
}
export interface SSEEvent {
type: string;
timestamp?: string;
[key: string]: any;
}
export interface BrowserSession {
session_id: string;
target_url: string;
status: 'launching' | 'running' | 'idle' | 'stopped' | 'error';
started_at: string;
pid?: number;
error_message?: string;
}
export interface UserNotification {
notification_id: string;
plan_id: string;
task_id: string;
kind: 'user_message' | 'need_input' | 'need_approval'
| 'task_progress' | 'task_failed' | 'task_finished'
| 'confirmation_pending' | 'confirmation_resolved';
title: string;
body: string;
level: 'info' | 'warn' | 'error' | 'action';
status: 'unread' | 'read' | 'dismissed' | 'resolved';
correlation_id: string;
created_at: string;
read_at?: string | null;
dismissed_at?: string | null;
}
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
export interface WsMessage {
type: 'message' | 'chunk' | 'chunk_reset' | 'thinking' | 'tool_call' | 'tool_result' | 'done' | 'error' | 'plan_msg_ack' | 'plan_notification' | 'plan_need_input' | 'plan_need_approval' | 'notification' | 'session_start' | 'session_renamed' | 'connected' | 'plan_created' | 'direct_task_created' | 'ask' | 'governance';
content?: string;
full_response?: string;
name?: string;
args?: any;
output?: string;
message?: string;
code?: string;
level?: string;
question?: string;
task_id?: string;
approval_id?: string;
action?: string;
plan_id?: string;
session_id?: string;
provisional?: boolean;
instruction?: string;
status?: string;
title?: string;
task_count?: number;
notification?: UserNotification;
response_kind?: string;
route_decision?: RouteDecisionRecord;
command?: unknown;
created_plan_id?: string | null;
created_schedule_id?: string | null;
created_task_ids?: string[];
created_run_ids?: string[];
projection_name?: string | null;
projection_payload?: Record<string, unknown> | null;
}
export interface PlanTaskSummary {
task_id: string;
title: string;
status: string;
}
export interface PlanStepSummary {
step_id: string;
title: string;
depends_on: string[];
preferred_skills: string[];
not_before?: string;
tasks: PlanTaskSummary[];
}
export interface PlanSummary {
plan_id: string;
title: string;
objective: string;
status: string;
created_at?: string;
steps: PlanStepSummary[];
}
// ---------------------------------------------------------------------------
// Execution governance
// ---------------------------------------------------------------------------
export type RouteDecisionRouteKind =
| 'ChatOnly'
| 'AskClarification'
| 'ProjectionQuery'
| 'PlanControl'
| 'DirectRun'
| 'PlanExecution'
| 'RejectedByPolicy'
| (string & {});
export type RouteDecisionIntentKind =
| 'chat'
| 'query'
| 'control'
| 'execute'
| 'schedule'
| 'amend'
| 'approve'
| 'recover'
| 'reject'
| 'Chat'
| 'Query'
| 'Control'
| 'Execute'
| 'Schedule'
| 'Amend'
| 'Approve'
| 'Recover'
| 'Reject'
| (string & {});
export interface RouteDecisionRecord {
id: string;
route_kind: RouteDecisionRouteKind;
intent_kind: RouteDecisionIntentKind;
reason_codes: string[];
candidate_skills: string[];
context_refs: Record<string, unknown>;
created_command_id: string | null;
correlation_id: string;
creates_plan: boolean;
creates_task_run: boolean;
approval_mode: string | null;
policy_result: string | null;
recorded_at: string;
entrypoint?: string | null;
actor?: string | null;
}
export interface RouteDecisionTimelineResponse {
has_route_decision_state: boolean;
decisions: RouteDecisionRecord[];
}
export interface IngressRouteRequest {
entrypoint?: 'chat' | 'task_center' | 'cron' | 'webhook' | 'legacy_api' | 'browser_event' | 'file_event';
actor?: string;
text?: string;
payload?: Record<string, unknown>;
attachments?: string[];
timestamp?: string;
correlation_id?: string;
idempotency_key?: string;
context_refs?: Record<string, unknown>;
}
export interface IngressRouteResponse {
accepted: boolean;
envelope?: Record<string, unknown>;
response_kind?: string;
route_decision?: RouteDecisionRecord;
command?: unknown;
created_plan_id?: string | null;
created_schedule_id?: string | null;
created_task_ids?: string[];
created_run_ids?: string[];
projection_name?: string | null;
projection_payload?: Record<string, unknown> | null;
message?: string;
error?: GovernanceCommandErrorCode;
}
export type GovernanceCommandKind =
| 'create_goal'
| 'create_direct_task'
| 'create_plan'
| 'submit_plan'
| 'approve_plan'
| 'reject_plan'
| 'activate_plan'
| 'pause_plan'
| 'resume_plan'
| 'amend_plan'
| 'cancel_plan'
| 'retry_task'
| 'skip_task'
| 'pause_task'
| 'resume_task'
| 'provide_task_input'
| 'cancel_task'
| 'start_run'
| 'cancel_run'
| 'request_approval'
| 'approve_approval'
| 'reject_approval'
| 'create_schedule'
| 'pause_schedule'
| 'run_schedule_now';
export type GovernanceCommandErrorCode =
| 'invalid_context'
| 'missing_capability'
| 'policy_blocked'
| 'conflict'
| 'execution_failed'
| 'unauthorized'
| 'invalid_state'
| (string & {});
export interface GovernanceCommandRequest {
command_kind: GovernanceCommandKind;
context_refs: Record<string, unknown>;
payload?: Record<string, unknown>;
idempotency_key?: string;
correlation_id?: string;
}
export interface GovernanceCommandResponse {
command_id: string;
accepted: boolean;
correlation_id: string;
result?: Record<string, unknown>;
error?: GovernanceCommandErrorCode;
message?: string;
}
// ═══════════════════════════════════════════════════════════════════════
// Console types — Debug API responses, Plan/Task/Run, Swimlane
// ═══════════════════════════════════════════════════════════════════════
export interface DashboardStats {
total: number;
queued: number;
running: number;
succeeded: number;
failed: number;
paused?: number;
}
export interface DebugTask {
task_id: string;
title: string;
status: string;
plan_id: string | null;
step_id: string;
assigned_skill_id: string;
description?: string;
created_at: string;
updated_at: string;
started_at: string | null;
finished_at: string | null;
trigger_source?: string | null;
scheduler_identity?: string | null;
cron_job_id?: string | null;
timeout_at?: string | null;
error_message?: string | null;
[key: string]: any;
}
export interface DebugRun {
run_id: string;
task_id: string;
plan_id: string | null;
skill_id?: string;
attempt_no: number;
lifecycle: string;
created_at: string;
completed_at: string | null;
events?: Array<{
event_id?: string;
kind?: string;
occurred_at?: string;
message?: string | null;
}>;
artifacts?: Array<Record<string, unknown>>;
[key: string]: any;
}
export interface DebugPlan {
plan_id: string;
title: string;
objective?: string;
status: string;
created_at: string;
steps?: PlanStepSummary[];
[key: string]: any;
}
export interface ApprovalEntry {
approval_id: string;
task_id: string;
status: string;
created_at?: string;
[key: string]: any;
}
export interface ArtifactEntry {
artifact_id?: string;
subject_id: string;
kind?: string;
name?: string;
uri?: string;
value?: string;
[key: string]: any;
}
export interface CanonicalEvent {
event_id: string;
aggregate_type?: string;
aggregate_id?: string;
event_type?: string;
kind?: string;
message?: string;
occurred_at?: string;
timestamp?: string;
plan_id: string | null;
task_id: string | null;
[key: string]: any;
}
export interface DebugStoreResponse {
tables: {
canonical_events: CanonicalEvent[];
tasks: DebugTask[];
runs: DebugRun[];
plans: DebugPlan[];
plan_steps: PlanStepSummary[];
approvals: ApprovalEntry[];
artifacts: ArtifactEntry[];
};
}
export interface FlowStep {
seq?: number;
lane: string;
label: string;
sub_label?: string;
timestamp: string;
color?: string;
source_event_id?: string;
source_type?: string;
payload?: Record<string, unknown>;
}
export interface LaneInfo {
key: string;
label: string;
color: string;
}
export interface FlowResponse {
steps: FlowStep[];
lanes: LaneInfo[];
task?: Record<string, unknown>;
plan_id?: string;
plan_title?: string;
plan_status?: string;
plan_objective?: string;
events?: Array<{ event_id: string; kind: string; occurred_at: string; message: string }>;
}
export interface SkillSpec {
skill_id: string;
name: string;
version: string;
enabled: boolean;
description: string;
[key: string]: any;
}
export interface CreatePlanRequest {
title: string;
description: string;
steps: Array<{
title: string;
description: string;
skills: string[];
depends_on: string[];
}>;
}
export interface DashboardResponse {
runtime: {
active_tasks: number;
failed_tasks: number;
pending_confirmations: number;
};
tasks: DashboardStats;
approvals: {
total: number;
pending: number;
};
}
export type DigitalEmployeeProjectionSource = 'live' | 'demo';
export interface DigitalEmployeeProfile {
name: string;
title: string;
major: string;
company: string;
position?: string;
current_status?: 'working' | 'resting' | string;
current_status_label?: string;
motto: string;
}
export interface DigitalEmployeeMetric {
label: string;
value: string;
}
export interface DigitalEmployeeDuty {
id: string;
title: string;
summary: string;
implemented: boolean;
selectable: boolean;
selected: boolean;
status: string;
status_label: string;
status_tone: 'running' | 'success' | 'danger' | 'warning' | 'waiting' | 'info' | string;
schedule_text: string;
result_text: string;
conversation_id?: string | null;
}
export interface DigitalEmployeeWorkdayState {
confirmed: boolean;
confirmed_at?: string | null;
current_status?: 'working' | 'resting' | string;
current_status_label?: string;
status_label: string;
status_tone: string;
summary: string;
bubble_text: string;
}
export interface DigitalEmployeeOverview {
title: string;
status_label: string;
status_tone: string;
metrics: DigitalEmployeeMetric[];
}
export interface DigitalEmployeeWorkEvent {
id: string;
time: string;
plan_title: string;
task_title: string;
step_title?: string | null;
detail: string;
status: string;
status_label: string;
status_tone: string;
updated_at?: string | null;
conversation_id?: string | null;
}
export interface DigitalEmployeeTaskExecutionSummary {
id: string;
plan_id?: string | null;
title: string;
description: string;
selected: boolean;
total_count: number;
success_count: number;
failure_count: number;
running_count: number;
waiting_count: number;
latest_status_label: string;
latest_status_tone: string;
business_summary: string;
ai_summary?: string;
}
export interface DigitalEmployeeRunDetail {
id: string;
time: string;
task_name: string;
step_name?: string | null;
operation_name?: string | null;
status_label: string;
status_tone: string;
business_detail: string;
raw_detail?: string;
updated_at?: string | null;
conversation_id?: string | null;
}
export interface DigitalEmployeeDecisionAction {
id: string;
label: string;
tone: 'primary' | 'secondary' | string;
}
export interface DigitalEmployeeDecision {
id: string;
title: string;
scenario: string;
status: string;
status_label: string;
plan_title: string;
task_title: string;
actions: DigitalEmployeeDecisionAction[];
}
export interface DigitalEmployeeDeliveryArtifact {
name: string;
status: string;
}
export interface DigitalEmployeeDelivery {
headline: string;
summary: string;
lines: string[];
artifacts: DigitalEmployeeDeliveryArtifact[];
}
export interface DigitalEmployeeDailyReport {
date: string;
title: string;
report_schedule_time: string;
status: 'not_generated' | 'generating' | 'ready' | 'failed' | string;
generated_at?: string | null;
generated_by: 'ai' | 'fallback' | string;
model?: string | null;
source?: string;
summary: string;
totals: {
task_count: number;
execution_count: number;
success_count: number;
failure_count: number;
running_count?: number;
};
task_sections: DigitalEmployeeTaskExecutionSummary[];
detail_lines: string[];
pdf_available: boolean;
pdf_url?: string | null;
}
export interface DigitalEmployeeWorkdayProjection {
source: DigitalEmployeeProjectionSource;
demo_reason?: string | null;
date: string;
employee: DigitalEmployeeProfile;
workday: DigitalEmployeeWorkdayState;
overview: DigitalEmployeeOverview;
duties: DigitalEmployeeDuty[];
events: DigitalEmployeeWorkEvent[];
execution_summaries?: DigitalEmployeeTaskExecutionSummary[];
run_details?: DigitalEmployeeRunDetail[];
decisions: DigitalEmployeeDecision[];
delivery: DigitalEmployeeDelivery;
daily_report?: DigitalEmployeeDailyReport;
}
export interface DigitalEmployeeWorkdayActionRequest {
action: 'confirm_workday' | 'decide_approval' | 'update_profile' | 'update_report_schedule' | 'generate_daily_report';
date?: string;
duty_ids?: string[];
decision_id?: string;
decision?: string;
name?: string;
company?: string;
position?: string;
current_status?: 'working' | 'resting' | string;
report_schedule_time?: string;
}
export interface PlanDispatchTask {
task_id: string;
title: string;
status: string;
}
export interface PlanDispatchResponse {
ok?: boolean;
plan_id?: string;
source_plan_id?: string | null;
dispatched_tasks?: PlanDispatchTask[];
error?: string;
}
/** Shared type used by PlanSidebar and PlanChat for the selected plan state. */
export interface PlanChatPlan {
plan_id: string;
title: string;
objective: string;
status: string;
created_at?: string | null;
steps: Array<{
step_id: string;
title: string;
tasks: Array<{ task_id: string; title: string; status: string }>;
}>;
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfoFile",
"incremental": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsBuildInfoFile",
"incremental": true,
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
base: "./",
plugins: [tailwindcss(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "../../public/sgrobot-digital",
emptyOutDir: true,
},
});