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
+22 -3
View File
@@ -12,6 +12,7 @@ import {
} from "@douyinfe/semi-ui-19";
import zhCN from "@douyinfe/semi-ui-19/lib/es/locale/source/zh_CN";
import { IconSetting, IconComment, IconHistory } from "@douyinfe/semi-icons";
import { QuantFlowPage } from "./research/QuantFlowPage";
import { PipelinePage } from "./research/PipelinePage";
import { FeaturesPage } from "./research/FeaturesPage";
import { ResearchWorkspace } from "./research/ResearchWorkspace";
@@ -52,9 +53,14 @@ export default function App() {
});
useEffect(() => {
if (
["operators", "templates", "variants", "features", "pipeline"].includes(
page,
)
[
"operators",
"templates",
"variants",
"features",
"pipeline",
"quantflow",
].includes(page)
)
setVisitedResearch((old) => (old.includes(page) ? old : [...old, page]));
}, [page]);
@@ -376,6 +382,15 @@ export default function App() {
/>
)}
</div>
{visitedResearch.includes("quantflow") && (
<div className="alpha-page-view" hidden={page !== "quantflow"}>
<QuantFlowPage
active={page === "quantflow"}
onAction={handleAction}
onContext={setResearchContext}
/>
</div>
)}
{visitedResearch.includes("pipeline") && (
<div className="alpha-page-view" hidden={page !== "pipeline"}>
<PipelinePage
@@ -462,6 +477,10 @@ export default function App() {
onClose={() => setChatOpen(false)}
context={
{
quantflow:
researchContext.page === "quantflow"
? researchContext
: { page: "quantflow" as const },
pipeline:
researchContext.page === "pipeline"
? researchContext
+2 -1
View File
@@ -20,7 +20,8 @@ export type PageContext = {
| "templates"
| "variants"
| "features"
| "pipeline";
| "pipeline"
| "quantflow";
research_run_id?: string | null;
research_asset_id?: string;
research_experiment_id?: string;
+2
View File
@@ -7,6 +7,8 @@ const contextLabels: Record<WorkspacePage, (context: PageContext) => string> = {
operators: () => "上下文:算子库",
templates: (context) =>
`上下文:模板工坊${context.research_asset_id ? ` · ${context.research_asset_id}` : ""}${context.research_experiment_id ? ` · 实验 ${context.research_experiment_id}` : ""}`,
quantflow: (context) =>
`上下文:QuantFlow${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
pipeline: (context) =>
`上下文:研究流水线${context.research_run_id ? ` · ${context.research_run_id}` : ""}`,
features: () => "上下文:特征工程",
+1
View File
@@ -28,6 +28,7 @@ const navigation = [
{ id: "variants", label: "Alpha 变体", icon: IconBeaker, group: "研究实验" },
{ id: "backtests", label: "回测研究", icon: IconBeaker, group: "研究实验" },
{ id: "pipeline", label: "研究流水线", icon: IconBeaker, group: "研究编排" },
{ id: "quantflow", label: "QuantFlow", icon: IconBeaker, group: "研究编排" },
{ id: "alphas", label: "Alpha 管理", icon: IconGridView, group: "研究成果" },
{ id: "account", label: "个人信息", icon: IconUser, group: "" },
] as const;
+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>
);
}
+32 -1
View File
@@ -24,7 +24,7 @@ export function FlowLaunchForm({
const [templates, setTemplates] = useState<Asset[]>([]);
const [ids, setIds] = useState<string[]>([]);
const [template, setTemplate] = useState<string>();
const [name, setName] = useState("固定研究流水线");
const [name, setName] = useState(workflow?.name || "固定研究流水线");
const [hypothesis, setHypothesis] = useState("");
const [parent, setParent] = useState("");
const [budget, setBudget] = useState<Budget>({
@@ -284,10 +284,41 @@ export function FlowLaunchForm({
<p>
{confirmation.name}:{confirmation.hypothesis}
</p>
{workflow && (
<p>
流程:{workflow.name} · v{workflow.version}
</p>
)}
<p>
{confirmation.input_ids.length} 个固定输入 · {settings.region}/
{settings.universe}/D{settings.delay}
</p>
<ul>
{confirmation.input_ids.map((id) => {
const input = inputs.find((i) => i.id === id);
return (
<li key={id}>
{input
? `${input.dataset_id} · ${input.scope.region}/${input.scope.universe}/D${input.scope.delay}`
: id}{" "}
· {id.slice(0, 8)}
</li>
);
})}
</ul>
{confirmation.parent_alpha_ids.length > 0 && (
<p>
种子 Alpha:{confirmation.parent_alpha_ids.join("、")}
。市场与设置变体保持种子表达式及其他设置,仅使用上述固定输入的目标范围。
</p>
)}
{confirmation.template_id && (
<p>
初始模板:
{templates.find((t) => t.id === confirmation.template_id)?.name}{" "}
· v{confirmation.template_version}
</p>
)}
<p>
最多 {confirmation.budget.max_rounds} 轮、
{confirmation.budget.max_simulations} 条模拟、
+1 -1
View File
@@ -36,7 +36,7 @@ export function PipelinePage({
async function refresh() {
try {
const list = await api<{ items: FlowRun[]; total: number }>(
`/research/flows/runs?offset=${(page - 1) * 25}`,
`/research/flows/runs?kind=pipeline&offset=${(page - 1) * 25}`,
{ signal: c.signal },
);
setItems(list.items);
+775
View File
@@ -0,0 +1,775 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Input,
InputNumber,
Pagination,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { PageContext, UIAction } from "../ai/types";
import { FlowCanvas, type NodeType } from "./FlowCanvas";
import { FlowLaunchForm } from "./FlowLaunchForm";
import { FlowRunView } from "./FlowRunView";
import type { FlowNode, FlowRun, Workflow } from "./flowTypes";
import { flowStatus } from "./flowTypes";
import "./workspace.css";
type WorkflowAsset = {
id: string;
version: number;
name: string;
content: Workflow;
};
type RefAsset = { id: string; version: number; name: string; kind: string };
const empty: Workflow = {
name: "新研究流程",
nodes: [
{ id: "input", type: "input", label: "固定输入", x: 40, y: 80, config: {} },
],
edges: [],
};
function readDraft(key: string, fallback: Workflow): Workflow {
try {
const value = JSON.parse(
localStorage.getItem(`quantflow-draft:${key}`) || "null",
);
const graph = value?.graph || value;
return graph?.nodes && graph?.edges && graph?.name ? graph : fallback;
} catch {
return fallback;
}
}
export function QuantFlowPage({
active,
onContext,
onAction,
}: {
active: boolean;
onContext: (c: PageContext) => void;
onAction: (a: UIAction) => void;
}) {
const [assets, setAssets] = useState<WorkflowAsset[]>([]),
[total, setTotal] = useState(0),
[page, setPage] = useState(1);
const [types, setTypes] = useState<NodeType[]>([]),
[refs, setRefs] = useState<RefAsset[]>([]);
const [asset, setAsset] = useState<WorkflowAsset | null>(null),
[versions, setVersions] = useState<{ version: number }[]>([]);
const [graph, setGraph] = useState<Workflow>(() => readDraft("new", empty));
const [selected, setSelected] = useState<string | null>("input"),
[nodeType, setNodeType] = useState("generate");
const [source, setSource] = useState(""),
[target, setTarget] = useState(""),
[branch, setBranch] = useState("");
const [error, setError] = useState(""),
[valid, setValid] = useState(false),
[busy, setBusy] = useState(false);
const [mode, setMode] = useState<"edit" | "launch" | "runs">(() =>
localStorage.getItem("quantflow-mode") === "runs" ? "runs" : "edit",
);
const [runs, setRuns] = useState<FlowRun[]>([]),
[runTotal, setRunTotal] = useState(0),
[runPage, setRunPage] = useState(1);
const [runId, setRunId] = useState<string | null>(() =>
localStorage.getItem("quantflow-selected-run"),
);
const [run, setRun] = useState<FlowRun | null>(null),
[revision, setRevision] = useState(0);
useEffect(() => {
localStorage.setItem("quantflow-mode", mode === "runs" ? "runs" : "edit");
}, [mode]);
const node = graph.nodes.find((n) => n.id === selected);
const dirty =
!asset || JSON.stringify(graph) !== JSON.stringify(asset.content);
useEffect(() => {
if (active)
onContext({
page: "quantflow",
research_asset_id: asset?.id,
research_run_id: mode === "runs" ? runId : null,
});
}, [active, asset?.id, runId, mode, onContext]);
useEffect(() => {
try {
localStorage.setItem(
`quantflow-draft:${asset?.id || "new"}`,
JSON.stringify({ graph, asset }),
);
} catch {
/* The in-memory draft remains usable when local storage is full. */
}
}, [graph, asset?.id]);
useEffect(() => {
if (!active) return;
const c = new AbortController();
Promise.all([
api<{ items: WorkflowAsset[]; total: number }>(
`/research/assets?kind=workflow&offset=${(page - 1) * 25}`,
{ signal: c.signal },
),
api<{ items: NodeType[] }>("/research/flows/nodes", { signal: c.signal }),
api<{ items: RefAsset[] }>("/research/assets?kind=feature&limit=100", {
signal: c.signal,
}),
api<{ items: RefAsset[] }>("/research/assets?kind=template&limit=100", {
signal: c.signal,
}),
])
.then(([a, t, f, m]) => {
setAssets(a.items);
setTotal(a.total);
setTypes(t.items);
setRefs([...f.items, ...m.items]);
})
.catch((e) => {
if (!c.signal.aborted) setError(e.message);
});
return () => c.abort();
}, [active, page, revision]);
useEffect(() => {
if (!active || mode !== "runs") return;
const c = new AbortController();
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
try {
const list = await api<{ items: FlowRun[]; total: number }>(
`/research/flows/runs?kind=quantflow&offset=${(runPage - 1) * 25}`,
{ signal: c.signal },
);
setRuns(list.items);
setRunTotal(list.total);
if (runId)
setRun(
await api(`/research/flows/runs/${runId}`, { signal: c.signal }),
);
} catch (e) {
if (!c.signal.aborted) setError((e as Error).message);
} finally {
if (!c.signal.aborted) timer = setTimeout(() => void refresh(), 1500);
}
}
void refresh();
return () => {
c.abort();
clearTimeout(timer);
};
}, [active, mode, runId, runPage, revision]);
function change(value: Workflow) {
setGraph(value);
setValid(false);
setError("");
}
async function open(item: WorkflowAsset, version?: number) {
try {
const loaded = version
? await api<WorkflowAsset>(
`/research/assets/${item.id}?version=${version}`,
)
: item;
let baseline = loaded;
if (!version) {
try {
const draft = JSON.parse(
localStorage.getItem(`quantflow-draft:${loaded.id}`) || "null",
);
if (draft?.asset?.id === loaded.id) baseline = draft.asset;
} catch {
/* Keep the server version when the local draft is unavailable. */
}
}
setAsset(baseline);
change(version ? loaded.content : readDraft(loaded.id, loaded.content));
setSelected(loaded.content.nodes[0]?.id || null);
setMode("edit");
setVersions(await api(`/research/assets/${item.id}/versions`));
} catch (e) {
setError((e as Error).message);
}
}
async function validate(value = graph) {
await post("/research/flows/validate", value);
setValid(true);
setError("");
}
async function save(copy = false) {
setBusy(true);
try {
await validate();
const result = await api<WorkflowAsset>(
asset && !copy ? `/research/assets/${asset.id}` : "/research/assets",
{
method: asset && !copy ? "PUT" : "POST",
body: JSON.stringify({
kind: "workflow",
content: graph,
...(asset && !copy ? { version: asset.version } : {}),
}),
},
);
setAsset(result);
setGraph(result.content);
setValid(true);
setVersions(await api(`/research/assets/${result.id}/versions`));
setRevision((v) => v + 1);
Toast.success(`已保存流程 v${result.version}`);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function updateNode(config: Partial<FlowNode>) {
if (node)
change({
...graph,
nodes: graph.nodes.map((n) =>
n.id === node.id ? { ...n, ...config } : n,
),
});
}
function addNode() {
const type = types.find((t) => t.type === nodeType);
if (!type || graph.nodes.length >= 50) return;
const id = `${nodeType}_${crypto.randomUUID().slice(0, 8)}`;
change({
...graph,
nodes: [
...graph.nodes,
{
id,
type: nodeType,
label: type.label,
x: 60 + (graph.nodes.length % 4) * 250,
y: 80 + Math.floor(graph.nodes.length / 4) * 180,
config:
nodeType === "iterate"
? { max_rounds: 3 }
: nodeType === "variant"
? { method: "structure" }
: {},
},
],
});
setSelected(id);
}
function addEdge() {
const a = graph.nodes.find((n) => n.id === source),
b = graph.nodes.find((n) => n.id === target);
if (!a || !b || source === target) {
setError("请选择两个不同的连线端点");
return;
}
if (
!types
.find((t) => t.type === b.type)
?.accepts.includes(types.find((t) => t.type === a.type)?.produces || "")
) {
setError("输入输出类型不兼容");
return;
}
const edge = {
source,
target,
branch: a.type === "condition" && branch ? branch : null,
};
if (
graph.edges.some(
(e) =>
e.source === source &&
e.target === target &&
e.branch === edge.branch,
)
) {
setError("连线重复");
return;
}
change({ ...graph, edges: [...graph.edges, edge] });
}
return (
<section className="research-workspace quantflow-workspace">
<div className="section-toolbar">
<div>
<h2>QuantFlow</h2>
<p className="muted">组合原生研究节点,复用回测、评估与有限预算。</p>
</div>
<div className="inline-actions">
<Button onClick={() => setMode("edit")}>编辑画布</Button>
<Button onClick={() => setMode("runs")}>运行记录</Button>
<Button
onClick={() => {
setAsset(null);
setVersions([]);
change(readDraft("new", empty));
setMode("edit");
}}
>
新建流程
</Button>
<Button
onClick={async () => {
try {
const recipe = await api<Workflow>("/research/flows/recipe");
setAsset(null);
setVersions([]);
change({ ...recipe, name: "固定研究配方副本" });
setMode("edit");
} catch (e) {
setError((e as Error).message);
}
}}
>
复制固定配方
</Button>
</div>
</div>
{error && <Banner type="danger" description={error} />}
<div className="research-layout">
<aside className="research-library">
<h3>{mode === "runs" ? "画布运行" : "流程库"}</h3>
{mode === "runs" ? (
<>
{runs.map((r) => (
<button
className={`research-library-item ${runId === r.id ? "selected" : ""}`}
key={r.id}
onClick={() => {
setRunId(r.id);
setRun(null);
localStorage.setItem("quantflow-selected-run", r.id);
}}
>
<strong>{r.name}</strong>
<span>
{flowStatus[r.status]} · 第 {r.round} 轮
</span>
</button>
))}
<Pagination
size="small"
total={runTotal}
currentPage={runPage}
pageSize={25}
onPageChange={setRunPage}
/>
</>
) : (
<>
{assets.map((a) => (
<button
className={`research-library-item ${asset?.id === a.id ? "selected" : ""}`}
key={a.id}
onClick={() => void open(a)}
>
<strong>{a.name}</strong>
<span>
v{a.version} · {a.content.nodes.length} 个节点
</span>
</button>
))}
<Pagination
size="small"
total={total}
currentPage={page}
pageSize={25}
onPageChange={setPage}
/>
</>
)}
</aside>
<div className="research-main">
{mode === "runs" ? (
run ? (
<>
<FlowCanvas
graph={run.definition}
types={types}
selected={selected}
onSelect={setSelected}
run={run}
/>
{selected && (
<div className="research-card">
<h4>所选节点产物</h4>
{run.steps
.filter((s) => s.node_id === selected)
.map((s) => (
<details key={s.id}>
<summary>
第 {s.round} 轮 · {flowStatus[s.status]}
</summary>
<pre className="code-block">
{JSON.stringify(s.output, null, 2)}
</pre>
</details>
))}
</div>
)}
<FlowRunView
run={run}
onRefresh={() => setRevision((v) => v + 1)}
onAction={onAction}
/>
</>
) : (
<p className="research-card">选择一条运行查看节点状态和产物。</p>
)
) : mode === "launch" && asset ? (
<>
<Button onClick={() => setMode("edit")}>返回流程</Button>
<FlowLaunchForm
workflow={asset}
onStarted={(r) => {
setRun(r);
setRunId(r.id);
localStorage.setItem("quantflow-selected-run", r.id);
setMode("runs");
}}
/>
</>
) : (
<>
<div className="research-card">
<div className="section-toolbar">
<Input
aria-label="流程名称"
value={graph.name}
onChange={(name) => change({ ...graph, name })}
/>
<div className="inline-actions">
{asset && (
<label>
历史版本
<select
aria-label="流程版本"
value={asset.version}
onChange={(e) =>
void open(asset, Number(e.target.value))
}
>
{versions.map((v) => (
<option key={v.version} value={v.version}>
v{v.version}
</option>
))}
</select>
</label>
)}
<Button
disabled={busy}
onClick={() =>
void validate().catch((e) => setError(e.message))
}
>
校验连接
</Button>
<Button disabled={busy} onClick={() => void save()}>
保存流程版本
</Button>
{asset && (
<Button disabled={busy} onClick={() => void save(true)}>
另存流程
</Button>
)}
<Button
theme="solid"
disabled={dirty || busy}
onClick={() => setMode("launch")}
>
启动已保存流程
</Button>
</div>
</div>
<p className="research-hint">
{valid
? "连接校验通过。"
: "保存时核验节点类型、连接和循环。"}{" "}
{dirty
? "有未保存草稿,启动前请保存版本。"
: `当前 v${asset?.version},运行固定此版本。`}{" "}
普通连接无环;终点迭代节点重复整个流程,每次运行都受预算约束。
</p>
</div>
<div className="flow-editor">
<div>
<FlowCanvas
graph={graph}
types={types}
selected={selected}
onSelect={setSelected}
onChange={change}
/>
<div className="research-card">
<h4>连线</h4>
<div className="flow-connect">
<select
aria-label="连线起点"
value={source}
onChange={(e) => setSource(e.target.value)}
>
<option value="">选择上游</option>
{graph.nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
<span>→</span>
<select
aria-label="连线终点"
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">选择下游</option>
{graph.nodes.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
<select
aria-label="连线条件"
value={branch}
disabled={
graph.nodes.find((n) => n.id === source)?.type !==
"condition"
}
onChange={(e) => setBranch(e.target.value)}
>
<option value="">始终</option>
{["pass", "review", "block"].map((v) => (
<option key={v}>{v}</option>
))}
</select>
<Button onClick={addEdge}>添加连线</Button>
</div>
<div className="flow-edge-list">
{graph.edges.map((e, i) => (
<div key={i}>
<span>
{graph.nodes.find((n) => n.id === e.source)?.label}{" "}
→{" "}
{graph.nodes.find((n) => n.id === e.target)?.label}{" "}
{e.branch && `· ${e.branch}`}
</span>
<Button
size="small"
aria-label={`删除连线 ${i + 1}`}
onClick={() =>
change({
...graph,
edges: graph.edges.filter((_, j) => j !== i),
})
}
>
删除
</Button>
</div>
))}
</div>
</div>
</div>
<aside className="research-card flow-inspector">
<h4>原生节点</h4>
<select
aria-label="新增节点类型"
value={nodeType}
onChange={(e) => setNodeType(e.target.value)}
>
{types
.filter((t) => t.type !== "input")
.map((t) => (
<option key={t.type} value={t.type}>
{t.label}
</option>
))}
</select>
<Button disabled={graph.nodes.length >= 50} onClick={addNode}>
添加节点
</Button>
{node && (
<>
<h4>节点配置</h4>
<label>
显示名称
<Input
aria-label="节点名称"
value={node.label}
onChange={(label) => updateNode({ label })}
/>
</label>
<p className="research-hint">{node.id}</p>
{["generate", "feature"].includes(node.type) && (
<label>
研究说明
<TextArea
aria-label="节点研究说明"
value={String(node.config.prompt || "")}
onChange={(prompt) =>
updateNode({ config: { ...node.config, prompt } })
}
/>
</label>
)}
{["feature", "expand"].includes(node.type) && (
<>
<label>
固定素材版本
<select
aria-label="节点素材"
value={String(node.config.asset_id || "")}
onChange={(e) => {
const ref = refs.find(
(r) => r.id === e.target.value,
);
const {
asset_id: _,
version: __,
...rest
} = node.config;
updateNode({
config: ref
? {
...rest,
asset_id: ref.id,
version: ref.version,
}
: rest,
});
}}
>
<option value="">
{node.type === "feature"
? "由模型生成方案"
: "使用上游或启动模板"}
</option>
{refs
.filter(
(r) =>
r.kind ===
(node.type === "feature"
? "feature"
: "template"),
)
.map((r) => (
<option key={r.id} value={r.id}>
{r.name} · v{r.version}
</option>
))}
</select>
</label>
{Boolean(node.config.asset_id) && (
<label>
素材版本
<InputNumber
aria-label="节点素材版本"
min={1}
precision={0}
value={Number(node.config.version)}
onChange={(version) => {
if (typeof version === "number")
updateNode({
config: { ...node.config, version },
});
}}
/>
</label>
)}
</>
)}
{node.type === "variant" && (
<label>
生成方法
<select
aria-label="变体方法"
value={String(node.config.method || "structure")}
onChange={(e) =>
updateNode({ config: { method: e.target.value } })
}
>
<option value="structure">结构与字段变体</option>
<option value="settings">市场与设置变体</option>
</select>
</label>
)}
{node.type === "filter" && (
<fieldset>
<legend>保留评估结论</legend>
{["pass", "review", "block"].map((v) => (
<label key={v}>
<input
type="checkbox"
checked={(
(node.config.verdicts as string[]) || ["pass"]
).includes(v)}
onChange={(e) => {
const values = (node.config
.verdicts as string[]) || ["pass"];
updateNode({
config: {
verdicts: e.target.checked
? [...values, v]
: values.filter((x) => x !== v),
},
});
}}
/>
{v}
</label>
))}
</fieldset>
)}
{node.type === "iterate" && (
<label>
整个流程最大轮数
<InputNumber
aria-label="节点迭代轮数"
min={1}
max={100}
precision={0}
value={Number(node.config.max_rounds || 1)}
onChange={(max_rounds) => {
if (typeof max_rounds === "number")
updateNode({ config: { max_rounds } });
}}
/>
</label>
)}
{node.type === "condition" && (
<p>
根据上游规则评估的 pass / review / block
选择出边。模型建议不改变分支判断。
</p>
)}
{node.type !== "input" && (
<Button
type="danger"
onClick={() => {
change({
...graph,
nodes: graph.nodes.filter(
(n) => n.id !== node.id,
),
edges: graph.edges.filter(
(e) =>
e.source !== node.id && e.target !== node.id,
),
});
setSelected("input");
}}
>
删除节点
</Button>
)}
</>
)}
</aside>
</div>
</>
)}
</div>
</div>
</section>
);
}
+2 -1
View File
@@ -10,7 +10,8 @@ export function ResearchToolCard({ call, onAction }: ToolCardProps) {
</p>
{typeof result.id === "string" &&
!result.report &&
!result.authorization && (
!result.authorization &&
result.kind !== "workflow" && (
<Button
onClick={() =>
onAction(
+1
View File
@@ -64,6 +64,7 @@ export type FlowRun = {
};
export const flowStatus: Record<string, string> = {
queued: "等待推进",
generated: "模型产物已保存",
running: "执行中",
completed: "已完成",
skipped: "已跳过",
+146
View File
@@ -235,3 +235,149 @@
.research-saved-views .semi-select {
min-width: 210px;
}
/* Native research graph: same compact workspace surfaces and focus states. */
.flow-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) 235px;
gap: 12px;
align-items: start;
}
.flow-canvas-shell {
border: 1px solid var(--semi-color-border);
border-radius: 8px;
overflow: hidden;
background: var(--semi-color-bg-1);
margin-bottom: 12px;
}
.flow-canvas-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 8px 12px;
color: var(--semi-color-text-2);
font-size: 12px;
}
.flow-canvas-viewport {
overflow: auto;
height: 460px;
background: radial-gradient(var(--semi-color-border) 1px, transparent 1px);
background-size: 20px 20px;
}
.flow-canvas {
position: relative;
}
.flow-edges {
position: absolute;
inset: 0;
pointer-events: none;
}
.flow-edges path {
fill: none;
stroke: var(--semi-color-text-2);
stroke-width: 1.6;
}
.flow-edges text {
fill: var(--semi-color-text-1);
font-size: 12px;
}
.flow-node {
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
width: 200px;
height: 100px;
padding: 12px;
border: 1px solid var(--semi-color-border);
border-radius: 8px;
background: var(--semi-color-bg-1);
color: var(--semi-color-text-0);
text-align: left;
cursor: grab;
box-shadow: 0 2px 8px #00000008;
}
.flow-node strong {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
}
.flow-node span {
font-size: 11px;
color: var(--semi-color-text-2);
}
.flow-node.selected,
.flow-node:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
.flow-node.completed {
border-left: 4px solid var(--semi-color-success);
}
.flow-node.running,
.flow-node.waiting {
border-left: 4px solid var(--semi-color-primary);
}
.flow-node.blocked,
.flow-node.interrupted {
border-left: 4px solid var(--semi-color-warning);
}
.flow-node.skipped {
opacity: 0.55;
}
.flow-inspector {
display: grid;
gap: 12px;
}
.quantflow-workspace select {
border: 1px solid var(--semi-color-border);
border-radius: 4px;
background: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
padding: 7px 8px;
max-width: 100%;
}
.flow-inspector label {
display: grid;
gap: 6px;
}
.flow-inspector fieldset {
border: 1px solid var(--semi-color-border);
border-radius: 4px;
}
.flow-inspector fieldset label {
display: flex;
margin: 8px 0;
}
.flow-connect {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.flow-edge-list {
display: grid;
gap: 4px;
margin-top: 12px;
}
.flow-edge-list > div {
display: flex;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--semi-color-border);
padding: 4px 0;
}
.quantflow-workspace .section-toolbar > .semi-input-wrapper {
max-width: 240px;
}
@media (max-width: 1200px) {
.flow-editor {
grid-template-columns: minmax(0, 1fr);
}
.flow-inspector {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}