瀏覽代碼

1. 实现了工作流节点变量作用域隔离机制,新增 NodeWorkspace / NodeWorkspaceBuilder / WorkflowScopeResolver / NodeInputResolver / VariableConverter / JsonPathExtractor 六个组件,按「起始节点到前序节点」的变量总和构建节点工作区,并以 nodeId 命名空间存储输出避免同名覆盖;
2. 实现了节点输入变量显式与隐式关联,支持手动绑定前序节点输出字段,并在未指定时按「同名 → 忽略大小写与分隔符 → JsonPath 同名 → JsonPath 模糊匹配」四级规则自动关联,类型不一致时按 docs/workflow-node-fields.md 的转换规则自动适配;
3. 重构了上下文系统提示注入策略,ContextPromptHelper 由「无差别注入前置节点全部变量」改为「仅注入当前节点显式声明的输入变量」,覆盖 LLM、智能操作、技能、Hermes Agent、Hermes 智能操作五类执行器,避免上下文膨胀分散模型注意力;
4. 实现了工作流 kebab-case 唯一标识(name)替代数字 ID,新增 displayName 中文名称字段与 WorkflowNameNormalizer 规范化器,URL 路由、外部 API、运行目录、版本快照全部切换为按 name 寻址,旧数字 ID 通过字符串回退自动兼容;
5. 实现了工作流编辑器表单与技能编辑器风格对齐,新增 name 五态机(empty/invalid/will-rename/will-normalize/valid)实时提示规范化与重命名影响,回滚操作只恢复 graphData 与 description 避免与 URL 错位;
6. 新增了智能体描述(description)字段到工作流编辑界面与后端实体,表单样式(Label/Placeholder/Hint)与技能编辑保持一致;
7. 实现了运行结果与画布节点的联动,点击图中节点自动滚动到对应运行结果卡片并短暂高亮,优化大工作流的结果定位体验;
8. 统一了主要操作按钮风格;
9. 实现了 LLM HTTP 日志向量脱敏,新增 LogRedactor 在请求/响应体落盘前正则替换向量数组为占位符,显著降低 llm.log 体积;
10. 优化了带思考过程节点的日志展示,点击「XXX 条日志」按钮不再重复展示与完整思考过程内容雷同的流式中间输出;
11. 新增了工作流节点字段与变量作用域设计文档(docs/workflow-node-fields.md、docs/workflow-variable-scope-design.md),梳理各节点自动输入输出字段及跨节点类型转换规则;
12. 新增了「智能体管理平台外部工作流」集成 Skill,规定第三方应用必须将 baseUrl、工作流 ID/名称、API Key 等通过配置文件注入而非硬编码,触发关键词扩展为「智能体管理平台」中文表述;
13. 新增了 LogRedactor、NodeInputResolver、TemplateRenderer、VariableConverter、WorkflowScopeResolver 五个组件的单元测试,覆盖向量脱敏、变量作用域解析与类型转换的核心规则;
14. 更新了 prompt.md 需求记录。

weisijie 1 月之前
父節點
當前提交
0a5ce63cd4
共有 73 個文件被更改,包括 3988 次插入862 次删除
  1. 112 0
      backend/src/main/java/com/agent/management/common/LogRedactor.java
  2. 5 2
      backend/src/main/java/com/agent/management/config/AiRequestLoggingConfig.java
  3. 43 13
      backend/src/main/java/com/agent/management/config/ExternalApiProperties.java
  4. 4 0
      backend/src/main/java/com/agent/management/controller/RunHistoryController.java
  5. 52 39
      backend/src/main/java/com/agent/management/controller/WorkflowController.java
  6. 89 8
      backend/src/main/java/com/agent/management/engine/ContextPromptHelper.java
  7. 6 1
      backend/src/main/java/com/agent/management/engine/DagResolver.java
  8. 112 0
      backend/src/main/java/com/agent/management/engine/JsonPathExtractor.java
  9. 5 4
      backend/src/main/java/com/agent/management/engine/NodeExecutor.java
  10. 280 0
      backend/src/main/java/com/agent/management/engine/NodeInputResolver.java
  11. 54 0
      backend/src/main/java/com/agent/management/engine/NodeWorkspace.java
  12. 144 0
      backend/src/main/java/com/agent/management/engine/NodeWorkspaceBuilder.java
  13. 33 5
      backend/src/main/java/com/agent/management/engine/TemplateRenderer.java
  14. 198 0
      backend/src/main/java/com/agent/management/engine/VariableConverter.java
  15. 46 1
      backend/src/main/java/com/agent/management/engine/WorkflowContext.java
  16. 3 2
      backend/src/main/java/com/agent/management/engine/WorkflowEngine.java
  17. 53 19
      backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java
  18. 47 15
      backend/src/main/java/com/agent/management/engine/WorkflowRunDirManager.java
  19. 68 0
      backend/src/main/java/com/agent/management/engine/WorkflowScopeResolver.java
  20. 11 11
      backend/src/main/java/com/agent/management/engine/executor/AgentExecutor.java
  21. 2 2
      backend/src/main/java/com/agent/management/engine/executor/ConditionExecutor.java
  22. 9 9
      backend/src/main/java/com/agent/management/engine/executor/HermesAgentExecutor.java
  23. 4 4
      backend/src/main/java/com/agent/management/engine/executor/HermesSmartActionExecutor.java
  24. 3 2
      backend/src/main/java/com/agent/management/engine/executor/KnowledgeRetrievalExecutor.java
  25. 5 5
      backend/src/main/java/com/agent/management/engine/executor/LlmExecutor.java
  26. 4 3
      backend/src/main/java/com/agent/management/engine/executor/OutputExecutor.java
  27. 3 2
      backend/src/main/java/com/agent/management/engine/executor/SkillExecutor.java
  28. 4 4
      backend/src/main/java/com/agent/management/engine/executor/SmartActionExecutor.java
  29. 4 3
      backend/src/main/java/com/agent/management/engine/executor/UserInputExecutor.java
  30. 93 77
      backend/src/main/java/com/agent/management/external/ExternalApiController.java
  31. 3 3
      backend/src/main/java/com/agent/management/external/ExternalRunRegistry.java
  32. 11 0
      backend/src/main/java/com/agent/management/external/dto/ExternalDtos.java
  33. 10 0
      backend/src/main/java/com/agent/management/model/entity/Workflow.java
  34. 7 0
      backend/src/main/java/com/agent/management/model/entity/WorkflowRun.java
  35. 4 0
      backend/src/main/java/com/agent/management/model/vo/WorkflowVO.java
  36. 55 0
      backend/src/main/java/com/agent/management/parser/WorkflowNameNormalizer.java
  37. 67 0
      backend/src/main/java/com/agent/management/repository/WorkflowRepository.java
  38. 9 5
      backend/src/main/java/com/agent/management/service/WorkflowService.java
  39. 26 7
      backend/src/main/java/com/agent/management/service/WorkflowVersionService.java
  40. 2 1
      backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeClient.java
  41. 41 14
      backend/src/main/java/com/agent/management/service/impl/WorkflowServiceImpl.java
  42. 47 27
      backend/src/main/java/com/agent/management/service/impl/WorkflowVersionServiceImpl.java
  43. 52 0
      backend/src/test/java/com/agent/management/common/LogRedactorTest.java
  44. 113 0
      backend/src/test/java/com/agent/management/engine/NodeInputResolverTest.java
  45. 49 0
      backend/src/test/java/com/agent/management/engine/TemplateRendererTest.java
  46. 58 0
      backend/src/test/java/com/agent/management/engine/VariableConverterTest.java
  47. 58 0
      backend/src/test/java/com/agent/management/engine/WorkflowScopeResolverTest.java
  48. 317 0
      docs/workflow-node-fields.md
  49. 390 0
      docs/workflow-variable-scope-design.md
  50. 24 18
      frontend/src/api/workflow.js
  51. 24 35
      frontend/src/components/skill/SkillForm.vue
  52. 4 4
      frontend/src/composables/useNodeFields.js
  53. 12 12
      frontend/src/composables/useVersionHistory.js
  54. 16 5
      frontend/src/composables/useWorkflowRunner.js
  55. 1 1
      frontend/src/router/index.js
  56. 8 8
      frontend/src/stores/workflow.js
  57. 1 1
      frontend/src/utils/ioInference.js
  58. 29 0
      frontend/src/utils/workflowName.js
  59. 8 8
      frontend/src/views/ModelManagement.vue
  60. 8 42
      frontend/src/views/SkillManagement.vue
  61. 2 2
      frontend/src/views/TagManagement.vue
  62. 1 1
      frontend/src/views/knowledge/DocumentManagement.vue
  63. 1 1
      frontend/src/views/knowledge/RagGovernance.vue
  64. 41 72
      frontend/src/views/skill/SkillEdit.vue
  65. 1 1
      frontend/src/views/workflow/RunHistory.vue
  66. 12 40
      frontend/src/views/workflow/TemplateManagement.vue
  67. 368 263
      frontend/src/views/workflow/WorkflowEditor.vue
  68. 42 59
      frontend/src/views/workflow/WorkflowList.vue
  69. 78 1
      prompt.md
  70. 74 0
      skills/agent-management-external-workflow/SKILL.md
  71. 4 0
      skills/agent-management-external-workflow/agents/openai.yaml
  72. 235 0
      skills/agent-management-external-workflow/references/external-workflow-api.md
  73. 179 0
      skills/agent-management-external-workflow/references/run-history-ui.md

+ 112 - 0
backend/src/main/java/com/agent/management/common/LogRedactor.java

@@ -0,0 +1,112 @@
+package com.agent.management.common;
+
+import java.text.DecimalFormat;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 日志脱敏工具:在写入 llm.log 等调试日志前,对内容中的大向量数字数组进行简写,
+ * 避免单条日志被数百/数千个浮点数撑满。
+ *
+ * <p>仅对"纯数字数组"生效(如 embedding 向量),不影响其他 JSON 结构。
+ * 采用字符扫描而非单一长正则,避免在大向量上发生 StackOverflowError。</p>
+ */
+public final class LogRedactor {
+
+    /** 小于该元素数的数组原样输出,避免误伤短数组(如坐标 [1,2] 或 logits 摘要) */
+    private static final int ABBREVIATE_THRESHOLD = 8;
+
+    /** 取头几个数字作为预览 */
+    private static final int PREVIEW_COUNT = 3;
+
+    private static final Pattern NUMBER_TOKEN = Pattern.compile("-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?");
+    private static final DecimalFormat PREVIEW_FMT = new DecimalFormat("0.####");
+
+    private LogRedactor() {
+    }
+
+    /**
+     * 对纯数字数组进行简写:[0.1,0.2,...] → [N 个浮点数已省略> 头部样本: ...]。
+     * 其他内容保持不变。
+     */
+    public static String redactVectors(String text) {
+        if (text == null || text.isEmpty()) {
+            return text;
+        }
+        StringBuilder out = new StringBuilder(text.length());
+        int i = 0;
+        int len = text.length();
+        while (i < len) {
+            char c = text.charAt(i);
+            if (c == '[') {
+                int end = findNumericArrayEnd(text, i + 1);
+                if (end > i) {
+                    String inner = text.substring(i + 1, end);
+                    int count = countNumbers(inner);
+                    if (count >= ABBREVIATE_THRESHOLD) {
+                        out.append("[<").append(count).append(" 个浮点数已省略>")
+                                .append(buildPreview(inner)).append("]");
+                        i = end + 1;
+                        continue;
+                    }
+                }
+            }
+            out.append(c);
+            i++;
+        }
+        return out.toString();
+    }
+
+    /**
+     * 从 start 开始扫描,若 [start..end) 之间仅包含数字数组合法字符且能匹配到 ']',
+     * 返回 ']' 的索引;否则返回 -1。允许内部不再出现 '['(否则视为非纯数字数组)。
+     */
+    private static int findNumericArrayEnd(String text, int start) {
+        int i = start;
+        int len = text.length();
+        while (i < len) {
+            char c = text.charAt(i);
+            if (c == ']') {
+                return i;
+            }
+            if (!isNumberArrayChar(c)) {
+                return -1;
+            }
+            i++;
+        }
+        return -1;
+    }
+
+    private static boolean isNumberArrayChar(char c) {
+        if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+'
+                || c == 'e' || c == 'E' || c == ',' || c == ' ') {
+            return true;
+        }
+        return c == '\t' || c == '\n' || c == '\r';
+    }
+
+    private static int countNumbers(String inner) {
+        Matcher m = NUMBER_TOKEN.matcher(inner);
+        int count = 0;
+        while (m.find()) {
+            count++;
+        }
+        return count;
+    }
+
+    private static String buildPreview(String inner) {
+        StringBuilder sb = new StringBuilder(" 头部样本: ");
+        Matcher m = NUMBER_TOKEN.matcher(inner);
+        int taken = 0;
+        while (m.find() && taken < PREVIEW_COUNT) {
+            if (taken > 0) sb.append(", ");
+            try {
+                sb.append(PREVIEW_FMT.format(Double.parseDouble(m.group())));
+            } catch (NumberFormatException ignore) {
+                sb.append(m.group());
+            }
+            taken++;
+        }
+        return sb.toString();
+    }
+}

+ 5 - 2
backend/src/main/java/com/agent/management/config/AiRequestLoggingConfig.java

@@ -16,6 +16,7 @@ import org.springframework.http.client.ClientHttpRequestInterceptor;
 import org.springframework.http.client.ClientHttpResponse;
 import org.springframework.http.client.SimpleClientHttpRequestFactory;
 import org.springframework.web.client.RestClient;
+import com.agent.management.common.LogRedactor;
 
 import java.io.BufferedReader;
 import java.io.IOException;
@@ -104,7 +105,8 @@ public class AiRequestLoggingConfig {
             });
 
             String requestBody = new String(body, StandardCharsets.UTF_8);
-            LLM_HTTP.debug("[AI-HTTP] 请求 Body ({} bytes): {}", body.length, truncate(requestBody, 2000));
+            LLM_HTTP.debug("[AI-HTTP] 请求 Body ({} bytes): {}", body.length,
+                    truncate(LogRedactor.redactVectors(requestBody), 2000));
 
             // === 执行请求 ===
             long startTime = System.currentTimeMillis();
@@ -134,7 +136,8 @@ public class AiRequestLoggingConfig {
                     new InputStreamReader(response.getBody(), StandardCharsets.UTF_8))) {
                 responseBody = reader.lines().collect(Collectors.joining("\n"));
             }
-            LLM_HTTP.debug("[AI-HTTP] 响应 Body ({} chars): {}", responseBody.length(), truncate(responseBody, 3000));
+            LLM_HTTP.debug("[AI-HTTP] 响应 Body ({} chars): {}", responseBody.length(),
+                    truncate(LogRedactor.redactVectors(responseBody), 3000));
             LLM_HTTP.debug("[AI-HTTP] ========== 请求结束 ==========");
 
             // 返回可重复读取的响应

+ 43 - 13
backend/src/main/java/com/agent/management/config/ExternalApiProperties.java

@@ -8,21 +8,23 @@ import java.util.ArrayList;
 import java.util.List;
 
 /**
- * 外部 API(/api/v1/**)配置:
- * - 启用开关
- * - API Key 列表(可绑定工作流白名单)
+ * 外部 API(/api/v1/**)配置。
  *
- * 配置示例:
+ * <p>配置示例:
  * <pre>
  * external:
  *   api:
  *     enabled: true
  *     keys:
  *       - key: "client-a-secret"
- *         workflow-ids: [12, 15]   # 仅允许调用工作流 12、15;留空表示不限制
+ *         workflow-names: [research-agent, summary-flow]   # 仅允许调用指定 name 的工作流;留空表示不限制
  *       - key: "client-b-secret"
- *         workflow-ids: []
+ *         workflow-names: []
  * </pre>
+ *
+ * <p>历史配置项 {@code workflow-ids}(按数字 id 配置)已废弃。
+ * 旧配置若仍按数字 id 配置,由于迁移后 name 默认为 String.valueOf(id),可继续按 name 形式工作;
+ * 但建议尽快迁移为 kebab-case 工作流名配置。
  */
 @Data
 @Configuration
@@ -39,14 +41,18 @@ public class ExternalApiProperties {
     public static class KeyEntry {
         /** API Key(建议 32+ 字符随机字符串) */
         private String key;
-        /** 允许调用的工作流 ID 白名单;空列表或不填表示不限制 */
+        /** 允许调用的工作流 name 白名单(kebab-case);空列表或不填表示不限制 */
+        private List<String> workflowNames;
+        /**
+         * 已废弃:旧字段名 workflowIds(数字 id)。
+         * 为兼容旧配置保留读取能力,运行时自动按数字字符串对比。
+         */
+        @Deprecated
         private List<Long> workflowIds;
     }
 
     /**
      * 校验 API Key 是否有效。
-     * @param key 客户端传入的 X-API-Key
-     * @return 匹配的 KeyEntry;无效返回 null
      */
     public KeyEntry lookup(String key) {
         if (key == null || key.isEmpty()) return null;
@@ -57,11 +63,35 @@ public class ExternalApiProperties {
     }
 
     /**
-     * 校验 API Key 是否有权调用指定工作流。
+     * 校验 API Key 是否有权调用指定工作流(按 name + id 双重匹配)。
+     *
+     * <p>规则:
+     * <ul>
+     *   <li>新配置 workflowNames:按字符串精确匹配 name;空列表 = 不限制</li>
+     *   <li>旧配置 workflowIds:按数字字符串匹配(兼容尚未迁移的配置);空列表 = 不限制</li>
+     *   <li>两者皆空时返回 true(不限制)</li>
+     * </ul>
      */
-    public boolean canAccess(KeyEntry entry, Long workflowId) {
+    public boolean canAccess(KeyEntry entry, String workflowName, Long workflowId) {
         if (entry == null) return false;
-        if (entry.getWorkflowIds() == null || entry.getWorkflowIds().isEmpty()) return true;
-        return entry.getWorkflowIds().contains(workflowId);
+
+        List<String> names = entry.getWorkflowNames();
+        List<Long> ids = entry.getWorkflowIds();
+
+        boolean namesConfigured = names != null && !names.isEmpty();
+        boolean idsConfigured = ids != null && !ids.isEmpty();
+
+        // 两者都未配置 = 不限制
+        if (!namesConfigured && !idsConfigured) return true;
+
+        // 优先匹配 name
+        if (namesConfigured && workflowName != null && names.contains(workflowName)) {
+            return true;
+        }
+        // 兼容旧 id 配置
+        if (idsConfigured && workflowId != null && ids.contains(workflowId)) {
+            return true;
+        }
+        return false;
     }
 }

+ 4 - 0
backend/src/main/java/com/agent/management/controller/RunHistoryController.java

@@ -49,6 +49,7 @@ public class RunHistoryController {
             WorkflowWithRunsVO vo = new WorkflowWithRunsVO();
             vo.setWorkflowId(wf.getId());
             vo.setWorkflowName(wf.getName());
+            vo.setDisplayName(wf.getDisplayName());
             vo.setTags(tagService.getEntityTags("workflow", String.valueOf(wf.getId())));
             vo.setRuns(entry.getValue().stream().map(this::toRunVO).collect(Collectors.toList()));
             result.add(vo);
@@ -100,7 +101,10 @@ public class RunHistoryController {
     @Data
     public static class WorkflowWithRunsVO {
         private Long workflowId;
+        /** kebab-case 唯一标识(URL 用) */
         private String workflowName;
+        /** 中文显示名 */
+        private String displayName;
         private List<TagBriefVO> tags;
         private List<RunVO> runs;
     }

+ 52 - 39
backend/src/main/java/com/agent/management/controller/WorkflowController.java

@@ -71,11 +71,12 @@ public class WorkflowController {
 
         List<Workflow> all = workflowService.listWorkflows();
 
-        // 搜索过滤
+        // 搜索过滤(匹配 name / displayName / description)
         if (search != null && !search.trim().isEmpty()) {
             String kw = search.trim().toLowerCase();
             all = all.stream()
                     .filter(wf -> (wf.getName() != null && wf.getName().toLowerCase().contains(kw))
+                            || (wf.getDisplayName() != null && wf.getDisplayName().toLowerCase().contains(kw))
                             || (wf.getDescription() != null && wf.getDescription().toLowerCase().contains(kw)))
                     .collect(Collectors.toList());
         }
@@ -101,40 +102,40 @@ public class WorkflowController {
         return Result.success(PageResult.of(voList, total, page, size));
     }
 
-    @GetMapping("/{id}")
-    public Result<WorkflowVO> get(@PathVariable Long id) {
-        Workflow wf = workflowService.getWorkflow(id);
+    @GetMapping("/{name}")
+    public Result<WorkflowVO> get(@PathVariable String name) {
+        Workflow wf = workflowService.getWorkflow(name);
         return Result.success(toVO(wf));
     }
 
     @PostMapping
     public Result<WorkflowVO> create(@RequestBody CreateWorkflowReq req) {
-        Workflow wf = workflowService.createWorkflow(req.getName(), req.getDescription());
+        Workflow wf = workflowService.createWorkflow(req.getName(), req.getDisplayName(), req.getDescription());
         return Result.success(toVO(wf));
     }
 
-    @PostMapping("/{id}/save")
-    public Result<WorkflowVO> save(@PathVariable Long id, @RequestBody SaveWorkflowReq req) {
-        Workflow wf = workflowService.updateWorkflow(id, req.getName(), req.getDescription(), req.getGraphData());
-        // 自动创建版本快照
+    @PostMapping("/{name}/save")
+    public Result<WorkflowVO> save(@PathVariable String name, @RequestBody SaveWorkflowReq req) {
+        Workflow wf = workflowService.updateWorkflow(name, req.getName(), req.getDisplayName(), req.getDescription(), req.getGraphData());
+        // 自动创建版本快照(双轨目录:优先 name,回退 id)
         try {
-            workflowVersionService.createSnapshot(id, req.getGraphData(), req.getName(), null);
+            workflowVersionService.createSnapshot(wf.getId(), wf.getName(), req.getGraphData(), req.getName(), null);
         } catch (Exception e) {
             log.warn("[Save] 创建版本快照失败,不影响保存: {}", e.getMessage());
         }
         return Result.success(toVO(wf));
     }
 
-    @PostMapping("/{id}/delete")
-    public Result<Void> delete(@PathVariable Long id) {
-        workflowService.deleteWorkflow(id);
+    @PostMapping("/{name}/delete")
+    public Result<Void> delete(@PathVariable String name) {
+        workflowService.deleteWorkflow(name);
         return Result.success(null);
     }
 
     /** 设置工作流分类 */
-    @PutMapping("/{id}/category")
-    public Result<Void> setCategory(@PathVariable Long id, @RequestBody SetCategoryReq req) {
-        workflowService.updateCategory(id, req.getCategoryId());
+    @PutMapping("/{name}/category")
+    public Result<Void> setCategory(@PathVariable String name, @RequestBody SetCategoryReq req) {
+        workflowService.updateCategory(name, req.getCategoryId());
         return Result.success(null);
     }
 
@@ -142,8 +143,8 @@ public class WorkflowController {
      * 运行工作流(SSE 实时推送执行状态)
      * 支持 multipart:inputs 作为 JSON 字符串字段 + 可选文件上传
      */
-    @PostMapping(value = "/{id}/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
-    public SseEmitter run(@PathVariable Long id,
+    @PostMapping(value = "/{name}/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter run(@PathVariable String name,
                           @RequestParam(value = "inputs", required = false) String inputsJson,
                           @RequestParam(value = "files", required = false) MultipartFile[] files,
                           @RequestParam(value = "paths", required = false) List<String> paths) throws IOException {
@@ -159,12 +160,17 @@ public class WorkflowController {
             }
         }
 
+        // 解析工作流(按 name 查找,必要时回退 id)
+        Workflow wf = workflowService.getWorkflow(name);
+        Long workflowId = wf.getId();
+        String wfName = wf.getName();
+
         // 如果有文件上传,先生成一个 runId 用于创建工作目录
         // 注意:实际 runId 由 WorkflowEngine 内部生成,但我们需要提前创建目录来存放文件
         // 所以这里提前生成 runId 并通过 inputs 传递给引擎
         if (files != null && files.length > 0) {
             String preRunId = UUID.randomUUID().toString().substring(0, 8);
-            Path runDir = runDirManager.createRunDir(id, preRunId);
+            Path runDir = runDirManager.createRunDir(workflowId, wfName, preRunId);
             for (int i = 0; i < files.length; i++) {
                 MultipartFile file = files[i];
                 if (!file.isEmpty()) {
@@ -195,16 +201,17 @@ public class WorkflowController {
         // 注意:future.cancel(true) 无法中断 HttpURLConnection 的阻塞读取,引擎的 300s 上限不构成硬约束。
         // 故取 30 分钟作为安全余量;如节点更多请相应调大。
         SseEmitter emitter = new SseEmitter(1_800_000L); // 30 分钟超时
-        workflowEngine.executeAsync(id, inputs, emitter);
+        workflowEngine.executeAsync(workflowId, inputs, emitter);
         return emitter;
     }
 
     /**
      * 下载运行工作目录的 zip 打包
      */
-    @GetMapping("/{id}/runs/{runId}/download")
-    public ResponseEntity<Resource> downloadRunDir(@PathVariable Long id, @PathVariable String runId) throws IOException {
-        Path runDir = runDirManager.getRunDir(id, runId);
+    @GetMapping("/{name}/runs/{runId}/download")
+    public ResponseEntity<Resource> downloadRunDir(@PathVariable String name, @PathVariable String runId) throws IOException {
+        Workflow wf = workflowService.getWorkflow(name);
+        Path runDir = runDirManager.getRunDir(wf.getId(), wf.getName(), runId);
         if (runDir == null) {
             return ResponseEntity.notFound().build();
         }
@@ -227,7 +234,7 @@ public class WorkflowController {
         }
 
         Resource resource = new UrlResource(zipPath.toUri());
-        String filename = URLEncoder.encode("workflow-" + id + "-run-" + runId + ".zip", StandardCharsets.UTF_8);
+        String filename = URLEncoder.encode("workflow-" + wf.getName() + "-run-" + runId + ".zip", StandardCharsets.UTF_8);
         return ResponseEntity.ok()
                 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename)
                 .contentType(MediaType.APPLICATION_OCTET_STREAM)
@@ -253,34 +260,38 @@ public class WorkflowController {
 
     // ==================== 版本管理 ====================
 
-    @GetMapping("/{id}/versions")
-    public Result<List<WorkflowVersionVO>> listVersions(@PathVariable Long id) {
-        return Result.success(workflowVersionService.listVersions(id));
+    @GetMapping("/{name}/versions")
+    public Result<List<WorkflowVersionVO>> listVersions(@PathVariable String name) {
+        Workflow wf = workflowService.getWorkflow(name);
+        return Result.success(workflowVersionService.listVersions(wf.getId(), wf.getName()));
     }
 
-    @GetMapping("/{id}/versions/{version}/content")
-    public Result<String> getVersionContent(@PathVariable Long id, @PathVariable int version) {
-        return Result.success(workflowVersionService.getVersionContent(id, version));
+    @GetMapping("/{name}/versions/{version}/content")
+    public Result<String> getVersionContent(@PathVariable String name, @PathVariable int version) {
+        Workflow wf = workflowService.getWorkflow(name);
+        return Result.success(workflowVersionService.getVersionContent(wf.getId(), wf.getName(), version));
     }
 
-    @PostMapping("/{id}/versions/{version}/rollback")
-    public Result<WorkflowVO> rollbackVersion(@PathVariable Long id, @PathVariable int version) {
+    @PostMapping("/{name}/versions/{version}/rollback")
+    public Result<WorkflowVO> rollbackVersion(@PathVariable String name, @PathVariable int version) {
+        Workflow wf = workflowService.getWorkflow(name);
         // 读取快照内容恢复到工作流
-        String snapshotJson = workflowVersionService.getVersionContent(id, version);
+        String snapshotJson = workflowVersionService.getVersionContent(wf.getId(), wf.getName(), version);
         try {
             WorkflowVersionServiceImpl.SnapshotData snapshot = MAPPER.readValue(snapshotJson, WorkflowVersionServiceImpl.SnapshotData.class);
-            Workflow wf = workflowService.updateWorkflow(id, snapshot.getName(), null, snapshot.getGraphData());
-            workflowVersionService.rollback(id, version);
-            return Result.success(toVO(wf));
+            Workflow updated = workflowService.updateWorkflow(name, snapshot.getName(), null, null, snapshot.getGraphData());
+            workflowVersionService.rollback(wf.getId(), wf.getName(), version);
+            return Result.success(toVO(updated));
         } catch (IOException e) {
             return Result.error("解析快照数据失败");
         }
     }
 
-    @PutMapping("/{id}/versions/{version}/message")
-    public Result<Void> updateVersionMessage(@PathVariable Long id, @PathVariable int version,
+    @PutMapping("/{name}/versions/{version}/message")
+    public Result<Void> updateVersionMessage(@PathVariable String name, @PathVariable int version,
                                               @RequestBody Map<String, String> body) {
-        workflowVersionService.updateVersionMessage(id, version, body.get("message"));
+        Workflow wf = workflowService.getWorkflow(name);
+        workflowVersionService.updateVersionMessage(wf.getId(), wf.getName(), version, body.get("message"));
         return Result.success(null);
     }
 
@@ -431,12 +442,14 @@ public class WorkflowController {
     @Data
     public static class CreateWorkflowReq {
         private String name;
+        private String displayName;
         private String description;
     }
 
     @Data
     public static class SaveWorkflowReq {
         private String name;
+        private String displayName;
         private String description;
         private String graphData;
     }

+ 89 - 8
backend/src/main/java/com/agent/management/engine/ContextPromptHelper.java

@@ -1,12 +1,16 @@
 package com.agent.management.engine;
 
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.util.LinkedHashMap;
 import java.util.Map;
 
 /**
  * 工作流上下文变量 → 系统提示词的工具类。
  *
- * <p>用于把前置节点输出的 variables 自动注入到 LLM/智能操作/技能等节点的系统提示词中,
- * 让模型在用户未显式声明 {{var}} 占位符时也能"看到"前置节点的输出。</p>
+ * <p>用于把节点明确定义的输入变量自动注入到 LLM/智能操作/技能等节点的系统提示词中,
+ * 让模型在用户未显式声明 {{var}} 占位符时也能"看到"当前节点真正需要的输入。
+ * 不再把前置节点的全部工作空间变量无差别注入,避免上下文膨胀分散模型注意力。</p>
  *
  * <p>DRY 集中点:5 个执行器(LlmExecutor / SmartActionExecutor / AgentExecutor /
  * HermesSmartActionExecutor / HermesAgentExecutor)共享同一份格式化逻辑。</p>
@@ -19,6 +23,35 @@ public final class ContextPromptHelper {
     private ContextPromptHelper() {
     }
 
+    /**
+     * 根据节点 data.inputs 中明确定义的输入字段,从工作区取值并格式化为系统提示词片段。
+     *
+     * @param nodeData  节点 data(JSON)
+     * @param workspace 当前节点工作区
+     * @return 格式化后的提示词;无输入定义或全部为空时返回空串
+     */
+    public static String buildInputContextSystemPrompt(JsonNode nodeData, NodeWorkspace workspace) {
+        if (nodeData == null || workspace == null) {
+            return "";
+        }
+        JsonNode inputsNode = nodeData.path("inputs");
+        if (!inputsNode.isArray() || inputsNode.isEmpty()) {
+            return "";
+        }
+        Map<String, Object> inputValues = new LinkedHashMap<>();
+        for (JsonNode field : inputsNode) {
+            String name = field.path("name").asText("");
+            if (name.isEmpty()) {
+                continue;
+            }
+            Object value = workspace.getVariable(name);
+            if (value != null) {
+                inputValues.put(name, value);
+            }
+        }
+        return buildContextSystemPrompt(inputValues, "节点输入变量");
+    }
+
     /**
      * 把工作流上下文的 variables 格式化为系统提示词片段。
      *
@@ -29,14 +62,34 @@ public final class ContextPromptHelper {
         if (context == null) {
             return "";
         }
-        Map<String, Object> vars = context.getVariables();
-        if (vars == null || vars.isEmpty()) {
+        return buildContextSystemPrompt(context.getVariables());
+    }
+
+    /**
+     * 把工作区变量表格式化为系统提示词片段。
+     *
+     * @param variables 变量表(可能为 null)
+     * @return 格式化后的提示词;如果变量表为空,返回空串
+     */
+    public static String buildContextSystemPrompt(Map<String, Object> variables) {
+        return buildContextSystemPrompt(variables, "工作流上下文(来自前置节点的输出变量)");
+    }
+
+    /**
+     * 把变量表格式化为系统提示词片段。
+     *
+     * @param variables 变量表(可能为 null)
+     * @param title     提示词片段标题
+     * @return 格式化后的提示词;如果变量表为空,返回空串
+     */
+    private static String buildContextSystemPrompt(Map<String, Object> variables, String title) {
+        if (variables == null || variables.isEmpty()) {
             return "";
         }
         StringBuilder sb = new StringBuilder();
-        sb.append("=== 工作流上下文(来自前置节点的输出变量) ===\n");
+        sb.append("=== ").append(title).append(" ===\n");
         sb.append("以下是当前可用的变量值,可在回答中引用:\n\n");
-        for (Map.Entry<String, Object> e : vars.entrySet()) {
+        for (Map.Entry<String, Object> e : variables.entrySet()) {
             Object v = e.getValue();
             if (v == null) {
                 continue;
@@ -55,9 +108,37 @@ public final class ContextPromptHelper {
      * @return 合并后的完整 systemPrompt
      */
     public static String merge(String userSystemPrompt, WorkflowContext context) {
-        String contextPrompt = buildContextSystemPrompt(context);
+        return merge(userSystemPrompt, context == null ? null : context.getVariables());
+    }
+
+    /**
+     * 把上下文系统提示词合并到用户自定义的系统提示词后。
+     *
+     * @param userSystemPrompt 节点本身声明的 systemPrompt(已渲染)
+     * @param variables        变量表
+     * @return 合并后的完整 systemPrompt
+     */
+    public static String merge(String userSystemPrompt, Map<String, Object> variables) {
+        String contextPrompt = buildContextSystemPrompt(variables);
+        return join(userSystemPrompt, contextPrompt);
+    }
+
+    /**
+     * 把节点明确定义的输入变量作为上下文系统提示词,合并到用户自定义的系统提示词后。
+     *
+     * @param userSystemPrompt 节点本身声明的 systemPrompt(已渲染)
+     * @param nodeData         节点 data(JSON)
+     * @param workspace        当前节点工作区
+     * @return 合并后的完整 systemPrompt
+     */
+    public static String merge(String userSystemPrompt, JsonNode nodeData, NodeWorkspace workspace) {
+        String inputContextPrompt = buildInputContextSystemPrompt(nodeData, workspace);
+        return join(userSystemPrompt, inputContextPrompt);
+    }
+
+    private static String join(String userSystemPrompt, String contextPrompt) {
         boolean userEmpty = userSystemPrompt == null || userSystemPrompt.isBlank();
-        boolean ctxEmpty = contextPrompt.isEmpty();
+        boolean ctxEmpty = contextPrompt == null || contextPrompt.isEmpty();
         if (userEmpty && ctxEmpty) {
             return "";
         }

+ 6 - 1
backend/src/main/java/com/agent/management/engine/DagResolver.java

@@ -37,6 +37,8 @@ public class DagResolver {
         private final Map<String, String> nodeTypeMap = new HashMap<>();
         /** nodeId → 该节点的所有出边 */
         private final Map<String, List<EdgeInfo>> outgoingEdges = new HashMap<>();
+        /** nodeId → 该节点的所有入边 */
+        private final Map<String, List<EdgeInfo>> incomingEdges = new HashMap<>();
     }
 
     /**
@@ -57,6 +59,7 @@ public class DagResolver {
                 dag.getNodeDataMap().put(id, node.path("data"));
                 dag.getNodeTypeMap().put(id, NodeTypeUtils.normalize(node.path("type").asText()));
                 dag.getOutgoingEdges().put(id, new ArrayList<>());
+                dag.getIncomingEdges().put(id, new ArrayList<>());
                 inDegree.put(id, 0);
             }
 
@@ -69,7 +72,9 @@ public class DagResolver {
                 String sourceHandle = edge.path("sourceHandle").asText("");
                 if (sourceHandle.isEmpty()) sourceHandle = "output";
 
-                dag.getOutgoingEdges().get(source).add(new EdgeInfo(source, target, sourceHandle));
+                EdgeInfo edgeInfo = new EdgeInfo(source, target, sourceHandle);
+                dag.getOutgoingEdges().get(source).add(edgeInfo);
+                dag.getIncomingEdges().get(target).add(edgeInfo);
                 targetSourceSet.computeIfAbsent(target, k -> new HashSet<>()).add(source);
             }
             for (Map.Entry<String, Set<String>> e : targetSourceSet.entrySet()) {

+ 112 - 0
backend/src/main/java/com/agent/management/engine/JsonPathExtractor.java

@@ -0,0 +1,112 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.*;
+
+/**
+ * JSON 嵌套路径取值工具。
+ *
+ * <p>支持点号分隔路径,数组使用数字下标:
+ * <ul>
+ *   <li>{@code output.total.result} → value["output"]["total"]["result"]</li>
+ *   <li>{@code items.0.name} → value["items"][0]["name"]</li>
+ * </ul>
+ */
+public class JsonPathExtractor {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    /**
+     * 从对象中按点号路径取值。
+     *
+     * @param root 根对象(Map / JsonNode / POJO / Collection)
+     * @param path 点号路径,可为 null 或空(表示取 root 本身)
+     * @return 路径对应的值;路径不存在返回 null
+     */
+    public static Object extract(Object root, String path) {
+        if (root == null) {
+            return null;
+        }
+        if (path == null || path.isBlank()) {
+            return root;
+        }
+        Object current = root;
+        String[] segments = path.split("\\.");
+        for (String segment : segments) {
+            if (segment.isEmpty()) {
+                continue;
+            }
+            current = step(current, segment);
+            if (current == null) {
+                return null;
+            }
+        }
+        return current;
+    }
+
+    private static Object step(Object current, String segment) {
+        if (current instanceof Map<?, ?> map) {
+            return map.get(segment);
+        }
+        if (current instanceof JsonNode node) {
+            if (node.isArray()) {
+                Integer idx = parseIndex(segment);
+                return idx != null && idx >= 0 && idx < node.size() ? jsonNodeToValue(node.get(idx)) : null;
+            }
+            JsonNode child = node.get(segment);
+            return child == null ? null : jsonNodeToValue(child);
+        }
+        if (current instanceof Collection<?> collection) {
+            Integer idx = parseIndex(segment);
+            if (idx == null || idx < 0) {
+                return null;
+            }
+            int i = 0;
+            for (Object item : collection) {
+                if (i == idx) {
+                    return item;
+                }
+                i++;
+            }
+            return null;
+        }
+        // POJO:先转 Map 再取字段
+        try {
+            JsonNode node = MAPPER.valueToTree(current);
+            JsonNode child = node.get(segment);
+            return child == null ? null : jsonNodeToValue(child);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static Integer parseIndex(String segment) {
+        try {
+            return Integer.parseInt(segment);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static Object jsonNodeToValue(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isTextual()) {
+            return node.asText();
+        }
+        if (node.isNumber()) {
+            return node.numberValue();
+        }
+        if (node.isBoolean()) {
+            return node.booleanValue();
+        }
+        try {
+            return MAPPER.treeToValue(node, Object.class);
+        } catch (Exception e) {
+            return node.toString();
+        }
+    }
+}

+ 5 - 4
backend/src/main/java/com/agent/management/engine/NodeExecutor.java

@@ -15,10 +15,11 @@ public interface NodeExecutor {
     /**
      * 执行节点逻辑
      *
-     * @param nodeId  节点 ID
-     * @param data    节点 data 字段(来自 graphData)
-     * @param context 工作流执行上下文
+     * @param nodeId    节点 ID
+     * @param data      节点 data 字段(来自 graphData)
+     * @param context   工作流执行上下文
+     * @param workspace 当前节点工作区(仅包含可达前驱变量与解析后的输入)
      * @return 执行结果
      */
-    NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context);
+    NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace);
 }

+ 280 - 0
backend/src/main/java/com/agent/management/engine/NodeInputResolver.java

@@ -0,0 +1,280 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.*;
+
+/**
+ * 节点输入变量解析器。
+ *
+ * <p>对节点 data.inputs[] 中的每个字段:
+ * <ol>
+ *   <li>优先使用显式 mapping(sourceNodeId/sourceField/sourcePath)</li>
+ *   <li>若未配置或来源不可达,进入四级隐式关联 fallback:
+ *       精确同名 → 忽略分隔符/大小写同名 → JSON 嵌套路径精确匹配 → JSON 嵌套路径模糊匹配</li>
+ *   <li>按目标 type 做类型转换</li>
+ * </ol>
+ */
+@Slf4j
+public class NodeInputResolver {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    /**
+     * 解析后的输入结果。
+     */
+    public static class ResolvedInputs {
+        private final Map<String, Object> values = new LinkedHashMap<>();
+        private final List<String> missing = new ArrayList<>();
+        private final List<String> warnings = new ArrayList<>();
+
+        public Map<String, Object> getValues() {
+            return values;
+        }
+
+        public List<String> getMissing() {
+            return missing;
+        }
+
+        public List<String> getWarnings() {
+            return warnings;
+        }
+
+        public boolean hasMissing() {
+            return !missing.isEmpty();
+        }
+    }
+
+    /**
+     * 解析节点输入。
+     *
+     * @param nodeData 节点 data(JSON)
+     * @param workspace 当前节点工作区
+     * @return 解析结果(值、缺失项、警告)
+     */
+    public static ResolvedInputs resolveInputs(JsonNode nodeData, NodeWorkspace workspace) {
+        ResolvedInputs result = new ResolvedInputs();
+        JsonNode inputsNode = nodeData.path("inputs");
+        if (!inputsNode.isArray() || inputsNode.isEmpty()) {
+            return result;
+        }
+
+        for (JsonNode field : inputsNode) {
+            String name = field.path("name").asText("");
+            if (name.isEmpty()) {
+                continue;
+            }
+            String type = field.path("type").asText("string");
+            boolean required = field.path("required").asBoolean(false);
+
+            Object value = resolveInputValue(name, type, field.path("mapping"), workspace, result);
+            if (value == null) {
+                if (required) {
+                    result.missing.add(name);
+                }
+            } else {
+                result.values.put(name, value);
+            }
+        }
+        return result;
+    }
+
+    private static Object resolveInputValue(String name, String type, JsonNode mappingNode,
+                                            NodeWorkspace workspace, ResolvedInputs result) {
+        // 1. 显式 mapping
+        if (mappingNode != null && !mappingNode.isMissingNode()) {
+            String sourceNodeId = mappingNode.path("sourceNodeId").asText("");
+            String sourceField = mappingNode.path("sourceField").asText("");
+            String sourcePath = mappingNode.path("sourcePath").asText(null);
+            if (!sourceNodeId.isEmpty()) {
+                if (workspace.getScopedOutputs().containsKey(sourceNodeId)) {
+                    if (sourceField.isEmpty()) {
+                        sourceField = name;
+                    }
+                    Object value = workspace.getScopedOutput(sourceNodeId, sourceField);
+                    if (value != null && sourcePath != null && !sourcePath.isBlank()) {
+                        value = JsonPathExtractor.extract(value, sourcePath);
+                    }
+                    if (value != null) {
+                        return VariableConverter.convert(value, type);
+                    }
+                    result.warnings.add(name + " 显式映射到 " + sourceNodeId + "." + sourceField
+                            + (sourcePath != null ? "/" + sourcePath : "") + " 但值为空");
+                } else {
+                    result.warnings.add(name + " 显式映射来源 " + sourceNodeId + " 不可达,已忽略");
+                }
+            }
+        }
+
+        // 2. 直接变量表(覆盖初始输入与向后兼容的扁平变量)
+        Object direct = workspace.getVariable(name);
+        if (direct != null) {
+            return VariableConverter.convert(direct, type);
+        }
+
+        // 3. 隐式关联 fallback:按拓扑逆序(最近前驱优先)
+        List<String> ordered = new ArrayList<>(workspace.getScopedOutputs().keySet());
+        Collections.reverse(ordered);
+
+        // 3.1 精确同名
+        for (String nodeId : ordered) {
+            Object value = workspace.getScopedOutput(nodeId, name);
+            if (value != null) {
+                return VariableConverter.convert(value, type);
+            }
+        }
+
+        // 3.2 忽略 [-_] 与大小写同名
+        String normName = normalizeForMatch(name);
+        for (String nodeId : ordered) {
+            Map<String, Object> scoped = workspace.getScopedOutputs().get(nodeId);
+            if (scoped == null) continue;
+            for (Map.Entry<String, Object> e : scoped.entrySet()) {
+                if (normalizeForMatch(e.getKey()).equals(normName)) {
+                    return VariableConverter.convert(e.getValue(), type);
+                }
+            }
+        }
+
+        // 3.3 JSON 嵌套路径精确同名:以整个节点输出 map 为根
+        for (String nodeId : ordered) {
+            Map<String, Object> scoped = workspace.getScopedOutputs().get(nodeId);
+            if (scoped == null) continue;
+            Object extracted = JsonPathExtractor.extract(scoped, name);
+            if (extracted != null) {
+                return VariableConverter.convert(extracted, type);
+            }
+        }
+
+        // 3.4 JSON 嵌套路径模糊同名:以整个节点输出 map 为根,忽略 [-_] 与大小写
+        for (String nodeId : ordered) {
+            Map<String, Object> scoped = workspace.getScopedOutputs().get(nodeId);
+            if (scoped == null) continue;
+            Object extracted = extractByNormalizedPath(scoped, name);
+            if (extracted != null) {
+                return VariableConverter.convert(extracted, type);
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * 忽略 [-_] 与大小写的归一化,用于模糊匹配。
+     */
+    static String normalizeForMatch(String s) {
+        if (s == null) return "";
+        return s.replaceAll("[-_]", "").toLowerCase(Locale.ROOT);
+    }
+
+    /**
+     * 按归一化路径从对象中取值:路径每一节都忽略 [-_] 与大小写匹配键名。
+     */
+    private static Object extractByNormalizedPath(Object root, String path) {
+        if (path == null || path.isBlank()) {
+            return root;
+        }
+        Object current = root;
+        String[] segments = path.split("\\.");
+        for (String segment : segments) {
+            if (segment.isEmpty()) {
+                continue;
+            }
+            current = stepFuzzy(current, segment);
+            if (current == null) {
+                return null;
+            }
+        }
+        return current;
+    }
+
+    private static Object stepFuzzy(Object current, String segment) {
+        String normSeg = normalizeForMatch(segment);
+
+        if (current instanceof Map<?, ?> map) {
+            // 先精确匹配,再模糊匹配
+            if (map.containsKey(segment)) {
+                return map.get(segment);
+            }
+            for (Map.Entry<?, ?> e : map.entrySet()) {
+                Object key = e.getKey();
+                if (key instanceof String k && normalizeForMatch(k).equals(normSeg)) {
+                    return e.getValue();
+                }
+            }
+            return null;
+        }
+
+        if (current instanceof JsonNode node) {
+            if (node.isArray()) {
+                Integer idx = parseIndex(segment);
+                return idx != null && idx >= 0 && idx < node.size()
+                        ? jsonNodeToValue(node.get(idx)) : null;
+            }
+            JsonNode child = node.get(segment);
+            if (child != null) {
+                return jsonNodeToValue(child);
+            }
+            for (Iterator<String> it = node.fieldNames(); it.hasNext(); ) {
+                String field = it.next();
+                if (normalizeForMatch(field).equals(normSeg)) {
+                    return jsonNodeToValue(node.get(field));
+                }
+            }
+            return null;
+        }
+
+        if (current instanceof Collection<?> collection) {
+            Integer idx = parseIndex(segment);
+            if (idx == null || idx < 0) {
+                return null;
+            }
+            int i = 0;
+            for (Object item : collection) {
+                if (i == idx) {
+                    return item;
+                }
+                i++;
+            }
+            return null;
+        }
+
+        // POJO:先转 JsonNode 再递归
+        try {
+            JsonNode node = MAPPER.valueToTree(current);
+            return stepFuzzy(node, segment);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    private static Integer parseIndex(String segment) {
+        try {
+            return Integer.parseInt(segment);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    private static Object jsonNodeToValue(JsonNode node) {
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        if (node.isTextual()) {
+            return node.asText();
+        }
+        if (node.isNumber()) {
+            return node.numberValue();
+        }
+        if (node.isBoolean()) {
+            return node.booleanValue();
+        }
+        try {
+            return MAPPER.treeToValue(node, Object.class);
+        } catch (Exception e) {
+            return node.toString();
+        }
+    }
+}

+ 54 - 0
backend/src/main/java/com/agent/management/engine/NodeWorkspace.java

@@ -0,0 +1,54 @@
+package com.agent.management.engine;
+
+import lombok.Data;
+
+import java.util.*;
+
+/**
+ * 单个节点执行前的工作区视图。
+ *
+ * <p>包含:
+ * <ul>
+ *   <li>可直接引用的扁平变量(含节点命名空间变量,如 nodeId__varName)</li>
+ *   <li>按节点 ID 隔离的前驱输出(scopedOutputs)</li>
+ *   <li>工作空间文件列表</li>
+ *   <li>本次运行 ID</li>
+ * </ul>
+ */
+@Data
+public class NodeWorkspace {
+
+    /** 可直接通过 {{varName}} 引用的扁平变量表 */
+    private final Map<String, Object> variables = new LinkedHashMap<>();
+
+    /** nodeId -> {varName -> value},用于精确引用某个节点的输出 */
+    private final Map<String, Map<String, Object>> scopedOutputs = new LinkedHashMap<>();
+
+    /** 工作空间文件列表(相对路径) */
+    private List<String> files = Collections.emptyList();
+
+    /** 本次运行 ID */
+    private String runId;
+
+    /**
+     * 从扁平变量表中取值。
+     */
+    public Object getVariable(String name) {
+        return variables.get(name);
+    }
+
+    /**
+     * 从指定节点命名空间中取值。
+     */
+    public Object getScopedOutput(String nodeId, String fieldName) {
+        Map<String, Object> scoped = scopedOutputs.get(nodeId);
+        return scoped == null ? null : scoped.get(fieldName);
+    }
+
+    /**
+     * 判断变量是否存在。
+     */
+    public boolean hasVariable(String name) {
+        return variables.containsKey(name);
+    }
+}

+ 144 - 0
backend/src/main/java/com/agent/management/engine/NodeWorkspaceBuilder.java

@@ -0,0 +1,144 @@
+package com.agent.management.engine;
+
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * 根据 DAG 与 WorkflowContext 构建节点工作区。
+ */
+@Component
+public class NodeWorkspaceBuilder {
+
+    /** 节点 ID 与变量名之间的分隔符,避免 nodeId 含下划线时产生歧义 */
+    public static final String NODE_FIELD_SEPARATOR = "__";
+
+    private final AgentIgnoreFilter agentIgnoreFilter;
+
+    public NodeWorkspaceBuilder(AgentIgnoreFilter agentIgnoreFilter) {
+        this.agentIgnoreFilter = agentIgnoreFilter;
+    }
+
+    /**
+     * 为指定节点构建工作区(静态可达前驱)。
+     *
+     * @param nodeId      当前节点 ID
+     * @param dag         已解析的 DAG
+     * @param context     工作流上下文
+     * @param scopeMode   是否启用严格作用域隔离(true:只注入可达前驱变量;false:额外保留扁平变量作为兼容)
+     */
+    public NodeWorkspace build(String nodeId, DagResolver.ResolvedDag dag,
+                                WorkflowContext context, boolean scopeMode) {
+        return build(nodeId, dag, context, Collections.emptySet(), scopeMode);
+    }
+
+    /**
+     * 为指定节点构建工作区(支持运行时活跃边过滤)。
+     *
+     * @param nodeId      当前节点 ID
+     * @param dag         已解析的 DAG
+     * @param context     工作流上下文
+     * @param activeEdges 活跃边 key 集合(source-sourceHandle-target);null 或空表示使用静态可达集
+     * @param scopeMode   是否启用严格作用域隔离
+     */
+    public NodeWorkspace build(String nodeId, DagResolver.ResolvedDag dag,
+                                WorkflowContext context, Set<String> activeEdges,
+                                boolean scopeMode) {
+        NodeWorkspace workspace = new NodeWorkspace();
+        workspace.setRunId(context.getRunId());
+
+        // 1. 初始输入始终可用
+        workspace.getVariables().putAll(context.getInitialInputs());
+
+        // 2. 收集可达前驱节点,按拓扑顺序注入命名空间变量
+        Set<String> reachable = WorkflowScopeResolver.getReachablePredecessors(
+                nodeId, dag, activeEdges == null ? Collections.emptySet() : activeEdges);
+        List<String> ordered = orderByLevels(reachable, dag);
+
+        for (String predecessorId : ordered) {
+            Map<String, Object> scoped = context.getNodeScopedOutput(predecessorId);
+            if (scoped.isEmpty()) {
+                continue;
+            }
+            workspace.getScopedOutputs().put(predecessorId, new LinkedHashMap<>(scoped));
+            for (Map.Entry<String, Object> e : scoped.entrySet()) {
+                String scopedKey = predecessorId + NODE_FIELD_SEPARATOR + e.getKey();
+                workspace.getVariables().put(scopedKey, e.getValue());
+                // 不启用严格隔离时,同时保留扁平变量(最近前驱覆盖)
+                if (!scopeMode) {
+                    workspace.getVariables().put(e.getKey(), e.getValue());
+                }
+            }
+        }
+
+        // 3. 工作空间文件
+        workspace.setFiles(scanWorkingDir(context.getWorkingDir()));
+
+        return workspace;
+    }
+
+    /**
+     * 默认构建方式:非严格隔离(向后兼容)。
+     */
+    public NodeWorkspace build(String nodeId, DagResolver.ResolvedDag dag, WorkflowContext context) {
+        return build(nodeId, dag, context, false);
+    }
+
+    /**
+     * 按 DAG 层级顺序排列节点 ID(先出现的层级在前)。
+     */
+    private List<String> orderByLevels(Set<String> nodeIds, DagResolver.ResolvedDag dag) {
+        List<String> result = new ArrayList<>();
+        for (List<String> level : dag.getLevels()) {
+            for (String nodeId : level) {
+                if (nodeIds.contains(nodeId)) {
+                    result.add(nodeId);
+                }
+            }
+        }
+        return result;
+    }
+
+    /**
+     * 扫描工作目录,返回经 .agentignore 过滤后的相对路径列表。
+     */
+    private List<String> scanWorkingDir(Path workingDir) {
+        if (workingDir == null || !Files.isDirectory(workingDir)) {
+            return Collections.emptyList();
+        }
+        try (Stream<Path> walk = Files.walk(workingDir)) {
+            return walk
+                    .filter(p -> shouldInclude(workingDir, p))
+                    .map(p -> workingDir.relativize(p).toString().replace('\\', '/'))
+                    .sorted()
+                    .collect(Collectors.toList());
+        } catch (IOException e) {
+            return Collections.emptyList();
+        }
+    }
+
+    private boolean shouldInclude(Path workingDir, Path p) {
+        if (Files.isDirectory(p)) {
+            return false;
+        }
+        Path rel = workingDir.relativize(p);
+        String relStr = rel.toString().replace('\\', '/');
+        if (agentIgnoreFilter.shouldIgnore(relStr, false)) {
+            return false;
+        }
+        Path cur = rel;
+        while (cur != null && cur.getNameCount() > 1) {
+            cur = cur.getParent();
+            if (cur == null) break;
+            if (agentIgnoreFilter.shouldIgnore(cur.toString().replace('\\', '/'), true)) {
+                return false;
+            }
+        }
+        return true;
+    }
+}

+ 33 - 5
backend/src/main/java/com/agent/management/engine/TemplateRenderer.java

@@ -5,12 +5,20 @@ import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
- * {{变量名}} 模板渲染器
- * 变量不存在时保留原始占位符,不报错
+ * {{变量名}} 模板渲染器。
+ *
+ * <p>支持:
+ * <ul>
+ *   <li>{@code {{varName}}} — 直接引用当前工作区变量</li>
+ *   <li>{@code {{nodeId.varName}}} — 引用指定节点输出变量(内部映射为 {@code nodeId__varName})</li>
+ *   <li>{@code {{nodeId.var.path}}} — 引用指定节点输出变量的嵌套 JSON 路径</li>
+ * </ul>
+ * 变量不存在时保留原始占位符,不报错。
  */
 public class TemplateRenderer {
 
-    private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{(\\w+)}}");
+    /** 匹配 {{...}},内部允许字母、数字、下划线、点号、中横线 */
+    private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{([^{}\\s]+)}}");
 
     public static String render(String template, Map<String, Object> variables) {
         if (template == null || template.isEmpty()) return "";
@@ -19,12 +27,32 @@ public class TemplateRenderer {
         Matcher matcher = TEMPLATE_PATTERN.matcher(template);
         StringBuilder sb = new StringBuilder();
         while (matcher.find()) {
-            String varName = matcher.group(1);
-            Object value = variables.get(varName);
+            String varExpr = matcher.group(1).trim();
+            Object value = resolveValue(varExpr, variables);
             String replacement = (value != null) ? Matcher.quoteReplacement(value.toString()) : matcher.group(0);
             matcher.appendReplacement(sb, replacement);
         }
         matcher.appendTail(sb);
         return sb.toString();
     }
+
+    private static Object resolveValue(String varExpr, Map<String, Object> variables) {
+        // 无命名空间前缀:直接变量表
+        if (!varExpr.contains(".")) {
+            return variables.get(varExpr);
+        }
+
+        // nodeId.var.path 语法:把第一级前缀转为 nodeId__ 后再按 JsonPath 取值
+        int firstDot = varExpr.indexOf('.');
+        String nodeId = varExpr.substring(0, firstDot);
+        String rest = varExpr.substring(firstDot + 1);
+        String lookupKey = nodeId + NodeWorkspaceBuilder.NODE_FIELD_SEPARATOR + rest;
+        Object value = JsonPathExtractor.extract(variables, lookupKey);
+        if (value != null) {
+            return value;
+        }
+
+        // 兜底:尝试把完整点号表达式当作扁平 key
+        return variables.get(varExpr);
+    }
 }

+ 198 - 0
backend/src/main/java/com/agent/management/engine/VariableConverter.java

@@ -0,0 +1,198 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 工作流变量类型转换器。
+ *
+ * <p>基于 docs/workflow-node-fields.md 中梳理的规则实现,支持:
+ * <ul>
+ *   <li>通用基础类型转换(string/number/boolean/object/array/filePath/directoryPath)</li>
+ *   <li>RAG 检索证据列表的特殊转换</li>
+ *   <li>LLM 文本结果的结构化提取</li>
+ * </ul>
+ */
+@Slf4j
+public class VariableConverter {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    /**
+     * 将源值转换为目标类型。
+     *
+     * @param value      源值
+     * @param targetType IOField 类型名
+     * @return 转换后的值;无法转换时返回 null(调用方可根据 required 决定是否报错)
+     */
+    @SuppressWarnings("unchecked")
+    public static Object convert(Object value, String targetType) {
+        if (value == null) {
+            return null;
+        }
+        if (targetType == null || "string".equals(targetType)) {
+            return toString(value);
+        }
+        return switch (targetType) {
+            case "number" -> toNumber(value);
+            case "boolean" -> toBoolean(value);
+            case "object" -> toObject(value);
+            case "array" -> toArray(value);
+            case "filePath", "directoryPath" -> toString(value); // 路径类型以字符串形式承载
+            default -> toString(value);
+        };
+    }
+
+    public static String toString(Object value) {
+        if (value == null) return null;
+        if (value instanceof String s) return s;
+        if (value instanceof Collection<?> c) {
+            try {
+                return MAPPER.writeValueAsString(c);
+            } catch (Exception e) {
+                return c.stream().map(Object::toString).collect(Collectors.joining("\n"));
+            }
+        }
+        if (value instanceof Map<?, ?> || isPojo(value)) {
+            try {
+                return MAPPER.writeValueAsString(value);
+            } catch (Exception e) {
+                return value.toString();
+            }
+        }
+        return value.toString();
+    }
+
+    public static Number toNumber(Object value) {
+        if (value == null) return Double.NaN;
+        if (value instanceof Number n) return n;
+        if (value instanceof Boolean b) return b ? 1 : 0;
+        if (value instanceof Collection<?> c) return c.size();
+        String s = value.toString().trim();
+        if (s.isEmpty()) return Double.NaN;
+        // 尝试提取第一个数字
+        StringBuilder digits = new StringBuilder();
+        boolean seenDot = false;
+        boolean seenDigit = false;
+        for (int i = 0; i < s.length(); i++) {
+            char ch = s.charAt(i);
+            if (ch >= '0' && ch <= '9') {
+                digits.append(ch);
+                seenDigit = true;
+            } else if (ch == '.' && !seenDot && seenDigit) {
+                digits.append(ch);
+                seenDot = true;
+            } else if (ch == '-' && digits.isEmpty()) {
+                digits.append(ch);
+            } else if (seenDigit) {
+                break;
+            }
+        }
+        if (!seenDigit) return Double.NaN;
+        try {
+            String numStr = digits.toString();
+            if (numStr.contains(".")) {
+                return Double.parseDouble(numStr);
+            }
+            return Long.parseLong(numStr);
+        } catch (NumberFormatException e) {
+            return Double.NaN;
+        }
+    }
+
+    public static Boolean toBoolean(Object value) {
+        if (value == null) return false;
+        if (value instanceof Boolean b) return b;
+        if (value instanceof Number n) return n.doubleValue() != 0;
+        if (value instanceof Collection<?> c) return !c.isEmpty();
+        if (value instanceof Map<?, ?> m) return !m.isEmpty();
+        String s = value.toString().trim().toLowerCase(Locale.ROOT);
+        return "true".equals(s) || "1".equals(s) || "yes".equals(s) || !s.isEmpty();
+    }
+
+    @SuppressWarnings("unchecked")
+    public static Map<String, Object> toObject(Object value) {
+        if (value == null) return null;
+        if (value instanceof Map<?, ?> m) {
+            return (Map<String, Object>) m;
+        }
+        if (value instanceof Collection<?> c) {
+            Iterator<?> it = c.iterator();
+            if (it.hasNext()) {
+                Object first = it.next();
+                if (first instanceof Map<?, ?>) {
+                    return (Map<String, Object>) first;
+                }
+            }
+            return Map.of("items", new ArrayList<>(c));
+        }
+        String s = toString(value);
+        if (s != null && !s.isEmpty()) {
+            try {
+                Object parsed = MAPPER.readValue(s, Object.class);
+                if (parsed instanceof Map) return (Map<String, Object>) parsed;
+                return Map.of("value", parsed);
+            } catch (Exception ignored) {
+                return Map.of("value", s);
+            }
+        }
+        return Map.of("value", value);
+    }
+
+    @SuppressWarnings("unchecked")
+    public static List<Object> toArray(Object value) {
+        if (value == null) return Collections.emptyList();
+        if (value instanceof Collection<?> c) {
+            return new ArrayList<>(c);
+        }
+        if (value.getClass().isArray()) {
+            int len = java.lang.reflect.Array.getLength(value);
+            List<Object> list = new ArrayList<>(len);
+            for (int i = 0; i < len; i++) {
+                list.add(java.lang.reflect.Array.get(value, i));
+            }
+            return list;
+        }
+        String s = toString(value);
+        if (s != null && !s.isEmpty()) {
+            try {
+                Object parsed = MAPPER.readValue(s, Object.class);
+                if (parsed instanceof Collection) return new ArrayList<>((Collection<?>) parsed);
+                if (parsed instanceof Map) return List.of(parsed);
+                return List.of(parsed);
+            } catch (Exception ignored) {
+                return Arrays.asList(s.split("\r?\n"));
+            }
+        }
+        return List.of(value);
+    }
+
+    /**
+     * 将 POJO 转换为 Map,用于把 RagEvidence 等对象注入 object/array 目标类型。
+     */
+    @SuppressWarnings("unchecked")
+    public static Map<String, Object> pojoToMap(Object pojo) {
+        if (pojo == null) return null;
+        if (pojo instanceof Map<?, ?> m) return (Map<String, Object>) m;
+        try {
+            ObjectNode node = MAPPER.valueToTree(pojo);
+            return MAPPER.convertValue(node, Map.class);
+        } catch (Exception e) {
+            log.warn("[VariableConverter] POJO 转 Map 失败: {}", e.getMessage());
+            return Map.of("value", pojo.toString());
+        }
+    }
+
+    /**
+     * 判断对象是否为普通 POJO(非 JDK 集合/Map/基本类型)。
+     */
+    private static boolean isPojo(Object value) {
+        return !(value instanceof String || value instanceof Number || value instanceof Boolean
+                || value instanceof Collection || value instanceof Map || value.getClass().isArray());
+    }
+}

+ 46 - 1
backend/src/main/java/com/agent/management/engine/WorkflowContext.java

@@ -17,6 +17,13 @@ public class WorkflowContext {
     private final String runId;
     /** 扁平变量表,所有节点的输出变量合并于此(向后兼容 {{varName}} 模板渲染) */
     private final Map<String, Object> variables = new ConcurrentHashMap<>();
+    /**
+     * 节点级隔离输出表:nodeId -> {varName -> value}。
+     * 用于实现变量作用域隔离,避免不同分支的同名输出相互覆盖。
+     */
+    private final Map<String, Map<String, Object>> nodeScopedOutputs = new ConcurrentHashMap<>();
+    /** 初始运行输入,与节点输出隔离保存 */
+    private final Map<String, Object> initialInputs;
     /** 本次运行的工作目录,所有节点共享 */
     private final Path workingDir;
     /** 累积记录每个节点的输出(按执行顺序) */
@@ -36,6 +43,7 @@ public class WorkflowContext {
         this.workflowId = workflowId;
         this.runId = runId;
         this.workingDir = workingDir;
+        this.initialInputs = initialInputs == null ? Map.of() : new LinkedHashMap<>(initialInputs);
         if (initialInputs != null) {
             this.variables.putAll(initialInputs);
         }
@@ -52,6 +60,13 @@ public class WorkflowContext {
     public NodeStreamSink getStreamSink() { return streamSink; }
     public void setStreamSink(NodeStreamSink streamSink) { this.streamSink = streamSink; }
 
+    /**
+     * 获取初始运行输入(与节点输出隔离)。
+     */
+    public Map<String, Object> getInitialInputs() {
+        return Collections.unmodifiableMap(initialInputs);
+    }
+
     public void setVariable(String key, Object value) {
         variables.put(key, value);
     }
@@ -61,11 +76,12 @@ public class WorkflowContext {
     }
 
     /**
-     * 记录节点输出:同时更新扁平变量和累积输出列表。
+     * 记录节点输出:同时更新扁平变量、节点级隔离输出和累积输出列表。
      * 变量覆盖检测:若 key 已存在(被上游节点写过),记录 WARN 日志,便于 debug。
      */
     public void setNodeOutput(String nodeId, Map<String, Object> output) {
         if (output != null) {
+            setNodeScopedOutput(nodeId, output);
             for (Map.Entry<String, Object> e : output.entrySet()) {
                 Object existing = variables.put(e.getKey(), e.getValue());
                 if (existing != null) {
@@ -77,6 +93,35 @@ public class WorkflowContext {
         }
     }
 
+    /**
+     * 以节点级命名空间保存节点输出。
+     */
+    public void setNodeScopedOutput(String nodeId, Map<String, Object> output) {
+        if (output == null || output.isEmpty()) {
+            return;
+        }
+        nodeScopedOutputs.compute(nodeId, (k, existing) -> {
+            Map<String, Object> map = existing == null ? new LinkedHashMap<>() : new LinkedHashMap<>(existing);
+            map.putAll(output);
+            return map;
+        });
+    }
+
+    /**
+     * 获取指定节点的命名空间输出。
+     */
+    public Map<String, Object> getNodeScopedOutput(String nodeId) {
+        Map<String, Object> map = nodeScopedOutputs.get(nodeId);
+        return map == null ? Map.of() : Collections.unmodifiableMap(map);
+    }
+
+    /**
+     * 获取全部节点命名空间输出(只读)。
+     */
+    public Map<String, Map<String, Object>> getAllNodeScopedOutputs() {
+        return Collections.unmodifiableMap(nodeScopedOutputs);
+    }
+
     /**
      * 计算当前 variables 的全量快照(深拷贝),用于 debug 视图。
      */

+ 3 - 2
backend/src/main/java/com/agent/management/engine/WorkflowEngine.java

@@ -149,6 +149,7 @@ public class WorkflowEngine {
         // 3. 创建运行记录
         WorkflowRun runRecord = new WorkflowRun();
         runRecord.setWorkflowId(workflowId);
+        runRecord.setWorkflowName(wf.getName());
         runRecord.setRunId(runId);
         runRecord.setStatus("RUNNING");
         try {
@@ -158,8 +159,8 @@ public class WorkflowEngine {
         }
         runRecord = runRepo.save(runRecord);
 
-        // 4. 创建工作目录
-        java.nio.file.Path workingDir = runDirManager.createRunDir(workflowId, runId);
+        // 4. 创建工作目录(按 name 命名)
+        java.nio.file.Path workingDir = runDirManager.createRunDir(workflowId, wf.getName(), runId);
 
         // 4.1 同步 Skills 到 {workingDir}/.hermes/skills,供 Hermes Bridge 读取
         try {

+ 53 - 19
backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java

@@ -11,7 +11,9 @@ import org.springframework.stereotype.Component;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.io.IOException;
+import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -35,8 +37,12 @@ public class WorkflowLevelExecutor {
     private static final ObjectMapper MAPPER = new ObjectMapper()
             .registerModule(new JavaTimeModule());
 
+    /** 当前实现默认关闭严格作用域隔离,保留扁平变量以向后兼容 */
+    private static final boolean DEFAULT_SCOPE_MODE = false;
+
     private final Map<String, NodeExecutor> executorMap;
     private final SseEventBus eventBus;
+    private final NodeWorkspaceBuilder workspaceBuilder;
     /**
      * 同层节点并行执行的线程池。独立于 WorkflowEngine.executor(工作流主线程池),
      * 避免层内并行与层间串行争用同一池导致死锁。
@@ -50,7 +56,8 @@ public class WorkflowLevelExecutor {
             }
     );
 
-    public WorkflowLevelExecutor(Map<String, NodeExecutor> injected, SseEventBus eventBus) {
+    public WorkflowLevelExecutor(Map<String, NodeExecutor> injected, SseEventBus eventBus,
+                                  NodeWorkspaceBuilder workspaceBuilder) {
         // Spring 注入 Map<String, NodeExecutor> 时 key 是 bean name(如 "userInputExecutor"),
         // 而引擎查找用的是 getType() 返回值(如 "userInput")。两者命名不一致,
         // 此处按 getType() 重建 map,避免对 bean 命名的依赖。
@@ -60,6 +67,7 @@ public class WorkflowLevelExecutor {
         }
         this.executorMap = typed;
         this.eventBus = eventBus;
+        this.workspaceBuilder = workspaceBuilder;
         log.info("[WorkflowLevelExecutor] 已注册节点执行器: {}", this.executorMap.keySet());
     }
 
@@ -111,6 +119,9 @@ public class WorkflowLevelExecutor {
             activeNodes.addAll(dag.getLevels().get(0));
         }
 
+        // 运行时活跃边集合,用于条件分支的动态可达集计算
+        Set<String> activeEdges = new HashSet<>();
+
         // 注入流式回调:executor 调用 sink.emit(...) 时实时包装为 node_stream 事件推给前端
         context.setStreamSink((nodeId, kind, content, toolName) ->
                 safeSend(emitter, WorkflowRunEvent.nodeStream(runId, nodeId, kind, content, toolName)));
@@ -143,10 +154,11 @@ public class WorkflowLevelExecutor {
 
             // 2. 同层活跃节点并行执行:每个节点提交到 nodeExecutor 线程池
             List<CompletableFuture<NodeExecutionResult>> futures = new ArrayList<>(activeInLevel.size());
+            Set<String> capturedActiveEdges = activeEdges;
             for (String nodeId : activeInLevel) {
                 final String finalNodeId = nodeId;
                 futures.add(CompletableFuture.supplyAsync(
-                        () -> executeOneNode(finalNodeId, dag, context, runId, emitter),
+                        () -> executeOneNode(finalNodeId, dag, context, runId, emitter, capturedActiveEdges),
                         nodeExecutor));
             }
             // 等待当前层所有并行节点完成(任一节点抛出的异常都被封装为 failed 结果,不会从 join 传播)
@@ -177,8 +189,8 @@ public class WorkflowLevelExecutor {
                     }
                 }
 
-                // 激活下游节点
-                activateDownstream(nodeId, nodeType, result, dag, activeNodes);
+                // 激活下游节点并记录活跃边
+                activateDownstream(nodeId, nodeType, result, dag, activeNodes, activeEdges);
 
                 if ("output".equals(nodeType) && result.getOutput() != null) {
                     finalOutputs.putAll(result.getOutput());
@@ -200,7 +212,8 @@ public class WorkflowLevelExecutor {
      * 异常一律封装为 failed 结果返回,不向上抛(避免中断 CompletableFuture.allOf)。
      */
     private NodeExecutionResult executeOneNode(String nodeId, DagResolver.ResolvedDag dag,
-                                               WorkflowContext context, String runId, SseEmitter emitter) {
+                                               WorkflowContext context, String runId,
+                                               SseEmitter emitter, Set<String> activeEdges) {
         String nodeType = dag.getNodeTypeMap().get(nodeId);
         JsonNode nodeData = dag.getNodeDataMap().get(nodeId);
 
@@ -213,14 +226,32 @@ public class WorkflowLevelExecutor {
 
         safeSend(emitter, WorkflowRunEvent.nodeRunning(runId, nodeId));
 
-        String precheckError = checkPreconditions(nodeData, context);
-        if (precheckError != null) {
-            log.warn("[WorkflowEngine] 节点 {} 前置条件不满足: {}", nodeId, precheckError);
-            return NodeExecutionResult.failed(nodeId, precheckError);
-        }
-
         try {
-            NodeExecutionResult result = executor.execute(nodeId, nodeData, context);
+            // 构建节点工作区(基于当前活跃边过滤可达前驱)
+            NodeWorkspace workspace = workspaceBuilder.build(nodeId, dag, context, activeEdges, DEFAULT_SCOPE_MODE);
+
+            // 解析节点输入(显式 mapping + 隐式 fallback)
+            NodeInputResolver.ResolvedInputs resolvedInputs = NodeInputResolver.resolveInputs(nodeData, workspace);
+            if (resolvedInputs.hasMissing()) {
+                String errMsg = "前置条件不满足: 缺少输入 " + String.join(", ", resolvedInputs.getMissing());
+                log.warn("[WorkflowEngine] 节点 {} {}", nodeId, errMsg);
+                return NodeExecutionResult.failed(nodeId, errMsg);
+            }
+            if (!resolvedInputs.getWarnings().isEmpty()) {
+                resolvedInputs.getWarnings().forEach(w ->
+                        log.warn("[WorkflowEngine] 节点 {} 输入解析警告: {}", nodeId, w));
+            }
+            // 将解析后的输入注入工作区,供执行器通过名称直接读取
+            workspace.getVariables().putAll(resolvedInputs.getValues());
+
+            // 前置条件校验:文件/目录存在性与类型兼容性
+            String precheckError = checkPreconditions(nodeData, workspace, context.getWorkingDir());
+            if (precheckError != null) {
+                log.warn("[WorkflowEngine] 节点 {} 前置条件不满足: {}", nodeId, precheckError);
+                return NodeExecutionResult.failed(nodeId, precheckError);
+            }
+
+            NodeExecutionResult result = executor.execute(nodeId, nodeData, context, workspace);
             if (result.getOutput() != null) {
                 context.setNodeOutput(nodeId, result.getOutput());
             }
@@ -244,10 +275,12 @@ public class WorkflowLevelExecutor {
     }
 
     /**
-     * 根据当前节点执行结果激活下游节点;条件分支按选中 sourceHandle 过滤。
+     * 根据当前节点执行结果激活下游节点;条件分支按选中 sourceHandle 过滤,
+     * 同时把实际激活的边加入 activeEdges 用于下游节点的动态可达集计算。
      */
     private void activateDownstream(String nodeId, String nodeType, NodeExecutionResult result,
-                                    DagResolver.ResolvedDag dag, Set<String> activeNodes) {
+                                    DagResolver.ResolvedDag dag, Set<String> activeNodes,
+                                    Set<String> activeEdges) {
         List<DagResolver.EdgeInfo> outEdges = dag.getOutgoingEdges().get(nodeId);
         if (outEdges == null) return;
 
@@ -257,6 +290,8 @@ public class WorkflowLevelExecutor {
                 continue;
             }
             activeNodes.add(edge.getTarget());
+            activeEdges.add(WorkflowScopeResolver.activeEdgeKey(
+                    edge.getSource(), edge.getSourceHandle(), edge.getTarget()));
         }
     }
 
@@ -286,16 +321,15 @@ public class WorkflowLevelExecutor {
     }
 
     /**
-     * 前置条件校验:检查节点 data.inputs 中 required=true 的字段是否在上下文中存在
+     * 前置条件校验:检查节点 data.inputs 中 required=true 的字段是否在工作区中存在
      * - filePath/directoryPath 类型额外校验工作目录中文件/目录是否存在
-     * - 类型校验:声明的 type 与上下文实际值的类型必须兼容(参考前端 ioInference.isTypeCompatible)
+     * - 类型校验:声明的 type 与工作区实际值的类型必须兼容(参考前端 ioInference.isTypeCompatible)
      * 无 inputs 定义时跳过校验(向后兼容)
      */
-    private String checkPreconditions(JsonNode nodeData, WorkflowContext context) {
+    private String checkPreconditions(JsonNode nodeData, NodeWorkspace workspace, Path workingDir) {
         JsonNode inputsNode = nodeData.path("inputs");
         if (!inputsNode.isArray() || inputsNode.isEmpty()) return null;
 
-        java.nio.file.Path workingDir = context.getWorkingDir();
         List<String> missing = new ArrayList<>();
 
         for (JsonNode field : inputsNode) {
@@ -306,7 +340,7 @@ public class WorkflowLevelExecutor {
             String type = field.path("type").asText("string");
             if (name.isEmpty()) continue;
 
-            Object value = context.getVariable(name);
+            Object value = workspace.getVariable(name);
             if (value == null) {
                 missing.add(name + " (变量未提供)");
                 continue;

+ 47 - 15
backend/src/main/java/com/agent/management/engine/WorkflowRunDirManager.java

@@ -14,7 +14,11 @@ import java.util.regex.Pattern;
  * 工作流运行目录管理器
  * 为每次工作流执行创建独立的工作目录,永久保留
  *
- * 目录结构: {basePath}/{workflowId}/{runId}/
+ * <p>目录结构:
+ * <ul>
+ *   <li>新建:{@code {basePath}/{workflowName}/{runId}/}(workflowName 为 kebab-case 唯一标识)</li>
+ *   <li>兼容:旧目录 {@code {basePath}/{workflowId}/{runId}/} 仍可读取(用户改名前的历史 run)</li>
+ * </ul>
  */
 @Slf4j
 @Component
@@ -37,15 +41,15 @@ public class WorkflowRunDirManager {
     }
 
     /**
-     * 为指定工作流执行创建工作目录
+     * 为指定工作流执行创建工作目录(新建使用 workflowName 作为目录段)。
      *
-     * @param workflowId 工作流 ID
-     * @param runId      运行 ID(必须为 1~16 位十六进制)
-     * @return 工作目录的绝对路径
+     * @param workflowId   工作流内部 ID(兼容旧目录兜底用)
+     * @param workflowName 工作流唯一标识(kebab-case),新建目录使用此字段
+     * @param runId        运行 ID(必须为 1~16 位十六进制)
      */
-    public Path createRunDir(Long workflowId, String runId) {
+    public Path createRunDir(Long workflowId, String workflowName, String runId) {
         validateRunId(runId);
-        Path runDir = resolveRunDir(workflowId, runId);
+        Path runDir = resolveByName(workflowName, runId);
         try {
             Files.createDirectories(runDir);
             log.info("[RunDirManager] 创建运行目录: {}", runDir);
@@ -56,16 +60,39 @@ public class WorkflowRunDirManager {
     }
 
     /**
-     * 获取指定运行的工作目录(已存在时返回,不存在返回 null)
+     * 获取指定运行的工作目录(已存在时返回,不存在返回 null)。
+     * 查询顺序:先按 name,再按 id 兜底。
      */
-    public Path getRunDir(Long workflowId, String runId) {
+    public Path getRunDir(Long workflowId, String workflowName, String runId) {
         try {
             validateRunId(runId);
         } catch (IllegalArgumentException e) {
             return null;
         }
-        Path runDir = resolveRunDir(workflowId, runId);
-        return Files.isDirectory(runDir) ? runDir : null;
+        // 1. 优先按 name 查找
+        if (workflowName != null && !workflowName.isEmpty()) {
+            Path byName = resolveByName(workflowName, runId);
+            if (Files.isDirectory(byName)) return byName;
+        }
+        // 2. 回退按 id 查找(兼容旧目录)
+        if (workflowId != null) {
+            Path byId = resolveById(workflowId, runId);
+            if (Files.isDirectory(byId)) return byId;
+        }
+        return null;
+    }
+
+    /**
+     * 兼容旧调用:仅按 id 创建/查询。新代码应使用带 name 的重载。
+     */
+    @Deprecated
+    public Path createRunDir(Long workflowId, String runId) {
+        return createRunDir(workflowId, String.valueOf(workflowId), runId);
+    }
+
+    @Deprecated
+    public Path getRunDir(Long workflowId, String runId) {
+        return getRunDir(workflowId, null, runId);
     }
 
     /**
@@ -84,10 +111,15 @@ public class WorkflowRunDirManager {
         }
     }
 
-    /**
-     * 解析运行目录绝对路径,并二次校验不得逃出 basePath
-     */
-    private Path resolveRunDir(Long workflowId, String runId) {
+    private Path resolveByName(String workflowName, String runId) {
+        Path runDir = basePath.resolve(workflowName).resolve(runId).normalize();
+        if (!runDir.startsWith(basePath)) {
+            throw new IllegalArgumentException("解析后的 runId 路径越界: " + runDir);
+        }
+        return runDir;
+    }
+
+    private Path resolveById(Long workflowId, String runId) {
         Path runDir = basePath.resolve(String.valueOf(workflowId)).resolve(runId).normalize();
         if (!runDir.startsWith(basePath)) {
             throw new IllegalArgumentException("解析后的 runId 路径越界: " + runDir);

+ 68 - 0
backend/src/main/java/com/agent/management/engine/WorkflowScopeResolver.java

@@ -0,0 +1,68 @@
+package com.agent.management.engine;
+
+import java.util.*;
+
+/**
+ * 工作流 DAG 作用域解析器:计算节点的可达前驱集合。
+ */
+public class WorkflowScopeResolver {
+
+    /**
+     * 静态可达前驱:基于 DAG 的入边反向 BFS,不考虑条件分支动态激活状态。
+     * 用于节点执行前构建保守工作区。
+     */
+    public static Set<String> getReachablePredecessors(String nodeId, DagResolver.ResolvedDag dag) {
+        Set<String> visited = new HashSet<>();
+        Queue<String> queue = new LinkedList<>();
+        queue.add(nodeId);
+        while (!queue.isEmpty()) {
+            String current = queue.poll();
+            for (DagResolver.EdgeInfo edge : dag.getIncomingEdges().getOrDefault(current, Collections.emptyList())) {
+                String source = edge.getSource();
+                if (visited.add(source)) {
+                    queue.add(source);
+                }
+            }
+        }
+        visited.remove(nodeId);
+        return visited;
+    }
+
+    /**
+     * 动态可达前驱:基于运行时活跃边集合过滤。
+     * 条件节点仅把实际激活的分支下游加入可达集。
+     *
+     * @param activeEdges 活跃边 key:source + "-" + sourceHandle + "-" + target
+     */
+    public static Set<String> getReachablePredecessors(String nodeId, DagResolver.ResolvedDag dag,
+                                                          Set<String> activeEdges) {
+        if (activeEdges == null || activeEdges.isEmpty()) {
+            return getReachablePredecessors(nodeId, dag);
+        }
+        Set<String> visited = new HashSet<>();
+        Queue<String> queue = new LinkedList<>();
+        queue.add(nodeId);
+        while (!queue.isEmpty()) {
+            String current = queue.poll();
+            for (DagResolver.EdgeInfo edge : dag.getIncomingEdges().getOrDefault(current, Collections.emptyList())) {
+                String edgeKey = edge.getSource() + "-" + edge.getSourceHandle() + "-" + edge.getTarget();
+                if (!activeEdges.contains(edgeKey)) {
+                    continue;
+                }
+                String source = edge.getSource();
+                if (visited.add(source)) {
+                    queue.add(source);
+                }
+            }
+        }
+        visited.remove(nodeId);
+        return visited;
+    }
+
+    /**
+     * 生成活跃边 key,与 getReachablePredecessors(dag, activeEdges) 的格式一致。
+     */
+    public static String activeEdgeKey(String source, String sourceHandle, String target) {
+        return source + "-" + (sourceHandle == null ? "output" : sourceHandle) + "-" + target;
+    }
+}

+ 11 - 11
backend/src/main/java/com/agent/management/engine/executor/AgentExecutor.java

@@ -42,7 +42,7 @@ public class AgentExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String agentId = data.path("agentId").asText("");
         if (agentId.isEmpty()) {
             return NodeExecutionResult.failed(nodeId, "Agent 节点未关联 Skill");
@@ -58,15 +58,15 @@ public class AgentExecutor implements NodeExecutor {
             return NodeExecutionResult.failed(nodeId, "加载 Skill 失败");
         }
 
-        // 构造用户消息:基于 Skill 的 inputs 定义从上下文取值
-        String userMessage = buildUserMessage(skill, context);
+        // 构造用户消息:基于 Skill 的 inputs 定义从工作区取值
+        String userMessage = buildUserMessage(skill, workspace);
         ChatClient client = resolveChatClient(data);
 
         log.info("[Agent] 节点 {} 执行 Skill: {}, 用户消息长度={}", nodeId, agentId, userMessage.length());
 
         try {
-            // 把前置节点 variables 追加到 SKILL.md 内容之后,作为完整系统提示
-            String fullSystem = ContextPromptHelper.merge(skillContent, context);
+            // 把节点明确定义的输入变量追加到 SKILL.md 内容之后,作为完整系统提示
+            String fullSystem = ContextPromptHelper.merge(skillContent, data, workspace);
             var request = client.prompt()
                     .system(fullSystem)
                     .user(userMessage);
@@ -100,24 +100,24 @@ public class AgentExecutor implements NodeExecutor {
     }
 
     /**
-     * 根据 Skill 的 inputs 定义,从上下文构造结构化用户消息
+     * 根据 Skill 的 inputs 定义,从工作区构造结构化用户消息
      */
-    private String buildUserMessage(SkillDTO skill, WorkflowContext context) {
+    private String buildUserMessage(SkillDTO skill, NodeWorkspace workspace) {
         StringBuilder sb = new StringBuilder();
 
         if (skill.getInputs() != null && !skill.getInputs().isEmpty()) {
             for (IOField input : skill.getInputs()) {
-                Object value = context.getVariable(input.getName());
+                Object value = workspace.getVariable(input.getName());
                 if (value != null) {
                     sb.append(input.getName()).append(": ").append(value).append("\n");
                 } else {
-                    log.debug("[Agent] Skill 输入字段 {} 在上下文中未找到值", input.getName());
+                    log.debug("[Agent] Skill 输入字段 {} 在工作区中未找到值", input.getName());
                 }
             }
         }
 
-        if (sb.isEmpty() && !context.getVariables().isEmpty()) {
-            sb.append(context.getVariables().entrySet().stream()
+        if (sb.isEmpty() && !workspace.getVariables().isEmpty()) {
+            sb.append(workspace.getVariables().entrySet().stream()
                     .filter(e -> e.getValue() != null)
                     .map(e -> e.getKey() + ": " + e.getValue())
                     .collect(Collectors.joining("\n")));

+ 2 - 2
backend/src/main/java/com/agent/management/engine/executor/ConditionExecutor.java

@@ -32,7 +32,7 @@ public class ConditionExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         JsonNode conditions = data.path("conditions");
         if (!conditions.isArray() || conditions.isEmpty()) {
             // 没有条件定义,走默认分支(空输出:路由靠 sourceHandle,无下游会读 {{nodeId}} 取分支号)
@@ -52,7 +52,7 @@ public class ConditionExecutor implements NodeExecutor {
         conditionText.append(branchCount).append(" (ELSE): 以上条件都不满足\n");
 
         // 构建上下文摘要
-        String contextSummary = context.getVariables().entrySet().stream()
+        String contextSummary = workspace.getVariables().entrySet().stream()
                 .filter(e -> e.getValue() != null)
                 .map(e -> e.getKey() + ": " + truncate(e.getValue().toString(), 200))
                 .collect(Collectors.joining("\n"));

+ 9 - 9
backend/src/main/java/com/agent/management/engine/executor/HermesAgentExecutor.java

@@ -47,7 +47,7 @@ public class HermesAgentExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String agentId = data.path("agentId").asText("");
         if (agentId.isEmpty()) {
             return NodeExecutionResult.failed(nodeId, "Agent 节点未关联 Skill");
@@ -58,9 +58,9 @@ public class HermesAgentExecutor implements NodeExecutor {
         String userMessage;
         try {
             SkillDTO skill = skillService.getSkill(agentId);
-            // 把前置节点 variables 追加到 SKILL.md 内容之后,作为完整系统提示
-            systemPrompt = ContextPromptHelper.merge(skillService.getSkillFullContent(agentId), context);
-            userMessage = buildUserMessage(skill, context);
+            // 把节点明确定义的输入变量追加到 SKILL.md 内容之后,作为完整系统提示
+            systemPrompt = ContextPromptHelper.merge(skillService.getSkillFullContent(agentId), data, workspace);
+            userMessage = buildUserMessage(skill, workspace);
         } catch (Exception e) {
             return NodeExecutionResult.failed(nodeId, "加载 Skill 失败: " + e.getMessage());
         }
@@ -122,22 +122,22 @@ public class HermesAgentExecutor implements NodeExecutor {
     }
 
     /**
-     * 根据 Skill 的 inputs 定义,从上下文构造结构化用户消息
+     * 根据 Skill 的 inputs 定义,从工作区构造结构化用户消息
      */
-    private String buildUserMessage(SkillDTO skill, WorkflowContext context) {
+    private String buildUserMessage(SkillDTO skill, NodeWorkspace workspace) {
         StringBuilder sb = new StringBuilder();
 
         if (skill.getInputs() != null && !skill.getInputs().isEmpty()) {
             for (IOField input : skill.getInputs()) {
-                Object value = context.getVariable(input.getName());
+                Object value = workspace.getVariable(input.getName());
                 if (value != null) {
                     sb.append(input.getName()).append(": ").append(value).append("\n");
                 }
             }
         }
 
-        if (sb.isEmpty() && !context.getVariables().isEmpty()) {
-            sb.append(context.getVariables().entrySet().stream()
+        if (sb.isEmpty() && !workspace.getVariables().isEmpty()) {
+            sb.append(workspace.getVariables().entrySet().stream()
                     .filter(e -> e.getValue() != null)
                     .map(e -> e.getKey() + ": " + e.getValue())
                     .collect(Collectors.joining("\n")));

+ 4 - 4
backend/src/main/java/com/agent/management/engine/executor/HermesSmartActionExecutor.java

@@ -41,9 +41,9 @@ public class HermesSmartActionExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String actionPrompt = TemplateRenderer.render(
-                data.path("actionPrompt").asText(""), context.getVariables());
+                data.path("actionPrompt").asText(""), workspace.getVariables());
 
         if (actionPrompt.isEmpty()) {
             return NodeExecutionResult.failed(nodeId, "智能操作节点的操作要求为空");
@@ -59,8 +59,8 @@ public class HermesSmartActionExecutor implements NodeExecutor {
             String workingDir = context.getWorkingDir() != null ? context.getWorkingDir().toString() : null;
             String sessionId = context.getRunId() + "_" + nodeId;
             Map<String, String> modelConfig = resolveModelConfig(data);
-            // 把前置节点 variables 作为系统提示注入 Hermes Bridge(原 null 不再使用)
-            String contextSystemPrompt = ContextPromptHelper.buildContextSystemPrompt(context);
+            // 把当前节点明确定义的输入变量作为系统提示注入 Hermes Bridge(原 null 不再使用)
+            String contextSystemPrompt = ContextPromptHelper.buildInputContextSystemPrompt(data, workspace);
             HermesRunResult runResult = bridgeClient.run(contextSystemPrompt.isEmpty() ? null : contextSystemPrompt,
                     actionPrompt, maxIterations,
                     hermesHome, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);

+ 3 - 2
backend/src/main/java/com/agent/management/engine/executor/KnowledgeRetrievalExecutor.java

@@ -2,6 +2,7 @@ package com.agent.management.engine.executor;
 
 import com.agent.management.engine.NodeExecutionResult;
 import com.agent.management.engine.NodeExecutor;
+import com.agent.management.engine.NodeWorkspace;
 import com.agent.management.engine.TemplateRenderer;
 import com.agent.management.engine.WorkflowContext;
 import com.agent.management.model.entity.DataSource;
@@ -70,10 +71,10 @@ public class KnowledgeRetrievalExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String source = data.path("source").asText("document");
         String rawQuery = data.path("query").asText("");
-        String query = TemplateRenderer.render(rawQuery, context.getVariables()).trim();
+        String query = TemplateRenderer.render(rawQuery, workspace.getVariables()).trim();
 
         if (query.isEmpty()) {
             return NodeExecutionResult.failed(nodeId, "知识库检索节点的检索语句为空");

+ 5 - 5
backend/src/main/java/com/agent/management/engine/executor/LlmExecutor.java

@@ -32,12 +32,12 @@ public class LlmExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
-        // 自动把前置节点输出的 variables 作为系统提示词注入,无需用户显式写 {{var}}
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
+        // 系统提示词:渲染用户自定义 prompt,并仅注入当前节点明确定义的输入变量
         String systemPrompt = ContextPromptHelper.merge(
-                TemplateRenderer.render(data.path("systemPrompt").asText(""), context.getVariables()),
-                context);
-        String userPrompt = TemplateRenderer.render(data.path("userPrompt").asText(""), context.getVariables());
+                TemplateRenderer.render(data.path("systemPrompt").asText(""), workspace.getVariables()),
+                data, workspace);
+        String userPrompt = TemplateRenderer.render(data.path("userPrompt").asText(""), workspace.getVariables());
 
         if (userPrompt.isEmpty()) {
             return NodeExecutionResult.failed(nodeId, "LLM 节点的用户提示词为空");

+ 4 - 3
backend/src/main/java/com/agent/management/engine/executor/OutputExecutor.java

@@ -1,9 +1,10 @@
 package com.agent.management.engine.executor;
 
 import com.agent.management.engine.AgentIgnoreFilter;
-import com.agent.management.engine.WorkflowContext;
 import com.agent.management.engine.NodeExecutionResult;
 import com.agent.management.engine.NodeExecutor;
+import com.agent.management.engine.NodeWorkspace;
+import com.agent.management.engine.WorkflowContext;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -34,10 +35,10 @@ public class OutputExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String outputType = data.path("outputType").asText("text");
 
-        // 收集上下文中的所有变量作为最终输出
+        // 收集上下文中的所有变量作为最终输出(保持与原 flat variables 行为一致)
         Map<String, Object> output = new HashMap<>(context.getVariables());
 
         // 收集工作目录中的文件列表(应用 .agentignore 过滤)

+ 3 - 2
backend/src/main/java/com/agent/management/engine/executor/SkillExecutor.java

@@ -2,6 +2,7 @@ package com.agent.management.engine.executor;
 
 import com.agent.management.engine.NodeExecutionResult;
 import com.agent.management.engine.NodeExecutor;
+import com.agent.management.engine.NodeWorkspace;
 import com.agent.management.engine.WorkflowContext;
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -39,7 +40,7 @@ public class SkillExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         NodeExecutor delegate = hermesAgentExecutor != null ? hermesAgentExecutor : builtinAgentExecutor;
         if (delegate == null) {
             return NodeExecutionResult.failed(nodeId, "未找到可用的 Agent 执行器(hermes.enabled 配置异常)");
@@ -55,6 +56,6 @@ public class SkillExecutor implements NodeExecutor {
         bridged.put("agentId", skillId);
 
         log.info("[Skill] 节点 {} 委托 {} 执行, skillId={}", nodeId, delegate.getClass().getSimpleName(), skillId);
-        return delegate.execute(nodeId, bridged, context);
+        return delegate.execute(nodeId, bridged, context, workspace);
     }
 }

+ 4 - 4
backend/src/main/java/com/agent/management/engine/executor/SmartActionExecutor.java

@@ -35,9 +35,9 @@ public class SmartActionExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String actionPrompt = TemplateRenderer.render(
-                data.path("actionPrompt").asText(""), context.getVariables());
+                data.path("actionPrompt").asText(""), workspace.getVariables());
 
         if (actionPrompt.isEmpty()) {
             return NodeExecutionResult.failed(nodeId, "智能操作节点的操作要求为空");
@@ -48,8 +48,8 @@ public class SmartActionExecutor implements NodeExecutor {
         log.info("[SmartAction] 节点 {} 开始执行, actionPrompt长度={}", nodeId, actionPrompt.length());
 
         try {
-            // 智能操作节点没有用户可配置的 systemPrompt,直接用前置节点 variables 作为系统提示
-            String contextSystemPrompt = ContextPromptHelper.buildContextSystemPrompt(context);
+            // 智能操作节点没有用户可配置的 systemPrompt,仅注入当前节点明确定义的输入变量作为系统提示
+            String contextSystemPrompt = ContextPromptHelper.buildInputContextSystemPrompt(data, workspace);
             var request = client.prompt().user(actionPrompt);
             if (!contextSystemPrompt.isEmpty()) {
                 request = request.system(contextSystemPrompt);

+ 4 - 3
backend/src/main/java/com/agent/management/engine/executor/UserInputExecutor.java

@@ -1,8 +1,9 @@
 package com.agent.management.engine.executor;
 
-import com.agent.management.engine.WorkflowContext;
 import com.agent.management.engine.NodeExecutionResult;
 import com.agent.management.engine.NodeExecutor;
+import com.agent.management.engine.NodeWorkspace;
+import com.agent.management.engine.WorkflowContext;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
@@ -24,7 +25,7 @@ public class UserInputExecutor implements NodeExecutor {
     }
 
     @Override
-    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context) {
+    public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         // 用户输入在运行时已通过 request.inputs 提供
         // 此节点的作用是:根据 variables 定义,将用户输入值规范化并注册到上下文
         JsonNode variablesNode = data.path("variables");
@@ -34,7 +35,7 @@ public class UserInputExecutor implements NodeExecutor {
             for (JsonNode var : variablesNode) {
                 String name = var.path("name").asText("");
                 if (!name.isEmpty()) {
-                    Object value = context.getVariable(name);
+                    Object value = workspace.getVariable(name);
                     if (value != null) {
                         output.put(name, value);
                         log.debug("[UserInput] 变量 {} = {}", name, value);

+ 93 - 77
backend/src/main/java/com/agent/management/external/ExternalApiController.java

@@ -56,16 +56,17 @@ import java.util.zip.ZipOutputStream;
 /**
  * 外部 API(/api/v1/**)入口:第三方系统通过此 Controller 调用平台工作流。
  *
+ * <p>URL 路径使用 kebab-case 唯一标识 {@code workflowName}(与 Skill 一致)。
+ * 旧调用方使用数字 id 的 URL 仍可通过 by-id 兜底逻辑访问(用户手动改名为非数字前都自动兼容)。
+ *
  * 流程:
- * - POST /workflows/{id}/runs           创建运行(提交输入与文件,返回 runId)
- * - POST /workflows/{id}/runs/{runId}/start  启动执行
- * - GET  /workflows/{id}/runs/{runId}/stream 订阅 SSE 流(思考过程 + 节点状态)
- * - GET  /workflows/{id}/runs/{runId}        查询运行结果
- * - GET  /workflows/{id}/runs/{runId}/workspace  下载工作空间 zip
- * - GET  /workflows/{id}/runs/{runId}/nodes/{nodeId}/logs  查询节点日志
+ * - POST /workflows/{workflowName}/runs           创建运行
+ * - POST /workflows/{workflowName}/runs/{runId}/start  启动执行
+ * - GET  /workflows/{workflowName}/runs/{runId}/stream 订阅 SSE 流
+ * - GET  /workflows/{workflowName}/runs/{runId}        查询运行结果
+ * - GET  /workflows/{workflowName}/runs/{runId}/workspace  下载工作空间 zip
+ * - GET  /workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs  查询节点日志
  * - GET  /workflows                          列出当前 API Key 可调用的工作流
- *
- * 鉴权:见 {@link ExternalApiAuthFilter},匹配的 KeyEntry 放入请求属性。
  */
 @Slf4j
 @RestController
@@ -88,38 +89,32 @@ public class ExternalApiController {
     private static final long SSE_TIMEOUT_MS = 1_800_000L;
 
     // ============================================================
-    // POST /workflows/{id}/runs — 创建运行
+    // POST /workflows/{workflowName}/runs — 创建运行
     // ============================================================
 
-    /**
-     * 形态 A:仅 JSON 输入
-     */
-    @PostMapping(value = "/workflows/{workflowId}/runs", consumes = MediaType.APPLICATION_JSON_VALUE)
-    public ResponseEntity<?> createRunJson(@PathVariable Long workflowId,
+    @PostMapping(value = "/workflows/{workflowName}/runs", consumes = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<?> createRunJson(@PathVariable String workflowName,
                                            @RequestAttribute(ExternalApiAuthFilter.ATTR_KEY_ENTRY) ExternalApiProperties.KeyEntry keyEntry,
                                            @RequestBody(required = false) CreateRunRequest req) {
-        return doCreateRun(workflowId, req, null, null);
+        return doCreateRun(workflowName, req, null, null);
     }
 
-    /**
-     * 形态 B:multipart(JSON payload + 文件/目录)
-     */
-    @PostMapping(value = "/workflows/{workflowId}/runs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
-    public ResponseEntity<?> createRunMultipart(@PathVariable Long workflowId,
+    @PostMapping(value = "/workflows/{workflowName}/runs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+    public ResponseEntity<?> createRunMultipart(@PathVariable String workflowName,
                                                 @RequestAttribute(ExternalApiAuthFilter.ATTR_KEY_ENTRY) ExternalApiProperties.KeyEntry keyEntry,
                                                 @RequestParam(value = "payload", required = false) String payloadJson,
                                                 @RequestParam(value = "files", required = false) MultipartFile[] files,
                                                 @RequestHeader(value = "X-Relative-Path", required = false) List<String> relativePaths) {
         CreateRunRequest req = parsePayload(payloadJson);
-        return doCreateRun(workflowId, req, files, relativePaths);
+        return doCreateRun(workflowName, req, files, relativePaths);
     }
 
-    private ResponseEntity<?> doCreateRun(Long workflowId, CreateRunRequest req,
+    private ResponseEntity<?> doCreateRun(String workflowName, CreateRunRequest req,
                                           MultipartFile[] files, List<String> relativePaths) {
-        // 1. 工作流存在性 + 图结构校验
-        Workflow wf = workflowRepository.findById(workflowId).orElse(null);
+        // 1. 工作流解析
+        Workflow wf = resolveWorkflow(workflowName);
         if (wf == null) {
-            return error(404, "WORKFLOW_NOT_FOUND", "工作流不存在: " + workflowId);
+            return error(404, "WORKFLOW_NOT_FOUND", "工作流不存在: " + workflowName);
         }
         if (wf.getGraphData() == null || wf.getGraphData().isBlank()
                 || wf.getGraphData().equals("{\"nodes\":[],\"edges\":[]}")) {
@@ -136,7 +131,7 @@ public class ExternalApiController {
         String runId;
         try {
             runId = generateRunId();
-            Path runDir = runDirManager.createRunDir(workflowId, runId);
+            Path runDir = runDirManager.createRunDir(wf.getId(), wf.getName(), runId);
             List<String> uploadedFiles = new ArrayList<>();
             if (files != null) {
                 for (int i = 0; i < files.length; i++) {
@@ -160,30 +155,32 @@ public class ExternalApiController {
         } catch (IllegalArgumentException e) {
             return error(400, "VALIDATION_FAILED", e.getMessage());
         } catch (IOException e) {
-            log.error("[ExternalApi] 创建运行失败 workflowId={}", workflowId, e);
+            log.error("[ExternalApi] 创建运行失败 workflowName={}", workflowName, e);
             return error(500, "INTERNAL_ERROR", "工作目录创建失败");
         }
 
-        // 4. 注册 CREATED 状态 + 在 EventBus 占位(确保订阅时一定能找到)
+        // 4. 注册 CREATED 状态
         int ttlHours = (req != null && req.getTtlHours() != null && req.getTtlHours() > 0)
                 ? req.getTtlHours() : DEFAULT_TTL_HOURS;
-        runRegistry.register(runId, workflowId, inputs, ttlHours);
+        runRegistry.register(runId, wf.getId(), wf.getName(), inputs, ttlHours);
         eventBus.register(runId);
 
-        // 5. async=false:立即启动并直接进入 SSE 流(与现有 /run 行为一致)
+        // 5. async=false:立即启动并直接进入 SSE 流
         boolean async = req == null || req.getAsync() == null || req.getAsync();
         if (!async) {
-            return startRun(workflowId, runId, inputs);
+            return startRun(wf.getName(), wf.getId(), runId, inputs);
         }
 
-        // 6. async=true:返回 runId + links
+        // 6. async=true:返回 runId + links(URL 使用 workflowName)
         CreateRunResponse body = new CreateRunResponse();
         body.setRunId(runId);
-        body.setWorkflowId(workflowId);
+        body.setWorkflowId(wf.getId());
+        body.setWorkflowName(wf.getName());
+        body.setDisplayName(wf.getDisplayName());
         body.setStatus("CREATED");
         body.setCreatedAt(Instant.now());
         Links links = new Links();
-        String base = "/api/v1/workflows/" + workflowId + "/runs/" + runId;
+        String base = "/api/v1/workflows/" + wf.getName() + "/runs/" + runId;
         links.setStart(base + "/start");
         links.setStream(base + "/stream");
         links.setResult(base);
@@ -193,48 +190,46 @@ public class ExternalApiController {
     }
 
     // ============================================================
-    // POST /workflows/{id}/runs/{runId}/start — 启动运行
+    // POST /workflows/{workflowName}/runs/{runId}/start — 启动运行
     // ============================================================
 
-    @PostMapping("/workflows/{workflowId}/runs/{runId}/start")
-    public ResponseEntity<?> startRun(@PathVariable Long workflowId,
+    @PostMapping("/workflows/{workflowName}/runs/{runId}/start")
+    public ResponseEntity<?> startRun(@PathVariable String workflowName,
                                       @PathVariable String runId) {
         ExternalRunRegistry.CreatedRun created = runRegistry.consume(runId);
         if (created == null) {
             return error(404, "RUN_NOT_FOUND", "运行不存在或已启动: " + runId);
         }
-        if (!Objects.equals(created.workflowId(), workflowId)) {
-            return error(400, "VALIDATION_FAILED", "runId 与 workflowId 不匹配");
+        // 校验 pathVariable 与 created 一致(兼容数字字符串)
+        if (!matchesWorkflow(created, workflowName)) {
+            return error(400, "VALIDATION_FAILED", "runId 与 workflowName 不匹配");
         }
-        return startRun(workflowId, runId, created.inputs());
+        return startRun(created.workflowName(), created.workflowId(), runId, created.inputs());
     }
 
-    private ResponseEntity<?> startRun(Long workflowId, String runId, Map<String, Object> inputs) {
-        // executeForExternal 直接以 runId 启动执行,不走 _preRunId 机制(避免污染 variables)
+    private ResponseEntity<?> startRun(String workflowName, Long workflowId, String runId, Map<String, Object> inputs) {
         workflowEngine.executeForExternal(workflowId, runId, inputs);
 
         StartRunResponse body = new StartRunResponse();
         body.setRunId(runId);
         body.setStatus("RUNNING");
-        body.setStreamUrl("/api/v1/workflows/" + workflowId + "/runs/" + runId + "/stream");
+        body.setStreamUrl("/api/v1/workflows/" + workflowName + "/runs/" + runId + "/stream");
         return ResponseEntity.ok(ApiError.ok(body));
     }
 
     // ============================================================
-    // GET /workflows/{id}/runs/{runId}/stream — SSE 流(含 Last-Event-ID 重连)
+    // GET /workflows/{workflowName}/runs/{runId}/stream — SSE 流
     // ============================================================
 
-    @GetMapping(value = "/workflows/{workflowId}/runs/{runId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
-    public ResponseEntity<SseEmitter> streamRun(@PathVariable Long workflowId,
+    @GetMapping(value = "/workflows/{workflowName}/runs/{runId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public ResponseEntity<SseEmitter> streamRun(@PathVariable String workflowName,
                                                 @PathVariable String runId,
                                                 @RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) {
-        // runId 必须存在于 EventBus(CREATED 或 RUNNING)或运行历史
         if (!eventBus.exists(runId)) {
             WorkflowRun record = findRunRecord(runId);
             if (record == null) {
                 return ResponseEntity.notFound().build();
             }
-            // 已结束的运行:注册一个已完成 stream,subscribe 后只补发缓存
             eventBus.register(runId);
             eventBus.publish(runId,
                     record.getStatus().equals("FAILED") ? "workflow_error" : "workflow_complete",
@@ -246,32 +241,35 @@ public class ExternalApiController {
             return ResponseEntity.notFound().build();
         }
         return ResponseEntity.ok()
-                .header("X-Accel-Buffering", "no") // 禁用 Nginx 缓冲,确保实时推送
+                .header("X-Accel-Buffering", "no")
                 .header("Cache-Control", "no-cache")
                 .body(emitter);
     }
 
     // ============================================================
-    // GET /workflows/{id}/runs/{runId} — 查询运行结果
+    // GET /workflows/{workflowName}/runs/{runId} — 查询运行结果
     // ============================================================
 
-    @GetMapping("/workflows/{workflowId}/runs/{runId}")
-    public ResponseEntity<?> getRunResult(@PathVariable Long workflowId,
+    @GetMapping("/workflows/{workflowName}/runs/{runId}")
+    public ResponseEntity<?> getRunResult(@PathVariable String workflowName,
                                           @PathVariable String runId) {
+        Workflow wf = resolveWorkflow(workflowName);
+        if (wf == null) {
+            return error(404, "WORKFLOW_NOT_FOUND", "工作流不存在: " + workflowName);
+        }
         RunResultResponse body = new RunResultResponse();
         body.setRunId(runId);
-        body.setWorkflowId(workflowId);
+        body.setWorkflowId(wf.getId());
+        body.setWorkflowName(wf.getName());
+        body.setDisplayName(wf.getDisplayName());
 
-        // 1. 优先从 EventBus 取实时状态
         SseEventBus.RunStatus live = eventBus.getStatus(runId);
         if (live != null) {
             body.setStatus(live.status());
         }
 
-        // 2. 从数据库取持久化结果(RUNNING 时 outputs 为空)
         WorkflowRun record = findRunRecord(runId);
         if (record == null) {
-            // 仍在 CREATED 状态
             ExternalRunRegistry.CreatedRun created = runRegistry.peek(runId);
             if (created != null) {
                 body.setStatus("CREATED");
@@ -290,7 +288,6 @@ public class ExternalApiController {
         body.setError(record.getError());
         body.setOutputs(parseOutputs(record));
 
-        // 3. 节点列表
         List<WorkflowRunNode> nodes = workflowRunNodeRepository.findByRunIdOrderBySortOrderAsc(record.getId());
         List<NodeResult> nodeDtos = nodes.stream().map(n -> {
             NodeResult dto = new NodeResult();
@@ -301,13 +298,12 @@ public class ExternalApiController {
             dto.setOutput(parseJsonToMap(n.getOutput()));
             dto.setError(n.getError());
             dto.setLogsCount(countLogs(n.getLogs()));
-            dto.setLogsUrl("/api/v1/workflows/" + workflowId + "/runs/" + runId + "/nodes/" + n.getNodeId() + "/logs");
+            dto.setLogsUrl("/api/v1/workflows/" + wf.getName() + "/runs/" + runId + "/nodes/" + n.getNodeId() + "/logs");
             return dto;
         }).collect(Collectors.toList());
         body.setNodes(nodeDtos);
 
-        // 4. 工作空间信息
-        Path runDir = runDirManager.getRunDir(workflowId, runId);
+        Path runDir = runDirManager.getRunDir(wf.getId(), wf.getName(), runId);
         if (runDir != null) {
             try {
                 final int[] fileCount = {0};
@@ -321,7 +317,7 @@ public class ExternalApiController {
                 WorkspaceInfo ws = new WorkspaceInfo();
                 ws.setFileCount(fileCount[0]);
                 ws.setTotalBytes(totalBytes[0]);
-                ws.setDownloadUrl("/api/v1/workflows/" + workflowId + "/runs/" + runId + "/workspace");
+                ws.setDownloadUrl("/api/v1/workflows/" + wf.getName() + "/runs/" + runId + "/workspace");
                 body.setWorkspace(ws);
             } catch (IOException e) {
                 log.warn("[ExternalApi] 统计工作目录失败 runId={}: {}", runId, e.getMessage());
@@ -332,13 +328,17 @@ public class ExternalApiController {
     }
 
     // ============================================================
-    // GET /workflows/{id}/runs/{runId}/workspace — 下载工作空间 zip
+    // GET /workflows/{workflowName}/runs/{runId}/workspace
     // ============================================================
 
-    @GetMapping("/workflows/{workflowId}/runs/{runId}/workspace")
-    public ResponseEntity<Resource> downloadWorkspace(@PathVariable Long workflowId,
+    @GetMapping("/workflows/{workflowName}/runs/{runId}/workspace")
+    public ResponseEntity<Resource> downloadWorkspace(@PathVariable String workflowName,
                                                       @PathVariable String runId) throws IOException {
-        Path runDir = runDirManager.getRunDir(workflowId, runId);
+        Workflow wf = resolveWorkflow(workflowName);
+        if (wf == null) {
+            return ResponseEntity.notFound().build();
+        }
+        Path runDir = runDirManager.getRunDir(wf.getId(), wf.getName(), runId);
         if (runDir == null) {
             return ResponseEntity.notFound().build();
         }
@@ -360,7 +360,7 @@ public class ExternalApiController {
         }
 
         Resource resource = new UrlResource(zipPath.toUri());
-        String filename = URLEncoder.encode("workflow-" + workflowId + "-run-" + runId + ".zip", StandardCharsets.UTF_8);
+        String filename = URLEncoder.encode("workflow-" + wf.getName() + "-run-" + runId + ".zip", StandardCharsets.UTF_8);
         return ResponseEntity.ok()
                 .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename)
                 .contentType(MediaType.APPLICATION_OCTET_STREAM)
@@ -368,11 +368,11 @@ public class ExternalApiController {
     }
 
     // ============================================================
-    // GET /workflows/{id}/runs/{runId}/nodes/{nodeId}/logs — 节点日志
+    // GET /workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs
     // ============================================================
 
-    @GetMapping("/workflows/{workflowId}/runs/{runId}/nodes/{nodeId}/logs")
-    public ResponseEntity<?> getNodeLogs(@PathVariable Long workflowId,
+    @GetMapping("/workflows/{workflowName}/runs/{runId}/nodes/{nodeId}/logs")
+    public ResponseEntity<?> getNodeLogs(@PathVariable String workflowName,
                                          @PathVariable String runId,
                                          @PathVariable String nodeId) {
         WorkflowRun record = findRunRecord(runId);
@@ -421,9 +421,8 @@ public class ExternalApiController {
     @GetMapping("/workflows")
     public ResponseEntity<?> listWorkflows(@RequestAttribute(ExternalApiAuthFilter.ATTR_KEY_ENTRY) ExternalApiProperties.KeyEntry keyEntry) {
         List<Workflow> all = workflowRepository.findAllByOrderByUpdatedAtDesc();
-        // 过滤:API Key 白名单 + 必须包含 graphData
         List<Workflow> accessible = all.stream()
-                .filter(w -> properties.canAccess(keyEntry, w.getId()))
+                .filter(w -> properties.canAccess(keyEntry, w.getName(), w.getId()))
                 .filter(w -> w.getGraphData() != null && !w.getGraphData().isBlank())
                 .collect(Collectors.toList());
 
@@ -432,6 +431,7 @@ public class ExternalApiController {
             WorkflowSummary s = new WorkflowSummary();
             s.setId(w.getId());
             s.setName(w.getName());
+            s.setDisplayName(w.getDisplayName());
             s.setDescription(w.getDescription());
             try {
                 JsonNode root = MAPPER.readTree(w.getGraphData());
@@ -454,6 +454,28 @@ public class ExternalApiController {
     // 工具方法
     // ============================================================
 
+    /**
+     * 解析工作流:优先 findByName,回退 findById(兼容数字 id URL)。
+     */
+    private Workflow resolveWorkflow(String nameOrId) {
+        if (nameOrId == null || nameOrId.isEmpty()) return null;
+        return workflowRepository.findByName(nameOrId)
+                .orElseGet(() -> {
+                    if (nameOrId.matches("\\d+")) {
+                        return workflowRepository.findById(Long.valueOf(nameOrId)).orElse(null);
+                    }
+                    return null;
+                });
+    }
+
+    /** 判断 created run 是否与 pathVariable workflowName 匹配(name 或数字 id 兼容) */
+    private boolean matchesWorkflow(ExternalRunRegistry.CreatedRun created, String workflowName) {
+        if (created == null || workflowName == null) return false;
+        if (workflowName.equals(created.workflowName())) return true;
+        if (workflowName.matches("\\d+") && Long.valueOf(workflowName).equals(created.workflowId())) return true;
+        return false;
+    }
+
     private ResponseEntity<ApiError> error(int httpStatus, String code, String message) {
         return ResponseEntity.status(httpStatus).body(ApiError.error(code, message));
     }
@@ -472,7 +494,6 @@ public class ExternalApiController {
         }
     }
 
-    /** 根据字符串 runId 查找数据库 WorkflowRun(unique 索引) */
     private WorkflowRun findRunRecord(String runId) {
         return workflowRunRepository.findAllByOrderByStartedAtDesc().stream()
                 .filter(r -> runId.equals(r.getRunId()))
@@ -508,7 +529,6 @@ public class ExternalApiController {
         }
     }
 
-    /** 从 graphData 提取某类型节点的字段定义 */
     private List<FieldDef> extractFields(JsonNode root, String nodeType) {
         List<FieldDef> result = new ArrayList<>();
         JsonNode nodes = root.path("nodes");
@@ -516,15 +536,12 @@ public class ExternalApiController {
         for (JsonNode node : nodes) {
             if (!nodeType.equals(node.path("type").asText(""))) continue;
             JsonNode data = node.path("data");
-
-            // userInput 节点字段定义在 data.variables
             JsonNode varArr = data.path("variables");
             if (varArr.isArray()) {
                 for (JsonNode v : varArr) {
                     result.add(toFieldDef(v));
                 }
             }
-            // output 节点字段定义在 data.fields
             JsonNode fieldArr = data.path("fields");
             if (fieldArr.isArray()) {
                 for (JsonNode f : fieldArr) {
@@ -544,7 +561,6 @@ public class ExternalApiController {
         return f;
     }
 
-    /** zip 打包过滤:应用 .agentignore 规则(含祖先目录) */
     private boolean shouldIncludeInZip(Path runDir, Path p) {
         Path rel = runDir.relativize(p);
         String relStr = rel.toString().replace('\\', '/');

+ 3 - 3
backend/src/main/java/com/agent/management/external/ExternalRunRegistry.java

@@ -27,9 +27,9 @@ public class ExternalRunRegistry {
     /**
      * 注册一个新建的运行。
      */
-    public void register(String runId, Long workflowId, Map<String, Object> inputs, int ttlHours) {
+    public void register(String runId, Long workflowId, String workflowName, Map<String, Object> inputs, int ttlHours) {
         Instant expiresAt = Instant.now().plusSeconds(ttlHours * 3600L);
-        store.put(runId, new CreatedRun(workflowId, inputs, expiresAt));
+        store.put(runId, new CreatedRun(workflowId, workflowName, inputs, expiresAt));
     }
 
     /**
@@ -59,5 +59,5 @@ public class ExternalRunRegistry {
         return run;
     }
 
-    public record CreatedRun(Long workflowId, Map<String, Object> inputs, Instant expiresAt) {}
+    public record CreatedRun(Long workflowId, String workflowName, Map<String, Object> inputs, Instant expiresAt) {}
 }

+ 11 - 0
backend/src/main/java/com/agent/management/external/dto/ExternalDtos.java

@@ -34,6 +34,10 @@ public final class ExternalDtos {
     public static class CreateRunResponse {
         private String runId;
         private Long workflowId;
+        /** 工作流唯一标识(kebab-case),URL 路径使用此字段 */
+        private String workflowName;
+        /** 工作流中文显示名 */
+        private String displayName;
         private String status;
         private Instant createdAt;
         private Links links;
@@ -62,6 +66,10 @@ public final class ExternalDtos {
     public static class RunResultResponse {
         private String runId;
         private Long workflowId;
+        /** 工作流唯一标识(kebab-case) */
+        private String workflowName;
+        /** 工作流中文显示名 */
+        private String displayName;
         private String status;
         private Instant startedAt;
         private Instant completedAt;
@@ -121,7 +129,10 @@ public final class ExternalDtos {
     @Data
     public static class WorkflowSummary {
         private Long id;
+        /** kebab-case 唯一标识,URL 路径使用此字段 */
         private String name;
+        /** 中文显示名 */
+        private String displayName;
         private String description;
         private List<FieldDef> inputs;
         private List<FieldDef> outputs;

+ 10 - 0
backend/src/main/java/com/agent/management/model/entity/Workflow.java

@@ -9,8 +9,18 @@ public class Workflow {
 
     private Long id;
 
+    /**
+     * 唯一标识,必须为 kebab-case(仅小写字母、数字、短横线)。
+     * 对外暴露的标识符,URL 路由使用此字段。
+     * 旧数据迁移后默认值为 String.valueOf(id)(纯数字天然合法)。
+     */
     private String name;
 
+    /**
+     * 中文显示名称(参考 Skill 的 displayName 字段),可为任意字符。
+     */
+    private String displayName;
+
     private String description;
 
     /** 节点和边数据,JSON 格式存储 */

+ 7 - 0
backend/src/main/java/com/agent/management/model/entity/WorkflowRun.java

@@ -19,6 +19,13 @@ public class WorkflowRun {
     @Column(name = "workflow_id", nullable = false)
     private Long workflowId;
 
+    /**
+     * 工作流唯一标识(kebab-case name)冗余列,便于按 name 查询运行历史。
+     * 旧数据可能为 null,按 workflowId 反查 Workflow.name 补齐。
+     */
+    @Column(name = "workflow_name")
+    private String workflowName;
+
     @Column(name = "run_id", nullable = false, unique = true)
     private String runId;
 

+ 4 - 0
backend/src/main/java/com/agent/management/model/vo/WorkflowVO.java

@@ -13,7 +13,10 @@ import java.util.List;
 public class WorkflowVO {
 
     private Long id;
+    /** kebab-case 唯一标识,对外暴露 */
     private String name;
+    /** 中文显示名 */
+    private String displayName;
     private String description;
     private String graphData;
     private Long categoryId;
@@ -30,6 +33,7 @@ public class WorkflowVO {
         WorkflowVO vo = new WorkflowVO();
         vo.setId(wf.getId());
         vo.setName(wf.getName());
+        vo.setDisplayName(wf.getDisplayName());
         vo.setDescription(wf.getDescription());
         vo.setGraphData(wf.getGraphData());
         vo.setCategoryId(wf.getCategoryId());

+ 55 - 0
backend/src/main/java/com/agent/management/parser/WorkflowNameNormalizer.java

@@ -0,0 +1,55 @@
+package com.agent.management.parser;
+
+import com.agent.management.common.exception.BusinessException;
+
+/**
+ * 工作流名称(name)规范化工具,与 Skill 的 {@code SkillNameNormalizer} 对齐。
+ *
+ * <p>规范定义:工作流的唯一标识 name 必须为 kebab-case,
+ * 仅允许 {@code [a-z0-9-]},不得包含大写字母、空格、下划线、中文或其他特殊字符;
+ * 不得以短横线开头或结尾;不得包含连续短横线。
+ *
+ * <p>设计目的:作为工作流对外暴露的稳定标识符(URL 路由、外部 API、TagAssignment 等),
+ * 与 Skill 的 folderName/name 在语义和格式上保持一致。
+ * 可读性与本地化由 displayName 承载。
+ */
+public final class WorkflowNameNormalizer {
+
+    private WorkflowNameNormalizer() {}
+
+    private static final java.util.regex.Pattern KEBAB_CASE_PATTERN =
+            java.util.regex.Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
+
+    private static final int MAX_LENGTH = 64;
+
+    /** 判断字符串是否为合法 kebab-case 工作流名称 */
+    public static boolean isValidKebabCase(String input) {
+        return input != null && KEBAB_CASE_PATTERN.matcher(input).matches() && input.length() <= MAX_LENGTH;
+    }
+
+    /**
+     * 尽力规范化:转小写,将空格/下划线替换为短横线,合并连续短横线,去首尾短横线,
+     * 最后剔除非 ASCII 字母数字与短横线字符(中文、特殊符号等)。
+     */
+    public static String normalize(String input) {
+        if (input == null) return "";
+        String s = input.trim().toLowerCase();
+        s = s.replaceAll("[\\s_]+", "-");
+        s = s.replaceAll("-+", "-");
+        s = s.replaceAll("^-+|-+$", "");
+        s = s.replaceAll("[^a-z0-9-]", "");
+        return s;
+    }
+
+    /**
+     * 严格规范化:normalize + 校验,若结果非法则抛出 BusinessException。
+     */
+    public static String normalizeStrict(String input) {
+        String normalized = normalize(input);
+        if (!isValidKebabCase(normalized)) {
+            throw new BusinessException(
+                    "工作流名称必须为 kebab-case 格式(仅小写字母、数字、短横线,禁用大写/空格/下划线/中文): " + input);
+        }
+        return normalized;
+    }
+}

+ 67 - 0
backend/src/main/java/com/agent/management/repository/WorkflowRepository.java

@@ -1,15 +1,20 @@
 package com.agent.management.repository;
 
 import com.agent.management.model.entity.Workflow;
+import com.agent.management.parser.WorkflowNameNormalizer;
 import com.agent.management.repository.json.AbstractJsonRepository;
 import com.agent.management.repository.json.JsonStore;
 import com.fasterxml.jackson.core.type.TypeReference;
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Optional;
 
+@Slf4j
 @Component
 public class WorkflowRepository extends AbstractJsonRepository<Workflow> {
 
@@ -37,4 +42,66 @@ public class WorkflowRepository extends AbstractJsonRepository<Workflow> {
         list.sort(Comparator.comparing(w -> w.getUpdatedAt() == null ? java.time.LocalDateTime.MIN : w.getUpdatedAt(), Comparator.reverseOrder()));
         return list;
     }
+
+    /** 按 kebab-case 唯一标识查询 */
+    public Optional<Workflow> findByName(String name) {
+        if (name == null || name.isEmpty()) return Optional.empty();
+        return findAll().stream().filter(w -> name.equals(w.getName())).findFirst();
+    }
+
+    /** 唯一性校验:排除指定 id(更新场景传当前 id,新建场景传 null) */
+    public boolean existsByNameExcluding(String name, Long excludeId) {
+        if (name == null || name.isEmpty()) return false;
+        return findAll().stream()
+                .anyMatch(w -> name.equals(w.getName()) && !name.equals(String.valueOf(excludeId == null ? "" : excludeId))
+                        && (excludeId == null || !excludeId.equals(w.getId())));
+    }
+
+    /**
+     * 一次性数据迁移:将旧 name(任意字符串,多为中文)迁移为 kebab-case 唯一标识。
+     *
+     * <p>规则:
+     * <ul>
+     *   <li>若 name 为空或非 kebab-case:把原 name 移到 displayName,name 设为 String.valueOf(id)</li>
+     *   <li>若 displayName 为空但原 name 非空:displayName 设为 name</li>
+     * </ul>
+     */
+    @PostConstruct
+    private void migrateLegacyNames() {
+        // initMaxId() 由父类 @PostConstruct 触发,顺序不保证;显式调用一次保证 id 已分配
+        List<Workflow> all = loadAll();
+        if (all.isEmpty()) return;
+
+        boolean dirty = false;
+        for (Workflow w : all) {
+            String currentName = w.getName();
+            boolean currentValid = WorkflowNameNormalizer.isValidKebabCase(currentName);
+
+            if (!currentValid) {
+                // 旧数据:原 name 实际是中文显示名
+                if (w.getDisplayName() == null && currentName != null) {
+                    w.setDisplayName(currentName);
+                }
+                w.setName(String.valueOf(w.getId()));
+                dirty = true;
+                log.info("[Migration] 工作流 id={} 迁移:name='{}' → '{}',displayName='{}'",
+                        w.getId(), currentName, w.getName(), w.getDisplayName());
+            } else if (w.getDisplayName() == null) {
+                // 新规范但 displayName 缺失:回退为 name
+                w.setDisplayName(currentName);
+                dirty = true;
+            }
+        }
+
+        // 检测迁移后是否产生 name 重复(理论上不会,因为 String.valueOf(id) 唯一)
+        long distinctCount = all.stream().map(Workflow::getName).distinct().count();
+        if (distinctCount != all.size()) {
+            log.error("[Migration] 检测到工作流 name 重复!请人工检查 workflows.json");
+        }
+
+        if (dirty) {
+            writeAll(all);
+            log.info("[Migration] 工作流数据迁移完成,共更新 {} 条记录", all.size());
+        }
+    }
 }

+ 9 - 5
backend/src/main/java/com/agent/management/service/WorkflowService.java

@@ -7,14 +7,18 @@ import java.util.List;
 public interface WorkflowService {
     List<Workflow> listWorkflows();
 
-    Workflow getWorkflow(Long id);
+    /** 按 name(kebab-case)查询;若 name 是数字字符串且无 kebab-case 匹配,回退按 id 查询 */
+    Workflow getWorkflow(String nameOrId);
 
-    Workflow createWorkflow(String name, String description);
+    /** 仅按内部 Long id 查询(供引擎等内部模块使用) */
+    Workflow getWorkflowById(Long id);
 
-    Workflow updateWorkflow(Long id, String name, String description, String graphData);
+    Workflow createWorkflow(String name, String displayName, String description);
+
+    Workflow updateWorkflow(String nameOrId, String newName, String displayName, String description, String graphData);
 
     /** 更新工作流分类(categoryId 为 null 表示取消分类) */
-    Workflow updateCategory(Long id, Long categoryId);
+    Workflow updateCategory(String nameOrId, Long categoryId);
 
-    void deleteWorkflow(Long id);
+    void deleteWorkflow(String nameOrId);
 }

+ 26 - 7
backend/src/main/java/com/agent/management/service/WorkflowVersionService.java

@@ -4,23 +4,42 @@ import com.agent.management.model.vo.WorkflowVersionVO;
 
 import java.util.List;
 
+/**
+ * 工作流版本快照服务。
+ *
+ * <p>目录策略(方案C Q2 选项1):
+ * <ul>
+ *   <li>旧版本目录:以数字 id 命名,如 {@code workflow-versions/12/_versions/...},保留原样</li>
+ *   <li>新版本目录:以 name 命名,如 {@code workflow-versions/my-flow/_versions/...}</li>
+ *   <li>查询时优先尝试 name 目录,回退到 id 目录</li>
+ *   <li>已存在 id 目录的工作流,继续沿用 id 目录以保持历史一致</li>
+ * </ul>
+ */
 public interface WorkflowVersionService {
 
-    /** 创建快照(保存当前工作流数据为 v{n+1}) */
-    int createSnapshot(Long workflowId, String message);
+    /**
+     * 创建快照(保存当前工作流数据为 v{n+1})
+     *
+     * @param workflowId   工作流内部主键
+     * @param workflowName 工作流唯一标识(kebab-case),用于新目录命名
+     * @param graphData    图数据 JSON
+     * @param snapshotName 快照内嵌的 name 字段(工作流 displayName 等,仅供回滚时恢复)
+     * @param message      快照备注
+     */
+    int createSnapshot(Long workflowId, String workflowName, String graphData, String snapshotName, String message);
 
     /** 获取当前版本号(若从未创建版本则返回 0) */
-    int getCurrentVersion(Long workflowId);
+    int getCurrentVersion(Long workflowId, String workflowName);
 
     /** 列出所有版本 */
-    List<WorkflowVersionVO> listVersions(Long workflowId);
+    List<WorkflowVersionVO> listVersions(Long workflowId, String workflowName);
 
     /** 获取指定版本的 graphData JSON 内容 */
-    String getVersionContent(Long workflowId, int version);
+    String getVersionContent(Long workflowId, String workflowName, int version);
 
     /** 回滚到指定版本(将快照内容恢复到工作流,不创建新快照) */
-    void rollback(Long workflowId, int version);
+    void rollback(Long workflowId, String workflowName, int version);
 
     /** 更新版本备注 */
-    void updateVersionMessage(Long workflowId, int version, String message);
+    void updateVersionMessage(Long workflowId, String workflowName, int version, String message);
 }

+ 2 - 1
backend/src/main/java/com/agent/management/service/impl/EmbeddingBridgeClient.java

@@ -1,5 +1,6 @@
 package com.agent.management.service.impl;
 
+import com.agent.management.common.LogRedactor;
 import com.agent.management.common.exception.BusinessException;
 import com.agent.management.config.EmbeddingBridgeProperties;
 import com.fasterxml.jackson.core.type.TypeReference;
@@ -167,7 +168,7 @@ public class EmbeddingBridgeClient {
             // === 响应 DEBUG 日志 ===
             LLM_HTTP.debug("[Embed-RESP] HTTP {} ({}ms), body 长度={}",
                     resp.getStatusCode(), elapsed, respBody == null ? 0 : respBody.length());
-            LLM_HTTP.debug("[Embed-RESP] Body: {}", respBody);
+            LLM_HTTP.debug("[Embed-RESP] Body: {}", respBody == null ? null : LogRedactor.redactVectors(respBody));
             LLM_HTTP.debug("[Embed-RESP] ========== 请求结束 ==========");
 
             if (!resp.getStatusCode().is2xxSuccessful() || respBody == null) {

+ 41 - 14
backend/src/main/java/com/agent/management/service/impl/WorkflowServiceImpl.java

@@ -4,13 +4,16 @@ import com.agent.management.common.exception.BusinessException;
 import com.agent.management.engine.DagResolver;
 import com.agent.management.engine.NodeTypeUtils;
 import com.agent.management.model.entity.Workflow;
+import com.agent.management.parser.WorkflowNameNormalizer;
 import com.agent.management.repository.WorkflowRepository;
 import com.agent.management.service.WorkflowService;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import java.util.*;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class WorkflowServiceImpl implements WorkflowService {
@@ -23,15 +26,32 @@ public class WorkflowServiceImpl implements WorkflowService {
     }
 
     @Override
-    public Workflow getWorkflow(Long id) {
+    public Workflow getWorkflow(String nameOrId) {
+        Workflow wf = workflowRepo.findByName(nameOrId).orElse(null);
+        if (wf != null) return wf;
+        // 回退:若 nameOrId 是数字字符串,按 id 查找(兼容旧 URL/调用方)
+        if (nameOrId != null && nameOrId.matches("\\d+")) {
+            return workflowRepo.findById(Long.valueOf(nameOrId))
+                    .orElseThrow(() -> new BusinessException("工作流不存在: " + nameOrId));
+        }
+        throw new BusinessException("工作流不存在: " + nameOrId);
+    }
+
+    @Override
+    public Workflow getWorkflowById(Long id) {
         return workflowRepo.findById(id)
-                .orElseThrow(() -> new BusinessException("工作流不存在"));
+                .orElseThrow(() -> new BusinessException("工作流不存在: " + id));
     }
 
     @Override
-    public Workflow createWorkflow(String name, String description) {
+    public Workflow createWorkflow(String name, String displayName, String description) {
+        String normalizedName = WorkflowNameNormalizer.normalizeStrict(name);
+        if (workflowRepo.existsByNameExcluding(normalizedName, null)) {
+            throw new BusinessException("工作流名称已存在: " + normalizedName);
+        }
         Workflow wf = new Workflow();
-        wf.setName(name);
+        wf.setName(normalizedName);
+        wf.setDisplayName(displayName != null && !displayName.isBlank() ? displayName : normalizedName);
         wf.setDescription(description);
         wf.setGraphData("{\"nodes\":[],\"edges\":[]}");
         wf.onCreate();
@@ -39,9 +59,18 @@ public class WorkflowServiceImpl implements WorkflowService {
     }
 
     @Override
-    public Workflow updateWorkflow(Long id, String name, String description, String graphData) {
-        Workflow wf = getWorkflow(id);
-        if (name != null) wf.setName(name);
+    public Workflow updateWorkflow(String nameOrId, String newName, String displayName, String description, String graphData) {
+        Workflow wf = getWorkflow(nameOrId);
+        // name 处理:若提供则规范化 + 唯一性校验
+        if (newName != null && !newName.isBlank()) {
+            String normalized = WorkflowNameNormalizer.normalizeStrict(newName);
+            if (!normalized.equals(wf.getName())
+                    && workflowRepo.existsByNameExcluding(normalized, wf.getId())) {
+                throw new BusinessException("工作流名称已存在: " + normalized);
+            }
+            wf.setName(normalized);
+        }
+        if (displayName != null) wf.setDisplayName(displayName);
         if (description != null) wf.setDescription(description);
         if (graphData != null) {
             String normalized = NodeTypeUtils.normalizeGraphData(graphData);
@@ -156,18 +185,16 @@ public class WorkflowServiceImpl implements WorkflowService {
     }
 
     @Override
-    public Workflow updateCategory(Long id, Long categoryId) {
-        Workflow wf = getWorkflow(id);
+    public Workflow updateCategory(String nameOrId, Long categoryId) {
+        Workflow wf = getWorkflow(nameOrId);
         wf.setCategoryId(categoryId);
         wf.onUpdate();
         return workflowRepo.save(wf);
     }
 
     @Override
-    public void deleteWorkflow(Long id) {
-        if (!workflowRepo.existsById(id)) {
-            throw new BusinessException("工作流不存在");
-        }
-        workflowRepo.deleteById(id);
+    public void deleteWorkflow(String nameOrId) {
+        Workflow wf = getWorkflow(nameOrId);
+        workflowRepo.deleteById(wf.getId());
     }
 }

+ 47 - 27
backend/src/main/java/com/agent/management/service/impl/WorkflowVersionServiceImpl.java

@@ -35,15 +35,8 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
     // ==================== 公有方法 ====================
 
     @Override
-    public int createSnapshot(Long workflowId, String message) {
-        throw new UnsupportedOperationException("请使用 createSnapshot(Long, String, String, String)");
-    }
-
-    /**
-     * 创建快照(保存当前工作流数据为 v{n+1})
-     */
-    public int createSnapshot(Long workflowId, String graphData, String name, String message) {
-        Path versionBase = getVersionBase(workflowId);
+    public int createSnapshot(Long workflowId, String workflowName, String graphData, String snapshotName, String message) {
+        Path versionBase = getVersionBase(workflowId, workflowName);
         Path versionsDir = versionBase.resolve(VERSIONS_DIR);
 
         try {
@@ -64,7 +57,7 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
             Files.createDirectories(snapshotDir);
             SnapshotData snapshot = new SnapshotData();
             snapshot.graphData = NodeTypeUtils.normalizeGraphData(graphData);
-            snapshot.name = name;
+            snapshot.name = snapshotName;
             snapshot.savedAt = LocalDateTime.now().toString();
             jsonMapper.writeValue(snapshotDir.resolve(SNAPSHOT_FILE).toFile(), snapshot);
         } catch (IOException e) {
@@ -80,19 +73,19 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
         meta.currentVersion = newVersion;
         saveMeta(versionBase, meta);
 
-        log.info("Workflow [{}] 创建快照 v{}", workflowId, newVersion);
+        log.info("Workflow [id={}, name={}] 创建快照 v{}", workflowId, versionBase.getFileName(), newVersion);
         return newVersion;
     }
 
     @Override
-    public int getCurrentVersion(Long workflowId) {
-        Path versionBase = getVersionBase(workflowId);
+    public int getCurrentVersion(Long workflowId, String workflowName) {
+        Path versionBase = getVersionBase(workflowId, workflowName);
         return loadMeta(versionBase).currentVersion;
     }
 
     @Override
-    public List<WorkflowVersionVO> listVersions(Long workflowId) {
-        Path versionBase = getVersionBase(workflowId);
+    public List<WorkflowVersionVO> listVersions(Long workflowId, String workflowName) {
+        Path versionBase = getVersionBase(workflowId, workflowName);
         VersionMeta meta = loadMeta(versionBase);
 
         List<WorkflowVersionVO> result = new ArrayList<>();
@@ -108,8 +101,8 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
     }
 
     @Override
-    public String getVersionContent(Long workflowId, int version) {
-        Path versionBase = getVersionBase(workflowId);
+    public String getVersionContent(Long workflowId, String workflowName, int version) {
+        Path versionBase = getVersionBase(workflowId, workflowName);
         validateVersionExists(versionBase, version);
 
         Path snapshotFile = versionBase.resolve(VERSIONS_DIR)
@@ -126,20 +119,20 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
     }
 
     @Override
-    public void rollback(Long workflowId, int version) {
-        Path versionBase = getVersionBase(workflowId);
+    public void rollback(Long workflowId, String workflowName, int version) {
+        Path versionBase = getVersionBase(workflowId, workflowName);
         VersionMeta meta = loadMeta(versionBase);
         validateVersionExists(versionBase, version);
 
         meta.currentVersion = version;
         saveMeta(versionBase, meta);
 
-        log.info("Workflow [{}] 回滚到 v{}", workflowId, version);
+        log.info("Workflow [id={}, name={}] 回滚到 v{}", workflowId, versionBase.getFileName(), version);
     }
 
     @Override
-    public void updateVersionMessage(Long workflowId, int version, String message) {
-        Path versionBase = getVersionBase(workflowId);
+    public void updateVersionMessage(Long workflowId, String workflowName, int version, String message) {
+        Path versionBase = getVersionBase(workflowId, workflowName);
         VersionMeta meta = loadMeta(versionBase);
 
         VersionEntry entry = meta.versions.stream()
@@ -154,12 +147,34 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
     // ==================== 私有方法 ====================
 
     /**
-     * 获取工作流版本的根目录:{run-dir 的父目录}/workflow-versions/{workflowId}/
+     * 获取工作流版本的根目录(双轨制)。
+     *
+     * <p>解析顺序(方案C Q2 选项1):
+     * <ol>
+     *   <li>若 id 目录已存在 → 沿用 id 目录(保持历史快照一致)</li>
+     *   <li>否则若 name 目录已存在 → 使用 name 目录</li>
+     *   <li>否则用 name 创建新目录</li>
+     *   <li>name 为空时退化为 id 目录</li>
+     * </ol>
      */
-    private Path getVersionBase(Long workflowId) {
-        Path runPath = Paths.get(runDir).toAbsolutePath();
-        Path versionRoot = runPath.getParent().resolve("workflow-versions");
-        Path versionBase = versionRoot.resolve(String.valueOf(workflowId));
+    private Path getVersionBase(Long workflowId, String workflowName) {
+        Path versionRoot = getVersionRoot();
+        Path idBase = versionRoot.resolve(String.valueOf(workflowId));
+        Path nameBase = (workflowName != null && !workflowName.isBlank())
+                ? versionRoot.resolve(workflowName) : null;
+
+        Path versionBase;
+        if (Files.isDirectory(idBase)) {
+            // 旧目录已存在,继续沿用,避免快照历史被分裂
+            versionBase = idBase;
+        } else if (nameBase != null && Files.isDirectory(nameBase)) {
+            versionBase = nameBase;
+        } else if (nameBase != null) {
+            versionBase = nameBase;
+        } else {
+            versionBase = idBase;
+        }
+
         try {
             Files.createDirectories(versionBase);
         } catch (IOException e) {
@@ -168,6 +183,11 @@ public class WorkflowVersionServiceImpl implements WorkflowVersionService {
         return versionBase;
     }
 
+    private Path getVersionRoot() {
+        Path runPath = Paths.get(runDir).toAbsolutePath();
+        return runPath.getParent().resolve("workflow-versions");
+    }
+
     private void validateVersionExists(Path versionBase, int version) {
         Path vDir = versionBase.resolve(VERSIONS_DIR).resolve("v" + version);
         if (!Files.exists(vDir)) {

+ 52 - 0
backend/src/test/java/com/agent/management/common/LogRedactorTest.java

@@ -0,0 +1,52 @@
+package com.agent.management.common;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LogRedactorTest {
+
+    @Test
+    void redactVectors_abbreviatesLargeNumericArrays() {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < 1024; i++) {
+            if (i > 0) sb.append(",");
+            sb.append(String.format("%.4f", (i - 512) * 0.001));
+        }
+        sb.append("]");
+        String vec = sb.toString();
+
+        String result = LogRedactor.redactVectors("embedding: " + vec);
+        assertThat(result).contains("1024 个浮点数已省略");
+        assertThat(result).doesNotContain("0.0123");
+        assertThat(result.length()).isLessThan(vec.length() / 10);
+    }
+
+    @Test
+    void redactVectors_preservesShortArrays() {
+        assertThat(LogRedactor.redactVectors("coord: [1, 2, 3]")).isEqualTo("coord: [1, 2, 3]");
+        // 4 个数字仍低于阈值 8,保留原样
+        assertThat(LogRedactor.redactVectors("[0.1, 0.2, 0.3, 0.4]"))
+                .isEqualTo("[0.1, 0.2, 0.3, 0.4]");
+    }
+
+    @Test
+    void redactVectors_preservesJsonStructure() {
+        String input = "{\"vectors\":[[0.1,0.2]],\"model\":\"text-embedding\"}";
+        String output = LogRedactor.redactVectors(input);
+        assertThat(output).contains("\"model\":\"text-embedding\"");
+    }
+
+    @Test
+    void redactVectors_handlesNullAndEmpty() {
+        assertThat(LogRedactor.redactVectors(null)).isNull();
+        assertThat(LogRedactor.redactVectors("")).isEqualTo("");
+    }
+
+    @Test
+    void redactVectors_handlesNegativeAndScientificNotation() {
+        String vec = "[-1.5e-3, 2.3E+4, -0.5, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0]";
+        String result = LogRedactor.redactVectors(vec);
+        assertThat(result).contains("10 个浮点数已省略");
+    }
+}

+ 113 - 0
backend/src/test/java/com/agent/management/engine/NodeInputResolverTest.java

@@ -0,0 +1,113 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class NodeInputResolverTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private NodeWorkspace workspaceWith(Map<String, Map<String, Object>> scoped) {
+        NodeWorkspace ws = new NodeWorkspace();
+        for (Map.Entry<String, Map<String, Object>> e : scoped.entrySet()) {
+            ws.getScopedOutputs().put(e.getKey(), e.getValue());
+            for (Map.Entry<String, Object> f : e.getValue().entrySet()) {
+                ws.getVariables().put(e.getKey() + NodeWorkspaceBuilder.NODE_FIELD_SEPARATOR + f.getKey(), f.getValue());
+                ws.getVariables().put(f.getKey(), f.getValue());
+            }
+        }
+        return ws;
+    }
+
+    @Test
+    void explicitMappingResolvesScopedOutput() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of(
+                "nodeA", Map.of("result", "fromA")
+        ));
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"input1\",\"type\":\"string\",\"required\":true,\"mapping\":{\"sourceNodeId\":\"nodeA\",\"sourceField\":\"result\"}}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.hasMissing()).isFalse();
+        assertThat(resolved.getValues()).containsEntry("input1", "fromA");
+    }
+
+    @Test
+    void explicitMappingWithSourcePathExtractsNestedValue() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of(
+                "nodeA", Map.of("data", Map.of("total", 100))
+        ));
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"input1\",\"type\":\"number\",\"mapping\":{\"sourceNodeId\":\"nodeA\",\"sourceField\":\"data\",\"sourcePath\":\"total\"}}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.getValues()).containsEntry("input1", 100);
+    }
+
+    @Test
+    void unreachableExplicitMappingFallsBackToImplicit() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of(
+                "nodeA", Map.of("result", "fromA")
+        ));
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"result\",\"type\":\"string\",\"mapping\":{\"sourceNodeId\":\"nodeZ\",\"sourceField\":\"x\"}}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.getValues()).containsEntry("result", "fromA");
+    }
+
+    @Test
+    void implicitExactMatch() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of(
+                "nodeA", Map.of("result", "fromA")
+        ));
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"result\",\"type\":\"string\"}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.getValues()).containsEntry("result", "fromA");
+    }
+
+    @Test
+    void implicitNormalizedMatchIgnoresUnderscoreAndCase() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of(
+                "nodeA", Map.of("myResult", "fromA")
+        ));
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"my_result\",\"type\":\"string\"}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.getValues()).containsEntry("my_result", "fromA");
+    }
+
+    @Test
+    void implicitJsonPathExactMatch() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of(
+                "nodeA", Map.of("data", Map.of("nested", Map.of("value", 42)))
+        ));
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"data.nested.value\",\"type\":\"number\"}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.getValues()).containsEntry("data.nested.value", 42);
+    }
+
+    @Test
+    void missingRequiredInputsAreReported() throws Exception {
+        NodeWorkspace ws = workspaceWith(Map.of());
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"requiredInput\",\"type\":\"string\",\"required\":true}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.hasMissing()).isTrue();
+        assertThat(resolved.getMissing()).containsExactly("requiredInput");
+    }
+
+    @Test
+    void directVariableFallback() throws Exception {
+        NodeWorkspace ws = new NodeWorkspace();
+        ws.getVariables().put("initial", "direct");
+        JsonNode data = MAPPER.readTree("{\"inputs\":[{\"name\":\"initial\",\"type\":\"string\"}]}");
+
+        NodeInputResolver.ResolvedInputs resolved = NodeInputResolver.resolveInputs(data, ws);
+        assertThat(resolved.getValues()).containsEntry("initial", "direct");
+    }
+}

+ 49 - 0
backend/src/test/java/com/agent/management/engine/TemplateRendererTest.java

@@ -0,0 +1,49 @@
+package com.agent.management.engine;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class TemplateRendererTest {
+
+    @Test
+    void rendersFlatVariable() {
+        String result = TemplateRenderer.render("Hello {{name}}", Map.of("name", "World"));
+        assertThat(result).isEqualTo("Hello World");
+    }
+
+    @Test
+    void rendersNodeScopedVariableWithDotSyntax() {
+        Map<String, Object> vars = Map.of(
+                "nodeA__result", "value-from-A"
+        );
+        assertThat(TemplateRenderer.render("{{nodeA.result}}", vars)).isEqualTo("value-from-A");
+    }
+
+    @Test
+    void rendersNestedScopedVariable() {
+        Map<String, Object> vars = Map.of(
+                "nodeA__data", Map.of("total", 42)
+        );
+        assertThat(TemplateRenderer.render("{{nodeA.data.total}}", vars)).isEqualTo("42");
+    }
+
+    @Test
+    void keepsMissingPlaceholder() {
+        assertThat(TemplateRenderer.render("{{missing}}", Map.of())).isEqualTo("{{missing}}");
+    }
+
+    @Test
+    void returnsEmptyForNullOrEmptyTemplate() {
+        assertThat(TemplateRenderer.render(null, Map.of("x", 1))).isEmpty();
+        assertThat(TemplateRenderer.render("", Map.of("x", 1))).isEmpty();
+    }
+
+    @Test
+    void supportsLegacyDoubleUnderscoreKey() {
+        Map<String, Object> vars = Map.of("node_1__output", 99);
+        assertThat(TemplateRenderer.render("{{node_1__output}}", vars)).isEqualTo("99");
+    }
+}

+ 58 - 0
backend/src/test/java/com/agent/management/engine/VariableConverterTest.java

@@ -0,0 +1,58 @@
+package com.agent.management.engine;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VariableConverterTest {
+
+    @Test
+    void toStringConvertsPrimitivesAndCollections() {
+        assertThat(VariableConverter.toString(42)).isEqualTo("42");
+        assertThat(VariableConverter.toString(true)).isEqualTo("true");
+        assertThat(VariableConverter.toString(List.of(1, 2))).isEqualTo("[1,2]");
+    }
+
+    @Test
+    void toNumberExtractsDigits() {
+        assertThat(VariableConverter.toNumber(42)).isEqualTo(42);
+        assertThat(VariableConverter.toNumber("result: 3.14")).isEqualTo(3.14);
+        assertThat(VariableConverter.toNumber("-7")).isEqualTo(-7L);
+        assertThat(VariableConverter.toNumber("no digits")).isEqualTo(Double.NaN);
+    }
+
+    @Test
+    void toBooleanFollowsTruthyRules() {
+        assertThat(VariableConverter.toBoolean(true)).isTrue();
+        assertThat(VariableConverter.toBoolean(0)).isFalse();
+        assertThat(VariableConverter.toBoolean(List.of())).isFalse();
+        assertThat(VariableConverter.toBoolean("yes")).isTrue();
+    }
+
+    @Test
+    void toObjectParsesJsonAndWrapsPojo() {
+        RecordPojo pojo = new RecordPojo("foo", 1);
+        Map<String, Object> map = VariableConverter.toObject(pojo);
+        assertThat(map).containsEntry("name", "foo").containsEntry("value", 1);
+    }
+
+    @Test
+    void toArrayHandlesCollectionAndJsonString() {
+        assertThat(VariableConverter.toArray(List.of("a", "b"))).containsExactly("a", "b");
+        assertThat(VariableConverter.toArray("[1,2,3]")).containsExactly(1, 2, 3);
+        assertThat(VariableConverter.toArray("single")).containsExactly("single");
+    }
+
+    @Test
+    void convertDispatchesByType() {
+        assertThat(VariableConverter.convert("123", "number")).isEqualTo(123L);
+        assertThat((Boolean) VariableConverter.convert(1, "boolean")).isTrue();
+        assertThat(VariableConverter.convert("[1,2]", "array")).isInstanceOf(List.class);
+        assertThat(VariableConverter.convert(List.of(1, 2), "object")).isInstanceOf(Map.class);
+    }
+
+    public record RecordPojo(String name, int value) {}
+}

+ 58 - 0
backend/src/test/java/com/agent/management/engine/WorkflowScopeResolverTest.java

@@ -0,0 +1,58 @@
+package com.agent.management.engine;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class WorkflowScopeResolverTest {
+
+    private DagResolver.ResolvedDag createDag() {
+        DagResolver.ResolvedDag dag = new DagResolver.ResolvedDag();
+        dag.getLevels().add(java.util.List.of("A", "B"));
+        dag.getLevels().add(java.util.List.of("C"));
+        dag.getLevels().add(java.util.List.of("D"));
+
+        // A -> C, B -> C, C -> D
+        dag.getOutgoingEdges().put("A", java.util.List.of(new DagResolver.EdgeInfo("A", "C", "output")));
+        dag.getOutgoingEdges().put("B", java.util.List.of(new DagResolver.EdgeInfo("B", "C", "output")));
+        dag.getOutgoingEdges().put("C", java.util.List.of(new DagResolver.EdgeInfo("C", "D", "output")));
+        dag.getOutgoingEdges().put("D", java.util.List.of());
+
+        dag.getIncomingEdges().put("A", java.util.List.of());
+        dag.getIncomingEdges().put("B", java.util.List.of());
+        dag.getIncomingEdges().put("C", java.util.List.of(
+                new DagResolver.EdgeInfo("A", "C", "output"),
+                new DagResolver.EdgeInfo("B", "C", "output")));
+        dag.getIncomingEdges().put("D", java.util.List.of(new DagResolver.EdgeInfo("C", "D", "output")));
+        return dag;
+    }
+
+    @Test
+    void staticReachableIncludesAllPredecessors() {
+        DagResolver.ResolvedDag dag = createDag();
+        assertThat(WorkflowScopeResolver.getReachablePredecessors("D", dag))
+                .containsExactlyInAnyOrder("C", "B", "A");
+        assertThat(WorkflowScopeResolver.getReachablePredecessors("C", dag))
+                .containsExactlyInAnyOrder("A", "B");
+        assertThat(WorkflowScopeResolver.getReachablePredecessors("A", dag)).isEmpty();
+    }
+
+    @Test
+    void dynamicReachableFiltersByActiveEdges() {
+        DagResolver.ResolvedDag dag = createDag();
+        Set<String> active = Set.of(
+                WorkflowScopeResolver.activeEdgeKey("A", "output", "C"),
+                WorkflowScopeResolver.activeEdgeKey("C", "output", "D")
+        );
+        assertThat(WorkflowScopeResolver.getReachablePredecessors("D", dag, active))
+                .containsExactlyInAnyOrder("C", "A");
+    }
+
+    @Test
+    void activeEdgeKeyNormalizesNullHandle() {
+        assertThat(WorkflowScopeResolver.activeEdgeKey("A", null, "B")).isEqualTo("A-output-B");
+        assertThat(WorkflowScopeResolver.activeEdgeKey("A", "branch-1", "B")).isEqualTo("A-branch-1-B");
+    }
+}

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

@@ -0,0 +1,317 @@
+# 工作流节点自动输入输出与变量转换规范
+
+> 说明:本文档基于当前代码实现梳理,覆盖后端执行器(`backend/src/main/java/com/agent/management/engine/executor`)与前端 IO 推断逻辑(`frontend/src/utils/ioInference.js`)。
+> 文中“转换器”一栏既描述当前运行时的实际行为,也给出推荐的统一转换规则,供后续在 `TemplateRenderer` / `ContextPromptHelper` 或前置条件校验中实现。
+
+---
+
+## 1. 术语与上下文机制
+
+### 1.1 变量存储
+
+工作流上下文 `WorkflowContext` 内部维护一张**扁平变量表** `variables: ConcurrentHashMap<String, Object>`:
+
+- 运行开始时,把 `request.inputs` 全部放入。
+- 每个节点执行完成后,`WorkflowLevelExecutor` 调用 `context.setNodeOutput(nodeId, output)`,把 `output` 中的每个 key-value 写入 `variables`。
+- 同名 key 会覆盖上游值,并打印 WARN 日志。
+
+### 1.2 变量消费方式
+
+| 消费方式 | 触发位置 | 当前行为 |
+|---------|---------|---------|
+| `{{变量名}}` 模板渲染 | `TemplateRenderer` | 从 `variables` 取值后调用 `value.toString()` 替换;变量不存在时保留占位符。 |
+| 自动系统提示注入 | `ContextPromptHelper` | 把全部 variables 按 `- key:value` 格式拼接为系统提示,value 同样走 `toString()`,超长截断至 2000 字符。 |
+| 前置条件校验 | `WorkflowLevelExecutor.checkPreconditions` | 仅校验存在性与类型兼容性,不做类型转换。 |
+| Agent / Skill 用户消息 | `AgentExecutor.buildUserMessage` / `HermesAgentExecutor.buildUserMessage` | 按 Skill.inputs 定义取值后 `toString()` 拼接。 |
+| 条件分支判断 | `ConditionExecutor` | 把全部 variables 的 `toString()` 拼成上下文摘要,交给 LLM 判断分支编号。 |
+
+### 1.3 类型系统
+
+前后端统一的 IO 类型定义:
+
+```
+string | number | boolean | array | object | filePath | directoryPath
+```
+
+来源:
+
+- 后端:`com.agent.management.model.dto.IOField`
+- 前端:`frontend/src/utils/ioInference.js` 的 `IO_TYPES`
+
+---
+
+## 2. 节点类型总览
+
+| 节点 type | 中文名 | 后端执行器 | 前端组件 |
+|----------|--------|-----------|---------|
+| `userInput` | 用户输入 | `UserInputExecutor` | `InputNode.vue` |
+| `llm` | 大模型处理 | `LlmExecutor` | `LLMNode.vue` |
+| `agent` | 智能体 | `AgentExecutor`(hermes=false)/ `HermesAgentExecutor`(hermes=true) | `AgentNode.vue` |
+| `skill` | 技能 | `SkillExecutor`(委托给 agent 执行器) | `SkillNode.vue` |
+| `smartAction` | 智能操作 | `SmartActionExecutor`(hermes=false)/ `HermesSmartActionExecutor`(hermes=true) | `SmartActionNode.vue` |
+| `knowledgeRetrieval` | 知识库检索 | `KnowledgeRetrievalExecutor` | `KnowledgeRetrievalNode.vue` |
+| `condition` | 条件分支 | `ConditionExecutor` | `ConditionNode.vue` |
+| `output` | 输出 | `OutputExecutor` | `OutputNode.vue` |
+
+---
+
+## 3. 各节点自动输入输出字段
+
+### 3.1 userInput(用户输入节点)
+
+**自动输入**:无。节点本身只负责把运行时已经注入到 `variables` 中的初始输入值注册为可用变量。
+
+**自动生成输出**:
+
+| 输出字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `variables[i].name` | 由前端声明的类型决定 | `request.inputs` 中同名 key | 节点 data.variables 数组里每个条目的 name 字段;若运行时未提供该 key,则不写入。 |
+
+> 例如:data.variables = `[{name:"question", type:"string"}]`,则输出变量名为 `question`。
+
+---
+
+### 3.2 llm(大模型节点)
+
+**自动输入**:
+
+| 输入字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `systemPrompt` 中的 `{{var}}` | string | 上游 variables | 模板渲染后作为系统提示。 |
+| `userPrompt` 中的 `{{var}}` | string | 上游 variables | 模板渲染后作为用户提示。 |
+| `data.inputs[]` 声明的字段 | 任意 | 上游 variables | 用于前端边映射校验与 `checkPreconditions` 前置条件校验。 |
+| 全部上游 variables | object/map | 上下文 | 通过 `ContextPromptHelper.merge(...)` 自动追加到系统提示词末尾。 |
+
+**自动生成输出**:
+
+| 输出字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `outputs[0].name` 或 `result` | string | LLM 响应文本 | 单输出场景。若 `data.outputs` 为空,默认变量名为 `result`。 |
+| `outputs[i].name`(i≥0) | 由 outputs[i].type 决定 | LLM 响应经 JSON 解析 | 多输出场景(`outputs` 长度 > 1):由 `StructuredOutputHelper` 注入 JSON 指令并解析。 |
+
+多输出字段类型转换见第 4 节。
+
+---
+
+### 3.3 agent / skill(智能体 / 技能节点)
+
+两者对外接口一致;`SkillExecutor` 把 `skillId` 复制为 `agentId` 后委托给 agent 执行器。
+
+**自动输入**:
+
+| 输入字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| 关联 Skill 的 `inputs[]` | 由 Skill 声明 | 上游 variables | `buildUserMessage` 按 Skill 输入定义从 context 取值。 |
+| 全部上游 variables | object/map | 上下文 | 通过 `ContextPromptHelper.merge(...)` 追加到 SKILL.md 系统提示后。 |
+
+> 注意:当前 agent/skill 节点的前端默认数据中没有 `inputs/outputs` 数组,依赖 Skill 元数据中的 `inputs`/`outputs`。
+
+**自动生成输出**:
+
+| 输出字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `outputs[0].name` 或 `result` | string | Agent 执行返回的 finalText | 当前仅支持单输出,多 outputs 时也只取第一个。 |
+
+---
+
+### 3.4 smartAction(智能操作节点)
+
+**自动输入**:
+
+| 输入字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `actionPrompt` 中的 `{{var}}` | string | 上游 variables | 模板渲染后作为操作要求。 |
+| `data.inputs[]` 声明的字段 | 任意 | 上游 variables | 用于前端边映射校验与前置条件校验。 |
+| 全部上游 variables | object/map | 上下文 | 通过 `ContextPromptHelper` 自动注入系统提示。 |
+
+**自动生成输出**:
+
+| 输出字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `outputs[0].name` 或 `result` | string | LLM / Hermes 执行结果 | 单输出场景,默认变量名 `result`。 |
+
+---
+
+### 3.5 knowledgeRetrieval(知识库检索节点)
+
+**自动输入**:
+
+| 输入字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `query` 中的 `{{var}}` | string | 上游 variables | 模板渲染后作为检索语句。 |
+| `data.inputs[]` 声明的字段 | 任意 | 上游 variables | 用于前端边映射校验与前置条件校验。 |
+| `source` / `topK` / 各类 sourceIds | 配置项 | 节点 data | 决定检索范围与来源类型。 |
+
+**自动生成输出**:
+
+| 输出字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `evidences` | `List<RagEvidence>` | 检索结果 | 命中的证据列表,每个元素含 `id / sourceType / evidenceType / title / content / score / sourceName / sourceRef / payload / metadata`。 |
+| `evidenceCount` | number | 检索结果 | `evidences.size()`。 |
+| `sourceType` | string | 检索结果 | `DOCUMENT` / `STRUCTURED_DATA` / `GRAPH` / `HYBRID`。 |
+| `diagnostics` | object | 检索结果 | 各来源的诊断信息,key 可能为 `datasource:<id>` / `graph:<id>` / `error:...`。 |
+| `knowledgeBaseId` | number | 节点 data(仅 hybrid) | 混合检索时指定的知识库 ID。 |
+| `enabledSources` | object/map | 后端结果(仅 hybrid) | 各来源是否启用。 |
+
+> 默认输出变量:若用户未在 `data.outputs` 中自定义,前端默认暴露 `evidences / evidenceCount / sourceType / diagnostics`。
+
+---
+
+### 3.6 condition(条件分支节点)
+
+**自动输入**:
+
+| 输入字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `conditions[i].expression` 中的 `{{var}}` | string | 上游 variables | 条件表达式模板。 |
+| `data.inputs[]` 声明的字段 | 任意 | 上游 variables | 用于前端边映射校验。 |
+| 全部上游 variables | object/map | 上下文 | `ConditionExecutor` 把 variables 摘要后交给 LLM 判断分支。 |
+
+**自动生成输出**:
+
+条件节点**不向 `variables` 写入任何变量**。它通过 `NodeExecutionResult.selectedBranch` 返回分支句柄(如 `branch-0`、`branch-1`),由 `WorkflowLevelExecutor.activateDownstream` 按 `edge.sourceHandle` 过滤下游激活节点。
+
+---
+
+### 3.7 output(输出节点)
+
+**自动输入**:
+
+| 输入字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| `data.fields[]` 声明的字段 | 任意 | 上游 variables | 用于前端选择最终输出字段。 |
+| 全部上游 variables | object/map | 上下文 | 节点执行时直接收集整个 variables 表。 |
+
+**自动生成输出**:
+
+| 输出字段 | 类型 | 来源 | 说明 |
+|---------|------|------|------|
+| 全部上游 variables | 混合 | 上下文 | `OutputExecutor` 把 `context.getVariables()` 原样放入输出。 |
+| `_workingDirFiles` | array<string> | 工作目录扫描 | 工作目录中经 `.agentignore` 过滤后的相对文件路径列表。 |
+| `_runId` | string | 上下文 | 本次运行 ID。 |
+
+---
+
+## 4. 输出字段 → 下游输入字段转换器规范
+
+### 4.1 通用转换规则
+
+当前运行时**没有**按目标类型做自动转换的组件,统一走 `Object.toString()`。为支持工作流节点之间的类型安全连接,推荐引入以下转换器语义。
+
+#### 基础类型转换表
+
+| 源实际值 | 目标类型 string | 目标类型 number | 目标类型 boolean | 目标类型 object | 目标类型 array | 目标类型 filePath / directoryPath |
+|---------|----------------|----------------|-----------------|----------------|---------------|----------------------------------|
+| `String` | 原值 | 尝试 `Double.parseDouble`;失败取 `NaN` | `"true"`/`"1"`/`"yes"` 为 true,其余 false | 尝试 JSON 解析为对象;失败封装为 `{value: 原值}` | 尝试 JSON 解析为数组;失败封装为 `[原值]` | 若值对应工作目录中合法路径则通过;否则保留字符串 |
+| `Number` | `toString()` | 原值 | `!= 0` 为 true | `{value: 数字}` | `[数字]` | 不适用 |
+| `Boolean` | `toString()` | true→1,false→0 | 原值 | `{value: true/false}` | `[true/false]` | 不适用 |
+| `Map / POJO` | `ObjectMapper.writeValueAsString` | 若 map 仅含数值字段可尝试;否则 NaN | 非空 map 为 true | 原值 | `[Map.Entry]` 列表 | 不适用 |
+| `Collection / Array` | 拼接为字符串(推荐 JSON) | 长度值 | 非空为 true | 若长度为 1 取首元素;否则 `{items: 数组}` | 原值 | 不适用 |
+| `RagEvidence`(检索证据) | `content` 字段内容 | 无意义 | 非空为 true | 将 POJO 转为 Map(含 title/content/score...) | `[证据Map]` | 不适用 |
+
+#### 兼容性原则(与现有代码保持一致)
+
+前后端已有的类型兼容规则:
+
+- `filePath` / `directoryPath` / `number` / `boolean` → 可隐式转为 `string`。
+- `array` → 可隐式赋值给 `object`。
+- 其他组合视为不兼容。
+
+### 4.2 各节点输出字段的推荐转换器
+
+#### 4.2.1 LLM / Agent / SmartAction 的 `result`(string)
+
+| 下游目标类型 | 转换器行为 |
+|-------------|-----------|
+| `string` | 直接传递完整文本。 |
+| `number` | 尝试提取文本中的第一个数字;无数字返回 `NaN`。 |
+| `boolean` | 文本为 `"true"`/`"1"`/`"yes"` 返回 true; `"false"`/`"0"`/`"no"` 返回 false;其余按非空判断。 |
+| `object` | ① 尝试整段 JSON 解析为 Map;② 失败则取文本前 2000 字符封装为 `{result: text}`。 |
+| `array` | ① 尝试 JSON 解析为 List;② 失败则按行/段落拆分后封装为 `["line1", "line2", ...]`。 |
+
+#### 4.2.2 LLM 结构化多输出字段
+
+由 `StructuredOutputHelper.convertValue` 已提供基础转换:
+
+- `number`:`value.isNumber() ? value.numberValue() : Double.parseDouble(value.asText())`
+- `boolean`:`value.isBoolean() ? value.booleanValue() : Boolean.parseBoolean(value.asText())`
+- `array` / `object`:`MAPPER.treeToValue(value, Object.class)`
+- 默认:文本或 `toString()`
+
+#### 4.2.3 KnowledgeRetrieval 的 `evidences`(`List<RagEvidence>`)
+
+这是用户问题中重点示例的场景。
+
+| 下游目标类型 | 转换器行为 |
+|-------------|-----------|
+| `string` | 取 Top-1 证据的 `content` 字段;若列表为空返回 `""`。推荐可配置为 `"top1"` 或 `"concat"` 两种模式。 |
+| `number` | 返回 `evidenceCount`(即列表长度)。 |
+| `boolean` | 列表非空返回 true。 |
+| `object` | 取 Top-1 证据,将其字段(id/sourceType/evidenceType/title/content/score/sourceName/sourceRef/payload/metadata)转为 Map。 |
+| `array` | 取 Top-K(默认全部)证据列表,每条证据转为 Map。 |
+| `filePath` / `directoryPath` | 不适用,转换失败。 |
+
+#### 4.2.4 KnowledgeRetrieval 的 `evidenceCount`(number)
+
+| 下游目标类型 | 转换器行为 |
+|-------------|-----------|
+| `string` | `String.valueOf(count)` |
+| `number` | 原值 |
+| `boolean` | `count > 0` |
+| `object` | `{evidenceCount: count}` |
+| `array` | `[count]` |
+
+#### 4.2.5 KnowledgeRetrieval 的 `sourceType`(string)
+
+| 下游目标类型 | 转换器行为 |
+|-------------|-----------|
+| `string` | 原值,如 `"DOCUMENT"` / `"GRAPH"` |
+| `object` | `{sourceType: value}` |
+| `array` | `[value]` |
+
+#### 4.2.6 KnowledgeRetrieval 的 `diagnostics`(Map)
+
+| 下游目标类型 | 转换器行为 |
+|-------------|-----------|
+| `string` | `ObjectMapper.writeValueAsString(diagnostics)` |
+| `object` | 原 Map |
+| `array` | Map 的 entry 列表 `[{key, value}, ...]` |
+
+#### 4.2.7 userInput 变量(按声明类型)
+
+由于前端已声明类型,转换器按声明类型直接透传;若下游目标类型与声明类型不兼容,再按 4.1 通用规则转换。
+
+#### 4.2.8 Output 节点收集的 `_workingDirFiles`(array<string>)
+
+| 下游目标类型 | 转换器行为 |
+|-------------|-----------|
+| `string` | 取第一条路径;空数组返回 `""`。 |
+| `array` | 原数组 |
+| `object` | `{files: array}` |
+
+---
+
+## 5. 当前代码实现速查
+
+| 关注点 | 文件路径 |
+|-------|---------|
+| 节点执行器接口 | `backend/src/main/java/com/agent/management/engine/NodeExecutor.java` |
+| 执行器注册与调度 | `backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java` |
+| 上下文与变量存储 | `backend/src/main/java/com/agent/management/engine/WorkflowContext.java` |
+| `{{变量}}` 模板渲染 | `backend/src/main/java/com/agent/management/engine/TemplateRenderer.java` |
+| 上下文系统提示注入 | `backend/src/main/java/com/agent/management/engine/ContextPromptHelper.java` |
+| 单/多输出变量名解析 | `backend/src/main/java/com/agent/management/engine/NodeTypeUtils.java` |
+| LLM 结构化输出解析 | `backend/src/main/java/com/agent/management/engine/StructuredOutputHelper.java` |
+| 前置条件类型校验 | `backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java:294` |
+| 前端 IO 推断与映射 | `frontend/src/utils/ioInference.js` |
+| 前端节点默认数据 | `frontend/src/utils/workflowNode.js` |
+| 证据数据结构 | `backend/src/main/java/com/agent/management/rag/model/RagEvidence.java` |
+
+---
+
+## 6. 待实现建议
+
+1. **统一转换器注册表**:在 `TemplateRenderer` 或新增 `VariableConverter` 中按“源字段 + 目标类型”注册转换函数,替代目前简单的 `value.toString()`。
+2. **KnowledgeRetrieval `evidences` 转 string 策略可配置**:支持 `top1`、`concat`、`json` 等模式,满足不同下游节点需求。
+3. **前置条件校验增强**:在 `checkPreconditions` 中引入转换器尝试,使 array→object、number→string 等兼容组合真正可用,而非仅做类型检查。
+4. **前端类型提示**:在 `ioInference.js` 的 `isTypeCompatible` 基础上,增加转换器说明,让用户在连线和配置 mapping 时看到自动转换规则。

+ 390 - 0
docs/workflow-variable-scope-design.md

@@ -0,0 +1,390 @@
+# 工作流节点变量作用域与输入变量关联设计
+
+## 1. 背景与目标
+
+当前工作流引擎使用**扁平变量表** `WorkflowContext.variables` 传递节点输出:每个节点执行完成后,把输出 Map 中的 key-value 直接写入上下文,下游节点通过 `{{变量名}}` 模板或 `ContextPromptHelper` 自动注入来消费变量。
+
+该机制存在两个问题:
+
+1. **同名变量覆盖**:若多个前驱节点都输出同名变量(例如都叫 `result`),后执行的节点会覆盖前者,导致下游节点只能看到最近一个同名输出。
+2. **无法精确关联**:节点输入变量无法显式指定来源;隐式关联仅依赖同名匹配,不支持类型转换、JSON 嵌套路径、模糊匹配等场景。
+
+本设计目标:
+
+- 为每个节点构建**隔离的节点级工作区**,仅包含当前节点可达前驱节点的输出变量。
+- 所有前驱节点输出按 `节点ID.变量名` 命名空间化,避免同名覆盖。
+- 支持输入变量**显式关联**前驱输出,并自动按类型转换规则转换。
+- 支持四级**隐式关联 fallback**:精确同名 → 忽略分隔符/大小写同名 → JSON 嵌套路径同名 → JSON 嵌套路径模糊同名。
+
+---
+
+## 2. 核心概念
+
+### 2.1 节点工作区(NodeWorkspace)
+
+节点 `N` 开始执行前,引擎为其构建一个工作区:
+
+```
+NodeWorkspace(N) = {
+  variables: Map<String, Object>,        // 可直接通过 {{var}} 引用的扁平变量
+  scopedOutputs: Map<String, Map<String, Object>>,  // nodeId -> {varName -> value}
+  files: List<String>,                   // 工作空间文件列表
+  runId: String
+}
+```
+
+其中 `variables` 的构造规则:
+
+- 保留初始运行输入(`request.inputs`)。
+- 对 `N` 的每个可达前驱节点 `P`,把 `scopedOutputs[P]` 中的变量以 `P_varName` 形式注入(双下划线或点号分隔,见下文)。
+- 是否同时保留扁平变量(无节点前缀)作为向后兼容,通过 feature flag 控制。
+
+### 2.2 节点级命名空间
+
+在 `WorkflowContext` 中新增独立的节点输出存储:
+
+```java
+private final Map<String, Map<String, Object>> nodeScopedOutputs = new ConcurrentHashMap<>();
+```
+
+每个节点执行成功后:
+
+```java
+context.setNodeOutput(nodeId, output);        // 现有:扁平合并(保留兼容)
+context.setNodeScopedOutput(nodeId, output);  // 新增:命名空间隔离
+```
+
+### 2.3 变量引用语法
+
+为兼容现有 `{{varName}}` 模板,同时支持精确引用某个节点的输出,引入两种引用方式:
+
+| 语法 | 含义 | 示例 |
+|------|------|------|
+| `{{varName}}` | 引用当前工作区中的 `varName`(可能是扁平变量,也可能是最近同名变量) | `{{question}}` |
+| `{{nodeId.varName}}` | 引用指定节点 `nodeId` 的输出变量 `varName` | `{{node_2.result}}` |
+| `{{nodeId.var.path}}` | 引用指定节点输出变量的嵌套 JSON 路径 | `{{node_2.output.total.result}}` |
+
+> 注:`nodeId` 本身可能包含下划线,为消除歧义,内部实现使用 `__NODE__` 作为分隔符或采用 Map 结构,前端展示使用 `.` 语法。
+
+---
+
+## 3. 数据模型
+
+### 3.1 后端 WorkflowContext 扩展
+
+```java
+public class WorkflowContext {
+    private final Map<String, Object> variables = new ConcurrentHashMap<>();
+    private final Map<String, Map<String, Object>> nodeScopedOutputs = new ConcurrentHashMap<>();
+    private final List<NodeOutput> nodeOutputs = ...;
+    private final Path workingDir;
+    // ...
+
+    public void setNodeScopedOutput(String nodeId, Map<String, Object> output);
+    public Map<String, Object> getNodeScopedOutput(String nodeId);
+    public Map<String, Map<String, Object>> getAllNodeScopedOutputs();
+}
+```
+
+### 3.2 前端节点 data 扩展
+
+节点 `data.inputs[]` 中每个字段增加 `mapping`:
+
+```json
+{
+  "name": "result",
+  "type": "string",
+  "description": "",
+  "required": true,
+  "mapping": {
+    "sourceNodeId": "node_2",
+    "sourceField": "result",
+    "sourcePath": null,
+    "autoMapped": false
+  }
+}
+```
+
+当 `sourcePath` 非空时,表示从 `sourceField` 的 JSON 嵌套路径取值,例如 `output.total.result`。
+
+`autoMapped` 标记该关联是否由隐式关联算法自动产生,用户可覆盖。
+
+### 3.3 边 mapping 数据
+
+保留现有边 `data.mapping` 用于精确源→目标字段映射,同时新增 `autoResolved` 标记:
+
+```json
+{
+  "mapping": [
+    {"sourceField": "result", "targetField": "result"}
+  ],
+  "autoResolved": true
+}
+```
+
+---
+
+## 4. 算法设计
+
+### 4.1 可达前驱计算
+
+基于现有 `DagResolver.ResolvedDag` 增加反向邻接表:
+
+```java
+Map<String, List<EdgeInfo>> incomingEdges
+```
+
+静态可达前驱(规划阶段使用):
+
+```java
+Set<String> getReachablePredecessors(String nodeId) {
+    Set<String> visited = new HashSet<>();
+    Queue<String> queue = new LinkedList<>();
+    queue.add(nodeId);
+    while (!queue.isEmpty()) {
+        String cur = queue.poll();
+        for (EdgeInfo edge : incomingEdges.getOrDefault(cur, emptyList())) {
+            if (visited.add(edge.getSource())) {
+                queue.add(edge.getSource());
+            }
+        }
+    }
+    visited.remove(nodeId);
+    return visited;
+}
+```
+
+动态可达前驱(运行时,条件分支生效后):
+
+- 条件节点仅把实际激活的分支下游加入可达集。
+- 实现方式:`WorkflowLevelExecutor` 在节点执行后根据 `selectedBranch` 维护 `activeEdges`,动态可达集基于活跃边计算。
+
+### 4.2 节点工作区构建
+
+```java
+NodeWorkspace buildWorkspace(String currentNodeId, ResolvedDag dag, WorkflowContext context) {
+    NodeWorkspace ws = new NodeWorkspace();
+    ws.getVariables().putAll(context.getInitialInputs());
+
+    Set<String> reachable = dag.getReachablePredecessors(currentNodeId);
+    for (String nodeId : topologicalOrder(reachable)) {
+        Map<String, Object> scoped = context.getNodeScopedOutput(nodeId);
+        if (scoped == null) continue;
+        ws.getScopedOutputs().put(nodeId, scoped);
+        for (Map.Entry<String, Object> e : scoped.entrySet()) {
+            String scopedKey = nodeId + "__" + e.getKey();
+            ws.getVariables().put(scopedKey, e.getValue());
+            // 向后兼容:同时扁平注入(可选,受 feature flag 控制)
+            ws.getVariables().putIfAbsent(e.getKey(), e.getValue());
+        }
+    }
+
+    ws.setFiles(scanWorkingDir(context.getWorkingDir()));
+    return ws;
+}
+```
+
+### 4.3 输入变量解析算法
+
+对节点 `N` 的每个输入字段 `input`:
+
+```
+1. 若 input.mapping 存在且 sourceNodeId 属于 reachable(N):
+    value = resolvePath(scopedOutputs[sourceNodeId], sourceField, sourcePath)
+    return convert(value, input.type)
+
+2. 否则进入隐式关联:
+    candidates = 按拓扑逆序排列的 reachable(N) 中的前驱节点
+
+    2.1 精确同名:在 candidates 中查找第一个输出字段名 == input.name
+    2.2 模糊同名:忽略 [-_] 和大小写后匹配
+    2.3 JSON 嵌套精确同名:遍历 candidates 的所有输出变量,按 JSON Path 查找字段名 == input.name
+    2.4 JSON 嵌套模糊同名:忽略 [-_] 和大小写后匹配
+
+3. 若找到匹配:
+    记录 mapping = {sourceNodeId, sourceField, sourcePath, autoMapped: true}
+    return convert(value, input.type)
+
+4. 若未找到:
+    若 input.required 为 true,前置条件校验失败
+    否则返回 null
+```
+
+### 4.4 模糊匹配归一化
+
+```java
+static String normalizeForMatch(String s) {
+    return s.replaceAll("[-_]", "").toLowerCase(Locale.ROOT);
+}
+```
+
+### 4.5 JSON Path 遍历
+
+支持点号路径,数组使用数字下标:
+
+- `output.total.result` → `value["output"]["total"]["result"]`
+- `items.0.name` → `value["items"][0]["name"]`
+
+实现基于 Jackson `JsonNode` 或递归 Map,缺失路径返回 `null`。
+
+---
+
+## 5. 类型转换规则
+
+基于 `docs/workflow-node-fields.md` 实现 `VariableConverter`。
+
+### 5.1 通用转换
+
+| 源类型 | 目标 string | 目标 number | 目标 boolean | 目标 object | 目标 array |
+|--------|------------|-------------|--------------|-------------|-----------|
+| String | 原值 | parseDouble / NaN | true/false/1/0/yes/no | JSON parse / wrap | JSON parse / wrap |
+| Number | toString | 原值 | != 0 | wrap | wrap |
+| Boolean | toString | 1/0 | 原值 | wrap | wrap |
+| Map | JSON string | NaN | !empty | 原值 | entry list |
+| Collection | JSON string | size | !empty | firstOrWrap | 原值 |
+
+### 5.2 节点特定转换
+
+#### KnowledgeRetrieval.evidences(List<RagEvidence>)
+
+| 目标类型 | 行为 |
+|----------|------|
+| string | 取 Top-1 的 content(可配置 top1/concat/json) |
+| number | evidenceCount |
+| boolean | 非空 |
+| object | Top-1 证据 Map |
+| array | Top-K 证据 Map 列表 |
+
+#### LLM/Agent/SmartAction.result(String)
+
+| 目标类型 | 行为 |
+|----------|------|
+| string | 原值 |
+| number | 提取首个数字 / NaN |
+| boolean | true/false/1/0/yes/no / 非空 |
+| object | JSON parse / wrap |
+| array | JSON parse / lines split |
+
+---
+
+## 6. 接口设计
+
+### 6.1 新增 Java 类
+
+| 类名 | 职责 |
+|------|------|
+| `NodeWorkspace` | 节点工作区数据对象 |
+| `NodeWorkspaceBuilder` | 根据 DAG 和上下文构建工作区 |
+| `NodeInputResolver` | 解析节点输入变量,支持显式/隐式关联 |
+| `VariableConverter` | 类型转换器 |
+| `JsonPathExtractor` | JSON 嵌套路径取值 |
+| `WorkflowScopeResolver` | 可达前驱计算(静态+动态) |
+
+### 6.2 修改的 Java 类
+
+| 类名 | 修改点 |
+|------|--------|
+| `WorkflowContext` | 增加 `nodeScopedOutputs` 及相关方法 |
+| `DagResolver.ResolvedDag` | 增加 `incomingEdges` |
+| `WorkflowLevelExecutor` | 节点执行前构建工作区、解析输入;条件分支维护活跃边 |
+| `TemplateRenderer` | 支持 `{{nodeId.varName}}` 和 `{{nodeId.var.path}}` 语法 |
+| 各 Executor | 从 NodeWorkspace 读取输入,不再直接读扁平 variables |
+
+### 6.3 前端改动
+
+| 文件 | 修改点 |
+|------|--------|
+| `ioInference.js` | `getNodeInputs` 返回 mapping 字段;`inferEdgeMapping` 增加隐式关联逻辑 |
+| `WorkflowEditor.vue` | 边变化时刷新相关节点 input mapping |
+| 节点配置面板 | 每个 input 增加“关联变量”选择器 |
+| `workflowNode.js` | 默认 data 中 inputs 增加 mapping 字段 |
+
+---
+
+## 7. 兼容策略
+
+### 7.1 向后兼容
+
+- 保留 `WorkflowContext.variables` 扁平表,旧工作流不开启命名空间也能运行。
+- 新增 feature flag `workflow.node-scoped-variables.enabled`,默认 `false`。
+- 当 flag 关闭时:
+  - 仍然写入 `nodeScopedOutputs`(无影响)。
+  - `NodeWorkspaceBuilder` 仍然构造工作区,但 `variables` 同时保留扁平注入。
+  - 各执行器优先从工作区读取,未找到时 fallback 到扁平 variables。
+
+### 7.2 数据迁移
+
+- 旧 graphData 中的 `inputs` 没有 `mapping` 字段,解析时按 `null` 处理,走隐式关联。
+- 前端保存时自动补全 `mapping` 字段。
+
+### 7.3 条件分支
+
+- 静态规划阶段使用保守可达集(所有分支都视为可达)。
+- 运行时根据 `selectedBranch` 动态过滤,确保未激活分支的变量不进入下游工作区。
+
+---
+
+## 8. 测试策略
+
+### 8.1 单元测试
+
+- `WorkflowScopeResolverTest`:线性 DAG、分支 DAG、循环检测。
+- `NodeInputResolverTest`:四级隐式关联 fallback、显式 mapping、关联失效。
+- `VariableConverterTest`:通用转换表、RagEvidence 转换、LLM result 转换。
+- `JsonPathExtractorTest`:对象嵌套、数组下标、缺失路径。
+
+### 8.2 集成测试
+
+- 复杂 DAG 执行后,验证下游节点工作区只包含可达前驱变量。
+- 同名变量场景:多个前驱输出同名变量,下游通过 `nodeId.varName` 精确引用。
+
+### 8.3 回归测试
+
+- 旧工作流(无 mapping)执行结果与改造前一致。
+
+---
+
+## 9. 实现顺序建议
+
+为控制风险,建议按以下 MVP → 增强 → 完善的顺序实现:
+
+1. **MVP**:
+   - `WorkflowContext` 节点级输出存储
+   - `DagResolver` 反向索引
+   - `NodeWorkspace` 构建
+   - `VariableConverter` 通用转换
+   - `NodeInputResolver` 显式 mapping + 精确同名隐式关联
+   - `TemplateRenderer` 支持 `{{nodeId.varName}}`
+   - 修改 `WorkflowLevelExecutor` 和主要 Executor
+
+2. **增强**:
+   - 2.2 / 2.3 / 2.4 级隐式关联
+   - 动态条件分支可达集
+
+3. **完善**:
+   - 前端 mapping UI
+   - 自动保存 mapping
+   - feature flag 默认开启并移除兼容代码
+
+---
+
+## 10. 相关文档
+
+- `docs/workflow-node-fields.md`:节点自动输入输出字段与转换规则
+- `docs/workflow-scheduling-engine-plan.md`:工作流调度引擎设计
+
+# TODO
+
+☐ 将设计写入 docs/workflow-variable-scope-design.md
+☐ 阶段1:后端基础设施(WorkflowContext、DagResolver、NodeWorkspace、VariableConverter)
+☐ 阶段2:输入变量解析(NodeInputResolver、WorkflowLevelExecutor、各执行器)
+☐ 创建 NodeInputResolver(显式 mapping + 四级隐式 fallback)
+☐ 更新 TemplateRenderer 支持 {{nodeId.varName}} 语法
+☐ 更新 ContextPromptHelper 支持 NodeWorkspace / Map 变量
+☐ 更新 NodeExecutor 接口与各执行器使用 NodeWorkspace
+☐ 更新 NodeWorkspaceBuilder 为 Spring Bean 并支持 activeEdges
+☐ 更新 WorkflowLevelExecutor 构建工作区、解析输入、维护活跃边
+☐ 编译并修复错误
+☐ 阶段3:前端配置界面(data结构、ioInference、mapping UI)
+☐ 阶段4:兼容性与测试
+☐ 添加 NodeInputResolver / TemplateRenderer / VariableConverter / WorkflowScopeResolver 测试

+ 24 - 18
frontend/src/api/workflow.js

@@ -1,27 +1,33 @@
 import request from '../utils/request'
 
+/**
+ * 工作流 API。
+ *
+ * 路由策略(方案C):URL 路径参数统一使用 name(kebab-case);
+ * 旧数字 id 作为字符串传入时也可被后端 getWorkflow(nameOrId) 兜底解析。
+ */
 export function getWorkflows(params = {}) {
   return request.get('/workflows', { params })
 }
 
-export function setWorkflowCategory(id, categoryId) {
-  return request.put(`/workflows/${id}/category`, { categoryId })
+export function setWorkflowCategory(nameOrId, categoryId) {
+  return request.put(`/workflows/${nameOrId}/category`, { categoryId })
 }
 
-export function getWorkflow(id) {
-  return request.get(`/workflows/${id}`)
+export function getWorkflow(nameOrId) {
+  return request.get(`/workflows/${nameOrId}`)
 }
 
 export function createWorkflow(data) {
   return request.post('/workflows', data)
 }
 
-export function saveWorkflow(id, data) {
-  return request.post(`/workflows/${id}/save`, data)
+export function saveWorkflow(nameOrId, data) {
+  return request.post(`/workflows/${nameOrId}/save`, data)
 }
 
-export function deleteWorkflow(id) {
-  return request.post(`/workflows/${id}/delete`)
+export function deleteWorkflow(nameOrId) {
+  return request.post(`/workflows/${nameOrId}/delete`)
 }
 
 /**
@@ -30,14 +36,14 @@ export function deleteWorkflow(id) {
  * paths 为每个文件在工作目录中的相对路径(目录上传时保留目录结构)
  * 返回原生 fetch Response,需自行解析 SSE 流
  */
-export function runWorkflow(id, inputs, files = [], paths = []) {
+export function runWorkflow(nameOrId, inputs, files = [], paths = []) {
   const formData = new FormData()
   formData.append('inputs', JSON.stringify(inputs || {}))
   files.forEach((file, i) => {
     formData.append('files', file)
     formData.append('paths', paths[i] || file.name)
   })
-  return fetch(`/api/workflows/${id}/run`, {
+  return fetch(`/api/workflows/${nameOrId}/run`, {
     method: 'POST',
     body: formData
   })
@@ -52,18 +58,18 @@ export function autoAssociate(graphData) {
 
 // ==================== 版本管理 ====================
 
-export function listWorkflowVersions(id) {
-  return request.get(`/workflows/${id}/versions`)
+export function listWorkflowVersions(nameOrId) {
+  return request.get(`/workflows/${nameOrId}/versions`)
 }
 
-export function getWorkflowVersionContent(id, version) {
-  return request.get(`/workflows/${id}/versions/${version}/content`)
+export function getWorkflowVersionContent(nameOrId, version) {
+  return request.get(`/workflows/${nameOrId}/versions/${version}/content`)
 }
 
-export function rollbackWorkflowVersion(id, version) {
-  return request.post(`/workflows/${id}/versions/${version}/rollback`)
+export function rollbackWorkflowVersion(nameOrId, version) {
+  return request.post(`/workflows/${nameOrId}/versions/${version}/rollback`)
 }
 
-export function updateWorkflowVersionMessage(id, version, message) {
-  return request.put(`/workflows/${id}/versions/${version}/message`, { message })
+export function updateWorkflowVersionMessage(nameOrId, version, message) {
+  return request.put(`/workflows/${nameOrId}/versions/${version}/message`, { message })
 }

+ 24 - 35
frontend/src/components/skill/SkillForm.vue

@@ -119,16 +119,15 @@ function closeModal() {
 </script>
 
 <template>
-  <n-modal
-    :show="show"
-    @update:show="closeModal"
-  >
+  <n-modal :show="show" @update:show="closeModal">
     <div class="edit-modal">
       <!-- 头部 -->
       <div class="modal-header">
         <h3 class="modal-title">编辑技能</h3>
         <button class="close-btn" @click="closeModal">
-          <n-icon size="18"><CloseOutline /></n-icon>
+          <n-icon size="18">
+            <CloseOutline />
+          </n-icon>
         </button>
       </div>
 
@@ -136,22 +135,14 @@ function closeModal() {
       <div class="form-area">
         <div class="form-group">
           <label class="form-label">技能展示名称</label>
-          <n-input
-            v-model:value="form.displayName"
-            placeholder="展示用名称,可包含中文(如:军事工作流技能生成器)"
-            :maxlength="100"
-          />
+          <n-input v-model:value="form.displayName" placeholder="如:军事工作流技能生成器" :maxlength="100" />
         </div>
         <div class="form-group">
           <label class="form-label">
             技能名称
-            <span class="label-hint">(kebab-case,用作目录名,禁用大写/空格/下划线/中文)</span>
+            <span class="label-hint">(kebab-case,唯一标识,禁用大写/空格/下划线/中文)</span>
           </label>
-          <n-input
-            v-model:value="form.name"
-            placeholder="如:military-workflow-skill-creator"
-            :maxlength="100"
-          />
+          <n-input v-model:value="form.name" placeholder="如:military-workflow-skill-creator" :maxlength="100" />
           <div v-if="nameState.status === 'will-normalize'" class="name-hint name-warn">
             保存时将自动规范化为:<code>{{ nameState.normalized }}</code>
           </div>
@@ -164,14 +155,8 @@ function closeModal() {
         </div>
         <div class="form-group">
           <label class="form-label">技能描述</label>
-          <n-input
-            v-model:value="form.description"
-            type="textarea"
-            placeholder="请输入技能描述"
-            :rows="4"
-            :maxlength="500"
-            show-count
-          />
+          <n-input v-model:value="form.description" type="textarea" placeholder="请输入技能描述" :rows="4" :maxlength="500"
+            show-count />
         </div>
 
         <div class="form-group">
@@ -185,18 +170,22 @@ function closeModal() {
           <div class="section-header">
             <label class="form-label">前置数据</label>
             <n-button size="tiny" quaternary @click="addField(form.inputs)">
-              <template #icon><n-icon size="14"><AddOutline /></n-icon></template>
+              <template #icon><n-icon size="14">
+                  <AddOutline />
+                </n-icon></template>
               添加
             </n-button>
           </div>
           <div v-if="form.inputs.length" class="io-list">
-            <div v-for="(field, i) in form.inputs" :key="'in-'+i" class="io-row">
+            <div v-for="(field, i) in form.inputs" :key="'in-' + i" class="io-row">
               <n-input v-model:value="field.name" placeholder="字段名" size="small" style="width: 100px;" />
               <n-select v-model:value="field.type" :options="FIELD_TYPES" size="small" style="width: 100px;" />
               <n-input v-model:value="field.description" placeholder="描述" size="small" style="flex: 1;" />
               <n-checkbox v-model:checked="field.required" size="small">必填</n-checkbox>
               <button class="remove-btn" @click="removeField(form.inputs, i)">
-                <n-icon size="14"><TrashOutline /></n-icon>
+                <n-icon size="14">
+                  <TrashOutline />
+                </n-icon>
               </button>
             </div>
           </div>
@@ -208,18 +197,22 @@ function closeModal() {
           <div class="section-header">
             <label class="form-label">输出字段</label>
             <n-button size="tiny" quaternary @click="addField(form.outputs)">
-              <template #icon><n-icon size="14"><AddOutline /></n-icon></template>
+              <template #icon><n-icon size="14">
+                  <AddOutline />
+                </n-icon></template>
               添加
             </n-button>
           </div>
           <div v-if="form.outputs.length" class="io-list">
-            <div v-for="(field, i) in form.outputs" :key="'out-'+i" class="io-row">
+            <div v-for="(field, i) in form.outputs" :key="'out-' + i" class="io-row">
               <n-input v-model:value="field.name" placeholder="字段名" size="small" style="width: 100px;" />
               <n-select v-model:value="field.type" :options="FIELD_TYPES" size="small" style="width: 100px;" />
               <n-input v-model:value="field.description" placeholder="描述" size="small" style="flex: 1;" />
               <n-checkbox v-model:checked="field.required" size="small">必填</n-checkbox>
               <button class="remove-btn" @click="removeField(form.outputs, i)">
-                <n-icon size="14"><TrashOutline /></n-icon>
+                <n-icon size="14">
+                  <TrashOutline />
+                </n-icon>
               </button>
             </div>
           </div>
@@ -231,11 +224,7 @@ function closeModal() {
       <div class="modal-footer">
         <n-tooltip v-if="hasEnglishField" placement="top">
           <template #trigger>
-            <n-button
-              :loading="isTranslating"
-              @click="handleTranslate"
-              quaternary
-            >
+            <n-button :loading="isTranslating" @click="handleTranslate" quaternary>
               <template #icon>
                 <span style="font-size: 16px;">🌐</span>
               </template>

+ 4 - 4
frontend/src/composables/useNodeFields.js

@@ -38,8 +38,8 @@ export function useNodeFields({
       if (field === 'agentId') update.agentName = opt ? opt.label : ''
       else update.skillName = opt ? opt.label : ''
       const skill = skillStore.skills?.find(s => s.folderName === value)
-      update.inputs = skill?.inputs || []
-      update.outputs = skill?.outputs || []
+      update.inputs = (skill?.inputs || []).map(f => ({ ...f, mapping: f.mapping ?? null }))
+      update.outputs = (skill?.outputs || []).map(f => ({ ...f, mapping: f.mapping ?? null }))
     }
     vfUpdateNode(selectedNode.value.id, update)
     syncSelectedNode()
@@ -62,7 +62,7 @@ export function useNodeFields({
   // ========== 通用字段增删(适用于 LLM/Agent/Skill/Condition 的 inputs/outputs 列表) ==========
   function addListField(field) {
     if (!selectedData.value) return
-    const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
+    const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '', mapping: null }]
     vfUpdateNode(selectedNode.value.id, { [field]: list })
     syncSelectedNode()
   }
@@ -136,7 +136,7 @@ export function useNodeFields({
     const merged = [...existing]
     for (const name of extracted) {
       if (!existingNames.has(name)) {
-        merged.push({ name, label: name, type: 'string', description: '' })
+        merged.push({ name, label: name, type: 'string', description: '', mapping: null })
       }
     }
     const final = merged.filter(f => extracted.includes(f.name) || f.label !== f.name || existingNames.has(f.name))

+ 12 - 12
frontend/src/composables/useVersionHistory.js

@@ -24,11 +24,11 @@ export function useVersionHistory(onRollbackApply) {
   const editingMessage = ref(false)
   const editingMessageText = ref('')
 
-  async function loadVersions(workflowId) {
-    if (!workflowId) return
+  async function loadVersions(nameOrId) {
+    if (!nameOrId) return
     versionsLoading.value = true
     try {
-      const res = await listWorkflowVersions(workflowId)
+      const res = await listWorkflowVersions(nameOrId)
       versions.value = (res.data || []).reverse()
     } catch {
       message.error('加载版本历史失败')
@@ -37,34 +37,34 @@ export function useVersionHistory(onRollbackApply) {
     }
   }
 
-  async function toggleVersionPanel(workflowId) {
+  async function toggleVersionPanel(nameOrId) {
     showVersionPanel.value = !showVersionPanel.value
     if (showVersionPanel.value) {
-      await loadVersions(workflowId)
+      await loadVersions(nameOrId)
     } else {
       selectedVersion.value = null
       versionContent.value = ''
     }
   }
 
-  async function handleVersionSelect(workflowId, v) {
+  async function handleVersionSelect(nameOrId, v) {
     selectedVersion.value = v
     try {
-      const res = await getWorkflowVersionContent(workflowId, v.number)
+      const res = await getWorkflowVersionContent(nameOrId, v.number)
       versionContent.value = res.data || ''
     } catch {
       versionContent.value = '加载失败'
     }
   }
 
-  async function handleRollback(workflowId, v, applyGraph) {
+  async function handleRollback(nameOrId, v, applyGraph) {
     try {
-      const res = await rollbackWorkflowVersion(workflowId, v.number)
+      const res = await rollbackWorkflowVersion(nameOrId, v.number)
       const wf = res.data
       if (wf && typeof applyGraph === 'function') {
         await applyGraph(wf)
       }
-      await loadVersions(workflowId)
+      await loadVersions(nameOrId)
       message.success('已回滚到 v' + v.number)
     } catch {
       message.error('回滚失败')
@@ -76,9 +76,9 @@ export function useVersionHistory(onRollbackApply) {
     editingMessageText.value = v.message || ''
   }
 
-  async function saveMessage(workflowId, v) {
+  async function saveMessage(nameOrId, v) {
     try {
-      await updateWorkflowVersionMessage(workflowId, v.number, editingMessageText.value)
+      await updateWorkflowVersionMessage(nameOrId, v.number, editingMessageText.value)
       v.message = editingMessageText.value
       editingMessage.value = false
     } catch {

+ 16 - 5
frontend/src/composables/useWorkflowRunner.js

@@ -9,7 +9,7 @@ import { runWorkflow } from '../api/workflow'
  * @param {Object} options
  * @param {import('vue').Ref} options.getNodes 画布节点(用于按 nodeId 找节点 + 设置 class)
  * @param {Object} options.nodeTypeInfo 节点类型 → { color, typeLabel } 映射
- * @param {import('vue').Ref} options.workflowIdRef 当前工作流 ID
+ * @param {import('vue').Ref} options.workflowIdRef 当前工作流 name 或 id(仅用于 lastRunWorkflowId 记录)
  */
 export function useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef }) {
   const message = useMessage()
@@ -40,11 +40,11 @@ export function useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef }) {
 
   /**
    * 启动工作流运行
-   * @param {number} workflowId
+   * @param {string|number} nameOrId 工作流 name(kebab-case)或旧数字 id
    * @param {Object} inputs 表单输入值
    * @param {Array<{file:File, relativePath:string}>} uploadFiles 上传文件列表
    */
-  async function startRun(workflowId, inputs, uploadFiles) {
+  async function startRun(nameOrId, inputs, uploadFiles) {
     showRunDialog.value = false
     running.value = true
     showResult.value = false
@@ -58,7 +58,7 @@ export function useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef }) {
 
     try {
       const response = await runWorkflow(
-        workflowId,
+        nameOrId,
         inputs,
         uploadFiles.map(f => f.file),
         uploadFiles.map(f => f.relativePath)
@@ -110,7 +110,9 @@ export function useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef }) {
         label: node?.data?.label || '节点',
         status,
         output: event.output || existing?.output,
-        logs: event.logs || existing?.logs || [],
+        // 过滤掉 THINKING 类型日志:思考过程已通过 streamingThinking 单独聚合展示,
+        // 列表中再显示会造成内容重复(流式 token 拼接的多条 message 与完整思考重复)
+        logs: filterNonThinkingLogs(event.logs) || existing?.logs || [],
         // 节点执行后的完整 variables 快照(仅 SUCCESS 时由后端携带),用于 debug 视图
         contextSnapshot: event.contextSnapshot || existing?.contextSnapshot || null,
         // 保留此前累加的流式 thinking 内容(节点完成后仍可见思考历史)
@@ -200,6 +202,15 @@ export function useWorkflowRunner({ getNodes, nodeTypeInfo, workflowIdRef }) {
     return d.toLocaleTimeString('zh-CN', { hour12: false })
   }
 
+  /**
+   * 过滤掉 THINKING 类型日志。
+   * 思考过程已通过 streamingThinking 单独聚合展示,日志列表中再显示会造成内容重复。
+   */
+  function filterNonThinkingLogs(logs) {
+    if (!Array.isArray(logs)) return logs
+    return logs.filter(l => l.type !== 'THINKING')
+  }
+
   function toggleLogs(nodeId) {
     expandedLogs[nodeId] = !expandedLogs[nodeId]
   }

+ 1 - 1
frontend/src/router/index.js

@@ -30,7 +30,7 @@ const routes = [
     meta: { title: '智能体编排' }
   },
   {
-    path: '/orchestration/edit/:id?',
+    path: '/orchestration/edit/:name?',
     name: 'WorkflowEditor',
     component: () => import('../views/workflow/WorkflowEditor.vue'),
     meta: { title: '编辑工作流' }

+ 8 - 8
frontend/src/stores/workflow.js

@@ -43,24 +43,24 @@ export const useWorkflowStore = defineStore('workflow', () => {
     }
   }
 
-  async function fetchOne(id) {
-    const { data } = await getWorkflow(id)
+  async function fetchOne(nameOrId) {
+    const { data } = await getWorkflow(nameOrId)
     current.value = data
     return data
   }
 
-  async function create(name, description) {
-    const { data } = await createWorkflow({ name, description })
+  async function create({ name, displayName, description }) {
+    const { data } = await createWorkflow({ name, displayName, description })
     return data
   }
 
-  async function save(id, payload) {
-    const { data } = await saveWorkflow(id, payload)
+  async function save(nameOrId, payload) {
+    const { data } = await saveWorkflow(nameOrId, payload)
     return data
   }
 
-  async function remove(id) {
-    await deleteWorkflow(id)
+  async function remove(nameOrId) {
+    await deleteWorkflow(nameOrId)
     fetchList()
   }
 

+ 1 - 1
frontend/src/utils/ioInference.js

@@ -34,7 +34,7 @@ export const EDGE_STATUS_STYLE = {
  */
 export function extractTemplateVariables(text) {
   if (!text) return []
-  const matches = text.matchAll(/\{\{\s*(\w+)\s*\}\}/g)
+  const matches = text.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)
   const seen = new Set()
   const result = []
   for (const m of matches) {

+ 29 - 0
frontend/src/utils/workflowName.js

@@ -0,0 +1,29 @@
+/**
+ * 工作流名称(name)规范化工具,对应后端 WorkflowNameNormalizer。
+ *
+ * 规范:kebab-case,仅允许 [a-z0-9-],不得包含大写/空格/下划线/中文;
+ * 不得以短横线开头/结尾;不得有连续短横线。
+ *
+ * 设计目的:name 同时是 URL 路由标识与运行目录名,必须稳定。
+ * 可读性由 displayName 承载。
+ */
+
+/**
+ * 尽力规范化:转小写,空格/下划线→短横线,合并连续短横线,去首尾短横线,
+ * 最后剔除非 [a-z0-9-] 字符(如中文)。
+ */
+export function normalizeKebabCase(input) {
+  if (!input) return ''
+  let s = String(input).trim().toLowerCase()
+  s = s.replace(/[\s_]+/g, '-')
+  s = s.replace(/-+/g, '-')
+  s = s.replace(/^-+|-+$/g, '')
+  // 剔除非 ASCII 字母数字与短横线(中文、特殊符号等)
+  s = s.replace(/[^a-z0-9-]/g, '')
+  return s
+}
+
+/** 判断是否为合法 kebab-case 工作流名称 */
+export function isValidKebabCase(input) {
+  return !!input && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(input) && input.length <= 64
+}

+ 8 - 8
frontend/src/views/ModelManagement.vue

@@ -340,14 +340,14 @@ async function savePriorities(reordered) {
         </span>
       </div>
       <div class="toolbar-right">
-        <button class="ghost-btn" @click="fetchModels" :disabled="loading">
-          <n-icon size="16"><ReloadOutline /></n-icon>
-          <span>刷新</span>
-        </button>
-        <button class="primary-btn" @click="openCreate">
-          <n-icon size="16"><AddOutline /></n-icon>
-          <span>新增模型</span>
-        </button>
+        <n-button size="small" @click="fetchModels" :disabled="loading">
+          <template #icon><n-icon><ReloadOutline /></n-icon></template>
+          刷新
+        </n-button>
+        <n-button size="small" type="primary" @click="openCreate">
+          <template #icon><n-icon><AddOutline /></n-icon></template>
+          新增模型
+        </n-button>
       </div>
     </div>
 

+ 8 - 42
frontend/src/views/SkillManagement.vue

@@ -292,14 +292,14 @@ function onSearchInput(value) {
             </span>
           </div>
           <div class="toolbar-right">
-            <button class="ghost-btn" @click="handleRefresh" :disabled="skillStore.loading">
-              <n-icon size="16"><ReloadOutline /></n-icon>
-              <span>刷新</span>
-            </button>
-            <button class="primary-btn" @click="showUpload = true">
-              <n-icon size="16"><AddOutline /></n-icon>
-              <span>新增技能</span>
-            </button>
+            <n-button size="small" @click="handleRefresh" :disabled="skillStore.loading">
+              <template #icon><n-icon><ReloadOutline /></n-icon></template>
+              刷新
+            </n-button>
+            <n-button size="small" type="primary" @click="showUpload = true">
+              <template #icon><n-icon><AddOutline /></n-icon></template>
+              新增技能
+            </n-button>
           </div>
         </div>
 
@@ -499,40 +499,6 @@ function onSearchInput(value) {
   background: rgba(37, 99, 235, 0.1);
 }
 
-/* 工具栏主按钮(与 ModelManagement/WorkflowList 统一风格) */
-.primary-btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 8px 16px;
-  background: var(--gradient-accent);
-  color: #fff;
-  border: none;
-  border-radius: var(--radius-sm);
-  font-size: 13px;
-  font-weight: 500;
-  cursor: pointer;
-  transition: all var(--transition-fast);
-}
-.primary-btn:hover { box-shadow: var(--glow-blue); }
-.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
-
-.ghost-btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 8px 16px;
-  background: transparent;
-  color: var(--text-secondary);
-  border: 1px solid var(--border-color);
-  border-radius: var(--radius-sm);
-  font-size: 13px;
-  cursor: pointer;
-  transition: all var(--transition-fast);
-}
-.ghost-btn:hover { color: var(--text-primary); border-color: var(--border-color-hover); }
-.ghost-btn:disabled { opacity: 0.5; cursor: not-allowed; }
-
 .root-adding {
   padding: 6px 16px;
 }

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

@@ -529,8 +529,8 @@ onMounted(() => {
 }
 
 .icon-btn:hover {
-  background: rgba(37, 99, 235, 0.1);
-  color: var(--color-accent);
+  background: rgba(255, 255, 255, 0.09);
+  color: var(--text-primary);
   border-color: var(--border-color-hover);
 }
 

+ 1 - 1
frontend/src/views/knowledge/DocumentManagement.vue

@@ -562,7 +562,7 @@ onUnmounted(() => {
               <DocumentTextOutline />
             </n-icon>
             <div class="empty-title">暂无文档</div>
-            <n-button size="small" type="primary" @click="showUploader = true">
+            <n-button size="small" type="success" @click="showUploader = true">
               <template #icon><n-icon>
                   <CloudUploadOutline />
                 </n-icon></template>

+ 1 - 1
frontend/src/views/knowledge/RagGovernance.vue

@@ -235,7 +235,7 @@ onMounted(loadProfile)
         </template>
         <template v-else><n-checkbox-group v-model:value="selectedTables"><n-collapse :default-expanded-names="['tables']"><n-collapse-item name="tables" :title="`Tables(${selectedTables.length}/${sql.tables.length})`"><n-collapse accordion><n-collapse-item v-for="table in sql.tables" :key="table.name" :name="`table:${table.name}`"><template #header><n-checkbox :value="table.name" :label="table.name" @click.stop /></template><div class="schema-detail"><p>{{ table.description || '无表说明' }}</p><n-space><n-tag v-for="column in table.columns" :key="column.name" size="small">{{ column.name }} · {{ column.type }}</n-tag></n-space></div></n-collapse-item></n-collapse></n-collapse-item></n-collapse></n-checkbox-group></template>
         <h3>生成规则(每行一条)</h3><n-input v-model:value="rulesText" type="textarea" :rows="5" placeholder="只生成只读查询&#10;优先使用明确关系而非笛卡尔积" />
-        <n-button type="success" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
+        <n-button type="primary" block class="apply" :loading="loading.apply" @click="applyConfig">应用审核后的授权配置</n-button>
       </n-card>
 
       <n-card title="建议与画像" class="panel"><n-alert type="info" :show-icon="false">JSON 是建议草稿:suggestedConfig 会同步到左侧表单,点击“应用”后才生效;examples 需点击导入并逐条审核。</n-alert><n-tag v-if="suggestion" type="warning" class="apply">草稿 · 尚未应用</n-tag><n-button v-if="suggestion?.examples?.length" block class="apply" @click="importCandidates">导入为待审核 Few-shot</n-button><pre>{{ JSON.stringify(suggestion || { message: '点击“生成业务子图与案例”获取建议' }, null, 2) }}</pre></n-card>

+ 41 - 72
frontend/src/views/skill/SkillEdit.vue

@@ -615,7 +615,9 @@ onMounted(() => {
     <div class="edit-toolbar">
       <div class="toolbar-left">
         <n-button quaternary @click="goBack" class="back-btn">
-          <template #icon><n-icon><ArrowBackOutline /></n-icon></template>
+          <template #icon><n-icon>
+              <ArrowBackOutline />
+            </n-icon></template>
           返回
         </n-button>
         <span class="edit-title">{{ form.displayName || form.name || folderName }}</span>
@@ -624,7 +626,9 @@ onMounted(() => {
         <n-tooltip v-if="hasEnglishContent" placement="bottom">
           <template #trigger>
             <n-button :loading="isTranslating" @click="handleTranslate" quaternary>
-              <template #icon><n-icon size="16"><GlobeOutline /></n-icon></template>
+              <template #icon><n-icon size="16">
+                  <GlobeOutline />
+                </n-icon></template>
               翻译
             </n-button>
           </template>
@@ -632,21 +636,23 @@ onMounted(() => {
         </n-tooltip>
 
         <n-button quaternary @click="toggleVersionPanel" :type="showVersionPanel ? 'primary' : 'default'">
-          <template #icon><n-icon><TimeOutline /></n-icon></template>
+          <template #icon><n-icon>
+              <TimeOutline />
+            </n-icon></template>
           版本历史
         </n-button>
 
-        <n-button
-          :type="advancedMode ? 'warning' : 'default'"
-          quaternary
-          @click="toggleAdvancedMode"
-        >
-          <template #icon><n-icon><CodeSlashOutline /></n-icon></template>
+        <n-button :type="advancedMode ? 'warning' : 'default'" quaternary @click="toggleAdvancedMode">
+          <template #icon><n-icon>
+              <CodeSlashOutline />
+            </n-icon></template>
           {{ advancedMode ? '基础编辑' : '高级编辑' }}
         </n-button>
 
         <n-button type="primary" @click="handleSave" :loading="saving">
-          <template #icon><n-icon><SaveOutline /></n-icon></template>
+          <template #icon><n-icon>
+              <SaveOutline />
+            </n-icon></template>
           保存
         </n-button>
       </div>
@@ -670,18 +676,18 @@ onMounted(() => {
                   技能展示名称
                   <span class="label-hint">(可包含中文)</span>
                 </label>
-                <n-input v-model:value="form.displayName" placeholder="展示用名称" :maxlength="100" />
+                <n-input v-model:value="form.displayName" placeholder="如:军事工作流技能生成器" :maxlength="100" />
               </div>
               <div class="form-group" style="flex: 2;">
                 <label class="form-label">技能描述</label>
-                <n-input v-model:value="form.description" placeholder="技能描述" :maxlength="500" />
+                <n-input v-model:value="form.description" placeholder="请输入技能描述" :maxlength="500" />
               </div>
             </div>
 
             <div class="form-group">
               <label class="form-label">
                 技能名称
-                <span class="label-hint">(kebab-case,用作目录名,禁用大写/空格/下划线/中文)</span>
+                <span class="label-hint">(kebab-case,唯一标识,禁用大写/空格/下划线/中文)</span>
               </label>
               <n-input v-model:value="form.name" placeholder="如:military-workflow-skill-creator" :maxlength="100" />
               <div v-if="nameState.status === 'will-rename'" class="name-hint name-warn">
@@ -715,7 +721,7 @@ onMounted(() => {
                 </n-button>
               </div>
               <div v-if="form.inputs.length" class="io-list">
-                <div v-for="(field, i) in form.inputs" :key="'in-'+i" class="io-row">
+                <div v-for="(field, i) in form.inputs" :key="'in-' + i" class="io-row">
                   <n-input v-model:value="field.name" placeholder="字段名" size="small" style="width: 100px;" />
                   <n-select v-model:value="field.type" :options="IO_TYPES" size="small" style="width: 110px;" />
                   <n-input v-model:value="field.description" placeholder="描述" size="small" style="flex: 1;" />
@@ -736,7 +742,7 @@ onMounted(() => {
                 </n-button>
               </div>
               <div v-if="form.outputs.length" class="io-list">
-                <div v-for="(field, i) in form.outputs" :key="'out-'+i" class="io-row">
+                <div v-for="(field, i) in form.outputs" :key="'out-' + i" class="io-row">
                   <n-input v-model:value="field.name" placeholder="字段名" size="small" style="width: 100px;" />
                   <n-select v-model:value="field.type" :options="IO_TYPES" size="small" style="width: 110px;" />
                   <n-input v-model:value="field.description" placeholder="描述" size="small" style="flex: 1;" />
@@ -751,12 +757,7 @@ onMounted(() => {
           <!-- 正文编辑器 -->
           <div class="body-section">
             <label class="form-label" style="margin-bottom: 8px;">正文内容 (Markdown)</label>
-            <CodeEditor
-              v-model="body"
-              language="markdown"
-              :height="'calc(100vh - 520px)'"
-              :minimap="false"
-            />
+            <CodeEditor v-model="body" language="markdown" :height="'calc(100vh - 520px)'" :minimap="false" />
           </div>
         </div>
 
@@ -764,14 +765,8 @@ onMounted(() => {
         <div v-else class="advanced-editor">
           <!-- 文件树 -->
           <div class="file-tree-panel">
-            <FileTree
-              :files="fileTree"
-              :currentFile="currentFilePath"
-              @select="handleFileSelect"
-              @create-file="handleCreateFile"
-              @create-dir="handleCreateDir"
-              @delete="handleDeleteFile"
-            />
+            <FileTree :files="fileTree" :currentFile="currentFilePath" @select="handleFileSelect"
+              @create-file="handleCreateFile" @create-dir="handleCreateDir" @delete="handleDeleteFile" />
           </div>
 
           <!-- 代码编辑器 -->
@@ -780,14 +775,9 @@ onMounted(() => {
               <span class="file-path">{{ currentFilePath }}</span>
               <span v-if="currentFileDirty" class="dirty-indicator">●</span>
             </div>
-            <CodeEditor
-              v-if="currentFilePath"
-              v-model="currentFileContent"
-              :language="getLanguageFromPath(currentFilePath)"
-              :height="'calc(100vh - 140px)'"
-              :minimap="true"
-              @change="handleFileContentChange"
-            />
+            <CodeEditor v-if="currentFilePath" v-model="currentFileContent"
+              :language="getLanguageFromPath(currentFilePath)" :height="'calc(100vh - 140px)'" :minimap="true"
+              @change="handleFileContentChange" />
             <div v-else class="no-file-selected">
               <p>选择文件以编辑</p>
             </div>
@@ -808,15 +798,10 @@ onMounted(() => {
             <n-spin v-if="versionsLoading" size="small" />
             <n-empty v-else-if="!versions.length" description="暂无版本" size="small" />
             <div v-else class="version-items">
-              <div
-                v-for="v in versions"
-                :key="v.number"
-                class="version-item"
-                :class="{
-                  active: selectedVersion?.number === v.number,
-                  'diff-selected': diffVersion?.number === v.number
-                }"
-              >
+              <div v-for="v in versions" :key="v.number" class="version-item" :class="{
+          active: selectedVersion?.number === v.number,
+          'diff-selected': diffVersion?.number === v.number
+        }">
                 <div class="vi-main" @click="handleVersionSelect(v)">
                   <div class="vi-row">
                     <span class="vi-version">v{{ v.number }}<span v-if="v.current" class="vi-current">当前</span></span>
@@ -826,30 +811,17 @@ onMounted(() => {
                     {{ v.message || '双击编辑备注' }}
                   </div>
                   <div v-else class="vi-message-edit">
-                    <n-input
-                      v-model:value="editingMessageText"
-                      size="tiny"
-                      placeholder="版本备注"
-                      @keyup.enter="saveMessage(v)"
-                      @keyup.escape="cancelEditMessage"
-                    />
+                    <n-input v-model:value="editingMessageText" size="tiny" placeholder="版本备注"
+                      @keyup.enter="saveMessage(v)" @keyup.escape="cancelEditMessage" />
                     <n-button size="tiny" type="primary" @click="saveMessage(v)">✓</n-button>
                     <n-button size="tiny" @click="cancelEditMessage">✕</n-button>
                   </div>
                 </div>
                 <div class="vi-actions">
-                  <button
-                    v-if="selectedVersion && selectedVersion.number !== v.number"
-                    class="vi-action-btn diff-btn"
-                    @click.stop="handleDiffSelect(v)"
-                    title="与选中版本对比"
-                  >⟷</button>
-                  <button
-                    v-if="!v.current"
-                    class="vi-action-btn rollback-btn"
-                    @click.stop="handleRollback(v)"
-                    title="回滚到此版本"
-                  >↩</button>
+                  <button v-if="selectedVersion && selectedVersion.number !== v.number" class="vi-action-btn diff-btn"
+                    @click.stop="handleDiffSelect(v)" title="与选中版本对比">⟷</button>
+                  <button v-if="!v.current" class="vi-action-btn rollback-btn" @click.stop="handleRollback(v)"
+                    title="回滚到此版本">↩</button>
                 </div>
               </div>
             </div>
@@ -890,13 +862,8 @@ onMounted(() => {
     </div>
 
     <!-- 未保存确认(切换文件 / 切换模式共用) -->
-    <n-modal
-      v-model:show="unsavedDialog.show"
-      preset="dialog"
-      title="当前修改未保存"
-      :show-icon="false"
-      :mask-closable="false"
-    >
+    <n-modal v-model:show="unsavedDialog.show" preset="dialog" title="当前修改未保存" :show-icon="false"
+      :mask-closable="false">
       <span>
         {{ unsavedDialog.contextText }} 有未保存的修改,是否保存?
       </span>
@@ -1285,6 +1252,7 @@ onMounted(() => {
 .diff-btn {
   color: #f59e0b;
 }
+
 .diff-btn:hover {
   background: rgba(245, 158, 11, 0.15);
 }
@@ -1292,6 +1260,7 @@ onMounted(() => {
 .rollback-btn {
   color: var(--color-danger);
 }
+
 .rollback-btn:hover {
   background: rgba(239, 68, 68, 0.15);
 }

+ 1 - 1
frontend/src/views/workflow/RunHistory.vue

@@ -207,7 +207,7 @@ function downloadWorkspace(run) {
               <ChevronForwardOutline />
             </n-icon>
             <n-icon size="14"><PlayCircleOutline /></n-icon>
-            <span>{{ wf.workflowName }}</span>
+            <span>{{ wf.displayName || wf.workflowName }}</span>
             <span class="wf-run-count">{{ wf.runs.length }}</span>
           </div>
           <div v-if="wf.tags && wf.tags.length" class="wf-tags">

+ 12 - 40
frontend/src/views/workflow/TemplateManagement.vue

@@ -445,18 +445,18 @@ function goBack() { router.back() }
         </span>
       </div>
       <div class="toolbar-right">
-        <button class="ghost-btn" @click="fetchList" :disabled="loading">
-          <n-icon size="16"><ReloadOutline /></n-icon>
-          <span>刷新</span>
-        </button>
-        <button class="ghost-btn" @click="handleNew">
-          <n-icon size="16"><AddOutline /></n-icon>
-          <span>新增模板</span>
-        </button>
-        <button class="primary-btn" :disabled="saving" @click="handleSave">
-          <n-icon size="16"><SaveOutline /></n-icon>
-          <span>{{ saving ? '保存中...' : (form.id ? '保存修改' : '创建模板') }}</span>
-        </button>
+        <n-button size="small" @click="fetchList" :disabled="loading">
+          <template #icon><n-icon><ReloadOutline /></n-icon></template>
+          刷新
+        </n-button>
+        <n-button size="small" @click="handleNew">
+          <template #icon><n-icon><AddOutline /></n-icon></template>
+          新增模板
+        </n-button>
+        <n-button size="small" type="primary" :disabled="saving" @click="handleSave">
+          <template #icon><n-icon><SaveOutline /></n-icon></template>
+          {{ saving ? '保存中...' : (form.id ? '保存修改' : '创建模板') }}
+        </n-button>
       </div>
     </div>
 
@@ -959,34 +959,6 @@ function goBack() { router.back() }
   background: rgba(255, 255, 255, 0.06);
 }
 
-.ghost-btn, .primary-btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 7px 14px;
-  font-size: 13px;
-  border-radius: 6px;
-  cursor: pointer;
-  border: none;
-}
-.ghost-btn {
-  background: transparent;
-  border: 1px solid rgba(255, 255, 255, 0.1);
-  color: var(--text-secondary, #94a3b8);
-}
-.ghost-btn:hover:not(:disabled) {
-  color: var(--text-primary, #fff);
-  border-color: rgba(255, 255, 255, 0.2);
-}
-.ghost-btn:disabled { opacity: 0.5; cursor: not-allowed; }
-.primary-btn {
-  background: linear-gradient(135deg, #2563eb 0%, #3b82f6 50%, #38bdf8 100%);
-  color: #fff;
-  font-weight: 500;
-}
-.primary-btn:hover:not(:disabled) { box-shadow: 0 0 12px rgba(37, 99, 235, 0.5); }
-.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
-
 /* ========== 4 列主体 ========== */
 .page-body {
   flex: 1;

文件差異過大導致無法顯示
+ 368 - 263
frontend/src/views/workflow/WorkflowEditor.vue


+ 42 - 59
frontend/src/views/workflow/WorkflowList.vue

@@ -38,7 +38,7 @@ function openCategoryPicker(wf) {
 async function onCategorySaved(categoryId) {
   if (!categorizingWf.value) return
   try {
-    await setWorkflowCategory(categorizingWf.value.id, categoryId)
+    await setWorkflowCategory(categorizingWf.value.name, categoryId)
     await wfStore.fetchList()
     await loadCategories()
     message.success('分类已更新')
@@ -62,17 +62,17 @@ function handleTemplateSelect(tpl) {
   router.push(`/orchestration/edit?templateId=${tpl.id}`)
 }
 
-function editWorkflow(id) {
-  router.push(`/orchestration/edit/${id}`)
+function editWorkflow(name) {
+  router.push(`/orchestration/edit/${name}`)
 }
 
 function handleDelete(wf) {
   confirmDelete({
-    name: wf.name,
+    name: wf.displayName || wf.name,
     itemType: '智能体',
     onConfirm: async () => {
       try {
-        await wfStore.remove(wf.id)
+        await wfStore.remove(wf.name)
         loadCategories()
         message.success('工作流已删除')
       } catch (e) {
@@ -288,18 +288,18 @@ onMounted(() => {
             </span>
           </div>
           <div class="toolbar-right">
-            <button class="ghost-btn" @click="wfStore.fetchList(); loadCategories()">
-              <n-icon size="16"><ReloadOutline /></n-icon>
-              <span>刷新</span>
-            </button>
-            <button class="ghost-btn" @click="router.push('/templates')">
-              <n-icon size="16"><CubeOutline /></n-icon>
-              <span>模板管理</span>
-            </button>
-            <button class="primary-btn" @click="createAndEdit">
-              <n-icon size="16"><AddOutline /></n-icon>
-              <span>创建智能体</span>
-            </button>
+            <n-button size="small" @click="wfStore.fetchList(); loadCategories()">
+              <template #icon><n-icon><ReloadOutline /></n-icon></template>
+              刷新
+            </n-button>
+            <n-button size="small" @click="router.push('/templates')">
+              <template #icon><n-icon><CubeOutline /></n-icon></template>
+              模板管理
+            </n-button>
+            <n-button size="small" type="primary" @click="createAndEdit">
+              <template #icon><n-icon><AddOutline /></n-icon></template>
+              创建智能体
+            </n-button>
           </div>
         </div>
 
@@ -321,15 +321,18 @@ onMounted(() => {
         <div v-else class="workflow-grid">
           <div
             v-for="(wf, index) in wfStore.workflows"
-            :key="wf.id"
+            :key="wf.name"
             class="workflow-card glow-border"
             :style="{ animationDelay: `${index * 60}ms` }"
-            @click="editWorkflow(wf.id)"
+            @click="editWorkflow(wf.name)"
           >
             <div class="card-glow-bg"></div>
             <div class="card-content">
               <div class="card-header">
-                <h3 class="card-title">{{ wf.name }}</h3>
+                <div class="card-title-wrap">
+                  <h3 class="card-title">{{ wf.displayName || wf.name }}</h3>
+                  <span class="card-name" :title="wf.name">{{ wf.name }}</span>
+                </div>
                 <div class="card-actions">
                   <button class="action-btn tag-btn" @click.stop="openTagSelector(wf)" title="标签">
                     <n-icon size="14"><PricetagsOutline /></n-icon>
@@ -337,7 +340,7 @@ onMounted(() => {
                   <button class="action-btn category-btn" @click.stop="openCategoryPicker(wf)" title="分类">
                     <n-icon size="14"><BookmarksOutline /></n-icon>
                   </button>
-                  <button class="action-btn edit-btn" @click.stop="editWorkflow(wf.id)" title="编辑">
+                  <button class="action-btn edit-btn" @click.stop="editWorkflow(wf.name)" title="编辑">
                     <n-icon size="14"><CreateOutline /></n-icon>
                   </button>
                   <button class="action-btn delete-btn" @click.stop="handleDelete(wf)" title="删除">
@@ -401,7 +404,7 @@ onMounted(() => {
     <TagSelector
       v-model:show="showTagSelector"
       entity-type="workflow"
-      :entity-id="taggingWf?.id ? String(taggingWf.id) : ''"
+      :entity-id="taggingWf?.name ? String(taggingWf.name) : ''"
       :selected-tag-ids="taggingWf?.tags?.map(t => t.tagId) || []"
       @saved="onTagsSaved"
     />
@@ -618,41 +621,6 @@ onMounted(() => {
   gap: 8px;
 }
 
-.primary-btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 8px 16px;
-  background: var(--gradient-accent);
-  color: #fff;
-  border: none;
-  border-radius: var(--radius-sm);
-  font-size: 13px;
-  font-weight: 500;
-  cursor: pointer;
-  transition: all var(--transition-fast);
-}
-
-.primary-btn:hover {
-  box-shadow: var(--glow-blue);
-}
-
-.ghost-btn {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 8px 16px;
-  background: transparent;
-  color: var(--text-secondary);
-  border: 1px solid var(--border-color);
-  border-radius: var(--radius-sm);
-  font-size: 13px;
-  cursor: pointer;
-  transition: all var(--transition-fast);
-}
-
-.ghost-btn:hover { color: var(--text-primary); border-color: var(--border-color-hover); }
-
 .loading-area, .empty-area {
   display: flex;
   align-items: center;
@@ -717,14 +685,29 @@ onMounted(() => {
   gap: 8px;
 }
 
+.card-title-wrap {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
 .card-title {
   font-size: 16px;
   font-weight: 600;
   color: var(--text-primary);
   line-height: 1.4;
   margin: 0;
-  flex: 1;
-  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.card-name {
+  font-size: 11px;
+  color: var(--text-tertiary, #888);
+  font-family: ui-monospace, 'Cascadia Code', Menlo, monospace;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;

+ 78 - 1
prompt.md

@@ -1068,4 +1068,81 @@ java.lang.IllegalStateException: rag-ai-bridge is disabled
 
 ---
 
-在当前文件的逻辑中,点击“应用审核后的授权配置”按钮,提示“知识库 1 中没有匹配的数据源绑定”。看起来没有和我真实的数据源关联起来。请解决。
+在当前文件的逻辑中,点击“应用审核后的授权配置”按钮,提示“知识库 1 中没有匹配的数据源绑定”。看起来没有和我真实的数据源关联起来。请解决。
+
+---
+
+针对工作流中的各类节点,进行以下工作:
+1. 除用户定义的输入输出外,每类节点会自动接受的输入和自动生成的输出字段,梳理出来;
+2. 针对每类节点自动生成的每个输出字段,定义其转换为下游节点输入字段的转换器,例如,文档数据库检索结果,若转换为下游输入的字符串,那么取检索结果top1的内容;若转换为下游输入的Json对象,取检索结果top1;若转换为下游输入的Json列表,取检索结果topK等等,将每类规则梳理出来。
+如有需要,仔细读取当前代码实现。最终形成markdown格式的文档,存放至docs下。
+
+---
+
+我针对工作流有几个想法:
+1. 每个节点,在开始执行时,存放的工作区内容,是从起始节点到前序节点的所有变量的总和(不包括与当前节点完全无关的其他工作流分支的变量),以及当前工作空间的所有文件(工作空间不区分分支,整个工作流共有)。针对变量,所有前序节点的输出均需要标记节点ID,这样做的话,如果前序多个节点拥有名称相同的输出变量,也不会相互覆盖;
+2. 如果某个节点,定义了输入变量,那么该输入变量的定义处,支持关联前序节点的某个输出变量(可指定某个前序节点的某个输出变量)。关联后,如果类型一致,直接赋值;否则根据 @docs/workflow-node-fields.md 梳理出的转换规则,进行转换后赋值。如果连接线变化导致了已配置的前序节点不再是当前前序节点了,那么关联解除。在没有明确指定关联变量的情况下,按照如下顺序进行变量隐式关联:
+  2.1. 逐个向前查找同名输出变量。例如,A->B->C,A定义了输出变量result,类型为字符串;B定义了输出变量result,类型为整数;C定义了输入变量result,那么关联到最近前序节点B的输出变量result,然后根据 @docs/workflow-node-fields.md 中的转换规则进行转换后,赋值给C的result变量;
+  2.2. 2.1不满足的情况下,逐个向前查找忽略“-”、“_”等字符和字母大小写后的同名输出变量,例如output-field和output_field和outputField均可自动关联,同样按照最近原则查找,然后转换后赋值;
+  2.3. 2.1和2.2不满足的情况下,逐个向前查找Json对象中的同名输出变量。例如,A->B->C,B定义了输出变量output,结构为:{"total": {"result": "XXX", "count": 2}};C定义了输入变量result,那么关联到最近前序节点B的output.total.result,同样按照最近原则查找,然后转换后赋值;
+  2.4。 2.1、2.2和2.3不满足的情况下,逐个向前查找Json对象中的忽略“-”、“_”等字符和字母大小写后的同名输出变量,同样按照最近原则查找,然后转换后赋值。
+
+---
+
+将设计写入 @docs 目录,然后按照此计划继续实现。
+
+---
+
+有了这个机制,原有的“前置节点输出的工作空间各变量作为系统提示词注入”的需求就可以取消了,只把节点明确定义的输入变量注入即可,否则很多情况下,变量太多,会造成智能体的注意力分散。
+另外,我希望在前端界面中,工作流“运行结果”处,点击图中某个节点,运行结果处自动滚动到对应节点的运行结果处,并高亮显示运行结果。
+还有,运行结果中,点击某个节点(带思考过程)中的“XXX条日志”按钮,不要再出现额外的流式中间输出(即以token分隔开的多条日志),因为它内容与完整的思考过程重复。
+
+---
+
+-llm.log中,具体的向量数字不用输出,太占空间了。
+
+---
+
+翻译 @skills/agent-management-external-workflow 目录中的内容。
+
+---
+
+稍微修改该skill的描述,确保用户提及“智能体管理平台”也能触发该skill,而不是必须提及“agent-management”这个英文名。
+
+---
+
+再加入描述,规定输入,用户需要指定智能体管理平台的baseUrl、工作流ID或名称等。相关业务应用需要将用户指定的baseUrl和工作流信息配置到配置文件中,而不是写死,以适应变化。
+
+---
+
+我希望让智能体(工作流)在存储时,也包含一个英文名ID(唯一,同样必须遵循 kebab-case 规范,若不满足,处理方式与技能编辑中的前端处理一致),以及一个中文名称。在工作流编辑页面中增加这两项内容,样式与技能编辑中一致。弃用原有的数字ID(但当前可以兼容,直到用户手动修改为止)。
+
+---
+
+修改设计方案,更新TODO。
+1. 不使用 slug 作为智能体的唯一标识,看看“Skill”中使用的是哪个字段,与之保持一致(我记得应该是name字段,你来确认);同理,中文名称也保持一致(我记得应该是displayName字段,你来确认);
+2. 兼容之前的数字ID,意思不是保留数字ID,而是数字ID同样符合 kebab-case 规范,所以直接将原有数字ID赋值给新的唯一标识字段即可;
+3. WorkflowList 不是可选展示唯一标识,而是一定要展示唯一标识 + 中文名称。
+5态机的设计可以复用,没问题。
+重新设计方案,经过我同意后再开始实施。
+
+---
+
+基本可行,但我希望URL路由也改成name。因为skill也是类似的逻辑。我建议Long类型id可以不用了,你来评估一下。
+
+---
+
+工作流前端表单的描述、提示等,样式也要和Skill一致,现在表单没有提示,也就是输入框前方的Label。
+
+---
+
+我手动修改了,技能编辑界面和工作流编辑界面,保持了Label、Placeholder、Hint等的风格完全一致。我希望在工作流编辑界面,再加上智能体描述。请修改前后端。
+
+---
+
+检索每个菜单页中的主要按钮(主要是各类列表上方的“创建”、“删除”、“刷新”等按钮。不包括弹出框中的按钮)。现在有两种风格,一种是无边框圆角矩形,最主要的按钮文字为白色;另一种是有边框圆角矩形,圆角稍小。主要的按钮文字为黑色,边框为浅绿色。我需要你统一风格,统一成后一种。(弃用)
+
+---
+
+1. 将“文档数据”页面的“上传文档”按钮、“RAG治理”页面的“应用审核后的授权配置”按钮、“技能管理”页面的“新增技能”按钮、“智能体编排”页面的“创建智能体”按钮、“智能体模板”页面的“保存修改”按钮、“模型管理”页面的“新增模型”按钮,统一成“知识图谱”页面的“查询”按钮风格。
+2. 将“技能管理”页面的“刷新”按钮、“智能体编排”页面的“刷新”按钮和“模板管理”按钮、“智能体模板”页面的“刷新”按钮和“新增模板”按钮、“标签管理”页面的标签组“导入”“导出”“新增”按钮和标签“新增”按钮,统一成“文档数据”页面“刷新”按钮的风格。其中,原来只有logo没有文字的按钮,仍保持只有logo即可,只有边框和背景统一。

+ 74 - 0
skills/agent-management-external-workflow/SKILL.md

@@ -0,0 +1,74 @@
+---
+name: agent-management-external-workflow
+description: "当需要构建或修改接入智能体管理平台(agent-management)外部工作流服务的软件时使用:调用 /api/v1 工作流运行接口、上传变量/文件、启动运行、订阅节点状态/思考/工具 SSE 流、查询结果、下载工作空间,或构建前端执行/运行记录视图且需保留智能体管理平台运行内容、流程与信息层级、但不必照搬 /history 页面样式时使用。用户必须显式提供智能体管理平台的 baseUrl、目标工作流的 ID 或名称(以及 API key、超时等可选项);这些值必须落在目标应用的配置文件中(如 .env / application.yml / config.json 等),严禁在源码中硬编码,以适应环境切换与平台迁移。触发关键词包括:智能体管理平台、接入智能体管理平台、agent-management、接入 agent-management、调用工作流、展示执行过程、展示思考过程、运行记录页面、参考运行记录页。"
+---
+
+# Agent Management 外部工作流
+
+使用此技能以确保 vibe coding 工作与 agent-management 外部工作流契约及预期的运行执行体验保持一致。用户的描述可能比较模糊,需根据其诉求和目标代码库自行推断集成范围。
+
+## 第一步
+
+由你自行判断任务形态,不要让用户来做分类。
+
+- **API 集成**:用户需要调用 agent-management 工作流服务、上传输入、消费 SSE 或拉取结果。阅读 `references/external-workflow-api.md`。
+- **运行展示 UI**:用户需要前端页面/组件来展示执行进度、节点输出、思考过程、日志、错误或工作空间下载。阅读 `references/run-history-ui.md`。
+- **端到端集成**:用户同时需要服务调用和前端执行视图。两份参考文档都要读。
+
+优先沿用目标仓库已有的框架、请求封装、状态管理、组件库、路由和样式约定。将打包的参考文档视为集成契约,把运行记录的概念翻译为目标系统原生的 UI 风格。
+
+## 输入约定
+
+集成前必须由用户明确给出以下输入;缺失任一关键项应主动追问,不要自行假设:
+
+- **智能体管理平台 baseUrl(必填)**:例如 `https://amp.example.com` 或 `http://10.0.0.5:2438`。注意是否包含端口与协议;末尾不要带 `/api/v1`,因为端点路径已经包含。
+- **目标工作流(必填)**:优先使用工作流 ID(`workflowId`,整数),其次是工作流名称(需在运行时解析为 ID)。同一 baseUrl 下可能存在多个工作流,必须由用户指定具体的一个或一组。
+- **API Key(必填,敏感)**:用于 `X-API-Key` 鉴权。禁止写入受版本控制的源码或浏览器可见产物;默认放在后端配置或环境变量中。
+- **超时、重试、TTL 等可选项**:如不提供,使用契约默认值(如 `ttlHours=720`)。
+
+### 配置而非硬编码
+
+目标业务应用必须把上述值落到自身的配置体系里,不允许在源码中写死,以适应环境切换、平台迁移和密钥轮换:
+
+- 后端应用:放进 `application.yml` / `application.properties` / `.env`,通过 `@Value` / `@ConfigurationProperties` 注入。
+- 前端应用:通过构建期环境变量(如 Vite 的 `import.meta.env.VITE_AMP_BASE_URL`)或运行期由后端代理下发的配置接口注入。
+- 容器/部署:通过环境变量或 ConfigMap 注入,写入部署文档。
+- 提供 `.env.example` / `application.yml.example` 等模板文件,列出全部需要填写的键,方便后续部署人员按环境填写。
+
+## 用户沟通
+
+仅在缺少信息会阻塞安全可运行的实现时才提问。
+
+- 不要询问那些能从目标仓库代码、配置、文档或现有运行视图中自行发现的信息。
+- 仅当无法推断时,才询问 `baseUrl`、API key 存储位置、目标 `workflowId` 或页面挂载位置。
+- `X-API-Key` 默认通过后端代理或服务端环境变量注入;不要将 API key 硬编码到浏览器代码中。
+- 若用户明确要求在前端直接携带 `X-API-Key`,需在实现前说明泄露风险。
+- 对非阻塞性细节,简要说明假设后继续推进。
+
+## 实现流程
+
+1. 编辑前先勘察目标应用。
+   - 识别前后端边界、请求辅助封装、环境配置、路由模式以及已有的 SSE 工具。
+   - 如果应用已存在 API 客户端或运行视图,应在其基础上扩展,而不是另起平行抽象。
+
+2. 按外部工作流契约实现 API 接入。
+   - 使用 `/api/v1` 端点,而不是内部 `/api/workflows` 端点,除非任务明确限定在 agent-management 内部。
+   - 支持默认异步流程:创建运行、启动运行、订阅流、查询结果、下载工作空间。
+   - 正确处理统一 JSON 响应包、二进制 zip 响应、SSE 事件、断连/重连以及终态成功/失败。
+   - **baseUrl、workflowId、API Key、超时、TTL 等参数全部从配置文件读取,禁止硬编码到源码**;凭证不得写入受版本控制的源码或浏览器可见产物中,除非用户明确接受此风险。
+
+3. 实现运行展示 UI,保留运行记录的内容与流程层级。
+   - 展示工作流/运行选择、运行状态、时间/耗时、节点卡片、输出变量、思考过程、非思考日志、错误和工作空间下载。
+   - 保留运行记录体验的核心概念:历史列表按工作流分组、选中运行详情、节点执行顺序、节点状态、可折叠的日志/上下文、思考过程独立成区。
+   - 不要求目标 UI 完全复刻原运行记录页的颜色、间距、图标、暗色主题、左右两栏布局或组件库,除非用户明确要求近似视觉。
+   - 把交互模型与信息层级翻译到目标框架中,而不是照搬框架特定的代码或样式。
+
+4. 验证集成。
+   - 用真实的变量走通「创建/启动/订阅/查询/下载」链路。
+   - 确认 SSE 事件能增量更新 UI,终态事件能正确关闭或标记运行。
+   - 确认失败运行能浮现错误,并仍允许查看结果/日志(如果可用)。
+   - 运行目标项目相关的测试、lint、类型检查或构建命令。
+
+## 可移植性
+
+本技能以自包含目录形式分发。不要依赖仓库本地路径、已部署的参考页面、特定公网 IP 或原前端的视觉主题。如果目标仓库已经自带 agent-management 适配器或运行记录视图,应在目标仓库内勘察并扩展该本地实现。

+ 4 - 0
skills/agent-management-external-workflow/agents/openai.yaml

@@ -0,0 +1,4 @@
+interface:
+  display_name: "Agent Management 外部工作流"
+  short_description: "按 agent-management 外部工作流规范接入并展示运行过程"
+  default_prompt: "使用 $agent-management-external-workflow 来接入 agent-management 外部工作流 API,并构建运行记录风格的执行视图。"

+ 235 - 0
skills/agent-management-external-workflow/references/external-workflow-api.md

@@ -0,0 +1,235 @@
+# 外部工作流 API 参考
+
+当需要从其他应用或新的集成层调用 agent-management 工作流时,按本参考实现服务调用。
+
+## 契约概要
+
+- 基础路径:`http(s)://<host>:<port>/api/v1`
+- 默认后端端口:`2438`
+- 鉴权:每个 `/api/v1/**` 请求需携带 `X-API-Key: <key>`
+- JSON 成功响应包:
+
+```json
+{ "code": "OK", "message": "", "data": {} }
+```
+
+- 错误响应包:
+
+```json
+{ "code": "VALIDATION_FAILED", "message": "可读错误信息", "data": null }
+```
+
+- 主要错误码:`UNAUTHORIZED`、`VALIDATION_FAILED`、`WORKFLOW_NOT_FOUND`、`RUN_NOT_FOUND`、`GRAPH_INVALID`、`RUN_ALREADY_STARTED`、`INTERNAL_ERROR`
+
+## 端点
+
+| 方法 | 路径 | 用途 |
+|---|---|---|
+| `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 |
+
+## 推荐的异步流程
+
+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`。
+5. 收到 `workflow_complete` 或 `workflow_error` 后,关闭流或标记为终态。
+6. 查询 `GET /workflows/{workflowId}/runs/{runId}` 获取持久化输出与节点摘要。
+7. 将 `GET /workflows/{workflowId}/runs/{runId}/workspace` 作为二进制下载入口提供给用户。
+
+仅当期望"单次阻塞请求直接返回 SSE"时才使用 `async: false`。
+
+## 创建运行
+
+纯 JSON 请求:
+
+```http
+POST /api/v1/workflows/12/runs
+X-API-Key: demo-key
+Content-Type: application/json
+
+{
+  "variables": {
+    "topic": "大语言模型在企业中的应用",
+    "maxTokens": 2048,
+    "options": { "lang": "zh", "verbose": false }
+  },
+  "async": true,
+  "failStrategy": "abort",
+  "ttlHours": 720
+}
+```
+
+Multipart 请求:
+
+```http
+POST /api/v1/workflows/12/runs
+X-API-Key: demo-key
+Content-Type: multipart/form-data
+
+payload: {"variables":{"topic":"汇总报告"},"async":true}
+files: report.md
+files: inputs/2024/data.csv
+```
+
+字段说明:
+
+- `variables`:自定义工作流输入变量;对象;可选。
+- `async`:默认 `true`。为 `true` 时仅创建;为 `false` 时立即启动并以 SSE 形式返回。
+- `failStrategy`:取值 `abort` 或 `skip`;可选且前向兼容。当前 agent-management 实现中该字段虽被 DTO 接收,但执行仍依赖 `graphData` 中节点级别的 `failStrategy`;除非目标版本显式支持请求级覆盖,否则不要依赖此字段。
+- `ttlHours`:工作空间保留小时数;默认 `720`。
+- `files`:可重复的 multipart 二进制 part;可选。
+- `X-Relative-Path`:multipart part 的可选 header,用于保留目录路径。若客户端技术栈不支持,则退化为仅使用文件名并明确记录该限制。
+
+`async: true` 的响应:
+
+```json
+{
+  "code": "OK",
+  "data": {
+    "runId": "a3f9b2c1",
+    "workflowId": 12,
+    "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"
+    }
+  }
+}
+```
+
+## SSE 流
+
+打开连接:
+
+```http
+GET /api/v1/workflows/{workflowId}/runs/{runId}/stream
+X-API-Key: <key>
+Accept: text/event-stream
+```
+
+断线重连时携带:
+
+```http
+Last-Event-ID: 42
+```
+
+每条事件遵循标准 SSE 格式:
+
+```text
+event: node_stream
+id: 43
+data: {"runId":"a3f9b2c1","type":"node_stream"}
+```
+
+事件类型:
+
+- `run_started`:引擎已启动。
+- `node_status`:节点状态变更。用于创建/更新节点卡片。
+- `node_stream`:节点增量流。`kind` 取值为 `thinking`、`tool_call` 或 `tool_result`。
+- `workflow_complete`:终态成功。包含最终输出和工作空间/结果 URL。
+- `workflow_error`:终态失败。包含错误信息,通常带有失败节点 ID。
+
+关键负载字段示例:
+
+```json
+{
+  "runId": "a3f9b2c1",
+  "nodeId": "node-2",
+  "nodeType": "agent",
+  "label": "研究助手",
+  "status": "RUNNING",
+  "kind": "thinking",
+  "content": "首先我需要搜索相关资料...",
+  "toolName": null,
+  "timestamp": "2026-07-01T08:30:07Z"
+}
+```
+
+客户端行为约定:
+
+- 以 `nodeId` 为主键维护每个节点的状态。
+- 将 `node_stream.kind === "thinking"` 的内容追加到该节点的思考缓冲区。
+- 将 `tool_call` 与 `tool_result` 作为日志存储,保留工具名和内容。
+- 把 `workflow_complete` 与 `workflow_error` 视为终态。
+- 如需支持重连,持久化最近一条 SSE 的 `id`。
+
+## 查询结果
+
+```http
+GET /api/v1/workflows/12/runs/a3f9b2c1
+X-API-Key: demo-key
+```
+
+预期的 data 形态:
+
+```json
+{
+  "runId": "a3f9b2c1",
+  "workflowId": 12,
+  "status": "SUCCESS",
+  "startedAt": "2026-07-01T08:30:05Z",
+  "completedAt": "2026-07-01T08:31:12Z",
+  "error": null,
+  "outputs": {
+    "summary": "本报告探讨了...",
+    "_workingDirFiles": ["report.md"],
+    "_runId": "a3f9b2c1"
+  },
+  "nodes": [
+    {
+      "nodeId": "node-2",
+      "nodeType": "agent",
+      "label": "研究助手",
+      "status": "SUCCESS",
+      "output": { "research": "..." },
+      "error": null,
+      "logsCount": 124,
+      "logsUrl": "/api/v1/workflows/12/runs/a3f9b2c1/nodes/node-2/logs"
+    }
+  ],
+  "workspace": {
+    "fileCount": 12,
+    "totalBytes": 456789,
+    "downloadUrl": "/api/v1/workflows/12/runs/a3f9b2c1/workspace"
+  }
+}
+```
+
+状态取值:`CREATED`、`RUNNING`、`SUCCESS`、`FAILED`。
+
+## 日志与工作空间
+
+节点日志:
+
+```http
+GET /api/v1/workflows/{workflowId}/runs/{runId}/nodes/{nodeId}/logs
+```
+
+日志类型通常为 `THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`INFO`、`ERROR`。把思考过程抽到独立展示区,非思考日志放在可折叠的日志列表中。
+
+工作空间下载:
+
+```http
+GET /api/v1/workflows/{workflowId}/runs/{runId}/workspace
+```
+
+响应是二进制 zip 内容,不要走 JSON 响应拦截器。
+
+## 安全与集成注意事项
+
+- 默认不要在前端代码中暴露 `X-API-Key`。优先使用后端代理,由服务端注入密钥。
+- base URL 与 API key 应存放在环境/配置中,不要硬编码到源码。
+- 如实现代理上传,需校验路径,防止 `../` 路径穿越。
+- 代理 SSE 时关闭缓冲,保留事件名、id 和 data 负载。
+- 未经用户同意,不要悄悄把 SSE 降级为轮询。
+- 使用 axios/fetch 等封装时,对 `text/event-stream` 和工作空间 zip 端点绕过 JSON 响应包解析。

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

@@ -0,0 +1,179 @@
+# 运行记录 UI 参考
+
+当需要构建展示 agent-management 工作流执行进度或历史运行详情的前端页面/组件时,使用本参考。保留其内容、状态流转和信息层级;视觉设计则按目标产品自身风格适配。
+
+如果用户提供了已部署的参考页面,可将其作为可选的内容与交互参考,而不是必须照搬的样式来源。
+
+## 页面结构
+
+采用面向执行的视图。对于管理/历史类页面,左右两栏的运行记录布局是较优的默认选择,但嵌入式或面向特定任务的集成不强制使用。
+
+- 页面标题:`运行记录` 或目标应用中的等价文案。
+- 浏览历史运行时需有运行选择区。
+- 选中或当前运行的详情区。
+- 没有运行或未选中运行时,展示清晰的空状态。
+- 初始加载运行数据时,居中展示 loading spinner。
+
+集成到目标应用时,遵循其既有约定,例如:
+
+- API 封装或服务模块约定。
+- 请求辅助封装、鉴权与错误处理约定。
+- 既有的组件库与图标集。
+- 既有的路由、状态管理、间距、排版与状态样式。
+
+除非用户明确要求近似视觉,否则不要照搬原运行记录页样式。
+
+## 工作流与运行选择
+
+展示运行历史时,按工作流分组有助于用户查找历史执行:
+
+- 工作流头部:折叠箭头、工作流图标、工作流名称和运行数量。
+- 工作流标题下方可选地展示工作流标签(小 chip)。
+- 运行条目:
+  - 状态圆点
+  - 短运行 ID,例如 `#a3f9b2c1`
+  - 状态徽标
+  - 启动时间
+  - 完成时展示耗时
+  - 删除操作仅出现在管理/历史页面,只读嵌入式视图不展示
+
+常用交互:
+
+- 工作流分组可折叠,默认展开。
+- 点击运行会加载并选中其详情。
+- 当前运行有明显的选中态。
+
+对于纯实时执行视图,完整的历史列表是可选的;展示当前工作流/运行身份、实时状态与节点推进即可。
+
+## 运行详情
+
+运行详情头部:
+
+- 状态圆点
+- `运行 #<runId>`
+- 状态徽标
+- 启动时间
+- 完成时间(如有)
+- 耗时(如有)
+- 运行级错误块(如存在)
+
+随后按执行顺序渲染节点结果卡片、时间轴行或等价的区块。
+
+页脚:
+
+- 当工作空间下载可用时,提供 `下载工作空间`。
+- 对外部 API 集成,使用 `workspace.downloadUrl` 或 `/api/v1/workflows/{workflowId}/runs/{runId}/workspace`。
+- 下载作为二进制导航/blob 处理,不要按 JSON 处理。
+
+## 节点类型映射
+
+使用一致的节点标签。下方颜色与图标仅为示例,不是必须的样式:
+
+| nodeType | 标签 | 颜色 | 图标建议 |
+|---|---|---|---|
+| `userInput` | `用户输入` | `#10B981` | 聊天/输入 |
+| `llm` | `大模型` | `#8B5CF6` | 闪光 |
+| `skill` | `技能` | `#06B6D4` | 烧瓶 |
+| `agent` | `智能体` | `#F59E0B` | 火箭 |
+| `smartAction` | `智能操作` | `#EC4899` | 魔杖 |
+| `condition` | `条件` | `#F97316` | 分支 |
+| `output` | `输出` | `#EF4444` | 出口/输出 |
+
+节点区块行为:
+
+- 展示标签、类型标签、日志数量(如可用)、状态,以及有日志时的展开控件。
+- 点击头部或明确的控件可切换非思考日志的显隐。
+- 若使用颜色/图标,需按节点类型保持一致,并与目标应用设计体系对齐。
+
+## 状态映射
+
+文案:
+
+- `running` 或 `RUNNING`:`执行中`
+- `success` 或 `SUCCESS`:`成功`
+- `failed` 或 `FAILED`:`失败`
+- `skipped` 或 `SKIPPED`:`跳过`
+- 未知:`未知`
+
+若使用 CSS 类,将状态归一化为小写,例如 `status-success`。
+
+可选的状态色意图(当目标设计体系使用状态色时):
+
+- 执行中:蓝/紫强调
+- 成功:绿
+- 失败:红
+- 跳过/未知:弱化灰
+
+## 节点卡片内容
+
+存在数据时渲染以下区块:
+
+1. 输出变量
+   - 必要时解析 JSON 对象输出。
+   - 以紧凑的等宽样式展示键和值。
+   - 对象/数组值用 `JSON.stringify(value, null, 2)` 美化输出。
+   - 限制高度并对长值提供滚动。
+
+2. 思考过程
+   - 抽取所有 type 为 `THINKING` 的日志。
+   - 按顺序拼接消息片段。
+   - 在完整日志列表上方的高亮区块中展示。
+   - 默认展开,除非用户折叠。
+   - 展示近似字符数。
+
+3. 上下文快照
+   - 可选的调试区块。
+   - 默认折叠,或放在明确的开关之后。
+   - 以等宽格式展示键值对,并限制高度。
+
+4. 错误
+   - 按目标应用的标准错误呈现方式,在节点内联展示错误。
+
+5. 非思考日志
+   - 过滤掉 `THINKING` 日志,因为思考过程已在专属区块展示。
+   - 将 `TOOL_CALL`、`TOOL_RESULT`、`INFO`、`ERROR` 保留在可折叠列表中。
+   - 展示日志类型标签、消息及可选的详情。
+
+## 辅助逻辑
+
+实现以下辅助函数或等价物:
+
+- `formatTime(t)`:兼容 ISO 字符串与 Spring/Java 的数组式日期数组。
+- `duration(run)`:基于 started/completed 计算耗时;按秒、分秒或时分适配展示。
+- `parseJson(str)`:成功返回解析后的对象,否则返回 `null`。
+- `formatContextValue(value)`:对象/数组字符串化;字符串原样保留;基本类型字符串化。
+- `extractThinking(logsJson)`:解析日志数组,拼接 `THINKING.message`。
+- `nonThinkingLogs(logsJson)`:解析日志数组,过滤掉 `THINKING`。
+
+对实时 SSE 视图,即便日志是以增量方式到达而非来自持久化 JSON,也应保持同样的派生数据形态。
+
+## 实时 SSE 视图适配
+
+当页面通过外部 API 展示正在运行的工作流时:
+
+- `run_started`:初始化运行状态,标记为执行中。
+- `node_status`:创建或更新节点卡片;存在时设置 status/output/error/completedAt。
+- `node_stream` 且 `kind: "thinking"`:把内容追加到该节点的思考缓冲区。
+- `node_stream` 且 `kind: "tool_call"` 或 `kind: "tool_result"`:追加到非思考日志。
+- `workflow_complete`:标记运行成功,存储最终输出,暴露工作空间/结果链接。
+- `workflow_error`:标记运行失败,展示错误与失败节点。
+
+终态事件到达后,再查询持久化结果端点,对齐持久化输出、节点顺序、日志数量与工作空间元数据。
+
+## 视觉规范
+
+- 保持信息密度高的运维型 UI。除非目标产品显式使用,否则避免落地页/营销式排版。
+- 运行条目与节点结果区块按目标设计体系,使用紧凑卡片、行、时间轴项或面板。
+- 不要在卡片里再嵌套卡片。
+- 圆点、徽标、图标与列表行使用稳定尺寸,避免布局抖动。
+- 长文本要换行或可滚动;输出、思考或日志文本绝不可溢出容器。
+- 在外部系统中,优先保证含义与工作流清晰度;视觉风格应贴合宿主应用。
+
+## 验证清单
+
+- 空、加载中、选中、执行中、成功、失败、无日志等状态均能正确渲染。
+- 长思考流不会破坏布局。
+- 大体量 JSON 输出可读且有边界约束。
+- 工具日志可展开,且不与思考文本重复。
+- 工作空间下载可正常工作,不触发 JSON 拦截器错误。
+- 移动端或窄屏下,状态徽标、运行 ID 或节点头部不会重叠。

部分文件因文件數量過多而無法顯示