import { ResearchList } from "./ResearchList"; import { useEffect, useState } from "react"; import { Banner, Button, Input, InputNumber, Pagination, TextArea, Toast, } from "@douyinfe/semi-ui-19"; import { api, post, formatTime } 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 [route, setRoute] = useState(() => location.hash); const [query, setQuery] = useState(""); const detail = /^#quantflow\/(new|workflows\/[^/]+|runs\/[^/]+)$/.test(route); useEffect(() => { const changed = () => setRoute(location.hash); window.addEventListener("hashchange", changed); return () => window.removeEventListener("hashchange", changed); }, []); function navigate(path: string) { location.hash = `quantflow${path}`; setRoute(`#quantflow${path}`); } const [assets, setAssets] = useState([]), [total, setTotal] = useState(0), [page, setPage] = useState(1); const [types, setTypes] = useState([]), [refs, setRefs] = useState([]); const [asset, setAsset] = useState(null), [versions, setVersions] = useState<{ version: number }[]>([]); const [graph, setGraph] = useState(() => readDraft("new", empty)); const [selected, setSelected] = useState("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([]), [runTotal, setRunTotal] = useState(0), [runPage, setRunPage] = useState(1); const [runId, setRunId] = useState(() => localStorage.getItem("quantflow-selected-run"), ); const [run, setRun] = useState(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}&q=${encodeURIComponent(query)}`, { 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, query]); useEffect(() => { if (!active || mode !== "runs") return; const c = new AbortController(); let timer: ReturnType; 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]); useEffect(() => { if (!active) return; const runMatch = route.match(/^#quantflow\/runs\/([^/]+)$/); const workflowMatch = route.match(/^#quantflow\/workflows\/([^/]+)$/); if (runMatch) { setMode("runs"); if (runId !== runMatch[1]) { setRun(null); setRunId(runMatch[1]); } } else if (route === "#quantflow/runs") { setMode("runs"); } else { setMode("edit"); if (route === "#quantflow/new" && asset) { setAsset(null); setVersions([]); change(readDraft("new", empty)); } if (workflowMatch && asset?.id !== workflowMatch[1]) { const controller = new AbortController(); void api( `/research/assets/${encodeURIComponent(workflowMatch[1])}`, { signal: controller.signal }, ) .then((item) => { if (!controller.signal.aborted) void open(item); }) .catch((e) => { if (!controller.signal.aborted) setError(e.message); }); return () => controller.abort(); } } }, [active, route]); function change(value: Workflow) { setGraph(value); setValid(false); setError(""); } async function open(item: WorkflowAsset, version?: number) { try { const loaded = version ? await api( `/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. */ } } navigate(`/workflows/${item.id}`); 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( 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 } : {}), }), }, ); history.replaceState(null, "", `#quantflow/workflows/${result.id}`); setRoute(`#quantflow/workflows/${result.id}`); 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) { 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 (
{error && } {!detail ? ( {mode !== "runs" && ( { setQuery(value); setPage(1); }} /> )} } actions={ <> } pagination={ <> 共 {mode === "runs" ? runTotal : total} 条 {(mode === "runs" ? runTotal : total) > 0 && ( )} } > {mode === "runs" ? ( <> {runs.map((r) => ( ))}
运行名称 状态 轮次 创建时间
{flowStatus[r.status]} {r.round} {formatTime(r.created_at)}
{!runs.length && (

暂无流程运行记录。

)} ) : ( <> {assets.map((a) => ( ))}
流程名称 版本 节点数量 连接数量
v{a.version} {a.content.nodes.length} {a.content.edges.length}
{!assets.length && (

暂无流程,请新建流程或复制固定配方。

)} )}
) : ( <>
{mode === "runs" ? ( run ? ( <> {selected && (

所选节点产物

{run.steps .filter((s) => s.node_id === selected) .map((s) => (
第 {s.round} 轮 · {flowStatus[s.status]}
                                {JSON.stringify(s.output, null, 2)}
                              
))}
)} setRevision((v) => v + 1)} onAction={onAction} /> ) : (

选择一条运行查看节点状态和产物。

) ) : mode === "launch" && asset ? ( <> { setRun(r); setRunId(r.id); localStorage.setItem("quantflow-selected-run", r.id); setMode("runs"); navigate(`/runs/${r.id}`); }} /> ) : ( <>
change({ ...graph, name })} />
{asset && ( )} {asset && ( )}

{valid ? "连接校验通过。" : "保存时核验节点类型、连接和循环。"}{" "} {dirty ? "有未保存草稿,启动前请保存版本。" : `当前 v${asset?.version},运行固定此版本。`}{" "} 普通连接无环;终点迭代节点重复整个流程,每次运行都受预算约束。

连线

→
{graph.edges.map((e, i) => (
{ graph.nodes.find((n) => n.id === e.source) ?.label }{" "} →{" "} { graph.nodes.find((n) => n.id === e.target) ?.label }{" "} {e.branch && `· ${e.branch}`}
))}