Bladeren bron

1. 实现了 Skill 在线编辑功能,支持元数据表单编辑和 Markdown 正文 Monaco Editor 编辑;
2. 实现了高级编辑模式,支持 Skill 目录下的文件树浏览、新建文件/目录、删除和 Monaco 代码编辑器多语言高亮;
3. 实现了 SKILL.md 智能保存机制,保存时自动合并元数据并保留非标准字段(version、source 等),frontmatter 格式自动包装;
4. 新增了「技能」工作流节点类型,支持在编排画布中选择已有 Skill 并自动同步 IO 字段;
5. 实现了工作流节点 IO 变量智能推断引擎,连线时自动匹配源输出与目标输入的字段名和类型兼容性;
6. 优化了工作流节点显示,所有节点类型(LLM、智能体、技能、条件分支、输出)均展示 IO 字段标签;
7. 实现了翻译长文本自动分段机制,超过 4000 字符的正文按段落拆分翻译,前端超时时长根据文本长度动态计算;
8. 优化了工作流调度引擎,线程池从无界改为固定大小,新增 5 分钟全局超时保护防止任务永久阻塞;
9. 收紧了 CORS 安全配置,将通配来源替换为具体协议+域名匹配模式;
10. 增强了全局异常处理,通用异常返回 HTTP 500 状态码并脱敏错误消息,不再泄露内部堆栈信息;
11. 优化了 SSE 连接管理,新增最大连接数限制(50),防止资源耗尽;
12. 清理了前端 console.log/error 残留,提取了重复的语言映射为共享工具函数。

weisijie 2 maanden geleden
bovenliggende
commit
5c33fe0fa1
42 gewijzigde bestanden met toevoegingen van 3060 en 125 verwijderingen
  1. 1 0
      .gitignore
  2. 4 1
      backend/src/main/java/com/agent/management/common/exception/GlobalExceptionHandler.java
  3. 1 1
      backend/src/main/java/com/agent/management/config/AiRequestLoggingConfig.java
  4. 1 1
      backend/src/main/java/com/agent/management/config/WebMvcConfig.java
  5. 103 0
      backend/src/main/java/com/agent/management/controller/SkillController.java
  6. 30 4
      backend/src/main/java/com/agent/management/engine/WorkflowEngine.java
  7. 5 0
      backend/src/main/java/com/agent/management/engine/WorkflowRunRequest.java
  8. 2 2
      backend/src/main/java/com/agent/management/engine/executor/AgentExecutor.java
  9. 1 1
      backend/src/main/java/com/agent/management/engine/executor/LlmExecutor.java
  10. 20 0
      backend/src/main/java/com/agent/management/model/dto/FileCreateDTO.java
  11. 32 0
      backend/src/main/java/com/agent/management/model/dto/FileNodeDTO.java
  12. 1 1
      backend/src/main/java/com/agent/management/model/dto/IOField.java
  13. 24 0
      backend/src/main/java/com/agent/management/model/vo/SkillDetailVO.java
  14. 2 2
      backend/src/main/java/com/agent/management/parser/SkillMarkdownParser.java
  15. 40 0
      backend/src/main/java/com/agent/management/service/SkillService.java
  16. 5 0
      backend/src/main/java/com/agent/management/service/SseService.java
  17. 387 0
      backend/src/main/java/com/agent/management/service/impl/SkillServiceImpl.java
  18. 62 4
      backend/src/main/java/com/agent/management/service/impl/TranslationServiceImpl.java
  19. 1 1
      frontend/index.html
  20. 39 0
      frontend/package-lock.json
  21. 1 0
      frontend/package.json
  22. 30 0
      frontend/src/api/skill.js
  23. 21 2
      frontend/src/api/translation.js
  24. 114 16
      frontend/src/components/layout/AppSidebar.vue
  25. 111 0
      frontend/src/components/skill/CodeEditor.vue
  26. 366 0
      frontend/src/components/skill/FileTree.vue
  27. 9 14
      frontend/src/components/skill/SkillForm.vue
  28. 1 1
      frontend/src/components/skill/SkillUpload.vue
  29. 25 1
      frontend/src/components/workflow/nodes/AgentNode.vue
  30. 16 0
      frontend/src/components/workflow/nodes/ConditionNode.vue
  31. 28 1
      frontend/src/components/workflow/nodes/LLMNode.vue
  32. 19 3
      frontend/src/components/workflow/nodes/OutputNode.vue
  33. 49 0
      frontend/src/components/workflow/nodes/SkillNode.vue
  34. 7 1
      frontend/src/router/index.js
  35. 337 0
      frontend/src/utils/ioInference.js
  36. 24 0
      frontend/src/utils/language.js
  37. 26 0
      frontend/src/utils/monacoSetup.js
  38. 2 9
      frontend/src/utils/sse.js
  39. 11 31
      frontend/src/views/SkillManagement.vue
  40. 595 0
      frontend/src/views/skill/SkillEdit.vue
  41. 419 27
      frontend/src/views/workflow/WorkflowEditor.vue
  42. 88 1
      prompt.md

+ 1 - 0
.gitignore

@@ -44,6 +44,7 @@ backend/src/main/resources/static/
 *.bak
 chat-history-*.jsonl
 test-*.json
+temp/
 
 # ===== 调试截图 =====
 condition-elif-test.png

+ 4 - 1
backend/src/main/java/com/agent/management/common/exception/GlobalExceptionHandler.java

@@ -2,7 +2,9 @@ package com.agent.management.common.exception;
 
 import com.agent.management.common.Result;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
 import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 import org.springframework.web.servlet.resource.NoResourceFoundException;
 
@@ -26,8 +28,9 @@ public class GlobalExceptionHandler {
     }
 
     @ExceptionHandler(Exception.class)
+    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
     public Result<Void> handleException(Exception e) {
         log.error("系统异常", e);
-        return Result.error(500, "服务器内部错误: " + e.getMessage());
+        return Result.error(500, "服务器内部错误");
     }
 }

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

@@ -60,7 +60,7 @@ public class AiRequestLoggingConfig {
     public RestClient.Builder restClientBuilder() {
         SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
         requestFactory.setConnectTimeout(Duration.ofSeconds(10));
-        requestFactory.setReadTimeout(Duration.ofSeconds(30));
+        requestFactory.setReadTimeout(Duration.ofSeconds(120));
 
         return RestClient.builder()
                 .requestFactory(new BufferingClientHttpRequestFactory(requestFactory))

+ 1 - 1
backend/src/main/java/com/agent/management/config/WebMvcConfig.java

@@ -10,7 +10,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/api/**")
-                .allowedOriginPatterns("*")
+                .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*", "http://*:*", "https://*:*")
                 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 .allowCredentials(true)

+ 103 - 0
backend/src/main/java/com/agent/management/controller/SkillController.java

@@ -1,8 +1,12 @@
 package com.agent.management.controller;
 
 import com.agent.management.common.Result;
+import com.agent.management.model.dto.FileCreateDTO;
+import com.agent.management.model.dto.FileNodeDTO;
+import com.agent.management.model.dto.IOField;
 import com.agent.management.model.dto.SkillDTO;
 import com.agent.management.model.dto.SkillUpdateDTO;
+import com.agent.management.model.vo.SkillDetailVO;
 import com.agent.management.model.vo.SkillVO;
 import com.agent.management.service.SkillService;
 import com.agent.management.service.TranslationService;
@@ -12,6 +16,7 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
+import java.util.Map;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -69,6 +74,104 @@ public class SkillController {
         return Result.success("删除成功", null);
     }
 
+    // ==================== 技能编辑相关 API ====================
+
+    /**
+     * 获取技能详情(含 SKILL.md 正文内容)
+     */
+    @GetMapping("/{folderName}/detail")
+    public Result<SkillDetailVO> getSkillDetail(@PathVariable String folderName) {
+        return Result.success(skillService.getSkillDetail(folderName));
+    }
+
+    /**
+     * 保存 SKILL.md(智能合并元数据)
+     */
+    @PutMapping("/{folderName}/skill-md")
+    public Result<Void> saveSkillMd(@PathVariable String folderName,
+                                    @RequestBody Map<String, Object> body) {
+        String name = (String) body.getOrDefault("name", "");
+        String description = (String) body.getOrDefault("description", "");
+        String bodyText = (String) body.getOrDefault("body", "");
+
+        // 解析 inputs / outputs
+        List<IOField> inputs = parseIOFields(body.get("inputs"));
+        List<IOField> outputs = parseIOFields(body.get("outputs"));
+
+        skillService.saveSkillMd(folderName, name, description, inputs, outputs, bodyText);
+        return Result.success("保存成功", null);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<IOField> parseIOFields(Object obj) {
+        if (obj == null) return null;
+        if (obj instanceof List) {
+            return ((List<?>) obj).stream()
+                    .map(item -> {
+                        if (item instanceof java.util.Map) {
+                            java.util.Map<String, Object> m = (java.util.Map<String, Object>) item;
+                            IOField field = new IOField();
+                            field.setName((String) m.getOrDefault("name", ""));
+                            field.setType((String) m.getOrDefault("type", "string"));
+                            field.setDescription((String) m.getOrDefault("description", ""));
+                            field.setRequired(Boolean.TRUE.equals(m.get("required")));
+                            return field;
+                        }
+                        return null;
+                    })
+                    .filter(java.util.Objects::nonNull)
+                    .collect(Collectors.toList());
+        }
+        return null;
+    }
+
+    /**
+     * 列出技能目录下的所有文件(树形结构)
+     */
+    @GetMapping("/{folderName}/files")
+    public Result<List<FileNodeDTO>> listFiles(@PathVariable String folderName) {
+        return Result.success(skillService.listSkillFiles(folderName));
+    }
+
+    /**
+     * 读取文件内容
+     */
+    @GetMapping("/{folderName}/files/content")
+    public Result<String> readFile(@PathVariable String folderName,
+                                   @RequestParam String path) {
+        return Result.success(skillService.readFile(folderName, path));
+    }
+
+    /**
+     * 保存文件内容
+     */
+    @PutMapping("/{folderName}/files/content")
+    public Result<Void> saveFile(@PathVariable String folderName,
+                                 @RequestBody Map<String, String> body) {
+        skillService.saveFile(folderName, body.get("path"), body.get("content"));
+        return Result.success("保存成功", null);
+    }
+
+    /**
+     * 创建文件或目录
+     */
+    @PostMapping("/{folderName}/files")
+    public Result<Void> createFile(@PathVariable String folderName,
+                                   @RequestBody FileCreateDTO dto) {
+        skillService.createFileOrDir(folderName, dto);
+        return Result.success("创建成功", null);
+    }
+
+    /**
+     * 删除文件或目录
+     */
+    @DeleteMapping("/{folderName}/files")
+    public Result<Void> deleteFile(@PathVariable String folderName,
+                                   @RequestParam String path) {
+        skillService.deleteFile(folderName, path);
+        return Result.success("删除成功", null);
+    }
+
     // ==================== 私有方法 ====================
 
     /**

+ 30 - 4
backend/src/main/java/com/agent/management/engine/WorkflowEngine.java

@@ -10,8 +10,8 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.io.IOException;
 import java.util.*;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * 工作流调度引擎
@@ -23,7 +23,23 @@ public class WorkflowEngine {
 
     private final Map<String, NodeExecutor> executorMap;
     private final WorkflowRepository workflowRepo;
-    private final ExecutorService executor = Executors.newCachedThreadPool();
+    private final ExecutorService executor = Executors.newFixedThreadPool(
+            Runtime.getRuntime().availableProcessors(),
+            r -> {
+                Thread t = new Thread(r, "workflow-engine");
+                t.setDaemon(true);
+                return t;
+            }
+    );
+
+    /** 工作流最大执行时间(5 分钟) */
+    private static final long MAX_EXECUTION_SECONDS = 300;
+
+    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
+        Thread t = new Thread(r, "workflow-timeout");
+        t.setDaemon(true);
+        return t;
+    });
 
     public WorkflowEngine(List<NodeExecutor> executors, WorkflowRepository workflowRepo) {
         this.executorMap = new HashMap<>();
@@ -39,7 +55,7 @@ public class WorkflowEngine {
      */
     public void executeAsync(Long workflowId, Map<String, Object> inputs, SseEmitter emitter) {
         String runId = UUID.randomUUID().toString().substring(0, 8);
-        executor.submit(() -> {
+        Future<?> future = executor.submit(() -> {
             try {
                 execute(workflowId, runId, inputs, emitter);
             } catch (Exception e) {
@@ -48,6 +64,16 @@ public class WorkflowEngine {
                 emitter.completeWithError(e);
             }
         });
+
+        // 超时保护:强制终止超时任务
+        scheduler.schedule(() -> {
+            if (!future.isDone()) {
+                future.cancel(true);
+                log.warn("[WorkflowEngine] 执行超时,强制终止: runId={}", runId);
+                safeSend(emitter, WorkflowRunEvent.workflowError(runId, "执行超时"));
+                emitter.completeWithError(new TimeoutException("工作流执行超时"));
+            }
+        }, MAX_EXECUTION_SECONDS, TimeUnit.SECONDS);
     }
 
     /**

+ 5 - 0
backend/src/main/java/com/agent/management/engine/WorkflowRunRequest.java

@@ -2,6 +2,7 @@ package com.agent.management.engine;
 
 import lombok.Data;
 
+import java.util.Collections;
 import java.util.Map;
 
 /**
@@ -10,4 +11,8 @@ import java.util.Map;
 @Data
 public class WorkflowRunRequest {
     private Map<String, Object> inputs;
+
+    public Map<String, Object> getInputs() {
+        return inputs != null ? inputs : Collections.emptyMap();
+    }
 }

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

@@ -47,7 +47,7 @@ public class AgentExecutor implements NodeExecutor {
             skill = skillService.getSkill(agentId);
             skillContent = skillService.getSkillFullContent(agentId);
         } catch (Exception e) {
-            return NodeExecutionResult.failed(nodeId, "加载 Skill 失败: " + e.getMessage());
+            return NodeExecutionResult.failed(nodeId, "加载 Skill 失败");
         }
 
         // 构造用户消息:基于 Skill 的 inputs 定义从上下文取值
@@ -70,7 +70,7 @@ public class AgentExecutor implements NodeExecutor {
 
         } catch (Exception e) {
             log.error("[Agent] 节点 {} 调用失败: {}", nodeId, e.getMessage());
-            return NodeExecutionResult.failed(nodeId, "Agent 调用失败: " + e.getMessage());
+            return NodeExecutionResult.failed(nodeId, "Agent 调用失败");
         }
     }
 

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

@@ -54,7 +54,7 @@ public class LlmExecutor implements NodeExecutor {
 
         } catch (Exception e) {
             log.error("[LLM] 节点 {} 调用失败: {}", nodeId, e.getMessage());
-            return NodeExecutionResult.failed(nodeId, "LLM 调用失败: " + e.getMessage());
+            return NodeExecutionResult.failed(nodeId, "LLM 调用失败");
         }
     }
 }

+ 20 - 0
backend/src/main/java/com/agent/management/model/dto/FileCreateDTO.java

@@ -0,0 +1,20 @@
+package com.agent.management.model.dto;
+
+import lombok.Data;
+
+/**
+ * 创建文件或目录的请求体
+ */
+@Data
+public class FileCreateDTO {
+
+    /**
+     * 相对于 Skill 根目录的路径(如 "src/newfile.py")
+     */
+    private String path;
+
+    /**
+     * 类型:file 或 directory
+     */
+    private String type;
+}

+ 32 - 0
backend/src/main/java/com/agent/management/model/dto/FileNodeDTO.java

@@ -0,0 +1,32 @@
+package com.agent.management.model.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 文件树节点(用于高级编辑模式的目录树展示)
+ */
+@Data
+public class FileNodeDTO {
+
+    /**
+     * 文件或目录名称
+     */
+    private String name;
+
+    /**
+     * 相对于 Skill 根目录的路径(如 "src/main.py")
+     */
+    private String path;
+
+    /**
+     * 类型:file 或 directory
+     */
+    private String type;
+
+    /**
+     * 子节点(仅目录有)
+     */
+    private List<FileNodeDTO> children;
+}

+ 1 - 1
backend/src/main/java/com/agent/management/model/dto/IOField.java

@@ -14,7 +14,7 @@ public class IOField {
     private String name;
 
     /**
-     * 字段类型(如 string, number, boolean, array, object)
+     * 字段类型,可选值:string, number, boolean, array, object, filePath, directoryPath
      */
     private String type;
 

+ 24 - 0
backend/src/main/java/com/agent/management/model/vo/SkillDetailVO.java

@@ -0,0 +1,24 @@
+package com.agent.management.model.vo;
+
+import com.agent.management.model.dto.IOField;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 技能详情视图对象(包含 SKILL.md 正文内容)
+ */
+@Data
+public class SkillDetailVO {
+
+    private String folderName;
+    private String name;
+    private String description;
+    private List<IOField> inputs;
+    private List<IOField> outputs;
+
+    /**
+     * SKILL.md 正文内容(元数据之后的部分)
+     */
+    private String body;
+}

+ 2 - 2
backend/src/main/java/com/agent/management/parser/SkillMarkdownParser.java

@@ -54,8 +54,8 @@ public class SkillMarkdownParser {
                 lineCount++;
                 String trimmed = line.trim();
 
-                // 跳过空行、注释行、Markdown 标题行
-                if (trimmed.isEmpty() || trimmed.startsWith("<!--") || trimmed.startsWith("#")) {
+                // 跳过空行、注释行、Markdown 标题行、frontmatter 分隔符
+                if (trimmed.isEmpty() || trimmed.startsWith("<!--") || trimmed.startsWith("#") || trimmed.equals("---")) {
                     continue;
                 }
 

+ 40 - 0
backend/src/main/java/com/agent/management/service/SkillService.java

@@ -1,7 +1,11 @@
 package com.agent.management.service;
 
+import com.agent.management.model.dto.FileCreateDTO;
+import com.agent.management.model.dto.FileNodeDTO;
+import com.agent.management.model.dto.IOField;
 import com.agent.management.model.dto.SkillDTO;
 import com.agent.management.model.dto.SkillUpdateDTO;
+import com.agent.management.model.vo.SkillDetailVO;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
@@ -40,4 +44,40 @@ public interface SkillService {
      * 读取 Skill 的 SKILL.md 完整内容(用作 Agent 系统提示词)
      */
     String getSkillFullContent(String folderName);
+
+    /**
+     * 获取 Skill 详情(含正文)
+     */
+    SkillDetailVO getSkillDetail(String folderName);
+
+    /**
+     * 保存 SKILL.md(智能合并:保留原有非标准元数据字段)
+     */
+    void saveSkillMd(String folderName, String name, String description,
+                     List<IOField> inputs, List<IOField> outputs, String body);
+
+    /**
+     * 列出 Skill 目录下的所有文件(树形结构)
+     */
+    List<FileNodeDTO> listSkillFiles(String folderName);
+
+    /**
+     * 读取 Skill 目录下的文件内容
+     */
+    String readFile(String folderName, String relativePath);
+
+    /**
+     * 保存文件内容
+     */
+    void saveFile(String folderName, String relativePath, String content);
+
+    /**
+     * 创建文件或目录
+     */
+    void createFileOrDir(String folderName, FileCreateDTO dto);
+
+    /**
+     * 删除文件或目录
+     */
+    void deleteFile(String folderName, String relativePath);
 }

+ 5 - 0
backend/src/main/java/com/agent/management/service/SseService.java

@@ -17,12 +17,17 @@ import java.util.concurrent.CopyOnWriteArrayList;
 @Service
 public class SseService {
 
+    private static final int MAX_EMITTERS = 50;
+
     private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
 
     /**
      * 创建新的 SSE 连接
      */
     public SseEmitter createEmitter() {
+        if (emitters.size() >= MAX_EMITTERS) {
+            throw new com.agent.management.common.exception.BusinessException(503, "SSE 连接数已满,请稍后重试");
+        }
         // 5 分钟超时
         SseEmitter emitter = new SseEmitter(300_000L);
         emitters.add(emitter);

+ 387 - 0
backend/src/main/java/com/agent/management/service/impl/SkillServiceImpl.java

@@ -2,9 +2,12 @@ package com.agent.management.service.impl;
 
 import com.agent.management.common.exception.BusinessException;
 import com.agent.management.config.SkillProperties;
+import com.agent.management.model.dto.FileCreateDTO;
+import com.agent.management.model.dto.FileNodeDTO;
 import com.agent.management.model.dto.IOField;
 import com.agent.management.model.dto.SkillDTO;
 import com.agent.management.model.dto.SkillUpdateDTO;
+import com.agent.management.model.vo.SkillDetailVO;
 import com.agent.management.parser.SkillMarkdownParser;
 import com.agent.management.service.SkillService;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -23,6 +26,7 @@ import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.*;
 import java.util.*;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 @Slf4j
@@ -256,6 +260,260 @@ public class SkillServiceImpl implements SkillService {
         }
     }
 
+    @Override
+    public SkillDetailVO getSkillDetail(String folderName) {
+        SkillDTO skill = getSkill(folderName);
+        String fullContent = getSkillFullContent(folderName);
+        String body = extractBody(fullContent);
+
+        SkillDetailVO detail = new SkillDetailVO();
+        detail.setFolderName(skill.getFolderName());
+        detail.setName(skill.getName());
+        detail.setDescription(skill.getDescription());
+        detail.setInputs(skill.getInputs());
+        detail.setOutputs(skill.getOutputs());
+        detail.setBody(body);
+        return detail;
+    }
+
+    @Override
+    public void saveSkillMd(String folderName, String name, String description,
+                            List<IOField> inputs, List<IOField> outputs, String body) {
+        Path skillMd = resolveSkillPath(folderName, SKILL_MD_FILENAME);
+        try {
+            // 读取原文件内容
+            String original = Files.exists(skillMd)
+                    ? Files.readString(skillMd, StandardCharsets.UTF_8)
+                    : "";
+
+            String merged = mergeSkillMd(original, name, description, inputs, outputs, body);
+            Files.writeString(skillMd, merged, StandardCharsets.UTF_8);
+            log.info("保存 SKILL.md 成功: {}", folderName);
+        } catch (IOException e) {
+            log.error("保存 SKILL.md 失败: {}", skillMd, e);
+            throw new BusinessException("保存 SKILL.md 失败");
+        }
+    }
+
+    /**
+     * 智能合并 SKILL.md 内容
+     * - 保留 frontmatter 中的非标准字段(如 version, source 等)
+     * - 替换/新增 name, description, inputs, outputs
+     * - 无 frontmatter 时自动包装
+     */
+    private String mergeSkillMd(String original, String name, String description,
+                                List<IOField> inputs, List<IOField> outputs, String body) {
+        String[] lines = original.split("\n");
+        List<String> metaLines = new ArrayList<>();  // frontmatter 内的行(不含 ---)
+        List<String> bodyLines = new ArrayList<>();  // 正文行
+        boolean hasFrontmatter = false;
+        int closeIdx = -1;
+
+        // 检测 frontmatter 并分离
+        if (lines.length > 0 && lines[0].trim().equals("---")) {
+            for (int i = 1; i < lines.length; i++) {
+                if (lines[i].trim().equals("---")) {
+                    hasFrontmatter = true;
+                    closeIdx = i;
+                    break;
+                }
+                metaLines.add(lines[i]);
+            }
+        }
+
+        if (hasFrontmatter) {
+            // 从关闭 --- 之后提取正文(跳过空行)
+            int bodyStart = closeIdx + 1;
+            while (bodyStart < lines.length && lines[bodyStart].trim().isEmpty()) {
+                bodyStart++;
+            }
+            for (int i = bodyStart; i < lines.length; i++) {
+                bodyLines.add(lines[i]);
+            }
+        } else {
+            // 无 frontmatter:提取元数据行和正文
+            String[] prefixes = {"name:", "description:", "inputs:", "outputs:"};
+            int metaEnd = -1;
+            for (int i = 0; i < lines.length && i < 30; i++) {
+                String trimmed = lines[i].trim();
+                if (trimmed.isEmpty() || trimmed.startsWith("<!--") || trimmed.startsWith("#")) {
+                    if (metaEnd >= 0) break;
+                    continue;
+                }
+                String lower = trimmed.toLowerCase();
+                boolean isMetadata = false;
+                for (String prefix : prefixes) {
+                    if (lower.startsWith(prefix)) {
+                        isMetadata = true;
+                        break;
+                    }
+                }
+                if (isMetadata) {
+                    metaLines.add(trimmed);
+                    metaEnd = i;
+                } else if (metaEnd >= 0) {
+                    // 元数据之后遇到了非元数据行
+                    break;
+                }
+            }
+
+            // 提取正文
+            if (metaEnd >= 0) {
+                int bodyStart = metaEnd + 1;
+                while (bodyStart < lines.length && lines[bodyStart].trim().isEmpty()) {
+                    bodyStart++;
+                }
+                for (int i = bodyStart; i < lines.length; i++) {
+                    bodyLines.add(lines[i]);
+                }
+            } else {
+                // 完全没有元数据,全部当作正文
+                for (String line : lines) {
+                    bodyLines.add(line);
+                }
+            }
+        }
+
+        // 合并 metaLines:替换标准字段,保留其他字段
+        ObjectMapper mapper = new ObjectMapper();
+        Set<String> standardKeys = Set.of("name", "description", "inputs", "outputs");
+        List<String> preserved = new ArrayList<>();  // 非标准字段
+        for (String line : metaLines) {
+            String lower = line.toLowerCase();
+            boolean isStandard = false;
+            for (String key : standardKeys) {
+                if (lower.startsWith(key + ":")) {
+                    isStandard = true;
+                    break;
+                }
+            }
+            if (!isStandard) {
+                preserved.add(line);
+            }
+        }
+
+        // 重建 frontmatter:标准字段按顺序 + 非标准字段
+        List<String> newMeta = new ArrayList<>();
+        newMeta.add("name: " + name);
+        newMeta.add("description: " + description);
+        try {
+            if (inputs != null && !inputs.isEmpty()) {
+                newMeta.add("inputs: " + mapper.writeValueAsString(inputs));
+            }
+            if (outputs != null && !outputs.isEmpty()) {
+                newMeta.add("outputs: " + mapper.writeValueAsString(outputs));
+            }
+        } catch (Exception e) {
+            log.warn("序列化 inputs/outputs 失败", e);
+        }
+        newMeta.addAll(preserved);
+
+        // 用 body 替换正文(如果 body 非空则使用新 body,否则保留原正文)
+        String bodyContent = (body != null && !body.isEmpty()) ? body : String.join("\n", bodyLines);
+
+        return "---\n" + String.join("\n", newMeta) + "\n---\n\n" + bodyContent;
+    }
+
+    @Override
+    public List<FileNodeDTO> listSkillFiles(String folderName) {
+        Path skillDir = getBasePath().resolve(folderName);
+        validateSkillDir(skillDir, folderName);
+        try {
+            return buildFileTree(skillDir, skillDir);
+        } catch (IOException e) {
+            log.error("列出文件失败: {}", skillDir, e);
+            throw new BusinessException("列出文件失败");
+        }
+    }
+
+    @Override
+    public String readFile(String folderName, String relativePath) {
+        Path file = resolveSkillPath(folderName, relativePath);
+        validateWithinSkillDir(file, folderName);
+        if (!Files.exists(file) || Files.isDirectory(file)) {
+            throw new BusinessException(404, "文件不存在: " + relativePath);
+        }
+        try {
+            return Files.readString(file, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            log.error("读取文件失败: {}", file, e);
+            throw new BusinessException("读取文件失败");
+        }
+    }
+
+    @Override
+    public void saveFile(String folderName, String relativePath, String content) {
+        Path file = resolveSkillPath(folderName, relativePath);
+        validateWithinSkillDir(file, folderName);
+        if (Files.isDirectory(file)) {
+            throw new BusinessException("目标是目录,不能保存: " + relativePath);
+        }
+        try {
+            Files.createDirectories(file.getParent());
+            Files.writeString(file, content, StandardCharsets.UTF_8);
+            log.info("保存文件成功: {}", file);
+        } catch (IOException e) {
+            log.error("保存文件失败: {}", file, e);
+            throw new BusinessException("保存文件失败");
+        }
+    }
+
+    @Override
+    public void createFileOrDir(String folderName, FileCreateDTO dto) {
+        if (dto.getPath() == null || dto.getPath().trim().isEmpty()) {
+            throw new BusinessException("路径不能为空");
+        }
+        Path target = resolveSkillPath(folderName, dto.getPath());
+        validateWithinSkillDir(target, folderName);
+
+        if (Files.exists(target)) {
+            throw new BusinessException(409, "已存在: " + dto.getPath());
+        }
+
+        try {
+            if ("directory".equals(dto.getType())) {
+                Files.createDirectories(target);
+                log.info("创建目录成功: {}", target);
+            } else {
+                Files.createDirectories(target.getParent());
+                Files.writeString(target, "", StandardCharsets.UTF_8);
+                log.info("创建文件成功: {}", target);
+            }
+        } catch (IOException e) {
+            log.error("创建失败: {}", target, e);
+            throw new BusinessException("创建失败");
+        }
+    }
+
+    @Override
+    public void deleteFile(String folderName, String relativePath) {
+        if (relativePath == null || relativePath.trim().isEmpty()) {
+            throw new BusinessException("路径不能为空");
+        }
+        // 禁止删除 SKILL.md
+        if (SKILL_MD_FILENAME.equalsIgnoreCase(relativePath)) {
+            throw new BusinessException("不能删除 SKILL.md 文件");
+        }
+        Path target = resolveSkillPath(folderName, relativePath);
+        validateWithinSkillDir(target, folderName);
+
+        if (!Files.exists(target)) {
+            throw new BusinessException(404, "不存在: " + relativePath);
+        }
+
+        try {
+            if (Files.isDirectory(target)) {
+                FileUtils.deleteDirectory(target.toFile());
+            } else {
+                Files.delete(target);
+            }
+            log.info("删除成功: {}", target);
+        } catch (IOException e) {
+            log.error("删除失败: {}", target, e);
+            throw new BusinessException("删除失败");
+        }
+    }
+
     // ==================== 私有方法 ====================
 
     private Path getBasePath() {
@@ -331,4 +589,133 @@ public class SkillServiceImpl implements SkillService {
 
         return null;
     }
+
+    /**
+     * 从 SKILL.md 完整内容中提取正文部分(元数据之后的内容)
+     */
+    private String extractBody(String fullContent) {
+        if (fullContent == null || fullContent.isEmpty()) return "";
+
+        String[] lines = fullContent.split("\n");
+        int bodyStart = -1;
+
+        // 检测 --- frontmatter 格式
+        if (lines.length > 0 && lines[0].trim().equals("---")) {
+            int closeIdx = -1;
+            for (int i = 1; i < lines.length; i++) {
+                if (lines[i].trim().equals("---")) {
+                    closeIdx = i;
+                    break;
+                }
+            }
+            if (closeIdx >= 0) {
+                bodyStart = closeIdx + 1;
+            }
+        }
+
+        // 回退:无 frontmatter,按元数据行前缀检测
+        if (bodyStart < 0) {
+            int metadataFieldsFound = 0;
+            String[] prefixes = {"name:", "description:", "inputs:", "outputs:"};
+
+            for (int i = 0; i < lines.length && i < 30; i++) {
+                String trimmed = lines[i].trim();
+                if (trimmed.isEmpty() || trimmed.startsWith("<!--") || trimmed.startsWith("#") || trimmed.equals("---")) {
+                    continue;
+                }
+
+                String lower = trimmed.toLowerCase();
+                boolean isMetadata = false;
+                for (String prefix : prefixes) {
+                    if (lower.startsWith(prefix)) {
+                        metadataFieldsFound++;
+                        isMetadata = true;
+                        break;
+                    }
+                }
+
+                if (isMetadata) {
+                    if (metadataFieldsFound == 4) {
+                        bodyStart = i + 1;
+                        break;
+                    }
+                    continue;
+                }
+
+                bodyStart = i;
+                break;
+            }
+        }
+
+        if (bodyStart < 0 || bodyStart >= lines.length) return "";
+
+        // 跳过正文开头的空行
+        while (bodyStart < lines.length && lines[bodyStart].trim().isEmpty()) {
+            bodyStart++;
+        }
+        if (bodyStart >= lines.length) return "";
+
+        return String.join("\n", Arrays.copyOfRange(lines, bodyStart, lines.length));
+    }
+
+    /**
+     * 递归构建文件树
+     */
+    private List<FileNodeDTO> buildFileTree(Path dir, Path root) throws IOException {
+        try (Stream<Path> paths = Files.list(dir)) {
+            return paths
+                    .sorted(Comparator.comparing((Path p) -> !Files.isDirectory(p))
+                            .thenComparing(p -> p.getFileName().toString()))
+                    .map(p -> {
+                        FileNodeDTO node = new FileNodeDTO();
+                        node.setName(p.getFileName().toString());
+                        node.setPath(root.relativize(p).toString().replace('\\', '/'));
+                        if (Files.isDirectory(p)) {
+                            node.setType("directory");
+                            try {
+                                node.setChildren(buildFileTree(p, root));
+                            } catch (IOException e) {
+                                node.setChildren(Collections.emptyList());
+                            }
+                        } else {
+                            node.setType("file");
+                        }
+                        return node;
+                    })
+                    .collect(Collectors.toList());
+        }
+    }
+
+    /**
+     * 解析 Skill 目录下的相对路径
+     */
+    private Path resolveSkillPath(String folderName, String relativePath) {
+        Path skillDir = getBasePath().resolve(folderName);
+        validateSkillDir(skillDir, folderName);
+        Path resolved = skillDir.resolve(relativePath).normalize();
+        // 路径穿越检查
+        if (!resolved.startsWith(skillDir.normalize())) {
+            throw new BusinessException("非法路径: " + relativePath);
+        }
+        return resolved;
+    }
+
+    /**
+     * 验证 Skill 目录存在
+     */
+    private void validateSkillDir(Path skillDir, String folderName) {
+        if (!Files.exists(skillDir) || !Files.isDirectory(skillDir)) {
+            throw new BusinessException(404, "Skill 不存在: " + folderName);
+        }
+    }
+
+    /**
+     * 验证路径在 Skill 目录内(路径穿越检查)
+     */
+    private void validateWithinSkillDir(Path path, String folderName) {
+        Path skillDir = getBasePath().resolve(folderName).normalize();
+        if (!path.normalize().startsWith(skillDir)) {
+            throw new BusinessException("非法路径");
+        }
+    }
 }

+ 62 - 4
backend/src/main/java/com/agent/management/service/impl/TranslationServiceImpl.java

@@ -8,6 +8,8 @@ import org.springframework.ai.chat.client.ChatClient;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
@@ -22,7 +24,8 @@ import java.util.concurrent.TimeoutException;
 @Service
 public class TranslationServiceImpl implements TranslationService {
 
-    private static final long TRANSLATE_TIMEOUT_SECONDS = 60;
+    private static final long TRANSLATE_TIMEOUT_SECONDS = 120;
+    private static final int MAX_CHUNK_LENGTH = 4000;
 
     private final ChatClient chatClient;
     private final SseService sseService;
@@ -117,16 +120,29 @@ public class TranslationServiceImpl implements TranslationService {
     }
 
     private String doTranslate(String text) {
-        // 双重检查:在持锁前可能另一个线程已完成
         String key = text.trim();
         String cached = translationCache.get(key);
         if (cached != null) return cached;
 
-        log.info("[翻译] 开始调用大模型翻译, 原文: {}", truncate(text, 100));
+        // 短文本直接翻译
+        if (text.length() <= MAX_CHUNK_LENGTH) {
+            return doTranslateSingle(text);
+        }
+
+        // 长文本:按段落分段翻译
+        return doTranslateChunked(text);
+    }
+
+    private String doTranslateSingle(String text) {
+        String key = text.trim();
+        String cached = translationCache.get(key);
+        if (cached != null) return cached;
+
+        log.info("[翻译] 开始调用大模型翻译, 原文长度: {} 字符", text.length());
         long startTime = System.currentTimeMillis();
         try {
             String translated = chatClient.prompt()
-                    .user(u -> u.text("将以下英文翻译为简体中文,只返回翻译结果,不要添加任何解释或额外文本:\n\n{text}")
+                    .user(u -> u.text("将以下英文翻译为简体中文,专有英文缩写、代码等不用翻译,只返回翻译结果,不要添加任何解释或额外文本:\n\n{text}")
                             .param("text", text))
                     .call()
                     .content();
@@ -146,6 +162,48 @@ public class TranslationServiceImpl implements TranslationService {
         }
     }
 
+    /**
+     * 长文本分段翻译:按空行切分段落,每段不超过 MAX_CHUNK_LENGTH,逐段翻译后拼接
+     */
+    private String doTranslateChunked(String text) {
+        log.info("[翻译] 长文本分段翻译开始, 总长度: {} 字符", text.length());
+        long startTime = System.currentTimeMillis();
+
+        String[] paragraphs = text.split("\n\\s*\n");
+        List<String> chunks = new ArrayList<>();
+        StringBuilder currentChunk = new StringBuilder();
+
+        for (String para : paragraphs) {
+            if (currentChunk.length() + para.length() + 2 > MAX_CHUNK_LENGTH && currentChunk.length() > 0) {
+                chunks.add(currentChunk.toString());
+                currentChunk = new StringBuilder();
+            }
+            if (currentChunk.length() > 0) currentChunk.append("\n\n");
+            currentChunk.append(para);
+        }
+        if (currentChunk.length() > 0) {
+            chunks.add(currentChunk.toString());
+        }
+
+        log.info("[翻译] 分为 {} 段", chunks.size());
+
+        StringBuilder result = new StringBuilder();
+        for (int i = 0; i < chunks.size(); i++) {
+            String chunk = chunks.get(i);
+            log.info("[翻译] 翻译第 {}/{} 段, 长度: {}", i + 1, chunks.size(), chunk.length());
+            String translated = doTranslateSingle(chunk);
+            if (i > 0) result.append("\n\n");
+            result.append(translated);
+        }
+
+        long elapsed = System.currentTimeMillis() - startTime;
+        log.info("[翻译] 长文本分段翻译完成, 总耗时: {}ms", elapsed);
+
+        String finalResult = result.toString();
+        translationCache.put(text.trim(), finalResult);
+        return finalResult;
+    }
+
     private String truncate(String text, int maxLen) {
         if (text == null) return "null";
         if (text.length() <= maxLen) return text;

+ 1 - 1
frontend/index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8" />
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>frontend</title>
+    <title>智能体管理平台</title>
   </head>
   <body>
     <div id="app"></div>

+ 39 - 0
frontend/package-lock.json

@@ -12,6 +12,7 @@
         "@vue-flow/background": "^1.3.2",
         "@vue-flow/core": "^1.48.2",
         "axios": "^1.16.1",
+        "monaco-editor": "^0.55.1",
         "naive-ui": "^2.44.1",
         "pinia": "^3.0.4",
         "vue": "^3.5.34",
@@ -457,6 +458,13 @@
         "@types/lodash": "*"
       }
     },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+      "license": "MIT",
+      "optional": true
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.20",
       "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
@@ -1000,6 +1008,15 @@
         "node": ">=8"
       }
     },
+    "node_modules/dompurify": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz",
+      "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
+      "license": "(MPL-2.0 OR Apache-2.0)",
+      "optionalDependencies": {
+        "@types/trusted-types": "^2.0.7"
+      }
+    },
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1571,6 +1588,18 @@
         "@jridgewell/sourcemap-codec": "^1.5.5"
       }
     },
+    "node_modules/marked": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz",
+      "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1607,6 +1636,16 @@
       "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
       "license": "MIT"
     },
+    "node_modules/monaco-editor": {
+      "version": "0.55.1",
+      "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.55.1.tgz",
+      "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
+      "license": "MIT",
+      "dependencies": {
+        "dompurify": "3.2.7",
+        "marked": "14.0.0"
+      }
+    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",

+ 1 - 0
frontend/package.json

@@ -13,6 +13,7 @@
     "@vue-flow/background": "^1.3.2",
     "@vue-flow/core": "^1.48.2",
     "axios": "^1.16.1",
+    "monaco-editor": "^0.55.1",
     "naive-ui": "^2.44.1",
     "pinia": "^3.0.4",
     "vue": "^3.5.34",

+ 30 - 0
frontend/src/api/skill.js

@@ -20,3 +20,33 @@ export function editSkill(folderName, data) {
 export function deleteSkill(folderName) {
   return request.post(`/skills/${encodeURIComponent(folderName)}/delete`)
 }
+
+// ==================== 技能编辑相关 ====================
+
+export function getSkillDetail(folderName) {
+  return request.get(`/skills/${encodeURIComponent(folderName)}/detail`)
+}
+
+export function saveSkillMd(folderName, data) {
+  return request.put(`/skills/${encodeURIComponent(folderName)}/skill-md`, data)
+}
+
+export function listSkillFiles(folderName) {
+  return request.get(`/skills/${encodeURIComponent(folderName)}/files`)
+}
+
+export function readSkillFile(folderName, path) {
+  return request.get(`/skills/${encodeURIComponent(folderName)}/files/content`, { params: { path } })
+}
+
+export function saveSkillFile(folderName, path, content) {
+  return request.put(`/skills/${encodeURIComponent(folderName)}/files/content`, { path, content })
+}
+
+export function createSkillFile(folderName, path, type) {
+  return request.post(`/skills/${encodeURIComponent(folderName)}/files`, { path, type })
+}
+
+export function deleteSkillFile(folderName, path) {
+  return request.delete(`/skills/${encodeURIComponent(folderName)}/files`, { params: { path } })
+}

+ 21 - 2
frontend/src/api/translation.js

@@ -1,5 +1,24 @@
-import request from '../utils/request'
+import axios from 'axios'
 
+const baseURL = '/api'
+
+/**
+ * 翻译文本,根据文本长度动态设置超时
+ * 按 20 字符/秒的最低速率 × 2 倍冗余计算
+ */
 export function translateText(text) {
-  return request.post('/translations/translate', { text })
+  const charsPerSec = 20
+  const multiplier = 2
+  const minTimeout = 30000
+  const dynamicTimeout = Math.max(minTimeout, Math.ceil((text.length / charsPerSec) * multiplier) * 1000)
+
+  return axios.post(`${baseURL}/translations/translate`, { text }, {
+    timeout: dynamicTimeout
+  }).then(response => {
+    const res = response.data
+    if (res.code !== 200) {
+      return Promise.reject(new Error(res.message || '翻译失败'))
+    }
+    return res
+  })
 }

+ 114 - 16
frontend/src/components/layout/AppSidebar.vue

@@ -1,23 +1,40 @@
 <script setup>
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import {
+  RocketOutline,
   GridOutline,
   GitMergeOutline,
-  PricetagsOutline
+  PricetagsOutline,
+  ChevronDownOutline
 } from '@vicons/ionicons5'
+import { NIcon } from 'naive-ui'
 
 const route = useRoute()
 const router = useRouter()
 
 const menuItems = [
-  { key: '/management', label: '智能体管理', icon: GridOutline },
-  { key: '/orchestration', label: '智能体编排', icon: GitMergeOutline },
+  {
+    group: '智能体管理',
+    icon: RocketOutline,
+    children: [
+      { key: '/management', label: '技能管理', icon: GridOutline },
+      { key: '/orchestration', label: '智能体编排', icon: GitMergeOutline }
+    ]
+  },
   { key: '/tags', label: '标签管理', icon: PricetagsOutline }
 ]
 
+// 分组是否展开(默认展开)
+const groupExpanded = ref(true)
+
 const activeKey = computed(() => route.path)
 
+// 判断分组中是否有激活项
+function isGroupActive(group) {
+  return group.children?.some(c => activeKey.value.startsWith(c.key))
+}
+
 function navigateTo(path) {
   router.push(path)
 }
@@ -48,19 +65,54 @@ function navigateTo(path) {
 
     <!-- 导航菜单 -->
     <nav class="sidebar-nav">
-      <div
-        v-for="item in menuItems"
-        :key="item.key"
-        class="nav-item"
-        :class="{ active: activeKey === item.key }"
-        @click="navigateTo(item.key)"
-      >
-        <div class="nav-item-icon">
-          <component :is="item.icon" />
+      <template v-for="item in menuItems" :key="item.key || item.group">
+        <!-- 分组菜单 -->
+        <template v-if="item.children">
+          <div
+            class="nav-group-header"
+            :class="{ active: isGroupActive(item) }"
+            @click="groupExpanded = !groupExpanded"
+          >
+            <div class="nav-group-left">
+              <div class="nav-item-icon">
+                <component :is="item.icon" />
+              </div>
+              <span class="nav-item-label">{{ item.group }}</span>
+            </div>
+            <div class="nav-group-arrow" :class="{ expanded: groupExpanded }">
+              <n-icon size="14"><ChevronDownOutline /></n-icon>
+            </div>
+          </div>
+          <div v-show="groupExpanded" class="nav-group-children">
+            <div
+              v-for="child in item.children"
+              :key="child.key"
+              class="nav-item sub-item"
+              :class="{ active: activeKey === child.key }"
+              @click="navigateTo(child.key)"
+            >
+              <div class="nav-item-icon">
+                <component :is="child.icon" />
+              </div>
+              <span class="nav-item-label">{{ child.label }}</span>
+              <div v-if="activeKey === child.key" class="nav-item-indicator"></div>
+            </div>
+          </div>
+        </template>
+        <!-- 普通菜单项 -->
+        <div
+          v-else
+          class="nav-item"
+          :class="{ active: activeKey === item.key }"
+          @click="navigateTo(item.key)"
+        >
+          <div class="nav-item-icon">
+            <component :is="item.icon" />
+          </div>
+          <span class="nav-item-label">{{ item.label }}</span>
+          <div v-if="activeKey === item.key" class="nav-item-indicator"></div>
         </div>
-        <span class="nav-item-label">{{ item.label }}</span>
-        <div v-if="activeKey === item.key" class="nav-item-indicator"></div>
-      </div>
+      </template>
     </nav>
 
     <!-- 底部装饰 -->
@@ -151,6 +203,10 @@ function navigateTo(path) {
   color: var(--color-accent);
 }
 
+.nav-item.sub-item {
+  padding: 10px 16px 10px 48px;
+}
+
 .nav-item-icon {
   width: 20px;
   height: 20px;
@@ -177,6 +233,48 @@ function navigateTo(path) {
   box-shadow: 0 0 10px rgba(37, 99, 235, 0.5);
 }
 
+/* 分组 */
+.nav-group-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  border-radius: var(--radius-md);
+  cursor: pointer;
+  transition: all var(--transition-normal);
+  color: var(--text-secondary);
+}
+
+.nav-group-header:hover {
+  background: rgba(37, 99, 235, 0.08);
+  color: var(--text-primary);
+}
+
+.nav-group-header.active {
+  color: var(--text-primary);
+}
+
+.nav-group-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.nav-group-arrow {
+  transition: transform 0.2s;
+  color: var(--text-tertiary);
+}
+
+.nav-group-arrow.expanded {
+  transform: rotate(180deg);
+}
+
+.nav-group-children {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
 /* 底部 */
 .sidebar-footer {
   padding: 16px 20px 24px;

+ 111 - 0
frontend/src/components/skill/CodeEditor.vue

@@ -0,0 +1,111 @@
+<script setup>
+import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
+import { setupMonaco } from '../../utils/monacoSetup'
+
+const props = defineProps({
+  modelValue: { type: String, default: '' },
+  language: { type: String, default: 'markdown' },
+  readOnly: { type: Boolean, default: false },
+  minimap: { type: Boolean, default: false },
+  lineNumbers: { type: Boolean, default: true },
+  wordWrap: { type: Boolean, default: true },
+  height: { type: String, default: '400px' }
+})
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+const editorContainer = ref(null)
+let editor = null
+
+// 语言映射表
+function getLanguage(filename) {
+  if (!filename) return 'plaintext'
+  const ext = filename.split('.').pop().toLowerCase()
+  const map = {
+    js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
+    json: 'json', html: 'html', css: 'css', scss: 'scss', less: 'less',
+    md: 'markdown', py: 'python', java: 'java', xml: 'xml', yaml: 'yaml',
+    yml: 'yaml', sh: 'shell', bash: 'shell', sql: 'sql', rs: 'rust',
+    go: 'go', vue: 'html', txt: 'plaintext'
+  }
+  return map[ext] || 'plaintext'
+}
+
+onMounted(async () => {
+  setupMonaco()
+  const monaco = await import('monaco-editor')
+
+  await nextTick()
+
+  editor = monaco.editor.create(editorContainer.value, {
+    value: props.modelValue || '',
+    language: props.language,
+    theme: 'vs-dark',
+    readOnly: props.readOnly,
+    minimap: { enabled: props.minimap },
+    lineNumbers: props.lineNumbers ? 'on' : 'off',
+    wordWrap: props.wordWrap ? 'on' : 'off',
+    automaticLayout: true,
+    fontSize: 13,
+    lineHeight: 20,
+    scrollBeyondLastLine: false,
+    renderLineHighlight: 'gutter',
+    padding: { top: 12, bottom: 12 },
+    overviewRulerBorder: false,
+    scrollbar: {
+      verticalScrollbarSize: 8,
+      horizontalScrollbarSize: 8
+    }
+  })
+
+  editor.onDidChangeModelContent(() => {
+    const value = editor.getValue()
+    emit('update:modelValue', value)
+    emit('change', value)
+  })
+})
+
+onBeforeUnmount(() => {
+  if (editor) {
+    editor.dispose()
+    editor = null
+  }
+})
+
+// 外部 modelValue 变化时更新编辑器
+watch(() => props.modelValue, (newVal) => {
+  if (editor && editor.getValue() !== newVal) {
+    editor.setValue(newVal || '')
+  }
+})
+
+// 外部 language 变化时更新
+watch(() => props.language, (newLang) => {
+  if (editor) {
+    const model = editor.getModel()
+    if (model) {
+      import('monaco-editor').then(monaco => {
+        monaco.editor.setModelLanguage(model, newLang)
+      })
+    }
+  }
+})
+
+defineExpose({ getLanguage })
+</script>
+
+<template>
+  <div
+    ref="editorContainer"
+    class="code-editor"
+    :style="{ height }"
+  />
+</template>
+
+<style scoped>
+.code-editor {
+  width: 100%;
+  border-radius: 6px;
+  overflow: hidden;
+}
+</style>

+ 366 - 0
frontend/src/components/skill/FileTree.vue

@@ -0,0 +1,366 @@
+<script setup>
+import { ref, h } from 'vue'
+import { NIcon, NInput, useDialog } from 'naive-ui'
+import {
+  FolderOutline, FolderOpenOutline, DocumentOutline,
+  CreateOutline, AddOutline, TrashOutline
+} from '@vicons/ionicons5'
+
+const props = defineProps({
+  files: { type: Array, default: () => [] },
+  currentFile: { type: String, default: '' }
+})
+
+const emit = defineEmits(['select', 'create-file', 'create-dir', 'delete'])
+
+const dialog = useDialog()
+const expandedDirs = ref(new Set())
+
+function toggleDir(path) {
+  const s = new Set(expandedDirs.value)
+  if (s.has(path)) s.delete(path)
+  else s.add(path)
+  expandedDirs.value = s
+}
+
+function isExpanded(path) {
+  return expandedDirs.value.has(path)
+}
+
+function handleNodeClick(node) {
+  if (node.type === 'directory') {
+    toggleDir(node.path)
+  } else {
+    emit('select', node.path)
+  }
+}
+
+// ==================== 弹窗式创建/删除 ====================
+
+function showCreateDialog(title, placeholder, onConfirm) {
+  let inputValue = ''
+  dialog.create({
+    title,
+    content: () => h(NInput, {
+      placeholder,
+      autofocus: true,
+      'onUpdate:value': (val) => { inputValue = val }
+    }),
+    positiveText: '确定',
+    negativeText: '取消',
+    onPositiveClick: () => {
+      const name = inputValue.trim()
+      if (!name) return false
+      onConfirm(name)
+    }
+  })
+}
+
+function handleCreateInDir(node, type) {
+  const prefix = node.path + '/'
+  if (type === 'file') {
+    showCreateDialog('新建文件', '输入文件名(如 main.py)', (name) => {
+      emit('create-file', prefix + name)
+    })
+  } else {
+    showCreateDialog('新建目录', '输入目录名', (name) => {
+      emit('create-dir', prefix + name)
+    })
+  }
+}
+
+function handleCreateAtRoot(type) {
+  if (type === 'file') {
+    showCreateDialog('新建文件', '输入文件名(如 main.py)', (name) => {
+      emit('create-file', name)
+    })
+  } else {
+    showCreateDialog('新建目录', '输入目录名', (name) => {
+      emit('create-dir', name)
+    })
+  }
+}
+
+function handleDelete(node) {
+  if (node.path === 'SKILL.md') return
+  dialog.warning({
+    title: '确认删除',
+    content: `确定要删除「${node.name}」吗?${node.type === 'directory' ? '目录下的所有文件也会被删除。' : ''}`,
+    positiveText: '确认删除',
+    negativeText: '取消',
+    onPositiveClick: () => {
+      emit('delete', node.path)
+    }
+  })
+}
+
+function getIconColor(node) {
+  if (node.type === 'directory') return '#e0a526'
+  const ext = node.name.split('.').pop().toLowerCase()
+  const colors = {
+    md: '#519aba', js: '#cbcb41', ts: '#3178c6', json: '#cbcb41',
+    py: '#3572a5', java: '#b07219', css: '#563d7c', html: '#e34c26',
+    sh: '#89e051', yaml: '#cb171e', yml: '#cb171e'
+  }
+  return colors[ext] || '#8b8b8b'
+}
+</script>
+
+<template>
+  <div class="file-tree">
+    <!-- 顶部操作栏 -->
+    <div class="tree-toolbar">
+      <span class="tree-title">文件浏览器</span>
+      <div class="tree-actions">
+        <button class="tree-action-btn" title="新建文件" @click="handleCreateAtRoot('file')">
+          <n-icon size="14"><CreateOutline /></n-icon>
+        </button>
+        <button class="tree-action-btn" title="新建目录" @click="handleCreateAtRoot('directory')">
+          <n-icon size="14"><AddOutline /></n-icon>
+        </button>
+      </div>
+    </div>
+
+    <!-- 文件树 -->
+    <div class="tree-content">
+      <template v-for="node in files" :key="node.path">
+        <div
+          class="tree-node"
+          :class="{ active: currentFile === node.path }"
+          :style="{ paddingLeft: '12px' }"
+          @click="handleNodeClick(node)"
+        >
+          <span v-if="node.type === 'directory'" class="tree-arrow" :class="{ expanded: isExpanded(node.path) }">
+            ›
+          </span>
+          <span v-else class="tree-arrow-spacer" />
+
+          <n-icon size="15" :color="getIconColor(node)" class="tree-icon">
+            <FolderOpenOutline v-if="node.type === 'directory' && isExpanded(node.path)" />
+            <FolderOutline v-else-if="node.type === 'directory'" />
+            <DocumentOutline v-else />
+          </n-icon>
+
+          <span class="tree-name">{{ node.name }}</span>
+
+          <div v-if="node.type === 'directory'" class="node-actions">
+            <button class="node-action" title="新建文件" @click.stop="handleCreateInDir(node, 'file')">
+              <n-icon size="12"><CreateOutline /></n-icon>
+            </button>
+            <button class="node-action" title="删除" @click.stop="handleDelete(node)">
+              <n-icon size="12"><TrashOutline /></n-icon>
+            </button>
+          </div>
+          <div v-else class="node-actions">
+            <button v-if="node.path !== 'SKILL.md'" class="node-action" title="删除" @click.stop="handleDelete(node)">
+              <n-icon size="12"><TrashOutline /></n-icon>
+            </button>
+          </div>
+        </div>
+
+        <!-- 子节点:紧跟父节点渲染 -->
+        <template v-if="node.type === 'directory' && isExpanded(node.path) && node.children">
+          <template v-for="child in node.children" :key="child.path">
+            <div
+              class="tree-node"
+              :class="{ active: currentFile === child.path }"
+              :style="{ paddingLeft: '28px' }"
+              @click="handleNodeClick(child)"
+            >
+              <span v-if="child.type === 'directory'" class="tree-arrow" :class="{ expanded: isExpanded(child.path) }">›</span>
+              <span v-else class="tree-arrow-spacer" />
+
+              <n-icon size="15" :color="getIconColor(child)" class="tree-icon">
+                <FolderOpenOutline v-if="child.type === 'directory' && isExpanded(child.path)" />
+                <FolderOutline v-else-if="child.type === 'directory'" />
+                <DocumentOutline v-else />
+              </n-icon>
+
+              <span class="tree-name">{{ child.name }}</span>
+
+              <div v-if="child.type === 'directory'" class="node-actions">
+                <button class="node-action" title="新建文件" @click.stop="handleCreateInDir(child, 'file')">
+                  <n-icon size="12"><CreateOutline /></n-icon>
+                </button>
+                <button class="node-action" title="删除" @click.stop="handleDelete(child)">
+                  <n-icon size="12"><TrashOutline /></n-icon>
+                </button>
+              </div>
+              <div v-else class="node-actions">
+                <button v-if="child.path !== 'SKILL.md'" class="node-action" title="删除" @click.stop="handleDelete(child)">
+                  <n-icon size="12"><TrashOutline /></n-icon>
+                </button>
+              </div>
+            </div>
+
+            <!-- 第三层:紧跟第二层父节点渲染 -->
+            <template v-if="child.type === 'directory' && isExpanded(child.path) && child.children">
+              <div
+                v-for="grandchild in child.children"
+                :key="grandchild.path"
+                class="tree-node"
+                :class="{ active: currentFile === grandchild.path }"
+                :style="{ paddingLeft: '44px' }"
+                @click="handleNodeClick(grandchild)"
+              >
+                <span v-if="grandchild.type === 'directory'" class="tree-arrow" :class="{ expanded: isExpanded(grandchild.path) }">›</span>
+                <span v-else class="tree-arrow-spacer" />
+
+                <n-icon size="15" :color="getIconColor(grandchild)" class="tree-icon">
+                  <FolderOpenOutline v-if="grandchild.type === 'directory' && isExpanded(grandchild.path)" />
+                  <FolderOutline v-else-if="grandchild.type === 'directory'" />
+                  <DocumentOutline v-else />
+                </n-icon>
+
+                <span class="tree-name">{{ grandchild.name }}</span>
+
+                <div class="node-actions">
+                  <button v-if="grandchild.path !== 'SKILL.md'" class="node-action" title="删除" @click.stop="handleDelete(grandchild)">
+                    <n-icon size="12"><TrashOutline /></n-icon>
+                  </button>
+                </div>
+              </div>
+            </template>
+          </template>
+        </template>
+      </template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.file-tree {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.tree-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 12px;
+  border-bottom: 1px solid var(--border-color);
+}
+
+.tree-title {
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--text-secondary);
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.tree-actions {
+  display: flex;
+  gap: 4px;
+}
+
+.tree-action-btn {
+  width: 24px;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: none;
+  border: none;
+  border-radius: 4px;
+  color: var(--text-tertiary);
+  cursor: pointer;
+  transition: all 0.15s;
+}
+
+.tree-action-btn:hover {
+  background: rgba(255, 255, 255, 0.06);
+  color: var(--text-primary);
+}
+
+.tree-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 4px 0;
+}
+
+.tree-node {
+  display: flex;
+  align-items: center;
+  height: 28px;
+  cursor: pointer;
+  transition: background 0.1s;
+  position: relative;
+}
+
+.tree-node:hover {
+  background: rgba(255, 255, 255, 0.04);
+}
+
+.tree-node.active {
+  background: rgba(37, 99, 235, 0.12);
+}
+
+.tree-arrow {
+  width: 16px;
+  font-size: 13px;
+  color: var(--text-tertiary);
+  transition: transform 0.15s;
+  flex-shrink: 0;
+  text-align: center;
+}
+
+.tree-arrow.expanded {
+  transform: rotate(90deg);
+}
+
+.tree-arrow-spacer {
+  width: 16px;
+  flex-shrink: 0;
+}
+
+.tree-icon {
+  flex-shrink: 0;
+  margin-right: 6px;
+}
+
+.tree-name {
+  flex: 1;
+  font-size: 13px;
+  color: var(--text-secondary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.tree-node.active .tree-name {
+  color: var(--text-primary);
+}
+
+.node-actions {
+  display: none;
+  align-items: center;
+  gap: 2px;
+  margin-right: 8px;
+}
+
+.tree-node:hover .node-actions {
+  display: flex;
+}
+
+.node-action {
+  width: 20px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: none;
+  border: none;
+  border-radius: 3px;
+  color: var(--text-muted);
+  cursor: pointer;
+}
+
+.node-action:hover {
+  background: rgba(255, 255, 255, 0.08);
+  color: var(--text-primary);
+}
+</style>

+ 9 - 14
frontend/src/components/skill/SkillForm.vue

@@ -3,14 +3,9 @@ import { ref, computed, watch } from 'vue'
 import { NModal, NButton, NInput, NIcon, NTooltip, NSelect, NCheckbox } from 'naive-ui'
 import { CloseOutline, AddOutline, TrashOutline } from '@vicons/ionicons5'
 import { translateText } from '../../api/translation'
+import { IO_TYPES } from '../../utils/ioInference'
 
-const FIELD_TYPES = [
-  { label: 'string', value: 'string' },
-  { label: 'number', value: 'number' },
-  { label: 'boolean', value: 'boolean' },
-  { label: 'array', value: 'array' },
-  { label: 'object', value: 'object' }
-]
+const FIELD_TYPES = IO_TYPES
 
 const props = defineProps({
   show: Boolean,
@@ -66,7 +61,7 @@ async function handleTranslate() {
       form.value.description = res.data || form.value.description
     }
   } catch (e) {
-    console.error('翻译失败:', e)
+    // 翻译失败静默处理
   } finally {
     isTranslating.value = false
   }
@@ -82,7 +77,7 @@ function removeField(list, index) {
 
 function handleSave() {
   if (!form.value.name.trim()) {
-    alert('请输入智能体名称')
+    alert('请输入技能名称')
     return
   }
   // 过滤掉 name 为空的字段
@@ -108,7 +103,7 @@ function closeModal() {
     <div class="edit-modal">
       <!-- 头部 -->
       <div class="modal-header">
-        <h3 class="modal-title">编辑智能体</h3>
+        <h3 class="modal-title">编辑技能</h3>
         <button class="close-btn" @click="closeModal">
           <n-icon size="18"><CloseOutline /></n-icon>
         </button>
@@ -117,19 +112,19 @@ function closeModal() {
       <!-- 表单 -->
       <div class="form-area">
         <div class="form-group">
-          <label class="form-label">智能体名称</label>
+          <label class="form-label">技能名称</label>
           <n-input
             v-model:value="form.name"
-            placeholder="请输入智能体名称"
+            placeholder="请输入技能名称"
             :maxlength="100"
           />
         </div>
         <div class="form-group">
-          <label class="form-label">智能体描述</label>
+          <label class="form-label">技能描述</label>
           <n-input
             v-model:value="form.description"
             type="textarea"
-            placeholder="请输入智能体描述"
+            placeholder="请输入技能描述"
             :rows="4"
             :maxlength="500"
             show-count

+ 1 - 1
frontend/src/components/skill/SkillUpload.vue

@@ -74,7 +74,7 @@ function closeModal() {
     <div class="upload-modal">
       <!-- 头部 -->
       <div class="modal-header">
-        <h3 class="modal-title">新增智能体</h3>
+        <h3 class="modal-title">新增技能</h3>
         <button v-if="!uploading" class="close-btn" @click="closeModal">
           <n-icon size="18"><CloseOutline /></n-icon>
         </button>

+ 25 - 1
frontend/src/components/workflow/nodes/AgentNode.vue

@@ -1,10 +1,19 @@
 <script setup>
+import { computed } from 'vue'
 import BaseNode from './BaseNode.vue'
 import { RocketOutline } from '@vicons/ionicons5'
 
 const props = defineProps({
   data: { type: Object, default: () => ({ label: '智能体', agentName: '' }) }
 })
+
+const inputSummary = computed(() => {
+  return (props.data?.inputs || []).map(v => v.name).filter(Boolean)
+})
+
+const outputSummary = computed(() => {
+  return (props.data?.outputs || []).map(v => v.name).filter(Boolean)
+})
 </script>
 
 <template>
@@ -12,6 +21,12 @@ const props = defineProps({
     <template #icon><RocketOutline /></template>
     <template #body>
       <div class="agent-name">{{ data?.agentName || '未选择智能体' }}</div>
+      <div v-if="inputSummary.length" class="io-summary">
+        <span class="io-tag io-in" v-for="name in inputSummary" :key="name">{{ name }}</span>
+      </div>
+      <div v-if="outputSummary.length" class="io-summary">
+        <span class="io-tag io-out" v-for="name in outputSummary" :key="name">{{ name }}</span>
+      </div>
     </template>
   </BaseNode>
 </template>
@@ -21,5 +36,14 @@ export default { name: 'AgentNode' }
 </script>
 
 <style scoped>
-.agent-name { font-size: 11px; color: var(--text-tertiary, #888); }
+.agent-name { font-size: 11px; color: var(--text-tertiary, #888); margin-bottom: 4px; }
+.io-summary { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
+.io-tag {
+  font-size: 10px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+.io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
+.io-out { background: rgba(34,197,94,0.15); color: #4ade80; }
 </style>

+ 16 - 0
frontend/src/components/workflow/nodes/ConditionNode.vue

@@ -17,6 +17,10 @@ const branches = computed(() => {
     topPercent: ((i + 1) / (labels.length + 1)) * 100
   }))
 })
+
+const inputSummary = computed(() => {
+  return (props.data?.inputs || []).map(v => v.name).filter(Boolean)
+})
 </script>
 
 <template>
@@ -26,6 +30,9 @@ const branches = computed(() => {
       <div class="cond-icon"><GitBranchOutline /></div>
       <span class="cond-title">{{ data?.label || '条件分支' }}</span>
     </div>
+    <div v-if="inputSummary.length" class="io-summary">
+      <span class="io-tag io-in" v-for="name in inputSummary" :key="name">{{ name }}</span>
+    </div>
     <div class="cond-branches">
       <div v-for="b in branches" :key="b.id" class="branch-row">
         <span class="branch-label">{{ b.label }}</span>
@@ -116,4 +123,13 @@ export default { name: 'ConditionNode' }
   background: #F97316 !important;
   border: 2px solid #fff !important;
 }
+
+.io-summary { display: flex; flex-wrap: wrap; gap: 3px; padding: 0 12px 2px; }
+.io-tag {
+  font-size: 10px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+.io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
 </style>

+ 28 - 1
frontend/src/components/workflow/nodes/LLMNode.vue

@@ -1,10 +1,22 @@
 <script setup>
+import { computed } from 'vue'
 import BaseNode from './BaseNode.vue'
 import { SparklesOutline } from '@vicons/ionicons5'
 
 const props = defineProps({
   data: { type: Object, default: () => ({ label: '大模型处理', model: '' }) }
 })
+
+const inputSummary = computed(() => {
+  const list = props.data?.inputs || []
+  return list.map(v => v.name).filter(Boolean)
+})
+
+const outputSummary = computed(() => {
+  const list = props.data?.outputs || []
+  if (!list.length) return ['result']
+  return list.map(v => v.name).filter(Boolean)
+})
 </script>
 
 <template>
@@ -12,6 +24,12 @@ const props = defineProps({
     <template #icon><SparklesOutline /></template>
     <template #body>
       <div class="llm-model">{{ data?.model || '未选择模型' }}</div>
+      <div v-if="inputSummary.length" class="io-summary">
+        <span class="io-tag io-in" v-for="name in inputSummary" :key="name">{{ name }}</span>
+      </div>
+      <div v-if="outputSummary.length" class="io-summary">
+        <span class="io-tag io-out" v-for="name in outputSummary" :key="name">{{ name }}</span>
+      </div>
     </template>
   </BaseNode>
 </template>
@@ -21,5 +39,14 @@ export default { name: 'LLMNode' }
 </script>
 
 <style scoped>
-.llm-model { font-size: 11px; color: var(--text-tertiary, #888); }
+.llm-model { font-size: 11px; color: var(--text-tertiary, #888); margin-bottom: 4px; }
+.io-summary { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
+.io-tag {
+  font-size: 10px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+.io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
+.io-out { background: rgba(34,197,94,0.15); color: #4ade80; }
 </style>

+ 19 - 3
frontend/src/components/workflow/nodes/OutputNode.vue

@@ -1,9 +1,14 @@
 <script setup>
+import { computed } from 'vue'
 import BaseNode from './BaseNode.vue'
 import { ExitOutline } from '@vicons/ionicons5'
 
 const props = defineProps({
-  data: { type: Object, default: () => ({ label: '输出', outputType: 'text' }) }
+  data: { type: Object, default: () => ({ label: '输出' }) }
+})
+
+const fieldSummary = computed(() => {
+  return (props.data?.fields || []).map(f => f.name).filter(Boolean)
 })
 </script>
 
@@ -11,7 +16,10 @@ const props = defineProps({
   <BaseNode :data="data" :has-target="true" :source-handles="[]" color="#EF4444">
     <template #icon><ExitOutline /></template>
     <template #body>
-      <div class="output-type">{{ data?.outputType === 'json' ? 'JSON 对象' : '文本' }}</div>
+      <div v-if="fieldSummary.length" class="io-summary">
+        <span class="io-tag io-in" v-for="name in fieldSummary" :key="name">{{ name }}</span>
+      </div>
+      <div v-else class="output-hint">无接收字段</div>
     </template>
   </BaseNode>
 </template>
@@ -21,5 +29,13 @@ export default { name: 'OutputNode' }
 </script>
 
 <style scoped>
-.output-type { font-size: 11px; color: var(--text-tertiary, #888); }
+.io-summary { display: flex; flex-wrap: wrap; gap: 3px; }
+.io-tag {
+  font-size: 10px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+.io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
+.output-hint { font-size: 11px; color: var(--text-tertiary, #888); }
 </style>

+ 49 - 0
frontend/src/components/workflow/nodes/SkillNode.vue

@@ -0,0 +1,49 @@
+<script setup>
+import { computed } from 'vue'
+import BaseNode from './BaseNode.vue'
+import { FlaskOutline } from '@vicons/ionicons5'
+
+const props = defineProps({
+  data: { type: Object, default: () => ({ label: '技能', skillName: '' }) }
+})
+
+const inputSummary = computed(() => {
+  return (props.data?.inputs || []).map(v => v.name).filter(Boolean)
+})
+
+const outputSummary = computed(() => {
+  return (props.data?.outputs || []).map(v => v.name).filter(Boolean)
+})
+</script>
+
+<template>
+  <BaseNode :data="data" color="#06B6D4">
+    <template #icon><FlaskOutline /></template>
+    <template #body>
+      <div class="skill-name">{{ data?.skillName || '未选择技能' }}</div>
+      <div v-if="inputSummary.length" class="io-summary">
+        <span class="io-tag io-in" v-for="name in inputSummary" :key="name">{{ name }}</span>
+      </div>
+      <div v-if="outputSummary.length" class="io-summary">
+        <span class="io-tag io-out" v-for="name in outputSummary" :key="name">{{ name }}</span>
+      </div>
+    </template>
+  </BaseNode>
+</template>
+
+<script>
+export default { name: 'SkillNode' }
+</script>
+
+<style scoped>
+.skill-name { font-size: 11px; color: var(--text-tertiary, #888); margin-bottom: 4px; }
+.io-summary { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
+.io-tag {
+  font-size: 10px;
+  padding: 1px 5px;
+  border-radius: 3px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+.io-in { background: rgba(99,102,241,0.15); color: #818cf8; }
+.io-out { background: rgba(34,197,94,0.15); color: #4ade80; }
+</style>

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

@@ -9,7 +9,13 @@ const routes = [
     path: '/management',
     name: 'SkillManagement',
     component: () => import('../views/SkillManagement.vue'),
-    meta: { title: '智能体管理' }
+    meta: { title: '技能管理' }
+  },
+  {
+    path: '/skill/edit/:folderName',
+    name: 'SkillEdit',
+    component: () => import('../views/skill/SkillEdit.vue'),
+    meta: { title: '编辑技能' }
   },
   {
     path: '/orchestration',

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

@@ -0,0 +1,337 @@
+/**
+ * 工作流节点 IO 变量推断引擎
+ *
+ * 职责:
+ * 1. 从节点数据中提取 inputs / outputs 定义
+ * 2. 连线时自动匹配源节点输出 → 目标节点输入
+ * 3. 多源汇聚时计算变量分配
+ */
+
+// ========== 类型常量 ==========
+
+export const IO_TYPES = [
+  { label: '字符串', value: 'string' },
+  { label: '数字', value: 'number' },
+  { label: '布尔值', value: 'boolean' },
+  { label: '数组', value: 'array' },
+  { label: '对象', value: 'object' },
+  { label: '文件路径', value: 'filePath' },
+  { label: '目录路径', value: 'directoryPath' }
+]
+
+// 边状态 → 样式
+export const EDGE_STATUS_STYLE = {
+  ok:       { stroke: '#22c55e', strokeWidth: 2 },
+  unmapped: { stroke: '#666',    strokeWidth: 2 },
+  partial:  { stroke: '#f59e0b', strokeWidth: 2 },
+  mismatch: { stroke: '#ef4444', strokeWidth: 2 }
+}
+
+// ========== 模板变量提取 ==========
+
+/**
+ * 从 {{变量名}} 模板中提取变量名列表(去重)
+ */
+export function extractTemplateVariables(text) {
+  if (!text) return []
+  const matches = text.matchAll(/\{\{\s*(\w+)\s*\}\}/g)
+  const seen = new Set()
+  const result = []
+  for (const m of matches) {
+    if (!seen.has(m[1])) {
+      seen.add(m[1])
+      result.push(m[1])
+    }
+  }
+  return result
+}
+
+// ========== 节点 IO 获取 ==========
+
+/**
+ * 获取节点的输出字段列表
+ */
+export function getNodeOutputs(node) {
+  const d = node.data || {}
+  switch (node.type) {
+    case 'userInput':
+      return (d.variables || []).map(v => ({
+        name: v.name,
+        label: v.label || v.name,
+        type: v.type || 'string',
+        description: v.description || ''
+      }))
+    case 'llm':
+      if (d.outputs && d.outputs.length) return d.outputs
+      return [{ name: 'result', label: 'LLM 输出', type: 'string', description: '大模型响应文本' }]
+    case 'agent':
+      return d.outputs || []
+    case 'skill':
+      return d.outputs || []
+    case 'output':
+      return []
+    case 'condition':
+      // 条件节点透传:输出 = 所有输入
+      return getNodeInputs(node)
+    default:
+      return d.outputs || []
+  }
+}
+
+/**
+ * 获取节点的输入字段列表
+ */
+export function getNodeInputs(node) {
+  const d = node.data || {}
+  switch (node.type) {
+    case 'userInput':
+      return []
+    case 'llm': {
+      // 优先使用手动定义的 inputs
+      if (d.inputs && d.inputs.length) return d.inputs
+      // 回退:从模板提取
+      const vars = new Set([
+        ...extractTemplateVariables(d.systemPrompt),
+        ...extractTemplateVariables(d.userPrompt)
+      ])
+      return [...vars].map(name => ({ name, label: name, type: 'string', description: '' }))
+    }
+    case 'agent':
+      return d.inputs || []
+    case 'skill':
+      return d.inputs || []
+    case 'output':
+      return d.fields || []
+    case 'condition': {
+      // 优先使用手动定义的 inputs
+      if (d.inputs && d.inputs.length) return d.inputs
+      // 回退:从条件表达式提取
+      const vars = new Set()
+      for (const c of (d.conditions || [])) {
+        for (const v of extractTemplateVariables(c.expression)) {
+          vars.add(v)
+        }
+      }
+      return [...vars].map(name => ({ name, label: name, type: 'string', description: '' }))
+    }
+    default:
+      return d.inputs || []
+  }
+}
+
+// ========== 类型兼容性 ==========
+
+/**
+ * 判断源类型是否可以赋值给目标类型
+ */
+export function isTypeCompatible(sourceType, targetType) {
+  if (sourceType === targetType) return true
+  // 以下类型可隐式转为 string
+  const stringLike = ['filePath', 'directoryPath', 'number', 'boolean']
+  if (stringLike.includes(sourceType) && targetType === 'string') return true
+  // array → object 兼容
+  if (sourceType === 'array' && targetType === 'object') return true
+  return false
+}
+
+// ========== 单边匹配 ==========
+
+/**
+ * 匹配源节点输出与目标节点输入
+ * @returns {{ status, mapping, unmatchedSource, unmatchedTarget, typeMismatches }}
+ */
+export function matchIO(sourceOutputs, targetInputs, existingMapping) {
+  const mapping = []
+  const unmatchedSource = [...sourceOutputs]
+  const unmatchedTarget = [...targetInputs]
+  const typeMismatches = []
+
+  // 第一轮:按变量名精确匹配
+  for (let si = unmatchedSource.length - 1; si >= 0; si--) {
+    const srcField = unmatchedSource[si]
+    const ti = unmatchedTarget.findIndex(t => t.name === srcField.name)
+    if (ti !== -1) {
+      const tgtField = unmatchedTarget[ti]
+      if (isTypeCompatible(srcField.type, tgtField.type)) {
+        mapping.push({ sourceField: srcField.name, targetField: tgtField.name })
+      } else {
+        typeMismatches.push({ source: srcField, target: tgtField })
+      }
+      unmatchedSource.splice(si, 1)
+      unmatchedTarget.splice(ti, 1)
+    }
+  }
+
+  // 第二轮:保留已有的手动映射(不与新映射冲突的部分)
+  if (existingMapping) {
+    for (const em of existingMapping) {
+      if (mapping.some(m => m.sourceField === em.sourceField && m.targetField === em.targetField)) continue
+      if (mapping.some(m => m.sourceField === em.sourceField || m.targetField === em.targetField)) continue
+      mapping.push(em)
+      const ti = unmatchedTarget.findIndex(t => t.name === em.targetField)
+      if (ti !== -1) unmatchedTarget.splice(ti, 1)
+    }
+  }
+
+  // 判定状态
+  let status
+  if (targetInputs.length === 0 && sourceOutputs.length === 0) {
+    status = 'unmapped'
+  } else if (unmatchedTarget.length === 0 && typeMismatches.length === 0) {
+    status = 'ok'
+  } else if (typeMismatches.length > 0) {
+    status = 'mismatch'
+  } else if (unmatchedTarget.length > 0 && unmatchedSource.length === 0) {
+    status = 'partial'
+  } else if (unmatchedTarget.length === 0 && unmatchedSource.length > 0) {
+    status = 'ok' // 源有多余输出,但目标全部满足
+  } else {
+    status = 'mismatch'
+  }
+
+  return { status, mapping, unmatchedSource, unmatchedTarget, typeMismatches }
+}
+
+// ========== 推断调度 ==========
+
+/**
+ * 推断一条边的映射状态
+ * @param {object} sourceNode - 源节点
+ * @param {object} targetNode - 目标节点
+ * @param {object} [existingEdge] - 已有边数据
+ * @returns {{ status, mapping, unmatchedSource, unmatchedTarget, typeMismatches }}
+ */
+export function inferEdgeMapping(sourceNode, targetNode, existingEdge) {
+  const sourceOutputs = getNodeOutputs(sourceNode)
+  const targetInputs = getNodeInputs(targetNode)
+  const existingMapping = existingEdge?.data?.mapping
+  return matchIO(sourceOutputs, targetInputs, existingMapping)
+}
+
+/**
+ * 推断指定边的样式
+ */
+export function getEdgeStyle(status) {
+  return EDGE_STATUS_STYLE[status] || EDGE_STATUS_STYLE.unmapped
+}
+
+/**
+ * 刷新图中与指定节点关联的所有边的映射
+ * 返回需要更新的边列表
+ * @param {string} nodeId - 变更的节点 ID
+ * @param {Array} nodes - 所有节点
+ * @param {Array} edges - 所有边
+ * @returns {Array} - 需要更新的边 [{ id, data, style }]
+ */
+export function refreshMappingsForNode(nodeId, nodes, edges) {
+  const updates = []
+  const nodeMap = new Map(nodes.map(n => [n.id, n]))
+
+  for (const edge of edges) {
+    const isRelevant = edge.source === nodeId || edge.target === nodeId
+    if (!isRelevant) continue
+
+    const sourceNode = nodeMap.get(edge.source)
+    const targetNode = nodeMap.get(edge.target)
+    if (!sourceNode || !targetNode) continue
+
+    const result = inferEdgeMapping(sourceNode, targetNode, edge)
+    updates.push({
+      id: edge.id,
+      data: {
+        ...edge.data,
+        mapping: result.mapping,
+        status: result.status,
+        unmatchedSource: result.unmatchedSource,
+        unmatchedTarget: result.unmatchedTarget,
+        typeMismatches: result.typeMismatches
+      },
+      style: getEdgeStyle(result.status)
+    })
+  }
+
+  return updates
+}
+
+/**
+ * 推断一条新边的映射(用于 onConnect)
+ */
+export function inferNewEdge(sourceNode, targetNode) {
+  const result = inferEdgeMapping(sourceNode, targetNode)
+  return {
+    data: {
+      mapping: result.mapping,
+      status: result.status,
+      unmatchedSource: result.unmatchedSource,
+      unmatchedTarget: result.unmatchedTarget,
+      typeMismatches: result.typeMismatches
+    },
+    style: getEdgeStyle(result.status)
+  }
+}
+
+// ========== 多源汇聚分配 ==========
+
+/**
+ * 计算多源汇聚时的变量分配方案
+ * @param {Array} sources - 源节点列表
+ * @param {object} target - 目标节点
+ * @param {Array} edges - 连接到目标的边列表
+ * @returns {{ edges: Array<{ edgeId, mapping, provided, missing }> }}
+ */
+export function resolveMultiSource(sources, target, edges) {
+  const targetInputs = getNodeInputs(target)
+  if (targetInputs.length === 0) {
+    return { edges: edges.map(e => ({ edgeId: e.id, mapping: [], provided: [], missing: [] })) }
+  }
+
+  const allSourceOutputs = sources.map(s => getNodeOutputs(s))
+  const results = []
+
+  // 计算所有源的合并输出
+  const mergedOutputs = new Map()
+  for (let i = 0; i < sources.length; i++) {
+    for (const field of allSourceOutputs[i]) {
+      if (!mergedOutputs.has(field.name)) {
+        mergedOutputs.set(field.name, { field, sourceIndex: i })
+      }
+    }
+  }
+
+  // 尝试匹配每个目标输入字段
+  const assigned = new Map() // targetField -> sourceIndex
+  for (const tgtField of targetInputs) {
+    const entry = mergedOutputs.get(tgtField.name)
+    if (entry && isTypeCompatible(entry.field.type, tgtField.type)) {
+      assigned.set(tgtField.name, entry.sourceIndex)
+    }
+  }
+
+  // 分配回每条边
+  for (let i = 0; i < edges.length; i++) {
+    const sourceOutputs = allSourceOutputs[i] || []
+    const edgeMapping = []
+    const provided = []
+
+    for (const tgtField of targetInputs) {
+      if (assigned.get(tgtField.name) === i) {
+        const srcField = sourceOutputs.find(f => f.name === tgtField.name)
+        if (srcField) {
+          edgeMapping.push({ sourceField: srcField.name, targetField: tgtField.name })
+          provided.push(tgtField.name)
+        }
+      }
+    }
+
+    const missing = targetInputs.filter(t => !assigned.has(t.name)).map(t => t.name)
+
+    results.push({
+      edgeId: edges[i].id,
+      mapping: edgeMapping,
+      provided,
+      missing
+    })
+  }
+
+  return { edges: results }
+}

+ 24 - 0
frontend/src/utils/language.js

@@ -0,0 +1,24 @@
+/**
+ * 文件扩展名 → Monaco Editor 语言名称映射
+ */
+const EXT_LANGUAGE_MAP = {
+  js: 'javascript', jsx: 'javascript',
+  ts: 'typescript', tsx: 'typescript',
+  json: 'json', html: 'html', css: 'css',
+  scss: 'scss', less: 'less',
+  md: 'markdown', py: 'python',
+  java: 'java', xml: 'xml',
+  yaml: 'yaml', yml: 'yaml',
+  sh: 'shell', bash: 'shell',
+  sql: 'sql', rs: 'rust', go: 'go',
+  vue: 'html', txt: 'plaintext'
+}
+
+/**
+ * 根据文件路径推断 Monaco Editor 语言
+ */
+export function getLanguageFromPath(path) {
+  if (!path) return 'plaintext'
+  const ext = path.split('.').pop().toLowerCase()
+  return EXT_LANGUAGE_MAP[ext] || 'plaintext'
+}

+ 26 - 0
frontend/src/utils/monacoSetup.js

@@ -0,0 +1,26 @@
+/**
+ * Monaco Editor Worker 配置(Vite 环境)
+ * 必须在创建编辑器实例之前导入此模块
+ */
+import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
+import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
+import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
+import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
+import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
+
+let configured = false
+
+export function setupMonaco() {
+  if (configured) return
+  configured = true
+
+  self.MonacoEnvironment = {
+    getWorker(_, label) {
+      if (label === 'json') return new jsonWorker()
+      if (label === 'css' || label === 'scss' || label === 'less') return new cssWorker()
+      if (label === 'html' || label === 'handlebars' || label === 'razor') return new htmlWorker()
+      if (label === 'typescript' || label === 'javascript') return new tsWorker()
+      return new editorWorker()
+    }
+  }
+}

+ 2 - 9
frontend/src/utils/sse.js

@@ -1,5 +1,3 @@
-import { getSkillList } from '../api/skill'
-
 let eventSource = null
 let translationCallback = null
 
@@ -21,17 +19,12 @@ export function connectSSE(onTranslation) {
       if (translationCallback) {
         translationCallback(data)
       }
-    } catch (e) {
-      console.error('[SSE] 解析翻译消息失败:', e)
+    } catch (_e) {
+      // 解析失败静默忽略
     }
   })
 
-  eventSource.onopen = () => {
-    console.log('[SSE] 连接建立')
-  }
-
   eventSource.onerror = () => {
-    console.log('[SSE] 连接错误或关闭')
     // EventSource 会自动重连
   }
 }

+ 11 - 31
frontend/src/views/SkillManagement.vue

@@ -1,19 +1,18 @@
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { NButton, NIcon, NSpin, NEmpty, NDialog, useDialog, useMessage } from 'naive-ui'
 import { AddOutline, ReloadOutline } from '@vicons/ionicons5'
 import { useSkillStore } from '../stores/skill'
 import SkillCard from '../components/skill/SkillCard.vue'
 import SkillUpload from '../components/skill/SkillUpload.vue'
-import SkillForm from '../components/skill/SkillForm.vue'
 
+const router = useRouter()
 const skillStore = useSkillStore()
 const dialog = useDialog()
 const message = useMessage()
 
 const showUpload = ref(false)
-const showEdit = ref(false)
-const editingSkill = ref(null)
 
 onMounted(() => {
   skillStore.init()
@@ -31,32 +30,20 @@ async function handleUpload(file) {
   try {
     await skillStore.createSkill(file)
     showUpload.value = false
-    message.success('智能体上传成功')
+    message.success('技能上传成功')
   } catch (err) {
     message.error(err.message || '上传失败')
   }
 }
 
 function handleEdit(skill) {
-  editingSkill.value = { ...skill }
-  showEdit.value = true
-}
-
-async function handleSaveEdit(formData) {
-  try {
-    await skillStore.updateSkill(editingSkill.value.folderName, formData)
-    showEdit.value = false
-    editingSkill.value = null
-    message.success('智能体更新成功')
-  } catch (err) {
-    message.error(err.message || '更新失败')
-  }
+  router.push(`/skill/edit/${skill.folderName}`)
 }
 
 function handleDelete(skill) {
   dialog.warning({
     title: '确认删除',
-    content: `确定要删除智能体「${skill.translatedName || skill.name}」吗?此操作不可恢复。`,
+    content: `确定要删除技能「${skill.translatedName || skill.name}」吗?此操作不可恢复。`,
     positiveText: '确认删除',
     negativeText: '取消',
     onPositiveClick: async () => {
@@ -77,7 +64,7 @@ function handleDelete(skill) {
     <div class="toolbar">
       <div class="toolbar-left">
         <span class="skill-count">
-          共 <span class="count-num">{{ skillStore.skills.length }}</span> 个智能体
+          共 <span class="count-num">{{ skillStore.skills.length }}</span> 个技能
         </span>
       </div>
       <div class="toolbar-right">
@@ -91,7 +78,7 @@ function handleDelete(skill) {
           <template #icon>
             <n-icon><AddOutline /></n-icon>
           </template>
-          新增智能体
+          新增技能
         </n-button>
       </div>
     </div>
@@ -99,7 +86,7 @@ function handleDelete(skill) {
     <!-- 加载状态 -->
     <div v-if="skillStore.loading && skillStore.skills.length === 0" class="loading-area">
       <n-spin size="large" />
-      <p class="loading-text">正在加载智能体列表...</p>
+      <p class="loading-text">正在加载技能列表...</p>
     </div>
 
     <!-- 空状态 -->
@@ -112,13 +99,13 @@ function handleDelete(skill) {
           <circle cx="60" cy="60" r="30" stroke="rgba(37, 99, 235, 0.1)" stroke-width="1" fill="none"/>
         </svg>
       </div>
-      <p class="empty-title">暂无智能体</p>
-      <p class="empty-desc">点击「新增智能体」上传您的第一个 Skill</p>
+      <p class="empty-title">暂无技能</p>
+      <p class="empty-desc">点击「新增技能」上传您的第一个 Skill</p>
       <n-button type="primary" @click="showUpload = true" style="margin-top: 16px">
         <template #icon>
           <n-icon><AddOutline /></n-icon>
         </template>
-        新增智能体
+        新增技能
       </n-button>
     </div>
 
@@ -142,13 +129,6 @@ function handleDelete(skill) {
       :progress="skillStore.uploadProgress"
       @upload="handleUpload"
     />
-
-    <!-- 编辑弹窗 -->
-    <SkillForm
-      v-model:show="showEdit"
-      :skill="editingSkill"
-      @save="handleSaveEdit"
-    />
   </div>
 </template>
 

+ 595 - 0
frontend/src/views/skill/SkillEdit.vue

@@ -0,0 +1,595 @@
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { NButton, NInput, NIcon, NSelect, NCheckbox, NTooltip, NSpin, useMessage } from 'naive-ui'
+import { ArrowBackOutline, SaveOutline, CodeSlashOutline, GlobeOutline } from '@vicons/ionicons5'
+import { IO_TYPES } from '../../utils/ioInference'
+import { getLanguageFromPath } from '../../utils/language'
+import { translateText } from '../../api/translation'
+import {
+  getSkillDetail, saveSkillMd, listSkillFiles,
+  readSkillFile, saveSkillFile, createSkillFile, deleteSkillFile
+} from '../../api/skill'
+import CodeEditor from '../../components/skill/CodeEditor.vue'
+import FileTree from '../../components/skill/FileTree.vue'
+
+const route = useRoute()
+const router = useRouter()
+const message = useMessage()
+
+const folderName = route.params.folderName
+const loading = ref(true)
+const saving = ref(false)
+const advancedMode = ref(false)
+
+// ==================== 基础编辑模式数据 ====================
+const form = ref({
+  name: '',
+  description: '',
+  inputs: [],
+  outputs: []
+})
+const body = ref('')
+const originalBody = ref('')
+
+// ==================== 高级编辑模式数据 ====================
+const fileTree = ref([])
+const currentFilePath = ref('')
+const currentFileContent = ref('')
+const currentFileDirty = ref(false)
+
+// ==================== 翻译 ====================
+const isTranslating = ref(false)
+
+function isEnglish(text) {
+  if (!text || !text.trim()) return false
+  for (let i = 0; i < text.length; i++) {
+    const c = text.charCodeAt(i)
+    if (c >= 0x4e00 && c <= 0x9fff) return false
+  }
+  return true
+}
+
+const hasEnglishContent = computed(() => {
+  return isEnglish(form.value.name) || isEnglish(form.value.description) || isEnglish(body.value)
+})
+
+async function handleTranslate() {
+  if (isTranslating.value) return
+  isTranslating.value = true
+  try {
+    if (isEnglish(form.value.name)) {
+      const res = await translateText(form.value.name)
+      form.value.name = res.data || form.value.name
+    }
+    if (isEnglish(form.value.description)) {
+      const res = await translateText(form.value.description)
+      form.value.description = res.data || form.value.description
+    }
+    if (isEnglish(body.value)) {
+      const len = body.value.length
+      if (len > 10000) {
+        message.info(`正文较长(${len} 字符),后端将分段翻译,请耐心等待...`)
+      }
+      const res = await translateText(body.value)
+      body.value = res.data || body.value
+    }
+    message.success('翻译完成')
+  } catch (e) {
+    message.error('翻译失败')
+  } finally {
+    isTranslating.value = false
+  }
+}
+
+// ==================== IO 字段操作 ====================
+function addField(list) {
+  list.push({ name: '', type: 'string', description: '', required: false })
+}
+
+function removeField(list, index) {
+  list.splice(index, 1)
+}
+
+// ==================== 数据加载 ====================
+async function loadSkillDetail() {
+  loading.value = true
+  try {
+    const res = await getSkillDetail(folderName)
+    const data = res.data
+    form.value = {
+      name: data.name || '',
+      description: data.description || '',
+      inputs: (data.inputs || []).map(f => ({ ...f })),
+      outputs: (data.outputs || []).map(f => ({ ...f }))
+    }
+    body.value = data.body || ''
+    originalBody.value = body.value
+  } catch (e) {
+    message.error('加载技能详情失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadFileTree() {
+  try {
+    const res = await listSkillFiles(folderName)
+    fileTree.value = res.data || []
+  } catch (e) {
+    // 加载失败静默处理
+  }
+}
+
+// ==================== 高级模式文件操作 ====================
+async function handleFileSelect(path) {
+  // 如果当前文件有未保存的修改,先保存
+  if (currentFileDirty.value && currentFilePath.value) {
+    await doSaveFile(currentFilePath.value, currentFileContent.value)
+  }
+  currentFilePath.value = path
+  currentFileDirty.value = false
+  try {
+    const res = await readSkillFile(folderName, path)
+    currentFileContent.value = res.data || ''
+  } catch (e) {
+    message.error('读取文件失败')
+  }
+}
+
+function handleFileContentChange(val) {
+  currentFileContent.value = val
+  currentFileDirty.value = true
+}
+
+async function handleCreateFile(path) {
+  try {
+    await createSkillFile(folderName, path, 'file')
+    message.success('文件创建成功')
+    await loadFileTree()
+    // 自动选中新文件
+    currentFilePath.value = path
+    currentFileContent.value = ''
+    currentFileDirty.value = false
+  } catch (e) {
+    message.error(e.response?.data?.message || '创建失败')
+  }
+}
+
+async function handleCreateDir(path) {
+  try {
+    await createSkillFile(folderName, path, 'directory')
+    message.success('目录创建成功')
+    await loadFileTree()
+  } catch (e) {
+    message.error(e.response?.data?.message || '创建失败')
+  }
+}
+
+async function handleDeleteFile(path) {
+  try {
+    await deleteSkillFile(folderName, path)
+    message.success('删除成功')
+    if (currentFilePath.value === path) {
+      currentFilePath.value = ''
+      currentFileContent.value = ''
+      currentFileDirty.value = false
+    }
+    await loadFileTree()
+  } catch (e) {
+    message.error(e.response?.data?.message || '删除失败')
+  }
+}
+
+// ==================== 保存 ====================
+function buildSaveData() {
+  return {
+    name: form.value.name,
+    description: form.value.description,
+    inputs: form.value.inputs.filter(f => f.name.trim()),
+    outputs: form.value.outputs.filter(f => f.name.trim()),
+    body: body.value
+  }
+}
+
+async function doSaveFile(path, content) {
+  await saveSkillFile(folderName, path, content)
+  currentFileDirty.value = false
+}
+
+async function handleSave() {
+  if (saving.value) return
+  saving.value = true
+  try {
+    if (advancedMode.value) {
+      // 高级模式:保存当前编辑的文件
+      if (currentFilePath.value) {
+        await doSaveFile(currentFilePath.value, currentFileContent.value)
+        message.success('文件保存成功')
+      } else {
+        message.warning('请先选择一个文件')
+      }
+    } else {
+      // 基础模式:重建并保存 SKILL.md
+      await saveSkillMd(folderName, buildSaveData())
+      originalBody.value = body.value
+      message.success('保存成功')
+    }
+  } catch (e) {
+    message.error('保存失败')
+  } finally {
+    saving.value = false
+  }
+}
+
+// ==================== 模式切换 ====================
+async function toggleAdvancedMode() {
+  if (!advancedMode.value) {
+    // 从基础模式切换到高级模式:先保存,再加载文件树
+    if (body.value !== originalBody.value || form.value.name) {
+      try {
+        await saveSkillMd(folderName, buildSaveData())
+        originalBody.value = body.value
+      } catch (e) {
+        message.error('切换模式前保存失败')
+        return
+      }
+    }
+    await loadFileTree()
+    // 默认选中 SKILL.md
+    currentFilePath.value = 'SKILL.md'
+    currentFileContent.value = ''
+    currentFileDirty.value = false
+    try {
+      const res = await readSkillFile(folderName, 'SKILL.md')
+      currentFileContent.value = res.data || ''
+    } catch (e) {
+      // 读取 SKILL.md 失败静默处理
+    }
+  }
+  advancedMode.value = !advancedMode.value
+}
+
+function goBack() {
+  router.push('/management')
+}
+
+// ==================== 生命周期 ====================
+onMounted(() => {
+  loadSkillDetail()
+})
+</script>
+
+<template>
+  <div class="skill-edit-page">
+    <!-- 顶部工具栏 -->
+    <div class="edit-toolbar">
+      <div class="toolbar-left">
+        <n-button quaternary @click="goBack" class="back-btn">
+          <template #icon><n-icon><ArrowBackOutline /></n-icon></template>
+          返回
+        </n-button>
+        <span class="edit-title">{{ form.name || folderName }}</span>
+      </div>
+      <div class="toolbar-right">
+        <n-tooltip v-if="hasEnglishContent && !advancedMode" placement="bottom">
+          <template #trigger>
+            <n-button :loading="isTranslating" @click="handleTranslate" quaternary>
+              <template #icon><n-icon size="16"><GlobeOutline /></n-icon></template>
+              翻译
+            </n-button>
+          </template>
+          检测到英文内容,点击翻译为中文
+        </n-tooltip>
+
+        <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>
+          保存
+        </n-button>
+      </div>
+    </div>
+
+    <!-- 加载中 -->
+    <div v-if="loading" class="loading-area">
+      <n-spin size="large" />
+    </div>
+
+    <!-- ==================== 基础编辑模式 ==================== -->
+    <div v-else-if="!advancedMode" class="basic-editor">
+      <!-- 元数据表单 -->
+      <div class="meta-section">
+        <div class="form-row">
+          <div class="form-group">
+            <label class="form-label">技能名称</label>
+            <n-input v-model:value="form.name" 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" />
+          </div>
+        </div>
+
+        <!-- 输入字段 -->
+        <div class="form-group">
+          <div class="section-header">
+            <label class="form-label">输入字段</label>
+            <n-button size="tiny" quaternary @click="addField(form.inputs)">
+              <template #icon><n-icon size="14">+</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">
+              <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;" />
+              <n-checkbox v-model:checked="field.required" size="small">必填</n-checkbox>
+              <button class="remove-btn" @click="removeField(form.inputs, i)">✕</button>
+            </div>
+          </div>
+          <div v-else class="io-empty">暂无输入字段</div>
+        </div>
+
+        <!-- 输出字段 -->
+        <div class="form-group">
+          <div class="section-header">
+            <label class="form-label">输出字段</label>
+            <n-button size="tiny" quaternary @click="addField(form.outputs)">
+              <template #icon><n-icon size="14">+</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">
+              <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;" />
+              <n-checkbox v-model:checked="field.required" size="small">必填</n-checkbox>
+              <button class="remove-btn" @click="removeField(form.outputs, i)">✕</button>
+            </div>
+          </div>
+          <div v-else class="io-empty">暂无输出字段</div>
+        </div>
+      </div>
+
+      <!-- 正文编辑器 -->
+      <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"
+        />
+      </div>
+    </div>
+
+    <!-- ==================== 高级编辑模式 ==================== -->
+    <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"
+        />
+      </div>
+
+      <!-- 代码编辑器 -->
+      <div class="editor-panel">
+        <div v-if="currentFilePath" class="editor-header">
+          <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"
+        />
+        <div v-else class="no-file-selected">
+          <p>选择文件以编辑</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.skill-edit-page {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+/* ==================== 工具栏 ==================== */
+.edit-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 24px;
+  border-bottom: 1px solid var(--border-color);
+  background: var(--bg-secondary);
+  flex-shrink: 0;
+}
+
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.toolbar-right {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.back-btn {
+  color: var(--text-secondary);
+}
+
+.edit-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+/* ==================== 加载 ==================== */
+.loading-area {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* ==================== 基础编辑模式 ==================== */
+.basic-editor {
+  flex: 1;
+  overflow-y: auto;
+  padding: 24px;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+.meta-section {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.form-row {
+  display: flex;
+  gap: 16px;
+}
+
+.form-group {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  flex: 1;
+}
+
+.form-label {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-secondary);
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.io-list {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.io-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.io-empty {
+  font-size: 12px;
+  color: var(--text-tertiary);
+  padding: 6px 0;
+}
+
+.remove-btn {
+  width: 24px;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: none;
+  border: none;
+  border-radius: 4px;
+  color: var(--text-tertiary);
+  cursor: pointer;
+  font-size: 12px;
+  flex-shrink: 0;
+}
+
+.remove-btn:hover {
+  background: rgba(255, 80, 80, 0.1);
+  color: #ff5050;
+}
+
+.body-section {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 300px;
+}
+
+/* ==================== 高级编辑模式 ==================== */
+.advanced-editor {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+}
+
+.file-tree-panel {
+  width: 240px;
+  border-right: 1px solid var(--border-color);
+  background: var(--bg-secondary);
+  flex-shrink: 0;
+}
+
+.editor-panel {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.editor-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 16px;
+  border-bottom: 1px solid var(--border-color);
+  background: var(--bg-secondary);
+  flex-shrink: 0;
+}
+
+.file-path {
+  font-size: 13px;
+  color: var(--text-secondary);
+  font-family: monospace;
+}
+
+.dirty-indicator {
+  color: #e0a526;
+  font-size: 14px;
+}
+
+.no-file-selected {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-muted);
+  font-size: 14px;
+}
+</style>

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

@@ -8,7 +8,7 @@ import {
 } from 'naive-ui'
 import {
   ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
-  SparklesOutline, RocketOutline, GitBranchOutline, ExitOutline,
+  SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
   PlayOutline, CloseOutline
 } from '@vicons/ionicons5'
 import { useWorkflowStore } from '../../stores/workflow'
@@ -17,8 +17,10 @@ import { runWorkflow } from '../../api/workflow'
 import InputNode from '../../components/workflow/nodes/InputNode.vue'
 import LLMNode from '../../components/workflow/nodes/LLMNode.vue'
 import AgentNode from '../../components/workflow/nodes/AgentNode.vue'
+import SkillNode from '../../components/workflow/nodes/SkillNode.vue'
 import OutputNode from '../../components/workflow/nodes/OutputNode.vue'
 import ConditionNode from '../../components/workflow/nodes/ConditionNode.vue'
+import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, IO_TYPES, extractTemplateVariables } from '../../utils/ioInference'
 
 import '@vue-flow/core/dist/style.css'
 import '@vue-flow/core/dist/theme-default.css'
@@ -73,18 +75,34 @@ const defaultEdgeStyle = { stroke: '#666', strokeWidth: 2 }
 const selectedEdgeStyle = { stroke: '#facc15', strokeWidth: 3 }
 
 onConnect((params) => {
+  const sourceNode = getNodes.value.find(n => n.id === params.source)
+  const targetNode = getNodes.value.find(n => n.id === params.target)
+  const inferred = sourceNode && targetNode ? inferNewEdge(sourceNode, targetNode) : null
   addEdges([{
     ...params,
     type: 'bezier',
     animated: true,
-    style: defaultEdgeStyle
+    style: inferred?.style || defaultEdgeStyle,
+    data: inferred?.data || {}
   }])
 })
 
+// 批量更新边的映射数据和样式
+function applyEdgeMappingUpdates(updates) {
+  for (const upd of updates) {
+    const edge = getEdges.value.find(e => e.id === upd.id)
+    if (edge) {
+      edge.data = { ...edge.data, ...upd.data }
+      edge.style = upd.style
+    }
+  }
+}
+
 // ========== 节点类型定义 ==========
 const nodeTypes = [
   { type: 'userInput', label: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline), color: '#10B981' },
   { type: 'llm', label: '大模型处理', icon: markRaw(SparklesOutline), color: '#8B5CF6' },
+  { type: 'skill', label: '技能', icon: markRaw(FlaskOutline), color: '#06B6D4' },
   { type: 'agent', label: '智能体', icon: markRaw(RocketOutline), color: '#F59E0B' },
   { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
   { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
@@ -93,6 +111,7 @@ const nodeTypes = [
 const nodeTypeInfo = {
   userInput: { color: '#10B981', typeLabel: '用户输入', icon: markRaw(ChatbubbleEllipsesOutline) },
   llm:       { color: '#8B5CF6', typeLabel: '大模型',   icon: markRaw(SparklesOutline) },
+  skill:     { color: '#06B6D4', typeLabel: '技能',     icon: markRaw(FlaskOutline) },
   agent:     { color: '#F59E0B', typeLabel: '智能体',   icon: markRaw(RocketOutline) },
   condition: { color: '#F97316', typeLabel: '条件',     icon: markRaw(GitBranchOutline) },
   output:    { color: '#EF4444', typeLabel: '输出',     icon: markRaw(ExitOutline) }
@@ -101,10 +120,11 @@ const nodeTypeInfo = {
 function getDefaultData(type) {
   switch (type) {
     case 'userInput': return { label: '用户输入', variables: [] }
-    case 'llm': return { label: '大模型处理', model: '', systemPrompt: '', userPrompt: '' }
+    case 'llm': return { label: '大模型处理', model: '', systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }] }
     case 'agent': return { label: '智能体', agentId: '', agentName: '' }
+    case 'skill': return { label: '技能', skillId: '', skillName: '' }
     case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
-    case 'output': return { label: '输出', outputType: 'text' }
+    case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
     default: return { label: '节点' }
   }
 }
@@ -161,10 +181,14 @@ function onEdgeClick({ edge }) {
 }
 
 function deleteSelectedEdge() {
-  if (selectedEdgeId.value) {
-    removeEdges(selectedEdgeId.value)
-    selectedEdgeId.value = null
-  }
+  if (!selectedEdgeId.value) return
+  const edge = getEdges.value.find(e => e.id === selectedEdgeId.value)
+  const sourceId = edge?.source
+  const targetId = edge?.target
+  removeEdges(selectedEdgeId.value)
+  selectedEdgeId.value = null
+  if (sourceId) applyEdgeMappingUpdates(refreshMappingsForNode(sourceId, getNodes.value, getEdges.value))
+  if (targetId) applyEdgeMappingUpdates(refreshMappingsForNode(targetId, getNodes.value, getEdges.value))
 }
 
 function onKeyDown(e) {
@@ -178,6 +202,11 @@ function onKeyDown(e) {
 
 const selectedData = computed(() => selectedNode.value?.data || null)
 
+const selectedEdge = computed(() => {
+  if (!selectedEdgeId.value) return null
+  return getEdges.value.find(e => e.id === selectedEdgeId.value) || null
+})
+
 // ========== 智能体选项 ==========
 const agentOptions = computed(() =>
   (skillStore.skills || []).map(s => ({
@@ -195,11 +224,6 @@ const modelOptions = [
   { label: 'GLM-4', value: 'glm-4' }
 ]
 
-const outputTypeOptions = [
-  { label: '文本', value: 'text' },
-  { label: 'JSON 对象', value: 'json' }
-]
-
 // ========== 条件分支操作 ==========
 function addElif() {
   if (!selectedData.value?.conditions) return
@@ -207,6 +231,7 @@ function addElif() {
   vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
   // 同步 selectedNode 引用
   selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
 }
 
 function removeElif(index) {
@@ -214,6 +239,7 @@ function removeElif(index) {
   const newConditions = selectedData.value.conditions.filter((_, i) => i !== index)
   vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
   selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
 }
 
 // ========== 变量操作 ==========
@@ -222,25 +248,39 @@ function addVariable() {
   const newVars = [...(selectedData.value.variables || []), { name: '', label: '', type: 'string', description: '' }]
   vfUpdateNode(selectedNode.value.id, { variables: newVars })
   selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
 }
 
 function removeVariable(index) {
   const newVars = (selectedData.value?.variables || []).filter((_, i) => i !== index)
   vfUpdateNode(selectedNode.value.id, { variables: newVars })
   selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
 }
 
 // ========== 通用数据更新 ==========
 function onFieldChange(field, value) {
   if (!selectedNode.value) return
   const update = { [field]: value }
-  // 特殊处理:智能体选择时同步名称
+  // 特殊处理:智能体选择时同步名称和 IO 字段
   if (field === 'agentId') {
     const opt = agentOptions.value.find(o => o.value === value)
     update.agentName = opt ? opt.label : ''
+    const skill = skillStore.skills?.find(s => s.folderName === value)
+    update.inputs = skill?.inputs || []
+    update.outputs = skill?.outputs || []
+  }
+  // 特殊处理:技能选择时同步名称和 IO 字段
+  if (field === 'skillId') {
+    const opt = agentOptions.value.find(o => o.value === value)
+    update.skillName = opt ? opt.label : ''
+    const skill = skillStore.skills?.find(s => s.folderName === value)
+    update.inputs = skill?.inputs || []
+    update.outputs = skill?.outputs || []
   }
   vfUpdateNode(selectedNode.value.id, update)
   selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
 }
 
 function onConditionChange(index, field, value) {
@@ -250,6 +290,96 @@ function onConditionChange(index, field, value) {
   )
   vfUpdateNode(selectedNode.value.id, { conditions: newConditions })
   selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+// ========== 输出节点字段操作 ==========
+function addOutputField() {
+  if (!selectedData.value) return
+  const newFields = [...(selectedData.value.fields || []), { name: '', label: '', type: 'string', description: '' }]
+  vfUpdateNode(selectedNode.value.id, { fields: newFields })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+function removeOutputField(index) {
+  const newFields = (selectedData.value?.fields || []).filter((_, i) => i !== index)
+  vfUpdateNode(selectedNode.value.id, { fields: newFields })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+// ========== LLM 节点 IO 字段操作 ==========
+function addLlmField(field) {
+  if (!selectedData.value) return
+  const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
+  vfUpdateNode(selectedNode.value.id, { [field]: list })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+function removeLlmField(field, index) {
+  const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
+  vfUpdateNode(selectedNode.value.id, { [field]: list })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+function syncLlmFieldsFromTemplate(field) {
+  if (!selectedData.value) return
+  const extracted = extractTemplateVariables(
+    (selectedData.value.systemPrompt || '') + '\n' + (selectedData.value.userPrompt || '')
+  )
+  const existing = selectedData.value[field] || []
+  const existingNames = new Set(existing.map(f => f.name))
+  const merged = [...existing]
+  for (const name of extracted) {
+    if (!existingNames.has(name)) {
+      merged.push({ name, label: name, type: 'string', description: '' })
+    }
+  }
+  // 移除不在模板中的字段(仅移除无 label 描述的自动提取字段)
+  const final = merged.filter(f => extracted.includes(f.name) || f.label !== f.name || existingNames.has(f.name))
+  vfUpdateNode(selectedNode.value.id, { [field]: final })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+// ========== 智能体节点 IO 字段操作 ==========
+function addAgentField(field) {
+  if (!selectedData.value) return
+  const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '' }]
+  vfUpdateNode(selectedNode.value.id, { [field]: list })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+function removeAgentField(field, index) {
+  const list = (selectedData.value?.[field] || []).filter((_, i) => i !== index)
+  vfUpdateNode(selectedNode.value.id, { [field]: list })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+// ========== 条件分支节点输入变量操作 ==========
+function addConditionInput() {
+  if (!selectedData.value) return
+  const list = [...(selectedData.value.inputs || []), { name: '', label: '', type: 'string', description: '' }]
+  vfUpdateNode(selectedNode.value.id, { inputs: list })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+function removeConditionInput(index) {
+  const list = (selectedData.value?.inputs || []).filter((_, i) => i !== index)
+  vfUpdateNode(selectedNode.value.id, { inputs: list })
+  selectedNode.value = getNodes.value.find(n => n.id === selectedNode.value.id) || selectedNode.value
+  applyEdgeMappingUpdates(refreshMappingsForNode(selectedNode.value.id, getNodes.value, getEdges.value))
+}
+
+// ========== 边状态文案 ==========
+function edgeStatusText(status) {
+  return { ok: '匹配完成', unmapped: '未映射', partial: '部分匹配', mismatch: '类型不匹配' }[status] || '未知'
 }
 
 // ========== 保存 ==========
@@ -262,7 +392,7 @@ async function handleSave() {
   try {
     const graphData = JSON.stringify({
       nodes: getNodes.value.map(n => ({ id: n.id, type: n.type, position: n.position, data: n.data })),
-      edges: getEdges.value.map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle }))
+      edges: getEdges.value.map(e => ({ id: e.id, source: e.source, target: e.target, sourceHandle: e.sourceHandle, data: e.data }))
     })
     if (workflowId) {
       await wfStore.save(workflowId, {
@@ -422,9 +552,14 @@ onMounted(async () => {
             ...e,
             type: 'bezier',
             animated: true,
-            style: defaultEdgeStyle
+            style: defaultEdgeStyle,
+            data: e.data || {}
           }))
           if (edgeData.length) addEdges(edgeData)
+          // 重新推断所有边的 IO 映射(保留已有的手动映射)
+          for (const node of getNodes.value) {
+            applyEdgeMappingUpdates(refreshMappingsForNode(node.id, getNodes.value, getEdges.value))
+          }
         } catch { /* ignore */ }
       }
     } catch {
@@ -502,6 +637,7 @@ onMounted(async () => {
           <template #node-userInput="nodeProps"><InputNode :data="nodeProps.data" /></template>
           <template #node-llm="nodeProps"><LLMNode :data="nodeProps.data" /></template>
           <template #node-agent="nodeProps"><AgentNode :data="nodeProps.data" /></template>
+          <template #node-skill="nodeProps"><SkillNode :data="nodeProps.data" /></template>
           <template #node-output="nodeProps"><OutputNode :data="nodeProps.data" /></template>
           <template #node-condition="nodeProps"><ConditionNode :data="nodeProps.data" /></template>
           <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
@@ -535,7 +671,7 @@ onMounted(async () => {
                       <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('variables', [...selectedData.variables]) }" />
                     </div>
                     <div class="var-form-row">
-                      <n-select :value="v.type" :options="[{label:'字符串',value:'string'},{label:'数字',value:'number'},{label:'布尔',value:'boolean'}]" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('variables', [...selectedData.variables]) }" />
                       <n-input :value="v.description" placeholder="描述" size="small" @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
                     </div>
                     <button class="text-btn danger" @click="removeVariable(i)">删除</button>
@@ -576,6 +712,45 @@ onMounted(async () => {
                     @update:value="v => onFieldChange('userPrompt', v)"
                   />
                 </div>
+                <!-- 输入变量 -->
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">输入变量</span>
+                    <div style="display:flex;gap:4px">
+                      <button class="text-btn" @click="syncLlmFieldsFromTemplate('inputs')">从模板同步</button>
+                      <button class="text-btn" @click="addLlmField('inputs')">+ 添加</button>
+                    </div>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">可用 {{变量名}} 引用,或手动添加</div>
+                </div>
+                <!-- 输出变量 -->
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">输出变量</span>
+                    <button class="text-btn" @click="addLlmField('outputs')">+ 添加</button>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                      <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
+                </div>
               </template>
 
               <!-- 智能体节点 -->
@@ -585,12 +760,99 @@ onMounted(async () => {
                   <n-select
                     :value="selectedData?.agentId"
                     :options="agentOptions"
-                    placeholder="选择 Skill 或智能体"
+                    placeholder="选择技能"
                     size="small"
                     filterable
                     @update:value="v => onFieldChange('agentId', v)"
                   />
                 </div>
+                <!-- 输入变量 -->
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">输入变量</span>
+                    <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
+                </div>
+                <!-- 输出变量 -->
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">输出变量</span>
+                    <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                      <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
+                </div>
+              </template>
+
+              <!-- 技能节点 -->
+              <template v-if="selectedNode?.type === 'skill'">
+                <div class="prop-section">
+                  <label class="prop-label">选择技能</label>
+                  <n-select
+                    :value="selectedData?.skillId"
+                    :options="agentOptions"
+                    placeholder="选择技能"
+                    size="small"
+                    filterable
+                    @update:value="v => onFieldChange('skillId', v)"
+                  />
+                </div>
+                <!-- 输入变量 -->
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">输入变量</span>
+                    <button class="text-btn" @click="addAgentField('inputs')">+ 添加</button>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.inputs || []" :key="'in-'+i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">选择技能后自动填充</div>
+                </div>
+                <!-- 输出变量 -->
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">输出变量</span>
+                    <button class="text-btn" @click="addAgentField('outputs')">+ 添加</button>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.outputs || []" :key="'out-'+i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                      <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
+                </div>
               </template>
 
               <!-- 条件分支节点 -->
@@ -605,7 +867,7 @@ onMounted(async () => {
                       <span class="cond-tag">{{ c.type }}</span>
                       <n-input
                         :value="c.expression"
-                        placeholder="自然语言条件"
+                        placeholder="自然语言条件,可用 {{变量名}}"
                         size="small"
                         @update:value="v => onConditionChange(i, 'expression', v)"
                       />
@@ -613,22 +875,96 @@ onMounted(async () => {
                     </div>
                   </div>
                 </div>
+                <div class="prop-section">
+                  <div class="prop-header">
+                    <span class="prop-label">接收变量</span>
+                    <button class="text-btn" @click="addConditionInput">+ 添加</button>
+                  </div>
+                  <div v-for="(v, i) in selectedData?.inputs || []" :key="i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="v.name" placeholder="变量名" size="small" @update:value="nv => { v.name = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <n-select :value="v.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { v.type = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="v.label" placeholder="中文名" size="small" @update:value="nv => { v.label = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                      <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
+                  <div class="passthrough-hint">所有接收变量将透传至下游节点</div>
+                </div>
               </template>
 
               <!-- 输出节点 -->
               <template v-if="selectedNode?.type === 'output'">
                 <div class="prop-section">
-                  <label class="prop-label">输出格式</label>
-                  <n-select
-                    :value="selectedData?.outputType"
-                    :options="outputTypeOptions"
-                    size="small"
-                    @update:value="v => onFieldChange('outputType', v)"
-                  />
+                  <div class="prop-header">
+                    <span class="prop-label">接收字段</span>
+                    <button class="text-btn" @click="addOutputField">+ 添加</button>
+                  </div>
+                  <div v-for="(f, i) in selectedData?.fields || []" :key="i" class="var-form">
+                    <div class="var-form-row">
+                      <n-input :value="f.name" placeholder="字段名" size="small" @update:value="nv => { f.name = nv; onFieldChange('fields', [...selectedData.fields]) }" />
+                      <n-select :value="f.type" :options="IO_TYPES" size="small" style="width:100px" @update:value="nv => { f.type = nv; onFieldChange('fields', [...selectedData.fields]) }" />
+                    </div>
+                    <div class="var-form-row">
+                      <n-input :value="f.label" placeholder="中文名" size="small" @update:value="nv => { f.label = nv; onFieldChange('fields', [...selectedData.fields]) }" />
+                      <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
+                    </div>
+                  </div>
+                  <div v-if="!selectedData?.fields?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
                 </div>
               </template>
             </template>
-            <div v-else class="empty-props-hint">点击节点查看属性</div>
+            <!-- 边映射信息 -->
+            <template v-else-if="selectedEdge?.data">
+              <div class="prop-section">
+                <div class="prop-header">
+                  <span class="prop-label">连接映射</span>
+                  <span class="edge-status-badge" :class="'es-' + selectedEdge.data.status">
+                    {{ edgeStatusText(selectedEdge.data.status) }}
+                  </span>
+                </div>
+                <!-- 已映射字段 -->
+                <div v-if="selectedEdge.data.mapping?.length" class="mapping-list">
+                  <div v-for="m in selectedEdge.data.mapping" :key="m.sourceField + '-' + m.targetField" class="mapping-item mapped">
+                    <span class="mapping-field">{{ m.sourceField }}</span>
+                    <span class="mapping-arrow">→</span>
+                    <span class="mapping-field">{{ m.targetField }}</span>
+                  </div>
+                </div>
+                <!-- 类型不匹配 -->
+                <div v-if="selectedEdge.data.typeMismatches?.length" class="mapping-list">
+                  <div v-for="tm in selectedEdge.data.typeMismatches" :key="tm.source.name" class="mapping-item mismatch">
+                    <span class="mapping-field">{{ tm.source.name }}</span>
+                    <span class="mapping-type">({{ tm.source.type }})</span>
+                    <span class="mapping-arrow">→</span>
+                    <span class="mapping-field">{{ tm.target.name }}</span>
+                    <span class="mapping-type">({{ tm.target.type }})</span>
+                  </div>
+                </div>
+                <!-- 未匹配的目标输入 -->
+                <div v-if="selectedEdge.data.unmatchedTarget?.length">
+                  <div class="prop-label" style="margin-top:8px">未匹配输入</div>
+                  <div v-for="f in selectedEdge.data.unmatchedTarget" :key="f.name" class="mapping-item unmapped">
+                    <span class="mapping-field">{{ f.name }}</span>
+                    <span class="mapping-type">({{ f.type }})</span>
+                  </div>
+                </div>
+                <!-- 多余的源输出 -->
+                <div v-if="selectedEdge.data.unmatchedSource?.length">
+                  <div class="prop-label" style="margin-top:8px">多余输出</div>
+                  <div v-for="f in selectedEdge.data.unmatchedSource" :key="f.name" class="mapping-item extra">
+                    <span class="mapping-field">{{ f.name }}</span>
+                    <span class="mapping-type">({{ f.type }})</span>
+                  </div>
+                </div>
+                <div v-if="!selectedEdge.data.mapping?.length && !selectedEdge.data.typeMismatches?.length && !(selectedEdge.data.unmatchedTarget?.length) && !(selectedEdge.data.unmatchedSource?.length)" class="empty-hint">
+                  无需映射
+                </div>
+              </div>
+            </template>
+            <div v-else class="empty-props-hint">点击节点或连线查看详情</div>
           </div>
 
           <!-- 运行结果 Tab -->
@@ -1064,6 +1400,16 @@ onMounted(async () => {
   font-style: italic;
 }
 
+.passthrough-hint {
+  font-size: 11px;
+  color: var(--text-tertiary, #555);
+  margin-top: 6px;
+  padding: 4px 8px;
+  background: rgba(249,115,22,0.08);
+  border-radius: 4px;
+  color: #fb923c;
+}
+
 /* 通用按钮 */
 .icon-btn {
   width: 32px;
@@ -1199,4 +1545,50 @@ onMounted(async () => {
   color: var(--text-secondary, #999);
   margin-bottom: 4px;
 }
+
+/* 边映射状态 */
+.edge-status-badge {
+  font-size: 11px;
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-weight: 600;
+}
+.edge-status-badge.es-ok { background: rgba(34,197,94,0.15); color: #4ade80; }
+.edge-status-badge.es-unmapped { background: rgba(107,114,128,0.15); color: #9ca3af; }
+.edge-status-badge.es-partial { background: rgba(245,158,11,0.15); color: #fbbf24; }
+.edge-status-badge.es-mismatch { background: rgba(239,68,68,0.15); color: #f87171; }
+
+.mapping-list {
+  margin-top: 6px;
+}
+
+.mapping-item {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  margin-bottom: 3px;
+  font-size: 12px;
+  font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+
+.mapping-item.mapped { background: rgba(34,197,94,0.08); }
+.mapping-item.mismatch { background: rgba(239,68,68,0.08); }
+.mapping-item.unmapped { background: rgba(245,158,11,0.08); }
+.mapping-item.extra { background: rgba(107,114,128,0.06); }
+
+.mapping-field {
+  color: var(--text-primary, #e0e0e0);
+  font-weight: 500;
+}
+
+.mapping-arrow {
+  color: var(--text-tertiary, #666);
+}
+
+.mapping-type {
+  color: var(--text-tertiary, #666);
+  font-size: 10px;
+}
 </style>

+ 88 - 1
prompt.md

@@ -155,4 +155,91 @@ Uncaught (in promise) TypeError: Failed to resolve module specifier "@/api/trans
 3. 当A和B均未定义输出,C定义了输入,那么需在属性区域分配C的哪些输入由A给出,哪些由B给出;
 4. 当A的输出+B的输出≠C的输入,或存在其他无法处理的情况,用户需手动分配输入和输出变量。
 
-另外,输入和输出的数据类型,可以为文件和目录的路径,增加该项配置。
+另外,输入和输出的数据类型,可以为文件和目录的路径,增加该项配置。
+
+---
+
+输出的参数也定义变量吧,不只选择文本和json类型了。
+大模型处理同样可设置输入变量和输出变量。
+条件分支也可以接收前方的输出变量,作为条件判断的依据。同时,它接收的所有变量均作为它的输出,透传至下个节点
+智能体节点,在选择智能体后,对应智能体的输入和输出就是该节点的输入输出,但可以编辑。
+
+---
+
+请统一Skill输入输出参数的类型和工作流节点输入输出参数的类型,并都用同样的中文来表示。
+另外,“输出”节点接收不再保留“文本”和“JSON对象”下拉框,而是默认保留一个result字段,类型可以选择各种参数类型。
+
+---
+
+我可能走入了一个误区。请帮我思考:Skill本身是为了让技能执行时更灵活,它运行时,很多情况下不是必须严格约束输入和输出。例如代码审查Skill,不一定输入源代码,有可能是读取目录中的文件。那么,我是否应该硬约束输入和输出?还是设计两套机制,可以切换?还是做一些软约束?
+
+---
+
+有些情况下,上游Skill和下游Skill,输入输出不一定完全匹配,例如上游“写代码”Skill,针对src子目录进行写入;下游“代码审查”Skill,针对整个目录进行审查。如何处理这种情况?
+
+---
+
+不是的,我的意思是,尽管输入和输出不一定完全匹配,但在业务上,这是合理的。我希望想办法来兼容这种情况,保留灵活性。请进一步思考。
+
+---
+
+将你的思考先保存到temp/docs目录下,以markdown的形式,我稍后再来仔细考虑这个问题。现在我们先干其他工作。
+
+---
+
+接下来,我们将智能体和Skill的概念区分开来。Skill作为“技能”,是更小粒度的能力;而智能体是将多个技能和其他智能体编排起来的成果。所以,我们的“智能体管理”菜单修改为“技能管理”,且该页面所有的“智能体”均修改为“技能”。然后,创建上级菜单“智能体管理”,下方包含“技能管理”和“智能体编排”两个菜单。
+
+---
+
+我们来完善技能编辑功能。点击“编辑”按钮之后,原来的弹出编辑对话框形式,修改为跳转页面,页面带返回按钮,这样编辑区域可以更大一些。编辑的内容,除自动识别的SKILL.md中的“名称”、“描述”、“输入”、“输出”之外,增加正文编辑区域,将正文部分加载至该区域进行编辑。同样提供翻译功能,支持将英文正文翻译为中文。参考vscode中对markdown格式的语法高亮,实现markdown关键字和符号的高亮。另外,在适当位置增加“高级编辑”按钮,点击后,页面左侧变为目录树形式(不影响菜单栏),将对应Skill目录中的所有文件列出,可以在目录树中新建目录、新建文件等,交互方式类似vscode中的新建方式。新建后,点击对应文件,右侧变为文本编辑区域,可以对文本进行编辑,支持各类后缀(json、py、md、java等)文件的对应关键字高亮。
+
+---
+
+在工作流中增加“技能”节点,与“智能体”行为类似。
+
+---
+
+将网页标题修改为“智能体管理平台”
+
+---
+
+Skill编辑时,正文内容不要包含---内部的部分,以及后面的一行空行,直接从正文开始。保存时,进行拼接。
+
+---
+
+保存时逻辑再完善一下,原有---中的内容,除name、description外,可能还有metadata等字段。所以如果我们进行了修改,那么只替换name、description中的内容即可;如果加了inputs和outputs字段,紧跟到description之后即可。原有metadata等字段不要删除。如果这些字段未被---包裹起来,再处理一层,将之用---包裹,并确认其与正文间包含一行空行。
+
+---
+
+后端报错: [翻译] 大模型调用异常: I/O error on POST request for "https://open.bigmodel.cn/api/paas/v4/chat/completions": Read timed out
+
+---
+
+关于Skill正文的翻译,后端已正常返回结果,为什么前端未更新为中文?
+
+---
+
+我有3个skill,点开第2个的编辑按钮,请求体发送了第2个skill的正文翻译请求,但返回结果没有对应响应,只有第1个的响应。对应日志如下:
+
+---
+
+一般而言,大模型的翻译速度最慢为40字符/秒。针对正文过长的情况,请动态设置前端超时时长,留出双倍的时间等待响应返回。
+
+---
+
+第一次点进“高级编辑”时,左侧总是空的,需要再次点进去才能加载目录结构,请检查。
+
+---
+
+目录结构展开的层级有问题,例如某个目录下,包含A目录、B目录、C文件,那么A目录展开后,A目录下的文件展开后,会显示到C文件下方,且为平级显示。请修复。
+
+---
+
+在目录中新建文件和新建目录的logo不太准确,修改一下;另外,不要用原生的弹出框进行输入,用统一界面风格的弹出框。
+
+---
+
+初次提交中,我把backend/src/main/java/resources/static/提交了,实际上不需要。我希望撤销提交,从暂存区删除该目录,然后重新提交;然后把本次修改生成第二次提交。请帮我生成git命令序列。注意:不要执行。
+
+---
+