feat: compose native research workflows in QuantFlow

This commit is contained in:
yuxuanhui
2026-09-08 22:42:29 +08:00
parent 7860434b04
commit a6e36e50ec
22 changed files with 2274 additions and 85 deletions
+246
View File
@@ -0,0 +1,246 @@
import { useRef, useState } from "react";
import { Button } from "@douyinfe/semi-ui-19";
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
import { flowStatus } from "./flowTypes";
export type NodeType = {
type: string;
label: string;
accepts: string[];
produces: string;
};
export function FlowCanvas({
graph,
types,
selected,
onSelect,
onChange,
run,
}: {
graph: Workflow;
types: NodeType[];
selected: string | null;
onSelect: (id: string) => void;
onChange?: (graph: Workflow) => void;
run?: FlowRun | null;
}) {
const [zoom, setZoom] = useState(0.8);
const viewport = useRef<HTMLDivElement>(null);
const portNames: Record<string, string> = {
context: "输入范围",
template: "模板",
candidates: "候选表达式",
results: "回测结果",
evaluation: "评估报告",
summary: "研究汇总",
iteration: "迭代产物",
};
const drag = useRef<{
id: string;
x: number;
y: number;
left: number;
top: number;
} | null>(null);
const width = Math.max(1120, ...graph.nodes.map((n) => n.x + 260));
const height = Math.max(520, ...graph.nodes.map((n) => n.y + 180));
function move(node: FlowNode, x: number, y: number) {
onChange?.({
...graph,
nodes: graph.nodes.map((n) =>
n.id === node.id
? {
...n,
x: Math.max(0, Math.min(10000, x)),
y: Math.max(0, Math.min(10000, y)),
}
: n,
),
});
}
return (
<div className="flow-canvas-shell">
<div className="flow-canvas-toolbar">
<span>
{onChange
? "拖动节点排列 · 选中后可用方向键微调"
: "节点颜色与状态显示当前轮次"}
</span>
<div className="inline-actions">
<Button
size="small"
aria-label="缩小画布"
onClick={() => setZoom((z) => Math.max(0.4, z - 0.1))}
>
−
</Button>
<span>{Math.round(zoom * 100)}%</span>
<Button
size="small"
aria-label="放大画布"
onClick={() => setZoom((z) => Math.min(1.5, z + 0.1))}
>
+
</Button>
<Button size="small" onClick={() => setZoom(0.8)}>
重置缩放
</Button>
<Button
size="small"
onClick={() =>
setZoom(
Math.max(
0.25,
Math.min(1, (viewport.current?.clientWidth || width) / width),
),
)
}
>
适应画布
</Button>
</div>
</div>
<div
ref={viewport}
className="flow-canvas-viewport"
aria-label="QuantFlow 画布"
>
<div style={{ width: width * zoom, height: height * zoom }}>
<div
className="flow-canvas"
style={{
width,
height,
transform: `scale(${zoom})`,
transformOrigin: "top left",
}}
>
<svg
width={width}
height={height}
className="flow-edges"
aria-label="节点连线"
>
{graph.edges.map((e, index) => {
const a = graph.nodes.find((n) => n.id === e.source),
b = graph.nodes.find((n) => n.id === e.target);
if (!a || !b) return null;
const x = a.x + 200,
y = a.y + 50,
tx = b.x,
ty = b.y + 50;
const sibling = graph.edges
.filter((edge) => edge.source === e.source)
.indexOf(e);
const routed = Boolean(e.branch) || tx < x || tx - x > 130;
const lane =
tx < x
? a.y + 130 + sibling * 14
: Math.max(10, Math.min(a.y, b.y) - 30 - sibling * 14);
const path = routed
? `M${x} ${y} H${x + 18} V${lane} H${tx - 18} V${ty} H${tx}`
: `M${x} ${y} C${x + 30} ${y}, ${tx - 30} ${ty}, ${tx} ${ty}`;
const upstream = run?.steps.find(
(step) =>
step.node_id === e.source && step.round === run.round,
);
const inactive =
upstream &&
(upstream.status === "skipped" ||
(e.branch && upstream.output.verdict !== e.branch));
return (
<g
key={`${e.source}-${e.target}-${index}`}
opacity={inactive ? 0.3 : 1}
>
<path d={path} />
<path
d={`M${tx - 8} ${ty - 5} L${tx} ${ty} L${tx - 8} ${ty + 5}`}
/>
{e.branch && (
<text
x={(x + tx) / 2}
y={routed ? lane - 5 : (y + ty) / 2 - 8}
>
{e.branch}
</text>
)}
</g>
);
})}
</svg>
{graph.nodes.map((node) => {
const type = types.find((t) => t.type === node.type);
const step = run?.steps.find(
(s) => s.node_id === node.id && s.round === run.round,
);
return (
<button
key={node.id}
type="button"
aria-label={`节点 ${node.label || node.id}`}
aria-pressed={selected === node.id}
className={`flow-node ${selected === node.id ? "selected" : ""} ${step?.status || ""}`}
style={{ left: node.x, top: node.y, touchAction: "none" }}
onClick={() => onSelect(node.id)}
onPointerDown={(e) => {
onSelect(node.id);
if (!onChange) return;
drag.current = {
id: node.id,
x: e.clientX,
y: e.clientY,
left: node.x,
top: node.y,
};
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = drag.current;
if (d?.id === node.id)
move(
node,
d.left + (e.clientX - d.x) / zoom,
d.top + (e.clientY - d.y) / zoom,
);
}}
onPointerUp={() => {
drag.current = null;
}}
onPointerCancel={() => {
drag.current = null;
}}
onKeyDown={(e) => {
const delta: Record<string, [number, number]> = {
ArrowLeft: [-10, 0],
ArrowRight: [10, 0],
ArrowUp: [0, -10],
ArrowDown: [0, 10],
};
if (onChange && delta[e.key]) {
e.preventDefault();
move(
node,
node.x + delta[e.key][0],
node.y + delta[e.key][1],
);
}
}}
>
<span className="flow-node-kind">
{type?.label || node.type}
</span>
<strong>{node.label || node.id}</strong>
<span>
{step
? flowStatus[step.status] || step.status
: `${type && type.accepts.length > 2 ? "多种研究产物" : type?.accepts.map((p) => portNames[p] || p).join(" / ") || "固定数据"} → ${portNames[type?.produces || ""] || "产物"}`}
</span>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}