mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui/workflows): Monaco editor + schema validation + compile-error markers
This commit is contained in:
parent
dcf1f56944
commit
de89df6900
@ -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",
|
||||
|
||||
57
mateclaw-ui/pnpm-lock.yaml
generated
57
mateclaw-ui/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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 ''
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
266
mateclaw-ui/src/components/workflow/WorkflowJsonEditor.vue
Normal file
266
mateclaw-ui/src/components/workflow/WorkflowJsonEditor.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '记忆文件',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user