Major architectural shift from Next.js to a lightweight Vite + React SPA model ("Puppet Architecture") to better support real-time workflow visualization and strict type safety.
Key Changes:
1. **Architecture & Build**:
- Initialized Vite + React + TypeScript project.
- Configured Tailwind CSS v4 and Shadcn UI.
- Archived legacy Next.js frontend to 'frontend/archive/v2_nextjs'.
2. **Core Features**:
- **Dashboard**: Implemented startup page with Symbol, Market, and Template selection.
- **Report Page**:
- **Workflow Visualization**: Integrated ReactFlow to show dynamic DAG of analysis tasks.
- **Real-time Status**: Implemented Mock SSE logic to simulate task progress, logs, and status changes.
- **Multi-Tab Interface**: Dynamic tabs for 'Overview', 'Fundamental Data', and analysis modules.
- **Streaming Markdown**: Enabled typewriter-style streaming rendering for analysis reports using 'react-markdown'.
- **Config Page**: Implemented settings for AI Providers, Data Sources, and Templates using TanStack Query.
3. **Documentation**:
- Created v2.0 User Guide ('docs/1_requirements/20251122_[Active]_user-guide_v2.md').
- Implemented 'DocsPage' in frontend to render the user guide directly within the app.
4. **Backend Alignment**:
- Created 'docs/frontend/backend_todos.md' outlining necessary backend adaptations (OpenAPI, Progress tracking).
This commit establishes the full frontend 'shell' ready for backend integration.
277 lines
9.6 KiB
TypeScript
277 lines
9.6 KiB
TypeScript
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import {
|
|
WorkflowEvent,
|
|
WorkflowDag,
|
|
TaskStatus,
|
|
StartWorkflowRequest,
|
|
StartWorkflowResponse,
|
|
TaskType
|
|
} from '@/types/workflow';
|
|
|
|
export type WorkflowConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error';
|
|
|
|
export interface TaskInfo {
|
|
taskId: string;
|
|
type?: TaskType;
|
|
status: TaskStatus;
|
|
message?: string;
|
|
lastUpdate: number;
|
|
}
|
|
|
|
interface UseWorkflowReturn {
|
|
// State
|
|
status: WorkflowConnectionStatus;
|
|
requestId: string | null;
|
|
dag: WorkflowDag | null;
|
|
taskStates: Record<string, TaskStatus>;
|
|
taskInfos: Record<string, TaskInfo>; // Added for rich metadata
|
|
taskOutputs: Record<string, string>; // Accumulates streaming content
|
|
error: string | null;
|
|
finalResult: any | null;
|
|
|
|
// Actions
|
|
// Returns StartWorkflowResponse to allow caller to handle redirects (e.g. symbol normalization)
|
|
startWorkflow: (params: StartWorkflowRequest) => Promise<StartWorkflowResponse | undefined>;
|
|
connectToWorkflow: (requestId: string) => void;
|
|
disconnect: () => void;
|
|
}
|
|
|
|
export function useWorkflow(): UseWorkflowReturn {
|
|
const [status, setStatus] = useState<WorkflowConnectionStatus>('idle');
|
|
const [requestId, setRequestId] = useState<string | null>(null);
|
|
const [dag, setDag] = useState<WorkflowDag | null>(null);
|
|
const [taskStates, setTaskStates] = useState<Record<string, TaskStatus>>({});
|
|
const [taskInfos, setTaskInfos] = useState<Record<string, TaskInfo>>({});
|
|
const [taskOutputs, setTaskOutputs] = useState<Record<string, string>>({});
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [finalResult, setFinalResult] = useState<any | null>(null);
|
|
|
|
// Ref for EventSource to handle cleanup
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
|
|
// Refs for state that updates frequently to avoid closure staleness in event handlers if needed
|
|
// (Though in this React pattern, simple state updates usually suffice unless high freq)
|
|
|
|
const disconnect = useCallback(() => {
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
eventSourceRef.current = null;
|
|
}
|
|
setStatus('disconnected');
|
|
}, []);
|
|
|
|
const handleEvent = useCallback((eventData: WorkflowEvent) => {
|
|
console.log('[useWorkflow] Handling event type:', eventData.type);
|
|
switch (eventData.type) {
|
|
case 'WorkflowStarted':
|
|
console.log('[useWorkflow] WorkflowStarted. Nodes:', eventData.payload.task_graph.nodes.length);
|
|
setDag(eventData.payload.task_graph);
|
|
// Initialize states based on graph
|
|
const initialStates: Record<string, TaskStatus> = {};
|
|
const initialInfos: Record<string, TaskInfo> = {};
|
|
eventData.payload.task_graph.nodes.forEach(node => {
|
|
initialStates[node.id] = node.initial_status;
|
|
initialInfos[node.id] = {
|
|
taskId: node.id,
|
|
type: node.type,
|
|
status: node.initial_status,
|
|
lastUpdate: eventData.payload.timestamp
|
|
};
|
|
});
|
|
setTaskStates(initialStates);
|
|
setTaskInfos(initialInfos);
|
|
break;
|
|
|
|
case 'TaskStateChanged':
|
|
const { task_id, status, message, timestamp, task_type } = eventData.payload;
|
|
console.log(`[useWorkflow] TaskStateChanged: ${task_id} -> ${status}`);
|
|
setTaskStates(prev => ({
|
|
...prev,
|
|
[task_id]: status
|
|
}));
|
|
setTaskInfos(prev => ({
|
|
...prev,
|
|
[task_id]: {
|
|
taskId: task_id,
|
|
type: task_type,
|
|
status: status,
|
|
message: message || undefined, // normalize null to undefined
|
|
lastUpdate: timestamp
|
|
}
|
|
}));
|
|
break;
|
|
|
|
case 'TaskStreamUpdate':
|
|
// console.log(`[useWorkflow] StreamUpdate for ${eventData.payload.task_id}, len: ${eventData.payload.content_delta.length}`);
|
|
setTaskOutputs(prev => ({
|
|
...prev,
|
|
[eventData.payload.task_id]: (prev[eventData.payload.task_id] || '') + eventData.payload.content_delta
|
|
}));
|
|
break;
|
|
|
|
case 'WorkflowStateSnapshot':
|
|
console.log('[useWorkflow] Snapshot received. Tasks:', Object.keys(eventData.payload.tasks_status).length);
|
|
// Restore full state
|
|
setDag(eventData.payload.task_graph);
|
|
setTaskStates(eventData.payload.tasks_status);
|
|
|
|
// Reconstruct basic infos from snapshot (Snapshot doesn't carry full history messages sadly,
|
|
// but we can at least sync status)
|
|
const syncedInfos: Record<string, TaskInfo> = {};
|
|
Object.entries(eventData.payload.tasks_status).forEach(([tid, stat]) => {
|
|
syncedInfos[tid] = {
|
|
taskId: tid,
|
|
status: stat,
|
|
lastUpdate: eventData.payload.timestamp
|
|
};
|
|
});
|
|
setTaskInfos(prev => ({...prev, ...syncedInfos})); // Merge to keep existing messages if we have them
|
|
|
|
// Restore outputs if present
|
|
const outputs: Record<string, string> = {};
|
|
Object.entries(eventData.payload.tasks_output).forEach(([k, v]) => {
|
|
if (v) outputs[k] = v;
|
|
});
|
|
setTaskOutputs(prev => ({ ...prev, ...outputs }));
|
|
break;
|
|
|
|
case 'WorkflowCompleted':
|
|
console.log('[useWorkflow] Workflow Completed');
|
|
setFinalResult(eventData.payload.result_summary);
|
|
disconnect(); // Close connection on completion
|
|
break;
|
|
|
|
case 'WorkflowFailed':
|
|
console.error('[useWorkflow] Workflow Failed:', eventData.payload.reason);
|
|
setError(eventData.payload.reason);
|
|
// We might want to keep connected or disconnect depending on if retry is possible
|
|
// For now, treat fatal error as disconnect reason
|
|
if (eventData.payload.is_fatal) {
|
|
disconnect();
|
|
setStatus('error');
|
|
}
|
|
break;
|
|
}
|
|
}, [disconnect]);
|
|
|
|
const connectToWorkflow = useCallback((id: string) => {
|
|
console.log('[useWorkflow] connectToWorkflow called for ID:', id);
|
|
if (eventSourceRef.current) {
|
|
console.log('[useWorkflow] Closing existing EventSource');
|
|
eventSourceRef.current.close();
|
|
}
|
|
|
|
setRequestId(id);
|
|
setStatus('connecting');
|
|
setError(null);
|
|
|
|
try {
|
|
const url = `/api/workflow/events/${id}`;
|
|
console.log('[useWorkflow] Creating new EventSource:', url);
|
|
// IMPORTANT: Do NOT use Next.js rewrites for SSE. They buffer.
|
|
// We use a direct API Route Handler (app/api/workflow/events/[requestId]/route.ts)
|
|
// which explicitly disables buffering via headers.
|
|
const es = new EventSource(url);
|
|
eventSourceRef.current = es;
|
|
|
|
es.onopen = (e) => {
|
|
console.log(`[useWorkflow] SSE onopen triggered. URL: ${url}`, e);
|
|
setStatus('connected');
|
|
};
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
console.log('[useWorkflow] Raw SSE Message:', event.data);
|
|
const data = JSON.parse(event.data) as WorkflowEvent;
|
|
console.log('[useWorkflow] Parsed Event:', data.type, data);
|
|
handleEvent(data);
|
|
} catch (e) {
|
|
console.error('[useWorkflow] Failed to parse workflow event:', e, event.data);
|
|
}
|
|
};
|
|
|
|
es.onerror = (e) => {
|
|
console.error('[useWorkflow] Workflow SSE error:', e);
|
|
// EventSource automatically retries, but we might want to handle it explicitly
|
|
// For now, let's assume if readyState is CLOSED, it's a fatal error
|
|
if (es.readyState === EventSource.CLOSED) {
|
|
console.log('[useWorkflow] SSE Closed permanently');
|
|
setStatus('error');
|
|
setError('Connection lost');
|
|
es.close();
|
|
}
|
|
};
|
|
|
|
} catch (e) {
|
|
console.error('Failed to create EventSource:', e);
|
|
setStatus('error');
|
|
setError(e instanceof Error ? e.message : 'Connection initialization failed');
|
|
}
|
|
}, [handleEvent]);
|
|
|
|
const startWorkflow = useCallback(async (params: StartWorkflowRequest) => {
|
|
console.log('[useWorkflow] startWorkflow called with params:', params);
|
|
setStatus('connecting');
|
|
setError(null);
|
|
setDag(null);
|
|
setTaskStates({});
|
|
setTaskInfos({});
|
|
setTaskOutputs({});
|
|
setFinalResult(null);
|
|
|
|
try {
|
|
console.log('[useWorkflow] Sending POST /api/workflow/start...');
|
|
const res = await fetch('/api/workflow/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(params),
|
|
});
|
|
console.log('[useWorkflow] POST response status:', res.status);
|
|
|
|
if (!res.ok) {
|
|
const errorBody = await res.json().catch(() => ({}));
|
|
console.error('[useWorkflow] Start failed:', errorBody);
|
|
throw new Error(errorBody.error || `HTTP ${res.status}`);
|
|
}
|
|
|
|
const data: StartWorkflowResponse = await res.json();
|
|
console.log('[useWorkflow] Workflow started successfully. Response:', data);
|
|
|
|
// Start listening
|
|
console.log('[useWorkflow] Initiating SSE connection for requestId:', data.request_id);
|
|
connectToWorkflow(data.request_id);
|
|
|
|
return data; // Return response so UI can handle symbol normalization redirection
|
|
|
|
} catch (e) {
|
|
console.error('[useWorkflow] Exception in startWorkflow:', e);
|
|
setStatus('error');
|
|
setError(e instanceof Error ? e.message : 'Failed to start workflow');
|
|
return undefined;
|
|
}
|
|
}, [connectToWorkflow]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (eventSourceRef.current) {
|
|
eventSourceRef.current.close();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
status,
|
|
requestId,
|
|
dag,
|
|
taskStates,
|
|
taskInfos,
|
|
taskOutputs,
|
|
error,
|
|
finalResult,
|
|
startWorkflow,
|
|
connectToWorkflow,
|
|
disconnect
|
|
};
|
|
}
|