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"> <label class="panel-field" v-if="modeNeedsAgent">
<span class="field-label">{{ t('workflows.canvas.nodeAgent') }}</span> <span class="field-label">{{ t('workflows.canvas.nodeAgent') }}</span>
<input <!-- Agent picker drops down the workspace's agents and falls
class="mc-input" back to a free-text input when the operator wants to type a
:value="step.agentName ?? ''" name that isn't in the list (legacy drafts, agent created in
@input="patch({ agentName: ($event.target as HTMLInputElement).value })" a different workspace by an admin, etc.). -->
spellcheck="false" <div class="agent-picker">
:placeholder="t('workflows.canvas.fields.agentPlaceholder')" <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>
<label class="panel-field" v-if="modeNeedsAgent"> <label class="panel-field" v-if="modeNeedsAgent">
@ -242,18 +271,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { RawStep } from '@/composables/useWorkflowGraph' 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 { interface Props {
/** The step the panel currently edits, or null. */ /** The step the panel currently edits, or null. */
step: RawStep | null step: RawStep | null
/** Index of the step inside `steps[]` — used by the parent to scope patches. */ /** Index of the step inside `steps[]` — used by the parent to scope patches. */
index: number 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<{ const emit = defineEmits<{
(e: 'patch', payload: { index: number; patch: Partial<RawStep> }): void (e: 'patch', payload: { index: number; patch: Partial<RawStep> }): void
(e: 'delete', payload: { index: number }): void (e: 'delete', payload: { index: number }): void
@ -321,6 +363,23 @@ function onDelete() {
function onDuplicate() { function onDuplicate() {
emit('duplicate', { index: props.index }) 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> </script>
<style scoped> <style scoped>
@ -437,6 +496,26 @@ function onDuplicate() {
font-family: 'JetBrains Mono', Consolas, monospace; font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 11.5px; 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 { .panel-hint {
font-size: 11.5px; font-size: 11.5px;
color: var(--mc-text-tertiary, #888); color: var(--mc-text-tertiary, #888);

View File

@ -2,6 +2,21 @@
<div class="workflow-canvas" :class="{ fullscreen }" :data-canvas-id="canvasId"> <div class="workflow-canvas" :class="{ fullscreen }" :data-canvas-id="canvasId">
<div class="canvas-toolbar"> <div class="canvas-toolbar">
<div class="canvas-toolbar-group"> <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'"> <button class="canvas-btn" :class="{ active: direction === 'LR' }" @click="direction = 'LR'">
{{ t('workflows.canvas.layoutLR') }} {{ t('workflows.canvas.layoutLR') }}
</button> </button>
@ -107,8 +122,23 @@ const props = withDefaults(defineProps<Props>(), { canvasId: 'workflow-canvas' }
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select-step', payload: StepNodeData | null): void (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 { t } = useI18n()
const direction = ref<'LR' | 'TB'>('LR') const direction = ref<'LR' | 'TB'>('LR')
@ -246,6 +276,35 @@ onBeforeUnmount(() => {
padding: 0; padding: 0;
height: 26px; 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 { .canvas-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1953,6 +1953,7 @@ export default {
parseError: 'JSON parse failed: {msg}', parseError: 'JSON parse failed: {msg}',
fullscreenEnter: 'Fullscreen', fullscreenEnter: 'Fullscreen',
fullscreenExit: 'Exit fullscreen', fullscreenExit: 'Exit fullscreen',
addNode: '+ Add node',
modeLabel: 'Mode', modeLabel: 'Mode',
nodeAgent: 'Agent', nodeAgent: 'Agent',
nodeExpression: 'Condition', nodeExpression: 'Condition',
@ -1983,7 +1984,10 @@ export default {
fields: { fields: {
name: 'Name', name: 'Name',
namePlaceholder: 'step-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', promptTemplate: 'Prompt template',
promptPlaceholder: 'Hello {{ inputs.payload }}', promptPlaceholder: 'Hello {{ inputs.payload }}',
outputVar: 'Output variable', outputVar: 'Output variable',

View File

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

View File

@ -65,11 +65,13 @@
v-model="canvasModel" v-model="canvasModel"
:canvas-id="`wf-${selected.id}`" :canvas-id="`wf-${selected.id}`"
@select-step="onCanvasSelect" @select-step="onCanvasSelect"
@insert-step="onInsertStep"
> >
<template v-if="canvasSelection" #panel> <template v-if="canvasSelection" #panel>
<StepPropertyPanel <StepPropertyPanel
:step="selectedStep" :step="selectedStep"
:index="canvasSelection.index" :index="canvasSelection.index"
:available-agents="availableAgents"
@patch="onStepPatch" @patch="onStepPatch"
@duplicate="onStepDuplicate" @duplicate="onStepDuplicate"
@delete="onStepDelete" @delete="onStepDelete"
@ -212,6 +214,7 @@ import { useI18n } from 'vue-i18n'
import { mcConfirm } from '@/components/common/useConfirm' import { mcConfirm } from '@/components/common/useConfirm'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
agentApi,
workflowApi, workflowApi,
type WorkflowSummary, type WorkflowSummary,
type WorkflowRun, type WorkflowRun,
@ -252,6 +255,12 @@ const runDetail = ref<{ run: WorkflowRun; steps: WorkflowRunStep[] } | null>(nul
const pausedRuns = ref<PausedRunSummary[]>([]) const pausedRuns = ref<PausedRunSummary[]>([])
const resumingId = ref<number | null>(null) 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('') const templateChoice = ref('')
// View-mode toggle between the canvas (read-only graph derived from // View-mode toggle between the canvas (read-only graph derived from
@ -377,11 +386,42 @@ const STEP_TEMPLATES: Record<string, object> = {
function insertTemplate() { function insertTemplate() {
const choice = templateChoice.value const choice = templateChoice.value
if (!choice) return 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] const stepBlock = STEP_TEMPLATES[choice]
if (!stepBlock) return 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 let next: string
try { try {
const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] } const parsed = JSON.parse(draftJson.value || '{}') as { steps?: unknown[] }
@ -392,7 +432,6 @@ function insertTemplate() {
next = JSON.stringify({ steps: [stepBlock] }, null, 2) next = JSON.stringify({ steps: [stepBlock] }, null, 2)
} }
draftJson.value = next draftJson.value = next
templateChoice.value = ''
} }
async function reload() { 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 { function truncateToken(token: string | undefined): string {
if (!token) return '-' if (!token) return '-'
return token.length <= 14 ? token : token.slice(0, 6) + '…' + token.slice(-4) return token.length <= 14 ? token : token.slice(0, 6) + '…' + token.slice(-4)
@ -641,10 +694,12 @@ function formatTime(iso?: string) {
onMounted(async () => { onMounted(async () => {
await reload() await reload()
await reloadPausedRuns() await reloadPausedRuns()
await reloadAgents()
}) })
watch(workspaceId, async () => { watch(workspaceId, async () => {
await reload() await reload()
await reloadPausedRuns() await reloadPausedRuns()
await reloadAgents()
}) })
</script> </script>