feat: add i18n and draggable workflow canvas

This commit is contained in:
eust-w 2026-03-27 11:48:00 +08:00
parent 1cf1f45690
commit 8ff26b3816
15 changed files with 1376 additions and 192 deletions

View File

@ -9,7 +9,7 @@ bootstrap:
test:
python3 -m unittest discover -s tests -p 'test_*.py'
pnpm --filter api test
pnpm --filter web test src/features/assets/assets-page.test.tsx src/features/workflows/workflow-editor-page.test.tsx src/features/explore/explore-page.test.tsx
pnpm --filter web test src/features/assets/assets-page.test.tsx src/features/workflows/workflow-editor-page.test.tsx src/features/explore/explore-page.test.tsx src/runtime/workflow-editor-state.test.ts src/runtime/i18n.test.ts
pnpm --filter web build
pnpm --filter worker test

View File

@ -67,6 +67,8 @@ The local validation path currently used for embodied data testing is:
You can register that directory from the Assets page or via `POST /api/assets/register`.
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 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.

View File

@ -11,6 +11,7 @@
"test": "tsx --test"
},
"dependencies": {
"@xyflow/react": "^12.8.4",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},

View File

@ -2,6 +2,8 @@ import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./runtime/app.tsx";
import { I18nProvider } from "./runtime/i18n.tsx";
import "@xyflow/react/dist/style.css";
import "./styles.css";
export function createWebApp() {
@ -21,7 +23,9 @@ export function mountWebApp() {
: "http://127.0.0.1:3001";
createRoot(target).render(
<React.StrictMode>
<I18nProvider>
<App apiBaseUrl={apiBaseUrl} />
</I18nProvider>
</React.StrictMode>,
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
import test from "node:test";
import assert from "node:assert/strict";
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", "workflowCreatedName", { count: 3 }),
"Delivery Normalize 3",
);
assert.equal(
translate("zh", "workflowCreatedName", { count: 3 }),
"交付标准化 3",
);
});
test("localize built-in node definitions into chinese labels", () => {
const localized = localizeNodeDefinition("zh", {
id: "validate-structure",
name: "Validate Structure",
description: "Validate required directories and metadata files.",
category: "Inspect",
});
assert.equal(localized.name, "校验目录结构");
assert.equal(localized.category, "检查");
assert.match(localized.description ?? "", /元数据/);
});

View File

@ -0,0 +1,534 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
export type Language = "en" | "zh";
type TranslationKey =
| "workspace"
| "project"
| "runs"
| "localDev"
| "navAssets"
| "navWorkflows"
| "navRuns"
| "navExplore"
| "navLabels"
| "navAdmin"
| "language"
| "english"
| "chinese"
| "assetsTitle"
| "assetsDescription"
| "localPath"
| "registerLocalPath"
| "noAssetsYet"
| "type"
| "source"
| "detected"
| "pending"
| "topLevelEntries"
| "notAvailable"
| "loadingAssetDetail"
| "assetId"
| "status"
| "sourcePath"
| "probeAgain"
| "createExploreArtifact"
| "openExploreView"
| "probeSummary"
| "warnings"
| "none"
| "recommendedNodes"
| "noProbeReportYet"
| "workflowsTitle"
| "createWorkflow"
| "noWorkflowsYet"
| "latestVersion"
| "workflowEditor"
| "runAsset"
| "saveWorkflowVersion"
| "triggerWorkflowRun"
| "reloadLatestSaved"
| "openLatestRun"
| "selectAssetBeforeRun"
| "nodeLibrary"
| "canvas"
| "canvasHint"
| "latestSavedVersions"
| "draftStatus"
| "draftSynced"
| "draftUnsaved"
| "nodeConfiguration"
| "category"
| "definition"
| "executorType"
| "runtimeTarget"
| "artifactTitle"
| "pythonCodeHook"
| "nodeNoHook"
| "selectNode"
| "removeNode"
| "workflowCreatedName"
| "noAssetsAvailable"
| "runsTitle"
| "runsDescription"
| "noRunsYet"
| "createdAt"
| "inputAssets"
| "runDetail"
| "workflow"
| "startedAt"
| "finishedAt"
| "runDuration"
| "runSummary"
| "cancelRun"
| "retryRun"
| "runGraph"
| "boundAssets"
| "selectedTask"
| "executor"
| "executorConfig"
| "codeHook"
| "duration"
| "summary"
| "error"
| "retryTask"
| "artifacts"
| "noTaskArtifactsYet"
| "stdout"
| "stderr"
| "executionLog"
| "resultPreview"
| "noStdout"
| "noStderr"
| "noTaskLogs"
| "noTasksCreated"
| "loadingRun"
| "exploreTitle"
| "exploreEmpty"
| "loadingArtifact"
| "bootstrappingLocalWorkspace"
| "failedLoadAssets"
| "failedRegisterAsset"
| "failedLoadWorkflows"
| "failedLoadWorkflow"
| "failedLoadRuns"
| "failedLoadRunDetail"
| "failedLoadTaskArtifacts"
| "failedCancelRun"
| "failedRetryRun"
| "failedRetryTask"
| "failedLoadArtifact"
| "failedBootstrap"
| "validatedAssetCount"
| "loadedAssetCount"
| "success"
| "failed"
| "running"
| "queued"
| "cancelled"
| "totalTasks"
| "stdoutLines"
| "stderrLines"
| "successCount"
| "failedCount"
| "runningCount"
| "cancelledCount"
| "artifactsCount"
| "viaExecutor"
| "assetCount"
| "artifactCount";
const TRANSLATIONS: Record<Language, Record<TranslationKey, string>> = {
en: {
workspace: "Workspace",
project: "Project",
runs: "Runs",
localDev: "Local Dev",
navAssets: "Assets",
navWorkflows: "Workflows",
navRuns: "Runs",
navExplore: "Explore",
navLabels: "Labels",
navAdmin: "Admin",
language: "Language",
english: "English",
chinese: "中文",
assetsTitle: "Assets",
assetsDescription:
"Register local folders, archives, or dataset files, then probe them into managed asset metadata.",
localPath: "Local Path",
registerLocalPath: "Register Local Path",
noAssetsYet: "No assets have been registered yet.",
type: "Type",
source: "Source",
detected: "Detected",
pending: "pending",
topLevelEntries: "Top-level entries",
notAvailable: "n/a",
loadingAssetDetail: "Loading asset detail...",
assetId: "Asset ID",
status: "Status",
sourcePath: "Source path",
probeAgain: "Probe Again",
createExploreArtifact: "Create Explore Artifact",
openExploreView: "Open Explore View",
probeSummary: "Probe Summary",
warnings: "Warnings",
none: "none",
recommendedNodes: "Recommended nodes",
noProbeReportYet: "No probe report yet.",
workflowsTitle: "Workflows",
createWorkflow: "Create Workflow",
noWorkflowsYet: "No workflows yet.",
latestVersion: "Latest version",
workflowEditor: "Workflow Editor",
runAsset: "Run Asset",
saveWorkflowVersion: "Save Workflow Version",
triggerWorkflowRun: "Trigger Workflow Run",
reloadLatestSaved: "Reload Latest Saved",
openLatestRun: "Open Latest Run",
selectAssetBeforeRun: "Select an asset before triggering a workflow run.",
nodeLibrary: "Node Library",
canvas: "Canvas",
canvasHint: "Drag nodes freely, connect handles, zoom, and pan.",
latestSavedVersions: "Latest saved versions",
draftStatus: "Draft status",
draftSynced: "synced",
draftUnsaved: "unsaved changes",
nodeConfiguration: "Node Configuration",
category: "Category",
definition: "Definition",
executorType: "Executor Type",
runtimeTarget: "Runtime Target",
artifactTitle: "Artifact Title",
pythonCodeHook: "Python Code Hook",
nodeNoHook: "This node does not expose a code hook in V1.",
selectNode: "Select a node.",
removeNode: "Remove Node",
workflowCreatedName: "Delivery Normalize {count}",
noAssetsAvailable: "No assets available",
runsTitle: "Runs",
runsDescription: "Recent workflow executions for the current project.",
noRunsYet: "No workflow runs yet.",
createdAt: "Created at",
inputAssets: "Input assets",
runDetail: "Run Detail",
workflow: "Workflow",
startedAt: "Started at",
finishedAt: "Finished at",
runDuration: "Run duration",
runSummary: "Run summary",
cancelRun: "Cancel Run",
retryRun: "Retry Run",
runGraph: "Run Graph",
boundAssets: "Bound assets",
selectedTask: "Selected Task",
executor: "Executor",
executorConfig: "Executor config",
codeHook: "Code hook",
duration: "Duration",
summary: "Summary",
error: "Error",
retryTask: "Retry Task",
artifacts: "Artifacts",
noTaskArtifactsYet: "No task artifacts yet.",
stdout: "Stdout",
stderr: "Stderr",
executionLog: "Execution Log",
resultPreview: "Result Preview",
noStdout: "No stdout lines.",
noStderr: "No stderr lines.",
noTaskLogs: "No task logs yet.",
noTasksCreated: "No tasks created.",
loadingRun: "Loading run...",
exploreTitle: "Explore",
exploreEmpty: "Create an artifact from asset detail to inspect it here.",
loadingArtifact: "Loading artifact...",
bootstrappingLocalWorkspace: "Bootstrapping local workspace...",
failedLoadAssets: "Failed to load assets",
failedRegisterAsset: "Failed to register local asset",
failedLoadWorkflows: "Failed to load workflows",
failedLoadWorkflow: "Failed to load workflow",
failedLoadRuns: "Failed to load runs",
failedLoadRunDetail: "Failed to load run detail",
failedLoadTaskArtifacts: "Failed to load task artifacts",
failedCancelRun: "Failed to cancel run",
failedRetryRun: "Failed to retry run",
failedRetryTask: "Failed to retry task",
failedLoadArtifact: "Failed to load artifact",
failedBootstrap: "Failed to bootstrap local context",
validatedAssetCount: "validated {count} asset{suffix}",
loadedAssetCount: "loaded {count} bound asset{suffix}",
success: "success",
failed: "failed",
running: "running",
queued: "queued",
cancelled: "cancelled",
totalTasks: "{count} total tasks",
stdoutLines: "{count} stdout lines",
stderrLines: "{count} stderr lines",
successCount: "{count} success",
failedCount: "{count} failed",
runningCount: "{count} running",
cancelledCount: "{count} cancelled",
artifactsCount: "artifacts {count}",
viaExecutor: "{outcome} via {executor}",
assetCount: "assets {count}",
artifactCount: "artifacts {count}",
},
zh: {
workspace: "工作空间",
project: "项目",
runs: "运行",
localDev: "本地开发",
navAssets: "数据资产",
navWorkflows: "工作流",
navRuns: "运行记录",
navExplore: "查看",
navLabels: "标注",
navAdmin: "管理",
language: "语言",
english: "English",
chinese: "中文",
assetsTitle: "数据资产",
assetsDescription: "注册本地目录、压缩包或数据集文件,并将其探测为受管资产元数据。",
localPath: "本地路径",
registerLocalPath: "注册本地路径",
noAssetsYet: "还没有注册任何资产。",
type: "类型",
source: "来源",
detected: "识别结果",
pending: "待探测",
topLevelEntries: "顶层条目",
notAvailable: "暂无",
loadingAssetDetail: "正在加载资产详情...",
assetId: "资产 ID",
status: "状态",
sourcePath: "源路径",
probeAgain: "重新探测",
createExploreArtifact: "创建查看产物",
openExploreView: "打开查看页",
probeSummary: "探测摘要",
warnings: "告警",
none: "无",
recommendedNodes: "推荐节点",
noProbeReportYet: "还没有探测报告。",
workflowsTitle: "工作流",
createWorkflow: "新建工作流",
noWorkflowsYet: "还没有工作流。",
latestVersion: "最新版本",
workflowEditor: "工作流编辑器",
runAsset: "运行资产",
saveWorkflowVersion: "保存工作流版本",
triggerWorkflowRun: "触发工作流运行",
reloadLatestSaved: "重新加载最新保存版本",
openLatestRun: "打开最新运行",
selectAssetBeforeRun: "触发工作流运行前请先选择资产。",
nodeLibrary: "节点面板",
canvas: "画布",
canvasHint: "支持自由拖动节点、拖拽连线、缩放和平移。",
latestSavedVersions: "最近保存版本",
draftStatus: "草稿状态",
draftSynced: "已同步",
draftUnsaved: "有未保存修改",
nodeConfiguration: "节点配置",
category: "分类",
definition: "定义",
executorType: "执行器类型",
runtimeTarget: "运行目标",
artifactTitle: "产物标题",
pythonCodeHook: "Python 代码钩子",
nodeNoHook: "该节点在 V1 中不开放代码钩子。",
selectNode: "请选择一个节点。",
removeNode: "删除节点",
workflowCreatedName: "交付标准化 {count}",
noAssetsAvailable: "没有可用资产",
runsTitle: "运行记录",
runsDescription: "当前项目最近的工作流执行记录。",
noRunsYet: "还没有工作流运行记录。",
createdAt: "创建时间",
inputAssets: "输入资产",
runDetail: "运行详情",
workflow: "工作流",
startedAt: "开始时间",
finishedAt: "结束时间",
runDuration: "运行时长",
runSummary: "运行摘要",
cancelRun: "取消运行",
retryRun: "重试运行",
runGraph: "运行图",
boundAssets: "绑定资产",
selectedTask: "当前任务",
executor: "执行器",
executorConfig: "执行器配置",
codeHook: "代码钩子",
duration: "耗时",
summary: "摘要",
error: "错误",
retryTask: "重试任务",
artifacts: "产物",
noTaskArtifactsYet: "当前任务还没有产物。",
stdout: "标准输出",
stderr: "标准错误",
executionLog: "执行日志",
resultPreview: "结果预览",
noStdout: "没有标准输出。",
noStderr: "没有标准错误。",
noTaskLogs: "当前任务还没有日志。",
noTasksCreated: "还没有生成任务。",
loadingRun: "正在加载运行...",
exploreTitle: "查看",
exploreEmpty: "先在资产详情或运行详情里创建产物,再来这里查看。",
loadingArtifact: "正在加载产物...",
bootstrappingLocalWorkspace: "正在初始化本地工作空间...",
failedLoadAssets: "加载资产失败",
failedRegisterAsset: "注册本地资产失败",
failedLoadWorkflows: "加载工作流失败",
failedLoadWorkflow: "加载工作流失败",
failedLoadRuns: "加载运行列表失败",
failedLoadRunDetail: "加载运行详情失败",
failedLoadTaskArtifacts: "加载任务产物失败",
failedCancelRun: "取消运行失败",
failedRetryRun: "重试运行失败",
failedRetryTask: "重试任务失败",
failedLoadArtifact: "加载产物失败",
failedBootstrap: "初始化本地上下文失败",
validatedAssetCount: "已校验 {count} 个资产",
loadedAssetCount: "已加载 {count} 个绑定资产",
success: "成功",
failed: "失败",
running: "运行中",
queued: "排队中",
cancelled: "已取消",
totalTasks: "共 {count} 个任务",
stdoutLines: "{count} 条标准输出",
stderrLines: "{count} 条标准错误",
successCount: "{count} 个成功",
failedCount: "{count} 个失败",
runningCount: "{count} 个运行中",
cancelledCount: "{count} 个已取消",
artifactsCount: "产物 {count}",
viaExecutor: "{outcome},执行器 {executor}",
assetCount: "资产 {count}",
artifactCount: "产物 {count}",
},
};
const BUILTIN_NODE_TRANSLATIONS: Record<string, { en: { name: string; description: string }; zh: { name: string; description: string } }> = {
"source-asset": {
en: { name: "Source Asset", description: "Load an uploaded asset or registered storage path." },
zh: { name: "数据源", description: "加载上传资产或已注册的存储路径。" },
},
"extract-archive": {
en: { name: "Extract Archive", description: "Expand a compressed archive into a managed folder artifact." },
zh: { name: "解压归档", description: "将压缩包展开成受管目录产物。" },
},
"rename-folder": {
en: { name: "Rename Delivery Folder", description: "Rename the top-level delivery folder to the business naming convention." },
zh: { name: "重命名交付目录", description: "将顶层交付目录重命名为业务命名规范。" },
},
"validate-structure": {
en: { name: "Validate Structure", description: "Validate required directories and metadata files." },
zh: { name: "校验目录结构", description: "校验必需目录和元数据文件。" },
},
"validate-metadata": {
en: { name: "Validate Metadata", description: "Validate meta.json, intrinsics.json, and video_meta.json." },
zh: { name: "校验元数据", description: "校验 meta.json、intrinsics.json 和 video_meta.json。" },
},
"export-delivery-package": {
en: { name: "Export Delivery Package", description: "Produce the final delivery package artifact for upload." },
zh: { name: "导出交付包", description: "生成最终交付包产物用于上传或交付。" },
},
};
type InterpolationValues = Record<string, string | number>;
function interpolate(template: string, values?: InterpolationValues) {
if (!values) {
return template;
}
return template.replace(/\{(\w+)\}/gu, (_match, key) => String(values[key] ?? ""));
}
export function translate(
language: Language,
key: TranslationKey,
values?: InterpolationValues,
) {
return interpolate(TRANSLATIONS[language][key], values);
}
export function getInitialLanguage(): Language {
if (typeof window === "undefined") {
return "zh";
}
const stored = window.localStorage.getItem("emboflow-language");
if (stored === "en" || stored === "zh") {
return stored;
}
return window.navigator.language.toLowerCase().startsWith("zh") ? "zh" : "en";
}
export function localizeNodeDefinition<T extends { id: string; name?: string; description?: string; category?: string }>(
language: Language,
definition: T,
) {
const localized = BUILTIN_NODE_TRANSLATIONS[definition.id];
const categoryMap = {
Source: language === "zh" ? "数据源" : "Source",
Transform: language === "zh" ? "处理" : "Transform",
Inspect: language === "zh" ? "检查" : "Inspect",
Annotate: language === "zh" ? "标注" : "Annotate",
Export: language === "zh" ? "导出" : "Export",
Utility: language === "zh" ? "工具" : "Utility",
} as const;
return {
...definition,
name: localized ? localized[language].name : definition.name,
description: localized ? localized[language].description : definition.description,
category:
definition.category && definition.category in categoryMap
? categoryMap[definition.category as keyof typeof categoryMap]
: definition.category,
};
}
type I18nContextValue = {
language: Language;
setLanguage: (language: Language) => void;
t: (key: TranslationKey, values?: InterpolationValues) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider(props: React.PropsWithChildren) {
const [language, setLanguage] = useState<Language>(() => getInitialLanguage());
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
}
if (typeof window !== "undefined") {
window.localStorage.setItem("emboflow-language", language);
}
}, [language]);
const value = useMemo<I18nContextValue>(
() => ({
language,
setLanguage,
t: (key, values) => translate(language, key, values),
}),
[language],
);
return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>;
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within I18nProvider");
}
return context;
}

View File

@ -3,11 +3,14 @@ import assert from "node:assert/strict";
import {
addNodeToDraft,
connectNodesInDraft,
createDefaultWorkflowDraft,
getNodeRuntimeConfig,
removeNodeFromDraft,
serializeWorkflowDraft,
setNodePosition,
setNodeRuntimeConfig,
setViewportInDraft,
workflowDraftFromVersion,
} from "./workflow-editor-state.ts";
@ -29,6 +32,7 @@ test("load persisted workflow version into an editable draft", () => {
assert.equal(draft.logicGraph.edges.length, 1);
assert.equal(draft.runtimeGraph.selectedPreset, "delivery-normalization");
assert.deepEqual(draft.pluginRefs, ["builtin:delivery-nodes"]);
assert.deepEqual(draft.visualGraph.nodePositions["source-asset"], { x: 120, y: 120 });
});
test("add node appends a unique node id and a sequential edge by default", () => {
@ -44,6 +48,10 @@ test("add node appends a unique node id and a sequential edge by default", () =>
from: "validate-structure",
to: "export-delivery-package-1",
});
assert.deepEqual(result.draft.visualGraph.nodePositions["export-delivery-package-1"], {
x: 140,
y: 300,
});
});
test("remove node prunes attached edges and serialize emits workflow version payload", () => {
@ -71,6 +79,7 @@ test("remove node prunes attached edges and serialize emits workflow version pay
assert.equal(next.logicGraph.edges.length, 0);
assert.equal(payload.runtimeGraph.selectedPreset, "delivery-normalization");
assert.equal(payload.logicGraph.nodes[1]?.id, "validate-structure");
assert.equal(payload.visualGraph.nodePositions["rename-folder"], undefined);
});
test("set per-node runtime config and keep it in the serialized workflow payload", () => {
@ -102,3 +111,18 @@ test("set per-node runtime config and keep it in the serialized workflow payload
"python",
);
});
test("update node positions, connect nodes, and persist viewport in the workflow draft", () => {
const draft = createDefaultWorkflowDraft();
const moved = setNodePosition(draft, "rename-folder", { x: 520, y: 240 });
const connected = connectNodesInDraft(moved, "source-asset", "validate-structure");
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",
});
});

View File

@ -8,6 +8,20 @@ export type WorkflowLogicEdge = {
to: string;
};
export type WorkflowPoint = {
x: number;
y: number;
};
export type WorkflowViewport = WorkflowPoint & {
zoom: number;
};
export type WorkflowVisualGraph = {
viewport: WorkflowViewport;
nodePositions: Record<string, WorkflowPoint>;
};
export type WorkflowNodeDefinitionSummary = {
id: string;
name: string;
@ -30,7 +44,7 @@ export type WorkflowNodeRuntimeConfig = {
};
export type WorkflowDraft = {
visualGraph: Record<string, unknown>;
visualGraph: WorkflowVisualGraph;
logicGraph: {
nodes: WorkflowLogicNode[];
edges: WorkflowLogicEdge[];
@ -45,9 +59,44 @@ export type WorkflowDraft = {
type WorkflowVersionLike = Partial<WorkflowDraft>;
const DEFAULT_VIEWPORT: WorkflowViewport = { x: 0, y: 0, zoom: 1 };
const DEFAULT_NODE_LAYOUT: Record<string, WorkflowPoint> = {
"source-asset": { x: 120, y: 120 },
"rename-folder": { x: 430, y: 280 },
"validate-structure": { x: 760, y: 450 },
};
function createDefaultNodePosition(index: number): WorkflowPoint {
const column = index % 3;
const row = Math.floor(index / 3);
return {
x: 140 + column * 280,
y: 120 + row * 180,
};
}
function createVisualGraph(input?: Partial<WorkflowVisualGraph>, nodes: WorkflowLogicNode[] = []): WorkflowVisualGraph {
const nodePositions = { ...(input?.nodePositions ?? {}) };
nodes.forEach((node, index) => {
nodePositions[node.id] ??= DEFAULT_NODE_LAYOUT[node.id] ?? createDefaultNodePosition(index);
});
return {
viewport: input?.viewport ? { ...DEFAULT_VIEWPORT, ...input.viewport } : { ...DEFAULT_VIEWPORT },
nodePositions,
};
}
function cloneDraft(draft: WorkflowDraft): WorkflowDraft {
return {
visualGraph: { ...draft.visualGraph },
visualGraph: {
viewport: { ...draft.visualGraph.viewport },
nodePositions: Object.fromEntries(
Object.entries(draft.visualGraph.nodePositions).map(([nodeId, position]) => [
nodeId,
{ ...position },
]),
),
},
logicGraph: {
nodes: draft.logicGraph.nodes.map((node) => ({ ...node })),
edges: draft.logicGraph.edges.map((edge) => ({ ...edge })),
@ -100,11 +149,7 @@ function inferDefinitionId(nodeId: string): string {
}
export function createDefaultWorkflowDraft(): WorkflowDraft {
return {
visualGraph: {
viewport: { x: 0, y: 0, zoom: 1 },
},
logicGraph: {
const logicGraph = {
nodes: [
{ id: "source-asset", type: "source" },
{ id: "rename-folder", type: "transform" },
@ -114,7 +159,10 @@ export function createDefaultWorkflowDraft(): WorkflowDraft {
{ from: "source-asset", to: "rename-folder" },
{ from: "rename-folder", to: "validate-structure" },
],
},
};
return {
visualGraph: createVisualGraph(undefined, logicGraph.nodes),
logicGraph,
runtimeGraph: {
selectedPreset: "delivery-normalization",
nodeBindings: {
@ -156,7 +204,7 @@ export function workflowDraftFromVersion(version?: WorkflowVersionLike | null):
}
return {
visualGraph: { ...(version.visualGraph ?? { viewport: { x: 0, y: 0, zoom: 1 } }) },
visualGraph: createVisualGraph(version.visualGraph, version.logicGraph.nodes),
logicGraph: {
nodes: version.logicGraph.nodes.map((node) => ({ ...node })),
edges: (version.logicGraph.edges ?? []).map((edge) => ({ ...edge })),
@ -195,6 +243,7 @@ export function addNodeToDraft(
next.runtimeGraph.nodeBindings ??= {};
next.runtimeGraph.nodeBindings[nodeId] = definition.id;
next.runtimeGraph.nodeConfigs ??= {};
next.visualGraph.nodePositions[nodeId] = createDefaultNodePosition(next.logicGraph.nodes.length - 1);
return { draft: next, nodeId };
}
@ -211,6 +260,7 @@ export function removeNodeFromDraft(draft: WorkflowDraft, nodeId: string): Workf
if (next.runtimeGraph.nodeConfigs) {
delete next.runtimeGraph.nodeConfigs[nodeId];
}
delete next.visualGraph.nodePositions[nodeId];
return next;
}
@ -237,6 +287,36 @@ export function setNodeRuntimeConfig(
return next;
}
export function setNodePosition(draft: WorkflowDraft, nodeId: string, position: WorkflowPoint): WorkflowDraft {
const next = cloneDraft(draft);
next.visualGraph.nodePositions[nodeId] = { ...position };
return next;
}
export function connectNodesInDraft(
draft: WorkflowDraft,
sourceNodeId: string | null | undefined,
targetNodeId: string | null | undefined,
): WorkflowDraft {
if (!sourceNodeId || !targetNodeId || 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 });
}
return next;
}
export function setViewportInDraft(draft: WorkflowDraft, viewport: WorkflowViewport): WorkflowDraft {
const next = cloneDraft(draft);
next.visualGraph.viewport = { ...viewport };
return next;
}
export function serializeWorkflowDraft(draft: WorkflowDraft): WorkflowDraft {
return cloneDraft(draft);
}

View File

@ -50,6 +50,15 @@ textarea {
gap: 16px;
}
.language-switcher {
display: inline-flex;
gap: 8px;
padding: 6px;
border: 1px solid #d1d5db;
border-radius: 999px;
background: #f8fafc;
}
.app-header__pill {
display: inline-flex;
flex-direction: column;
@ -113,6 +122,7 @@ textarea {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
.list-grid {
@ -199,6 +209,11 @@ textarea {
color: #111827;
}
.button-secondary[data-active="true"] {
background: #111827;
color: #ffffff;
}
.mono-block {
border-radius: 12px;
background: #0f172a;
@ -213,6 +228,106 @@ textarea {
color: #6b7280;
}
.workflow-canvas-panel {
overflow: hidden;
}
.workflow-canvas-shell {
height: 680px;
margin-top: 12px;
border: 1px solid #d4d4d8;
border-radius: 18px;
overflow: hidden;
background:
radial-gradient(circle at top left, rgba(14, 165, 233, 0.1), transparent 30%),
linear-gradient(180deg, #ffffff 0%, #f4f6fb 100%);
}
.workflow-canvas-footer {
display: flex;
justify-content: space-between;
gap: 16px;
margin-top: 12px;
color: #52525b;
font-size: 13px;
}
.workflow-flow-node {
min-width: 220px;
padding: 0;
border: 2px solid #111827;
border-radius: 18px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.08);
color: #111827;
}
.workflow-flow-node.selected,
.workflow-flow-node:focus-visible {
border-color: #0f766e;
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
}
.workflow-flow-node__body {
display: grid;
gap: 8px;
padding: 18px 20px;
text-align: center;
}
.workflow-flow-node__body strong {
font-size: 15px;
font-weight: 700;
}
.workflow-flow-node__body span {
font-size: 12px;
color: #52525b;
}
.workflow-flow-node--source {
border-color: #111827;
}
.workflow-flow-node--transform {
border-color: #1d4ed8;
}
.workflow-flow-node--inspect {
border-color: #047857;
}
.workflow-flow-node--export {
border-color: #b45309;
}
.react-flow__panel {
margin: 14px;
}
.react-flow__controls {
border-radius: 14px;
overflow: hidden;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.16);
}
.react-flow__controls-button {
width: 32px;
height: 32px;
background: #ffffff;
}
.react-flow__minimap {
border: 1px solid #d4d4d8;
border-radius: 14px;
background: rgba(255, 255, 255, 0.92);
}
.react-flow__edge-path {
stroke: #3f3f46;
stroke-width: 2;
}
@media (max-width: 1080px) {
.app-shell {
grid-template-columns: 1fr;
@ -228,4 +343,12 @@ textarea {
.two-column {
grid-template-columns: 1fr;
}
.workflow-canvas-shell {
height: 520px;
}
.workflow-canvas-footer {
display: grid;
}
}

View File

@ -26,6 +26,7 @@ Recommended global header content:
- workspace switcher
- project switcher
- language switcher for Chinese and English
- search entry
- run notifications
- user menu
@ -148,6 +149,18 @@ The current V1 implementation is simpler than the target canvas UX, but it alrea
- save the current draft as a new workflow version
- auto-save a dirty draft before triggering a run
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
- drag-to-connect edges between node handles
- zoom and pan
- dotted background grid
- mini-map
- canvas controls
- persisted node positions and viewport in `visualGraph`
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.
### Right Configuration Panel
The right panel is schema-driven.

View File

@ -26,6 +26,7 @@
- `2026-03-27`: The current runtime-config pass freezes per-node executor config into `workflow_runs` and `run_tasks`, exposes runtime editing controls in the React workflow editor, and executes trusted-local Python code hooks from the task snapshot.
- `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.
---

183
pnpm-lock.yaml generated
View File

@ -30,6 +30,9 @@ importers:
apps/web:
dependencies:
'@xyflow/react':
specifier: ^12.8.4
version: 12.10.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.2.4
version: 19.2.4
@ -330,6 +333,24 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-drag@3.0.7':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-selection@3.0.11':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/d3-transition@3.0.9':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/webidl-conversions@7.0.3':
resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==}
@ -352,6 +373,15 @@ packages:
babel-plugin-react-compiler:
optional: true
'@xyflow/react@12.10.1':
resolution: {integrity: sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==}
peerDependencies:
react: '>=17'
react-dom: '>=17'
'@xyflow/system@0.0.75':
resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -443,6 +473,9 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
classcat@5.0.5:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@ -466,6 +499,44 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-drag@3.0.0:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-selection@3.0.0:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
d3-transition@3.0.1:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
d3-zoom@3.0.0:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -995,6 +1066,11 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -1057,6 +1133,21 @@ packages:
resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==}
engines: {node: '>=12'}
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
snapshots:
'@emnapi/core@1.9.1':
@ -1222,6 +1313,27 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-selection@3.0.11': {}
'@types/d3-transition@3.0.9':
dependencies:
'@types/d3-selection': 3.0.11
'@types/d3-zoom@3.0.8':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/webidl-conversions@7.0.3': {}
'@types/whatwg-url@11.0.5':
@ -1237,6 +1349,29 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.7
vite: 8.0.3(esbuild@0.27.4)(tsx@4.21.0)
'@xyflow/react@12.10.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@xyflow/system': 0.0.75
classcat: 5.0.5
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
zustand: 4.5.7(react@19.2.4)
transitivePeerDependencies:
- '@types/react'
- immer
'@xyflow/system@0.0.75':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -1316,6 +1451,8 @@ snapshots:
camelcase@6.3.0: {}
classcat@5.0.5: {}
commondir@1.0.1: {}
content-disposition@1.0.1: {}
@ -1331,6 +1468,42 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-drag@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
d3-ease@3.0.1: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-selection@3.0.0: {}
d3-timer@3.0.1: {}
d3-transition@3.0.1(d3-selection@3.0.0):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
d3-zoom@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1(d3-selection@3.0.0)
debug@4.4.3:
dependencies:
ms: 2.1.3
@ -1910,6 +2083,10 @@ snapshots:
unpipe@1.0.0: {}
use-sync-external-store@1.6.0(react@19.2.4):
dependencies:
react: 19.2.4
vary@1.1.2: {}
vite@8.0.3(esbuild@0.27.4)(tsx@4.21.0):
@ -1937,3 +2114,9 @@ snapshots:
dependencies:
buffer-crc32: 0.2.13
pend: 1.2.0
zustand@4.5.7(react@19.2.4):
dependencies:
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
react: 19.2.4

View File

@ -54,6 +54,8 @@ def classify(path_text: str) -> str:
lower = path_text.lower()
path = Path(path_text)
if path.name == "Makefile":
return "config"
if any(token in lower for token in TEST_HINTS):
return "tests"
if any(token in lower for token in DOC_PATTERNS) or path.suffix == ".md":

View File

@ -40,6 +40,9 @@ class DocCodeSyncAssessmentTests(unittest.TestCase):
def test_classifies_env_example_as_config(self):
self.assertEqual(MODULE.classify(".env.example"), "config")
def test_classifies_makefile_as_config(self):
self.assertEqual(MODULE.classify("Makefile"), "config")
def test_extract_status_paths_preserves_first_character(self):
self.assertEqual(
MODULE.extract_status_paths([" M apps/api/package.json"]),