Przeglądaj źródła

1. 实现了工作流节点名称功能,支持可选输入并在画布内唯一校验,标题改为「节点名称+节点类型」双行展示;
2. 统一了工作流节点宽度为固定 250px,不再根据内容动态扩展;
3. 优化了节点输入「关联方式」交互,从独立分区移到每个前置数据字段下方作为属性行;
4. 扩展了前置数据关联范围,由「仅直接上游」改为「画布中所有可达前序节点」,与运行时变量作用域定义对齐;
5. 统一了画布连接线视觉,不再按 io 推断状态变色,全部使用默认灰色避免混色差异;
6. 优化了工作流编辑器工具栏布局,名称输入框对齐上方 Label、描述输入框扩展到版本历史按钮左侧;
7. 修复了工作流画布与标签管理页面多余的上下滚动问题;
8. 优化了模型管理、技能管理、智能体编排列表页工具栏,滚动时固定在顶部不再一起滚动;
9. 更新了「智能体管理平台外部工作流」集成 Skill,外部 API 端点路径全部切换为 kebab-case 工作流 name 标识,同步端点契约与白名单说明;
10. 更新了 prompt.md 需求记录与工作流节点字段、变量作用域设计文档。

weisijie 1 miesiąc temu
rodzic
commit
a44ab848dd

+ 16 - 0
docs/workflow-node-fields.md

@@ -307,6 +307,22 @@ string | number | boolean | array | object | filePath | directoryPath
 | 前端节点默认数据 | `frontend/src/utils/workflowNode.js` |
 | 证据数据结构 | `backend/src/main/java/com/agent/management/rag/model/RagEvidence.java` |
 
+### 5.1 前置数据关联范围
+
+前端 `WorkflowEditor.vue` 中节点配置面板的"关联方式"下拉选项由 `inputMappingOptions` 计算得出,其范围:
+
+- **不限于直接上游**:从当前选中节点反向 BFS 遍历边图(`getReachablePredecessors(targetId, allNodes, allEdges)`),收集所有**可达前序节点**的输出字段。
+- 这意味着:上游的上游、分叉合并的另一支、跨多层跳跃的前序节点输出,都可在"关联方式"中显式选择。
+- 该范围与运行时 `NodeWorkspace` 的可达前驱定义一致(详见 `docs/workflow-variable-scope-design.md` 4.1 节),保证编辑期选项与运行期可见变量对齐。
+
+涉及的实现:
+
+| 函数 | 位置 | 作用 |
+|------|------|------|
+| `getReachablePredecessors` | `frontend/src/utils/ioInference.js` | 反向 BFS 计算可达前驱节点列表 |
+| `inputMappingOptions` | `frontend/src/views/workflow/WorkflowEditor.vue` | 列出可达前驱 × 输出字段作为关联选项 |
+| `getNodeOutputs` | `frontend/src/utils/ioInference.js` | 提取单个节点的输出字段 |
+
 ---
 
 ## 6. 待实现建议

+ 12 - 4
docs/workflow-variable-scope-design.md

@@ -134,7 +134,7 @@ public class WorkflowContext {
 Map<String, List<EdgeInfo>> incomingEdges
 ```
 
-静态可达前驱(规划阶段使用):
+静态可达前驱(规划阶段与前端编辑阶段使用):
 
 ```java
 Set<String> getReachablePredecessors(String nodeId) {
@@ -154,6 +154,12 @@ Set<String> getReachablePredecessors(String nodeId) {
 }
 ```
 
+> 前端 `ioInference.js` 中已实现等价的 `getReachablePredecessors(targetId, allNodes, allEdges)`(BFS 反向遍历边图),用于:
+> - 节点"前置数据关联"下拉选项(`WorkflowEditor.vue` 的 `inputMappingOptions`):列出所有可达前序节点的输出字段供用户显式关联,**不仅限直接上游**。
+> - 边映射状态判断(`collectReachableOutputs`):合并所有可达前驱的输出做同名匹配。
+>
+> 这保证编辑期可见的关联选项与运行期 `NodeWorkspace` 的可达变量集合对齐。
+
 动态可达前驱(运行时,条件分支生效后):
 
 - 条件节点仅把实际激活的分支下游加入可达集。
@@ -294,11 +300,13 @@ static String normalizeForMatch(String s) {
 
 | 文件 | 修改点 |
 |------|--------|
-| `ioInference.js` | `getNodeInputs` 返回 mapping 字段;`inferEdgeMapping` 增加隐式关联逻辑 |
-| `WorkflowEditor.vue` | 边变化时刷新相关节点 input mapping |
-| 节点配置面板 | 每个 input 增加“关联变量”选择器 |
+| `ioInference.js` | 新增 `getReachablePredecessors(targetId, allNodes, allEdges)`:通过边反向 BFS 计算可达前驱;`getNodeInputs` 返回 mapping 字段;`inferEdgeMapping` 增加隐式关联逻辑 |
+| `WorkflowEditor.vue` | `inputMappingOptions` 基于 `getReachablePredecessors` 列出**所有可达前驱节点**的输出(不仅限直接上游);边变化时刷新相关节点 input mapping |
+| 节点配置面板 | 每个 input 增加"关联方式"选择器,选项来自全部可达前驱 |
 | `workflowNode.js` | 默认 data 中 inputs 增加 mapping 字段 |
 
+> **关联范围说明**:节点 `N` 的"前置数据关联"下拉选项 = `getReachablePredecessors(N.id)` 返回的所有节点 × 各节点的输出字段。即:从 `N` 反向 BFS 边图,凡是画布中可达的前序节点(直接上游、上游的上游、分叉合并的另一支等)输出都可显式关联。这与运行时 `NodeWorkspace` 的可达前驱定义一致(见 4.1 节),保证编辑期选项与运行期可见变量对齐。
+
 ---
 
 ## 7. 兼容策略

+ 31 - 3
frontend/src/components/workflow/nodes/BaseNode.vue

@@ -21,7 +21,10 @@ const props = defineProps({
       <div class="node-icon">
         <slot name="icon" />
       </div>
-      <span class="node-title">{{ data?.label || '节点' }}</span>
+      <div class="node-title-wrap">
+        <span v-if="data?.nodeName" class="node-title-name" :title="data.nodeName">{{ data.nodeName }}</span>
+        <span class="node-title-type">{{ data?.label || '节点' }}</span>
+      </div>
     </div>
 
     <div class="node-body" v-if="$slots.body">
@@ -44,8 +47,8 @@ const props = defineProps({
   background: var(--bg-card, #1e1e2e);
   border: 2px solid var(--node-color);
   border-radius: 10px;
-  min-width: 180px;
-  max-width: 260px;
+  width: 250px;
+  box-sizing: border-box;
   box-shadow: 0 2px 8px rgba(0,0,0,0.15);
   transition: box-shadow 0.2s;
 }
@@ -80,6 +83,31 @@ const props = defineProps({
   color: var(--text-primary, #e0e0e0);
 }
 
+.node-title-wrap {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+  min-width: 0;
+  flex: 1;
+}
+
+.node-title-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-primary, #e0e0e0);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.node-title-type {
+  font-size: 11px;
+  font-weight: 400;
+  color: var(--text-tertiary, #888);
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
 .node-body {
   padding: 8px 12px;
   font-size: 12px;

+ 31 - 2
frontend/src/components/workflow/nodes/ConditionNode.vue

@@ -28,7 +28,10 @@ const inputSummary = computed(() => {
     <Handle type="target" :position="Position.Left" class="handle-target" />
     <div class="cond-header">
       <div class="cond-icon"><GitBranchOutline /></div>
-      <span class="cond-title">{{ data?.label || '条件分支' }}</span>
+      <div class="cond-title-wrap">
+        <span v-if="data?.nodeName" class="cond-title-name" :title="data.nodeName">{{ data.nodeName }}</span>
+        <span class="cond-title-type">{{ data?.label || '条件分支' }}</span>
+      </div>
     </div>
     <div v-if="inputSummary.length" class="io-summary">
       <span class="io-tag io-in" v-for="name in inputSummary" :key="name">{{ name }}</span>
@@ -60,7 +63,8 @@ export default { name: 'ConditionNode' }
   background: var(--bg-card, #1e1e2e);
   border: 2px solid #F97316;
   border-radius: 10px;
-  min-width: 180px;
+  width: 250px;
+  box-sizing: border-box;
   box-shadow: 0 2px 8px rgba(0,0,0,0.15);
   position: relative;
 }
@@ -91,6 +95,31 @@ export default { name: 'ConditionNode' }
   color: var(--text-primary, #e0e0e0);
 }
 
+.cond-title-wrap {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+  min-width: 0;
+  flex: 1;
+}
+
+.cond-title-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-primary, #e0e0e0);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.cond-title-type {
+  font-size: 11px;
+  font-weight: 400;
+  color: var(--text-tertiary, #888);
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
 .cond-branches {
   padding: 6px 12px;
 }

+ 34 - 0
frontend/src/utils/ioInference.js

@@ -302,6 +302,40 @@ function collectReachableOutputs(targetId, allNodes, allEdges) {
   return [...merged.values()]
 }
 
+/**
+ * 获取目标节点的所有可达前驱节点(通过边反向 BFS)
+ *
+ * 用于"前置数据关联"下拉选项:节点输入不仅可关联直接上游,
+ * 也可关联画布中所有可达的前序节点输出。
+ *
+ * @param {string} targetId - 目标节点 ID
+ * @param {Array} allNodes - 全部节点
+ * @param {Array} allEdges - 全部边
+ * @returns {Array} 可达前驱节点列表(按 BFS 发现顺序,不含 targetId 本身,不重复)
+ */
+export function getReachablePredecessors(targetId, allNodes, allEdges) {
+  const nodeMap = new Map(allNodes.map(n => [n.id, n]))
+  const visited = new Set([targetId])
+  const queue = [targetId]
+  const result = []
+
+  while (queue.length > 0) {
+    const currentId = queue.shift()
+    for (const edge of allEdges) {
+      if (edge.target !== currentId) continue
+      if (visited.has(edge.source)) continue
+      visited.add(edge.source)
+      const sourceNode = nodeMap.get(edge.source)
+      if (sourceNode) {
+        result.push(sourceNode)
+        queue.push(edge.source)
+      }
+    }
+  }
+
+  return result
+}
+
 /**
  * 推断指定边的样式
  */

+ 5 - 0
frontend/src/views/ModelManagement.vue

@@ -445,6 +445,11 @@ async function savePriorities(reordered) {
   align-items: center;
   justify-content: space-between;
   margin-bottom: 20px;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  background: var(--bg-primary);
+  padding-bottom: 12px;
 }
 
 .model-count {

+ 5 - 0
frontend/src/views/SkillManagement.vue

@@ -533,6 +533,11 @@ function onSearchInput(value) {
   align-items: center;
   justify-content: space-between;
   margin-bottom: 20px;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  background: var(--bg-primary);
+  padding-bottom: 12px;
 }
 
 .toolbar-left {

+ 1 - 1
frontend/src/views/TagManagement.vue

@@ -396,7 +396,7 @@ onMounted(() => {
 .tag-management {
   display: flex;
   gap: 24px;
-  height: calc(100vh - var(--header-height) - 48px);
+  height: calc(100vh - var(--header-height) - 56px);
   padding: 0;
 }
 

+ 138 - 61
frontend/src/views/workflow/WorkflowEditor.vue

@@ -32,7 +32,7 @@ import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
 import SmartActionNode from '../../components/workflow/nodes/SmartActionNode.vue'
 import VirtualGroupNode from '../../components/workflow/nodes/VirtualGroupNode.vue'
 import CodeEditor from '../../components/skill/CodeEditor.vue'
-import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, getNodeOutputs, IO_TYPES } from '../../utils/ioInference'
+import { inferNewEdge, refreshMappingsForNode, getNodeOutputs, getReachablePredecessors, IO_TYPES } from '../../utils/ioInference'
 import { useVersionHistory } from '../../composables/useVersionHistory'
 import { useWorkflowRunner } from '../../composables/useWorkflowRunner'
 import { useNodeFields } from '../../composables/useNodeFields'
@@ -175,7 +175,8 @@ onConnect((params) => {
     ...params,
     type: 'bezier',
     animated: true,
-    style: inferred?.style || defaultEdgeStyle,
+    // 视觉上统一灰色,io 推断状态仅写入 data 供属性面板展示(不再影响线条颜色)
+    style: defaultEdgeStyle,
     data: inferred?.data || {}
   }])
 })
@@ -227,13 +228,12 @@ onNodeDragStop(() => {
   virtualEdgeDragInProgress = false
 })
 
-// 批量更新边的映射数据和样式
+// 批量更新边的映射数据(仅写入 data,不再改变 style;视觉统一灰色,避免不同映射状态造成颜色差异)
 function applyEdgeMappingUpdates(updates) {
   for (const upd of updates) {
     const edge = getEdges.value.find(e => e.id === upd.id)
     if (edge) {
       edge.data = { ...edge.data, ...upd.data }
-      edge.style = upd.style
     }
   }
 }
@@ -263,12 +263,13 @@ const nodeTypeInfo = {
 
 function getDefaultData(type) {
   switch (type) {
-    case 'userInput': return { label: '用户输入', variables: [] }
-    case 'llm': return { label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
-    case 'agent': return { label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
-    case 'skill': return { label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
-    case 'smartAction': return { label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
+    case 'userInput': return { nodeName: '', label: '用户输入', variables: [] }
+    case 'llm': return { nodeName: '', label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
+    case 'agent': return { nodeName: '', label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
+    case 'skill': return { nodeName: '', label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
+    case 'smartAction': return { nodeName: '', label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
     case 'knowledgeRetrieval': return {
+      nodeName: '',
       label: '知识库检索',
       source: 'document',
       query: '',
@@ -281,9 +282,9 @@ function getDefaultData(type) {
       knowledgeBaseId: null,
       topK: 5
     }
-    case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
-    case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
-    default: return { label: '节点' }
+    case 'condition': return { nodeName: '', label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
+    case 'output': return { nodeName: '', label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
+    default: return { nodeName: '', label: '节点' }
   }
 }
 
@@ -402,19 +403,18 @@ const selectedEdge = computed(() => {
   return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
 })
 
-// ========== 输入变量关联:列出所有直接上游节点的输出字段供选择 ==========
+// ========== 输入变量关联:列出所有可达前驱节点的输出字段供选择 ==========
+// 关联范围:不仅限直接上游,而是通过边反向 BFS 找出画布中所有可达的前序节点
 const inputMappingOptions = computed(() => {
   if (!selectedNode.value) return []
+  const predecessors = getReachablePredecessors(selectedNode.value.id, getNodes.value, getEdges.value)
   const opts = []
-  for (const edge of getEdges.value) {
-    if (edge.target !== selectedNode.value.id) continue
-    const src = getNodes.value.find(n => n.id === edge.source)
-    if (!src) continue
+  for (const src of predecessors) {
     const outputs = getNodeOutputs(src)
     for (const out of outputs) {
       const value = JSON.stringify({ nodeId: src.id, field: out.name })
       opts.push({
-        label: `${src.data?.label || src.id}.${out.name}`,
+        label: `${src.data?.nodeName || src.data?.label || src.id}.${out.name}`,
         value,
         nodeId: src.id,
         field: out.name
@@ -444,6 +444,22 @@ function clearInputMapping(input) {
   onFieldChange('inputs', [...selectedData.value.inputs])
 }
 
+// ========== 节点名称编辑(含重名校验) ==========
+function updateNodeName(value) {
+  const trimmed = (value || '').trim()
+  if (trimmed && selectedNode.value) {
+    const duplicate = getNodes.value.find(n =>
+      n.id !== selectedNode.value.id &&
+      n.data?.nodeName === trimmed
+    )
+    if (duplicate) {
+      message.warning(`节点名称「${trimmed}」已被其他节点使用`)
+      return
+    }
+  }
+  onFieldChange('nodeName', trimmed)
+}
+
 // ========== 智能体选项 ==========
 const agentOptions = computed(() =>
   (skillStore.skills || []).map(s => ({
@@ -937,7 +953,8 @@ async function applyVersionToCanvas(wf) {
       ...e,
       type: 'bezier',
       animated: true,
-      style: getEdgeStyle(e.data?.status),
+      style: defaultEdgeStyle,
+      data: e.data || {}
     })))
     await nextTick()
     for (const node of getNodes.value) {
@@ -1042,7 +1059,7 @@ function applyGraphData(graphData) {
           </n-icon>
         </button>
         <div class="toolbar-form">
-          <div class="wf-field">
+          <div class="wf-field wf-field-show">
             <label class="wf-label">
               智能体展示名称
               <span class="label-hint">(可包含中文)</span>
@@ -1050,34 +1067,34 @@ function applyGraphData(graphData) {
             <n-input v-model:value="form.displayName" placeholder="如:军事分析助手" style="width: 200px" size="small"
               :maxlength="100" />
           </div>
-          <div class="wf-field">
+          <div class="wf-field wf-field-name">
             <label class="wf-label">
               智能体名称
               <span class="label-hint">(kebab-case,唯一标识,禁用大写/空格/下划线/中文)</span>
             </label>
-            <n-input v-model:value="form.name" placeholder="如:military-analyze-agent" style="width: 220px" size="small"
+            <n-input v-model:value="form.name" placeholder="如:military-analyze-agent" size="small"
               :maxlength="64"
               :status="nameState.status === 'invalid' ? 'error' : nameState.status === 'will-rename' || nameState.status === 'will-normalize' ? 'warning' : 'success'" />
-            <div class="wf-name-hint">
-              <span v-if="nameState.status === 'will-rename'" class="hint-warn">
-                ⚠ 保存时将重命名:<code>{{ routeName }}</code> → <code>{{ nameState.normalized }}</code>
-              </span>
-              <span v-else-if="nameState.status === 'will-normalize'" class="hint-warn">
-                将规范化为:<code>{{ nameState.normalized }}</code>
-              </span>
-              <span v-else-if="nameState.status === 'invalid'" class="hint-error">
-                规范化后仍非法(需含字母或数字)
-              </span>
-              <span v-else-if="nameState.status === 'valid'" class="hint-ok">
-                ✓ 合法 kebab-case
-              </span>
-            </div>
           </div>
-          <div class="wf-field">
+          <div class="wf-field wf-field-grow">
             <label class="wf-label">智能体描述</label>
-            <n-input v-model:value="form.description" placeholder="请输入智能体描述" style="width: 240px" size="small"
+            <n-input v-model:value="form.description" placeholder="请输入智能体描述" size="small"
               :maxlength="500" />
           </div>
+          <div class="wf-name-hint">
+            <span v-if="nameState.status === 'will-rename'" class="hint-warn">
+              ⚠ 保存时将重命名:<code>{{ routeName }}</code> → <code>{{ nameState.normalized }}</code>
+            </span>
+            <span v-else-if="nameState.status === 'will-normalize'" class="hint-warn">
+              将规范化为:<code>{{ nameState.normalized }}</code>
+            </span>
+            <span v-else-if="nameState.status === 'invalid'" class="hint-error">
+              规范化后仍非法(需含字母或数字)
+            </span>
+            <span v-else-if="nameState.status === 'valid'" class="hint-ok">
+              ✓ 合法 kebab-case
+            </span>
+          </div>
         </div>
       </div>
       <div class="toolbar-right">
@@ -1181,6 +1198,12 @@ function applyGraphData(graphData) {
           <!-- 节点属性 Tab -->
           <div v-show="activeTab === 'props'" class="tab-pane">
             <template v-if="selectedData">
+              <!-- 节点名称(可选,画布内唯一) -->
+              <div class="prop-section">
+                <label class="prop-label">节点名称</label>
+                <n-input :value="selectedData?.nodeName || ''" placeholder="可选,留空则仅显示节点类型" size="small"
+                  @update:value="v => updateNodeName(v)" />
+              </div>
               <!-- 用户输入节点 -->
               <template v-if="selectedNode?.type === 'userInput'">
                 <div class="prop-section">
@@ -1247,6 +1270,12 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                       <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
                     </div>
+                    <div class="var-form-row mapping-row">
+                      <n-select :value="currentMappingValue(v)"
+                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
+                        clearable placeholder="关联方式:自动推断"
+                        @update:value="nv => updateInputMapping(v, nv)" />
+                    </div>
                   </div>
                   <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
                 </div>
@@ -1310,6 +1339,12 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                       <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                     </div>
+                    <div class="var-form-row mapping-row">
+                      <n-select :value="currentMappingValue(v)"
+                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
+                        clearable placeholder="关联方式:自动推断"
+                        @update:value="nv => updateInputMapping(v, nv)" />
+                    </div>
                   </div>
                   <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
                 </div>
@@ -1373,6 +1408,12 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                       <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                     </div>
+                    <div class="var-form-row mapping-row">
+                      <n-select :value="currentMappingValue(v)"
+                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
+                        clearable placeholder="关联方式:自动推断"
+                        @update:value="nv => updateInputMapping(v, nv)" />
+                    </div>
                   </div>
                   <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
                 </div>
@@ -1443,6 +1484,12 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                       <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                     </div>
+                    <div class="var-form-row mapping-row">
+                      <n-select :value="currentMappingValue(v)"
+                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
+                        clearable placeholder="关联方式:自动推断"
+                        @update:value="nv => updateInputMapping(v, nv)" />
+                    </div>
                   </div>
                   <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{ 变量名 }} 引用,或手动添加</div>
                 </div>
@@ -1599,6 +1646,12 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
                       <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
                     </div>
+                    <div class="var-form-row mapping-row">
+                      <n-select :value="currentMappingValue(v)"
+                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
+                        clearable placeholder="关联方式:自动推断"
+                        @update:value="nv => updateInputMapping(v, nv)" />
+                    </div>
                   </div>
                   <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
                   <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
@@ -1628,23 +1681,6 @@ function applyGraphData(graphData) {
                   <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
                 </div>
               </template>
-              <!-- 输入变量关联 -->
-              <template v-if="selectedData?.inputs?.length">
-                <div class="prop-section">
-                  <div class="prop-header">
-                    <span class="prop-label">输入变量关联</span>
-                    <span class="empty-hint">未选择时将使用自动推断</span>
-                  </div>
-                  <div v-for="(v, i) in selectedData.inputs" :key="'map-' + i" class="var-form">
-                    <div class="var-form-row">
-                      <span class="mapping-field" style="flex:1;min-width:80px">{{ v.name }}</span>
-                      <n-select :value="currentMappingValue(v)"
-                        :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small" style="flex:2"
-                        clearable placeholder="自动推断" @update:value="nv => updateInputMapping(v, nv)" />
-                    </div>
-                  </div>
-                </div>
-              </template>
             </template>
             <!-- 边映射信息 -->
             <template v-else-if="selectedEdge?.data">
@@ -1910,7 +1946,7 @@ function applyGraphData(graphData) {
   height: calc(100vh - var(--header-height));
   display: flex;
   flex-direction: column;
-  margin: -24px -32px 0;
+  margin: -28px -32px;
   background: #13111a;
 }
 
@@ -1931,6 +1967,12 @@ function applyGraphData(graphData) {
   gap: 10px;
 }
 
+/* 让左侧工具栏占满 toolbar 中间空间,使描述输入框可扩展到版本历史按钮左侧 */
+.toolbar-left {
+  flex: 1;
+  min-width: 0;
+}
+
 /* 名称 5 态机提示 */
 .name-status-hint {
   font-size: 11px;
@@ -1952,17 +1994,44 @@ function applyGraphData(graphData) {
   color: #22c55e;
 }
 
-/* 工具栏表单(参考 SkillEdit 的 .form-group / .form-label / .label-hint / .name-hint 风格) */
+/* 工具栏表单:grid 布局让名称提示跨名称、描述两列延伸到描述字段右边界 */
 .toolbar-form {
-  display: flex;
-  gap: 14px;
-  align-items: flex-start;
+  display: grid;
+  grid-template-columns: auto auto 1fr;
+  column-gap: 14px;
+  row-gap: 4px;
+  align-items: start;
+  flex: 1;
+  min-width: 0;
 }
 
 .wf-field {
   display: flex;
   flex-direction: column;
   gap: 4px;
+  min-width: 0;
+}
+
+/* 让 wf-field 内的 n-input 拉伸到 field 宽度(默认 inline-flex 不会自动占满) */
+.wf-field :deep(.n-input) {
+  width: 100%;
+}
+
+/* 三列定位:展示名称 / 名称 / 描述(描述占满剩余空间) */
+.wf-field-show {
+  grid-column: 1;
+  grid-row: 1;
+}
+
+.wf-field-name {
+  grid-column: 2;
+  grid-row: 1;
+}
+
+.wf-field.wf-field-grow {
+  grid-column: 3;
+  grid-row: 1;
+  min-width: 0;
 }
 
 .wf-label {
@@ -1979,13 +2048,14 @@ function applyGraphData(graphData) {
 }
 
 .wf-name-hint {
+  grid-column: 2 / -1;
+  grid-row: 2;
   font-size: 11px;
   min-height: 14px;
   line-height: 1.2;
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  max-width: 220px;
 }
 
 .wf-name-hint code {
@@ -2604,6 +2674,13 @@ function applyGraphData(graphData) {
   margin-bottom: 6px;
 }
 
+/* 关联方式行:作为前置数据的一个属性,紧跟字段定义 */
+.var-form-row.mapping-row {
+  margin-top: 2px;
+  padding-top: 6px;
+  border-top: 1px dashed rgba(255, 255, 255, 0.06);
+}
+
 .cond-form {
   margin-bottom: 8px;
 }

+ 5 - 0
frontend/src/views/workflow/WorkflowList.vue

@@ -562,6 +562,11 @@ onMounted(() => {
   align-items: center;
   justify-content: space-between;
   margin-bottom: 20px;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  background: var(--bg-primary);
+  padding-bottom: 12px;
 }
 
 .toolbar-left {

+ 39 - 1
prompt.md

@@ -1145,4 +1145,42 @@ java.lang.IllegalStateException: rag-ai-bridge is disabled
 ---
 
 1. 将“文档数据”页面的“上传文档”按钮、“RAG治理”页面的“应用审核后的授权配置”按钮、“技能管理”页面的“新增技能”按钮、“智能体编排”页面的“创建智能体”按钮、“智能体模板”页面的“保存修改”按钮、“模型管理”页面的“新增模型”按钮,统一成“知识图谱”页面的“查询”按钮风格。
-2. 将“技能管理”页面的“刷新”按钮、“智能体编排”页面的“刷新”按钮和“模板管理”按钮、“智能体模板”页面的“刷新”按钮和“新增模板”按钮、“标签管理”页面的标签组“导入”“导出”“新增”按钮和标签“新增”按钮,统一成“文档数据”页面“刷新”按钮的风格。其中,原来只有logo没有文字的按钮,仍保持只有logo即可,只有边框和背景统一。
+2. 将“技能管理”页面的“刷新”按钮、“智能体编排”页面的“刷新”按钮和“模板管理”按钮、“智能体模板”页面的“刷新”按钮和“新增模板”按钮、“标签管理”页面的标签组“导入”“导出”“新增”按钮和标签“新增”按钮,统一成“文档数据”页面“刷新”按钮的风格。其中,原来只有logo没有文字的按钮,仍保持只有logo即可,只有边框和背景统一。
+
+---
+
+工作流页面,菜单栏之外的区域,整体会发生一个约28px的上下滚动,我不希望它发生。
+
+---
+
+1. 工作流画布中,在不选中的状态下,有的连接线是灰色的,有的是橙色的。运行结束后,有的是绿色的,有的是橙色的。为什么会有差异?统一一下。
+2. 标签管理页面,也会有一个整体的微小上下滚动,去掉它。
+3. 模型管理页面、技能管理页面、智能体编排页面,上下滚动时,将最上方的按钮栏固定住,不要一起滚动。
+
+---
+
+我要给每个节点增加一个节点名称输入框(可选输入),若输入,在画布中节点名称不能重复。输入后,画布中节点展示为“节点名称 节点类型”,如“关系查询 知识库检索 ”。节点名称和节点类型字体区分开来。若节点名称为空,直接显示“节点类型”。
+另外,工作流中,每个节点的前置数据,添加后,关联方式出现在了表单最下方,应该紧跟该前置数据,作为数据的一个属性。关联方式中,使用节点名称来区分各节点。
+
+---
+
+工作流编辑界面,智能体名称输入框,扩展到与上方Label右边界相同;智能体描述输入框,右边界扩展到“版本历史”按钮左侧。
+
+---
+
+工作流中,每个节点宽度固定为250px,不再根据其中内容动态扩展节点宽度。
+
+---
+
+之前我说,定义某个节点的输入数据,让它可以与前序节点的输出关联。我说的前序节点不只是它前面的一个节点,而是它前面所有节点的输出都可以关联。修改逻辑,同步更新docs下的对应文档。
+
+---
+
+我们将工作流唯一标识的设计进行了修改,对应更新 @skills/agent-management-external-workflow 中的技能。
+
+---
+
+智能体名称的输入框,最大就到上方Label的右边界即可;下方名称更改的提示,最大宽度可以延伸到右侧描述输入框的右边界。
+
+---
+

+ 4 - 3
skills/agent-management-external-workflow/SKILL.md

@@ -22,7 +22,7 @@ description: "当需要构建或修改接入智能体管理平台(agent-manage
 集成前必须由用户明确给出以下输入;缺失任一关键项应主动追问,不要自行假设:
 
 - **智能体管理平台 baseUrl(必填)**:例如 `https://amp.example.com` 或 `http://10.0.0.5:2438`。注意是否包含端口与协议;末尾不要带 `/api/v1`,因为端点路径已经包含。
-- **目标工作流(必填)**:优先使用工作流 ID(`workflowId`,整数),其次是工作流名称(需在运行时解析为 ID)。同一 baseUrl 下可能存在多个工作流,必须由用户指定具体的一个或一组。
+- **目标工作流(必填)**:优先使用工作流的 `name`(kebab-case 字符串,例如 `military-analyze-agent`,全局唯一、可读、用于所有外部 API URL 路径)。同时兼容旧的数字 `id`:纯数字路径参数会被视为 `id` 回退查找。同一 baseUrl 下可能存在多个工作流,必须由用户指定具体的一个或一组。
 - **API Key(必填,敏感)**:用于 `X-API-Key` 鉴权。禁止写入受版本控制的源码或浏览器可见产物;默认放在后端配置或环境变量中。
 - **超时、重试、TTL 等可选项**:如不提供,使用契约默认值(如 `ttlHours=720`)。
 
@@ -40,7 +40,7 @@ description: "当需要构建或修改接入智能体管理平台(agent-manage
 仅在缺少信息会阻塞安全可运行的实现时才提问。
 
 - 不要询问那些能从目标仓库代码、配置、文档或现有运行视图中自行发现的信息。
-- 仅当无法推断时,才询问 `baseUrl`、API key 存储位置、目标 `workflowId` 或页面挂载位置。
+- 仅当无法推断时,才询问 `baseUrl`、API key 存储位置、目标工作流 `name`(或旧数字 `id`)或页面挂载位置。
 - `X-API-Key` 默认通过后端代理或服务端环境变量注入;不要将 API key 硬编码到浏览器代码中。
 - 若用户明确要求在前端直接携带 `X-API-Key`,需在实现前说明泄露风险。
 - 对非阻塞性细节,简要说明假设后继续推进。
@@ -53,9 +53,10 @@ description: "当需要构建或修改接入智能体管理平台(agent-manage
 
 2. 按外部工作流契约实现 API 接入。
    - 使用 `/api/v1` 端点,而不是内部 `/api/workflows` 端点,除非任务明确限定在 agent-management 内部。
+   - 工作流通过 `name`(kebab-case)标识,所有 URL 路径使用 `{workflowName}` 段;纯数字 `id` 作为兼容兜底也合法,但不应在新集成中作为首选。
    - 支持默认异步流程:创建运行、启动运行、订阅流、查询结果、下载工作空间。
    - 正确处理统一 JSON 响应包、二进制 zip 响应、SSE 事件、断连/重连以及终态成功/失败。
-   - **baseUrl、workflowId、API Key、超时、TTL 等参数全部从配置文件读取,禁止硬编码到源码**;凭证不得写入受版本控制的源码或浏览器可见产物中,除非用户明确接受此风险。
+   - **baseUrl、workflow name/id、API Key、超时、TTL 等参数全部从配置文件读取,禁止硬编码到源码**;凭证不得写入受版本控制的源码或浏览器可见产物中,除非用户明确接受此风险。
 
 3. 实现运行展示 UI,保留运行记录的内容与流程层级。
    - 展示工作流/运行选择、运行状态、时间/耗时、节点卡片、输出变量、思考过程、非思考日志、错误和工作空间下载。

+ 50 - 26
skills/agent-management-external-workflow/references/external-workflow-api.md

@@ -21,27 +21,46 @@
 
 - 主要错误码:`UNAUTHORIZED`、`VALIDATION_FAILED`、`WORKFLOW_NOT_FOUND`、`RUN_NOT_FOUND`、`GRAPH_INVALID`、`RUN_ALREADY_STARTED`、`INTERNAL_ERROR`
 
+## 工作流标识
+
+外部 API 的所有 `{workflowName}` 路径段同时接受两种值,**优先使用 `name`**:
+
+- **`name`(推荐)**:kebab-case 字符串,正则 `^[a-z0-9]+(-[a-z0-9]+)*$`,最大 64 字符,全局唯一。例:`military-analyze-agent`、`report-pipeline`。
+- **`id`(兼容兜底)**:纯数字字符串。当传入值不是 `name` 且为纯数字时,按内部主键 `id` 查找。仅用于兼容历史调用方,新集成不要依赖。
+
+解析顺序:先 `findByName(nameOrId)`,未命中且为纯数字时回退 `findById(Long.valueOf(nameOrId))`。
+
+响应中的标识字段约定:
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `workflowId` | number | 内部数字主键,仅作为元数据返回,**不用于 URL 路径** |
+| `workflowName` | string | kebab-case 唯一标识,所有 links、downloadUrl、logsUrl 都用它拼接 |
+| `displayName` | string | 中文展示名,仅用于 UI 展示 |
+
+工作流列表响应还会返回 `description`、`inputs`、`outputs`(从 `userInput` / `output` 节点自动提取的字段定义)。
+
 ## 端点
 
 | 方法 | 路径 | 用途 |
 |---|---|---|
-| `GET` | `/api/v1/workflows` | 列出当前 API key 可访问的工作流。当前 agent-management 实现返回所有可访问工作流;分页/搜索查询参数仅作前向兼容保留,除非已确认目标版本支持。 |
-| `POST` | `/api/v1/workflows/{workflowId}/runs` | 创建一次运行,可携带变量与可选文件 |
-| `POST` | `/api/v1/workflows/{workflowId}/runs/{runId}/start` | 启动一个已创建的异步运行 |
-| `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}/stream` | 订阅 SSE 节点/运行事件 |
-| `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}` | 查询运行状态、输出、节点和工作空间信息 |
-| `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}/nodes/{nodeId}/logs` | 查询某节点的持久化日志 |
-| `GET` | `/api/v1/workflows/{workflowId}/runs/{runId}/workspace` | 下载工作空间 zip |
+| `GET` | `/api/v1/workflows` | 列出当前 API key 可访问的工作流。响应项含 `id`、`name`、`displayName`、`description`、`inputs`、`outputs`;当前实现返回所有可访问工作流,分页/搜索查询参数仅作前向兼容保留。 |
+| `POST` | `/api/v1/workflows/{workflowName}/runs` | 创建一次运行,可携带变量与可选文件。`{workflowName}` 接受 kebab-case `name` 或纯数字 `id`。 |
+| `POST` | `/api/v1/workflows/{workflowName}/runs/{runId}/start` | 启动一个已创建的异步运行 |
+| `GET` | `/api/v1/workflows/{workflowName}/runs/{runId}/stream` | 订阅 SSE 节点/运行事件 |
+| `GET` | `/api/v1/workflows/{workflowName}/runs/{runId}` | 查询运行状态、输出、节点和工作空间信息 |
+| `GET` | `/api/v1/workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs` | 查询某节点的持久化日志 |
+| `GET` | `/api/v1/workflows/{workflowName}/runs/{runId}/workspace` | 下载工作空间 zip |
 
 ## 推荐的异步流程
 
-1. `POST /workflows/{workflowId}/runs`,带 `async: true`。
-2. 读取 `data.runId` 和 `data.links`。
-3. `POST /workflows/{workflowId}/runs/{runId}/start`。
-4. 打开 `GET /workflows/{workflowId}/runs/{runId}/stream`,请求头 `Accept: text/event-stream`。
+1. `POST /workflows/{workflowName}/runs`,带 `async: true`。`{workflowName}` 推荐使用 kebab-case `name`。
+2. 读取 `data.runId` 和 `data.links`。响应中的 `workflowId`(数字主键)和 `workflowName`(kebab-case)可分别作为元数据保存。
+3. `POST /workflows/{workflowName}/runs/{runId}/start`。
+4. 打开 `GET /workflows/{workflowName}/runs/{runId}/stream`,请求头 `Accept: text/event-stream`。
 5. 收到 `workflow_complete` 或 `workflow_error` 后,关闭流或标记为终态。
-6. 查询 `GET /workflows/{workflowId}/runs/{runId}` 获取持久化输出与节点摘要。
-7. 将 `GET /workflows/{workflowId}/runs/{runId}/workspace` 作为二进制下载入口提供给用户。
+6. 查询 `GET /workflows/{workflowName}/runs/{runId}` 获取持久化输出与节点摘要。
+7. 将 `GET /workflows/{workflowName}/runs/{runId}/workspace` 作为二进制下载入口提供给用户。
 
 仅当期望"单次阻塞请求直接返回 SSE"时才使用 `async: false`。
 
@@ -50,7 +69,7 @@
 纯 JSON 请求:
 
 ```http
-POST /api/v1/workflows/12/runs
+POST /api/v1/workflows/military-analyze-agent/runs
 X-API-Key: demo-key
 Content-Type: application/json
 
@@ -69,7 +88,7 @@ Content-Type: application/json
 Multipart 请求:
 
 ```http
-POST /api/v1/workflows/12/runs
+POST /api/v1/workflows/military-analyze-agent/runs
 X-API-Key: demo-key
 Content-Type: multipart/form-data
 
@@ -95,13 +114,15 @@ files: inputs/2024/data.csv
   "data": {
     "runId": "a3f9b2c1",
     "workflowId": 12,
+    "workflowName": "military-analyze-agent",
+    "displayName": "军事分析助手",
     "status": "CREATED",
     "createdAt": "2026-07-01T08:30:00Z",
     "links": {
-      "start": "/api/v1/workflows/12/runs/a3f9b2c1/start",
-      "stream": "/api/v1/workflows/12/runs/a3f9b2c1/stream",
-      "result": "/api/v1/workflows/12/runs/a3f9b2c1",
-      "workspace": "/api/v1/workflows/12/runs/a3f9b2c1/workspace"
+      "start": "/api/v1/workflows/military-analyze-agent/runs/a3f9b2c1/start",
+      "stream": "/api/v1/workflows/military-analyze-agent/runs/a3f9b2c1/stream",
+      "result": "/api/v1/workflows/military-analyze-agent/runs/a3f9b2c1",
+      "workspace": "/api/v1/workflows/military-analyze-agent/runs/a3f9b2c1/workspace"
     }
   }
 }
@@ -109,10 +130,10 @@ files: inputs/2024/data.csv
 
 ## SSE 流
 
-打开连接:
+打开连接(路径段使用 `workflowName`,即 kebab-case `name`)
 
 ```http
-GET /api/v1/workflows/{workflowId}/runs/{runId}/stream
+GET /api/v1/workflows/{workflowName}/runs/{runId}/stream
 X-API-Key: <key>
 Accept: text/event-stream
 ```
@@ -166,7 +187,7 @@ data: {"runId":"a3f9b2c1","type":"node_stream"}
 ## 查询结果
 
 ```http
-GET /api/v1/workflows/12/runs/a3f9b2c1
+GET /api/v1/workflows/military-analyze-agent/runs/a3f9b2c1
 X-API-Key: demo-key
 ```
 
@@ -176,6 +197,8 @@ X-API-Key: demo-key
 {
   "runId": "a3f9b2c1",
   "workflowId": 12,
+  "workflowName": "military-analyze-agent",
+  "displayName": "军事分析助手",
   "status": "SUCCESS",
   "startedAt": "2026-07-01T08:30:05Z",
   "completedAt": "2026-07-01T08:31:12Z",
@@ -194,13 +217,13 @@ X-API-Key: demo-key
       "output": { "research": "..." },
       "error": null,
       "logsCount": 124,
-      "logsUrl": "/api/v1/workflows/12/runs/a3f9b2c1/nodes/node-2/logs"
+      "logsUrl": "/api/v1/workflows/military-analyze-agent/runs/a3f9b2c1/nodes/node-2/logs"
     }
   ],
   "workspace": {
     "fileCount": 12,
     "totalBytes": 456789,
-    "downloadUrl": "/api/v1/workflows/12/runs/a3f9b2c1/workspace"
+    "downloadUrl": "/api/v1/workflows/military-analyze-agent/runs/a3f9b2c1/workspace"
   }
 }
 ```
@@ -212,7 +235,7 @@ X-API-Key: demo-key
 节点日志:
 
 ```http
-GET /api/v1/workflows/{workflowId}/runs/{runId}/nodes/{nodeId}/logs
+GET /api/v1/workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs
 ```
 
 日志类型通常为 `THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`INFO`、`ERROR`。把思考过程抽到独立展示区,非思考日志放在可折叠的日志列表中。
@@ -220,7 +243,7 @@ GET /api/v1/workflows/{workflowId}/runs/{runId}/nodes/{nodeId}/logs
 工作空间下载:
 
 ```http
-GET /api/v1/workflows/{workflowId}/runs/{runId}/workspace
+GET /api/v1/workflows/{workflowName}/runs/{runId}/workspace
 ```
 
 响应是二进制 zip 内容,不要走 JSON 响应拦截器。
@@ -229,6 +252,7 @@ GET /api/v1/workflows/{workflowId}/runs/{runId}/workspace
 
 - 默认不要在前端代码中暴露 `X-API-Key`。优先使用后端代理,由服务端注入密钥。
 - base URL 与 API key 应存放在环境/配置中,不要硬编码到源码。
+- API Key 的访问范围(白名单)在 agent-management 后端 `ExternalApiProperties` 中配置,同时支持 `workflowNames`(推荐,按 kebab-case `name` 精确匹配)和 `workflowIds`(兼容,按数字主键匹配);两者皆空表示不限制。新集成建议使用 `workflowNames` 与 URL 路径保持一致。
 - 如实现代理上传,需校验路径,防止 `../` 路径穿越。
 - 代理 SSE 时关闭缓冲,保留事件名、id 和 data 负载。
 - 未经用户同意,不要悄悄把 SSE 降级为轮询。

+ 1 - 1
skills/agent-management-external-workflow/references/run-history-ui.md

@@ -62,7 +62,7 @@
 页脚:
 
 - 当工作空间下载可用时,提供 `下载工作空间`。
-- 对外部 API 集成,使用 `workspace.downloadUrl` 或 `/api/v1/workflows/{workflowId}/runs/{runId}/workspace`
+- 对外部 API 集成,使用 `workspace.downloadUrl` 或 `/api/v1/workflows/{workflowName}/runs/{runId}/workspace`。`{workflowName}` 是 kebab-case 唯一标识(推荐),纯数字 `id` 作为兼容兜底也合法
 - 下载作为二进制导航/blob 处理,不要按 JSON 处理。
 
 ## 节点类型映射