소스 검색

1. 调整了工作流画布输入/输出节点变量标签颜色语义,输入节点显示蓝色、输出节点显示绿色,统一视觉表达;
2. 实现了输出节点接收字段的关联方式配置,支持在字段上选择自动推断或显式关联前驱节点输出;
3. 实现了节点内变量/字段名唯一性校验,重命名或引入时通过 toast 提示重复,保存前拦截并列出具体节点与列表;
4. 实现了前驱节点变量删除后关联关系自动回退,覆盖普通节点 inputs 与输出节点 fields 的显式 mapping;
5. 优化了模板变量同步机制,区分自动字段与手动字段,避免在 {{}} 中输入时产生中间变量碎片;
6. 新增了运行对话框变量导出/导入功能,支持将已输入变量保存为 JSON 文件并回填;
7. 修复了运行对话框点击遮罩层意外关闭的问题,仅允许通过关闭按钮或操作按钮关闭;
8. 更新了 prompt.md 需求记录。

weisijie 1 개월 전
부모
커밋
2a0a50e4cf

+ 2 - 1
frontend/src/components/workflow/nodes/InputNode.vue

@@ -12,7 +12,7 @@ const props = defineProps({
     <template #icon><ChatbubbleEllipsesOutline /></template>
     <template #body>
       <div v-if="data?.variables?.length" class="io-summary">
-        <span v-for="v in data.variables" :key="v.name" class="io-tag io-out">{{ v.name }}</span>
+        <span v-for="v in data.variables" :key="v.name" class="io-tag io-in">{{ v.name }}</span>
       </div>
       <div v-else class="output-hint">未配置变量</div>
     </template>
@@ -31,6 +31,7 @@ export default { name: 'InputNode' }
   border-radius: 3px;
   font-family: 'Cascadia Code', 'Fira Code', monospace;
 }
+.io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
 .io-out { background: rgba(34,197,94,0.15); color: #4ade80; }
 .output-hint { font-size: 11px; color: var(--text-tertiary, #888); }
 </style>

+ 2 - 1
frontend/src/components/workflow/nodes/OutputNode.vue

@@ -17,7 +17,7 @@ const fieldSummary = computed(() => {
     <template #icon><ExitOutline /></template>
     <template #body>
       <div v-if="fieldSummary.length" class="io-summary">
-        <span class="io-tag io-in" v-for="name in fieldSummary" :key="name">{{ name }}</span>
+        <span class="io-tag io-out" v-for="name in fieldSummary" :key="name">{{ name }}</span>
       </div>
       <div v-else class="output-hint">无接收字段</div>
     </template>
@@ -37,5 +37,6 @@ export default { name: 'OutputNode' }
   font-family: 'Cascadia Code', 'Fira Code', monospace;
 }
 .io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
+.io-out { background: rgba(34,197,94,0.15); color: #4ade80; }
 .output-hint { font-size: 11px; color: var(--text-tertiary, #888); }
 </style>

+ 73 - 7
frontend/src/composables/useNodeFields.js

@@ -90,16 +90,28 @@ export function useNodeFields({
       for (const v of extractTemplateVariables(t)) extracted.add(v)
     }
 
-    // 合并到 inputs:仅追加未存在的新变量,保留用户已配置的字段
+    // 模板变量动态同步:区分手动字段与自动字段,删除已不在模板中的自动字段,
+    // 避免在 {{}} 中缓慢输入 abc 时同时残留 a、ab、abc 三个变量
     const existing = update.inputs || d.inputs || []
-    const existingNames = new Set(existing.map(f => f.name))
-    const merged = [...existing]
+    const manualInputs = existing.filter(f => !f.autoTemplate)
+    const autoInputs = existing.filter(f => f.autoTemplate)
+    const manualNames = new Set(manualInputs.map(f => f.name))
+
+    const newAutoInputs = []
     for (const name of extracted) {
-      if (!existingNames.has(name)) {
-        merged.push({ name, label: name, type: 'string', description: '', mapping: null })
+      if (manualNames.has(name)) continue
+      const existingAuto = autoInputs.find(f => f.name === name)
+      if (existingAuto) {
+        newAutoInputs.push(existingAuto)
+      } else {
+        newAutoInputs.push({ name, label: name, type: 'string', description: '', mapping: null, autoTemplate: true })
       }
     }
-    if (merged.length !== existing.length) {
+
+    const merged = [...manualInputs, ...newAutoInputs]
+    const oldNames = existing.map(f => f.name).join('\n')
+    const newNames = merged.map(f => f.name).join('\n')
+    if (oldNames !== newNames) {
       update.inputs = merged
     }
   }
@@ -113,8 +125,12 @@ export function useNodeFields({
   }
 
   function removeListField(field, index) {
+    const removedName = selectedData.value?.[field]?.[index]?.name
     const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
     vfUpdateNode(selectedNode.value.id, { [field]: list })
+    if (field === 'outputs' && removedName) {
+      revertSuccessorMappings(selectedNode.value.id, removedName)
+    }
     syncSelectedNode()
   }
 
@@ -127,8 +143,12 @@ export function useNodeFields({
   }
 
   function removeVariable(index) {
+    const removedName = selectedData.value?.variables?.[index]?.name
     const newVars = (selectedData.value?.variables || []).filter((_, i) => i !== index)
     vfUpdateNode(selectedNode.value.id, { variables: newVars })
+    if (removedName) {
+      revertSuccessorMappings(selectedNode.value.id, removedName)
+    }
     syncSelectedNode()
   }
 
@@ -175,6 +195,51 @@ export function useNodeFields({
     syncSelectedNode()
   }
 
+  // ========== 前驱节点变量删除后,后继节点关联回退为自动推断 ==========
+  function revertSuccessorMappings(sourceNodeId, sourceFieldName = null) {
+    const affectedNodeIds = []
+    for (const node of getNodes().value) {
+      if (node.id === sourceNodeId) continue
+      const update = {}
+
+      // 普通节点的 inputs
+      const inputs = node.data?.inputs || []
+      let inputsChanged = false
+      const newInputs = inputs.map(inp => {
+        if (inp.mapping?.sourceNodeId === sourceNodeId) {
+          if (sourceFieldName === null || inp.mapping.sourceField === sourceFieldName) {
+            inputsChanged = true
+            return { ...inp, mapping: null }
+          }
+        }
+        return inp
+      })
+      if (inputsChanged) update.inputs = newInputs
+
+      // 输出节点的 fields(接收字段也可关联前驱输出)
+      const fields = node.data?.fields || []
+      let fieldsChanged = false
+      const newFields = fields.map(f => {
+        if (f.mapping?.sourceNodeId === sourceNodeId) {
+          if (sourceFieldName === null || f.mapping.sourceField === sourceFieldName) {
+            fieldsChanged = true
+            return { ...f, mapping: null }
+          }
+        }
+        return f
+      })
+      if (fieldsChanged) update.fields = newFields
+
+      if (Object.keys(update).length) {
+        vfUpdateNode(node.id, update)
+        affectedNodeIds.push(node.id)
+      }
+    }
+    for (const nodeId of affectedNodeIds) {
+      applyEdgeMappingUpdates(refreshMappingsForNode(nodeId, getNodes().value, getEdges().value))
+    }
+  }
+
   return {
     onFieldChange,
     onModelChange,
@@ -186,6 +251,7 @@ export function useNodeFields({
     removeOutputField,
     addElif,
     removeElif,
-    onConditionChange
+    onConditionChange,
+    revertSuccessorMappings
   }
 }

+ 204 - 27
frontend/src/views/workflow/WorkflowEditor.vue

@@ -10,7 +10,7 @@ import {
   ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
   SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
   PlayOutline, CloseOutline, ColorWandOutline, LinkOutline, TrashOutline, OpenOutline,
-  TimeOutline, LibraryOutline
+  TimeOutline, LibraryOutline, DownloadOutline, CloudUploadOutline
 } from '@vicons/ionicons5'
 import { useWorkflowStore } from '../../stores/workflow'
 import { useSkillStore } from '../../stores/skill'
@@ -103,7 +103,7 @@ const inputFields = computed(() => {
   for (const node of getNodes.value) {
     if (node.type === 'userInput' && node.data?.variables) {
       for (const v of node.data.variables) {
-        if (v.name) fields.push(v)
+        if (v.name) fields.push({ ...v, nodeName: node.data?.nodeName || '输入节点' })
       }
     }
   }
@@ -361,6 +361,8 @@ function deleteNodeById(nodeId) {
     }
   }
   removeNodes(nodeId)
+  // 节点删除后,其后继节点中指向该节点的显式关联回退为自动推断
+  revertSuccessorMappings(nodeId)
   if (selectedNode.value?.id === nodeId) {
     selectedNode.value = null
   }
@@ -512,14 +514,17 @@ function confirmImportField(candidate) {
   if (importDialog.mode === 'predecessor') {
     // 输出节点:引入到 fields(接收字段仍作为业务输出);其他节点:引入到 inputs(含 mapping)
     const targetField = nodeType === 'output' ? 'fields' : 'inputs'
+    const exists = (selectedData.value?.[targetField] || []).some(f => f.name === candidate.fieldName)
+    if (exists) {
+      message.warning(`字段「${candidate.fieldName}」已在${FIELD_LIST_LABELS[targetField]}中存在`)
+      return
+    }
     const newField = {
       name: candidate.fieldName,
       label: candidate.fieldLabel || candidate.fieldName,
       type: candidate.fieldType || 'string',
-      description: candidate.fieldDescription || ''
-    }
-    if (targetField === 'inputs') {
-      newField.mapping = {
+      description: candidate.fieldDescription || '',
+      mapping: {
         sourceNodeId: candidate.nodeId,
         sourceField: candidate.fieldName,
         sourcePath: null,
@@ -531,6 +536,11 @@ function confirmImportField(candidate) {
   } else if (importDialog.mode === 'successor') {
     // 输入节点:引入到 variables(输入变量仍对业务暴露);其他节点:引入到 outputs
     const targetField = nodeType === 'userInput' ? 'variables' : 'outputs'
+    const exists = (selectedData.value?.[targetField] || []).some(f => f.name === candidate.fieldName)
+    if (exists) {
+      message.warning(`字段「${candidate.fieldName}」已在${FIELD_LIST_LABELS[targetField]}中存在`)
+      return
+    }
     const newField = {
       name: candidate.fieldName,
       label: candidate.fieldLabel || candidate.fieldName,
@@ -582,6 +592,24 @@ function updateNodeName(value) {
   onFieldChange('nodeName', trimmed)
 }
 
+// 节点内字段列表名称唯一性校验
+const FIELD_LIST_LABELS = {
+  variables: '输入变量',
+  inputs: '前置数据',
+  outputs: '输出变量',
+  fields: '接收字段'
+}
+
+function updateFieldName(item, index, listKey, newName) {
+  const list = selectedData.value?.[listKey] || []
+  const trimmed = (newName || '').trim()
+  if (trimmed && list.some((f, i) => i !== index && f.name === trimmed)) {
+    message.warning(`变量名「${trimmed}」在${FIELD_LIST_LABELS[listKey] || listKey}中已存在`)
+  }
+  item.name = newName
+  onFieldChange(listKey, [...list])
+}
+
 // ========== 智能体选项 ==========
 const agentOptions = computed(() =>
   (skillStore.skills || []).map(s => ({
@@ -701,7 +729,8 @@ const {
   addListField, removeListField,
   addVariable, removeVariable,
   addOutputField, removeOutputField,
-  addElif, removeElif, onConditionChange
+  addElif, removeElif, onConditionChange,
+  revertSuccessorMappings
 } = useNodeFields({
   selectedNode,
   selectedData,
@@ -965,6 +994,39 @@ function validateGraphData() {
   return { valid: true }
 }
 
+// 校验各节点内部变量/字段名是否重复
+function validateDuplicateFieldNames() {
+  const listConfigs = [
+    { key: 'variables', label: '输入变量' },
+    { key: 'inputs', label: '前置数据' },
+    { key: 'outputs', label: '输出变量' },
+    { key: 'fields', label: '接收字段' }
+  ]
+  const duplicates = []
+  for (const node of getNodes.value.filter(n => !n.data?.virtual)) {
+    const d = node.data || {}
+    const nodeLabel = d.nodeName || d.label || node.id
+    for (const { key, label } of listConfigs) {
+      const items = d[key] || []
+      const seen = new Map()
+      const dupNames = new Set()
+      for (const item of items) {
+        const name = (item.name || '').trim()
+        if (!name) continue
+        if (seen.has(name)) {
+          dupNames.add(name)
+        } else {
+          seen.set(name, true)
+        }
+      }
+      if (dupNames.size) {
+        duplicates.push({ nodeLabel, listLabel: label, names: [...dupNames] })
+      }
+    }
+  }
+  return duplicates
+}
+
 // ========== 保存 ==========
 async function handleSave() {
   // 校验 displayName(必填)
@@ -988,6 +1050,12 @@ async function handleSave() {
     message.warning(validation.message)
     return
   }
+  const duplicates = validateDuplicateFieldNames()
+  if (duplicates.length) {
+    const lines = duplicates.map(d => `${d.nodeLabel}(${d.listLabel}):${d.names.join('、')}`)
+    message.warning('以下节点存在重名变量,请修改后再保存:\n' + lines.join('\n'))
+    return
+  }
   saving.value = true
   try {
     const graphData = JSON.stringify({
@@ -1034,6 +1102,90 @@ function goBack() {
   router.push('/orchestration')
 }
 
+// ========== 运行对话框变量导出/导入 ==========
+async function exportRunVariables() {
+  const data = {}
+  for (const f of inputFields.value) {
+    const value = formData[f.name]
+    if (value !== undefined && value !== null && String(value).trim() !== '') {
+      data[f.name] = value
+    }
+  }
+  if (!Object.keys(data).length) {
+    message.warning('当前没有已输入的变量可导出')
+    return
+  }
+  const workflowName = form.value.displayName || form.value.name || 'workflow'
+  const nodeName = inputFields.value[0]?.nodeName || 'input'
+  const fileName = `${workflowName}-${nodeName}-输入变量.json`
+  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
+
+  // 优先使用 File System Access API,强制弹出保存位置选择框
+  if (typeof window !== 'undefined' && window.showSaveFilePicker) {
+    try {
+      const handle = await window.showSaveFilePicker({
+        suggestedName: fileName,
+        types: [{
+          description: 'JSON 文件',
+          accept: { 'application/json': ['.json'] }
+        }]
+      })
+      const writable = await handle.createWritable()
+      await writable.write(blob)
+      await writable.close()
+      message.success('变量已导出')
+    } catch (error) {
+      if (error.name === 'AbortError') {
+        // 用户取消选择,静默处理
+        return
+      }
+      message.error(`导出失败:${error.message}`)
+    }
+    return
+  }
+
+  // 不支持 File System Access API 的浏览器回退到默认下载
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = fileName
+  document.body.appendChild(a)
+  a.click()
+  document.body.removeChild(a)
+  URL.revokeObjectURL(url)
+  message.success('变量已导出(当前浏览器不支持选择保存位置)')
+}
+
+function onImportVariablesClick() {
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.accept = 'application/json,.json'
+  input.onchange = e => importRunVariables(e.target.files?.[0])
+  input.click()
+}
+
+async function importRunVariables(file) {
+  if (!file) return
+  try {
+    const text = await file.text()
+    const data = JSON.parse(text)
+    if (!data || typeof data !== 'object') {
+      message.error('导入文件格式错误,应为 JSON 对象')
+      return
+    }
+    let count = 0
+    for (const f of inputFields.value) {
+      if (Object.prototype.hasOwnProperty.call(data, f.name)) {
+        formData[f.name] = data[f.name]
+        count++
+      }
+    }
+    message.success(`已导入 ${count} 个变量`)
+  } catch (error) {
+    message.error(`导入失败:${error.message}`)
+  }
+}
+
 // ========== 文件选择(运行时上传) ==========
 function onFileSelect(e, isDir = false) {
   const files = Array.from(e.target.files || [])
@@ -1047,6 +1199,11 @@ function onFileSelect(e, isDir = false) {
 // 文件上传列表(运行对话框使用)
 const uploadFiles = ref([])
 
+function handleOpenRunDialog() {
+  openRunDialog(inputFields, formData)
+  uploadFiles.value = []
+}
+
 // 启动运行(包装 composable,处理上传文件 + 输入字段)
 async function handleStartRun() {
   const inputs = {}
@@ -1057,11 +1214,6 @@ async function handleStartRun() {
   uploadFiles.value = []
 }
 
-function handleOpenRunDialog() {
-  openRunDialog(inputFields, formData)
-  uploadFiles.value = []
-}
-
 // 回滚应用到画布(供 useVersionHistory 调用)
 async function applyVersionToCanvas(wf) {
   if (!wf) return
@@ -1338,7 +1490,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.variables || []" :key="i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('variables', [...selectedData.variables]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'variables', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
                     </div>
@@ -1385,7 +1537,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                     </div>
@@ -1417,7 +1569,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
@@ -1466,7 +1618,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                     </div>
@@ -1498,7 +1650,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
@@ -1547,7 +1699,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                     </div>
@@ -1579,7 +1731,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
@@ -1635,7 +1787,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                     </div>
@@ -1667,7 +1819,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
@@ -1792,7 +1944,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                     </div>
@@ -1824,7 +1976,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-' + i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'outputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
@@ -1869,7 +2021,7 @@ function applyGraphData(graphData) {
                   <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="v.name" placeholder="变量名" size="small"
-                        @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        @update:value="nv => { updateFieldName(v, i, 'inputs', nv) }" />
                       <n-input :value="v.label" placeholder="中文名" size="small"
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                     </div>
@@ -1905,7 +2057,7 @@ function applyGraphData(graphData) {
                   <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
                     <div class="var-form-row">
                       <n-input :value="f.name" placeholder="字段名" size="small"
-                        @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
+                        @update:value="nv => { updateFieldName(f, i, 'fields', nv) }" />
                       <n-input :value="f.label" placeholder="中文名" size="small"
                         @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
                     </div>
@@ -1915,7 +2067,11 @@ function applyGraphData(graphData) {
                       <n-input :value="f.description" placeholder="描述" size="small"
                         @update:value="nv => { f.description = nv; onFieldChange('fields', [...selectedData.fields]) }" />
                     </div>
-                    <div class="var-form-row" style="justify-content:flex-end">
+                    <div class="var-form-row mapping-row">
+                      <n-select :value="currentMappingValue(f)"
+                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
+                        clearable placeholder="关联方式:自动推断" style="flex:1"
+                        @update:value="nv => updateInputMapping(f, nv)" />
                       <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
                     </div>
                   </div>
@@ -2053,7 +2209,7 @@ function applyGraphData(graphData) {
     </div>
 
     <!-- 运行对话框 -->
-    <div class="dialog-overlay" v-if="showRunDialog" @click.self="cancelRun">
+    <div class="dialog-overlay" v-if="showRunDialog">
       <div class="run-dialog">
         <div class="dialog-header">
           <span class="dialog-title">运行工作流</span>
@@ -2063,6 +2219,14 @@ function applyGraphData(graphData) {
         </div>
         <div class="dialog-body">
           <div v-if="inputFields.length === 0" class="empty-hint">此工作流无需输入参数</div>
+          <div class="run-actions" v-if="inputFields.length">
+            <button class="text-btn" @click="exportRunVariables">
+              <n-icon size="12"><DownloadOutline /></n-icon> 变量导出
+            </button>
+            <button class="text-btn" @click="onImportVariablesClick">
+              <n-icon size="12"><CloudUploadOutline /></n-icon> 变量导入
+            </button>
+          </div>
           <div v-for="f in inputFields" :key="f.name" class="run-field">
             <label class="run-field-label">{{ f.label || f.name }}</label>
             <n-input v-model:value="formData[f.name]" :placeholder="f.description || f.name"
@@ -3376,6 +3540,19 @@ function applyGraphData(graphData) {
   margin-bottom: 4px;
 }
 
+/* 运行对话框变量导出/导入按钮 */
+.run-actions {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+
+.run-actions .text-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
 /* 文件上传区域 */
 .run-upload {
   margin-top: 16px;

+ 30 - 1
prompt.md

@@ -1259,4 +1259,33 @@ java.lang.IllegalStateException: rag-ai-bridge is disabled
 
 ---
 
-根据 @docs/workflow-node-output-envelope.md ,开展修改。
+在工作流编辑中:
+1. 对于输出节点来说,其输出变量的“引入”操作,接收前序节点的输出变量,也应有关联关系的配置功能,与其他节点的“前置数据”引入一样。
+2. 目前画布内节点的变量颜色,“前置数据”为蓝色,“输出变量”为绿色。对于“用户输入”节点而言,定义的所有变量,固然是该节点的“输出变量”,但对于整个工作流而言,它属于“输入变量”,因此,我希望它们在画布中显示为蓝色;对于“输出”节点而言,它定义的所有变量,固然都从其他节点接收,但对于整个工作流而言,属于“输出变量”,因此,我希望它们在画布中显示为绿色。
+3. 在一个节点内,前置数据变量名不能重复;输出变量名不能重复;此规则在添加和引入时都应触发,以toast形式警告用户。
+4. 如果一个变量关联到了前驱节点的某个变量,那么那个变量删除后,关联关系应自动回退为“自动推断”,现在显示有问题。
+
+---
+
+1. 变量重名时,阻止整个工作流的保存,弹出提示中说明哪些节点的哪些变量重名了;但不阻止输入;
+2. 如果一个变量关联到了前驱节点的某个变量,那么那个变量删除后,关联关系应自动回退为“自动推断”,这个仍未实现;
+3. 假如我在LLM 提示词中输入{{}},然后在里面缓慢地输入abc,那么有可能生成a、ab、abc三个变量,这是不对的,应该是同一个变量动态更名。实现它。
+
+---
+
+根据 @docs/workflow-node-output-envelope.md ,开展修改。
+
+---
+
+工作流的“运行”按钮按下后,弹出的变量输入对话框,增加一个“变量导出”和“变量导入”的按钮,导出按钮点击后,将已输入的变量保存为Json格式,未输入的不用管,命名为“工作流名称-节点名-输入变量.json”,然后提示用户选择保存位置保存。导入按钮支持将该文件导入,将已输入的变量填入对应表单。
+
+---
+
+变量导出报错:TypeError: Cannot read properties of undefined (reading 'num1')
+另外,变量输入对话框,点击对话框外的区域还是会被关闭,我不希望这样。
+
+---
+
+变量导出时,强制让用户选择保存位置,而不是下载到浏览器指定的默认为止。(未实现)
+
+---