吸收数字员工工作台入口重构收口
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 543 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 } = {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()}日`;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user