add canvas node drag drop and connection guards

This commit is contained in:
eust-w 2026-03-27 12:08:51 +08:00
parent 8ff26b3816
commit 6ee54c8399
9 changed files with 472 additions and 22 deletions

View File

@ -68,7 +68,8 @@ You can register that directory from the Assets page or via `POST /api/assets/re
The workflow editor currently requires selecting at least one registered asset before a run can be created. The workflow editor currently requires selecting at least one registered asset before a run can be created.
The editor now also persists per-node runtime config in workflow versions, including executor overrides, optional artifact title overrides, and Python code-hook source for inspect and transform style nodes. The editor now also persists per-node runtime config in workflow versions, including executor overrides, optional artifact title overrides, and Python code-hook source for inspect and transform style nodes.
The runtime web shell now exposes a visible `中文 / English` language toggle. The core workspace shell and workflow authoring surface are translated through a lightweight i18n layer. The runtime web shell now exposes a visible `中文 / English` language toggle. The core workspace shell and workflow authoring surface are translated through a lightweight i18n layer.
The workflow editor center panel now uses a real draggable node canvas with zoom, pan, mini-map, dotted background, handle-based edge creation, and persisted node positions instead of a static list of node cards. The workflow editor center panel now uses a real draggable node canvas with zoom, pan, mini-map, dotted background, handle-based edge creation, persisted node positions, and localized validation feedback instead of a static list of node cards.
The node library now supports both click-to-append and drag-and-drop placement into the canvas. V1 connection rules block self-edges, duplicate edges, cycles, incoming edges into source nodes, outgoing edges from export nodes, and multiple upstream edges into a single node.
The Runs workspace now shows project-scoped run history, run-level aggregated summaries, cancel/retry controls, and run detail views with persisted task summaries, stdout/stderr sections, result previews, and artifact links into Explore. The Runs workspace now shows project-scoped run history, run-level aggregated summaries, cancel/retry controls, and run detail views with persisted task summaries, stdout/stderr sections, result previews, and artifact links into Explore.
Selected run tasks now expose the frozen node definition id, executor config snapshot, and code-hook metadata that were captured when the run was created. Selected run tasks now expose the frozen node definition id, executor config snapshot, and code-hook metadata that were captured when the run was created.
When a node uses `executorType=docker` and provides `executorConfig.image`, the worker now runs a real local Docker container with mounted `input.json` / `output.json` exchange files. If no image is configured, the executor falls back to the lightweight simulated behavior used by older demo tasks. When a node uses `executorType=docker` and provides `executorConfig.image`, the worker now runs a real local Docker container with mounted `input.json` / `output.json` exchange files. If no image is configured, the executor falls back to the lightweight simulated behavior used by older demo tasks.

View File

@ -9,13 +9,20 @@ import {
type Edge, type Edge,
type Node, type Node,
type NodeChange, type NodeChange,
type ReactFlowInstance,
type Viewport, type Viewport,
} from "@xyflow/react"; } from "@xyflow/react";
import { ApiClient } from "./api-client.ts"; import { ApiClient } from "./api-client.ts";
import { localizeNodeDefinition, useI18n } from "./i18n.tsx"; import {
localizeNodeDefinition,
type TranslationKey,
useI18n,
} from "./i18n.tsx";
import { import {
addNodeToDraft, addNodeToDraft,
addNodeToDraftAtPosition,
canConnectNodesInDraft,
connectNodesInDraft, connectNodesInDraft,
createDefaultWorkflowDraft, createDefaultWorkflowDraft,
getNodeRuntimeConfig, getNodeRuntimeConfig,
@ -26,10 +33,36 @@ import {
setViewportInDraft, setViewportInDraft,
setNodeRuntimeConfig, setNodeRuntimeConfig,
workflowDraftFromVersion, workflowDraftFromVersion,
type WorkflowConnectionValidationReason,
type WorkflowDraft, type WorkflowDraft,
type WorkflowNodeRuntimeConfig, type WorkflowNodeRuntimeConfig,
} from "./workflow-editor-state.ts"; } from "./workflow-editor-state.ts";
const NODE_LIBRARY_MIME = "application/x-emboflow-node-definition";
function mapConnectionValidationReasonToKey(
reason: WorkflowConnectionValidationReason | "missing_connection_endpoint",
): TranslationKey {
switch (reason) {
case "missing_source":
case "missing_target":
case "missing_connection_endpoint":
return "invalidConnectionMissingEndpoint";
case "self":
return "invalidConnectionSelf";
case "duplicate":
return "invalidConnectionDuplicate";
case "source_disallows_outgoing":
return "invalidConnectionSourceDisallowsOutgoing";
case "target_disallows_incoming":
return "invalidConnectionTargetDisallowsIncoming";
case "target_already_has_incoming":
return "invalidConnectionTargetAlreadyHasIncoming";
case "cycle":
return "invalidConnectionCycle";
}
}
type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin"; type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin";
type BootstrapContext = { type BootstrapContext = {
@ -446,6 +479,9 @@ function WorkflowEditorPage(props: {
const [lastRunId, setLastRunId] = useState<string | null>(null); const [lastRunId, setLastRunId] = useState<string | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [canvasFeedbackKey, setCanvasFeedbackKey] = useState<TranslationKey | null>(null);
const [canvasDropActive, setCanvasDropActive] = useState(false);
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<Node, Edge> | null>(null);
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
@ -470,6 +506,7 @@ function WorkflowEditorPage(props: {
setDraft(nextDraft); setDraft(nextDraft);
setSelectedNodeId(nextDraft.logicGraph.nodes[0]?.id ?? "rename-folder"); setSelectedNodeId(nextDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
setDirty(false); setDirty(false);
setCanvasFeedbackKey(null);
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadWorkflow")); setError(loadError instanceof Error ? loadError.message : t("failedLoadWorkflow"));
} }
@ -480,6 +517,10 @@ function WorkflowEditorPage(props: {
() => nodes.map((node) => localizeNodeDefinition(language, node)), () => nodes.map((node) => localizeNodeDefinition(language, node)),
[language, nodes], [language, nodes],
); );
const nodeDefinitionsById = useMemo(
() => new Map(nodes.map((node) => [node.id, node])),
[nodes],
);
const selectedNodeDefinitionId = useMemo( const selectedNodeDefinitionId = useMemo(
() => resolveDefinitionIdForNode(draft, selectedNodeId), () => resolveDefinitionIdForNode(draft, selectedNodeId),
@ -571,8 +612,17 @@ function WorkflowEditorPage(props: {
}, []); }, []);
const onCanvasConnect = useCallback((connection: Connection) => { const onCanvasConnect = useCallback((connection: Connection) => {
setDraft((current) => connectNodesInDraft(current, connection.source, connection.target)); setDraft((current) => {
setDirty(true); const validation = canConnectNodesInDraft(current, connection.source, connection.target);
if (!validation.ok) {
setCanvasFeedbackKey(mapConnectionValidationReasonToKey(validation.reason));
return current;
}
setCanvasFeedbackKey(null);
setDirty(true);
return connectNodesInDraft(current, connection.source, connection.target);
});
}, []); }, []);
const onViewportChangeEnd = useCallback((viewport: Viewport) => { const onViewportChangeEnd = useCallback((viewport: Viewport) => {
@ -586,6 +636,50 @@ function WorkflowEditorPage(props: {
setDirty(true); setDirty(true);
}, []); }, []);
const handleNodeLibraryDragStart = useCallback((event: React.DragEvent<HTMLButtonElement>, nodeId: string) => {
event.dataTransfer.setData(NODE_LIBRARY_MIME, nodeId);
event.dataTransfer.effectAllowed = "copy";
}, []);
const handleCanvasDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes(NODE_LIBRARY_MIME)) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setCanvasDropActive(true);
}, []);
const handleCanvasDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const definitionId = event.dataTransfer.getData(NODE_LIBRARY_MIME);
if (!definitionId) {
setCanvasDropActive(false);
return;
}
event.preventDefault();
const definition = nodeDefinitionsById.get(definitionId);
if (!definition) {
setCanvasDropActive(false);
setCanvasFeedbackKey("invalidConnectionMissingEndpoint");
return;
}
const droppedAt = flowInstance
? flowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY })
: { x: event.clientX, y: event.clientY };
const result = addNodeToDraftAtPosition(draft, definition, droppedAt);
setDraft(result.draft);
setSelectedNodeId(result.nodeId);
setDirty(true);
setCanvasFeedbackKey(null);
setCanvasDropActive(false);
}, [draft, flowInstance, nodeDefinitionsById]);
const handleCanvasDragLeave = useCallback(() => {
setCanvasDropActive(false);
}, []);
function updateSelectedNodeRuntimeConfig( function updateSelectedNodeRuntimeConfig(
nextConfig: WorkflowNodeRuntimeConfig | ((current: WorkflowNodeRuntimeConfig) => WorkflowNodeRuntimeConfig), nextConfig: WorkflowNodeRuntimeConfig | ((current: WorkflowNodeRuntimeConfig) => WorkflowNodeRuntimeConfig),
) { ) {
@ -678,16 +772,21 @@ function WorkflowEditorPage(props: {
<section className="editor-layout"> <section className="editor-layout">
<aside className="panel"> <aside className="panel">
<h2>{t("nodeLibrary")}</h2> <h2>{t("nodeLibrary")}</h2>
<p className="empty-state">{t("nodeLibraryHint")}</p>
<div className="list-grid"> <div className="list-grid">
{localizedNodes.map((node) => ( {localizedNodes.map((node) => (
<button <button
key={node.id} key={node.id}
className="button-secondary" className="button-secondary workflow-node-library-item"
draggable
onDragStart={(event) => handleNodeLibraryDragStart(event, node.id)}
onClick={() => { onClick={() => {
const result = addNodeToDraft(draft, node); const rawNode = nodeDefinitionsById.get(node.id) ?? node;
const result = addNodeToDraft(draft, rawNode);
setDraft(result.draft); setDraft(result.draft);
setSelectedNodeId(result.nodeId); setSelectedNodeId(result.nodeId);
setDirty(true); setDirty(true);
setCanvasFeedbackKey(null);
}} }}
> >
{node.name} {node.name}
@ -700,13 +799,28 @@ function WorkflowEditorPage(props: {
<div className="toolbar"> <div className="toolbar">
<h2 style={{ margin: 0 }}>{t("canvas")}</h2> <h2 style={{ margin: 0 }}>{t("canvas")}</h2>
<span className="app-header__label">{t("canvasHint")}</span> <span className="app-header__label">{t("canvasHint")}</span>
{canvasFeedbackKey ? (
<span className="workflow-canvas-feedback" data-tone="danger">
{t(canvasFeedbackKey)}
</span>
) : null}
</div> </div>
<div className="workflow-canvas-shell"> <div
className="workflow-canvas-shell"
data-drop-active={String(canvasDropActive)}
onDragOver={handleCanvasDragOver}
onDrop={handleCanvasDrop}
onDragLeave={handleCanvasDragLeave}
>
{canvasDropActive ? (
<div className="workflow-canvas-drop-hint">{t("dragNodeToCanvas")}</div>
) : null}
<ReactFlow <ReactFlow
key={versions[0]?._id ?? workflow?._id ?? "workflow-canvas"} key={versions[0]?._id ?? workflow?._id ?? "workflow-canvas"}
nodes={canvasNodes} nodes={canvasNodes}
edges={canvasEdges} edges={canvasEdges}
defaultViewport={draft.visualGraph.viewport} defaultViewport={draft.visualGraph.viewport}
onInit={setFlowInstance}
onNodesChange={onCanvasNodesChange} onNodesChange={onCanvasNodesChange}
onConnect={onCanvasConnect} onConnect={onCanvasConnect}
onNodeClick={(_event, node) => setSelectedNodeId(node.id)} onNodeClick={(_event, node) => setSelectedNodeId(node.id)}

View File

@ -6,6 +6,14 @@ import { localizeNodeDefinition, translate } from "./i18n.tsx";
test("translate returns chinese and english labels for shared frontend keys", () => { test("translate returns chinese and english labels for shared frontend keys", () => {
assert.equal(translate("en", "navWorkflows"), "Workflows"); assert.equal(translate("en", "navWorkflows"), "Workflows");
assert.equal(translate("zh", "navWorkflows"), "工作流"); assert.equal(translate("zh", "navWorkflows"), "工作流");
assert.equal(
translate("en", "invalidConnectionCycle"),
"This edge would create a cycle.",
);
assert.equal(
translate("zh", "dragNodeToCanvas"),
"将节点拖放到这里即可在画布中创建。",
);
assert.equal( assert.equal(
translate("en", "workflowCreatedName", { count: 3 }), translate("en", "workflowCreatedName", { count: 3 }),
"Delivery Normalize 3", "Delivery Normalize 3",

View File

@ -2,7 +2,7 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from "
export type Language = "en" | "zh"; export type Language = "en" | "zh";
type TranslationKey = export type TranslationKey =
| "workspace" | "workspace"
| "project" | "project"
| "runs" | "runs"
@ -51,8 +51,10 @@ type TranslationKey =
| "openLatestRun" | "openLatestRun"
| "selectAssetBeforeRun" | "selectAssetBeforeRun"
| "nodeLibrary" | "nodeLibrary"
| "nodeLibraryHint"
| "canvas" | "canvas"
| "canvasHint" | "canvasHint"
| "dragNodeToCanvas"
| "latestSavedVersions" | "latestSavedVersions"
| "draftStatus" | "draftStatus"
| "draftSynced" | "draftSynced"
@ -136,7 +138,14 @@ type TranslationKey =
| "artifactsCount" | "artifactsCount"
| "viaExecutor" | "viaExecutor"
| "assetCount" | "assetCount"
| "artifactCount"; | "artifactCount"
| "invalidConnectionMissingEndpoint"
| "invalidConnectionSelf"
| "invalidConnectionDuplicate"
| "invalidConnectionSourceDisallowsOutgoing"
| "invalidConnectionTargetDisallowsIncoming"
| "invalidConnectionTargetAlreadyHasIncoming"
| "invalidConnectionCycle";
const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = { const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
en: { en: {
@ -189,8 +198,10 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
openLatestRun: "Open Latest Run", openLatestRun: "Open Latest Run",
selectAssetBeforeRun: "Select an asset before triggering a workflow run.", selectAssetBeforeRun: "Select an asset before triggering a workflow run.",
nodeLibrary: "Node Library", nodeLibrary: "Node Library",
nodeLibraryHint: "Click to append or drag a node onto the canvas.",
canvas: "Canvas", canvas: "Canvas",
canvasHint: "Drag nodes freely, connect handles, zoom, and pan.", canvasHint: "Drag nodes freely, connect handles, zoom, and pan.",
dragNodeToCanvas: "Drop the node here to place it on the canvas.",
latestSavedVersions: "Latest saved versions", latestSavedVersions: "Latest saved versions",
draftStatus: "Draft status", draftStatus: "Draft status",
draftSynced: "synced", draftSynced: "synced",
@ -275,6 +286,13 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
viaExecutor: "{outcome} via {executor}", viaExecutor: "{outcome} via {executor}",
assetCount: "assets {count}", assetCount: "assets {count}",
artifactCount: "artifacts {count}", artifactCount: "artifacts {count}",
invalidConnectionMissingEndpoint: "The connection is missing a valid source or target node.",
invalidConnectionSelf: "A node cannot connect to itself.",
invalidConnectionDuplicate: "This edge already exists.",
invalidConnectionSourceDisallowsOutgoing: "Export nodes cannot create outgoing connections in V1.",
invalidConnectionTargetDisallowsIncoming: "Source nodes cannot accept incoming connections.",
invalidConnectionTargetAlreadyHasIncoming: "This node already has an upstream connection in V1.",
invalidConnectionCycle: "This edge would create a cycle.",
}, },
zh: { zh: {
workspace: "工作空间", workspace: "工作空间",
@ -325,8 +343,10 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
openLatestRun: "打开最新运行", openLatestRun: "打开最新运行",
selectAssetBeforeRun: "触发工作流运行前请先选择资产。", selectAssetBeforeRun: "触发工作流运行前请先选择资产。",
nodeLibrary: "节点面板", nodeLibrary: "节点面板",
nodeLibraryHint: "支持点击追加,也支持将节点拖入画布指定位置。",
canvas: "画布", canvas: "画布",
canvasHint: "支持自由拖动节点、拖拽连线、缩放和平移。", canvasHint: "支持自由拖动节点、拖拽连线、缩放和平移。",
dragNodeToCanvas: "将节点拖放到这里即可在画布中创建。",
latestSavedVersions: "最近保存版本", latestSavedVersions: "最近保存版本",
draftStatus: "草稿状态", draftStatus: "草稿状态",
draftSynced: "已同步", draftSynced: "已同步",
@ -411,6 +431,13 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
viaExecutor: "{outcome},执行器 {executor}", viaExecutor: "{outcome},执行器 {executor}",
assetCount: "资产 {count}", assetCount: "资产 {count}",
artifactCount: "产物 {count}", artifactCount: "产物 {count}",
invalidConnectionMissingEndpoint: "该连线缺少有效的起点或终点节点。",
invalidConnectionSelf: "节点不能连接自己。",
invalidConnectionDuplicate: "这条连线已经存在。",
invalidConnectionSourceDisallowsOutgoing: "V1 中导出节点不允许继续向外连线。",
invalidConnectionTargetDisallowsIncoming: "数据源节点不允许接收入边。",
invalidConnectionTargetAlreadyHasIncoming: "V1 中该节点只能保留一条上游入边。",
invalidConnectionCycle: "这条连线会形成环路。",
}, },
}; };

View File

@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import { import {
addNodeToDraft, addNodeToDraft,
addNodeToDraftAtPosition,
canConnectNodesInDraft,
connectNodesInDraft, connectNodesInDraft,
createDefaultWorkflowDraft, createDefaultWorkflowDraft,
getNodeRuntimeConfig, getNodeRuntimeConfig,
@ -54,6 +56,29 @@ test("add node appends a unique node id and a sequential edge by default", () =>
}); });
}); });
test("add node at explicit canvas position without auto-connecting it", () => {
const base = createDefaultWorkflowDraft();
const result = addNodeToDraftAtPosition(
base,
{
id: "export-delivery-package",
name: "Export Delivery Package",
category: "Export",
},
{ x: 888, y: 432 },
);
assert.equal(result.nodeId, "export-delivery-package-1");
assert.deepEqual(result.draft.visualGraph.nodePositions["export-delivery-package-1"], {
x: 888,
y: 432,
});
assert.equal(
result.draft.logicGraph.edges.some((edge) => edge.to === "export-delivery-package-1"),
false,
);
});
test("remove node prunes attached edges and serialize emits workflow version payload", () => { test("remove node prunes attached edges and serialize emits workflow version payload", () => {
const draft = workflowDraftFromVersion({ const draft = workflowDraftFromVersion({
visualGraph: { viewport: { x: 0, y: 0, zoom: 1 } }, visualGraph: { viewport: { x: 0, y: 0, zoom: 1 } },
@ -113,16 +138,121 @@ test("set per-node runtime config and keep it in the serialized workflow payload
}); });
test("update node positions, connect nodes, and persist viewport in the workflow draft", () => { test("update node positions, connect nodes, and persist viewport in the workflow draft", () => {
const draft = createDefaultWorkflowDraft(); const draft = addNodeToDraftAtPosition(
createDefaultWorkflowDraft(),
{
id: "export-delivery-package",
name: "Export Delivery Package",
category: "Export",
},
{ x: 920, y: 520 },
).draft;
const moved = setNodePosition(draft, "rename-folder", { x: 520, y: 240 }); const moved = setNodePosition(draft, "rename-folder", { x: 520, y: 240 });
const connected = connectNodesInDraft(moved, "source-asset", "validate-structure"); const connected = connectNodesInDraft(moved, "validate-structure", "export-delivery-package-1");
const next = setViewportInDraft(connected, { x: -120, y: 45, zoom: 1.35 }); const next = setViewportInDraft(connected, { x: -120, y: 45, zoom: 1.35 });
const payload = serializeWorkflowDraft(next); const payload = serializeWorkflowDraft(next);
assert.deepEqual(payload.visualGraph.nodePositions["rename-folder"], { x: 520, y: 240 }); assert.deepEqual(payload.visualGraph.nodePositions["rename-folder"], { x: 520, y: 240 });
assert.deepEqual(payload.visualGraph.viewport, { x: -120, y: 45, zoom: 1.35 }); assert.deepEqual(payload.visualGraph.viewport, { x: -120, y: 45, zoom: 1.35 });
assert.deepEqual(payload.logicGraph.edges.at(-1), { assert.deepEqual(payload.logicGraph.edges.at(-1), {
from: "source-asset", from: "validate-structure",
to: "validate-structure", to: "export-delivery-package-1",
}); });
}); });
test("reject invalid connections for self edges, duplicate edges, cycles, and multiple inbound edges", () => {
const draft = createDefaultWorkflowDraft();
assert.equal(canConnectNodesInDraft(draft, "source-asset", "source-asset").ok, false);
assert.equal(
canConnectNodesInDraft(draft, "source-asset", "rename-folder").reason,
"duplicate",
);
assert.equal(
canConnectNodesInDraft(draft, "validate-structure", "source-asset").reason,
"target_disallows_incoming",
);
assert.equal(
canConnectNodesInDraft(draft, "validate-structure", "rename-folder").reason,
"target_already_has_incoming",
);
assert.equal(
canConnectNodesInDraft(draft, "rename-folder", "source-asset").reason,
"target_disallows_incoming",
);
});
test("reject connections that would create a cycle or start from an export node", () => {
const draft = createDefaultWorkflowDraft();
const withExport = addNodeToDraftAtPosition(
draft,
{
id: "export-delivery-package",
name: "Export Delivery Package",
category: "Export",
},
{ x: 900, y: 500 },
).draft;
const connected = connectNodesInDraft(withExport, "validate-structure", "export-delivery-package-1");
assert.equal(
canConnectNodesInDraft(connected, "export-delivery-package-1", "rename-folder").reason,
"source_disallows_outgoing",
);
assert.equal(
canConnectNodesInDraft(connected, "validate-structure", "source-asset").reason,
"target_disallows_incoming",
);
assert.equal(
canConnectNodesInDraft(connected, "rename-folder", "source-asset").reason,
"target_disallows_incoming",
);
assert.equal(
canConnectNodesInDraft(connected, "export-delivery-package-1", "validate-structure").reason,
"source_disallows_outgoing",
);
assert.equal(
canConnectNodesInDraft(connected, "validate-structure", "source-asset").ok,
false,
);
assert.equal(
canConnectNodesInDraft(connected, "validate-structure", "rename-folder").reason,
"target_already_has_incoming",
);
assert.equal(
canConnectNodesInDraft(connected, "rename-folder", "source-asset").ok,
false,
);
assert.equal(
canConnectNodesInDraft(connected, "source-asset", "validate-structure").reason,
"target_already_has_incoming",
);
});
test("reject connections that would form a back edge cycle when target accepts inbound edges", () => {
const draft = workflowDraftFromVersion({
logicGraph: {
nodes: [
{ id: "source-asset", type: "source" },
{ id: "rename-folder", type: "transform" },
{ id: "validate-structure", type: "inspect" },
{ id: "validate-metadata-1", type: "inspect" },
],
edges: [
{ from: "source-asset", to: "rename-folder" },
{ from: "rename-folder", to: "validate-structure" },
{ from: "validate-structure", to: "validate-metadata-1" },
],
},
});
assert.equal(
canConnectNodesInDraft(draft, "validate-metadata-1", "rename-folder").reason,
"target_already_has_incoming",
);
assert.equal(
canConnectNodesInDraft(removeNodeFromDraft(draft, "source-asset"), "validate-metadata-1", "rename-folder")
.reason,
"cycle",
);
});

View File

@ -59,6 +59,20 @@ export type WorkflowDraft = {
type WorkflowVersionLike = Partial<WorkflowDraft>; type WorkflowVersionLike = Partial<WorkflowDraft>;
export type WorkflowConnectionValidationReason =
| "missing_source"
| "missing_target"
| "self"
| "duplicate"
| "source_disallows_outgoing"
| "target_disallows_incoming"
| "target_already_has_incoming"
| "cycle";
export type WorkflowConnectionValidationResult =
| { ok: true }
| { ok: false; reason: WorkflowConnectionValidationReason };
const DEFAULT_VIEWPORT: WorkflowViewport = { x: 0, y: 0, zoom: 1 }; const DEFAULT_VIEWPORT: WorkflowViewport = { x: 0, y: 0, zoom: 1 };
const DEFAULT_NODE_LAYOUT: Record<string, WorkflowPoint> = { const DEFAULT_NODE_LAYOUT: Record<string, WorkflowPoint> = {
"source-asset": { x: 120, y: 120 }, "source-asset": { x: 120, y: 120 },
@ -221,6 +235,30 @@ export function workflowDraftFromVersion(version?: WorkflowVersionLike | null):
export function addNodeToDraft( export function addNodeToDraft(
draft: WorkflowDraft, draft: WorkflowDraft,
definition: WorkflowNodeDefinitionSummary, definition: WorkflowNodeDefinitionSummary,
): { draft: WorkflowDraft; nodeId: string } {
return addNodeToDraftInternal(draft, definition, {
connectFromPrevious: true,
});
}
export function addNodeToDraftAtPosition(
draft: WorkflowDraft,
definition: WorkflowNodeDefinitionSummary,
position: WorkflowPoint,
): { draft: WorkflowDraft; nodeId: string } {
return addNodeToDraftInternal(draft, definition, {
connectFromPrevious: false,
position,
});
}
function addNodeToDraftInternal(
draft: WorkflowDraft,
definition: WorkflowNodeDefinitionSummary,
options: {
connectFromPrevious: boolean;
position?: WorkflowPoint;
},
): { draft: WorkflowDraft; nodeId: string } { ): { draft: WorkflowDraft; nodeId: string } {
const next = cloneDraft(draft); const next = cloneDraft(draft);
let suffix = 1; let suffix = 1;
@ -237,13 +275,14 @@ export function addNodeToDraft(
}; };
const previousNode = next.logicGraph.nodes.at(-1); const previousNode = next.logicGraph.nodes.at(-1);
next.logicGraph.nodes.push(node); next.logicGraph.nodes.push(node);
if (previousNode) { if (options.connectFromPrevious && previousNode) {
next.logicGraph.edges.push({ from: previousNode.id, to: nodeId }); next.logicGraph.edges.push({ from: previousNode.id, to: nodeId });
} }
next.runtimeGraph.nodeBindings ??= {}; next.runtimeGraph.nodeBindings ??= {};
next.runtimeGraph.nodeBindings[nodeId] = definition.id; next.runtimeGraph.nodeBindings[nodeId] = definition.id;
next.runtimeGraph.nodeConfigs ??= {}; next.runtimeGraph.nodeConfigs ??= {};
next.visualGraph.nodePositions[nodeId] = createDefaultNodePosition(next.logicGraph.nodes.length - 1); next.visualGraph.nodePositions[nodeId] =
options.position ?? createDefaultNodePosition(next.logicGraph.nodes.length - 1);
return { draft: next, nodeId }; return { draft: next, nodeId };
} }
@ -293,21 +332,100 @@ export function setNodePosition(draft: WorkflowDraft, nodeId: string, position:
return next; return next;
} }
function findNode(draft: WorkflowDraft, nodeId: string) {
return draft.logicGraph.nodes.find((node) => node.id === nodeId) ?? null;
}
function nodeDisallowsOutgoing(node: WorkflowLogicNode) {
return node.type === "export";
}
function nodeDisallowsIncoming(node: WorkflowLogicNode) {
return node.type === "source";
}
function wouldCreateCycle(draft: WorkflowDraft, sourceNodeId: string, targetNodeId: string) {
const adjacency = new Map<string, string[]>();
for (const edge of draft.logicGraph.edges) {
const current = adjacency.get(edge.from) ?? [];
current.push(edge.to);
adjacency.set(edge.from, current);
}
const stack = [targetNodeId];
const visited = new Set<string>();
while (stack.length > 0) {
const currentNodeId = stack.pop();
if (!currentNodeId || visited.has(currentNodeId)) {
continue;
}
if (currentNodeId === sourceNodeId) {
return true;
}
visited.add(currentNodeId);
for (const nextNodeId of adjacency.get(currentNodeId) ?? []) {
stack.push(nextNodeId);
}
}
return false;
}
export function canConnectNodesInDraft(
draft: WorkflowDraft,
sourceNodeId: string | null | undefined,
targetNodeId: string | null | undefined,
): WorkflowConnectionValidationResult {
if (!sourceNodeId) {
return { ok: false, reason: "missing_source" };
}
if (!targetNodeId) {
return { ok: false, reason: "missing_target" };
}
if (sourceNodeId === targetNodeId) {
return { ok: false, reason: "self" };
}
const sourceNode = findNode(draft, sourceNodeId);
if (!sourceNode) {
return { ok: false, reason: "missing_source" };
}
const targetNode = findNode(draft, targetNodeId);
if (!targetNode) {
return { ok: false, reason: "missing_target" };
}
if (draft.logicGraph.edges.some((edge) => edge.from === sourceNodeId && edge.to === targetNodeId)) {
return { ok: false, reason: "duplicate" };
}
if (nodeDisallowsOutgoing(sourceNode)) {
return { ok: false, reason: "source_disallows_outgoing" };
}
if (nodeDisallowsIncoming(targetNode)) {
return { ok: false, reason: "target_disallows_incoming" };
}
if (draft.logicGraph.edges.some((edge) => edge.to === targetNodeId)) {
return { ok: false, reason: "target_already_has_incoming" };
}
if (wouldCreateCycle(draft, sourceNodeId, targetNodeId)) {
return { ok: false, reason: "cycle" };
}
return { ok: true };
}
export function connectNodesInDraft( export function connectNodesInDraft(
draft: WorkflowDraft, draft: WorkflowDraft,
sourceNodeId: string | null | undefined, sourceNodeId: string | null | undefined,
targetNodeId: string | null | undefined, targetNodeId: string | null | undefined,
): WorkflowDraft { ): WorkflowDraft {
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) { const validation = canConnectNodesInDraft(draft, sourceNodeId, targetNodeId);
if (!validation.ok || !sourceNodeId || !targetNodeId) {
return draft; return draft;
} }
const next = cloneDraft(draft); const next = cloneDraft(draft);
const exists = next.logicGraph.edges.some( next.logicGraph.edges.push({ from: sourceNodeId, to: targetNodeId });
(edge) => edge.from === sourceNodeId && edge.to === targetNodeId,
);
if (!exists) {
next.logicGraph.edges.push({ from: sourceNodeId, to: targetNodeId });
}
return next; return next;
} }

View File

@ -233,6 +233,7 @@ textarea {
} }
.workflow-canvas-shell { .workflow-canvas-shell {
position: relative;
height: 680px; height: 680px;
margin-top: 12px; margin-top: 12px;
border: 1px solid #d4d4d8; border: 1px solid #d4d4d8;
@ -243,6 +244,26 @@ textarea {
linear-gradient(180deg, #ffffff 0%, #f4f6fb 100%); linear-gradient(180deg, #ffffff 0%, #f4f6fb 100%);
} }
.workflow-canvas-shell[data-drop-active="true"] {
border-color: #0284c7;
box-shadow: inset 0 0 0 2px rgba(2, 132, 199, 0.15);
}
.workflow-canvas-drop-hint {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
border-radius: 999px;
padding: 8px 14px;
background: rgba(2, 132, 199, 0.92);
color: #f8fafc;
font-size: 13px;
font-weight: 600;
pointer-events: none;
}
.workflow-canvas-footer { .workflow-canvas-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -252,6 +273,25 @@ textarea {
font-size: 13px; font-size: 13px;
} }
.workflow-canvas-feedback {
margin-left: auto;
padding: 6px 10px;
border-radius: 999px;
background: #fee2e2;
color: #991b1b;
font-size: 12px;
}
.workflow-node-library-item {
width: 100%;
text-align: left;
cursor: grab;
}
.workflow-node-library-item:active {
cursor: grabbing;
}
.workflow-flow-node { .workflow-flow-node {
min-width: 220px; min-width: 220px;
padding: 0; padding: 0;

View File

@ -152,12 +152,23 @@ The current V1 implementation is simpler than the target canvas UX, but it alrea
The current runtime implementation now also renders the center surface as a real node canvas instead of a static placeholder list: The current runtime implementation now also renders the center surface as a real node canvas instead of a static placeholder list:
- free node dragging on the canvas - free node dragging on the canvas
- left-panel node drag-and-drop into the canvas, in addition to click-to-append
- drag-to-connect edges between node handles - drag-to-connect edges between node handles
- zoom and pan - zoom and pan
- dotted background grid - dotted background grid
- mini-map - mini-map
- canvas controls - canvas controls
- persisted node positions and viewport in `visualGraph` - persisted node positions and viewport in `visualGraph`
- localized inline validation feedback when a connection is rejected
The current V1 authoring rules intentionally keep the graph model constrained so the workflow stays legible and executable:
- source nodes do not accept inbound edges
- export nodes do not emit outbound edges
- duplicate edges are blocked
- self-edges are blocked
- a node may only keep one inbound edge
- cycles are blocked
The runtime header also now exposes a visible `中文 / English` language toggle and the main shell plus workflow authoring surface are translated through a lightweight i18n layer. The runtime header also now exposes a visible `中文 / English` language toggle and the main shell plus workflow authoring surface are translated through a lightweight i18n layer.

View File

@ -27,6 +27,7 @@
- `2026-03-27`: The current Docker-runtime pass upgrades `executorType=docker` from a pure stub to a real local container execution path whenever `executorConfig.image` is provided, while retaining a compatibility fallback for older demo tasks without an image. - `2026-03-27`: The current Docker-runtime pass upgrades `executorType=docker` from a pure stub to a real local container execution path whenever `executorConfig.image` is provided, while retaining a compatibility fallback for older demo tasks without an image.
- `2026-03-27`: The current built-in-node pass enriches the worker execution context with bound asset metadata and gives the default Python implementations for `source-asset` and `validate-structure` real delivery-oriented behavior instead of placeholder output. - `2026-03-27`: The current built-in-node pass enriches the worker execution context with bound asset metadata and gives the default Python implementations for `source-asset` and `validate-structure` real delivery-oriented behavior instead of placeholder output.
- `2026-03-27`: The current web-authoring pass adds a visible zh/en language switcher, a lightweight i18n layer for the runtime shell, and a real React Flow canvas with persisted node positions and viewport instead of the earlier static node list. - `2026-03-27`: The current web-authoring pass adds a visible zh/en language switcher, a lightweight i18n layer for the runtime shell, and a real React Flow canvas with persisted node positions and viewport instead of the earlier static node list.
- `2026-03-27`: The follow-up canvas pass adds left-panel drag-and-drop node placement, localized canvas feedback, and V1 connection guards for self-edges, duplicates, cycles, invalid source/export directions, and multiple inbound edges.
--- ---