吸收数字员工工作台入口重构收口

This commit is contained in:
baiyanyun
2026-06-29 19:53:34 +08:00
parent d0b7223b53
commit f84dc1d44e
42 changed files with 18514 additions and 3816 deletions

View File

@@ -8,10 +8,13 @@
"dev": "vite"
},
"dependencies": {
"animejs": "^4.4.1",
"lucide-react": "^0.468.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",

View File

@@ -1,4 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import {
buildDemoFlowEvents,
buildDemoPlanMessages,
buildDemoRuns,
buildDemoTasks,
getDemoMissions,
} from '@/pages/digital/demoScenario';
import {
displayBackendMessage,
displayBusinessText,
@@ -101,6 +108,20 @@ type DetailEvent = {
message: string;
};
type ApprovalSummary = {
id: string;
status: string;
note: string;
requestedAt: string | null;
decidedAt: string | null;
decidedBy: string | null;
runId: string;
taskId: string | null;
planId: string | null;
targetKind: string;
targetId: string;
};
function skillLabel(skillId: string | null | undefined): string {
return displayBusinessText(skillId || '', skillId || '自动化技能');
}
@@ -142,6 +163,29 @@ function latestByResultSubject(events: DetailEvent[]): DetailEvent[] {
return Array.from(bySubject.values());
}
function approvalSummariesFromRuns(runs: DebugRun[]): ApprovalSummary[] {
const byId = new Map<string, ApprovalSummary>();
for (const run of runs) {
for (const approval of run.approvals || []) {
const id = valueText(approval.approval_id) || `approval-${run.run_id}`;
byId.set(id, {
id,
status: valueText(approval.status) || 'requested',
note: valueText(approval.note) || '等待人工审批',
requestedAt: valueText(approval.requested_at) || null,
decidedAt: valueText(approval.decided_at) || null,
decidedBy: valueText(approval.decided_by) || null,
runId: valueText(approval.run_id) || run.run_id,
taskId: valueText(approval.task_id) || run.task_id || null,
planId: valueText(approval.plan_id) || run.plan_id || null,
targetKind: valueText(approval.target_kind) || 'task',
targetId: valueText(approval.target_id) || run.task_id || run.run_id,
});
}
}
return Array.from(byId.values()).sort((a, b) => (b.requestedAt || '').localeCompare(a.requestedAt || ''));
}
function objectValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
@@ -303,20 +347,25 @@ export default function FlowDetailPanel({ mission, onClose }: FlowDetailPanelPro
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 ?? []);
setRuns(tasksResult.value.runs ?? []);
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([]);
setRuns([]);
setTasks(fallbackTasks);
setRuns(fallbackRuns);
}
if (flowResult.status === 'fulfilled' && flowResult.value?.events && flowResult.value.events.length > 0) {
setFlow(flowResult.value);
} else {
setFlow(null);
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([]);
}
@@ -349,11 +398,18 @@ export default function FlowDetailPanel({ mission, onClose }: FlowDetailPanelPro
if (data.messages && data.messages.length > 0) {
setSessionMessages(data.messages);
} else {
setSessionMessages([]);
const demoMission = getDemoMissions().find((item) => item.id === planId);
setSessionMessages(demoMission ? buildDemoPlanMessages(demoMission) : []);
}
} catch (err) {
setSessionMessages([]);
setSessionError(err instanceof Error ? err.message : '读取会话失败');
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);
}
@@ -384,6 +440,11 @@ export default function FlowDetailPanel({ mission, onClose }: FlowDetailPanelPro
[eventTimeline],
);
const approvalSummaries = useMemo(
() => approvalSummariesFromRuns(runs),
[runs],
);
const resultEvents = useMemo(
() => {
const facts = eventTimeline.filter((event) => ['artifact', 'task_summary'].includes(event.kind));
@@ -444,6 +505,30 @@ export default function FlowDetailPanel({ mission, onClose }: FlowDetailPanelPro
</div>
</section>
{approvalSummaries.length > 0 ? (
<section className="de-detail-section">
<div className="de-detail-section-head">
<h4></h4>
<span className="de-chain-plan-id">{approvalSummaries.length} </span>
</div>
<div className="de-event-list">
{approvalSummaries.map((approval) => (
<div key={`approval-${approval.id}`} className="de-event-row">
<strong>{displayStatus(approval.status)}</strong>
<span>
{approval.note}
{' '}
{approval.taskId ? `Task ${approval.taskId}` : `Target ${approval.targetKind}:${approval.targetId}`}
{approval.runId ? ` / Run ${approval.runId}` : ''}
{approval.decidedBy ? ` / ${approval.decidedBy}` : ''}
</span>
<time>{fmtTime(approval.decidedAt || approval.requestedAt)}</time>
</div>
))}
</div>
</section>
) : null}
<section className="de-detail-section de-execution-chain">
<div className="de-detail-section-head">
<h4></h4>

View File

@@ -0,0 +1,119 @@
import { CheckCircle, ExternalLink, ShieldAlert, XCircle } from 'lucide-react';
import type { ApprovalDecisionView } from '@/types/api';
import { approvalDecisionId, isApprovalOpen } from '@/lib/approvals';
interface ApprovalActionCardProps {
decision: ApprovalDecisionView;
busyAction?: string | null;
disabled?: boolean;
compact?: boolean;
className?: string;
onResolve: (decision: ApprovalDecisionView, actionId: string) => void;
onViewContext?: (decision: ApprovalDecisionView) => void;
}
export default function ApprovalActionCard({
decision,
busyAction,
disabled = false,
compact = false,
className = '',
onResolve,
onViewContext,
}: ApprovalActionCardProps) {
const approvalId = approvalDecisionId(decision);
const isOpen = isApprovalOpen(decision.status);
const hasBusyAction = busyAction != null;
const contextLabel = [decision.plan_title, decision.task_title]
.map((item) => item?.trim())
.filter(Boolean)
.join(' / ');
return (
<div
className={className}
style={{
display: 'grid',
gap: compact ? 8 : 10,
padding: compact ? '10px 12px' : '14px 16px',
border: '1px solid rgba(255, 159, 10, 0.24)',
background: 'rgba(255, 159, 10, 0.08)',
borderRadius: 8,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<ShieldAlert size={16} style={{ color: 'var(--color-status-warning, #f59e0b)', flex: '0 0 auto', marginTop: 2 }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<strong style={{ color: 'var(--pc-text-primary, #f8fafc)', fontSize: compact ? 12 : 14 }}>
{decision.title || '真实执行事项需要确认'}
</strong>
<span style={{ color: 'var(--color-status-warning, #f59e0b)', fontSize: 11, fontWeight: 600 }}>
{decision.status_label || '待决策'}
</span>
</div>
<p style={{ margin: '4px 0 0', color: 'var(--pc-text-muted, #94a3b8)', fontSize: compact ? 12 : 13, lineHeight: 1.45 }}>
{decision.scenario || '请确认下一步处理方式。'}
</p>
{contextLabel && (
<p style={{ margin: '4px 0 0', color: 'var(--pc-text-secondary, #cbd5e1)', fontSize: 11 }}>
{contextLabel}
</p>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
{decision.actions.map((action) => {
const busy = busyAction === `${approvalId}:${action.id}`;
const primary = action.tone === 'primary';
return (
<button
key={action.id}
type="button"
disabled={disabled || hasBusyAction || !isOpen}
onClick={() => onResolve(decision, action.id)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
borderRadius: 999,
border: primary ? '1px solid rgba(48, 209, 88, 0.3)' : '1px solid rgba(255, 59, 48, 0.28)',
background: primary ? 'rgba(48, 209, 88, 0.15)' : 'rgba(255, 59, 48, 0.10)',
color: primary ? '#30d158' : 'var(--color-status-error, #ef4444)',
fontSize: 11,
fontWeight: 700,
padding: '5px 10px',
opacity: disabled || !isOpen ? 0.55 : 1,
cursor: disabled || hasBusyAction || !isOpen ? 'not-allowed' : 'pointer',
}}
>
{primary ? <CheckCircle size={13} /> : <XCircle size={13} />}
{busy ? '处理中...' : action.label}
</button>
);
})}
{onViewContext && (
<button
type="button"
onClick={() => onViewContext(decision)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
borderRadius: 999,
border: '1px solid var(--pc-border, rgba(148, 163, 184, 0.24))',
background: 'var(--pc-bg-surface, rgba(15, 23, 42, 0.72))',
color: 'var(--pc-text-secondary, #cbd5e1)',
fontSize: 11,
fontWeight: 700,
padding: '5px 10px',
}}
>
<ExternalLink size={13} />
</button>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react';
import { readDigitalEmployeeUiState, saveDigitalEmployeeUiStatePatch } from '@/lib/digitalUiStateBridge';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
export type ConsoleRole = 'finance' | 'engineering' | 'executive' | 'hr' | 'sales';
export type MotionLevel = 'none' | 'minimal';
@@ -123,12 +122,11 @@ interface RoleContextValue {
const RoleContext = createContext<RoleContextValue | null>(null);
const STORAGE_KEY = 'qimingclaw:digital-console-role';
const LEGACY_STORAGE_KEY = 'sgrobot-console-role';
const STORAGE_KEY = 'sgrobot-console-role';
function getStoredRole(): ConsoleRole {
try {
const stored = localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && stored in ROLE_PROFILES) return stored as ConsoleRole;
} catch { /* localStorage blocked */ }
return 'sales';
@@ -137,32 +135,9 @@ function getStoredRole(): ConsoleRole {
export function RoleProvider({ children }: { children: ReactNode }) {
const [role, setRoleState] = useState<ConsoleRole>(getStoredRole);
useEffect(() => {
let cancelled = false;
void readDigitalEmployeeUiState().then((state) => {
if (cancelled) return;
const storedRole = state?.consoleRole;
if (storedRole && storedRole in ROLE_PROFILES) {
setRoleState(storedRole as ConsoleRole);
try {
localStorage.setItem(STORAGE_KEY, storedRole);
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch { /* noop */ }
}
});
return () => { cancelled = true; };
}, []);
const setRole = useCallback((newRole: ConsoleRole) => {
setRoleState(newRole);
try {
localStorage.setItem(STORAGE_KEY, newRole);
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch { /* noop */ }
void saveDigitalEmployeeUiStatePatch(
{ consoleRole: newRole },
{ action: 'update_console_role', metadata: { role: newRole } },
);
try { localStorage.setItem(STORAGE_KEY, newRole); } catch { /* noop */ }
}, []);
const profile = ROLE_PROFILES[role];

View File

@@ -13,6 +13,7 @@ import type {
ChannelDetail,
BrowserSession,
ApprovalSubscriptionPreferences,
ApprovalDecisionsResponse,
BusinessViewResponse,
DigitalEmployeeBusinessViewKind,
DigitalEmployeeBusinessViewQuery,
@@ -72,6 +73,7 @@ import { handleQimingclawDigitalApi } from './qimingclawAdapter';
const API_FETCH_TIMEOUT_MS = 9000;
const DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS = 20000;
const DIGITAL_EMPLOYEE_REPORT_TIMEOUT_MS = 70000;
type ApiFetchOptions = RequestInit & {
timeoutMs?: number;
@@ -695,6 +697,14 @@ export function governanceCommand(
});
}
export function getApprovalDecisions(
status: 'open' | 'all' = 'open',
): Promise<ApprovalDecisionsResponse> {
return apiFetch<ApprovalDecisionsResponse>(
`/api/approvals?status=${encodeURIComponent(status)}`,
);
}
export function runSchedulerNow(): Promise<GovernanceCommandResponse> {
const stamp = Date.now();
return governanceCommand({
@@ -1021,6 +1031,109 @@ export function postDigitalEmployeeWorkdayAction(
});
}
export function syncDigitalEmployeeTaskSelection(
date: string,
dutyIds: string[],
): Promise<DigitalEmployeeWorkdayProjection> {
return apiFetch<DigitalEmployeeWorkdayProjection>('/api/digital-employee/workday/actions', {
method: 'POST',
body: JSON.stringify({
action: 'update_task_selection',
date,
duty_ids: dutyIds,
}),
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
});
}
export function generateDigitalEmployeeDailyReport(
body: Omit<DigitalEmployeeWorkdayActionRequest, 'action'>,
): Promise<DigitalEmployeeWorkdayProjection> {
return apiFetch<DigitalEmployeeWorkdayProjection>('/api/digital-employee/workday/actions', {
method: 'POST',
body: JSON.stringify({ ...body, action: 'generate_daily_report' }),
timeoutMs: DIGITAL_EMPLOYEE_REPORT_TIMEOUT_MS,
});
}
export function digitalEmployeeReportPdfUrl(date: string): string {
return `${apiOrigin()}${apiBase}/api/digital-employee/reports/${encodeURIComponent(date)}/pdf`;
}
export function revealDigitalEmployeeDailyReportFolder(
date: string,
): Promise<{ ok: boolean; folder?: string; path?: string }> {
return apiFetch<{ ok: boolean; folder?: string; path?: string }>(
`/api/digital-employee/reports/${encodeURIComponent(date)}/reveal`,
{
method: 'POST',
timeoutMs: DIGITAL_EMPLOYEE_REPORT_TIMEOUT_MS,
},
);
}
export async function downloadDigitalEmployeeDailyReportPdf(
date: string,
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const headers = new Headers();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), DIGITAL_EMPLOYEE_REPORT_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(digitalEmployeeReportPdfUrl(date), {
headers,
signal: controller.signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(`API request timed out after ${DIGITAL_EMPLOYEE_REPORT_TIMEOUT_MS / 1000}s: /api/digital-employee/reports/${date}/pdf`);
}
throw error;
} finally {
window.clearTimeout(timeout);
}
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}`);
}
const blob = await response.blob();
if (blob.size === 0) {
throw new Error('日报 PDF 内容为空,请重新生成后再下载。');
}
return {
blob,
filename: `智能数字人系统日报-${date}.pdf`,
};
}
export function triggerBrowserDownload(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => window.URL.revokeObjectURL(url), 30_000);
}
export function restartManagedService(
serviceId: string,
body: { reason?: string; actor?: string } = {},

View File

@@ -0,0 +1,121 @@
import type {
ApprovalDecisionView,
GovernanceCommandResponse,
UserNotification,
} from '@/types/api';
import { governanceCommand } from './api';
export type ApprovalDecisionResolution = 'approve' | 'reject';
const REJECT_ACTION_IDS = new Set(['reject', 'rejected', 'defer', 'deferred']);
const APPROVE_SHORTCUTS = new Set(['同意', '批准', '通过', '同意执行', '批准执行', 'approve', 'approved', 'yes', 'ok']);
const REJECT_SHORTCUTS = new Set(['拒绝', '驳回', '不同意', '否决', '拒绝执行', '驳回执行', 'reject', 'rejected', 'no']);
export function isApprovalOpen(status?: string | null): boolean {
return ['pending', 'requested'].includes(String(status || '').toLowerCase());
}
export function approvalDecisionId(decision: ApprovalDecisionView): string {
return decision.approval_id || decision.id;
}
export function approvalResolutionFromAction(actionId: string): ApprovalDecisionResolution {
return REJECT_ACTION_IDS.has(actionId.toLowerCase()) ? 'reject' : 'approve';
}
export function parseApprovalShortcut(input: string): ApprovalDecisionResolution | null {
const normalized = input
.trim()
.toLowerCase()
.replace(/[\s。.!?]/g, '');
if (APPROVE_SHORTCUTS.has(normalized)) return 'approve';
if (REJECT_SHORTCUTS.has(normalized)) return 'reject';
return null;
}
export function notificationToApprovalDecision(
notification: UserNotification,
): ApprovalDecisionView | null {
if (notification.kind !== 'need_approval' || notification.status !== 'unread') {
return null;
}
const approvalId = notification.approval_id || notification.correlation_id;
if (!approvalId) return null;
return {
id: approvalId,
approval_id: approvalId,
plan_id: notification.plan_id || null,
task_id: notification.task_id || null,
run_id: null,
target_kind: notification.task_id ? 'task' : null,
target_id: notification.task_id || notification.plan_id || approvalId,
title: notification.title || '真实执行事项需要确认',
scenario: notification.body || '请确认下一步处理方式。',
status: 'requested',
status_label: '待决策',
plan_title: notification.plan_id || '真实计划',
task_title: notification.task_id || approvalId,
actions: [
{ id: 'approve', label: '同意', tone: 'primary' },
{ id: 'reject', label: '驳回', tone: 'secondary' },
],
};
}
export function mergeApprovalDecisions(
canonical: ApprovalDecisionView[],
legacy: ApprovalDecisionView[],
): ApprovalDecisionView[] {
const merged = new Map<string, ApprovalDecisionView>();
canonical.forEach((decision) => {
merged.set(approvalDecisionId(decision), decision);
});
legacy.forEach((decision) => {
const id = approvalDecisionId(decision);
if (!merged.has(id)) merged.set(id, decision);
});
return Array.from(merged.values());
}
export async function submitApprovalDecision(
decision: ApprovalDecisionView,
actionIdOrResolution: string,
options: {
source: string;
comment?: string;
correlationPrefix?: string;
},
): Promise<GovernanceCommandResponse> {
const approvalId = approvalDecisionId(decision);
if (!approvalId) {
throw new Error('缺少 approval_id无法处理审批。');
}
const resolution = approvalResolutionFromAction(actionIdOrResolution);
const action = decision.actions.find((item) => item.id === actionIdOrResolution);
const comment = options.comment || action?.label || (resolution === 'reject' ? '驳回' : '同意');
const stamp = Date.now();
const prefix = (options.correlationPrefix || options.source || 'approval')
.replace(/[^a-zA-Z0-9_-]/g, '-')
.replace(/-+/g, '-');
const response = await governanceCommand({
command_kind: resolution === 'reject' ? 'reject_approval' : 'approve_approval',
context_refs: {
approval_id: approvalId,
plan_id: decision.plan_id || undefined,
task_id: decision.task_id || undefined,
run_id: decision.run_id || undefined,
},
payload: {
source: options.source,
comment,
target_kind: decision.target_kind || undefined,
target_id: decision.target_id || undefined,
},
correlation_id: `${prefix}-${approvalId}-${resolution}-${stamp}`,
idempotency_key: `${prefix}-${approvalId}-${resolution}-${stamp}`,
});
if (!response.accepted) {
throw new Error(response.message || `审批命令未被接受:${response.command_id || approvalId}`);
}
return response;
}

View File

@@ -1,12 +1,11 @@
const TOKEN_KEY = 'qimingclaw_digital_token';
const LEGACY_TOKEN_KEY = 'zeroclaw_token';
const TOKEN_KEY = 'zeroclaw_token';
/**
* Retrieve the stored authentication token.
*/
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY) ?? localStorage.getItem(LEGACY_TOKEN_KEY);
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
@@ -18,7 +17,6 @@ export function getToken(): string | null {
export function setToken(token: string): void {
try {
localStorage.setItem(TOKEN_KEY, token);
localStorage.removeItem(LEGACY_TOKEN_KEY);
} catch {
// localStorage may be unavailable (e.g. in some private browsing modes)
}
@@ -30,7 +28,6 @@ export function setToken(token: string): void {
export function clearToken(): void {
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
} catch {
// Ignore
}

View File

@@ -0,0 +1,47 @@
const DAY_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function pad(n: number): string {
return n < 10 ? '0' + n : String(n);
}
/** Format an ISO timestamp into a human-readable Chinese relative time. */
export function formatRelativeTime(iso: string | undefined | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const now = new Date();
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
// Today
if (isSameDay(d, now)) return `今天 ${time}`;
// Yesterday
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDay(d, yesterday)) return `昨天 ${time}`;
// Within 7 days: show weekday
const diffDays = Math.floor(
(now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays < 7 && d.getDay() !== now.getDay()) {
return `${DAY_NAMES[d.getDay()]} ${time}`;
}
// Same year: month-day
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()}`;
}
// Different year
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
}

View File

@@ -0,0 +1,27 @@
/**
* Generate a UUID v4 string.
*
* Uses `crypto.randomUUID()` when available (modern browsers, secure contexts)
* and falls back to a manual implementation backed by `crypto.getRandomValues()`
* for older browsers (e.g. Safari < 15.4, some Electron/Raspberry-Pi builds).
*
* Closes #3303, #3261.
*/
export function generateUUID(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: RFC 4122 version 4 UUID via getRandomValues
// crypto must exist if we reached here (only randomUUID is missing)
const c = globalThis.crypto;
const bytes = new Uint8Array(16);
c.getRandomValues(bytes);
// Set version (4) and variant (10xx) bits per RFC 4122
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

View File

@@ -0,0 +1,171 @@
import type { WsMessage } from '../types/api';
import { getToken } from './auth';
import { apiOrigin, apiBase } from './basePath';
import { isTauri } from './tauri';
import { generateUUID } from './uuid';
export type WsMessageHandler = (msg: WsMessage) => void;
export type WsOpenHandler = () => void;
export type WsCloseHandler = (ev: CloseEvent) => void;
export type WsErrorHandler = (ev: Event) => void;
export interface WebSocketClientOptions {
/** Base URL override. Defaults to current host with ws(s) protocol. */
baseUrl?: string;
/** Delay in ms before attempting reconnect. Doubles on each failure up to maxReconnectDelay. */
reconnectDelay?: number;
/** Maximum reconnect delay in ms. */
maxReconnectDelay?: number;
/** Set to false to disable auto-reconnect. Default true. */
autoReconnect?: boolean;
/** If set, route messages to the PlanAgent for this plan. */
planId?: string;
}
const DEFAULT_RECONNECT_DELAY = 1000;
const MAX_RECONNECT_DELAY = 30000;
const SESSION_STORAGE_KEY = 'zeroclaw_session_id';
/** Return a stable session ID, persisted in sessionStorage across reconnects. */
function getOrCreateSessionId(): string {
let id = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!id) {
id = generateUUID();
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
}
return id;
}
export class WebSocketClient {
private ws: WebSocket | null = null;
private currentDelay: number;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionallyClosed = false;
public onMessage: WsMessageHandler | null = null;
public onOpen: WsOpenHandler | null = null;
public onClose: WsCloseHandler | null = null;
public onError: WsErrorHandler | null = null;
private readonly baseUrl: string;
private readonly reconnectDelay: number;
private readonly maxReconnectDelay: number;
private readonly autoReconnect: boolean;
private planId: string | undefined;
constructor(options: WebSocketClientOptions = {}) {
let defaultBase: string;
if (isTauri() && apiOrigin()) {
defaultBase = apiOrigin().replace(/^http/, 'ws');
} else {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
defaultBase = `${protocol}//${window.location.host}`;
}
this.baseUrl = options.baseUrl ?? defaultBase;
this.reconnectDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY;
this.maxReconnectDelay = options.maxReconnectDelay ?? MAX_RECONNECT_DELAY;
this.autoReconnect = options.autoReconnect ?? true;
this.currentDelay = this.reconnectDelay;
this.planId = options.planId;
}
/** Open the WebSocket connection. */
connect(): void {
this.intentionallyClosed = false;
this.clearReconnectTimer();
const token = getToken();
const sessionId = getOrCreateSessionId();
const params = new URLSearchParams();
if (token) params.set('token', token);
params.set('session_id', sessionId);
if (this.planId) params.set('plan_id', this.planId);
const url = `${this.baseUrl}${apiBase}/ws/chat?${params.toString()}`;
const protocols: string[] = ['zeroclaw.v1'];
if (token) protocols.push(`bearer.${token}`);
this.ws = new WebSocket(url, protocols);
this.ws.onopen = () => {
this.currentDelay = this.reconnectDelay;
this.onOpen?.();
};
this.ws.onmessage = (ev: MessageEvent) => {
try {
const msg = JSON.parse(ev.data) as WsMessage;
this.onMessage?.(msg);
} catch {
// Ignore non-JSON frames
}
};
this.ws.onclose = (ev: CloseEvent) => {
this.onClose?.(ev);
this.scheduleReconnect();
};
this.ws.onerror = (ev: Event) => {
this.onError?.(ev);
};
}
/** Switch the optional plan binding used by the next connection. */
setPlanId(planId?: string | null): void {
this.planId = planId ?? undefined;
}
/** Send a chat message to the agent. */
sendMessage(content: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not connected');
}
const payload: Record<string, unknown> = { type: 'message', content };
if (this.planId) payload.plan_id = this.planId;
this.ws.send(JSON.stringify(payload));
}
/** Close the connection without auto-reconnecting. */
disconnect(): void {
this.intentionallyClosed = true;
this.clearReconnectTimer();
if (this.ws) {
const socket = this.ws;
this.ws = null;
if (socket.readyState === WebSocket.CONNECTING) {
socket.onopen = () => socket.close();
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
return;
}
socket.close();
}
}
/** Returns true if the socket is open. */
get connected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
// ---------------------------------------------------------------------------
// Reconnection logic
// ---------------------------------------------------------------------------
private scheduleReconnect(): void {
if (this.intentionallyClosed || !this.autoReconnect) return;
this.reconnectTimer = setTimeout(() => {
this.currentDelay = Math.min(this.currentDelay * 2, this.maxReconnectDelay);
this.connect();
}, this.currentDelay);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}

View File

@@ -1,24 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, BellRing, Check, ExternalLink, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { RoleProvider } from '@/contexts/RoleContext';
import {
dismissNotification,
getDesktopNotificationStatus,
getNotificationPreferences,
getNotifications,
getUnreadNotificationCount,
markNotificationRead,
openDesktopNotificationSettings,
patchNotificationPreferences,
pullNotificationUpdates,
replyToNotification,
showDesktopNotification,
type DesktopNotificationShowResult,
type DesktopNotificationStatus,
type NotificationPreferences,
type UserNotification,
} from '@/lib/api';
import CommandDeck from './CommandDeck';
import MissionCenter from './MissionCenter';
import SkillLibrary from './SkillLibrary';
@@ -35,57 +17,25 @@ import SchedulerWorkbench from './SchedulerWorkbench';
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: 'business', label: '业务视图' },
{ id: 'interventions', label: '介入台' },
{ id: 'memory-governance', label: '记忆治理' },
{ id: 'skill-audit', label: '技能审计' },
{ id: 'ops-workbench', label: '处置台' },
{ id: 'notification-workbench', label: '通知运营' },
{ id: 'daily-report-workbench', label: '日报运营' },
{ id: 'policy-center', label: '策略中心' },
{ id: 'scheduler-workbench', label: '调度运营' },
{ id: 'settings', label: '设置' },
const DIGITAL_TABS: readonly DigitalTab[] = [
'home',
'missions',
'skills',
'reports',
'business',
'interventions',
'memory-governance',
'skill-audit',
'ops-workbench',
'notification-workbench',
'daily-report-workbench',
'policy-center',
'scheduler-workbench',
'settings',
];
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'qimingclaw:digital-close';
const NOTIFICATION_LEVEL_OPTIONS: Array<{ id: UserNotification['level']; label: string }> = [
{ id: 'info', label: '消息' },
{ id: 'warn', label: '提醒' },
{ id: 'error', label: '异常' },
{ id: 'action', label: '待处理' },
];
const NOTIFICATION_KIND_OPTIONS: Array<{ id: UserNotification['kind']; label: string }> = [
{ id: 'task_finished', label: '任务完成' },
{ id: 'task_failed', label: '任务异常' },
{ id: 'need_approval', label: '审批' },
{ id: 'need_input', label: '输入' },
{ id: 'task_progress', label: '进度' },
];
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
enabled: true,
desktop_enabled: true,
muted_kinds: [],
muted_levels: [],
updated_at: null,
};
const DESKTOP_NOTIFICATION_DEDUPE_KEY = 'qimingclaw:digital-desktop-notified:v1';
const DESKTOP_NOTIFICATION_LEVELS = new Set<UserNotification['level']>(['action', 'warn', 'error']);
type DesktopNotificationFeedback = {
tone: 'success' | 'warning';
message: string;
detail?: string | null;
};
function isDigitalTab(value: string | null): value is DigitalTab {
return Boolean(value && TABS.some(tab => tab.id === value));
return Boolean(value && DIGITAL_TABS.includes(value as DigitalTab));
}
function tabFromPathname(pathname: string): DigitalTab | null {
@@ -122,8 +72,7 @@ function TabContent({
target: DigitalNavigationTarget;
onNavigate: (target: DigitalNavigationTarget) => void;
}) {
const tab = target.tab;
switch (tab) {
switch (target.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} onNavigate={onNavigate} />;
@@ -141,174 +90,15 @@ function TabContent({
}
}
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();
}
function notificationLevelLabel(level: UserNotification['level']): string {
if (level === 'action') return '待处理';
if (level === 'error') return '异常';
if (level === 'warn') return '提醒';
return '消息';
}
function notificationStatusLabel(status: UserNotification['status']): string {
if (status === 'resolved') return '已解决';
if (status === 'dismissed') return '已忽略';
if (status === 'read') return '已读';
return '未读';
}
function notificationTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
}
function desktopNotificationReasonLabel(reason?: string | null): string | null {
if (!reason) return null;
if (reason === 'notification_unavailable') return '系统桌面通知不可用或未授权';
if (reason === 'notification_settings_unavailable') return '当前平台无法自动打开系统通知设置';
if (reason === 'qimingclaw_bridge_unavailable') return '客户端通知桥接尚不可用';
return reason;
}
function desktopNotificationResultDetail(result: Pick<DesktopNotificationShowResult, 'reason' | 'error'>): string {
return desktopNotificationReasonLabel(result.reason) ?? result.error ?? '通知未能发送';
}
function readDesktopNotificationDedupe(): Set<string> {
try {
const raw = window.localStorage.getItem(DESKTOP_NOTIFICATION_DEDUPE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []);
} catch {
return new Set();
}
}
function writeDesktopNotificationDedupe(ids: Set<string>): void {
try {
window.localStorage.setItem(DESKTOP_NOTIFICATION_DEDUPE_KEY, JSON.stringify(Array.from(ids).slice(-300)));
} catch {
// localStorage may be unavailable in restricted webviews; desktop notification is best effort.
}
}
function notificationMatchesDesktopPreferences(notification: UserNotification, preferences: NotificationPreferences): boolean {
if (!preferences.enabled || !preferences.desktop_enabled) return false;
if (notification.status !== 'unread') return false;
if (!DESKTOP_NOTIFICATION_LEVELS.has(notification.level)) return false;
if (preferences.muted_levels.includes(notification.level)) return false;
return !preferences.muted_kinds.includes(notification.kind);
}
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<DesktopNotificationShowResult | null> {
const notified = readDesktopNotificationDedupe();
const candidates = notifications.filter((notification) => (
notificationMatchesDesktopPreferences(notification, preferences)
&& !notified.has(notification.notification_id)
));
if (candidates.length === 0) return null;
candidates.forEach((notification) => notified.add(notification.notification_id));
writeDesktopNotificationDedupe(notified);
const results = await Promise.all(candidates.slice(0, 3).map((notification) => showDesktopNotification({
notification_id: notification.notification_id,
title: notification.title,
body: notification.body,
level: notification.level,
kind: notification.kind,
status: notification.status,
}).catch((error): DesktopNotificationShowResult => ({
ok: false,
shown: false,
error: error instanceof Error ? error.message : String(error),
}))));
return results.find((result) => result.ok === false || result.shown !== true) ?? null;
}
export default function DigitalEmployeeShell() {
const location = useLocation();
const navigate = useNavigate();
const [target, setTarget] = useState<DigitalNavigationTarget>(() => targetFromLocation(location.pathname, location.search));
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsLoading, setNotificationsLoading] = useState(false);
const [notificationsSyncing, setNotificationsSyncing] = useState(false);
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false);
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>(DEFAULT_NOTIFICATION_PREFERENCES);
const [desktopNotificationStatus, setDesktopNotificationStatus] = useState<DesktopNotificationStatus | null>(null);
const [desktopNotificationStatusLoading, setDesktopNotificationStatusLoading] = useState(false);
const [desktopNotificationTesting, setDesktopNotificationTesting] = useState(false);
const [desktopNotificationSettingsOpening, setDesktopNotificationSettingsOpening] = useState(false);
const [desktopNotificationFeedback, setDesktopNotificationFeedback] = useState<DesktopNotificationFeedback | null>(null);
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
const refreshDesktopNotificationStatus = useCallback(async () => {
setDesktopNotificationStatusLoading(true);
try {
setDesktopNotificationStatus(await getDesktopNotificationStatus());
} catch (error) {
setDesktopNotificationStatus({
supported: false,
platform: 'unknown',
can_open_settings: false,
reason: error instanceof Error ? error.message : String(error),
});
} finally {
setDesktopNotificationStatusLoading(false);
}
}, []);
const refreshNotifications = useCallback(async () => {
setNotificationsLoading(true);
try {
const [items, count, preferences] = await Promise.all([
getNotifications('all'),
getUnreadNotificationCount(),
getNotificationPreferences().catch(() => DEFAULT_NOTIFICATION_PREFERENCES),
]);
setNotifications(items);
setUnreadCount(count);
setNotificationPreferences(preferences);
void showUnreadDesktopNotifications(items, preferences).then((failure) => {
if (failure) {
setDesktopNotificationFeedback({
tone: 'warning',
message: '自动桌面通知发送失败',
detail: desktopNotificationResultDetail(failure),
});
}
});
} catch {
setNotifications([]);
setUnreadCount(0);
setNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
} finally {
setNotificationsLoading(false);
}
}, []);
useEffect(() => {
setTarget(targetFromLocation(location.pathname, location.search));
}, [location.pathname, location.search]);
useEffect(() => {
void refreshNotifications();
}, [refreshNotifications]);
useEffect(() => {
if (notificationSettingsOpen) {
void refreshDesktopNotificationStatus();
}
}, [notificationSettingsOpen, refreshDesktopNotificationStatus]);
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
setTarget(nextTarget);
const params = new URLSearchParams();
@@ -331,314 +121,9 @@ export default function DigitalEmployeeShell() {
}, { replace: false });
}, [location.pathname, navigate]);
const handleNotificationRead = useCallback(async (id: string) => {
await markNotificationRead(id);
await refreshNotifications();
}, [refreshNotifications]);
const handleNotificationDismiss = useCallback(async (id: string) => {
await dismissNotification(id);
await refreshNotifications();
}, [refreshNotifications]);
const handleNotificationReply = useCallback(async (id: string) => {
const content = (replyDraftById[id] ?? '').trim();
if (!content) return;
await replyToNotification(id, content);
setReplyDraftById((current) => ({ ...current, [id]: '' }));
await refreshNotifications();
}, [refreshNotifications, replyDraftById]);
const handleNotificationSync = useCallback(async () => {
setNotificationsSyncing(true);
try {
await pullNotificationUpdates();
} finally {
setNotificationsSyncing(false);
await refreshNotifications();
}
}, [refreshNotifications]);
const updateNotificationPreferences = useCallback(async (patch: Partial<NotificationPreferences>) => {
const next = await patchNotificationPreferences(patch);
setNotificationPreferences(next);
await refreshNotifications();
}, [refreshNotifications]);
const toggleMutedLevel = useCallback((level: UserNotification['level']) => {
const muted = new Set(notificationPreferences.muted_levels);
if (muted.has(level)) muted.delete(level);
else muted.add(level);
void updateNotificationPreferences({ muted_levels: Array.from(muted) });
}, [notificationPreferences.muted_levels, updateNotificationPreferences]);
const toggleMutedKind = useCallback((kind: UserNotification['kind']) => {
const muted = new Set(notificationPreferences.muted_kinds);
if (muted.has(kind)) muted.delete(kind);
else muted.add(kind);
void updateNotificationPreferences({ muted_kinds: Array.from(muted) });
}, [notificationPreferences.muted_kinds, updateNotificationPreferences]);
const handleDesktopNotificationTest = useCallback(async () => {
setDesktopNotificationTesting(true);
try {
const result = await showDesktopNotification({
notification_id: `desktop-notification-test-${Date.now()}`,
title: '数字员工桌面通知测试',
body: '如果看到这条通知,系统桌面通知入口已可用。',
level: 'info',
kind: 'task_progress',
status: 'unread',
});
setDesktopNotificationFeedback(result.ok !== false && result.shown === true
? { tone: 'success', message: '测试桌面通知已发送', detail: null }
: { tone: 'warning', message: '测试桌面通知发送失败', detail: desktopNotificationResultDetail(result) });
await refreshDesktopNotificationStatus();
} catch (error) {
setDesktopNotificationFeedback({
tone: 'warning',
message: '测试桌面通知发送失败',
detail: error instanceof Error ? error.message : String(error),
});
} finally {
setDesktopNotificationTesting(false);
}
}, [refreshDesktopNotificationStatus]);
const handleDesktopNotificationSettingsOpen = useCallback(async () => {
setDesktopNotificationSettingsOpening(true);
try {
const result = await openDesktopNotificationSettings();
setDesktopNotificationFeedback(result.success
? { tone: 'success', message: '已打开系统设置', detail: null }
: { tone: 'warning', message: '无法打开系统设置', detail: desktopNotificationReasonLabel(result.reason) ?? result.error ?? '当前平台不支持自动打开' });
await refreshDesktopNotificationStatus();
} catch (error) {
setDesktopNotificationFeedback({
tone: 'warning',
message: '无法打开系统设置',
detail: error instanceof Error ? error.message : String(error),
});
} finally {
setDesktopNotificationSettingsOpening(false);
}
}, [refreshDesktopNotificationStatus]);
const desktopNotificationTone = desktopNotificationFeedback?.tone === 'warning' || desktopNotificationStatus?.supported === false ? 'warning' : 'success';
const desktopNotificationMessage = desktopNotificationFeedback?.message
?? (desktopNotificationStatusLoading
? '正在检查桌面通知'
: desktopNotificationStatus?.supported === false
? '系统桌面通知不可用'
: '系统桌面通知可用');
const desktopNotificationDetail = desktopNotificationFeedback?.detail
?? desktopNotificationReasonLabel(desktopNotificationStatus?.reason)
?? (desktopNotificationStatus?.platform ? `平台:${desktopNotificationStatus.platform}` : null);
const shouldShowDesktopSettingsButton = desktopNotificationStatus?.can_open_settings === true || desktopNotificationTone === 'warning';
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>
<div className="de-notification-anchor">
<button
type="button"
className={`de-header-icon ${notificationsOpen ? 'active' : ''}`}
title="通知"
onClick={() => {
setNotificationsOpen((open) => !open);
void refreshNotifications();
}}
>
<Bell size={16} />
{unreadCount > 0 && <span className="de-notification-count">{unreadCount > 99 ? '99+' : unreadCount}</span>}
</button>
{notificationsOpen && (
<div className="de-notification-popover">
<div className="de-notification-head">
<div className="de-notification-head-title">
<strong></strong>
<span>{unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读'}</span>
</div>
<div className="de-notification-head-actions">
<button
type="button"
className={`de-header-icon de-notification-close ${notificationsSyncing ? 'active' : ''}`}
title="同步通知"
onClick={() => { void handleNotificationSync(); }}
disabled={notificationsSyncing}
>
<RefreshCw size={14} />
</button>
<button
type="button"
className={`de-header-icon de-notification-close ${notificationSettingsOpen ? 'active' : ''}`}
title="通知设置"
onClick={() => setNotificationSettingsOpen((open) => !open)}
>
<Settings2 size={14} />
</button>
<button type="button" className="de-header-icon de-notification-close" title="关闭" onClick={() => setNotificationsOpen(false)}>
<X size={14} />
</button>
</div>
</div>
{notificationSettingsOpen && (
<div className="de-notification-preferences">
<label className="de-notification-switch">
<input
type="checkbox"
checked={notificationPreferences.enabled}
onChange={(event) => { void updateNotificationPreferences({ enabled: event.target.checked }); }}
/>
<span></span>
</label>
<label className="de-notification-switch">
<input
type="checkbox"
checked={notificationPreferences.desktop_enabled}
onChange={(event) => { void updateNotificationPreferences({ desktop_enabled: event.target.checked }); }}
/>
<span></span>
</label>
<div className={`de-notification-desktop-status de-notification-desktop-status--${desktopNotificationTone}`}>
<div className="de-notification-desktop-copy">
<span></span>
<strong>{desktopNotificationMessage}</strong>
{desktopNotificationDetail && <small>{desktopNotificationDetail}</small>}
</div>
<div className="de-notification-desktop-actions">
<button
type="button"
className="de-notification-desktop-btn"
title="测试桌面通知"
onClick={() => { void handleDesktopNotificationTest(); }}
disabled={desktopNotificationTesting}
>
<BellRing size={13} />
<span></span>
</button>
{shouldShowDesktopSettingsButton && (
<button
type="button"
className="de-notification-desktop-btn"
title="打开系统设置"
onClick={() => { void handleDesktopNotificationSettingsOpen(); }}
disabled={desktopNotificationSettingsOpening}
>
<ExternalLink size={13} />
<span></span>
</button>
)}
</div>
</div>
<div className="de-notification-preference-group">
<span></span>
<div className="de-notification-segment-row">
{NOTIFICATION_LEVEL_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className={notificationPreferences.muted_levels.includes(option.id) ? 'active' : ''}
title={`${option.label}静音`}
onClick={() => toggleMutedLevel(option.id)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="de-notification-preference-group">
<span></span>
<div className="de-notification-toggle-grid">
{NOTIFICATION_KIND_OPTIONS.map((option) => (
<label key={option.id}>
<input
type="checkbox"
checked={notificationPreferences.muted_kinds.includes(option.id)}
onChange={() => toggleMutedKind(option.id)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</div>
</div>
)}
<div className="de-notification-list">
{notificationsLoading && <div className="de-notification-empty"></div>}
{!notificationsLoading && notifications.length === 0 && <div className="de-notification-empty"></div>}
{!notificationsLoading && notifications.map((notification) => (
<article key={notification.notification_id} className={`de-notification-item de-notification-item--${notification.level}`}>
<div className="de-notification-item-head">
<span className="de-notification-level">{notificationLevelLabel(notification.level)}</span>
<span className="de-notification-time">{notificationTime(notification.created_at)}</span>
</div>
<div className="de-notification-title-row">
<h3>{notification.title}</h3>
<span>{notificationStatusLabel(notification.status)}</span>
</div>
<p>{notification.body}</p>
{notification.reply && <div className="de-notification-reply">{notification.reply}</div>}
{notification.status !== 'resolved' && notification.status !== 'dismissed' && (
<div className="de-notification-actions">
{notification.status === 'unread' && (
<button type="button" className="de-notification-icon-btn" title="标记已读" onClick={() => void handleNotificationRead(notification.notification_id)}>
<Check size={14} />
</button>
)}
<button type="button" className="de-notification-icon-btn" title="忽略" onClick={() => void handleNotificationDismiss(notification.notification_id)}>
<X size={14} />
</button>
</div>
)}
{notification.kind === 'need_input' && notification.status !== 'dismissed' && (
<div className="de-notification-reply-row">
<input
value={replyDraftById[notification.notification_id] ?? ''}
onChange={(event) => setReplyDraftById((current) => ({ ...current, [notification.notification_id]: event.target.value }))}
/>
<button type="button" className="de-notification-icon-btn" title="回复" onClick={() => void handleNotificationReply(notification.notification_id)}>
<MessageSquare size={14} />
</button>
</div>
)}
</article>
))}
</div>
</div>
)}
</div>
<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 de-shell--embedded">
<div className="de-shell-body">
<TabContent target={target} onNavigate={navigateDigital} />
</div>

View File

@@ -7,12 +7,26 @@ import { adaptMissions, type Mission } from '@/lib/consoleDataAdapter';
import { getDebugPlans, governanceCommand, searchDebugPlans } from '@/lib/api';
import type { GovernanceCommandKind, GovernanceCommandResponse } from '@/types/api';
import { t } from '@/lib/i18n';
import {
completeDemoMissionOnce,
getDemoMissions,
runDemoMissionOnce,
} from './demoScenario';
import { domSafeId } from './navigation';
const REQUIRED_MISSION_IDS = [
'qimingclaw-client-readiness',
'qimingclaw-remote-task-ingress',
'qimingclaw-daily-report',
'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 = [
@@ -132,6 +146,27 @@ function missionDateSortValue(mission: Mission, selectedDate: string): number {
return 24 * 60;
}
function missionLatestExecutionTime(mission: Mission, selectedDate: string): number {
const times = missionTaskTimes(mission)
.filter((value) => dateKey(value) === selectedDate)
.map((value) => new Date(value).getTime())
.filter(Number.isFinite);
return times.length > 0 ? Math.max(...times) : 0;
}
function missionRuntimePriority(mission: Mission): number {
const statuses = [
mission.status,
...mission.steps.map((step) => step.status),
...mission.steps.flatMap((step) => step.tasks.map((task) => task.status)),
].map((status) => status.toLowerCase());
if (statuses.some((status) => ['running', 'active', 'leased'].includes(status))) return 5;
if (statuses.some((status) => ['queued', 'ready', 'pending'].includes(status))) return 4;
if (statuses.some((status) => ['failed', 'error'].includes(status))) return 3;
if (statuses.some((status) => ['succeeded', 'success', 'completed', 'complete', 'finished'].includes(status))) return 2;
return 1;
}
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';
@@ -270,6 +305,7 @@ export default function MissionCenter({
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);
@@ -317,18 +353,51 @@ export default function MissionCenter({
.sort((a, b) => planSortTime(b) - planSortTime(a));
const orderedPlans = [...recentPlans, ...requiredPlans];
if (orderedPlans.length > 0) {
const nextMissions = adaptMissions(orderedPlans);
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;
}
setMissions([]);
return [];
} catch (err) {
if (!mountedRef.current) return [];
console.error('[MissionCenter] fetch failed:', err);
setMissions([]);
setError(true);
return [];
const demoMissions = getDemoMissions();
setDemoMode(true);
setMissions(demoMissions);
setError(false);
return demoMissions;
} finally {
if (mountedRef.current) setLoading(false);
}
@@ -381,9 +450,23 @@ export default function MissionCenter({
setRunningMissionId(mission.id);
setError(false);
setActionError(null);
if (!mission.sourceIds.planId) {
setActionError('当前任务没有 qimingclaw Plan 记录,无法委托执行。');
setRunningMissionId(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 {
@@ -424,15 +507,14 @@ export default function MissionCenter({
} finally {
setRunningMissionId(null);
}
}, [fetchMissions, focusMission]);
}, [demoMode, fetchMissions, focusMission]);
const restartMission = useCallback(async (mission: Mission) => {
setRunningMissionId(mission.id);
setError(false);
setActionError(null);
if (!mission.sourceIds.planId) {
setActionError('当前任务没有 qimingclaw Plan 记录,无法重新执行。');
setRunningMissionId(null);
if (demoMode || !mission.sourceIds.planId) {
await runMission(mission);
return;
}
try {
@@ -464,7 +546,7 @@ export default function MissionCenter({
} finally {
setRunningMissionId(null);
}
}, [fetchMissions, focusMission]);
}, [demoMode, fetchMissions, focusMission, runMission]);
const selectedDate = dateFilter || todayDateKey();
const filteredByDate = dedupeMissionsForDate(
@@ -481,6 +563,27 @@ export default function MissionCenter({
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);
@@ -496,7 +599,7 @@ export default function MissionCenter({
setRunningMissionId(null);
setRunningAll(false);
}
}, [fetchMissions, runnableMissions]);
}, [demoMode, fetchMissions, runnableMissions]);
const filtered = filter === 'all'
? filteredByDate
@@ -505,10 +608,12 @@ export default function MissionCenter({
.map((mission, index) => ({ mission, index }))
.sort((a, b) => {
const priority = (mission: Mission) => (
(mission.id === recentMissionId ? 2 : 0)
+ (mission.id === selected?.id ? 1 : 0)
missionRuntimePriority(mission) * 100
+ (mission.id === recentMissionId ? 20 : 0)
+ (mission.id === selected?.id ? 10 : 0)
);
return priority(b.mission) - priority(a.mission)
|| missionLatestExecutionTime(b.mission, selectedDate) - missionLatestExecutionTime(a.mission, selectedDate)
|| missionDateSortValue(a.mission, selectedDate) - missionDateSortValue(b.mission, selectedDate)
|| a.index - b.index;
})
@@ -541,6 +646,11 @@ export default function MissionCenter({
<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}
@@ -592,13 +702,13 @@ export default function MissionCenter({
) : displayMissions.length === 0 ? (
<div className="de-mission-empty-panel">
<strong></strong>
<p> qimingclaw </p>
<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>
@@ -630,7 +740,7 @@ export default function MissionCenter({
<div className="de-card-body">
<div className="de-mission-empty-panel de-mission-empty-panel--detail">
<strong></strong>
<p> qimingclaw Plan / Task / Run / Event payload 线</p>
<p></p>
</div>
</div>
</GlassCard>

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,74 @@
import { useRef, useEffect, type KeyboardEvent, type ChangeEvent } from 'react';
import { Send } from 'lucide-react';
import { t } from '@/lib/i18n';
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
connected: boolean;
disabled?: boolean;
}
export default function ChatInput({
value,
onChange,
onSend,
connected,
disabled,
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 200)}px`;
}
}, [value]);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
return (
<div className="plan-chat-input border-t px-4 py-3" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<div className="flex items-end gap-2 max-w-3xl mx-auto">
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={connected ? t('planb.chat.placeholder') : t('agent.connecting')}
disabled={!connected || disabled}
className="input-electric flex-1 px-4 text-sm resize-none disabled:opacity-40 rounded-2xl"
style={{ minHeight: '44px', maxHeight: '200px', paddingTop: '11px', paddingBottom: '11px' }}
/>
<button
type="button"
onClick={onSend}
disabled={!connected || !value.trim() || disabled}
className="flex-shrink-0 rounded-full flex items-center justify-center transition-all"
style={{
width: '40px',
height: '40px',
background: connected && value.trim() ? 'var(--pc-accent)' : 'var(--pc-bg-elevated)',
border: connected && value.trim() ? 'none' : '1px solid var(--pc-border)',
color: connected && value.trim() ? 'white' : 'var(--pc-text-faint)',
opacity: connected && value.trim() ? 1 : 0.5,
}}
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,412 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { ArrowLeft, Bot, User, AlertCircle, Copy, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { WsMessage, PlanChatPlan } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
import { getPlanMessages } from '@/lib/api';
import { generateUUID } from '@/lib/uuid';
import { formatRelativeTime } from '@/lib/dateFormat';
import ChatInput from '@/planb/ChatInput';
interface ChatMessage {
id: string;
role: 'user' | 'agent' | 'system';
content: string;
thinking?: string;
markdown?: boolean;
timestamp: Date;
}
interface Props {
plan: PlanChatPlan;
onBack: () => void;
mode?: 'plan-control' | 'conversation';
contextText?: string;
}
const STATUS_LABEL: Record<string, string> = {
draft: '草稿',
reviewing: '待确认',
approved: '已批准',
active: '执行中',
blocked: '已阻塞',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
superseded: '已替换',
};
function stepProgress(p: PlanChatPlan) {
let total = 0;
let done = 0;
for (const s of p.steps || []) {
for (const t of s.tasks || []) {
total++;
if (t.status === 'succeeded' || t.status === 'completed') done++;
}
}
return { total, done };
}
export default function PlanChat({
plan,
onBack,
mode = 'plan-control',
contextText = '',
}: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [typing, setTyping] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const wsRef = useRef<WebSocketClient | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const pendingContentRef = useRef('');
const pendingThinkingRef = useRef('');
const capturedThinkingRef = useRef('');
const contextTextRef = useRef(contextText);
const [streamingContent, setStreamingContent] = useState('');
const [streamingThinking, setStreamingThinking] = useState('');
useEffect(() => {
contextTextRef.current = contextText;
}, [contextText]);
useEffect(() => {
let cancelled = false;
setMessages([]);
if (mode === 'conversation') {
setMessages([{
id: generateUUID(),
role: 'agent',
content: `已进入“${plan.title || plan.plan_id}”执行单会话。\n我已关联当前执行状态和待处理事项你可以询问当前进展、需要提供什么材料或下一步安排。`,
timestamp: new Date(),
}]);
return () => { cancelled = true; };
}
getPlanMessages(plan.plan_id)
.then((data) => {
if (cancelled) return;
setMessages((data.messages || []).map((msg) => ({
id: generateUUID(),
role: msg.role === 'user' ? 'user' : msg.role === 'assistant' ? 'system' : 'system',
content: msg.content,
timestamp: new Date(),
})));
})
.catch(() => {
if (!cancelled) setMessages([]);
});
return () => { cancelled = true; };
}, [mode, plan.plan_id, plan.title]);
// Plan control binds plan_id; embedded execution conversations use the normal agent channel.
useEffect(() => {
const ws = new WebSocketClient(mode === 'conversation' ? {} : { planId: plan.plan_id });
ws.onOpen = () => { setConnected(true); setError(null); };
ws.onClose = (ev) => {
setConnected(false);
if (ev.code !== 1000 && ev.code !== 1001) setError(`Connection closed (code: ${ev.code})`);
};
ws.onError = () => setError('Connection error');
ws.onMessage = (msg: WsMessage) => {
switch (msg.type) {
case 'thinking':
setTyping(true);
pendingThinkingRef.current += msg.content ?? '';
setStreamingThinking(pendingThinkingRef.current);
break;
case 'chunk':
setTyping(true);
pendingContentRef.current += msg.content ?? '';
setStreamingContent(pendingContentRef.current);
break;
case 'chunk_reset':
capturedThinkingRef.current = pendingThinkingRef.current;
pendingContentRef.current = ''; pendingThinkingRef.current = '';
setStreamingContent(''); setStreamingThinking('');
break;
case 'message': case 'done': {
const content = msg.full_response ?? msg.content ?? pendingContentRef.current;
const thinking = capturedThinkingRef.current || pendingThinkingRef.current || undefined;
if (content) {
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'agent', content, thinking,
markdown: true, timestamp: new Date(),
}]);
}
pendingContentRef.current = ''; pendingThinkingRef.current = '';
capturedThinkingRef.current = '';
setStreamingContent(''); setStreamingThinking('');
setTyping(false);
break;
}
case 'plan_msg_ack':
// Just acknowledge — user message was delivered to PlanAgent
break;
case 'plan_notification':
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'system',
content: msg.content ?? '',
timestamp: new Date(),
}]);
break;
case 'plan_need_input':
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'system',
content: msg.question ? `需要输入:${msg.question}` : '需要用户输入',
timestamp: new Date(),
}]);
break;
case 'plan_need_approval':
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'system',
content: msg.action ? `需要审批:${msg.action}` : '需要用户审批',
timestamp: new Date(),
}]);
break;
case 'plan_activity':
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'agent',
content: msg.content ?? msg.body ?? msg.title ?? '执行进度已更新',
timestamp: new Date(),
}]);
pendingContentRef.current = ''; pendingThinkingRef.current = '';
capturedThinkingRef.current = '';
setStreamingContent(''); setStreamingThinking('');
if (msg.phase === 'plan_done' || msg.phase === 'plan_failed') {
setTyping(false);
}
break;
case 'governance': {
if (!msg.created_plan_id && msg.message) {
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'system',
content: msg.message ?? '执行请求已受理',
timestamp: new Date(),
}]);
}
pendingContentRef.current = ''; pendingThinkingRef.current = '';
capturedThinkingRef.current = '';
setStreamingContent(''); setStreamingThinking('');
setTyping(false);
break;
}
case 'ask':
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'agent',
content: msg.question ?? msg.message ?? '请补充信息。',
timestamp: new Date(),
}]);
pendingContentRef.current = ''; pendingThinkingRef.current = '';
capturedThinkingRef.current = '';
setStreamingContent(''); setStreamingThinking('');
setTyping(false);
break;
case 'error':
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'system',
content: `Error: ${msg.message ?? 'Unknown error'}`,
timestamp: new Date(),
}]);
break;
}
};
ws.connect();
wsRef.current = ws;
return () => { ws.disconnect(); };
}, [mode, plan.plan_id]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, typing]);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || !wsRef.current?.connected) return;
setMessages((prev) => [...prev, {
id: generateUUID(), role: 'user', content: trimmed, timestamp: new Date(),
}]);
try {
const outboundMessage = mode === 'conversation'
? [
'你正在数字员工页面的“执行单会话”中。以下内容仅作为当前业务上下文:',
contextTextRef.current,
'',
`用户消息:${trimmed}`,
'',
'请使用自然、简洁的对话方式回答。优先结合上下文说明当前进展、是否需要用户处理以及下一步。',
'除非用户明确要求控制当前执行单,否则不要将消息解释为 Plan 控制命令,不要返回 retry、skip、pause、resume、status 命令帮助,也不要创建新的计划或任务。',
].filter(Boolean).join('\n')
: trimmed;
pendingContentRef.current = '';
pendingThinkingRef.current = '';
capturedThinkingRef.current = '';
setStreamingContent('');
setStreamingThinking('');
setTyping(true);
wsRef.current.sendMessage(outboundMessage);
} catch { setError('Send failed'); }
setInput('');
}, [input, mode]);
const handleCopy = useCallback((msgId: string, content: string) => {
const onSuccess = () => { setCopiedId(msgId); setTimeout(() => setCopiedId((p) => (p === msgId ? null : p)), 2000); };
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(content).then(onSuccess).catch(() => {});
}
}, []);
const prog = stepProgress(plan);
const statusColor =
plan.status === 'active' ? 'var(--color-status-success)'
: plan.status === 'blocked' ? 'var(--color-status-error)'
: plan.status === 'reviewing' ? '#f59e0b'
: 'var(--pc-text-muted)';
return (
<div className={`plan-chat flex flex-col h-full min-h-0 ${mode === 'conversation' ? 'plan-chat--conversation' : 'plan-chat--control'}`}>
{/* Plan identity header */}
{mode === 'plan-control' && (
<div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: 'var(--pc-border)', background: 'var(--pc-bg-surface)' }}>
<div className="flex items-center gap-2 mb-1">
<button
onClick={onBack}
className="h-6 w-6 flex items-center justify-center rounded"
style={{ color: 'var(--pc-text-muted)' }}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<span className="text-sm font-semibold" style={{ color: 'var(--pc-text-primary)' }}>
{plan.title || plan.plan_id}
</span>
</div>
<div className="flex items-center gap-3 text-[10px] ml-8" style={{ color: 'var(--pc-text-muted)' }}>
<span>{formatRelativeTime(plan.created_at)} </span>
<span style={{ color: statusColor }}>{STATUS_LABEL[plan.status] || plan.status}</span>
{prog.total > 0 && <span>{prog.done}/{prog.total} steps</span>}
</div>
{plan.objective && (
<div className="text-[10px] ml-8 mt-0.5 truncate" style={{ color: 'var(--pc-text-faint)' }}>
"{plan.objective}"
</div>
)}
</div>
)}
{/* Error banner */}
{error && (
<div className="px-3 py-1.5 border-b flex items-center gap-2 text-xs flex-shrink-0" style={{ background: 'rgba(239,68,68,0.08)', borderColor: 'rgba(239,68,68,0.2)', color: '#f87171' }}>
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{error}</span>
<button onClick={() => setError(null)} className="ml-auto shrink-0 text-xs underline">Close</button>
</div>
)}
{/* Messages */}
<div className="plan-chat-messages flex-1 overflow-y-auto px-3 min-h-0">
{messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-xs" style={{ color: 'var(--pc-text-muted)' }}>
{mode === 'conversation'
? '执行单沟通已就绪,可以询问进展、待办事项或下一步安排'
: 'Plan 对话就绪 — 发送命令或消息与 PlanAgent 互动'}
</p>
</div>
) : (
<div className="py-3 space-y-3">
{messages.map((msg) => (
<div key={msg.id} className={`group flex items-start gap-2 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
<div
className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center border"
style={{
background: msg.role === 'user' ? 'var(--pc-accent)' : msg.role === 'system' ? 'var(--pc-bg-surface)' : 'var(--pc-bg-elevated)',
borderColor: msg.role === 'user' ? 'var(--pc-accent)' : 'var(--pc-border)',
}}
>
{msg.role === 'user'
? <User className="h-3 w-3 text-white" />
: msg.role === 'system'
? <AlertCircle className="h-3 w-3" style={{ color: 'var(--pc-accent)' }} />
: <Bot className="h-3 w-3" style={{ color: 'var(--pc-accent)' }} />
}
</div>
<div className="relative max-w-[85%] min-w-0">
<div
className="rounded-xl px-3 py-2 border"
style={
msg.role === 'user'
? { background: 'var(--pc-accent-glow)', borderColor: 'var(--pc-accent-dim)', color: 'var(--pc-text-primary)' }
: msg.role === 'system'
? { background: 'var(--pc-bg-elevated)', borderColor: 'rgba(167,139,250,0.2)', color: 'var(--pc-text-muted)' }
: { background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)' }
}
>
{msg.thinking && (
<details className="mb-1.5">
<summary className="text-[11px] cursor-pointer select-none font-medium" style={{ color: 'var(--pc-text-muted)' }}>
Thought
</summary>
<pre className="text-[10px] mt-1 whitespace-pre-wrap break-words overflow-auto max-h-36 p-1.5 rounded-lg" style={{ color: 'var(--pc-text-muted)', background: 'var(--pc-bg-surface)' }}>
{msg.thinking}
</pre>
</details>
)}
{msg.markdown ? (
<div className="text-[13px] break-words leading-relaxed chat-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
) : (
<p className="text-[13px] whitespace-pre-wrap break-words">{msg.content}</p>
)}
</div>
{msg.role === 'agent' && (
<button
onClick={() => handleCopy(msg.id, msg.content)}
className="absolute top-0.5 right-0.5 opacity-0 group-hover:opacity-100 transition-all p-1 rounded-lg"
style={{ background: 'var(--pc-bg-elevated)', border: '1px solid var(--pc-border)', color: 'var(--pc-text-muted)' }}
>
{copiedId === msg.id ? <Check className="h-2.5 w-2.5" style={{ color: '#34d399' }} /> : <Copy className="h-2.5 w-2.5" />}
</button>
)}
</div>
</div>
))}
{/* Streaming */}
{typing && (
<div className="flex items-start gap-2">
<div className="flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center border" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)' }}>
<Bot className="h-3 w-3" style={{ color: 'var(--pc-accent)' }} />
</div>
<div className="rounded-xl px-3 py-2 border max-w-[85%]" style={{ background: 'var(--pc-bg-elevated)', borderColor: 'var(--pc-border)', color: 'var(--pc-text-primary)' }}>
{streamingThinking && (
<details className="mb-1.5" open={!streamingContent}>
<summary className="text-[11px] cursor-pointer select-none font-medium" style={{ color: 'var(--pc-text-muted)' }}>Thought</summary>
<pre className="text-[10px] mt-1 whitespace-pre-wrap break-words overflow-auto max-h-36 p-1.5 rounded-lg" style={{ color: 'var(--pc-text-muted)', background: 'var(--pc-bg-surface)' }}>{streamingThinking}</pre>
</details>
)}
{streamingContent ? (
<p className="text-[13px] whitespace-pre-wrap break-words">{streamingContent}<span className="inline-block w-0.5 h-3.5 ml-0.5 animate-pulse align-middle" style={{ background: 'var(--pc-accent)' }} /></p>
) : (
<div className="flex items-center gap-1">
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
<span className="bounce-dot w-1.5 h-1.5 rounded-full" style={{ background: 'var(--pc-accent)' }} />
</div>
)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<ChatInput value={input} onChange={setInput} onSend={handleSend} connected={connected} />
</div>
);
}

View File

@@ -165,6 +165,7 @@ export interface UserNotification {
notification_id: string;
plan_id: string;
task_id: string;
approval_id?: string | null;
kind: 'user_message' | 'need_input' | 'need_approval'
| 'task_progress' | 'task_failed' | 'task_finished'
| 'confirmation_pending' | 'confirmation_resolved';
@@ -613,8 +614,15 @@ export interface DigitalEmployeeBusinessViewRow extends Record<string, unknown>
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
export interface PlanActivityArtifact {
artifact_id: string;
name: string;
kind?: string | null;
download_url?: string | null;
}
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';
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' | 'plan_activity';
content?: string;
full_response?: string;
name?: string;
@@ -635,6 +643,11 @@ export interface WsMessage {
title?: string;
task_count?: number;
notification?: UserNotification;
phase?: string;
body?: string;
artifact?: PlanActivityArtifact;
artifact_id?: string;
actions?: string[];
response_kind?: string;
route_decision?: RouteDecisionRecord;
command?: unknown;
@@ -1141,6 +1154,8 @@ export interface DigitalEmployeeDuty {
export interface DigitalEmployeeWorkdayState {
confirmed: boolean;
confirmed_at?: string | null;
selection_initialized?: boolean;
selected_duty_ids?: string[];
current_status?: 'working' | 'resting' | string;
current_status_label?: string;
status_label: string;
@@ -1260,6 +1275,12 @@ export interface DigitalEmployeeDecisionAction {
export interface DigitalEmployeeDecision {
id: string;
approval_id?: string | null;
plan_id?: string | null;
task_id?: string | null;
run_id?: string | null;
target_kind?: string | null;
target_id?: string | null;
title: string;
scenario: string;
status: string;
@@ -1291,6 +1312,14 @@ export interface DigitalEmployeeDecision {
actions: DigitalEmployeeDecisionAction[];
}
export interface ApprovalDecisionView extends DigitalEmployeeDecision {}
export interface ApprovalDecisionsResponse {
status: 'open' | 'all' | string;
has_approval_state: boolean;
approvals: ApprovalDecisionView[];
}
export interface DigitalEmployeeDeliveryArtifact {
name: string;
status: string;
@@ -1452,7 +1481,7 @@ export interface DigitalEmployeeWorkdayProjection {
}
export interface DigitalEmployeeWorkdayActionRequest {
action: 'confirm_workday' | 'decide_approval' | 'update_profile' | 'update_report_schedule' | 'generate_daily_report';
action: 'confirm_workday' | 'decide_approval' | 'update_profile' | 'update_task_selection' | 'update_report_schedule' | 'generate_daily_report';
date?: string;
duty_ids?: string[];
decision_id?: string;