From f8499eefa76c273e0cb3a6f2ca867afb74238ab1 Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:05:45 +0800 Subject: [PATCH] feat(ui/workflows): vue-flow canvas + component dialogs + dark / mobile polish --- mateclaw-ui/package.json | 6 + mateclaw-ui/pnpm-lock.yaml | 132 ++++++++ mateclaw-ui/src/assets/main.css | 47 +++ .../workflow/CreateWorkflowDialog.vue | 85 +++++ .../src/components/workflow/PublishDialog.vue | 77 +++++ .../src/components/workflow/StepNode.vue | 215 +++++++++++++ .../components/workflow/WorkflowCanvas.vue | 250 +++++++++++++++ .../src/composables/useWorkflowGraph.ts | 235 ++++++++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 39 +++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 39 +++ mateclaw-ui/src/views/Triggers.vue | 31 +- mateclaw-ui/src/views/Workflows.vue | 291 ++++++++++++++++-- 12 files changed, 1413 insertions(+), 34 deletions(-) create mode 100644 mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue create mode 100644 mateclaw-ui/src/components/workflow/PublishDialog.vue create mode 100644 mateclaw-ui/src/components/workflow/StepNode.vue create mode 100644 mateclaw-ui/src/components/workflow/WorkflowCanvas.vue create mode 100644 mateclaw-ui/src/composables/useWorkflowGraph.ts diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index b336e561..7776b5df 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -13,9 +13,14 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.1", "@google/model-viewer": "^4.2.0", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/minimap": "^1.5.4", "axios": "^1.7.9", "cytoscape": "^3.32.0", "cytoscape-cose-bilkent": "^4.1.0", + "dagre": "^0.8.5", "dayjs": "^1.11.13", "dompurify": "^3.3.3", "echarts": "^6.0.0", @@ -33,6 +38,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.2.2", + "@types/dagre": "^0.7.54", "@types/katex": "^0.16.8", "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.7.0", diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index f9057aac..c02f3fbf 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -14,6 +14,18 @@ importers: '@google/model-viewer': specifier: ^4.2.0 version: 4.2.0(three@0.182.0) + '@vue-flow/background': + specifier: ^1.3.2 + version: 1.3.2(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3)) + '@vue-flow/controls': + specifier: ^1.1.3 + version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3)) + '@vue-flow/core': + specifier: ^1.48.2 + version: 1.48.2(vue@3.5.31(typescript@5.7.3)) + '@vue-flow/minimap': + specifier: ^1.5.4 + version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3)) axios: specifier: ^1.7.9 version: 1.14.0 @@ -23,6 +35,9 @@ importers: cytoscape-cose-bilkent: specifier: ^4.1.0 version: 4.1.0(cytoscape@3.33.2) + dagre: + specifier: ^0.8.5 + version: 0.8.5 dayjs: specifier: ^1.11.13 version: 1.11.20 @@ -69,6 +84,9 @@ importers: '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + '@types/dagre': + specifier: ^0.7.54 + version: 0.7.54 '@types/katex': specifier: ^0.16.8 version: 0.16.8 @@ -753,6 +771,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/dagre@0.7.54': + resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -796,6 +817,29 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-flow/background@1.3.2': + resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/controls@1.1.3': + resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/core@1.48.2': + resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==} + peerDependencies: + vue: ^3.3.0 + + '@vue-flow/minimap@1.5.4': + resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + '@vue/compiler-core@3.5.31': resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==} @@ -851,12 +895,21 @@ packages: vue: optional: true + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + '@vueuse/core@12.0.0': resolution: {integrity: sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==} + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + '@vueuse/metadata@12.0.0': resolution: {integrity: sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==} + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + '@vueuse/shared@12.0.0': resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==} @@ -1148,6 +1201,9 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -1372,6 +1428,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -1937,6 +1996,17 @@ packages: vue-component-type-helpers@3.2.6: resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + vue-eslint-parser@9.4.3: resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} engines: {node: ^14.17.0 || >=16.0.0} @@ -2501,6 +2571,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/dagre@0.7.54': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -2542,6 +2614,34 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@vue-flow/background@1.3.2(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.31(typescript@5.7.3)) + vue: 3.5.31(typescript@5.7.3) + + '@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.31(typescript@5.7.3)) + vue: 3.5.31(typescript@5.7.3) + + '@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3))': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.31(typescript@5.7.3)) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.31(typescript@5.7.3) + transitivePeerDependencies: + - '@vue/composition-api' + + '@vue-flow/minimap@1.5.4(@vue-flow/core@1.48.2(vue@3.5.31(typescript@5.7.3)))(vue@3.5.31(typescript@5.7.3))': + dependencies: + '@vue-flow/core': 1.48.2(vue@3.5.31(typescript@5.7.3)) + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.31(typescript@5.7.3) + '@vue/compiler-core@3.5.31': dependencies: '@babel/parser': 7.29.2 @@ -2631,6 +2731,16 @@ snapshots: typescript: 5.7.3 vue: 3.5.31(typescript@5.7.3) + '@vueuse/core@10.11.1(vue@3.5.31(typescript@5.7.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.31(typescript@5.7.3)) + vue-demi: 0.14.10(vue@3.5.31(typescript@5.7.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@vueuse/core@12.0.0(typescript@5.7.3)': dependencies: '@types/web-bluetooth': 0.0.20 @@ -2640,8 +2750,17 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/metadata@10.11.1': {} + '@vueuse/metadata@12.0.0': {} + '@vueuse/shared@10.11.1(vue@3.5.31(typescript@5.7.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.31(typescript@5.7.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + '@vueuse/shared@12.0.0(typescript@5.7.3)': dependencies: vue: 3.5.31(typescript@5.7.3) @@ -2962,6 +3081,11 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 + dagre@0.8.5: + dependencies: + graphlib: 2.1.8 + lodash: 4.18.1 + dayjs@1.11.20: {} debug@4.4.3: @@ -3241,6 +3365,10 @@ snapshots: graceful-fs@4.2.11: {} + graphlib@2.1.8: + dependencies: + lodash: 4.18.1 + hachure-fill@0.5.2: {} has-flag@4.0.0: {} @@ -3724,6 +3852,10 @@ snapshots: vue-component-type-helpers@3.2.6: {} + vue-demi@0.14.10(vue@3.5.31(typescript@5.7.3)): + dependencies: + vue: 3.5.31(typescript@5.7.3) + vue-eslint-parser@9.4.3(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 4.4.3 diff --git a/mateclaw-ui/src/assets/main.css b/mateclaw-ui/src/assets/main.css index d79a2f9e..50b396d4 100644 --- a/mateclaw-ui/src/assets/main.css +++ b/mateclaw-ui/src/assets/main.css @@ -140,6 +140,11 @@ --mc-scrollbar-thumb: #C4B5A8; --mc-scrollbar-thumb-hover: #A08070; + /* workflow canvas — vue-flow surfaces */ + --mc-canvas-dot: rgba(0, 0, 0, 0.18); + --mc-edge: #c2b3a5; + --mc-edge-strong: #8a7261; + /* Element Plus light override */ --el-color-primary: #D97757; --el-font-family: var(--mc-font-body); @@ -260,9 +265,51 @@ html.dark { --mc-scrollbar-thumb: #5A4438; --mc-scrollbar-thumb-hover: #7A6050; + /* workflow canvas — darker dots + softer edge color so they read on + the dark background without overpowering the node card. */ + --mc-canvas-dot: rgba(255, 255, 255, 0.14); + --mc-edge: #5a4a3e; + --mc-edge-strong: #8c7361; + color-scheme: dark; } +/* Vue Flow theme bridge — its default stylesheet ships hard-coded + light colors for handles / mini-map / control buttons. Map them to + our theme tokens so dark mode inherits cleanly. */ +.vue-flow__handle { + background: var(--mc-edge-strong, #777); + border-color: var(--mc-bg-elevated, #fff); +} +.vue-flow__edge-path { + stroke: var(--mc-edge, #999); +} +.vue-flow__edge-text { + fill: var(--mc-text-secondary, #555); + font-size: 11px; +} +.vue-flow__edge-textbg { + fill: var(--mc-bg-elevated, #fff); +} +.vue-flow__minimap { + background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04)) !important; + border: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08)); +} +.vue-flow__minimap-mask { + fill: var(--mc-bg-muted, rgba(0, 0, 0, 0.06)); +} +.vue-flow__controls { + box-shadow: var(--mc-shadow-soft, 0 2px 6px rgba(0, 0, 0, 0.1)); +} +.vue-flow__controls-button { + background: var(--mc-bg-elevated, #fff); + border-bottom-color: var(--mc-border-light, rgba(0, 0, 0, 0.08)); + fill: var(--mc-text-primary, #333); +} +.vue-flow__controls-button:hover { + background: var(--mc-bg-muted, rgba(0, 0, 0, 0.05)); +} + /* ================================================================ Base resets ================================================================ */ diff --git a/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue b/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue new file mode 100644 index 00000000..e1b92192 --- /dev/null +++ b/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue @@ -0,0 +1,85 @@ + + + diff --git a/mateclaw-ui/src/components/workflow/PublishDialog.vue b/mateclaw-ui/src/components/workflow/PublishDialog.vue new file mode 100644 index 00000000..3ebb4f59 --- /dev/null +++ b/mateclaw-ui/src/components/workflow/PublishDialog.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/mateclaw-ui/src/components/workflow/StepNode.vue b/mateclaw-ui/src/components/workflow/StepNode.vue new file mode 100644 index 00000000..f3be44d8 --- /dev/null +++ b/mateclaw-ui/src/components/workflow/StepNode.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue b/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue new file mode 100644 index 00000000..c3c3a019 --- /dev/null +++ b/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/mateclaw-ui/src/composables/useWorkflowGraph.ts b/mateclaw-ui/src/composables/useWorkflowGraph.ts new file mode 100644 index 00000000..6105e267 --- /dev/null +++ b/mateclaw-ui/src/composables/useWorkflowGraph.ts @@ -0,0 +1,235 @@ +import { computed, type Ref } from 'vue' +import { Position, type Edge, type Node } from '@vue-flow/core' +import dagre from 'dagre' + +/** + * Linear `{ steps: [...] }` workflow JSON ↔ vue-flow graph translation. + * + * The JSON shape is the source of truth (matches the workflow design's + * "linear list + mode field" model). The canvas is a derived view, so + * this composable rebuilds nodes + edges every time the JSON changes. + * + * Edge rules (v0 7-mode set): + * - sequential / await_approval / dispatch_channel / write_memory: + * one inbound edge from the previous "merge point", outbound becomes + * the next merge point. + * - fan_out: branches off the current merge point. Consecutive fan_out + * steps all share the same parent (parallel siblings). + * - collect: joins from every fan_out step in the most recent fan_out + * group; the join becomes the new merge point. + * - conditional: same shape as sequential, but the inbound edge carries + * the expression as a label so readers can see the gate at a glance. + * + * Layout is done via dagre with a left-to-right rank direction; vue-flow + * just renders the resulting `{ x, y }` positions. We don't enable + * draggable nodes — keeping the graph derived from JSON keeps the two + * representations from drifting. + */ + +export interface RawStep { + name?: string + agentName?: string + mode?: { type?: string; expression?: string; [k: string]: unknown } + promptTemplate?: string + outputVar?: string + outputContentType?: string + [k: string]: unknown +} + +export interface RawWorkflow { + steps?: RawStep[] +} + +export interface StepNodeData { + index: number + name: string + modeType: string + agentName?: string + promptTemplate?: string + expression?: string + approvalKind?: string + channels?: string[] + mergeStrategy?: string + raw: RawStep +} + +const NODE_WIDTH = 220 +const NODE_HEIGHT = 88 +const RANK_SEP = 80 +const NODE_SEP = 40 + +function fallbackName(idx: number) { + return `step-${idx + 1}` +} + +/** + * Produce sane node ids even when two steps share a name. The compiler + * rejects duplicates at publish time, but a draft might have them; we + * suffix with the index so vue-flow can still render the graph. + */ +function uniqueId(name: string, idx: number, seen: Set): string { + const base = name && name.trim() ? name.trim() : fallbackName(idx) + if (!seen.has(base)) { + seen.add(base) + return base + } + const composite = `${base}__${idx}` + seen.add(composite) + return composite +} + +export function buildGraph(json: string): { nodes: Node[]; edges: Edge[]; parseError: string | null } { + let parsed: RawWorkflow | null = null + let parseError: string | null = null + try { + parsed = json && json.trim() ? (JSON.parse(json) as RawWorkflow) : { steps: [] } + } catch (e) { + parseError = (e as Error).message + return { nodes: [], edges: [], parseError } + } + const steps = Array.isArray(parsed?.steps) ? parsed!.steps! : [] + if (steps.length === 0) { + return { nodes: [], edges: [], parseError } + } + + const seen = new Set() + const nodes: Node[] = steps.map((step, idx) => { + const id = uniqueId(step?.name ?? '', idx, seen) + const modeType = (step?.mode?.type ?? 'sequential').toString() + const data: StepNodeData = { + index: idx, + name: step?.name?.trim() || fallbackName(idx), + modeType, + agentName: step?.agentName, + promptTemplate: step?.promptTemplate, + expression: typeof step?.mode?.expression === 'string' ? step.mode!.expression : undefined, + approvalKind: typeof step?.mode?.approvalKind === 'string' ? (step.mode!.approvalKind as string) : undefined, + channels: Array.isArray(step?.mode?.channels) ? (step.mode!.channels as string[]) : undefined, + mergeStrategy: typeof step?.mode?.mergeStrategy === 'string' ? (step.mode!.mergeStrategy as string) : undefined, + raw: step ?? {}, + } + return { + id, + type: 'step', + position: { x: 0, y: 0 }, // placeholder; dagre fills it in + data, + // Disable interactions — the JSON is the source of truth. + draggable: false, + selectable: true, + connectable: false, + } + }) + + // Edge derivation walks the steps once, tracking: + // mergePoint: the node id whose output the next non-fan_out step should consume + // fanGroup: the ids of fan_out steps in the most recent open group, ready to be joined by a collect + const edges: Edge[] = [] + let mergePoint: string | null = null + let fanGroup: string[] = [] + let prevModeWasFanOut = false + + nodes.forEach((node, idx) => { + const mode = node.data!.modeType + if (mode === 'fan_out') { + // Open a new fan_out group on the previous merge point. + if (!prevModeWasFanOut) fanGroup = [] + fanGroup.push(node.id) + if (mergePoint) { + edges.push({ + id: `e-${mergePoint}->${node.id}`, + source: mergePoint, + target: node.id, + animated: false, + }) + } + prevModeWasFanOut = true + } else if (mode === 'collect') { + // Join every open fan_out branch into this node and reset the + // merge point to here. If there is no open group (misconfigured + // draft) we still draw an edge from the prior merge point so the + // canvas surfaces the orphan visually. + const sources = fanGroup.length ? fanGroup : (mergePoint ? [mergePoint] : []) + for (const src of sources) { + edges.push({ + id: `e-${src}->${node.id}`, + source: src, + target: node.id, + animated: false, + }) + } + fanGroup = [] + mergePoint = node.id + prevModeWasFanOut = false + } else { + // Sequential / conditional / await_approval / dispatch_channel / write_memory + // all attach to the current merge point and become the next merge point. + if (mergePoint) { + const edge: Edge = { + id: `e-${mergePoint}->${node.id}`, + source: mergePoint, + target: node.id, + animated: false, + } + if (mode === 'conditional' && node.data?.expression) { + edge.label = `if ${node.data.expression}` + } + edges.push(edge) + } + mergePoint = node.id + fanGroup = [] + prevModeWasFanOut = false + } + // Quietly suppress idx/unused-warning if the runtime drops us here. + void idx + }) + + return { nodes, edges, parseError } +} + +/** + * Run dagre over the given nodes / edges and write the computed + * positions back onto the node objects in place. Returns the same + * arrays so callers can chain. + */ +export function applyDagreLayout( + nodes: Node[], + edges: Edge[], + direction: 'LR' | 'TB' = 'LR' +): { nodes: Node[]; edges: Edge[] } { + if (nodes.length === 0) return { nodes, edges } + const g = new dagre.graphlib.Graph({ compound: false }) + g.setDefaultEdgeLabel(() => ({})) + g.setGraph({ rankdir: direction, nodesep: NODE_SEP, ranksep: RANK_SEP }) + + for (const n of nodes) { + g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) + } + for (const e of edges) { + g.setEdge(e.source, e.target) + } + dagre.layout(g) + + const laidOut: Node[] = nodes.map((n) => { + const pos = g.node(n.id) + return { + ...n, + // dagre returns the node center; vue-flow expects the top-left + // corner, so offset by half the dimensions. + position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 }, + sourcePosition: direction === 'LR' ? Position.Right : Position.Bottom, + targetPosition: direction === 'LR' ? Position.Left : Position.Top, + } + }) + + return { nodes: laidOut, edges } +} + +export function useWorkflowGraph(jsonRef: Ref, direction: Ref<'LR' | 'TB'>) { + const graph = computed(() => { + const raw = buildGraph(jsonRef.value) + if (raw.nodes.length === 0) return raw + const { nodes, edges } = applyDagreLayout(raw.nodes, raw.edges, direction.value) + return { nodes, edges, parseError: raw.parseError } + }) + return graph +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index d24edd6d..479509fa 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1941,6 +1941,45 @@ export default { }, jsonInvalid: 'JSON parse failed: {msg}', jsonOk: 'JSON syntax OK.', + tabs: { + canvas: 'Canvas', + json: 'JSON', + }, + canvas: { + empty: 'The current draft has no steps. Add one in the JSON editor or pick a template.', + layoutLR: 'Horizontal', + layoutTB: 'Vertical', + stats: '{nodes} nodes · {edges} edges', + parseError: 'JSON parse failed: {msg}', + fullscreenEnter: 'Fullscreen', + fullscreenExit: 'Exit fullscreen', + nodeAgent: 'Agent', + nodeExpression: 'Condition', + nodeApprovalKind: 'Approval', + nodeChannels: 'Channels', + nodeMergeStrategy: 'Merge', + approvalDefault: 'manual', + channelsEmpty: 'unspecified', + inspector: { + title: 'Step inspector', + empty: 'Click a node on the canvas to inspect it.', + rawHeader: 'Raw step JSON', + }, + }, + dialogs: { + createTitle: 'New workflow', + fieldName: 'Name', + namePlaceholder: 'e.g. employee-onboarding', + fieldDescription: 'Description', + descriptionPlaceholder: 'Optional. One or two sentences on what this workflow does.', + createSubmit: 'Create', + publishTitle: 'Publish workflow', + publishHint: 'Publishing compiles the current draft into a new revision. Running instances stay on their current revision.', + publishNote: 'Publish note', + publishNotePlaceholder: 'Optional summary of what changed.', + deleteTitle: 'Delete workflow', + deleteContent: 'Delete workflow "{name}"? This is reversible via the audit log.', + }, selectHint: 'Select a workflow on the left, or click "New Workflow" to start a fresh draft.', runs: { header: 'Recent runs ({count})', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d989ba8f..c2df223c 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1953,6 +1953,45 @@ export default { }, jsonInvalid: 'JSON 解析失败:{msg}', jsonOk: 'JSON 语法 OK。', + tabs: { + canvas: '画布视图', + json: 'JSON 编辑', + }, + canvas: { + empty: '当前草稿没有任何步骤,先在 JSON 编辑器中添加步骤或选择一个模板。', + layoutLR: '横向布局', + layoutTB: '纵向布局', + stats: '{nodes} 节点 · {edges} 连线', + parseError: 'JSON 解析失败:{msg}', + fullscreenEnter: '全屏', + fullscreenExit: '退出全屏', + nodeAgent: 'Agent', + nodeExpression: '条件', + nodeApprovalKind: '审批', + nodeChannels: '渠道', + nodeMergeStrategy: '合并策略', + approvalDefault: '人工审批', + channelsEmpty: '未指定', + inspector: { + title: '步骤详情', + empty: '点击画布上的节点查看详情。', + rawHeader: '原始 JSON', + }, + }, + dialogs: { + createTitle: '新建工作流', + fieldName: '名称', + namePlaceholder: '例如:employee-onboarding', + fieldDescription: '描述', + descriptionPlaceholder: '可选,用一两句话说明这条工作流要解决什么问题', + createSubmit: '创建', + publishTitle: '发布工作流', + publishHint: '发布会编译当前草稿,固化为新的 revision;正在运行的实例不会回退。', + publishNote: '发布备注', + publishNotePlaceholder: '可选,简述这次变更的内容', + deleteTitle: '删除工作流', + deleteContent: '将删除工作流「{name}」?此操作可通过审计日志撤销。', + }, selectHint: '在左侧选择一个工作流,或点击「新建工作流」开始。', runs: { header: '近期运行 ({count})', diff --git a/mateclaw-ui/src/views/Triggers.vue b/mateclaw-ui/src/views/Triggers.vue index 0c834c9c..618ef017 100644 --- a/mateclaw-ui/src/views/Triggers.vue +++ b/mateclaw-ui/src/views/Triggers.vue @@ -309,7 +309,7 @@ watch(workspaceId, reload) } .triggers-table th { font-weight: 600; - background: var(--mc-surface, rgba(0, 0, 0, 0.04)); + background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04)); } .pattern-detail { font-family: 'JetBrains Mono', Consolas, monospace; @@ -411,4 +411,33 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } + +/* Mobile / tablet: the trigger table has 9 columns and breaks on + phones. Wrap it in a horizontal-scroll container, collapse the + form to one column, and stack the page header. */ +@media (max-width: 900px) { + .triggers-table { + display: block; + overflow-x: auto; + white-space: nowrap; + } + .triggers-table thead, + .triggers-table tbody, + .triggers-table tr { + display: table; + width: 100%; + table-layout: auto; + } + .form-grid { + grid-template-columns: 1fr; + } + .form-grid .span-2 { + grid-column: span 1; + } + .mc-page-header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +} diff --git a/mateclaw-ui/src/views/Workflows.vue b/mateclaw-ui/src/views/Workflows.vue index bb4ee0b2..ab8917ee 100644 --- a/mateclaw-ui/src/views/Workflows.vue +++ b/mateclaw-ui/src/views/Workflows.vue @@ -50,28 +50,64 @@ -
- - {{ jsonHint }} + +
+ +
-