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.
This commit is contained in:
Lv, Qi 2025-11-28 00:21:38 +08:00
parent b8eab4dfd5
commit fbfb820853
5 changed files with 302 additions and 71 deletions

View File

@ -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<String>`) 字段。
- **行动**: (可选) 如果我们需要实时更新也携带名称,可以更新 `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),但现有功能不受影响。

View File

@ -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 (
<div className={cn(
"px-4 py-2 rounded-lg border shadow-sm min-w-[180px] transition-all duration-300 cursor-pointer bg-background",
@ -53,7 +56,7 @@ const WorkflowNode = ({ data, selected }: { data: { label: string, status: TaskS
<StatusIcon status={data.status} />
<div className="text-xs font-mono uppercase text-muted-foreground">{data.type}</div>
</div>
<div className="font-medium text-sm truncate" title={data.label}>{data.label}</div>
<div className="font-medium text-sm truncate capitalize" title={displayLabel}>{displayLabel}</div>
<Handle type="source" position={Position.Bottom} className="!bg-muted-foreground/50 !w-2 !h-2" />
</div>
);
@ -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<string, number>();
const nodeMap = new Map(dagNodes.map(n => [n.id, n]));
const incomingEdges = new Map<string, string[]>();
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<string>()): 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));
// Group nodes by Rank
const nodesByRank: Record<number, any[]> = {};
dagNodes.forEach(n => {
const r = ranks.get(n.id) || 0;
if (!nodesByRank[r]) nodesByRank[r] = [];
nodesByRank[r].push(n);
});
return { nodes: layoutedNodes, edges };
} catch (e) {
console.error('ELK Layout Error:', e);
return { nodes, edges };
// 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<number>(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
]}
>
<Background color="#94a3b8" gap={20} size={1} />
<Controls position="top-right" />
{/* Controls removed to force simpler view */}
</ReactFlow>
</div>
);

View File

@ -37,3 +37,7 @@
@apply bg-background text-foreground;
}
}
.writing-vertical-lr {
writing-mode: vertical-lr;
}

View File

@ -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, ' ');
}

View File

@ -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() {
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-12 gap-4 flex-1 min-h-0">
{/* Left Col: Visualizer (4 cols) */}
<div className="col-span-4 flex flex-col gap-4 min-h-0 h-full">
<Card className="flex-1 flex flex-col min-h-0 py-0 gap-0">
<CardHeader className="py-3 px-4 shrink-0">
<CardTitle className="text-sm font-medium">Workflow Status</CardTitle>
<div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
{/* Left Col: Visualizer */}
<div className={cn(
"flex flex-col gap-4 min-h-0 h-full transition-all duration-300 ease-in-out",
isSidebarCollapsed ? "w-[60px]" : "w-[33%] min-w-[350px]"
)}>
<Card className="flex-1 flex flex-col min-h-0 py-0 gap-0 overflow-hidden">
<CardHeader className={cn("py-3 px-4 shrink-0 flex flex-row items-center space-y-0 transition-all duration-300", isSidebarCollapsed ? "h-full flex-col justify-start py-4 gap-4" : "h-[60px] justify-between")}>
{!isSidebarCollapsed ? (
<CardTitle className="text-sm font-medium truncate">Workflow Status</CardTitle>
) : (
<div className="writing-vertical-lr transform rotate-180 text-sm font-medium whitespace-nowrap tracking-wide text-muted-foreground">
Workflow Status
</div>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}>
{isSidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</Button>
</CardHeader>
<CardContent className="p-0 flex-1 min-h-0">
<CardContent className={cn("p-0 flex-1 min-h-0 relative transition-opacity duration-200", isSidebarCollapsed ? "opacity-0 pointer-events-none" : "opacity-100")}>
<div className="absolute inset-0">
<WorkflowVisualizer />
</div>
</CardContent>
</Card>
</div>
{/* Right Col: Detail Tabs (8 cols) */}
<div className="col-span-8 h-full min-h-0">
{/* Right Col: Detail Tabs */}
<div className="flex-1 h-full min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<div className="w-full bg-background border-b shrink-0">
<TabsList className="h-auto p-0 bg-transparent gap-0 flex-wrap justify-start w-full">
<TabsTrigger value="overview" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3">
<div className="w-full shrink-0">
<TabsList className="h-auto p-0 bg-transparent gap-1 flex-wrap justify-start w-full border-b">
<TabsTrigger
value="overview"
className="
rounded-t-md rounded-b-none
border border-b-0 border-border/50
bg-muted/60
data-[state=active]:bg-background
data-[state=active]:border-border
data-[state=active]:border-b-background
data-[state=active]:mb-[-1px]
data-[state=active]:shadow-sm
data-[state=active]:z-10
px-4 py-2.5
text-muted-foreground
data-[state=active]:text-foreground
relative
"
>
Overview
</TabsTrigger>
<TabsTrigger value="data" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3">
<TabsTrigger
value="data"
className="
rounded-t-md rounded-b-none
border border-b-0 border-border/50
bg-muted/60
data-[state=active]:bg-background
data-[state=active]:border-border
data-[state=active]:border-b-background
data-[state=active]:mb-[-1px]
data-[state=active]:shadow-sm
data-[state=active]:z-10
px-4 py-2.5
text-muted-foreground
data-[state=active]:text-foreground
relative
"
>
Fundamental Data
</TabsTrigger>
{tabNodes.map(node => (
<TabsTrigger
key={node.id}
value={node.id}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-3 gap-2 min-w-fit"
className="
rounded-t-md rounded-b-none
border border-b-0 border-border/50
bg-muted/60
data-[state=active]:bg-background
data-[state=active]:border-border
data-[state=active]:border-b-background
data-[state=active]:mb-[-1px]
data-[state=active]:shadow-sm
data-[state=active]:z-10
px-4 py-2.5 gap-2
text-muted-foreground
data-[state=active]:text-foreground
relative
"
>
{node.name}
{formatNodeName(node.name)}
<TaskStatusIndicator status={tasks[node.id]?.status || schemas.TaskStatus.enum.Pending} />
</TabsTrigger>
))}
@ -131,7 +195,7 @@ export function ReportPage() {
</div>
{/* Content Area */}
<div className="flex-1 min-h-0 bg-muted/5 relative">
<div className="flex-1 min-h-0 bg-background border border-t-0 rounded-b-md relative shadow-sm">
<TabsContent value="overview" className="absolute inset-0 m-0 p-6 overflow-y-auto">
<OverviewTabContent
status={status}