✨ 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 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.
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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: "这条连线会形成环路。",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user