feat(ui/workflows): vue-flow canvas + component dialogs + dark / mobile polish

This commit is contained in:
matevip 2026-05-08 15:05:45 +08:00
parent 2a25f4fcfb
commit f8499eefa7
12 changed files with 1413 additions and 34 deletions

View File

@ -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",

View File

@ -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

View File

@ -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
================================================================ */

View File

@ -0,0 +1,85 @@
<template>
<ElDialog
v-model="visible"
:title="t('workflows.dialogs.createTitle')"
width="480px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<ElForm :model="form" label-position="top" @submit.prevent="handleSubmit">
<ElFormItem :label="t('workflows.dialogs.fieldName')" required>
<ElInput
v-model="form.name"
:placeholder="t('workflows.dialogs.namePlaceholder')"
maxlength="128"
show-word-limit
autofocus
@keyup.enter="handleSubmit"
/>
</ElFormItem>
<ElFormItem :label="t('workflows.dialogs.fieldDescription')">
<ElInput
v-model="form.description"
type="textarea"
:rows="2"
maxlength="1024"
show-word-limit
:placeholder="t('workflows.dialogs.descriptionPlaceholder')"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">{{ t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="loading" :disabled="!form.name.trim()" @click="handleSubmit">
{{ t('workflows.dialogs.createSubmit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
interface Props {
modelValue: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), { loading: false })
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'submit', payload: { name: string; description: string }): void
}>()
const { t } = useI18n()
const visible = ref(props.modelValue)
const form = reactive({ name: '', description: '' })
// Reset form whenever the dialog re-opens so a stale value from the
// last create attempt doesn't leak in.
watch(
() => props.modelValue,
(open) => {
visible.value = open
if (open) {
form.name = ''
form.description = ''
}
}
)
watch(visible, (v) => emit('update:modelValue', v))
function handleClose() {
visible.value = false
}
function handleSubmit() {
const trimmed = form.name.trim()
if (!trimmed) return
emit('submit', { name: trimmed, description: form.description.trim() })
}
</script>

View File

@ -0,0 +1,77 @@
<template>
<ElDialog
v-model="visible"
:title="t('workflows.dialogs.publishTitle')"
width="480px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<p class="publish-hint">{{ t('workflows.dialogs.publishHint') }}</p>
<ElForm :model="form" label-position="top" @submit.prevent="handleSubmit">
<ElFormItem :label="t('workflows.dialogs.publishNote')">
<ElInput
v-model="form.note"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
:placeholder="t('workflows.dialogs.publishNotePlaceholder')"
autofocus
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">{{ t('common.cancel') }}</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSubmit">
{{ t('workflows.actions.publish') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElDialog, ElForm, ElFormItem, ElInput, ElButton } from 'element-plus'
interface Props {
modelValue: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), { loading: false })
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
(e: 'submit', payload: { note: string }): void
}>()
const { t } = useI18n()
const visible = ref(props.modelValue)
const form = reactive({ note: '' })
watch(
() => props.modelValue,
(open) => {
visible.value = open
if (open) form.note = ''
}
)
watch(visible, (v) => emit('update:modelValue', v))
function handleClose() {
visible.value = false
}
function handleSubmit() {
emit('submit', { note: form.note.trim() })
}
</script>
<style scoped>
.publish-hint {
margin: 0 0 12px;
font-size: 12px;
opacity: 0.75;
}
</style>

View File

@ -0,0 +1,215 @@
<template>
<div class="step-node" :class="`mode-${data.modeType}`" :data-selected="props.selected">
<Handle type="target" :position="targetPosition" />
<div class="step-band" />
<div class="step-body">
<div class="step-header">
<span class="step-icon" v-html="modeIcon" />
<span class="step-name" :title="data.name">{{ data.name }}</span>
<span class="step-mode-tag">{{ data.modeType }}</span>
</div>
<div v-if="agentSlug" class="step-row">
<span class="step-row-label">{{ t('workflows.canvas.nodeAgent') }}</span>
<span class="step-row-value" :title="agentSlug">{{ agentSlug }}</span>
</div>
<div v-if="data.expression" class="step-row">
<span class="step-row-label">{{ t('workflows.canvas.nodeExpression') }}</span>
<code class="step-row-code" :title="data.expression">{{ data.expression }}</code>
</div>
<div v-if="approverHint" class="step-row">
<span class="step-row-label">{{ t('workflows.canvas.nodeApprovalKind') }}</span>
<span class="step-row-value">{{ approverHint }}</span>
</div>
<div v-if="channelHint" class="step-row">
<span class="step-row-label">{{ t('workflows.canvas.nodeChannels') }}</span>
<span class="step-row-value">{{ channelHint }}</span>
</div>
<div v-if="mergeHint" class="step-row">
<span class="step-row-label">{{ t('workflows.canvas.nodeMergeStrategy') }}</span>
<span class="step-row-value">{{ mergeHint }}</span>
</div>
<div v-if="data.promptTemplate" class="step-prompt" :title="data.promptTemplate">
{{ truncatedPrompt }}
</div>
</div>
<Handle type="source" :position="sourcePosition" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Handle, Position, type NodeProps } from '@vue-flow/core'
import type { StepNodeData } from '@/composables/useWorkflowGraph'
const props = defineProps<NodeProps<StepNodeData>>()
const { t } = useI18n()
// Position values come from the parent canvas (LR or TB orientation);
// we read them off the node so the arrows attach in the right place.
const sourcePosition = computed(() => props.sourcePosition ?? Position.Right)
const targetPosition = computed(() => props.targetPosition ?? Position.Left)
const data = computed(() => props.data as StepNodeData)
const agentSlug = computed(() => data.value.agentName?.trim() || '')
const approverHint = computed(() => {
if (data.value.modeType !== 'await_approval') return ''
return data.value.approvalKind || t('workflows.canvas.approvalDefault')
})
const channelHint = computed(() => {
if (data.value.modeType !== 'dispatch_channel') return ''
const list = data.value.channels ?? []
if (!list.length) return t('workflows.canvas.channelsEmpty')
return list.join(', ')
})
const mergeHint = computed(() => {
if (data.value.modeType !== 'write_memory') return ''
return data.value.mergeStrategy || 'append'
})
const truncatedPrompt = computed(() => {
const pt = data.value.promptTemplate ?? ''
if (pt.length <= 80) return pt
return pt.slice(0, 77) + '…'
})
// Inline icons keep the bundle slim no external icon set per node type.
const ICONS: Record<string, string> = {
sequential:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16"/><path d="M14 6l6 6-6 6"/></svg>',
fan_out:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h6"/><path d="M11 6h8"/><path d="M11 18h8"/><path d="M11 6v12"/></svg>',
collect:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6h8"/><path d="M5 18h8"/><path d="M13 6v12"/><path d="M13 12h6"/></svg>',
conditional:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 9-9 9-9-9z"/></svg>',
await_approval:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
dispatch_channel:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l18-8-7 18-2-8z"/></svg>',
write_memory:
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M4 9h16"/><path d="M9 14h6"/></svg>',
}
const modeIcon = computed(() => ICONS[data.value.modeType] ?? ICONS.sequential)
</script>
<style scoped>
.step-node {
position: relative;
width: 220px;
min-height: 88px;
border-radius: 10px;
background: var(--mc-bg-elevated, #ffffff);
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
color: var(--mc-text-primary, inherit);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
display: flex;
align-items: stretch;
overflow: hidden;
transition: box-shadow 0.12s ease, transform 0.12s ease;
}
.step-node[data-selected="true"] {
box-shadow: 0 0 0 2px var(--mc-primary, #4084ff);
transform: translateY(-1px);
}
.step-band {
width: 6px;
flex: 0 0 6px;
background: var(--node-band, #4084ff);
}
/* Per-mode color bands so the canvas reads at a glance. */
.step-node.mode-sequential { --node-band: #4084ff; }
.step-node.mode-fan_out { --node-band: #b388ff; }
.step-node.mode-collect { --node-band: #7e57c2; }
.step-node.mode-conditional { --node-band: #ff8f3f; }
.step-node.mode-await_approval { --node-band: #ffb84d; }
.step-node.mode-dispatch_channel { --node-band: #2ecc71; }
.step-node.mode-write_memory { --node-band: #1abc9c; }
.step-body {
flex: 1;
padding: 8px 10px 8px 10px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.step-header {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 13px;
}
.step-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--node-band, #4084ff);
}
.step-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-mode-tag {
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.06));
color: var(--mc-text-secondary, inherit);
text-transform: lowercase;
letter-spacing: 0.02em;
}
.step-row {
display: flex;
gap: 6px;
align-items: baseline;
font-size: 11px;
opacity: 0.85;
min-width: 0;
}
.step-row-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.55;
flex: 0 0 auto;
}
.step-row-value {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-row-code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.step-prompt {
margin-top: 2px;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 10.5px;
opacity: 0.7;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
/* Limit to two lines to keep the node a sane height. */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,250 @@
<template>
<div class="workflow-canvas" :class="{ fullscreen }">
<div class="canvas-toolbar">
<div class="canvas-toolbar-group">
<button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
{{ t('workflows.canvas.layoutLR') }}
</button>
<button class="canvas-btn" :class="{ active: direction === 'TB' }" @click="direction = 'TB'">
{{ t('workflows.canvas.layoutTB') }}
</button>
</div>
<div class="canvas-toolbar-group">
<span class="canvas-stat">{{ t('workflows.canvas.stats', { nodes: graph.nodes.length, edges: graph.edges.length }) }}</span>
<span v-if="graph.parseError" class="canvas-stat err">{{ t('workflows.canvas.parseError', { msg: graph.parseError }) }}</span>
<button
class="canvas-btn icon-btn"
:title="fullscreen ? t('workflows.canvas.fullscreenExit') : t('workflows.canvas.fullscreenEnter')"
@click="toggleFullscreen"
>
<svg v-if="!fullscreen" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9V3h6"/><path d="M21 9V3h-6"/><path d="M3 15v6h6"/><path d="M21 15v6h-6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 3v6H3"/><path d="M15 3v6h6"/><path d="M9 21v-6H3"/><path d="M15 21v-6h6"/>
</svg>
</button>
</div>
</div>
<div v-if="!graph.nodes.length && !graph.parseError" class="canvas-empty">
{{ t('workflows.canvas.empty') }}
</div>
<VueFlow
v-else-if="!graph.parseError"
:id="canvasId"
class="canvas-flow"
:nodes="graph.nodes"
:edges="graph.edges"
:node-types="nodeTypes"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="true"
:pan-on-drag="true"
:zoom-on-scroll="true"
:min-zoom="0.4"
:max-zoom="1.8"
:default-edge-options="defaultEdgeOptions"
fit-view-on-init
@node-click="handleNodeClick"
@pane-click="handlePaneClick"
>
<Background :pattern-color="dotColor" :gap="20" />
<Controls show-zoom show-fit-view show-interactive />
<MiniMap
:node-color="miniNodeColor"
:node-stroke-color="miniNodeStrokeColor"
:node-class-name="(n: any) => 'mini-' + (n?.data?.modeType ?? 'sequential')"
pannable
zoomable
/>
</VueFlow>
</div>
</template>
<script setup lang="ts">
import { computed, markRaw, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { VueFlow, type Node } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import StepNode from './StepNode.vue'
import { useWorkflowGraph, type StepNodeData } from '@/composables/useWorkflowGraph'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import '@vue-flow/minimap/dist/style.css'
interface Props {
/** Workflow draft JSON — the source of truth. Canvas is read-only over it. */
modelValue: string
/** Stable id so multiple canvases on the page don't collide in vue-flow's state map. */
canvasId?: string
}
const props = withDefaults(defineProps<Props>(), { canvasId: 'workflow-canvas' })
const emit = defineEmits<{
(e: 'select-step', payload: StepNodeData | null): void
}>()
const { t } = useI18n()
const direction = ref<'LR' | 'TB'>('LR')
const jsonRef = computed(() => props.modelValue)
const graph = useWorkflowGraph(jsonRef, direction)
// Register the custom node component once. markRaw prevents Vue from
// turning the component definition into a deep proxy, which vue-flow
// recommends for performance.
const nodeTypes = {
step: markRaw(StepNode),
}
// Match the rest of the app's accent.
const defaultEdgeOptions = {
type: 'smoothstep' as const,
animated: false,
style: { stroke: 'var(--mc-edge, #999999)', strokeWidth: 1.5 },
}
const dotColor = 'var(--mc-canvas-dot, rgba(0, 0, 0, 0.16))'
const miniNodeColor = (node: Node<StepNodeData>) => {
switch (node.data?.modeType) {
case 'fan_out': return '#b388ff'
case 'collect': return '#7e57c2'
case 'conditional': return '#ff8f3f'
case 'await_approval': return '#ffb84d'
case 'dispatch_channel': return '#2ecc71'
case 'write_memory': return '#1abc9c'
default: return '#4084ff'
}
}
const miniNodeStrokeColor = () => 'transparent'
function handleNodeClick(payload: { node: Node<StepNodeData> }) {
emit('select-step', payload?.node?.data ?? null)
}
function handlePaneClick() {
emit('select-step', null)
}
// Reset direction toggle highlight when external code resets the model.
watch(jsonRef, () => {
/* nothing to do — the graph computed re-runs automatically */
})
// Fullscreen toggle. We use a CSS-driven overlay rather than the
// Fullscreen API so the rest of the page (sidebar utilities, app
// header) stays styled consistently and the operator can still drag a
// dialog over the canvas if needed.
const fullscreen = ref(false)
function toggleFullscreen() {
fullscreen.value = !fullscreen.value
}
function handleEsc(e: KeyboardEvent) {
if (e.key === 'Escape' && fullscreen.value) fullscreen.value = false
}
watch(fullscreen, (on) => {
if (on) {
document.addEventListener('keydown', handleEsc)
// Lock page scroll behind the overlay.
document.body.style.overflow = 'hidden'
} else {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
}
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
})
</script>
<style scoped>
.workflow-canvas {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
min-height: 320px;
flex: 1;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
border-radius: 8px;
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.02));
color: var(--mc-text-primary, inherit);
overflow: hidden;
}
.workflow-canvas.fullscreen {
position: fixed;
inset: 0;
z-index: 2000;
border-radius: 0;
border: none;
min-height: 100vh;
background: var(--mc-bg, #ffffff);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
padding: 0;
height: 26px;
}
.canvas-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 10px;
border-bottom: 1px solid var(--mc-border-light, rgba(0, 0, 0, 0.08));
font-size: 12px;
background: var(--mc-bg-elevated, transparent);
}
.canvas-toolbar-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.canvas-btn {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
.canvas-btn.active {
background: var(--mc-primary, #4084ff);
border-color: var(--mc-primary, #4084ff);
color: #ffffff;
}
.canvas-stat {
opacity: 0.75;
}
.canvas-stat.err {
color: #c0392b;
font-family: 'JetBrains Mono', Consolas, monospace;
}
.canvas-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
opacity: 0.7;
padding: 24px;
}
.canvas-flow {
flex: 1;
width: 100%;
height: 100%;
min-height: 280px;
}
</style>

View File

@ -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>): 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<StepNodeData>[]; 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<string>()
const nodes: Node<StepNodeData>[] = 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<StepNodeData>[],
edges: Edge[],
direction: 'LR' | 'TB' = 'LR'
): { nodes: Node<StepNodeData>[]; 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<StepNodeData>[] = 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<string>, 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
}

View File

@ -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})',

View File

@ -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})',

View File

@ -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;
}
}
</style>

View File

@ -50,28 +50,64 @@
<button class="btn-danger" :disabled="busy" @click="remove">{{ t('workflows.actions.delete') }}</button>
</div>
</header>
<div class="editor-toolbar">
<label class="template-picker">
<span>{{ t('workflows.templates.label') }}</span>
<select v-model="templateChoice" @change="insertTemplate">
<option value="">{{ t('workflows.templates.placeholder') }}</option>
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
<option value="collect">{{ t('workflows.templates.collect') }}</option>
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
</select>
</label>
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
<div class="editor-tabs">
<button class="tab-btn" :class="{ active: editorTab === 'canvas' }" @click="editorTab = 'canvas'">
{{ t('workflows.tabs.canvas') }}
</button>
<button class="tab-btn" :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
{{ t('workflows.tabs.json') }}
</button>
</div>
<textarea
v-model="draftJson"
class="editor-body"
spellcheck="false"
:placeholder="t('workflows.bodyPlaceholder')"
/>
<div v-if="editorTab === 'canvas'" class="canvas-pane">
<WorkflowCanvas
v-model="canvasModel"
:canvas-id="`wf-${selected.id}`"
@select-step="onCanvasSelect"
/>
<aside v-if="canvasSelection" class="canvas-inspector">
<header>{{ t('workflows.canvas.inspector.title') }}</header>
<dl class="inspector-grid">
<dt>{{ t('workflows.dialogs.fieldName') }}</dt><dd>{{ canvasSelection.name }}</dd>
<dt>mode</dt><dd>{{ canvasSelection.modeType }}</dd>
<dt v-if="canvasSelection.agentName">{{ t('workflows.canvas.nodeAgent') }}</dt>
<dd v-if="canvasSelection.agentName">{{ canvasSelection.agentName }}</dd>
<dt v-if="canvasSelection.expression">{{ t('workflows.canvas.nodeExpression') }}</dt>
<dd v-if="canvasSelection.expression"><code>{{ canvasSelection.expression }}</code></dd>
</dl>
<details>
<summary>{{ t('workflows.canvas.inspector.rawHeader') }}</summary>
<pre class="inspector-raw">{{ inspectorJson }}</pre>
</details>
</aside>
</div>
<div v-else class="json-pane">
<div class="editor-toolbar">
<label class="template-picker">
<span>{{ t('workflows.templates.label') }}</span>
<select v-model="templateChoice" @change="insertTemplate">
<option value="">{{ t('workflows.templates.placeholder') }}</option>
<option value="sequential">{{ t('workflows.templates.sequential') }}</option>
<option value="fan_out">{{ t('workflows.templates.fan_out') }}</option>
<option value="collect">{{ t('workflows.templates.collect') }}</option>
<option value="conditional">{{ t('workflows.templates.conditional') }}</option>
<option value="await_approval">{{ t('workflows.templates.await_approval') }}</option>
<option value="dispatch_channel">{{ t('workflows.templates.dispatch_channel') }}</option>
<option value="write_memory">{{ t('workflows.templates.write_memory') }}</option>
</select>
</label>
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
</div>
<textarea
v-model="draftJson"
class="editor-body"
spellcheck="false"
:placeholder="t('workflows.bodyPlaceholder')"
/>
</div>
<div v-if="compileErrors.length" class="errors-panel">
<div class="errors-title">{{ t('workflows.compileErrorsTitle', { count: compileErrors.length }) }}</div>
<ul>
@ -126,12 +162,16 @@
</div>
</div>
</div>
<CreateWorkflowDialog v-model="createDialogOpen" :loading="busy" @submit="onCreateSubmit" />
<PublishDialog v-model="publishDialogOpen" :loading="busy" @submit="onPublishSubmit" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import {
workflowApi,
type WorkflowSummary,
@ -141,6 +181,10 @@ import {
type WorkflowCompileFailure,
} from '@/api'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
import PublishDialog from '@/components/workflow/PublishDialog.vue'
import type { StepNodeData } from '@/composables/useWorkflowGraph'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
@ -160,6 +204,36 @@ const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(nul
const templateChoice = ref('')
// View-mode toggle between the canvas (read-only graph derived from
// the JSON) and the raw JSON editor. Canvas is the default most
// authors visit the page to make sense of an existing flow rather
// than to type fresh JSON.
const editorTab = ref<'canvas' | 'json'>('canvas')
// The canvas reads from `draftJson` directly. We keep the model write
// path on the JSON editor only the canvas is purely a derived view
// to avoid two sources of truth drifting apart.
const canvasModel = computed({
get: () => draftJson.value,
set: (v: string) => { draftJson.value = v },
})
const canvasSelection = ref<StepNodeData | null>(null)
function onCanvasSelect(payload: StepNodeData | null) {
canvasSelection.value = payload
}
const inspectorJson = computed(() => {
if (!canvasSelection.value) return ''
try {
return JSON.stringify(canvasSelection.value.raw, null, 2)
} catch {
return ''
}
})
const createDialogOpen = ref(false)
const publishDialogOpen = ref(false)
// Live JSON-syntax check on the textarea so the operator sees parse errors
// immediately, instead of waiting for compile to round-trip.
const jsonHint = computed(() => {
@ -292,18 +366,23 @@ async function loadRun(runId: number) {
}
}
async function openCreate() {
function openCreate() {
if (!workspaceId.value) return
createDialogOpen.value = true
}
async function onCreateSubmit(payload: { name: string; description: string }) {
if (!workspaceId.value) return
const name = window.prompt(t('workflows.prompts.newName'), t('workflows.prompts.defaultName'))
if (!name) return
busy.value = true
try {
const res = await workflowApi.create({
workspaceId: workspaceId.value,
name,
name: payload.name,
description: payload.description || undefined,
enabled: true,
})
const created = res.data as unknown as WorkflowSummary
createDialogOpen.value = false
await reload()
if (created?.id) await select(created.id)
} catch (e) {
@ -359,14 +438,19 @@ async function compile() {
}
}
async function publish() {
function publish() {
if (!selected.value) return
publishDialogOpen.value = true
}
async function onPublishSubmit(payload: { note: string }) {
if (!selected.value) return
busy.value = true
compileErrors.value = []
try {
await workflowApi.saveDraft(selected.value.id, draftJson.value)
const note = window.prompt(t('workflows.prompts.publishNote'), '') ?? undefined
await workflowApi.publish(selected.value.id, note)
await workflowApi.publish(selected.value.id, payload.note || undefined)
publishDialogOpen.value = false
setStatus(t('workflows.status.published'), 'ok')
await reload()
} catch (e) {
@ -378,7 +462,21 @@ async function publish() {
async function remove() {
if (!selected.value) return
if (!window.confirm(t('workflows.prompts.deleteConfirm', { name: selected.value.name ?? '' }))) return
const name = selected.value.name ?? ''
try {
await ElMessageBox.confirm(
t('workflows.dialogs.deleteContent', { name }),
t('workflows.dialogs.deleteTitle'),
{
confirmButtonText: t('workflows.actions.delete'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
} catch {
// user cancelled nothing to do
return
}
busy.value = true
try {
await workflowApi.delete(selected.value.id)
@ -471,7 +569,7 @@ watch(workspaceId, reload)
}
.list-row:hover,
.run-row:hover {
background: var(--mc-surface-hover, rgba(0, 0, 0, 0.05));
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.05));
}
.list-row.active {
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.18));
@ -493,7 +591,7 @@ watch(workspaceId, reload)
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
background: var(--mc-surface-hover, rgba(0, 0, 0, 0.06));
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.06));
}
.badge.published {
background: #2ecc71;
@ -574,7 +672,7 @@ watch(workspaceId, reload)
padding: 12px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.1));
border-radius: 6px;
background: var(--mc-surface, rgba(0, 0, 0, 0.02));
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.02));
color: inherit;
resize: vertical;
min-height: 320px;
@ -655,7 +753,7 @@ watch(workspaceId, reload)
margin-top: 12px;
padding: 10px;
border-radius: 6px;
background: var(--mc-surface, rgba(0, 0, 0, 0.04));
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
}
.run-detail-title {
font-weight: 600;
@ -711,9 +809,136 @@ button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editor-tabs {
display: flex;
gap: 4px;
padding: 4px 0 8px;
border-bottom: 1px solid var(--mc-border, rgba(0, 0, 0, 0.06));
margin-bottom: 8px;
}
.tab-btn {
padding: 6px 14px;
border: none;
background: transparent;
color: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
opacity: 0.7;
}
.tab-btn:hover {
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
opacity: 1;
}
.tab-btn.active {
opacity: 1;
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.14));
color: var(--mc-primary, #4084ff);
}
.canvas-pane {
flex: 1;
display: grid;
grid-template-columns: 1fr 240px;
gap: 12px;
min-height: 360px;
}
.canvas-inspector {
background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.02));
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.08));
border-radius: 8px;
padding: 10px;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: hidden;
}
.canvas-inspector header {
font-weight: 600;
font-size: 13px;
margin-bottom: 4px;
}
.inspector-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 8px;
margin: 0;
font-size: 12px;
}
.inspector-grid dt {
opacity: 0.6;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.04em;
align-self: center;
}
.inspector-grid dd {
margin: 0;
word-break: break-word;
}
.inspector-grid code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11px;
}
.inspector-raw {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 10.5px;
background: var(--mc-bg-sunken, rgba(0, 0, 0, 0.04));
border-radius: 6px;
padding: 8px;
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow: auto;
margin-top: 6px;
}
.json-pane {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 360px;
}
/* Responsive: collapse to a single column below 1100px so the editor
can breathe on tablets, and stack everything vertically below 720px
so the page is usable on a phone. The runs panel becomes a
collapsible details element on small screens. */
@media (max-width: 1100px) {
.workflows-grid {
grid-template-columns: 1fr;
}
.canvas-pane {
grid-template-columns: 1fr;
}
.canvas-inspector {
max-height: 240px;
}
}
@media (max-width: 720px) {
.mc-page-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.editor-header {
flex-direction: column;
align-items: stretch;
}
.editor-name,
.editor-desc {
flex: none;
width: 100%;
}
.editor-actions {
justify-content: flex-end;
}
.editor-body {
min-height: 240px;
}
.canvas-pane {
min-height: 320px;
}
}
</style>