feat(ui/workflows): Monaco editor + schema validation + compile-error markers

This commit is contained in:
matevip 2026-05-08 15:08:50 +08:00
parent dcf1f56944
commit de89df6900
11 changed files with 693 additions and 157 deletions

View File

@ -13,6 +13,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@google/model-viewer": "^4.2.0",
"@guolao/vue-monaco-editor": "^1.6.0",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
@ -30,6 +31,7 @@
"marked": "^15.0.6",
"marked-highlight": "^2.2.3",
"mermaid": "^11.14.0",
"monaco-editor": "^0.55.1",
"pinia": "^3.0.1",
"pixelarticons": "2.1.0",
"vue": "^3.5.13",

View File

@ -14,6 +14,9 @@ importers:
'@google/model-viewer':
specifier: ^4.2.0
version: 4.2.0(three@0.182.0)
'@guolao/vue-monaco-editor':
specifier: ^1.6.0
version: 1.6.0(monaco-editor@0.55.1)(vue@3.5.31(typescript@5.7.3))
'@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))
@ -65,6 +68,9 @@ importers:
mermaid:
specifier: ^11.14.0
version: 11.14.0
monaco-editor:
specifier: ^0.55.1
version: 0.55.1
pinia:
specifier: ^3.0.1
version: 3.0.4(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3))
@ -376,6 +382,16 @@ packages:
peerDependencies:
three: ^0.182.0
'@guolao/vue-monaco-editor@1.6.0':
resolution: {integrity: sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==}
peerDependencies:
'@vue/composition-api': ^1.7.2
monaco-editor: '>=0.43.0'
vue: ^2.6.14 || >=3.0.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@ -435,6 +451,9 @@ packages:
'@mermaid-js/parser@1.1.0':
resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==}
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@monogrid/gainmap-js@3.4.0':
resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==}
peerDependencies:
@ -1230,6 +1249,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.3:
resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
@ -1656,6 +1678,11 @@ packages:
peerDependencies:
marked: '>=4 <18'
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
@ -1693,6 +1720,9 @@ packages:
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -1861,6 +1891,9 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@ -2242,6 +2275,13 @@ snapshots:
lit: 3.3.2
three: 0.182.0
'@guolao/vue-monaco-editor@1.6.0(monaco-editor@0.55.1)(vue@3.5.31(typescript@5.7.3))':
dependencies:
'@monaco-editor/loader': 1.7.0
monaco-editor: 0.55.1
vue: 3.5.31(typescript@5.7.3)
vue-demi: 0.14.10(vue@3.5.31(typescript@5.7.3))
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@ -2302,6 +2342,10 @@ snapshots:
dependencies:
langium: 4.2.2
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
'@monogrid/gainmap-js@3.4.0(three@0.182.0)':
dependencies:
promise-worker-transferable: 1.0.4
@ -3102,6 +3146,10 @@ snapshots:
detect-libc@2.1.2: {}
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.3.3:
optionalDependencies:
'@types/trusted-types': 2.0.7
@ -3551,6 +3599,8 @@ snapshots:
dependencies:
marked: 15.0.12
marked@14.0.0: {}
marked@15.0.12: {}
marked@16.4.2: {}
@ -3602,6 +3652,11 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.3
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
ms@2.1.3: {}
muggle-string@0.4.1: {}
@ -3767,6 +3822,8 @@ snapshots:
speakingurl@14.0.1: {}
state-local@1.0.7: {}
strip-json-comments@3.1.1: {}
stylis@4.4.0: {}

View File

@ -74,7 +74,11 @@ const targetPosition = computed(() => props.targetPosition ?? Position.Left)
const data = computed(() => props.data as StepNodeData)
const agentSlug = computed(() => data.value.agentName?.trim() || '')
const agentSlug = computed(() => {
const name = data.value.agentName?.trim()
if (name) return name
return data.value.agentId != null ? `#${data.value.agentId}` : ''
})
const approverHint = computed(() => {
if (data.value.modeType !== 'await_approval') return ''

View File

@ -39,41 +39,22 @@
<label class="panel-field" v-if="modeNeedsAgent">
<span class="field-label">{{ t('workflows.canvas.nodeAgent') }}</span>
<!-- Agent picker drops down the workspace's agents and falls
back to a free-text input when the operator wants to type a
name that isn't in the list (legacy drafts, agent created in
a different workspace by an admin, etc.). -->
<!-- Digital employee picker. Agent steps persist agentId so the
workflow cannot drift when an employee is renamed. -->
<div class="agent-picker">
<select
v-if="!useFreeAgentName"
class="mc-input"
:value="agentSelectValue"
@change="onAgentSelect"
>
<option value="">{{ t('workflows.canvas.fields.agentPlaceholder') }}</option>
<option v-for="a in availableAgents" :key="a.id" :value="a.name">
<option v-for="a in availableAgents" :key="a.id" :value="String(a.id)">
{{ a.name }}<template v-if="a.title"> {{ a.title }}</template>
</option>
<option v-if="agentNotInList" :value="step.agentName ?? ''" disabled>
{{ t('workflows.canvas.fields.agentMissing', { name: step.agentName }) }}
<option v-if="agentNotInList" :value="agentSelectValue" disabled>
{{ t('workflows.canvas.fields.agentMissing', { name: step.agentName || step.agentId }) }}
</option>
<option value="__custom__">{{ t('workflows.canvas.fields.agentUseCustom') }}</option>
</select>
<input
v-else
class="mc-input"
:value="step.agentName ?? ''"
@input="patch({ agentName: ($event.target as HTMLInputElement).value })"
spellcheck="false"
:placeholder="t('workflows.canvas.fields.agentPlaceholder')"
/>
<button
v-if="useFreeAgentName"
type="button"
class="agent-toggle"
:title="t('workflows.canvas.fields.agentBackToList')"
@click="useFreeAgentName = false"
>×</button>
</div>
</label>
@ -109,7 +90,6 @@
>
<option value="text">text</option>
<option value="json">json</option>
<option value="bytes">bytes</option>
</select>
</label>
</div>
@ -189,14 +169,40 @@
<!-- dispatch_channel -->
<template v-if="modeType === 'dispatch_channel'">
<label class="panel-field">
<div class="panel-field">
<span class="field-label">{{ t('workflows.canvas.fields.channels') }}</span>
<div class="channel-picker" v-if="availableDispatchChannels.length">
<label
v-for="channel in availableDispatchChannels"
:key="channel.channelType"
class="channel-option"
>
<input
type="checkbox"
:checked="selectedChannelTypes.includes(channel.channelType)"
@change="onDispatchChannelToggle(channel.channelType, ($event.target as HTMLInputElement).checked)"
/>
<span class="channel-name">{{ channel.name }}</span>
<code class="channel-type">{{ channel.channelType }}</code>
</label>
</div>
<p v-else class="panel-hint">{{ t('workflows.canvas.fields.channelsEmptyHint') }}</p>
<p v-if="unknownSelectedChannels.length" class="panel-hint danger">
{{ t('workflows.canvas.fields.channelsUnknown', { channels: unknownSelectedChannels.join(', ') }) }}
</p>
</div>
<label
v-for="channelType in selectedChannelTypes"
:key="`target-${channelType}`"
class="panel-field"
>
<span class="field-label">{{ t('workflows.canvas.fields.dispatchTarget', { channel: channelLabel(channelType) }) }}</span>
<input
class="mc-input"
:value="(modeField('channels', []) as string[]).join(', ')"
@change="patchMode({ channels: parseList(($event.target as HTMLInputElement).value) })"
:value="dispatchTarget(channelType)"
@input="onDispatchTargetInput(channelType, ($event.target as HTMLInputElement).value)"
spellcheck="false"
placeholder="feishu, dingtalk"
:placeholder="t('workflows.canvas.fields.dispatchTargetPlaceholder')"
/>
</label>
<label class="panel-field">
@ -271,7 +277,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RawStep } from '@/composables/useWorkflowGraph'
@ -283,6 +289,12 @@ export interface AgentOption {
name: string
title?: string
}
export interface ChannelOption {
id: number | string
name: string
channelType: string
enabled?: boolean
}
interface Props {
/** The step the panel currently edits, or null. */
@ -291,10 +303,13 @@ interface Props {
index: number
/** Workspace-scoped agent list rendered as a dropdown. */
availableAgents?: AgentOption[]
/** Workspace-scoped enabled channels rendered as a multi-select picker. */
availableChannels?: ChannelOption[]
}
const props = withDefaults(defineProps<Props>(), {
availableAgents: () => [],
availableChannels: () => [],
})
const emit = defineEmits<{
(e: 'patch', payload: { index: number; patch: Partial<RawStep> }): void
@ -317,9 +332,9 @@ function modeOptionLabel(type: string): string {
}
const modeNeedsAgent = computed(() => {
// fan_out / collect / await_approval / dispatch_channel / write_memory
// collect / await_approval / dispatch_channel / write_memory
// don't take an agent the runtime calls a service adapter instead.
return ['sequential', 'conditional'].includes(modeType.value)
return ['sequential', 'fan_out', 'conditional'].includes(modeType.value)
})
const rawJson = computed(() => {
@ -364,21 +379,89 @@ function onDuplicate() {
emit('duplicate', { index: props.index })
}
// Agent picker support toggles between dropdown and free-text input.
const useFreeAgentName = ref(false)
const agentNotInList = computed(() => {
if (props.step?.agentId != null) {
return !props.availableAgents.some((a) => String(a.id) === String(props.step?.agentId))
}
const n = props.step?.agentName
if (!n) return false
return !props.availableAgents.some((a) => a.name === n)
})
const agentSelectValue = computed(() => props.step?.agentName ?? '')
const agentSelectValue = computed(() => {
if (props.step?.agentId != null) return String(props.step.agentId)
const byName = props.availableAgents.find((a) => a.name === props.step?.agentName)
return byName ? String(byName.id) : (props.step?.agentName ?? '')
})
function onAgentSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
if (v === '__custom__') {
useFreeAgentName.value = true
if (!v) {
patch({ agentId: undefined, agentName: undefined })
return
}
patch({ agentName: v })
const id = Number(v)
const selected = props.availableAgents.find((a) => String(a.id) === v)
patch({
agentId: Number.isFinite(id) ? id : undefined,
// Kept as a denormalized label for the canvas node; runtime resolves by agentId.
agentName: selected?.name,
})
}
const availableDispatchChannels = computed(() => {
const seen = new Set<string>()
const rows: ChannelOption[] = []
for (const channel of props.availableChannels) {
if (!channel?.channelType || channel.enabled === false) continue
if (seen.has(channel.channelType)) continue
seen.add(channel.channelType)
rows.push({
id: channel.id,
name: channel.name || channel.channelType,
channelType: channel.channelType,
enabled: channel.enabled,
})
}
return rows
})
const selectedChannelTypes = computed(() => {
const raw = modeField('channels', []) as unknown
if (!Array.isArray(raw)) return []
return raw.map((v) => String(v)).filter(Boolean)
})
const unknownSelectedChannels = computed(() => {
const known = new Set(availableDispatchChannels.value.map((c) => c.channelType))
return selectedChannelTypes.value.filter((c) => !known.has(c))
})
function channelLabel(channelType: string): string {
const row = availableDispatchChannels.value.find((c) => c.channelType === channelType)
return row ? `${row.name} (${row.channelType})` : channelType
}
function dispatchTargets(): Record<string, string> {
const raw = modeField('targets', {}) as unknown
return raw && typeof raw === 'object' && !Array.isArray(raw)
? { ...(raw as Record<string, string>) }
: {}
}
function dispatchTarget(channelType: string): string {
return dispatchTargets()[channelType] ?? ''
}
function onDispatchChannelToggle(channelType: string, checked: boolean) {
const next = new Set(selectedChannelTypes.value)
const targets = dispatchTargets()
if (checked) {
next.add(channelType)
} else {
next.delete(channelType)
delete targets[channelType]
}
patchMode({ channels: Array.from(next), targets })
}
function onDispatchTargetInput(channelType: string, raw: string) {
const targets = dispatchTargets()
const value = raw.trim()
if (value) targets[channelType] = value
else delete targets[channelType]
patchMode({ targets })
}
</script>
@ -502,6 +585,35 @@ function onAgentSelect(e: Event) {
gap: 4px;
}
.agent-picker .mc-input { flex: 1; min-width: 0; }
.channel-picker {
display: grid;
gap: 6px;
}
.channel-option {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 6px 8px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 6px;
background: var(--mc-bg, transparent);
cursor: pointer;
}
.channel-option input {
flex: 0 0 auto;
}
.channel-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-type {
font-size: 10.5px;
color: var(--mc-text-tertiary, #888);
}
.agent-toggle {
width: 24px;
height: 28px;
@ -522,6 +634,9 @@ function onAgentSelect(e: Event) {
font-style: italic;
margin: 0;
}
.panel-hint.danger {
color: var(--mc-danger, #c0392b);
}
.raw-section summary {
font-size: 11px;
color: var(--mc-text-tertiary, #888);

View File

@ -8,13 +8,13 @@
<span class="visually-hidden">{{ t('workflows.canvas.addNode') }}</span>
<select :value="''" class="canvas-add-select" @change="onAddNode">
<option value="" disabled>{{ t('workflows.canvas.addNode') }}</option>
<option value="sequential">+ sequential</option>
<option value="fan_out">+ fan_out</option>
<option value="collect">+ collect</option>
<option value="conditional">+ conditional</option>
<option value="await_approval">+ await_approval</option>
<option value="dispatch_channel">+ dispatch_channel</option>
<option value="write_memory">+ write_memory</option>
<option value="sequential">+ {{ modeOptionLabel('sequential') }}</option>
<option value="fan_out">+ {{ modeOptionLabel('fan_out') }}</option>
<option value="collect">+ {{ modeOptionLabel('collect') }}</option>
<option value="conditional">+ {{ modeOptionLabel('conditional') }}</option>
<option value="await_approval">+ {{ modeOptionLabel('await_approval') }}</option>
<option value="dispatch_channel">+ {{ modeOptionLabel('dispatch_channel') }}</option>
<option value="write_memory">+ {{ modeOptionLabel('write_memory') }}</option>
</select>
</label>
<button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
@ -141,6 +141,12 @@ function onAddNode(e: Event) {
const { t } = useI18n()
function modeOptionLabel(type: string): string {
const key = `workflows.canvas.modeLabels.${type}`
const localized = t(key, '')
return localized && localized !== key ? localized : type
}
const direction = ref<'LR' | 'TB'>('LR')
const jsonRef = computed(() => props.modelValue)
const graph = useWorkflowGraph(jsonRef, direction)

View File

@ -0,0 +1,266 @@
<template>
<div class="json-editor-shell" :class="{ dark: isDark }">
<VueMonacoEditor
:value="modelValue"
language="json"
:theme="isDark ? 'vs-dark' : 'vs'"
:options="editorOptions"
:path="filePath"
class="json-editor-monaco"
@update:value="(v: string | undefined) => emit('update:modelValue', v ?? '')"
@mount="onEditorMount"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue'
import { VueMonacoEditor, loader } from '@guolao/vue-monaco-editor'
import * as monaco from 'monaco-editor'
// Vite's ?worker imports Monaco loads its language workers as separate
// JS bundles, and without this MonacoEnvironment shim the editor falls
// back to fetching from a CDN URL ('vs/base/worker/workerMain.js') that
// our private deploys don't have. Loading them here means the JSON
// editor still works offline.
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
import { useThemeStore } from '@/stores/useThemeStore'
import type { WorkflowCompileError } from '@/api'
// Monaco reads MonacoEnvironment.getWorker once on first model load.
// Set it on the global so multiple editor instances share the same
// worker factory.
declare global {
interface Window {
MonacoEnvironment?: { getWorker(_moduleId: string, label: string): Worker }
}
}
if (typeof window !== 'undefined' && !window.MonacoEnvironment) {
window.MonacoEnvironment = {
getWorker(_moduleId: string, label: string) {
// Workflow editor only needs json + the base editor worker.
// Other languages (typescript / css / html) are not used in this
// page so we keep the bundle slim by not registering them.
if (label === 'json') return new JsonWorker()
return new EditorWorker()
},
}
}
interface Props {
modelValue: string
/** Compile errors from the backend, surfaced as inline markers. */
compileErrors?: WorkflowCompileError[]
/** Stable monaco "file path" so multiple editors don't share a model. */
filePath?: string
}
const props = withDefaults(defineProps<Props>(), {
compileErrors: () => [],
filePath: 'inmemory://workflow-draft.json',
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
}>()
// Make the bundled npm module the source of truth for monaco assets,
// rather than letting vue-monaco-editor pull a CDN copy at runtime.
// Without this you get a 5MB CDN fetch on every cold visit, which is
// painful for offline / private deploys.
loader.config({ monaco })
const themeStore = useThemeStore()
const isDark = computed(() => themeStore.isDark)
const editorOptions = {
// `automaticLayout` lets the editor relayout when its container resizes
// (canvas / json tab toggles, sidebar collapse, fullscreen).
automaticLayout: true,
fontSize: 13,
fontFamily: 'JetBrains Mono, Consolas, monospace',
minimap: { enabled: false },
// Tabular indent matches the templates the picker inserts.
tabSize: 2,
// Workflow JSON is small (a few hundred lines tops); bracketPairs +
// word-wrap save more screen than they cost.
wordWrap: 'on' as const,
bracketPairColorization: { enabled: true },
// Keep operators focused the bottom status bar adds noise.
scrollBeyondLastLine: false,
// Show the right-margin guide so authors notice when prompt
// templates start wrapping.
rulers: [100],
// Render whitespace only on selection so it doesn't flood the view.
renderWhitespace: 'selection' as const,
}
// Workflow JSON schema gives Monaco hover docs + completion + inline
// validation for the structural fields. Rendered from the same shape
// the backend WorkflowSchemaValidator enforces; modes are an enum so
// typos like "sequencial" surface as a marker before compile.
const WORKFLOW_SCHEMA = {
$id: 'mateclaw://workflow.schema.json',
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'MateClaw Workflow',
type: 'object',
required: ['steps'],
properties: {
steps: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['name', 'mode'],
properties: {
name: { type: 'string', maxLength: 64, description: 'Unique step name within the workflow.' },
agentName: { type: 'string', description: 'Workspace-scoped agent name; required for sequential / conditional.' },
agentId: { type: 'integer', description: 'Workspace-scoped agent id; alternative to agentName.' },
promptTemplate: { type: 'string', description: 'Pebble template threaded with `inputs` and `outputs`.' },
outputVar: { type: 'string', description: 'Stash this step\'s output under `outputs.<name>` for later steps.' },
outputContentType: { type: 'string', enum: ['text', 'json'], description: 'How later steps decode this output.' },
timeoutSecs: { type: 'integer', minimum: 0 },
mode: {
type: 'object',
required: ['type'],
properties: {
type: {
type: 'string',
enum: ['sequential', 'fan_out', 'collect', 'conditional',
'await_approval', 'dispatch_channel', 'write_memory'],
},
expression: { type: 'string', description: 'Pebble expression for `conditional`.' },
approvalKind: { type: 'string', description: 'Approval label, e.g. manager / oncall.' },
approverChannels: { type: 'array', items: { type: 'string' }, minItems: 1 },
approvalMessage: { type: 'string' },
channels: { type: 'array', items: { type: 'string' } },
targets: { type: 'object', additionalProperties: { type: 'string' } },
content: { type: 'string' },
employeeId: { type: 'string' },
file: { type: 'string' },
mergeStrategy: { type: 'string', enum: ['append', 'prepend', 'replace_section', 'overwrite'] },
},
additionalProperties: true,
},
},
additionalProperties: true,
},
},
},
additionalProperties: false,
} as const
let editorRef: monaco.editor.IStandaloneCodeEditor | null = null
function onEditorMount(editor: monaco.editor.IStandaloneCodeEditor) {
editorRef = editor
// Bind the schema to JSON files matching our virtual path. The cast
// is needed because monaco's d.ts marks the static `languages.json`
// namespace as deprecated even though it's still the official runtime
// API for jsonDefaults.setDiagnosticsOptions in the v0.55.x line.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const jsonLang = (monaco.languages as any).json
if (jsonLang?.jsonDefaults) {
jsonLang.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: false,
schemas: [
{
uri: 'mateclaw://workflow.schema.json',
fileMatch: [props.filePath],
schema: WORKFLOW_SCHEMA,
},
],
})
}
// Re-apply markers if the parent already pushed compile errors before
// the editor mounted.
applyCompileErrorMarkers(props.compileErrors ?? [])
}
/**
* Map backend compile errors into Monaco markers so they show inline in
* the gutter + as squiggles. We can't always derive a precise line
* range from the JSON path (e.g. `steps[2].mode.expression`), so the
* fallback is to put the marker at line 1 with the path quoted in the
* message at least the operator sees the path and the cause without
* scrolling to a separate panel.
*/
function applyCompileErrorMarkers(errors: WorkflowCompileError[]) {
if (!editorRef) return
const model = editorRef.getModel()
if (!model) return
const markers: monaco.editor.IMarkerData[] = errors.map((err) => {
const range = locatePath(model.getValue(), err.path)
return {
severity: monaco.MarkerSeverity.Error,
message: `[${err.code}] ${err.message}\n${err.path}`,
startLineNumber: range.startLine,
startColumn: range.startCol,
endLineNumber: range.endLine,
endColumn: range.endCol,
}
})
monaco.editor.setModelMarkers(model, 'workflow-compile', markers)
}
/**
* Locate a JSON path like `steps[2].mode.expression` in the source text.
* v0 implementation: walk the source line by line and look for the last
* key segment as a token; good enough to put markers near the right
* field for most well-formatted drafts. Falls back to (1,1)(1,1) when
* we can't find anything Monaco still shows the marker in the
* Problems panel.
*/
function locatePath(source: string, path: string): {
startLine: number; startCol: number; endLine: number; endCol: number
} {
const fallback = { startLine: 1, startCol: 1, endLine: 1, endCol: 1 }
if (!source || !path) return fallback
// Strip the array index so we search for the leaf key only.
const leaf = path.replace(/^.*?(\.|^)([^.[]+)\]?$/, '$2')
if (!leaf) return fallback
const lines = source.split('\n')
for (let i = 0; i < lines.length; i++) {
const idx = lines[i].indexOf(`"${leaf}"`)
if (idx >= 0) {
return {
startLine: i + 1,
startCol: idx + 1,
endLine: i + 1,
endCol: idx + leaf.length + 3,
}
}
}
return fallback
}
watch(
() => props.compileErrors,
(next) => applyCompileErrorMarkers(next ?? []),
{ deep: true }
)
onBeforeUnmount(() => {
if (editorRef) {
const model = editorRef.getModel()
if (model) monaco.editor.setModelMarkers(model, 'workflow-compile', [])
}
})
</script>
<style scoped>
.json-editor-shell {
flex: 1;
display: flex;
min-height: 320px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 6px;
overflow: hidden;
background: var(--mc-bg, transparent);
}
.json-editor-monaco {
width: 100%;
height: 100%;
min-height: 320px;
}
</style>

View File

@ -28,6 +28,7 @@ import dagre from 'dagre'
export interface RawStep {
name?: string
agentId?: number
agentName?: string
mode?: { type?: string; expression?: string; [k: string]: unknown }
promptTemplate?: string
@ -44,6 +45,7 @@ export interface StepNodeData {
index: number
name: string
modeType: string
agentId?: number
agentName?: string
promptTemplate?: string
expression?: string
@ -100,6 +102,7 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
index: idx,
name: step?.name?.trim() || fallbackName(idx),
modeType,
agentId: typeof step?.agentId === 'number' ? step.agentId : undefined,
agentName: step?.agentName,
promptTemplate: step?.promptTemplate,
expression: typeof step?.mode?.expression === 'string' ? step.mode!.expression : undefined,

View File

@ -1926,7 +1926,7 @@ export default {
publish: 'Publish',
delete: 'Delete',
},
bodyPlaceholder: '{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}',
bodyPlaceholder: '{"steps":[{"name":"step-a","agentId":1,"mode":{"type":"sequential"},"promptTemplate":"..."}]}',
compileErrorsTitle: '{count} compile error(s)',
templates: {
label: 'Insert template',
@ -1955,7 +1955,7 @@ export default {
fullscreenExit: 'Exit fullscreen',
addNode: '+ Add node',
modeLabel: 'Mode',
nodeAgent: 'Agent',
nodeAgent: 'Digital employee',
nodeExpression: 'Condition',
nodeApprovalKind: 'Approval',
nodeChannels: 'Channels',
@ -1984,10 +1984,8 @@ export default {
fields: {
name: 'Name',
namePlaceholder: 'step-name',
agentPlaceholder: 'Select agent',
agentMissing: 'Current agentName is not in the list: {name}',
agentUseCustom: '— Type a custom agentName —',
agentBackToList: 'Back to dropdown',
agentPlaceholder: 'Select digital employee',
agentMissing: 'Current digital employee is not in the list: {name}',
promptTemplate: 'Prompt template',
promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: 'Output variable',
@ -1997,7 +1995,11 @@ export default {
approverChannels: 'Approver channels (comma-sep)',
approvalMessage: 'Approval message',
timeoutSecs: 'Timeout (s)',
channels: 'Channels (comma-sep)',
channels: 'Dispatch channels',
channelsEmptyHint: 'No enabled channels in this workspace. Enable one in Channel Management first.',
channelsUnknown: 'Draft references unavailable channels: {channels}',
dispatchTarget: '{channel} target',
dispatchTargetPlaceholder: 'Group ID / user ID / conversation ID',
dispatchContent: 'Message content',
employeeId: 'Employee ID',
memoryFile: 'Memory file',

View File

@ -1938,7 +1938,7 @@ export default {
publish: '发布',
delete: '删除',
},
bodyPlaceholder: '{"steps":[{"name":"step-a","agentName":"...","mode":{"type":"sequential"},"promptTemplate":"..."}]}',
bodyPlaceholder: '{"steps":[{"name":"step-a","agentId":1,"mode":{"type":"sequential"},"promptTemplate":"..."}]}',
compileErrorsTitle: '{count} 条编译错误',
templates: {
label: '插入模板',
@ -1967,7 +1967,7 @@ export default {
fullscreenExit: '退出全屏',
addNode: '+ 添加节点',
modeLabel: '模式',
nodeAgent: '智能体',
nodeAgent: '数字员工',
nodeExpression: '条件',
nodeApprovalKind: '审批类型',
nodeChannels: '渠道',
@ -1996,10 +1996,8 @@ export default {
fields: {
name: '名称',
namePlaceholder: 'step-name',
agentPlaceholder: '选择智能体',
agentMissing: '当前 agentName 不在列表中:{name}',
agentUseCustom: '— 使用自定义 agentName —',
agentBackToList: '回到下拉选择',
agentPlaceholder: '选择数字员工',
agentMissing: '当前数字员工不在列表中:{name}',
promptTemplate: 'Prompt 模板',
promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: '输出变量名',
@ -2009,7 +2007,11 @@ export default {
approverChannels: '通知渠道(逗号分隔)',
approvalMessage: '审批消息',
timeoutSecs: '超时(秒)',
channels: '渠道(逗号分隔)',
channels: '分发渠道',
channelsEmptyHint: '当前工作区没有可用渠道,请先在渠道管理中启用渠道。',
channelsUnknown: '草稿里有不可用渠道:{channels}',
dispatchTarget: '{channel} 目标',
dispatchTargetPlaceholder: '群 ID / 用户 ID / 会话 ID',
dispatchContent: '消息内容',
employeeId: '员工 ID',
memoryFile: '记忆文件',

View File

@ -66,85 +66,92 @@
</tbody>
</table>
<!-- Inline form (replaces modal for v0 simplicity) -->
<section v-if="formOpen" class="form-card mc-surface-card">
<header>
<strong>{{ editing?.id ? t('triggers.formTitleEdit') : t('triggers.formTitleNew') }}</strong>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.close') }}</button>
</header>
<div class="form-grid">
<label>{{ t('triggers.fields.name') }}
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
</label>
<label>{{ t('triggers.fields.patternType') }}
<select v-model="formState.patternType">
<option value="cron">cron</option>
<option value="channel_message">channel_message</option>
<option value="content_match">content_match</option>
<option value="agent_lifecycle">agent_lifecycle</option>
<option value="workflow_completion">workflow_completion</option>
<option value="webhook">webhook</option>
</select>
</label>
<div class="span-2">
<span class="form-grid-label">{{ t('triggers.fields.patternJson') }}</span>
<TriggerPatternForm
v-model="formState.patternJson"
:pattern-type="formState.patternType"
:available-workflows="availableWorkflows"
@validation="onPatternValidation"
/>
</div>
<label>{{ t('triggers.fields.targetType') }}
<!-- v0 only ships the workflow dispatcher; agent target is
reserved for v1 and rejected by the API to avoid the
"looks enabled, never fires" trap. -->
<select v-model="formState.targetType" disabled>
<option value="workflow">workflow</option>
</select>
</label>
<label>{{ t('triggers.fields.targetId') }}
<select v-model.number="formState.targetId">
<option v-if="!availableWorkflows.length" :value="0">
{{ t('triggers.targetWorkflowEmpty') }}
</option>
<option v-else v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
#{{ wf.id }} {{ wf.name || t('triggers.unnamed') }}
</option>
</select>
</label>
<label>{{ t('triggers.fields.ratePerMin') }}
<input v-model.number="formState.rateLimitPerMin" type="number" />
</label>
<label>{{ t('triggers.fields.dedupWindowSecs') }}
<input v-model.number="formState.dedupWindowSecs" type="number" />
</label>
<label>{{ t('triggers.fields.maxFires') }}
<input v-model.number="formState.maxFires" type="number" />
</label>
<label>
<input type="checkbox" v-model="formState.botSelfFilter" />
{{ t('triggers.fields.botSelfFilter') }}
</label>
<label class="span-2">{{ t('triggers.fields.payloadTemplate') }}
<textarea v-model="formState.payloadTemplate" rows="3"
:placeholder="t('triggers.fields.payloadTemplatePlaceholder')" />
</label>
<label class="span-2">
<input type="checkbox" v-model="formState.enabled" />
{{ t('triggers.fields.enabled') }}
</label>
<Teleport to="body">
<div v-if="formOpen" class="trigger-modal-overlay" @click.self="closeForm">
<section class="trigger-modal" role="dialog" aria-modal="true">
<header class="trigger-modal-header">
<strong>{{ editing?.id ? t('triggers.formTitleEdit') : t('triggers.formTitleNew') }}</strong>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.close') }}</button>
</header>
<div class="trigger-modal-body">
<div class="form-grid">
<label>{{ t('triggers.fields.name') }}
<input v-model="formState.name" :placeholder="t('triggers.fields.namePlaceholder')" />
</label>
<label>{{ t('triggers.fields.patternType') }}
<select v-model="formState.patternType">
<option value="cron">cron</option>
<option value="channel_message">channel_message</option>
<option value="content_match">content_match</option>
<option value="agent_lifecycle">agent_lifecycle</option>
<option value="workflow_completion">workflow_completion</option>
<option value="webhook">webhook</option>
</select>
</label>
<div class="span-2">
<span class="form-grid-label">{{ t('triggers.fields.patternJson') }}</span>
<TriggerPatternForm
v-model="formState.patternJson"
:pattern-type="formState.patternType"
:available-workflows="availableWorkflows"
@validation="onPatternValidation"
/>
</div>
<label>{{ t('triggers.fields.targetType') }}
<!-- v0 only ships the workflow dispatcher; agent target is
reserved for v1 and rejected by the API to avoid the
"looks enabled, never fires" trap. -->
<select v-model="formState.targetType" disabled>
<option value="workflow">workflow</option>
</select>
</label>
<label>{{ t('triggers.fields.targetId') }}
<select v-model.number="formState.targetId">
<option v-if="!availableWorkflows.length" :value="0">
{{ t('triggers.targetWorkflowEmpty') }}
</option>
<option v-else v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
#{{ wf.id }} {{ wf.name || t('triggers.unnamed') }}
</option>
</select>
</label>
<label>{{ t('triggers.fields.ratePerMin') }}
<input v-model.number="formState.rateLimitPerMin" type="number" />
</label>
<label>{{ t('triggers.fields.dedupWindowSecs') }}
<input v-model.number="formState.dedupWindowSecs" type="number" />
</label>
<label>{{ t('triggers.fields.maxFires') }}
<input v-model.number="formState.maxFires" type="number" />
</label>
<label class="checkbox-field">
<input type="checkbox" v-model="formState.botSelfFilter" />
<span>{{ t('triggers.fields.botSelfFilter') }}</span>
</label>
<label class="span-2">{{ t('triggers.fields.payloadTemplate') }}
<textarea v-model="formState.payloadTemplate" rows="3"
:placeholder="t('triggers.fields.payloadTemplatePlaceholder')" />
</label>
<label class="span-2 checkbox-field">
<input type="checkbox" v-model="formState.enabled" />
<span>{{ t('triggers.fields.enabled') }}</span>
</label>
</div>
</div>
<footer class="trigger-modal-footer">
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
<button
class="btn-primary"
:disabled="busy || patternHasErrors"
:title="patternHasErrors ? Object.values(patternErrors).join('; ') : ''"
@click="save"
>{{ t('triggers.actions.save') }}</button>
</footer>
</section>
</div>
<footer>
<button class="btn-ghost" @click="closeForm">{{ t('triggers.actions.cancel') }}</button>
<button
class="btn-primary"
:disabled="busy || patternHasErrors"
:title="patternHasErrors ? Object.values(patternErrors).join('; ') : ''"
@click="save"
>{{ t('triggers.actions.save') }}</button>
</footer>
</section>
</Teleport>
</div>
</div>
</div>
@ -456,19 +463,49 @@ watch(workspaceId, reload)
display: flex;
gap: 6px;
}
.form-card {
padding: 16px;
.trigger-modal-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 12, 8, 0.42);
backdrop-filter: blur(6px);
}
.form-card header {
.trigger-modal {
width: min(1120px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 12px;
background: var(--mc-bg-elevated, #fff);
color: var(--mc-text-primary, inherit);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
}
:global(html.dark) .trigger-modal {
background: #1f1713;
}
.trigger-modal-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
align-items: center;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid var(--mc-border, rgba(0, 0, 0, 0.08));
flex: 0 0 auto;
}
.trigger-modal-body {
padding: 16px 20px 20px;
overflow: auto;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
gap: 14px 16px;
}
.form-grid .span-2 {
grid-column: span 2;
@ -490,10 +527,13 @@ watch(workspaceId, reload)
color: inherit;
font-family: inherit;
font-size: 13px;
min-height: 38px;
}
.form-grid textarea {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
min-height: 96px;
resize: vertical;
}
.pattern-hint {
display: block;
@ -511,11 +551,27 @@ watch(workspaceId, reload)
margin-bottom: 4px;
color: var(--mc-text-secondary, inherit);
}
.form-card footer {
margin-top: 12px;
.checkbox-field {
flex-direction: row !important;
align-items: center;
justify-content: flex-start;
min-height: 38px;
padding-top: 20px;
}
.checkbox-field input[type='checkbox'] {
width: 16px;
height: 16px;
min-height: 0;
flex: 0 0 auto;
}
.trigger-modal-footer {
flex: 0 0 auto;
padding: 14px 20px;
display: flex;
gap: 8px;
justify-content: flex-end;
border-top: 1px solid var(--mc-border, rgba(0, 0, 0, 0.08));
background: var(--mc-bg-elevated, #fff);
}
.btn-primary,
.btn-ghost,

View File

@ -72,6 +72,7 @@
:step="selectedStep"
:index="canvasSelection.index"
:available-agents="availableAgents"
:available-channels="availableChannels"
@patch="onStepPatch"
@duplicate="onStepDuplicate"
@delete="onStepDelete"
@ -97,11 +98,10 @@
</label>
<span class="json-hint" :class="jsonHintKind">{{ jsonHint }}</span>
</div>
<textarea
<WorkflowJsonEditor
v-model="draftJson"
class="editor-body"
spellcheck="false"
:placeholder="t('workflows.bodyPlaceholder')"
:compile-errors="compileErrors"
:file-path="`inmemory://workflow-${selected.id}.json`"
/>
</div>
@ -215,6 +215,7 @@ import { mcConfirm } from '@/components/common/useConfirm'
import { ElMessage } from 'element-plus'
import {
agentApi,
channelApi,
workflowApi,
type WorkflowSummary,
type WorkflowRun,
@ -224,9 +225,11 @@ import {
type PausedRunSummary,
type ResumeOutcome,
} from '@/api'
import type { Channel } from '@/types'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import WorkflowCanvas from '@/components/workflow/WorkflowCanvas.vue'
import StepPropertyPanel from '@/components/workflow/StepPropertyPanel.vue'
import WorkflowJsonEditor from '@/components/workflow/WorkflowJsonEditor.vue'
import CreateWorkflowDialog from '@/components/workflow/CreateWorkflowDialog.vue'
import PublishDialog from '@/components/workflow/PublishDialog.vue'
import type { StepNodeData, RawStep } from '@/composables/useWorkflowGraph'
@ -260,6 +263,13 @@ const resumingId = ref<number | null>(null)
// exist. Loaded once on mount and on workspace switch.
interface AgentOption { id: number; name: string; title?: string }
const availableAgents = ref<AgentOption[]>([])
interface ChannelOption {
id: number | string
name: string
channelType: string
enabled?: boolean
}
const availableChannels = ref<ChannelOption[]>([])
const templateChoice = ref('')
@ -326,25 +336,20 @@ const jsonHintKind = computed(() => (jsonHint.value === t('workflows.jsonOk') ?
const STEP_TEMPLATES: Record<string, object> = {
sequential: {
name: 'step-sequential',
agentName: 'agent-name',
mode: { type: 'sequential' },
promptTemplate: 'Process: {{ inputs.payload }}',
},
fan_out: {
name: 'step-fan',
agentName: 'agent-name',
mode: { type: 'fan_out' },
promptTemplate: 'Branch task',
},
collect: {
name: 'step-collect',
agentName: 'agent-name',
mode: { type: 'collect' },
promptTemplate: 'Combine: {{ inputs.payload }}',
},
conditional: {
name: 'step-conditional',
agentName: 'agent-name',
mode: { type: 'conditional', expression: '{{ inputs.payload != null }}' },
promptTemplate: 'Run only when condition holds',
},
@ -366,8 +371,8 @@ const STEP_TEMPLATES: Record<string, object> = {
name: 'step-dispatch',
mode: {
type: 'dispatch_channel',
channels: ['feishu'],
targets: { feishu: 'group-id-here' },
channels: [],
targets: {},
content: 'Notification: {{ inputs.payload }}',
},
},
@ -496,6 +501,23 @@ async function reloadAgents() {
}
}
async function reloadChannels() {
try {
const res = await channelApi.list()
const rows = (res.data as unknown as Channel[]) ?? []
availableChannels.value = rows
.filter((c) => c && c.channelType && c.enabled !== false)
.map((c) => ({
id: c.id,
name: c.name || c.channelType,
channelType: c.channelType,
enabled: c.enabled,
}))
} catch (e) {
console.error('listChannels failed', e)
}
}
function truncateToken(token: string | undefined): string {
if (!token) return '-'
return token.length <= 14 ? token : token.slice(0, 6) + '…' + token.slice(-4)
@ -553,7 +575,6 @@ async function onCreateSubmit(payload: { name: string; description: string }) {
steps: [
{
name: 'first-step',
agentName: 'agent-name',
mode: { type: 'sequential' },
promptTemplate: 'Hello {{ inputs.payload }}',
},
@ -695,11 +716,13 @@ onMounted(async () => {
await reload()
await reloadPausedRuns()
await reloadAgents()
await reloadChannels()
})
watch(workspaceId, async () => {
await reload()
await reloadPausedRuns()
await reloadAgents()
await reloadChannels()
})
</script>