将 desktop/src 中 23 处 console.log 替换为 createLogger() 结构化日志: - 生产构建自动静默 debug/info 级别 - 保留 console.error 用于关键错误可见性 - 新增 dompurify 依赖修复 XSS 防护引入缺失 涉及文件: App.tsx, offlineStore.ts, autonomy-manager.ts, gateway-auth.ts, llm-service.ts, request-helper.ts, security-index.ts, skill-discovery.ts, use-onboarding.ts 等 16 个文件
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
/**
|
|
* useAutomationEvents - WebSocket Event Hook for Automation System
|
|
*
|
|
* Subscribes to hand and workflow events from ZCLAW WebSocket
|
|
* and updates the corresponding stores.
|
|
*
|
|
* @module hooks/useAutomationEvents
|
|
*/
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { useHandStore } from '../store/handStore';
|
|
import { useWorkflowStore } from '../store/workflowStore';
|
|
import { useChatStore } from '../store/chatStore';
|
|
import type { GatewayClient } from '../lib/gateway-client';
|
|
import { speechSynth } from '../lib/speech-synth';
|
|
import { createLogger } from '../lib/logger';
|
|
|
|
const log = createLogger('useAutomationEvents');
|
|
|
|
// === Event Types ===
|
|
|
|
interface HandEventData {
|
|
hand_name: string;
|
|
hand_status: 'triggered' | 'running' | 'completed' | 'failed' | 'needs_approval';
|
|
hand_result?: unknown;
|
|
hand_error?: string;
|
|
run_id?: string;
|
|
timestamp?: number;
|
|
}
|
|
|
|
interface WorkflowEventData {
|
|
workflow_id: string;
|
|
workflow_status: 'started' | 'step_completed' | 'completed' | 'failed' | 'paused';
|
|
current_step?: number;
|
|
total_steps?: number;
|
|
step_name?: string;
|
|
result?: unknown;
|
|
error?: string;
|
|
run_id?: string;
|
|
timestamp?: number;
|
|
}
|
|
|
|
interface ApprovalEventData {
|
|
approval_id: string;
|
|
hand_name?: string;
|
|
workflow_id?: string;
|
|
run_id?: string;
|
|
status: 'requested' | 'approved' | 'rejected' | 'expired';
|
|
reason?: string;
|
|
requested_by?: string;
|
|
timestamp?: number;
|
|
}
|
|
|
|
// === Hook Options ===
|
|
|
|
export interface UseAutomationEventsOptions {
|
|
/** Whether to inject hand results into chat as messages */
|
|
injectResultsToChat?: boolean;
|
|
/** Whether to auto-refresh hands on status change */
|
|
refreshOnStatusChange?: boolean;
|
|
/** Custom event handlers */
|
|
onHandEvent?: (data: HandEventData) => void;
|
|
onWorkflowEvent?: (data: WorkflowEventData) => void;
|
|
onApprovalEvent?: (data: ApprovalEventData) => void;
|
|
}
|
|
|
|
// === Helper Functions ===
|
|
|
|
function isHandEvent(data: unknown): data is HandEventData {
|
|
return typeof data === 'object' && data !== null && 'hand_name' in data && 'hand_status' in data;
|
|
}
|
|
|
|
function isWorkflowEvent(data: unknown): data is WorkflowEventData {
|
|
return typeof data === 'object' && data !== null && 'workflow_id' in data && 'workflow_status' in data;
|
|
}
|
|
|
|
function isApprovalEvent(data: unknown): data is ApprovalEventData {
|
|
return typeof data === 'object' && data !== null && 'approval_id' in data && 'status' in data;
|
|
}
|
|
|
|
// === Main Hook ===
|
|
|
|
/**
|
|
* Hook for subscribing to automation-related WebSocket events.
|
|
*
|
|
* @param client - The GatewayClient instance (optional, will try to get from store if not provided)
|
|
* @param options - Configuration options
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* function AutomationPanel() {
|
|
* const client = useConnectionStore(s => s.client);
|
|
* useAutomationEvents(client, {
|
|
* injectResultsToChat: true,
|
|
* refreshOnStatusChange: true,
|
|
* });
|
|
* // ...
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useAutomationEvents(
|
|
client: GatewayClient | null,
|
|
options: UseAutomationEventsOptions = {}
|
|
): void {
|
|
const {
|
|
injectResultsToChat = true,
|
|
refreshOnStatusChange = true,
|
|
onHandEvent,
|
|
onWorkflowEvent,
|
|
onApprovalEvent,
|
|
} = options;
|
|
|
|
// Store references
|
|
const loadHands = useHandStore(s => s.loadHands);
|
|
const loadHandRuns = useHandStore(s => s.loadHandRuns);
|
|
const loadApprovals = useHandStore(s => s.loadApprovals);
|
|
const loadWorkflows = useWorkflowStore(s => s.loadWorkflows);
|
|
const loadWorkflowRuns = useWorkflowStore(s => s.loadWorkflowRuns);
|
|
const addMessage = useChatStore(s => s.addMessage);
|
|
|
|
// Track subscriptions for cleanup
|
|
const unsubscribersRef = useRef<Array<() => void>>([]);
|
|
|
|
useEffect(() => {
|
|
if (!client) {
|
|
return;
|
|
}
|
|
|
|
// Clean up any existing subscriptions
|
|
unsubscribersRef.current.forEach(unsub => unsub());
|
|
unsubscribersRef.current = [];
|
|
|
|
// === Hand Event Handler ===
|
|
const handleHandEvent = (data: unknown) => {
|
|
if (!isHandEvent(data)) return;
|
|
|
|
const eventData = data as HandEventData;
|
|
log.debug('Hand event:', eventData);
|
|
|
|
// Refresh hands if status changed
|
|
if (refreshOnStatusChange) {
|
|
loadHands();
|
|
}
|
|
|
|
// Load updated runs for this hand
|
|
if (eventData.run_id) {
|
|
loadHandRuns(eventData.hand_name);
|
|
}
|
|
|
|
// Inject result into chat
|
|
if (injectResultsToChat && eventData.hand_status === 'completed') {
|
|
const resultContent = eventData.hand_result
|
|
? typeof eventData.hand_result === 'string'
|
|
? eventData.hand_result
|
|
: JSON.stringify(eventData.hand_result, null, 2)
|
|
: 'Hand completed successfully';
|
|
|
|
addMessage({
|
|
id: `hand-${eventData.run_id || Date.now()}`,
|
|
role: 'hand',
|
|
content: `**${eventData.hand_name}** 执行完成\n\n${resultContent}`,
|
|
timestamp: new Date(),
|
|
handName: eventData.hand_name,
|
|
handStatus: eventData.hand_status,
|
|
handResult: eventData.hand_result,
|
|
runId: eventData.run_id,
|
|
});
|
|
|
|
// Trigger browser TTS for SpeechHand results
|
|
if (eventData.hand_name === 'speech' && eventData.hand_result && typeof eventData.hand_result === 'object') {
|
|
const res = eventData.hand_result as Record<string, unknown>;
|
|
if (res.tts_method === 'browser' && typeof res.text === 'string' && res.text) {
|
|
speechSynth.speak({
|
|
text: res.text,
|
|
voice: typeof res.voice === 'string' ? res.voice : undefined,
|
|
language: typeof res.language === 'string' ? res.language : undefined,
|
|
rate: typeof res.rate === 'number' ? res.rate : undefined,
|
|
pitch: typeof res.pitch === 'number' ? res.pitch : undefined,
|
|
volume: typeof res.volume === 'number' ? res.volume : undefined,
|
|
}).catch((err: unknown) => {
|
|
log.warn('Browser TTS failed:', err);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle error status
|
|
if (eventData.hand_status === 'failed' && eventData.hand_error) {
|
|
addMessage({
|
|
id: `hand-error-${eventData.run_id || Date.now()}`,
|
|
role: 'hand',
|
|
content: `**${eventData.hand_name}** 执行失败\n\n错误: ${eventData.hand_error}`,
|
|
timestamp: new Date(),
|
|
handName: eventData.hand_name,
|
|
handStatus: eventData.hand_status,
|
|
error: eventData.hand_error,
|
|
runId: eventData.run_id,
|
|
});
|
|
}
|
|
|
|
// Handle approval needed
|
|
if (eventData.hand_status === 'needs_approval') {
|
|
loadApprovals('pending');
|
|
}
|
|
|
|
// Call custom handler
|
|
onHandEvent?.(eventData);
|
|
};
|
|
|
|
// === Workflow Event Handler ===
|
|
const handleWorkflowEvent = (data: unknown) => {
|
|
if (!isWorkflowEvent(data)) return;
|
|
|
|
const eventData = data as WorkflowEventData;
|
|
log.debug('Workflow event:', eventData);
|
|
|
|
// Refresh workflows if status changed
|
|
if (refreshOnStatusChange) {
|
|
loadWorkflows();
|
|
}
|
|
|
|
// Load updated runs for this workflow
|
|
if (eventData.run_id) {
|
|
loadWorkflowRuns(eventData.workflow_id);
|
|
}
|
|
|
|
// Inject result into chat
|
|
if (injectResultsToChat && eventData.workflow_status === 'completed') {
|
|
const resultContent = eventData.result
|
|
? typeof eventData.result === 'string'
|
|
? eventData.result
|
|
: JSON.stringify(eventData.result, null, 2)
|
|
: 'Workflow completed successfully';
|
|
|
|
addMessage({
|
|
id: `workflow-${eventData.run_id || Date.now()}`,
|
|
role: 'workflow',
|
|
content: `**工作流: ${eventData.workflow_id}** 执行完成\n\n${resultContent}`,
|
|
timestamp: new Date(),
|
|
workflowId: eventData.workflow_id,
|
|
workflowStatus: eventData.workflow_status,
|
|
workflowResult: eventData.result,
|
|
runId: eventData.run_id,
|
|
});
|
|
}
|
|
|
|
// Call custom handler
|
|
onWorkflowEvent?.(eventData);
|
|
};
|
|
|
|
// === Approval Event Handler ===
|
|
const handleApprovalEvent = (data: unknown) => {
|
|
if (!isApprovalEvent(data)) return;
|
|
|
|
const eventData = data as ApprovalEventData;
|
|
log.debug('Approval event:', eventData);
|
|
|
|
// Refresh approvals list
|
|
loadApprovals();
|
|
|
|
// Call custom handler
|
|
onApprovalEvent?.(eventData);
|
|
};
|
|
|
|
// Subscribe to events
|
|
const unsubHand = client.on('hand', handleHandEvent);
|
|
const unsubWorkflow = client.on('workflow', handleWorkflowEvent);
|
|
const unsubApproval = client.on('approval', handleApprovalEvent);
|
|
|
|
unsubscribersRef.current = [unsubHand, unsubWorkflow, unsubApproval];
|
|
|
|
// Cleanup on unmount or client change
|
|
return () => {
|
|
unsubscribersRef.current.forEach(unsub => unsub());
|
|
unsubscribersRef.current = [];
|
|
};
|
|
}, [
|
|
client,
|
|
injectResultsToChat,
|
|
refreshOnStatusChange,
|
|
loadHands,
|
|
loadHandRuns,
|
|
loadApprovals,
|
|
loadWorkflows,
|
|
loadWorkflowRuns,
|
|
addMessage,
|
|
onHandEvent,
|
|
onWorkflowEvent,
|
|
onApprovalEvent,
|
|
]);
|
|
}
|
|
|
|
// === Utility Hooks ===
|
|
|
|
/**
|
|
* Hook for subscribing to a specific hand's events only
|
|
*/
|
|
export function useHandEvents(
|
|
client: GatewayClient | null,
|
|
handName: string,
|
|
onEvent?: (data: HandEventData) => void
|
|
): void {
|
|
useEffect(() => {
|
|
if (!client || !handName) return;
|
|
|
|
const handler = (data: unknown) => {
|
|
if (isHandEvent(data) && (data as HandEventData).hand_name === handName) {
|
|
onEvent?.(data as HandEventData);
|
|
}
|
|
};
|
|
|
|
const unsub = client.on('hand', handler);
|
|
return unsub;
|
|
}, [client, handName, onEvent]);
|
|
}
|
|
|
|
/**
|
|
* Hook for subscribing to a specific workflow's events only
|
|
*/
|
|
export function useWorkflowEvents(
|
|
client: GatewayClient | null,
|
|
workflowId: string,
|
|
onEvent?: (data: WorkflowEventData) => void
|
|
): void {
|
|
useEffect(() => {
|
|
if (!client || !workflowId) return;
|
|
|
|
const handler = (data: unknown) => {
|
|
if (isWorkflowEvent(data) && (data as WorkflowEventData).workflow_id === workflowId) {
|
|
onEvent?.(data as WorkflowEventData);
|
|
}
|
|
};
|
|
|
|
const unsub = client.on('workflow', handler);
|
|
return unsub;
|
|
}, [client, workflowId, onEvent]);
|
|
}
|
|
|
|
export default useAutomationEvents;
|