✨ add canvas node drag drop and connection guards
This commit is contained in:
parent
8ff26b3816
commit
6ee54c8399
@ -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 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 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.
|
||||
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.
|
||||
|
||||
@ -9,13 +9,20 @@ import {
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
type ReactFlowInstance,
|
||||
type Viewport,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import { ApiClient } from "./api-client.ts";
|
||||
import { localizeNodeDefinition, useI18n } from "./i18n.tsx";
|
||||
import {
|
||||
localizeNodeDefinition,
|
||||
type TranslationKey,
|
||||
useI18n,
|
||||
} from "./i18n.tsx";
|
||||
import {
|
||||
addNodeToDraft,
|
||||
addNodeToDraftAtPosition,
|
||||
canConnectNodesInDraft,
|
||||
connectNodesInDraft,
|
||||
createDefaultWorkflowDraft,
|
||||
getNodeRuntimeConfig,
|
||||
@ -26,10 +33,36 @@ import {
|
||||
setViewportInDraft,
|
||||
setNodeRuntimeConfig,
|
||||
workflowDraftFromVersion,
|
||||
type WorkflowConnectionValidationReason,
|
||||
type WorkflowDraft,
|
||||
type WorkflowNodeRuntimeConfig,
|
||||
} 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 BootstrapContext = {
|
||||
@ -446,6 +479,9 @@ function WorkflowEditorPage(props: {
|
||||
const [lastRunId, setLastRunId] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
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(() => {
|
||||
void (async () => {
|
||||
@ -470,6 +506,7 @@ function WorkflowEditorPage(props: {
|
||||
setDraft(nextDraft);
|
||||
setSelectedNodeId(nextDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
|
||||
setDirty(false);
|
||||
setCanvasFeedbackKey(null);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : t("failedLoadWorkflow"));
|
||||
}
|
||||
@ -480,6 +517,10 @@ function WorkflowEditorPage(props: {
|
||||
() => nodes.map((node) => localizeNodeDefinition(language, node)),
|
||||
[language, nodes],
|
||||
);
|
||||
const nodeDefinitionsById = useMemo(
|
||||
() => new Map(nodes.map((node) => [node.id, node])),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const selectedNodeDefinitionId = useMemo(
|
||||
() => resolveDefinitionIdForNode(draft, selectedNodeId),
|
||||
@ -571,8 +612,17 @@ function WorkflowEditorPage(props: {
|
||||
}, []);
|
||||
|
||||
const onCanvasConnect = useCallback((connection: Connection) => {
|
||||
setDraft((current) => connectNodesInDraft(current, connection.source, connection.target));
|
||||
setDirty(true);
|
||||
setDraft((current) => {
|
||||
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) => {
|
||||
@ -586,6 +636,50 @@ function WorkflowEditorPage(props: {
|
||||
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(
|
||||
nextConfig: WorkflowNodeRuntimeConfig | ((current: WorkflowNodeRuntimeConfig) => WorkflowNodeRuntimeConfig),
|
||||
) {
|
||||
@ -678,16 +772,21 @@ function WorkflowEditorPage(props: {
|
||||
<section className="editor-layout">
|
||||
<aside className="panel">
|
||||
<h2>{t("nodeLibrary")}</h2>
|
||||
<p className="empty-state">{t("nodeLibraryHint")}</p>
|
||||
<div className="list-grid">
|
||||
{localizedNodes.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
className="button-secondary"
|
||||
className="button-secondary workflow-node-library-item"
|
||||
draggable
|
||||
onDragStart={(event) => handleNodeLibraryDragStart(event, node.id)}
|
||||
onClick={() => {
|
||||
const result = addNodeToDraft(draft, node);
|
||||
const rawNode = nodeDefinitionsById.get(node.id) ?? node;
|
||||
const result = addNodeToDraft(draft, rawNode);
|
||||
setDraft(result.draft);
|
||||
setSelectedNodeId(result.nodeId);
|
||||
setDirty(true);
|
||||
setCanvasFeedbackKey(null);
|
||||
}}
|
||||
>
|
||||
{node.name}
|
||||
@ -700,13 +799,28 @@ function WorkflowEditorPage(props: {
|
||||
<div className="toolbar">
|
||||
<h2 style={{ margin: 0 }}>{t("canvas")}</h2>
|
||||
<span className="app-header__label">{t("canvasHint")}</span>
|
||||
{canvasFeedbackKey ? (
|
||||
<span className="workflow-canvas-feedback" data-tone="danger">
|
||||
{t(canvasFeedbackKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</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
|
||||
key={versions[0]?._id ?? workflow?._id ?? "workflow-canvas"}
|
||||
nodes={canvasNodes}
|
||||
edges={canvasEdges}
|
||||
defaultViewport={draft.visualGraph.viewport}
|
||||
onInit={setFlowInstance}
|
||||
onNodesChange={onCanvasNodesChange}
|
||||
onConnect={onCanvasConnect}
|
||||
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
|
||||
|
||||
@ -6,6 +6,14 @@ import { localizeNodeDefinition, translate } from "./i18n.tsx";
|
||||
test("translate returns chinese and english labels for shared frontend keys", () => {
|
||||
assert.equal(translate("en", "navWorkflows"), "Workflows");
|
||||
assert.equal(translate("zh", "navWorkflows"), "工作流");
|
||||
assert.equal(
|
||||
translate("en", "invalidConnectionCycle"),
|
||||
"This edge would create a cycle.",
|
||||
);
|
||||
assert.equal(
|
||||
translate("zh", "dragNodeToCanvas"),
|
||||
"将节点拖放到这里即可在画布中创建。",
|
||||
);
|
||||
assert.equal(
|
||||
translate("en", "workflowCreatedName", { count: 3 }),
|
||||
"Delivery Normalize 3",
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from "
|
||||
|
||||
export type Language = "en" | "zh";
|
||||
|
||||
type TranslationKey =
|
||||
export type TranslationKey =
|
||||
| "workspace"
|
||||
| "project"
|
||||
| "runs"
|
||||
@ -51,8 +51,10 @@ type TranslationKey =
|
||||
| "openLatestRun"
|
||||
| "selectAssetBeforeRun"
|
||||
| "nodeLibrary"
|
||||
| "nodeLibraryHint"
|
||||
| "canvas"
|
||||
| "canvasHint"
|
||||
| "dragNodeToCanvas"
|
||||
| "latestSavedVersions"
|
||||
| "draftStatus"
|
||||
| "draftSynced"
|
||||
@ -136,7 +138,14 @@ type TranslationKey =
|
||||
| "artifactsCount"
|
||||
| "viaExecutor"
|
||||
| "assetCount"
|
||||
| "artifactCount";
|
||||
| "artifactCount"
|
||||
| "invalidConnectionMissingEndpoint"
|
||||
| "invalidConnectionSelf"
|
||||
| "invalidConnectionDuplicate"
|
||||
| "invalidConnectionSourceDisallowsOutgoing"
|
||||
| "invalidConnectionTargetDisallowsIncoming"
|
||||
| "invalidConnectionTargetAlreadyHasIncoming"
|
||||
| "invalidConnectionCycle";
|
||||
|
||||
const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||
en: {
|
||||
@ -189,8 +198,10 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||
openLatestRun: "Open Latest Run",
|
||||
selectAssetBeforeRun: "Select an asset before triggering a workflow run.",
|
||||
nodeLibrary: "Node Library",
|
||||
nodeLibraryHint: "Click to append or drag a node onto the canvas.",
|
||||
canvas: "Canvas",
|
||||
canvasHint: "Drag nodes freely, connect handles, zoom, and pan.",
|
||||
dragNodeToCanvas: "Drop the node here to place it on the canvas.",
|
||||
latestSavedVersions: "Latest saved versions",
|
||||
draftStatus: "Draft status",
|
||||
draftSynced: "synced",
|
||||
@ -275,6 +286,13 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||
viaExecutor: "{outcome} via {executor}",
|
||||
assetCount: "assets {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: {
|
||||
workspace: "工作空间",
|
||||
@ -325,8 +343,10 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||
openLatestRun: "打开最新运行",
|
||||
selectAssetBeforeRun: "触发工作流运行前请先选择资产。",
|
||||
nodeLibrary: "节点面板",
|
||||
nodeLibraryHint: "支持点击追加,也支持将节点拖入画布指定位置。",
|
||||
canvas: "画布",
|
||||
canvasHint: "支持自由拖动节点、拖拽连线、缩放和平移。",
|
||||
dragNodeToCanvas: "将节点拖放到这里即可在画布中创建。",
|
||||
latestSavedVersions: "最近保存版本",
|
||||
draftStatus: "草稿状态",
|
||||
draftSynced: "已同步",
|
||||
@ -411,6 +431,13 @@ const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
|
||||
viaExecutor: "{outcome},执行器 {executor}",
|
||||
assetCount: "资产 {count}",
|
||||
artifactCount: "产物 {count}",
|
||||
invalidConnectionMissingEndpoint: "该连线缺少有效的起点或终点节点。",
|
||||
invalidConnectionSelf: "节点不能连接自己。",
|
||||
invalidConnectionDuplicate: "这条连线已经存在。",
|
||||
invalidConnectionSourceDisallowsOutgoing: "V1 中导出节点不允许继续向外连线。",
|
||||
invalidConnectionTargetDisallowsIncoming: "数据源节点不允许接收入边。",
|
||||
invalidConnectionTargetAlreadyHasIncoming: "V1 中该节点只能保留一条上游入边。",
|
||||
invalidConnectionCycle: "这条连线会形成环路。",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
addNodeToDraft,
|
||||
addNodeToDraftAtPosition,
|
||||
canConnectNodesInDraft,
|
||||
connectNodesInDraft,
|
||||
createDefaultWorkflowDraft,
|
||||
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", () => {
|
||||
const draft = workflowDraftFromVersion({
|
||||
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", () => {
|
||||
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 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 payload = serializeWorkflowDraft(next);
|
||||
|
||||
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.logicGraph.edges.at(-1), {
|
||||
from: "source-asset",
|
||||
to: "validate-structure",
|
||||
from: "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",
|
||||
);
|
||||
});
|
||||
|
||||
@ -59,6 +59,20 @@ export type 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_NODE_LAYOUT: Record<string, WorkflowPoint> = {
|
||||
"source-asset": { x: 120, y: 120 },
|
||||
@ -221,6 +235,30 @@ export function workflowDraftFromVersion(version?: WorkflowVersionLike | null):
|
||||
export function addNodeToDraft(
|
||||
draft: WorkflowDraft,
|
||||
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 } {
|
||||
const next = cloneDraft(draft);
|
||||
let suffix = 1;
|
||||
@ -237,13 +275,14 @@ export function addNodeToDraft(
|
||||
};
|
||||
const previousNode = next.logicGraph.nodes.at(-1);
|
||||
next.logicGraph.nodes.push(node);
|
||||
if (previousNode) {
|
||||
if (options.connectFromPrevious && previousNode) {
|
||||
next.logicGraph.edges.push({ from: previousNode.id, to: nodeId });
|
||||
}
|
||||
next.runtimeGraph.nodeBindings ??= {};
|
||||
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
|
||||
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 };
|
||||
}
|
||||
@ -293,21 +332,100 @@ export function setNodePosition(draft: WorkflowDraft, nodeId: string, position:
|
||||
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(
|
||||
draft: WorkflowDraft,
|
||||
sourceNodeId: string | null | undefined,
|
||||
targetNodeId: string | null | undefined,
|
||||
): WorkflowDraft {
|
||||
if (!sourceNodeId || !targetNodeId || sourceNodeId === targetNodeId) {
|
||||
const validation = canConnectNodesInDraft(draft, sourceNodeId, targetNodeId);
|
||||
if (!validation.ok || !sourceNodeId || !targetNodeId) {
|
||||
return draft;
|
||||
}
|
||||
const next = cloneDraft(draft);
|
||||
const exists = next.logicGraph.edges.some(
|
||||
(edge) => edge.from === sourceNodeId && edge.to === targetNodeId,
|
||||
);
|
||||
if (!exists) {
|
||||
next.logicGraph.edges.push({ from: sourceNodeId, to: targetNodeId });
|
||||
}
|
||||
next.logicGraph.edges.push({ from: sourceNodeId, to: targetNodeId });
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@ -233,6 +233,7 @@ textarea {
|
||||
}
|
||||
|
||||
.workflow-canvas-shell {
|
||||
position: relative;
|
||||
height: 680px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #d4d4d8;
|
||||
@ -243,6 +244,26 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -252,6 +273,25 @@ textarea {
|
||||
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 {
|
||||
min-width: 220px;
|
||||
padding: 0;
|
||||
|
||||
@ -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:
|
||||
|
||||
- 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
|
||||
- zoom and pan
|
||||
- dotted background grid
|
||||
- mini-map
|
||||
- canvas controls
|
||||
- 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.
|
||||
|
||||
|
||||
@ -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 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 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user