2026-09-09 09:29:30 +08:00
|
|
|
import { ResearchList } from "./ResearchList";
|
2026-09-08 22:42:29 +08:00
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
Banner,
|
|
|
|
|
Button,
|
|
|
|
|
Input,
|
|
|
|
|
InputNumber,
|
|
|
|
|
Pagination,
|
|
|
|
|
TextArea,
|
|
|
|
|
Toast,
|
|
|
|
|
} from "@douyinfe/semi-ui-19";
|
2026-09-09 09:29:30 +08:00
|
|
|
import { api, post, formatTime } from "../api";
|
2026-09-08 22:42:29 +08:00
|
|
|
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;
|
|
|
|
|
}) {
|
2026-09-09 09:29:30 +08:00
|
|
|
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}`);
|
|
|
|
|
}
|
2026-09-08 22:42:29 +08:00
|
|
|
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 }>(
|
2026-09-09 09:29:30 +08:00
|
|
|
`/research/assets?kind=workflow&offset=${(page - 1) * 25}&q=${encodeURIComponent(query)}`,
|
2026-09-08 22:42:29 +08:00
|
|
|
{ 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();
|
2026-09-09 09:29:30 +08:00
|
|
|
}, [active, page, revision, query]);
|
2026-09-08 22:42:29 +08:00
|
|
|
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]);
|
2026-09-09 09:29:30 +08:00
|
|
|
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<WorkflowAsset>(
|
|
|
|
|
`/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]);
|
2026-09-08 22:42:29 +08:00
|
|
|
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. */
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-09 09:29:30 +08:00
|
|
|
navigate(`/workflows/${item.id}`);
|
2026-09-08 22:42:29 +08:00
|
|
|
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 } : {}),
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-09-09 09:29:30 +08:00
|
|
|
history.replaceState(null, "", `#quantflow/workflows/${result.id}`);
|
|
|
|
|
setRoute(`#quantflow/workflows/${result.id}`);
|
2026-09-08 22:42:29 +08:00
|
|
|
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 (
|
2026-09-09 09:29:30 +08:00
|
|
|
<section className="research-workspace research-list-page quantflow-workspace">
|
2026-09-08 22:42:29 +08:00
|
|
|
{error && <Banner type="danger" description={error} />}
|
2026-09-09 09:29:30 +08:00
|
|
|
{!detail ? (
|
|
|
|
|
<ResearchList
|
|
|
|
|
resetKey={JSON.stringify([mode, query, page, runPage])}
|
|
|
|
|
label="QuantFlow"
|
|
|
|
|
filters={
|
2026-09-08 22:42:29 +08:00
|
|
|
<>
|
2026-09-09 09:29:30 +08:00
|
|
|
<select
|
|
|
|
|
aria-label="QuantFlow 列表"
|
|
|
|
|
value={mode === "runs" ? "runs" : "workflows"}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setMode(e.target.value === "runs" ? "runs" : "edit");
|
|
|
|
|
navigate(e.target.value === "runs" ? "/runs" : "");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<option value="workflows">流程库</option>
|
|
|
|
|
<option value="runs">运行记录</option>
|
|
|
|
|
</select>
|
|
|
|
|
{mode !== "runs" && (
|
|
|
|
|
<Input
|
|
|
|
|
aria-label="搜索流程"
|
|
|
|
|
placeholder="搜索流程名称"
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
setQuery(value);
|
|
|
|
|
setPage(1);
|
2026-09-08 22:42:29 +08:00
|
|
|
}}
|
2026-09-09 09:29:30 +08:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-09-08 22:42:29 +08:00
|
|
|
</>
|
2026-09-09 09:29:30 +08:00
|
|
|
}
|
|
|
|
|
actions={
|
2026-09-08 22:42:29 +08:00
|
|
|
<>
|
2026-09-09 09:29:30 +08:00
|
|
|
<Button
|
|
|
|
|
theme="solid"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setAsset(null);
|
|
|
|
|
setVersions([]);
|
|
|
|
|
change(readDraft("new", empty));
|
|
|
|
|
setMode("edit");
|
|
|
|
|
navigate("/new");
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
新建流程
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
const recipe = await api<Workflow>(
|
|
|
|
|
"/research/flows/recipe",
|
|
|
|
|
);
|
|
|
|
|
setAsset(null);
|
|
|
|
|
setVersions([]);
|
|
|
|
|
change({ ...recipe, name: "固定研究配方副本" });
|
|
|
|
|
setMode("edit");
|
|
|
|
|
navigate("/new");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setError((e as Error).message);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
复制固定配方
|
|
|
|
|
</Button>
|
2026-09-08 22:42:29 +08:00
|
|
|
</>
|
2026-09-09 09:29:30 +08:00
|
|
|
}
|
|
|
|
|
pagination={
|
|
|
|
|
<>
|
|
|
|
|
<span>共 {mode === "runs" ? runTotal : total} 条</span>
|
|
|
|
|
{(mode === "runs" ? runTotal : total) > 0 && (
|
|
|
|
|
<Pagination
|
|
|
|
|
size="small"
|
|
|
|
|
total={mode === "runs" ? runTotal : total}
|
|
|
|
|
currentPage={mode === "runs" ? runPage : page}
|
|
|
|
|
pageSize={25}
|
|
|
|
|
onPageChange={mode === "runs" ? setRunPage : setPage}
|
2026-09-08 22:42:29 +08:00
|
|
|
/>
|
2026-09-09 09:29:30 +08:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
{mode === "runs" ? (
|
2026-09-08 22:42:29 +08:00
|
|
|
<>
|
2026-09-09 09:29:30 +08:00
|
|
|
<table className="research-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>运行名称</th>
|
|
|
|
|
<th>状态</th>
|
|
|
|
|
<th>轮次</th>
|
|
|
|
|
<th>创建时间</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{runs.map((r) => (
|
|
|
|
|
<tr key={r.id}>
|
|
|
|
|
<td>
|
|
|
|
|
<Button
|
|
|
|
|
theme="borderless"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRunId(r.id);
|
|
|
|
|
setRun(null);
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
"quantflow-selected-run",
|
|
|
|
|
r.id,
|
|
|
|
|
);
|
|
|
|
|
navigate(`/runs/${r.id}`);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{r.name}
|
|
|
|
|
</Button>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{flowStatus[r.status]}</td>
|
|
|
|
|
<td>{r.round}</td>
|
|
|
|
|
<td>{formatTime(r.created_at)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
{!runs.length && (
|
|
|
|
|
<p className="research-list-empty">暂无流程运行记录。</p>
|
|
|
|
|
)}
|
2026-09-08 22:42:29 +08:00
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-09-09 09:29:30 +08:00
|
|
|
<table className="research-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>流程名称</th>
|
|
|
|
|
<th>版本</th>
|
|
|
|
|
<th>节点数量</th>
|
|
|
|
|
<th>连接数量</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{assets.map((a) => (
|
|
|
|
|
<tr key={a.id}>
|
|
|
|
|
<td>
|
|
|
|
|
<Button theme="borderless" onClick={() => void open(a)}>
|
|
|
|
|
{a.name}
|
|
|
|
|
</Button>
|
|
|
|
|
</td>
|
|
|
|
|
<td>v{a.version}</td>
|
|
|
|
|
<td>{a.content.nodes.length}</td>
|
|
|
|
|
<td>{a.content.edges.length}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
{!assets.length && (
|
|
|
|
|
<p className="research-list-empty">
|
|
|
|
|
暂无流程,请新建流程或复制固定配方。
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</ResearchList>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="inline-actions quantflow-detail-actions">
|
|
|
|
|
<Button onClick={() => navigate(mode === "runs" ? "/runs" : "")}>
|
|
|
|
|
返回列表
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="quantflow-detail-body">
|
|
|
|
|
<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>
|
2026-09-08 22:42:29 +08:00
|
|
|
))}
|
2026-09-09 09:29:30 +08:00
|
|
|
</div>
|
2026-09-08 22:42:29 +08:00
|
|
|
)}
|
2026-09-09 09:29:30 +08:00
|
|
|
<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");
|
|
|
|
|
navigate(`/runs/${r.id}`);
|
|
|
|
|
}}
|
2026-09-08 22:42:29 +08:00
|
|
|
/>
|
2026-09-09 09:29:30 +08:00
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-09-08 22:42:29 +08:00
|
|
|
<div className="research-card">
|
2026-09-09 09:29:30 +08:00
|
|
|
<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 && (
|
2026-09-08 22:42:29 +08:00
|
|
|
<Button
|
2026-09-09 09:29:30 +08:00
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => void save(true)}
|
2026-09-08 22:42:29 +08:00
|
|
|
>
|
2026-09-09 09:29:30 +08:00
|
|
|
另存流程
|
2026-09-08 22:42:29 +08:00
|
|
|
</Button>
|
2026-09-09 09:29:30 +08:00
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
theme="solid"
|
|
|
|
|
disabled={dirty || busy}
|
|
|
|
|
onClick={() => setMode("launch")}
|
|
|
|
|
>
|
|
|
|
|
启动已保存流程
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-09-08 22:42:29 +08:00
|
|
|
</div>
|
2026-09-09 09:29:30 +08:00
|
|
|
<p className="research-hint">
|
|
|
|
|
{valid
|
|
|
|
|
? "连接校验通过。"
|
|
|
|
|
: "保存时核验节点类型、连接和循环。"}{" "}
|
|
|
|
|
{dirty
|
|
|
|
|
? "有未保存草稿,启动前请保存版本。"
|
|
|
|
|
: `当前 v${asset?.version},运行固定此版本。`}{" "}
|
|
|
|
|
普通连接无环;终点迭代节点重复整个流程,每次运行都受预算约束。
|
|
|
|
|
</p>
|
2026-09-08 22:42:29 +08:00
|
|
|
</div>
|
2026-09-09 09:29:30 +08:00
|
|
|
<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"
|
2026-09-08 22:42:29 +08:00
|
|
|
}
|
2026-09-09 09:29:30 +08:00
|
|
|
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 && (
|
2026-09-08 22:42:29 +08:00
|
|
|
<>
|
2026-09-09 09:29:30 +08:00
|
|
|
<h4>节点配置</h4>
|
2026-09-08 22:42:29 +08:00
|
|
|
<label>
|
2026-09-09 09:29:30 +08:00
|
|
|
显示名称
|
|
|
|
|
<Input
|
|
|
|
|
aria-label="节点名称"
|
|
|
|
|
value={node.label}
|
|
|
|
|
onChange={(label) => updateNode({ label })}
|
|
|
|
|
/>
|
2026-09-08 22:42:29 +08:00
|
|
|
</label>
|
2026-09-09 09:29:30 +08:00
|
|
|
<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" && (
|
2026-09-08 22:42:29 +08:00
|
|
|
<label>
|
2026-09-09 09:29:30 +08:00
|
|
|
生成方法
|
|
|
|
|
<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>
|
|
|
|
|
整个流程最大轮数
|
2026-09-08 22:42:29 +08:00
|
|
|
<InputNumber
|
2026-09-09 09:29:30 +08:00
|
|
|
aria-label="节点迭代轮数"
|
2026-09-08 22:42:29 +08:00
|
|
|
min={1}
|
2026-09-09 09:29:30 +08:00
|
|
|
max={100}
|
2026-09-08 22:42:29 +08:00
|
|
|
precision={0}
|
2026-09-09 09:29:30 +08:00
|
|
|
value={Number(node.config.max_rounds || 1)}
|
|
|
|
|
onChange={(max_rounds) => {
|
|
|
|
|
if (typeof max_rounds === "number")
|
|
|
|
|
updateNode({ config: { max_rounds } });
|
2026-09-08 22:42:29 +08:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
)}
|
2026-09-09 09:29:30 +08:00
|
|
|
{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>
|
|
|
|
|
)}
|
2026-09-08 22:42:29 +08:00
|
|
|
</>
|
|
|
|
|
)}
|
2026-09-09 09:29:30 +08:00
|
|
|
</aside>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-09-08 22:42:29 +08:00
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|