From fbfb820853bb85e9ab218c15ee4e0cc7cfca4d78 Mon Sep 17 00:00:00 2001 From: "Lv, Qi" Date: Fri, 28 Nov 2025 00:21:38 +0800 Subject: [PATCH] feat(frontend): enhance workflow visualizer layout, report page UI and add pending task doc - Refactor WorkflowVisualizer to use rank-based grid layout for better readability of parallel tasks. - Improve edge rendering in visualizer with highlighting logic and curved lines. - Update ReportPage tabs styling to be more distinct and card-like. - Implement collapsible sidebar for Workflow Status visualizer with vertical text support. - Add formatNodeName utility for cleaner task name display. - Fix CSS issues in index.css. - Add documentation for pending task: adding display_name to workflow DTOs. --- .../20251127_add_task_display_names.md | 39 ++++ .../workflow/WorkflowVisualizer.tsx | 215 +++++++++++++----- frontend/src/index.css | 4 + frontend/src/lib/utils.ts | 13 ++ frontend/src/pages/ReportPage.tsx | 102 +++++++-- 5 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 docs/tasks/pending/20251127_add_task_display_names.md diff --git a/docs/tasks/pending/20251127_add_task_display_names.md b/docs/tasks/pending/20251127_add_task_display_names.md new file mode 100644 index 0000000..3e526ae --- /dev/null +++ b/docs/tasks/pending/20251127_add_task_display_names.md @@ -0,0 +1,39 @@ +# [Pending] 为工作流任务添加人类可读名称 (Display Name) + +## 背景 +目前,前端在显示任务名称时使用的是任务 ID(例如 `analysis:news_analysis`,或者是经过简单格式化后的 `news analysis`)。然而,真正的人类可读名称(例如 “新闻分析”)是定义在 `AnalysisTemplate` 配置中的,但这些名称并没有通过工作流事件传播到 `WorkflowOrchestrator` 或前端。 + +## 目标 +确保前端可以在工作流可视化图表(Visualizer)和标签页(Tab Headers)中显示模板中定义的本地化/人类可读的任务名称。 + +## 需要的变更 + +### 1. Common Contracts (`services/common-contracts`) +- **文件**: `src/workflow_types.rs` 或 `src/messages.rs` +- **行动**: 更新 `TaskNode` 结构体(用于 `WorkflowStateSnapshot`),增加一个 `display_name` (`Option`) 字段。 +- **行动**: (可选) 如果我们需要实时更新也携带名称,可以更新 `WorkflowTaskEvent`,虽然对于静态拓扑来说,快照(Snapshot)通常就足够了。 + +### 2. Workflow Orchestrator Service (`services/workflow-orchestrator-service`) +- **文件**: `src/dag_scheduler.rs` +- **行动**: 在通过 `add_node` 添加节点时,接受一个 `display_name` 参数。 +- **文件**: `src/workflow.rs` +- **行动**: 在 `build_dag` 函数中,遍历 `template.modules` 时: + - 提取 `module_config.name`(例如 “新闻分析”)。 + - 在创建 DAG 节点时传递这个名称。 + +### 3. Frontend (`frontend`) +- **文件**: `src/types/workflow.ts` +- **行动**: 更新 `TaskNode` 接口以匹配新的后端 DTO。 +- **文件**: `src/components/workflow/WorkflowVisualizer.tsx` & `src/pages/ReportPage.tsx` +- **行动**: 如果 `node.display_name` 存在,则优先使用它;否则回退到使用 `formatNodeName(node.id)`。 + +## 替代方案 / 临时方案 (纯前端) +由于前端已经(通过 `useAnalysisTemplates` hook)获取了 `AnalysisTemplate`,我们可以: +1. 从 URL 参数中获取当前的 `templateId`。 +2. 查找对应的模板定义。 +3. 创建一个映射表:`module_id -> module_name`。 +4. 在 `ReportPage` 和 `WorkflowVisualizer` 中使用此映射表来动态解析名称。 + +## 优先级 +中等 - 能够显著改善用户体验 (UX),但现有功能不受影响。 + diff --git a/frontend/src/components/workflow/WorkflowVisualizer.tsx b/frontend/src/components/workflow/WorkflowVisualizer.tsx index c061b7f..97a486b 100644 --- a/frontend/src/components/workflow/WorkflowVisualizer.tsx +++ b/frontend/src/components/workflow/WorkflowVisualizer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useState } from 'react'; import ReactFlow, { Background, Controls, @@ -42,6 +42,9 @@ const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskS [schemas.TaskStatus.enum.Skipped]: 'border-gray-200 bg-gray-50/5 opacity-60', }; + // Remove 'analysis:' or 'fetch:' prefix for cleaner display + const displayLabel = data.label.replace(/^(analysis:|fetch:)/, '').replace(/_/g, ' '); + return (
{data.type}
-
{data.label}
+
{displayLabel}
); @@ -63,55 +66,113 @@ const nodeTypes = { taskNode: WorkflowNode, }; -// --- Layout Helper with ELK --- -const elk = new ELK(); +// --- Custom Grid Layout (Manual) --- +const useGridLayout = () => { + const getLayoutedElements = useCallback(async (nodes: Node[], edges: Edge[], dagNodes: any[], dagEdges: any[]) => { + // Constants + const NODE_WIDTH = 240; // Increased width for long names + const NODE_HEIGHT = 64; + const X_SPACING = 40; + const Y_SPACING = 80; + const MAX_COLS = 3; // Max columns per row -const useLayout = () => { - const getLayoutedElements = useCallback(async (nodes: Node[], edges: Edge[]) => { - const graph = { - id: 'root', - layoutOptions: { - 'elk.algorithm': 'layered', - 'elk.direction': 'DOWN', - 'elk.spacing.nodeNode': '60', // Horizontal spacing - 'elk.layered.spacing.nodeNodeBetweenLayers': '80', // Vertical spacing - 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', - // Optimize for fewer edge bends/crossings - 'elk.edgeRouting': 'SPLINES', - }, - children: nodes.map((node) => ({ - id: node.id, - width: 180, // Match min-w-[180px] - height: 64, // Approx height - })), - edges: edges.map((edge) => ({ - id: edge.id, - sources: [edge.source], - targets: [edge.target], - })), + // 1. Topological Sort (Rank-Based Grid) + // Calculate Ranks + const ranks = new Map(); + const nodeMap = new Map(dagNodes.map(n => [n.id, n])); + const incomingEdges = new Map(); + + dagNodes.forEach(n => incomingEdges.set(n.id, [])); + dagEdges.forEach(e => incomingEdges.get(e.to)?.push(e.from)); + + // Rank 0: Roots (No incoming edges) + const roots = dagNodes.filter(n => (incomingEdges.get(n.id)?.length || 0) === 0); + roots.forEach(n => ranks.set(n.id, 0)); + + // Calculate Ranks for others (Longest Path) + // Simple approach: For small DAGs, we can iterate. + // Correct approach: Kahn's algorithm or DFS with memoization + + const getRank = (nodeId: string, visited = new Set()): number => { + if (ranks.has(nodeId)) return ranks.get(nodeId)!; + if (visited.has(nodeId)) return 0; // Cycle detected, fallback + + visited.add(nodeId); + const parents = incomingEdges.get(nodeId) || []; + if (parents.length === 0) return 0; + + let maxParentRank = 0; + for (const pId of parents) { + maxParentRank = Math.max(maxParentRank, getRank(pId, new Set(visited))); + } + + const rank = maxParentRank + 1; + ranks.set(nodeId, rank); + return rank; }; - try { - const layoutedGraph = await elk.layout(graph); + dagNodes.forEach(n => getRank(n.id)); - const layoutedNodes = nodes.map((node) => { - const nodeElk = layoutedGraph.children?.find((n) => n.id === node.id); - return { - ...node, - targetPosition: Position.Top, - sourcePosition: Position.Bottom, - position: { - x: nodeElk?.x || 0, - y: nodeElk?.y || 0, - }, - }; - }); + // Special Handling: + // 'company_profile' relies on DataFetch (Rank 0), so it gets Rank 1. + // 'final_conclusion' relies on many nodes. + // If 'final_conclusion' is the true sink, ensure it's at the bottom. + // Let's identify the max rank. + let maxRank = 0; + ranks.forEach(r => maxRank = Math.max(maxRank, r)); - return { nodes: layoutedNodes, edges }; - } catch (e) { - console.error('ELK Layout Error:', e); - return { nodes, edges }; + // Group nodes by Rank + const nodesByRank: Record = {}; + dagNodes.forEach(n => { + const r = ranks.get(n.id) || 0; + if (!nodesByRank[r]) nodesByRank[r] = []; + nodesByRank[r].push(n); + }); + + // Sort nodes within ranks for consistency + Object.values(nodesByRank).forEach(list => list.sort((a, b) => a.id.localeCompare(b.id))); + + // 2. Assign Grid Positions + const layoutedNodes = [...nodes]; + let currentY = 0; + + // Iterate through ranks 0 to maxRank + for (let r = 0; r <= maxRank; r++) { + const rankNodes = nodesByRank[r] || []; + if (rankNodes.length === 0) continue; + + // Grid layout for this rank + // If it's Rank 0 (Root) or Max Rank (Sink), maybe center single row? + // Actually, just applying grid logic uniformly works well. + + for (let i = 0; i < rankNodes.length; i += MAX_COLS) { + const chunk = rankNodes.slice(i, i + MAX_COLS); + const rowWidthTotal = chunk.length * NODE_WIDTH + (chunk.length - 1) * X_SPACING; + const startX = -(rowWidthTotal / 2) + (NODE_WIDTH / 2); + + chunk.forEach((node, idx) => { + const n = layoutedNodes.find(ln => ln.id === node.id); + if (n) { + n.position = { + x: startX + idx * (NODE_WIDTH + X_SPACING), + y: currentY + }; + } + }); + + currentY += (NODE_HEIGHT + Y_SPACING); + } } + + // 3. Simplified Edges + // Use default bezier curve for smoother look + const layoutedEdges = edges.map(e => ({ + ...e, + type: 'default', + })); + + return { nodes: layoutedNodes, edges: layoutedEdges, height: currentY }; + }, []); return { getLayoutedElements }; @@ -123,7 +184,8 @@ export function WorkflowVisualizer() { const { dag, tasks, setActiveTab, activeTab } = useWorkflowStore(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const { getLayoutedElements } = useLayout(); + const [graphHeight, setGraphHeight] = useState(0); + const { getLayoutedElements } = useGridLayout(); // Use custom layout const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { if (node.data.type === schemas.TaskType.enum.DataFetch) { @@ -147,6 +209,7 @@ export function WorkflowVisualizer() { id: node.id, type: 'taskNode', position: { x: 0, y: 0 }, + draggable: false, // Disable node dragging data: { label: node.name, status, @@ -156,24 +219,61 @@ export function WorkflowVisualizer() { }; }); - // 2. Create Edges (Use Default Bezier for better visuals than Smoothstep with hierarchical) + // 2. Create Edges const initialEdges: Edge[] = dag.edges.map(edge => { + // Determine if this edge should be highlighted + let isHighlighted = false; + let isDimmed = false; + + if (activeTab === 'overview') { + // In overview, show all edges but subtly + isHighlighted = false; + isDimmed = false; + } else if (activeTab === 'data') { + // If 'data' tab is active, highlight edges connected to DataFetch nodes + const fromNode = dag.nodes.find(n => n.id === edge.from); + const toNode = dag.nodes.find(n => n.id === edge.to); + if (fromNode?.type === schemas.TaskType.enum.DataFetch || toNode?.type === schemas.TaskType.enum.DataFetch) { + isHighlighted = true; + } else { + isDimmed = true; + } + } else { + // Specific node selected + if (edge.from === activeTab || edge.to === activeTab) { + isHighlighted = true; + } else { + isDimmed = true; + } + } + + const style = isHighlighted + ? { stroke: '#3b82f6', strokeWidth: 2, opacity: 1 } // Highlight: Blue, Thick, Opaque + : isDimmed + ? { stroke: '#e2e8f0', strokeWidth: 1, opacity: 0.1 } // Dimmed: Faint + : { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.4 }; // Normal (Overview): Medium gray + return { id: `${edge.from}-${edge.to}`, source: edge.from, target: edge.to, - type: 'default', // Bezier curve - markerEnd: { type: MarkerType.ArrowClosed }, + markerEnd: { + type: MarkerType.ArrowClosed, + color: style.stroke + }, animated: tasks[edge.from]?.status === schemas.TaskStatus.enum.Running || tasks[edge.to]?.status === schemas.TaskStatus.enum.Running, - style: { stroke: '#64748b', strokeWidth: 1.5 } + zIndex: isHighlighted ? 10 : 0, + style }; }); // 3. Apply Layout - const { nodes: layoutedNodes, edges: layoutedEdges } = await getLayoutedElements(initialNodes, initialEdges); + // @ts-ignore - getLayoutedElements returns extra height prop + const { nodes: layoutedNodes, edges: layoutedEdges, height } = await getLayoutedElements(initialNodes, initialEdges, dag.nodes, dag.edges); setNodes(layoutedNodes); setEdges(layoutedEdges); + if (height) setGraphHeight(height); }; createGraph(); @@ -192,9 +292,20 @@ export function WorkflowVisualizer() { nodeTypes={nodeTypes} fitView attributionPosition="bottom-right" + panOnDrag={false} // Lock panning (X/Y) - user scroll does vertical + zoomOnScroll={false} // Lock zoom + zoomOnPinch={false} + zoomOnDoubleClick={false} + panOnScroll={true} // Allow vertical scroll to pan Y + preventScrolling={false} // Allow browser scroll if needed, but usually false for canvas + nodesConnectable={false} // Disable connecting nodes + translateExtent={[ + [-600, -100], + [600, Math.max(graphHeight + 100, 500)] // Ensure at least some height + ]} > - + {/* Controls removed to force simpler view */} ); diff --git a/frontend/src/index.css b/frontend/src/index.css index 8a3d106..5644b7c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -37,3 +37,7 @@ @apply bg-background text-foreground; } } + +.writing-vertical-lr { + writing-mode: vertical-lr; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index e8ed525..fe95c38 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -5,3 +5,16 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } +export function formatNodeName(name: string): string { + // If the name looks like an ID (contains underscores or colons), try to format it nicely + // Otherwise, assume it's already a human-readable name (like "新闻分析") and return as is. + + // Basic heuristic: If it has Chinese characters, it's probably already a name. + if (/[\u4e00-\u9fa5]/.test(name)) { + return name; + } + + // Fallback for IDs: remove prefix, replace underscores + return name.replace(/^(analysis:|fetch:)/, '').replace(/_/g, ' '); +} + diff --git a/frontend/src/pages/ReportPage.tsx b/frontend/src/pages/ReportPage.tsx index 70279b2..afe5a41 100644 --- a/frontend/src/pages/ReportPage.tsx +++ b/frontend/src/pages/ReportPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" @@ -6,7 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { WorkflowVisualizer } from '@/components/workflow/WorkflowVisualizer'; import { useWorkflowStore } from '@/stores/useWorkflowStore'; import { TaskStatus, schemas } from '@/api/schema.gen'; -import { Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react'; +import { Loader2, CheckCircle2, AlertCircle, Clock, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { Button } from '@/components/ui/button'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -16,6 +16,7 @@ import { useAutoScroll } from '@/hooks/useAutoScroll'; import { RealtimeLogs } from '@/components/RealtimeLogs'; import { WorkflowStatus, ConnectionStatus, TaskState } from '@/types/workflow'; import { Progress } from "@/components/ui/progress" +import { cn, formatNodeName } from '@/lib/utils'; export function ReportPage() { const { id } = useParams(); @@ -23,6 +24,7 @@ export function ReportPage() { const symbol = searchParams.get('symbol'); const market = searchParams.get('market'); const templateId = searchParams.get('templateId'); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const { initialize, @@ -93,37 +95,99 @@ export function ReportPage() { {/* Main Content Grid */} -
- {/* Left Col: Visualizer (4 cols) */} -
- - - Workflow Status +
+ {/* Left Col: Visualizer */} +
+ + + {!isSidebarCollapsed ? ( + Workflow Status + ) : ( +
+ Workflow Status +
+ )} +
- - + +
+ +
- {/* Right Col: Detail Tabs (8 cols) */} -
+ {/* Right Col: Detail Tabs */} +
-
- - +
+ + Overview - + Fundamental Data {tabNodes.map(node => ( - {node.name} + {formatNodeName(node.name)} ))} @@ -131,7 +195,7 @@ export function ReportPage() {
{/* Content Area */} -
+