mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui/workflows): canvas add-node toolbar + workspace agent picker
This commit is contained in:
parent
160bf28981
commit
533868c41b
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '输出变量名',
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user