feat(ui/workflows): canvas add-node toolbar + workspace agent picker

This commit is contained in:
matevip 2026-05-08 15:07:47 +08:00
parent 160bf28981
commit 533868c41b
5 changed files with 216 additions and 15 deletions

View File

@ -39,13 +39,42 @@
<label class="panel-field" v-if="modeNeedsAgent">
<span class="field-label">{{ t('workflows.canvas.nodeAgent') }}</span>
<input
class="mc-input"
:value="step.agentName ?? ''"
@input="patch({ agentName: ($event.target as HTMLInputElement).value })"
spellcheck="false"
:placeholder="t('workflows.canvas.fields.agentPlaceholder')"
/>
<!-- 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.). -->
<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">
{{ 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>
<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>
<label class="panel-field" v-if="modeNeedsAgent">
@ -242,18 +271,31 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { RawStep } from '@/composables/useWorkflowGraph'
/** Minimal agent shape the panel needs to render the picker kept
* inside the component so the panel doesn't have to import the
* full workspace agent type. */
export interface AgentOption {
id: number | string
name: string
title?: string
}
interface Props {
/** The step the panel currently edits, or null. */
step: RawStep | null
/** Index of the step inside `steps[]` — used by the parent to scope patches. */
index: number
/** Workspace-scoped agent list rendered as a dropdown. */
availableAgents?: AgentOption[]
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
availableAgents: () => [],
})
const emit = defineEmits<{
(e: 'patch', payload: { index: number; patch: Partial<RawStep> }): void
(e: 'delete', payload: { index: number }): void
@ -321,6 +363,23 @@ function onDelete() {
function onDuplicate() {
emit('duplicate', { index: props.index })
}
// Agent picker support toggles between dropdown and free-text input.
const useFreeAgentName = ref(false)
const agentNotInList = computed(() => {
const n = props.step?.agentName
if (!n) return false
return !props.availableAgents.some((a) => a.name === n)
})
const agentSelectValue = computed(() => props.step?.agentName ?? '')
function onAgentSelect(e: Event) {
const v = (e.target as HTMLSelectElement).value
if (v === '__custom__') {
useFreeAgentName.value = true
return
}
patch({ agentName: v })
}
</script>
<style scoped>
@ -437,6 +496,26 @@ function onDuplicate() {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11.5px;
}
.agent-picker {
display: flex;
align-items: center;
gap: 4px;
}
.agent-picker .mc-input { flex: 1; min-width: 0; }
.agent-toggle {
width: 24px;
height: 28px;
border: 1px solid var(--mc-border, rgba(0, 0, 0, 0.12));
border-radius: 5px;
background: transparent;
color: var(--mc-text-tertiary, #888);
cursor: pointer;
font-size: 14px;
}
.agent-toggle:hover {
background: var(--mc-bg-muted, rgba(0, 0, 0, 0.04));
color: var(--mc-text-primary, inherit);
}
.panel-hint {
font-size: 11.5px;
color: var(--mc-text-tertiary, #888);

View File

@ -2,6 +2,21 @@
<div class="workflow-canvas" :class="{ fullscreen }" :data-canvas-id="canvasId">
<div class="canvas-toolbar">
<div class="canvas-toolbar-group">
<!-- Add-node picker inserts a fresh step after the currently
selected node (or at the end when nothing is selected). -->
<label class="canvas-add-picker">
<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>
</select>
</label>
<button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
{{ t('workflows.canvas.layoutLR') }}
</button>
@ -107,8 +122,23 @@ const props = withDefaults(defineProps<Props>(), { canvasId: 'workflow-canvas' }
const emit = defineEmits<{
(e: 'select-step', payload: StepNodeData | null): void
(e: 'insert-step', payload: { afterIndex: number; modeType: string }): void
}>()
function onAddNode(e: Event) {
const target = e.target as HTMLSelectElement
const modeType = target.value
if (!modeType) return
// Reset the select so a re-pick of the same option re-fires the
// change event. Without this the second click of "+ sequential"
// would silently no-op.
target.value = ''
// The parent reads the current selection from canvasSelection, so
// we just need to forward the requested mode + a hint about where
// (-1 means "append at end" when nothing is selected).
emit('insert-step', { afterIndex: -1, modeType })
}
const { t } = useI18n()
const direction = ref<'LR' | 'TB'>('LR')
@ -246,6 +276,35 @@ onBeforeUnmount(() => {
padding: 0;
height: 26px;
}
.canvas-add-picker {
display: inline-flex;
align-items: center;
}
.canvas-add-select {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--mc-primary, #4084ff);
background: var(--mc-primary-bg, rgba(64, 132, 255, 0.14));
color: var(--mc-primary, #4084ff);
font-size: 12px;
cursor: pointer;
font-weight: 500;
}
.canvas-add-select:hover {
background: var(--mc-primary, #4084ff);
color: var(--mc-text-inverse, #ffffff);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.canvas-toolbar {
display: flex;
align-items: center;

View File

@ -1953,6 +1953,7 @@ export default {
parseError: 'JSON parse failed: {msg}',
fullscreenEnter: 'Fullscreen',
fullscreenExit: 'Exit fullscreen',
addNode: '+ Add node',
modeLabel: 'Mode',
nodeAgent: 'Agent',
nodeExpression: 'Condition',
@ -1983,7 +1984,10 @@ export default {
fields: {
name: 'Name',
namePlaceholder: 'step-name',
agentPlaceholder: 'agent-name',
agentPlaceholder: 'Select agent',
agentMissing: 'Current agentName is not in the list: {name}',
agentUseCustom: '— Type a custom agentName —',
agentBackToList: 'Back to dropdown',
promptTemplate: 'Prompt template',
promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: 'Output variable',

View File

@ -1965,6 +1965,7 @@ export default {
parseError: 'JSON 解析失败:{msg}',
fullscreenEnter: '全屏',
fullscreenExit: '退出全屏',
addNode: '+ 添加节点',
modeLabel: '模式',
nodeAgent: '智能体',
nodeExpression: '条件',
@ -1995,7 +1996,10 @@ export default {
fields: {
name: '名称',
namePlaceholder: 'step-name',
agentPlaceholder: '智能体名称',
agentPlaceholder: '选择智能体',
agentMissing: '当前 agentName 不在列表中:{name}',
agentUseCustom: '— 使用自定义 agentName —',
agentBackToList: '回到下拉选择',
promptTemplate: 'Prompt 模板',
promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: '输出变量名',

View File

@ -65,11 +65,13 @@
v-model="canvasModel"
:canvas-id="`wf-${selected.id}`"
@select-step="onCanvasSelect"
@insert-step="onInsertStep"
>
<template v-if="canvasSelection" #panel>
<StepPropertyPanel
:step="selectedStep"
:index="canvasSelection.index"
:available-agents="availableAgents"
@patch="onStepPatch"
@duplicate="onStepDuplicate"
@delete="onStepDelete"
@ -212,6 +214,7 @@ import { useI18n } from 'vue-i18n'
import { mcConfirm } from '@/components/common/useConfirm'
import { ElMessage } from 'element-plus'
import {
agentApi,
workflowApi,
type WorkflowSummary,
type WorkflowRun,
@ -252,6 +255,12 @@ const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(nul
const pausedRuns = ref<PausedRunSummary[]>([])
const resumingId = ref<number | null>(null)
// Workspace agent list fed to the StepPropertyPanel's agent picker
// so authors stop typing free-form agent names that don't actually
// exist. Loaded once on mount and on workspace switch.
interface AgentOption { id: number; name: string; title?: string }
const availableAgents = ref<AgentOption[]>([])
const templateChoice = ref('')
// View-mode toggle between the canvas (read-only graph derived from
@ -377,11 +386,42 @@ const STEP_TEMPLATES: Record<string, object> = {
function insertTemplate() {
const choice = templateChoice.value
if (!choice) return
appendStepTemplate(choice)
templateChoice.value = ''
}
/**
* Insert a step from the canvas toolbar's "+ add node" picker. When a
* canvas node is selected we splice the new step in right after it; on
* an empty selection we append at the end. The same STEP_TEMPLATES
* skeletons the JSON-tab dropdown uses keep the two entry points
* consistent.
*/
function onInsertStep(payload: { afterIndex: number; modeType: string }) {
const stepBlock = STEP_TEMPLATES[payload.modeType]
if (!stepBlock) return
// The canvas hands us afterIndex=-1 because it doesn't know about
// canvasSelection on this side. Resolve "after the currently selected
// step" here so the toolbar UX feels consistent with the inspector.
const selIndex = canvasSelection.value?.index ?? -1
let next: string
try {
const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] }
if (!Array.isArray(parsed.steps)) parsed.steps = []
const insertAt = selIndex >= 0 && selIndex < parsed.steps.length
? selIndex + 1
: parsed.steps.length
parsed.steps.splice(insertAt, 0, stepBlock)
next = JSON.stringify(parsed, null, 2)
} catch {
next = JSON.stringify({ steps: [stepBlock] }, null, 2)
}
draftJson.value = next
}
function appendStepTemplate(choice: string) {
const stepBlock = STEP_TEMPLATES[choice]
if (!stepBlock) return
// Try to inject into an existing draft's `steps` array; fall back to a
// fresh skeleton if the current text isn't valid JSON or doesn't have
// the expected shape.
let next: string
try {
const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] }
@ -392,7 +432,6 @@ function insertTemplate() {
next = JSON.stringify({ steps: [stepBlock] }, null, 2)
}
draftJson.value = next
templateChoice.value = ''
}
async function reload() {
@ -443,6 +482,20 @@ async function reloadPausedRuns() {
}
}
async function reloadAgents() {
try {
const res = await agentApi.list()
const rows = (res.data as unknown as AgentOption[]) ?? []
// The agent list endpoint already scopes to the caller's workspace
// via the X-Workspace-Id header, so no client-side filter is needed.
availableAgents.value = rows
.filter((a) => a && a.name)
.map((a) => ({ id: a.id, name: a.name, title: a.title }))
} catch (e) {
console.error('listAgents failed', e)
}
}
function truncateToken(token: string | undefined): string {
if (!token) return '-'
return token.length <= 14 ? token : token.slice(0, 6) + '…' + token.slice(-4)
@ -641,10 +694,12 @@ function formatTime(iso?: string) {
onMounted(async () => {
await reload()
await reloadPausedRuns()
await reloadAgents()
})
watch(workspaceId, async () => {
await reload()
await reloadPausedRuns()
await reloadAgents()
})
</script>