Sfoglia il codice sorgente

1. 实现了标签打标系统,支持对技能和工作流实体进行多标签关联,提供标签选择器组件和导入导出功能;
2. 实现了技能版本管理,支持版本列表查看、版本内容对比、版本备注编辑和一键回滚;
3. 实现了技能分类管理,支持树形分类的增删改查、拖拽移动和按分类筛选,分类与工作流分类独立隔离;
4. 实现了技能列表搜索和分页,支持关键词模糊搜索和分类筛选下的服务端分页;
5. 实现了AI模型管理页面,支持多模型配置、连接测试、可用模型列表拉取和默认模型切换;
6. 实现了工作流(智能体)分类管理,独立于技能分类的树形分类体系,支持增删改查和拖拽移动;
7. 实现了工作流列表搜索和分页,支持关键词搜索、分类筛选和分页卡片展示;
8. 新增了智能动作(SmartAction)工作流节点类型,支持结构化动作执行;
9. 优化了工作流引擎,新增节点失败策略(abort/skip),失败时可选择跳过继续执行;
10. 优化了标签管理,修复标签组内新增一级标签在打标选择器中不显示的问题;
11. 优化了工作流编辑器默认缩放级别,画布加载后自动适配视口。

weisijie 4 mesi fa
parent
commit
389384057a
71 ha cambiato i file con 7216 aggiunte e 390 eliminazioni
  1. 2 0
      .gitignore
  2. 127 0
      backend/src/main/java/com/agent/management/controller/AiModelController.java
  3. 50 0
      backend/src/main/java/com/agent/management/controller/CategoryController.java
  4. 142 17
      backend/src/main/java/com/agent/management/controller/SkillController.java
  5. 56 0
      backend/src/main/java/com/agent/management/controller/TagController.java
  6. 50 0
      backend/src/main/java/com/agent/management/controller/WfCategoryController.java
  7. 80 8
      backend/src/main/java/com/agent/management/controller/WorkflowController.java
  8. 5 1
      backend/src/main/java/com/agent/management/engine/NodeExecutionResult.java
  9. 10 3
      backend/src/main/java/com/agent/management/engine/WorkflowEngine.java
  10. 21 6
      backend/src/main/java/com/agent/management/engine/executor/AgentExecutor.java
  11. 3 2
      backend/src/main/java/com/agent/management/engine/executor/ConditionExecutor.java
  12. 22 4
      backend/src/main/java/com/agent/management/engine/executor/LlmExecutor.java
  13. 76 0
      backend/src/main/java/com/agent/management/engine/executor/SmartActionExecutor.java
  14. 50 0
      backend/src/main/java/com/agent/management/model/dto/TagExportDTO.java
  15. 23 0
      backend/src/main/java/com/agent/management/model/dto/TagImportDTO.java
  16. 28 0
      backend/src/main/java/com/agent/management/model/dto/TagImportResultDTO.java
  17. 63 0
      backend/src/main/java/com/agent/management/model/entity/AiModel.java
  18. 45 0
      backend/src/main/java/com/agent/management/model/entity/Category.java
  19. 35 0
      backend/src/main/java/com/agent/management/model/entity/SkillCategory.java
  20. 40 0
      backend/src/main/java/com/agent/management/model/entity/TagAssignment.java
  21. 45 0
      backend/src/main/java/com/agent/management/model/entity/WfCategory.java
  22. 4 0
      backend/src/main/java/com/agent/management/model/entity/Workflow.java
  23. 19 0
      backend/src/main/java/com/agent/management/model/vo/CategoryVO.java
  24. 28 0
      backend/src/main/java/com/agent/management/model/vo/PageResult.java
  25. 20 0
      backend/src/main/java/com/agent/management/model/vo/SkillVO.java
  26. 27 0
      backend/src/main/java/com/agent/management/model/vo/SkillVersionVO.java
  27. 18 0
      backend/src/main/java/com/agent/management/model/vo/TagBriefVO.java
  28. 40 0
      backend/src/main/java/com/agent/management/model/vo/WorkflowVO.java
  29. 14 0
      backend/src/main/java/com/agent/management/repository/AiModelRepository.java
  30. 13 0
      backend/src/main/java/com/agent/management/repository/CategoryRepository.java
  31. 18 0
      backend/src/main/java/com/agent/management/repository/SkillCategoryRepository.java
  32. 15 0
      backend/src/main/java/com/agent/management/repository/TagAssignmentRepository.java
  33. 15 0
      backend/src/main/java/com/agent/management/repository/WfCategoryRepository.java
  34. 52 0
      backend/src/main/java/com/agent/management/service/AiModelService.java
  35. 23 0
      backend/src/main/java/com/agent/management/service/CategoryService.java
  36. 42 0
      backend/src/main/java/com/agent/management/service/SkillVersionService.java
  37. 30 0
      backend/src/main/java/com/agent/management/service/TagService.java
  38. 20 0
      backend/src/main/java/com/agent/management/service/WfCategoryService.java
  39. 295 0
      backend/src/main/java/com/agent/management/service/impl/AiModelServiceImpl.java
  40. 202 0
      backend/src/main/java/com/agent/management/service/impl/CategoryServiceImpl.java
  41. 14 0
      backend/src/main/java/com/agent/management/service/impl/SkillServiceImpl.java
  42. 261 0
      backend/src/main/java/com/agent/management/service/impl/SkillVersionServiceImpl.java
  43. 282 7
      backend/src/main/java/com/agent/management/service/impl/TagServiceImpl.java
  44. 3 1
      backend/src/main/java/com/agent/management/service/impl/TranslationServiceImpl.java
  45. 201 0
      backend/src/main/java/com/agent/management/service/impl/WfCategoryServiceImpl.java
  46. 1 1
      backend/src/main/resources/application.yml.example
  47. 378 0
      docs/tag-system-spec.md
  48. 21 0
      frontend/src/api/category.js
  49. 36 0
      frontend/src/api/model.js
  50. 26 2
      frontend/src/api/skill.js
  51. 27 0
      frontend/src/api/tag.js
  52. 21 0
      frontend/src/api/wfCategory.js
  53. 6 2
      frontend/src/api/workflow.js
  54. 3 1
      frontend/src/components/layout/AppSidebar.vue
  55. 230 0
      frontend/src/components/skill/CategoryTree.vue
  56. 42 3
      frontend/src/components/skill/SkillCard.vue
  57. 3 3
      frontend/src/components/skill/SkillForm.vue
  58. 484 0
      frontend/src/components/tag/TagSelector.vue
  59. 6 2
      frontend/src/components/workflow/nodes/LLMNode.vue
  60. 58 0
      frontend/src/components/workflow/nodes/SmartActionNode.vue
  61. 6 0
      frontend/src/router/index.js
  62. 51 3
      frontend/src/stores/skill.js
  63. 44 7
      frontend/src/stores/workflow.js
  64. 440 0
      frontend/src/views/ModelManagement.vue
  65. 505 60
      frontend/src/views/SkillManagement.vue
  66. 202 5
      frontend/src/views/TagManagement.vue
  67. 624 93
      frontend/src/views/skill/SkillEdit.vue
  68. 293 38
      frontend/src/views/workflow/WorkflowEditor.vue
  69. 730 121
      frontend/src/views/workflow/WorkflowList.vue
  70. 78 0
      prompt.md
  71. 272 0
      skills/tag-system/SKILL.md

+ 2 - 0
.gitignore

@@ -45,6 +45,8 @@ backend/src/main/resources/static/
 chat-history-*.jsonl
 test-*.json
 temp/
+uploads/
+hermes-agent/
 
 # ===== 调试截图 =====
 condition-elif-test.png

+ 127 - 0
backend/src/main/java/com/agent/management/controller/AiModelController.java

@@ -0,0 +1,127 @@
+package com.agent.management.controller;
+
+import com.agent.management.common.Result;
+import com.agent.management.model.entity.AiModel;
+import com.agent.management.service.AiModelService;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/models")
+@RequiredArgsConstructor
+public class AiModelController {
+
+    private final AiModelService modelService;
+
+    @GetMapping
+    public Result<List<AiModel>> list() {
+        List<AiModel> models = modelService.listModels();
+        for (AiModel m : models) {
+            m.setApiKey(maskApiKey(m.getApiKey()));
+        }
+        return Result.success(models);
+    }
+
+    @GetMapping("/{id}")
+    public Result<AiModel> get(@PathVariable Long id) {
+        AiModel model = modelService.getModel(id);
+        model.setApiKey(maskApiKey(model.getApiKey()));
+        return Result.success(model);
+    }
+
+    @PostMapping
+    public Result<AiModel> create(@RequestBody ModelReq req) {
+        AiModel model = new AiModel();
+        model.setName(req.getName());
+        model.setBaseUrl(req.getBaseUrl());
+        model.setCompletionsPath(req.getCompletionsPath());
+        model.setApiKey(req.getApiKey());
+        model.setModelName(req.getModelName());
+        model.setTemperature(req.getTemperature() != null ? req.getTemperature() : 0.3);
+        model.setIsDefault(req.getIsDefault() != null ? req.getIsDefault() : false);
+        AiModel saved = modelService.createModel(model);
+        saved.setApiKey(maskApiKey(saved.getApiKey()));
+        return Result.success(saved);
+    }
+
+    @PostMapping("/{id}/edit")
+    public Result<AiModel> update(@PathVariable Long id, @RequestBody ModelReq req) {
+        AiModel model = new AiModel();
+        model.setName(req.getName());
+        model.setBaseUrl(req.getBaseUrl());
+        model.setCompletionsPath(req.getCompletionsPath());
+        model.setApiKey(req.getApiKey());
+        model.setModelName(req.getModelName());
+        model.setTemperature(req.getTemperature());
+        model.setIsDefault(req.getIsDefault());
+        AiModel saved = modelService.updateModel(id, model);
+        saved.setApiKey(maskApiKey(saved.getApiKey()));
+        return Result.success(saved);
+    }
+
+    @PostMapping("/{id}/delete")
+    public Result<Void> delete(@PathVariable Long id) {
+        modelService.deleteModel(id);
+        return Result.success(null);
+    }
+
+    @PostMapping("/{id}/default")
+    public Result<Void> setDefault(@PathVariable Long id) {
+        modelService.setDefault(id);
+        return Result.success(null);
+    }
+
+    /**
+     * 测试已保存模型的连接
+     */
+    @PostMapping("/{id}/test")
+    public Result<Map<String, Object>> testSaved(@PathVariable Long id) {
+        return Result.success(modelService.testConnection(id));
+    }
+
+    /**
+     * 测试自定义配置的连接(无需保存,用于新建/编辑时预验证)
+     */
+    @PostMapping("/test")
+    public Result<Map<String, Object>> testConnection(@RequestBody ModelReq req) {
+        AiModel model = new AiModel();
+        model.setBaseUrl(req.getBaseUrl());
+        model.setCompletionsPath(req.getCompletionsPath());
+        model.setApiKey(req.getApiKey());
+        model.setModelName(req.getModelName());
+        model.setTemperature(req.getTemperature() != null ? req.getTemperature() : 0.3);
+        return Result.success(modelService.testConnection(model));
+    }
+
+    /**
+     * 获取接口下可用的模型列表
+     */
+    @PostMapping("/fetch-models")
+    public Result<List<String>> fetchModels(@RequestBody ModelReq req) {
+        AiModel model = new AiModel();
+        model.setBaseUrl(req.getBaseUrl());
+        model.setApiKey(req.getApiKey());
+        return Result.success(modelService.fetchAvailableModels(model));
+    }
+
+    @Data
+    public static class ModelReq {
+        private String name;
+        private String baseUrl;
+        private String completionsPath;
+        private String apiKey;
+        private String modelName;
+        private Double temperature;
+        private Boolean isDefault;
+    }
+
+    /** 脱敏:保留前4位和后4位 */
+    private String maskApiKey(String key) {
+        if (key == null || key.length() <= 12) return "****";
+        return key.substring(0, 4) + "****" + key.substring(key.length() - 4);
+    }
+}

+ 50 - 0
backend/src/main/java/com/agent/management/controller/CategoryController.java

@@ -0,0 +1,50 @@
+package com.agent.management.controller;
+
+import com.agent.management.common.Result;
+import com.agent.management.model.vo.CategoryVO;
+import com.agent.management.service.CategoryService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/categories")
+@RequiredArgsConstructor
+public class CategoryController {
+
+    private final CategoryService categoryService;
+
+    @GetMapping
+    public Result<List<CategoryVO>> getCategoryTree() {
+        return Result.success(categoryService.getCategoryTree());
+    }
+
+    @PostMapping
+    public Result<CategoryVO> createCategory(@RequestBody Map<String, Object> body) {
+        Long parentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null;
+        String name = (String) body.get("name");
+        return Result.success(categoryService.createCategory(parentId, name));
+    }
+
+    @PutMapping("/{id}")
+    public Result<CategoryVO> updateCategory(@PathVariable Long id,
+                                              @RequestBody Map<String, String> body) {
+        return Result.success(categoryService.updateCategory(id, body.get("name")));
+    }
+
+    @DeleteMapping("/{id}")
+    public Result<Void> deleteCategory(@PathVariable Long id) {
+        categoryService.deleteCategory(id);
+        return Result.success("删除成功", null);
+    }
+
+    @PutMapping("/{id}/move")
+    public Result<Void> moveCategory(@PathVariable Long id,
+                                      @RequestBody Map<String, Object> body) {
+        Long newParentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null;
+        categoryService.moveCategory(id, newParentId);
+        return Result.success("移动成功", null);
+    }
+}

+ 142 - 17
backend/src/main/java/com/agent/management/controller/SkillController.java

@@ -1,22 +1,23 @@
 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.model.dto.*;
+import com.agent.management.model.entity.Category;
+import com.agent.management.model.entity.SkillCategory;
+import com.agent.management.model.vo.*;
+import com.agent.management.repository.CategoryRepository;
+import com.agent.management.repository.SkillCategoryRepository;
+import com.agent.management.service.CategoryService;
 import com.agent.management.service.SkillService;
+import com.agent.management.service.SkillVersionService;
+import com.agent.management.service.TagService;
 import com.agent.management.service.TranslationService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
 @Slf4j
@@ -27,23 +28,72 @@ public class SkillController {
 
     private final SkillService skillService;
     private final TranslationService translationService;
+    private final TagService tagService;
+    private final SkillVersionService skillVersionService;
+    private final SkillCategoryRepository skillCategoryRepository;
+    private final CategoryRepository categoryRepository;
+    private final CategoryService categoryService;
 
     /**
-     * 获取所有 Skill 列表
-     * 立即返回数据(优先带缓存翻译),未缓存的通过 SSE 异步推送
+     * 获取 Skill 列表(支持搜索、分类筛选、分页)
      */
     @GetMapping
-    public Result<List<SkillVO>> listSkills() {
+    public Result<PageResult<SkillVO>> listSkills(
+            @RequestParam(required = false) String search,
+            @RequestParam(required = false) Long categoryId,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "12") int size) {
+
         List<SkillDTO> skills = skillService.listSkills();
 
-        // 构建 VO:优先从缓存读取翻译(不触发 LLM)
+        // 构建分类映射:folderName -> categoryId
+        Map<String, Long> skillCategoryMap = skillCategoryRepository.findAll().stream()
+                .collect(Collectors.toMap(SkillCategory::getSkillFolderName, SkillCategory::getCategoryId));
+        // 分类名称映射
+        Map<Long, String> categoryNameMap = categoryRepository.findAll().stream()
+                .collect(Collectors.toMap(Category::getId, Category::getName));
+
+        // 搜索过滤
+        String keyword = (search != null && !search.trim().isEmpty()) ? search.trim().toLowerCase() : null;
+        if (keyword != null) {
+            skills = skills.stream()
+                    .filter(dto -> {
+                        String name = dto.getName() != null ? dto.getName().toLowerCase() : "";
+                        String desc = dto.getDescription() != null ? dto.getDescription().toLowerCase() : "";
+                        return name.contains(keyword) || desc.contains(keyword);
+                    })
+                    .collect(Collectors.toList());
+        }
+
+        // 分类筛选(含子分类)
+        if (categoryId != null) {
+            List<Long> categoryIds = new ArrayList<>();
+            categoryIds.add(categoryId);
+            categoryIds.addAll(categoryService.getDescendantCategoryIds(categoryId));
+            Set<Long> allowedIds = new HashSet<>(categoryIds);
+            skills = skills.stream()
+                    .filter(dto -> {
+                        Long cid = skillCategoryMap.get(dto.getFolderName());
+                        return cid != null && allowedIds.contains(cid);
+                    })
+                    .collect(Collectors.toList());
+        }
+
+        // 构建 VO
         List<SkillVO> voList = skills.stream()
-                .map(this::toVOWithCache)
+                .map(dto -> {
+                    SkillVO vo = toVOWithCache(dto);
+                    Long cid = skillCategoryMap.get(dto.getFolderName());
+                    if (cid != null) {
+                        vo.setCategoryId(cid);
+                        vo.setCategoryName(categoryNameMap.get(cid));
+                    }
+                    return vo;
+                })
                 .collect(Collectors.toList());
 
-        // 对需要翻译的 Skill 异步翻译 + SSE 推送
+        // 异步翻译
         for (SkillDTO dto : skills) {
-            // 只有缓存未命中时才需要异步翻译
             String cachedName = translationService.getCachedTranslation(dto.getName());
             String cachedDesc = translationService.getCachedTranslation(dto.getDescription());
             if (cachedName == null || cachedDesc == null) {
@@ -52,7 +102,15 @@ public class SkillController {
             }
         }
 
-        return Result.success(voList);
+        // 分页
+        long total = voList.size();
+        int fromIndex = (page - 1) * size;
+        int toIndex = Math.min(fromIndex + size, voList.size());
+        List<SkillVO> paged = fromIndex < voList.size()
+                ? voList.subList(fromIndex, toIndex)
+                : Collections.emptyList();
+
+        return Result.success(PageResult.of(paged, total, page, size));
     }
 
     @PostMapping("/upload")
@@ -172,6 +230,71 @@ public class SkillController {
         return Result.success("删除成功", null);
     }
 
+    // ==================== 版本管理 API ====================
+
+    /**
+     * 获取版本列表
+     */
+    @GetMapping("/{folderName}/versions")
+    public Result<List<SkillVersionVO>> listVersions(@PathVariable String folderName) {
+        return Result.success(skillVersionService.listVersions(folderName));
+    }
+
+    /**
+     * 获取指定版本的 SKILL.md 内容
+     */
+    @GetMapping("/{folderName}/versions/{version}/content")
+    public Result<String> getVersionContent(@PathVariable String folderName,
+                                            @PathVariable int version) {
+        return Result.success(skillVersionService.getVersionContent(folderName, version));
+    }
+
+    /**
+     * 回滚到指定版本
+     */
+    @PostMapping("/{folderName}/versions/{version}/rollback")
+    public Result<Void> rollbackVersion(@PathVariable String folderName,
+                                        @PathVariable int version) {
+        skillVersionService.rollback(folderName, version);
+        return Result.success("回滚成功", null);
+    }
+
+    /**
+     * 更新版本备注
+     */
+    @PutMapping("/{folderName}/versions/{version}/message")
+    public Result<Void> updateVersionMessage(@PathVariable String folderName,
+                                             @PathVariable int version,
+                                             @RequestBody Map<String, String> body) {
+        skillVersionService.updateVersionMessage(folderName, version, body.get("message"));
+        return Result.success("更新成功", null);
+    }
+
+    // ==================== 技能-分类关联 API ====================
+
+    /**
+     * 设置技能的分类(传 categoryId 为 null 时取消分类)
+     */
+    @PutMapping("/{folderName}/category")
+    public Result<Void> setSkillCategory(@PathVariable String folderName,
+                                          @RequestBody Map<String, Object> body) {
+        Object cidObj = body.get("categoryId");
+        if (cidObj == null) {
+            skillCategoryRepository.deleteBySkillFolderName(folderName);
+        } else {
+            Long categoryId = Long.valueOf(cidObj.toString());
+            SkillCategory mapping = skillCategoryRepository.findBySkillFolderName(folderName)
+                    .orElseGet(() -> {
+                        SkillCategory sc = new SkillCategory();
+                        sc.setSkillFolderName(folderName);
+                        return sc;
+                    });
+            mapping.setCategoryId(categoryId);
+            skillCategoryRepository.save(mapping);
+        }
+        return Result.success("更新成功", null);
+    }
+
     // ==================== 私有方法 ====================
 
     /**
@@ -186,6 +309,8 @@ public class SkillController {
         vo.setTranslatedDescription(translationService.getCachedTranslation(dto.getDescription()));
         vo.setInputs(dto.getInputs());
         vo.setOutputs(dto.getOutputs());
+        vo.setTags(tagService.getEntityTags("skill", dto.getFolderName()));
+        vo.setCurrentVersion(skillVersionService.getCurrentVersion(dto.getFolderName()));
         return vo;
     }
 }

+ 56 - 0
backend/src/main/java/com/agent/management/controller/TagController.java

@@ -1,14 +1,19 @@
 package com.agent.management.controller;
 
 import com.agent.management.common.Result;
+import com.agent.management.model.dto.TagExportDTO;
+import com.agent.management.model.dto.TagImportDTO;
+import com.agent.management.model.dto.TagImportResultDTO;
 import com.agent.management.model.entity.Tag;
 import com.agent.management.model.entity.TagGroup;
+import com.agent.management.model.vo.TagBriefVO;
 import com.agent.management.service.TagService;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
+import java.util.Map;
 
 @RestController
 @RequestMapping("/api/tags")
@@ -68,6 +73,50 @@ public class TagController {
         return Result.success(tagService.moveTag(id, req.getParentId(), req.getSortOrder()));
     }
 
+    // ========== 导入导出 ==========
+
+    @GetMapping("/export")
+    public Result<TagExportDTO> exportTags() {
+        return Result.success(tagService.exportAll());
+    }
+
+    @PostMapping("/import")
+    public Result<TagImportResultDTO> importTags(@RequestBody TagImportDTO req) {
+        return Result.success(tagService.importTags(req));
+    }
+
+    // ========== 打标 ==========
+
+    /** 设置实体的标签(全量替换) */
+    @PostMapping("/assignments")
+    public Result<Void> setTags(@RequestBody SetTagsReq req) {
+        tagService.setTags(req.getEntityType(), req.getEntityId(), req.getTagIds());
+        return Result.success(null);
+    }
+
+    /** 获取实体的标签列表 */
+    @GetMapping("/assignments")
+    public Result<List<TagBriefVO>> getEntityTags(
+            @RequestParam String entityType,
+            @RequestParam String entityId) {
+        return Result.success(tagService.getEntityTags(entityType, entityId));
+    }
+
+    /** 获取所有标签组及其标签(用于标签选择器) */
+    @GetMapping("/all-with-tags")
+    public Result<List<Map<String, Object>>> listAllWithTags() {
+        List<TagGroup> groups = tagService.listGroups();
+        List<Map<String, Object>> result = new java.util.ArrayList<>();
+        for (TagGroup g : groups) {
+            Map<String, Object> item = new java.util.LinkedHashMap<>();
+            item.put("id", g.getId());
+            item.put("name", g.getName());
+            item.put("tags", tagService.listTags(g.getId()));
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
     // ========== 请求体 ==========
 
     @Data
@@ -98,4 +147,11 @@ public class TagController {
         private Long parentId;
         private Integer sortOrder;
     }
+
+    @Data
+    public static class SetTagsReq {
+        private String entityType;
+        private String entityId;
+        private List<Long> tagIds;
+    }
 }

+ 50 - 0
backend/src/main/java/com/agent/management/controller/WfCategoryController.java

@@ -0,0 +1,50 @@
+package com.agent.management.controller;
+
+import com.agent.management.common.Result;
+import com.agent.management.model.vo.CategoryVO;
+import com.agent.management.service.WfCategoryService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/wf-categories")
+@RequiredArgsConstructor
+public class WfCategoryController {
+
+    private final WfCategoryService wfCategoryService;
+
+    @GetMapping
+    public Result<List<CategoryVO>> getCategoryTree() {
+        return Result.success(wfCategoryService.getCategoryTree());
+    }
+
+    @PostMapping
+    public Result<CategoryVO> createCategory(@RequestBody Map<String, Object> body) {
+        Long parentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null;
+        String name = (String) body.get("name");
+        return Result.success(wfCategoryService.createCategory(parentId, name));
+    }
+
+    @PutMapping("/{id}")
+    public Result<CategoryVO> updateCategory(@PathVariable Long id,
+                                              @RequestBody Map<String, String> body) {
+        return Result.success(wfCategoryService.updateCategory(id, body.get("name")));
+    }
+
+    @DeleteMapping("/{id}")
+    public Result<Void> deleteCategory(@PathVariable Long id) {
+        wfCategoryService.deleteCategory(id);
+        return Result.success("删除成功", null);
+    }
+
+    @PutMapping("/{id}/move")
+    public Result<Void> moveCategory(@PathVariable Long id,
+                                      @RequestBody Map<String, Object> body) {
+        Long newParentId = body.get("parentId") != null ? Long.valueOf(body.get("parentId").toString()) : null;
+        wfCategoryService.moveCategory(id, newParentId);
+        return Result.success("移动成功", null);
+    }
+}

+ 80 - 8
backend/src/main/java/com/agent/management/controller/WorkflowController.java

@@ -4,6 +4,11 @@ import com.agent.management.common.Result;
 import com.agent.management.engine.WorkflowEngine;
 import com.agent.management.engine.WorkflowRunRequest;
 import com.agent.management.model.entity.Workflow;
+import com.agent.management.model.vo.PageResult;
+import com.agent.management.model.vo.WorkflowVO;
+import com.agent.management.repository.WfCategoryRepository;
+import com.agent.management.service.TagService;
+import com.agent.management.service.WfCategoryService;
 import com.agent.management.service.WorkflowService;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
@@ -11,7 +16,9 @@ import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
+import java.util.ArrayList;
 import java.util.List;
+import java.util.stream.Collectors;
 
 @RestController
 @RequestMapping("/api/workflows")
@@ -20,25 +27,65 @@ public class WorkflowController {
 
     private final WorkflowService workflowService;
     private final WorkflowEngine workflowEngine;
+    private final TagService tagService;
+    private final WfCategoryRepository wfCategoryRepository;
+    private final WfCategoryService wfCategoryService;
 
     @GetMapping
-    public Result<List<Workflow>> list() {
-        return Result.success(workflowService.listWorkflows());
+    public Result<PageResult<WorkflowVO>> list(
+            @RequestParam(required = false) String search,
+            @RequestParam(required = false) Long categoryId,
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "12") int size) {
+
+        List<Workflow> all = workflowService.listWorkflows();
+
+        // 搜索过滤
+        if (search != null && !search.trim().isEmpty()) {
+            String kw = search.trim().toLowerCase();
+            all = all.stream()
+                    .filter(wf -> (wf.getName() != null && wf.getName().toLowerCase().contains(kw))
+                            || (wf.getDescription() != null && wf.getDescription().toLowerCase().contains(kw)))
+                    .collect(Collectors.toList());
+        }
+
+        // 分类过滤(含子孙分类)
+        if (categoryId != null) {
+            List<Long> catIds = new ArrayList<>();
+            catIds.add(categoryId);
+            catIds.addAll(wfCategoryService.getDescendantCategoryIds(categoryId));
+            all = all.stream()
+                    .filter(wf -> wf.getCategoryId() != null && catIds.contains(wf.getCategoryId()))
+                    .collect(Collectors.toList());
+        }
+
+        int total = all.size();
+        int totalPages = Math.max(1, (int) Math.ceil((double) total / size));
+        if (page > totalPages) page = totalPages;
+        int from = (page - 1) * size;
+        int to = Math.min(from + size, total);
+        List<Workflow> pageData = from < total ? all.subList(from, to) : List.of();
+
+        List<WorkflowVO> voList = pageData.stream().map(this::toVO).collect(Collectors.toList());
+        return Result.success(PageResult.of(voList, total, page, size));
     }
 
     @GetMapping("/{id}")
-    public Result<Workflow> get(@PathVariable Long id) {
-        return Result.success(workflowService.getWorkflow(id));
+    public Result<WorkflowVO> get(@PathVariable Long id) {
+        Workflow wf = workflowService.getWorkflow(id);
+        return Result.success(toVO(wf));
     }
 
     @PostMapping
-    public Result<Workflow> create(@RequestBody CreateWorkflowReq req) {
-        return Result.success(workflowService.createWorkflow(req.getName(), req.getDescription()));
+    public Result<WorkflowVO> create(@RequestBody CreateWorkflowReq req) {
+        Workflow wf = workflowService.createWorkflow(req.getName(), req.getDescription());
+        return Result.success(toVO(wf));
     }
 
     @PostMapping("/{id}/save")
-    public Result<Workflow> save(@PathVariable Long id, @RequestBody SaveWorkflowReq req) {
-        return Result.success(workflowService.updateWorkflow(id, req.getName(), req.getDescription(), req.getGraphData()));
+    public Result<WorkflowVO> save(@PathVariable Long id, @RequestBody SaveWorkflowReq req) {
+        Workflow wf = workflowService.updateWorkflow(id, req.getName(), req.getDescription(), req.getGraphData());
+        return Result.success(toVO(wf));
     }
 
     @PostMapping("/{id}/delete")
@@ -47,6 +94,15 @@ public class WorkflowController {
         return Result.success(null);
     }
 
+    /** 设置工作流分类 */
+    @PutMapping("/{id}/category")
+    public Result<Void> setCategory(@PathVariable Long id, @RequestBody SetCategoryReq req) {
+        Workflow wf = workflowService.getWorkflow(id);
+        wf.setCategoryId(req.getCategoryId());
+        workflowService.updateWorkflow(wf.getId(), wf.getName(), wf.getDescription(), wf.getGraphData());
+        return Result.success(null);
+    }
+
     /**
      * 运行工作流(SSE 实时推送执行状态)
      */
@@ -57,6 +113,17 @@ public class WorkflowController {
         return emitter;
     }
 
+    private WorkflowVO toVO(Workflow wf) {
+        WorkflowVO vo = WorkflowVO.from(wf);
+        vo.setTags(tagService.getEntityTags("workflow", String.valueOf(wf.getId())));
+        // 填充分类名称
+        if (wf.getCategoryId() != null) {
+            wfCategoryRepository.findById(wf.getCategoryId())
+                    .ifPresent(cat -> vo.setCategoryName(cat.getName()));
+        }
+        return vo;
+    }
+
     @Data
     public static class CreateWorkflowReq {
         private String name;
@@ -69,4 +136,9 @@ public class WorkflowController {
         private String description;
         private String graphData;
     }
+
+    @Data
+    public static class SetCategoryReq {
+        private Long categoryId;
+    }
 }

+ 5 - 1
backend/src/main/java/com/agent/management/engine/NodeExecutionResult.java

@@ -8,7 +8,7 @@ import java.util.Map;
  */
 public class NodeExecutionResult {
 
-    public enum Status { RUNNING, SUCCESS, FAILED, SKIPPED }
+    public enum Status { RUNNING, SUCCESS, FAILED, SKIPPED, NO_RUN }
 
     private final String nodeId;
     private final Status status;
@@ -41,6 +41,10 @@ public class NodeExecutionResult {
         return new NodeExecutionResult(nodeId, Status.SKIPPED, Collections.emptyMap(), null, null);
     }
 
+    public static NodeExecutionResult noRun(String nodeId) {
+        return new NodeExecutionResult(nodeId, Status.NO_RUN, Collections.emptyMap(), null, null);
+    }
+
     public String getNodeId() { return nodeId; }
     public Status getStatus() { return status; }
     public Map<String, Object> getOutput() { return output; }

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

@@ -149,9 +149,16 @@ public class WorkflowEngine {
                 safeSend(emitter, WorkflowRunEvent.nodeResult(runId, result));
 
                 if (result.getStatus() == NodeExecutionResult.Status.FAILED) {
-                    safeSend(emitter, WorkflowRunEvent.workflowError(runId, "节点 " + nodeId + " 执行失败: " + result.getError()));
-                    emitter.complete();
-                    return;
+                    // 检查失败策略:abort(终止)或 skip(跳过继续)
+                    String failStrategy = nodeData.path("failStrategy").asText("abort");
+                    if ("skip".equals(failStrategy)) {
+                        log.info("[WorkflowEngine] 节点 {} 执行失败,策略为跳过,继续工作流", nodeId);
+                        // 跳过该节点,仍然激活下游节点
+                    } else {
+                        safeSend(emitter, WorkflowRunEvent.workflowError(runId, "节点 " + nodeId + " 执行失败: " + result.getError()));
+                        emitter.complete();
+                        return;
+                    }
                 }
 
                 // 激活下游节点

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

@@ -3,6 +3,7 @@ package com.agent.management.engine.executor;
 import com.agent.management.engine.*;
 import com.agent.management.model.dto.IOField;
 import com.agent.management.model.dto.SkillDTO;
+import com.agent.management.service.AiModelService;
 import com.agent.management.service.SkillService;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
@@ -15,16 +16,19 @@ import java.util.stream.Collectors;
 /**
  * Agent 节点执行器
  * 读取关联 Skill 的 SKILL.md 内容作为系统提示,结合 Skill 的 inputs 构造用户消息
+ * 支持 modelId 字段指定模型
  */
 @Slf4j
 @Component
 public class AgentExecutor implements NodeExecutor {
 
-    private final ChatClient chatClient;
+    private final AiModelService aiModelService;
+    private final ChatClient defaultChatClient;
     private final SkillService skillService;
 
-    public AgentExecutor(ChatClient.Builder chatClientBuilder, SkillService skillService) {
-        this.chatClient = chatClientBuilder.build();
+    public AgentExecutor(ChatClient.Builder chatClientBuilder, AiModelService aiModelService, SkillService skillService) {
+        this.aiModelService = aiModelService;
+        this.defaultChatClient = aiModelService.getChatClientOrFallback(chatClientBuilder);
         this.skillService = skillService;
     }
 
@@ -52,11 +56,12 @@ public class AgentExecutor implements NodeExecutor {
 
         // 构造用户消息:基于 Skill 的 inputs 定义从上下文取值
         String userMessage = buildUserMessage(skill, context);
+        ChatClient client = resolveChatClient(data);
 
         log.info("[Agent] 节点 {} 执行 Skill: {}, 用户消息长度={}", nodeId, agentId, userMessage.length());
 
         try {
-            var request = chatClient.prompt()
+            var request = client.prompt()
                     .system(skillContent)
                     .user(userMessage);
             String result = request.call().content();
@@ -74,6 +79,18 @@ public class AgentExecutor implements NodeExecutor {
         }
     }
 
+    private ChatClient resolveChatClient(JsonNode data) {
+        long modelId = data.path("modelId").asLong(0);
+        if (modelId > 0) {
+            try {
+                return aiModelService.getChatClient(modelId);
+            } catch (Exception e) {
+                log.warn("[Agent] 指定模型 {} 获取失败,回退到默认模型: {}", modelId, e.getMessage());
+            }
+        }
+        return defaultChatClient;
+    }
+
     /**
      * 根据 Skill 的 inputs 定义,从上下文构造结构化用户消息
      */
@@ -81,7 +98,6 @@ public class AgentExecutor implements NodeExecutor {
         StringBuilder sb = new StringBuilder();
 
         if (skill.getInputs() != null && !skill.getInputs().isEmpty()) {
-            // 按 Skill 定义的 inputs 逐个取值
             for (IOField input : skill.getInputs()) {
                 Object value = context.getVariable(input.getName());
                 if (value != null) {
@@ -92,7 +108,6 @@ public class AgentExecutor implements NodeExecutor {
             }
         }
 
-        // 如果没有定义 inputs 或全部为空,使用所有上下文变量
         if (sb.isEmpty() && !context.getVariables().isEmpty()) {
             sb.append(context.getVariables().entrySet().stream()
                     .filter(e -> e.getValue() != null)

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

@@ -1,6 +1,7 @@
 package com.agent.management.engine.executor;
 
 import com.agent.management.engine.*;
+import com.agent.management.service.AiModelService;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.ai.chat.client.ChatClient;
@@ -19,8 +20,8 @@ public class ConditionExecutor implements NodeExecutor {
 
     private final ChatClient chatClient;
 
-    public ConditionExecutor(ChatClient.Builder chatClientBuilder) {
-        this.chatClient = chatClientBuilder.build();
+    public ConditionExecutor(ChatClient.Builder chatClientBuilder, AiModelService aiModelService) {
+        this.chatClient = aiModelService.getChatClientOrFallback(chatClientBuilder);
     }
 
     @Override

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

@@ -1,6 +1,7 @@
 package com.agent.management.engine.executor;
 
 import com.agent.management.engine.*;
+import com.agent.management.service.AiModelService;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.ai.chat.client.ChatClient;
@@ -11,15 +12,18 @@ import java.util.Map;
 /**
  * LLM 节点执行器
  * 渲染 systemPrompt/userPrompt 中的 {{变量名}},调用 ChatClient,结果写入上下文
+ * 支持 modelId 字段指定模型,无 modelId 时使用默认模型
  */
 @Slf4j
 @Component
 public class LlmExecutor implements NodeExecutor {
 
-    private final ChatClient chatClient;
+    private final AiModelService aiModelService;
+    private final ChatClient defaultChatClient;
 
-    public LlmExecutor(ChatClient.Builder chatClientBuilder) {
-        this.chatClient = chatClientBuilder.build();
+    public LlmExecutor(ChatClient.Builder chatClientBuilder, AiModelService aiModelService) {
+        this.aiModelService = aiModelService;
+        this.defaultChatClient = aiModelService.getChatClientOrFallback(chatClientBuilder);
     }
 
     @Override
@@ -36,10 +40,12 @@ public class LlmExecutor implements NodeExecutor {
             return NodeExecutionResult.failed(nodeId, "LLM 节点的用户提示词为空");
         }
 
+        ChatClient client = resolveChatClient(data);
+
         log.info("[LLM] 节点 {} 开始调用, userPrompt长度={}", nodeId, userPrompt.length());
 
         try {
-            var request = chatClient.prompt().user(userPrompt);
+            var request = client.prompt().user(userPrompt);
             if (!systemPrompt.isEmpty()) {
                 request = request.system(systemPrompt);
             }
@@ -57,4 +63,16 @@ public class LlmExecutor implements NodeExecutor {
             return NodeExecutionResult.failed(nodeId, "LLM 调用失败");
         }
     }
+
+    private ChatClient resolveChatClient(JsonNode data) {
+        long modelId = data.path("modelId").asLong(0);
+        if (modelId > 0) {
+            try {
+                return aiModelService.getChatClient(modelId);
+            } catch (Exception e) {
+                log.warn("[LLM] 指定模型 {} 获取失败,回退到默认模型: {}", modelId, e.getMessage());
+            }
+        }
+        return defaultChatClient;
+    }
 }

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

@@ -0,0 +1,76 @@
+package com.agent.management.engine.executor;
+
+import com.agent.management.engine.*;
+import com.agent.management.service.AiModelService;
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 智能操作节点执行器
+ * 根据 actionPrompt(操作要求)调用 LLM,支持 modelId 指定模型
+ */
+@Slf4j
+@Component
+public class SmartActionExecutor implements NodeExecutor {
+
+    private final AiModelService aiModelService;
+    private final ChatClient defaultChatClient;
+
+    public SmartActionExecutor(ChatClient.Builder chatClientBuilder, AiModelService aiModelService) {
+        this.aiModelService = aiModelService;
+        this.defaultChatClient = aiModelService.getChatClientOrFallback(chatClientBuilder);
+    }
+
+    @Override
+    public String getType() {
+        return "smartAction";
+    }
+
+    @Override
+    public NodeExecutionResult execute(String nodeId, JsonNode data, ExecutionContext context) {
+        String actionPrompt = TemplateRenderer.render(
+                data.path("actionPrompt").asText(""), context.getVariables());
+
+        if (actionPrompt.isEmpty()) {
+            return NodeExecutionResult.failed(nodeId, "智能操作节点的操作要求为空");
+        }
+
+        ChatClient client = resolveChatClient(data);
+
+        log.info("[SmartAction] 节点 {} 开始执行, actionPrompt长度={}", nodeId, actionPrompt.length());
+
+        try {
+            String result = client.prompt()
+                    .user(actionPrompt)
+                    .call()
+                    .content();
+
+            if (result == null || result.isBlank()) {
+                return NodeExecutionResult.failed(nodeId, "智能操作返回空结果");
+            }
+
+            log.info("[SmartAction] 节点 {} 完成, 结果长度={}", nodeId, result.length());
+            return NodeExecutionResult.success(nodeId, Map.of(nodeId, result));
+
+        } catch (Exception e) {
+            log.error("[SmartAction] 节点 {} 调用失败: {}", nodeId, e.getMessage());
+            return NodeExecutionResult.failed(nodeId, "智能操作调用失败");
+        }
+    }
+
+    private ChatClient resolveChatClient(JsonNode data) {
+        long modelId = data.path("modelId").asLong(0);
+        if (modelId > 0) {
+            try {
+                return aiModelService.getChatClient(modelId);
+            } catch (Exception e) {
+                log.warn("[SmartAction] 指定模型 {} 获取失败,回退到默认模型: {}", modelId, e.getMessage());
+            }
+        }
+        return defaultChatClient;
+    }
+}

+ 50 - 0
backend/src/main/java/com/agent/management/model/dto/TagExportDTO.java

@@ -0,0 +1,50 @@
+package com.agent.management.model.dto;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 标签导出数据结构
+ * JSON 格式规范见 docs/tag-import-export-spec.md
+ */
+@Data
+public class TagExportDTO {
+
+    /** 格式版本号 */
+    private String version = "1.0";
+
+    /** 导出时间 */
+    private String exportedAt;
+
+    /** 标签组列表 */
+    private List<TagGroupExport> groups;
+
+    @Data
+    public static class TagGroupExport {
+        /** 标签组名称(作为导入时的匹配键) */
+        private String name;
+
+        /** 标签组描述 */
+        private String description;
+
+        /** 排序序号 */
+        private Integer sortOrder;
+
+        /** 标签树(嵌套结构) */
+        private List<TagNodeExport> tags;
+    }
+
+    @Data
+    public static class TagNodeExport {
+        /** 标签名称 */
+        private String name;
+
+        /** 排序序号 */
+        private Integer sortOrder;
+
+        /** 子标签(递归嵌套) */
+        private List<TagNodeExport> children;
+    }
+}

+ 23 - 0
backend/src/main/java/com/agent/management/model/dto/TagImportDTO.java

@@ -0,0 +1,23 @@
+package com.agent.management.model.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 标签导入请求
+ */
+@Data
+public class TagImportDTO {
+
+    /** 导入数据(完整的 TagExportDTO 结构) */
+    private TagExportDTO data;
+
+    /**
+     * 冲突策略映射:groupName -> strategy
+     * strategy 取值:create | overwrite | merge | skip
+     * 仅当标签组名称与现有冲突时生效,无冲突时忽略此字段
+     */
+    private Map<String, String> strategies;
+}

+ 28 - 0
backend/src/main/java/com/agent/management/model/dto/TagImportResultDTO.java

@@ -0,0 +1,28 @@
+package com.agent.management.model.dto;
+
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 标签导入结果
+ */
+@Data
+public class TagImportResultDTO {
+
+    /** 新建的标签组数 */
+    private int created = 0;
+
+    /** 覆盖的标签组数 */
+    private int overwritten = 0;
+
+    /** 合并的标签组数 */
+    private int merged = 0;
+
+    /** 跳过的标签组数 */
+    private int skipped = 0;
+
+    /** 详细信息列表 */
+    private List<String> details = new ArrayList<>();
+}

+ 63 - 0
backend/src/main/java/com/agent/management/model/entity/AiModel.java

@@ -0,0 +1,63 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 大模型配置
+ */
+@Data
+@Entity
+@Table(name = "ai_model")
+public class AiModel {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /** 显示名称(如 "智谱 GLM-4") */
+    @Column(nullable = false)
+    private String name;
+
+    /** 接口基础地址(如 https://open.bigmodel.cn/api/paas/v4) */
+    @Column(nullable = false)
+    private String baseUrl;
+
+    /** Chat 补全路径(如 /chat/completions),与 baseUrl 拼接为完整请求地址 */
+    @Column
+    private String completionsPath = "/chat/completions";
+
+    /** API 密钥 */
+    @Column(nullable = false)
+    private String apiKey;
+
+    /** 模型标识(如 glm-4-flash) */
+    @Column(nullable = false)
+    private String modelName;
+
+    /** 温度参数 */
+    private Double temperature = 0.3;
+
+    /** 是否为默认模型 */
+    @Column(nullable = false)
+    private Boolean isDefault = false;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+}

+ 45 - 0
backend/src/main/java/com/agent/management/model/entity/Category.java

@@ -0,0 +1,45 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 技能分类 — 自引用树结构,支持无限层级
+ */
+@Data
+@Entity
+@Table(name = "category")
+public class Category {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    /** 父分类 ID,根节点为 null */
+    private Long parentId;
+
+    @Column(nullable = false)
+    private String name;
+
+    @Column(name = "sort_order")
+    private Integer sortOrder;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+}

+ 35 - 0
backend/src/main/java/com/agent/management/model/entity/SkillCategory.java

@@ -0,0 +1,35 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 技能-分类关联(一个技能只能属于一个分类)
+ */
+@Data
+@Entity
+@Table(name = "skill_category", uniqueConstraints = {
+        @UniqueConstraint(columnNames = {"skill_folder_name"})
+})
+public class SkillCategory {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "skill_folder_name", nullable = false, length = 128)
+    private String skillFolderName;
+
+    @Column(name = "category_id", nullable = false)
+    private Long categoryId;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+    }
+}

+ 40 - 0
backend/src/main/java/com/agent/management/model/entity/TagAssignment.java

@@ -0,0 +1,40 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 标签关联实体 — 支持对任意业务对象(技能、智能体等)打标
+ * entityType: 业务对象类型,如 "skill"
+ * entityId: 业务对象标识,如 Skill 的 folderName
+ */
+@Data
+@Entity
+@Table(name = "tag_assignment", uniqueConstraints = {
+        @UniqueConstraint(columnNames = {"entity_type", "entity_id", "tag_id"})
+})
+public class TagAssignment {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "entity_type", nullable = false, length = 32)
+    private String entityType;
+
+    @Column(name = "entity_id", nullable = false, length = 128)
+    private String entityId;
+
+    @Column(name = "tag_id", nullable = false)
+    private Long tagId;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+    }
+}

+ 45 - 0
backend/src/main/java/com/agent/management/model/entity/WfCategory.java

@@ -0,0 +1,45 @@
+package com.agent.management.model.entity;
+
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 智能体(工作流)分类 — 自引用树结构,支持无限层级
+ * 与技能分类(Category)独立
+ */
+@Data
+@Entity
+@Table(name = "wf_category")
+public class WfCategory {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    private Long parentId;
+
+    @Column(nullable = false)
+    private String name;
+
+    @Column(name = "sort_order")
+    private Integer sortOrder;
+
+    @Column(name = "created_at")
+    private LocalDateTime createdAt;
+
+    @Column(name = "updated_at")
+    private LocalDateTime updatedAt;
+
+    @PrePersist
+    protected void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    @PreUpdate
+    protected void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+}

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

@@ -22,6 +22,10 @@ public class Workflow {
     @Column(columnDefinition = "TEXT")
     private String graphData;
 
+    /** 分类 ID */
+    @Column(name = "category_id")
+    private Long categoryId;
+
     @Column(name = "created_at")
     private LocalDateTime createdAt;
 

+ 19 - 0
backend/src/main/java/com/agent/management/model/vo/CategoryVO.java

@@ -0,0 +1,19 @@
+package com.agent.management.model.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 分类树节点 VO
+ */
+@Data
+public class CategoryVO {
+
+    private Long id;
+    private Long parentId;
+    private String name;
+    private Integer sortOrder;
+    private List<CategoryVO> children;
+    private long skillCount;
+}

+ 28 - 0
backend/src/main/java/com/agent/management/model/vo/PageResult.java

@@ -0,0 +1,28 @@
+package com.agent.management.model.vo;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 分页结果包装
+ */
+@Data
+public class PageResult<T> {
+
+    private List<T> content;
+    private long total;
+    private int page;
+    private int size;
+    private int totalPages;
+
+    public static <T> PageResult<T> of(List<T> content, long total, int page, int size) {
+        PageResult<T> result = new PageResult<>();
+        result.setContent(content);
+        result.setTotal(total);
+        result.setPage(page);
+        result.setSize(size);
+        result.setTotalPages(size > 0 ? (int) Math.ceil((double) total / size) : 0);
+        return result;
+    }
+}

+ 20 - 0
backend/src/main/java/com/agent/management/model/vo/SkillVO.java

@@ -45,4 +45,24 @@ public class SkillVO {
      * 输出字段定义
      */
     private List<IOField> outputs;
+
+    /**
+     * 关联的标签列表
+     */
+    private List<TagBriefVO> tags;
+
+    /**
+     * 当前版本号(0 表示无版本)
+     */
+    private int currentVersion;
+
+    /**
+     * 所属分类 ID(null 表示未分类)
+     */
+    private Long categoryId;
+
+    /**
+     * 所属分类名称
+     */
+    private String categoryName;
 }

+ 27 - 0
backend/src/main/java/com/agent/management/model/vo/SkillVersionVO.java

@@ -0,0 +1,27 @@
+package com.agent.management.model.vo;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Skill 版本信息 VO
+ */
+@Data
+public class SkillVersionVO {
+
+    /**
+     * 版本号(单调递增)
+     */
+    private int number;
+
+    /**
+     * 版本创建时间
+     */
+    private LocalDateTime createdAt;
+
+    /**
+     * 版本备注
+     */
+    private String message;
+}

+ 18 - 0
backend/src/main/java/com/agent/management/model/vo/TagBriefVO.java

@@ -0,0 +1,18 @@
+package com.agent.management.model.vo;
+
+import lombok.Data;
+
+/**
+ * 标签简要信息(用于关联展示)
+ */
+@Data
+public class TagBriefVO {
+
+    private Long tagId;
+
+    private String tagName;
+
+    private Long groupId;
+
+    private String groupName;
+}

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

@@ -0,0 +1,40 @@
+package com.agent.management.model.vo;
+
+import com.agent.management.model.entity.Workflow;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 工作流列表 VO(含标签)
+ */
+@Data
+public class WorkflowVO {
+
+    private Long id;
+    private String name;
+    private String description;
+    private String graphData;
+    private Long categoryId;
+    private String categoryName;
+    private LocalDateTime createdAt;
+    private LocalDateTime updatedAt;
+
+    /**
+     * 关联的标签列表
+     */
+    private List<TagBriefVO> tags;
+
+    public static WorkflowVO from(Workflow wf) {
+        WorkflowVO vo = new WorkflowVO();
+        vo.setId(wf.getId());
+        vo.setName(wf.getName());
+        vo.setDescription(wf.getDescription());
+        vo.setGraphData(wf.getGraphData());
+        vo.setCategoryId(wf.getCategoryId());
+        vo.setCreatedAt(wf.getCreatedAt());
+        vo.setUpdatedAt(wf.getUpdatedAt());
+        return vo;
+    }
+}

+ 14 - 0
backend/src/main/java/com/agent/management/repository/AiModelRepository.java

@@ -0,0 +1,14 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.AiModel;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface AiModelRepository extends JpaRepository<AiModel, Long> {
+
+    Optional<AiModel> findByIsDefaultTrue();
+
+    List<AiModel> findAllByOrderByIsDefaultDescCreatedAtAsc();
+}

+ 13 - 0
backend/src/main/java/com/agent/management/repository/CategoryRepository.java

@@ -0,0 +1,13 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.Category;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface CategoryRepository extends JpaRepository<Category, Long> {
+
+    List<Category> findByParentIdOrderBySortOrderAsc(Long parentId);
+
+    List<Category> findAllByOrderBySortOrderAsc();
+}

+ 18 - 0
backend/src/main/java/com/agent/management/repository/SkillCategoryRepository.java

@@ -0,0 +1,18 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.SkillCategory;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface SkillCategoryRepository extends JpaRepository<SkillCategory, Long> {
+
+    Optional<SkillCategory> findBySkillFolderName(String skillFolderName);
+
+    List<SkillCategory> findByCategoryIdIn(List<Long> categoryIds);
+
+    void deleteBySkillFolderName(String skillFolderName);
+
+    List<SkillCategory> findByCategoryId(Long categoryId);
+}

+ 15 - 0
backend/src/main/java/com/agent/management/repository/TagAssignmentRepository.java

@@ -0,0 +1,15 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.TagAssignment;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface TagAssignmentRepository extends JpaRepository<TagAssignment, Long> {
+
+    List<TagAssignment> findByEntityTypeAndEntityId(String entityType, String entityId);
+
+    void deleteAllByEntityTypeAndEntityId(String entityType, String entityId);
+
+    boolean existsByEntityTypeAndEntityIdAndTagId(String entityType, String entityId, Long tagId);
+}

+ 15 - 0
backend/src/main/java/com/agent/management/repository/WfCategoryRepository.java

@@ -0,0 +1,15 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.WfCategory;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface WfCategoryRepository extends JpaRepository<WfCategory, Long> {
+
+    List<WfCategory> findByParentIdOrderBySortOrderAsc(Long parentId);
+
+    List<WfCategory> findAllByOrderBySortOrderAsc();
+}

+ 52 - 0
backend/src/main/java/com/agent/management/service/AiModelService.java

@@ -0,0 +1,52 @@
+package com.agent.management.service;
+
+import com.agent.management.model.entity.AiModel;
+import org.springframework.ai.chat.client.ChatClient;
+
+import java.util.List;
+import java.util.Map;
+
+public interface AiModelService {
+
+    List<AiModel> listModels();
+
+    AiModel getModel(Long id);
+
+    AiModel createModel(AiModel model);
+
+    AiModel updateModel(Long id, AiModel model);
+
+    void deleteModel(Long id);
+
+    void setDefault(Long id);
+
+    /** 获取默认模型的 ChatClient,无默认模型时返回 null */
+    ChatClient getDefaultChatClient();
+
+    /** 获取默认模型的 ChatClient,无默认模型时回退到 auto-configured builder */
+    ChatClient getChatClientOrFallback(ChatClient.Builder fallbackBuilder);
+
+    /**
+     * 测试模型连接:发送简短 Chat 请求,返回 success/latency/error
+     * @param model 模型配置(可以是未保存的)
+     * @return { success: boolean, latencyMs: long, error: String }
+     */
+    Map<String, Object> testConnection(AiModel model);
+
+    /**
+     * 测试已保存模型的连接
+     */
+    Map<String, Object> testConnection(Long modelId);
+
+    /**
+     * 获取接口下可用的模型列表(调用 /v1/models)
+     * @param model 包含 baseUrl + apiKey 的配置
+     * @return 模型标识列表
+     */
+    List<String> fetchAvailableModels(AiModel model);
+
+    /**
+     * 根据模型 ID 获取对应的 ChatClient(带缓存)
+     */
+    ChatClient getChatClient(Long modelId);
+}

+ 23 - 0
backend/src/main/java/com/agent/management/service/CategoryService.java

@@ -0,0 +1,23 @@
+package com.agent.management.service;
+
+import com.agent.management.model.vo.CategoryVO;
+
+import java.util.List;
+
+public interface CategoryService {
+
+    List<CategoryVO> getCategoryTree();
+
+    CategoryVO createCategory(Long parentId, String name);
+
+    CategoryVO updateCategory(Long id, String name);
+
+    void deleteCategory(Long id);
+
+    void moveCategory(Long id, Long newParentId);
+
+    /**
+     * 获取指定分类(含所有子分类)的 ID 集合
+     */
+    List<Long> getDescendantCategoryIds(Long categoryId);
+}

+ 42 - 0
backend/src/main/java/com/agent/management/service/SkillVersionService.java

@@ -0,0 +1,42 @@
+package com.agent.management.service;
+
+import com.agent.management.model.vo.SkillVersionVO;
+
+import java.util.List;
+
+/**
+ * Skill 版本管理服务
+ */
+public interface SkillVersionService {
+
+    /**
+     * 创建快照(保存当前工作区为 v{n+1})
+     * @return 新快照的版本号
+     */
+    int createSnapshot(String folderName, String message);
+
+    /**
+     * 获取当前版本号(若从未创建版本则返回 0)
+     */
+    int getCurrentVersion(String folderName);
+
+    /**
+     * 列出所有版本
+     */
+    List<SkillVersionVO> listVersions(String folderName);
+
+    /**
+     * 获取指定版本的 SKILL.md 内容
+     */
+    String getVersionContent(String folderName, int version);
+
+    /**
+     * 回滚到指定版本(将快照内容恢复到工作区,不创建新快照)
+     */
+    void rollback(String folderName, int version);
+
+    /**
+     * 更新版本备注
+     */
+    void updateVersionMessage(String folderName, int version, String message);
+}

+ 30 - 0
backend/src/main/java/com/agent/management/service/TagService.java

@@ -1,7 +1,11 @@
 package com.agent.management.service;
 
+import com.agent.management.model.dto.TagExportDTO;
+import com.agent.management.model.dto.TagImportDTO;
+import com.agent.management.model.dto.TagImportResultDTO;
 import com.agent.management.model.entity.Tag;
 import com.agent.management.model.entity.TagGroup;
+import com.agent.management.model.vo.TagBriefVO;
 
 import java.util.List;
 
@@ -33,4 +37,30 @@ public interface TagService {
 
     /** 移动标签到新的父节点 */
     Tag moveTag(Long id, Long newParentId, Integer sortOrder);
+
+    // ========== 导入导出 ==========
+
+    /** 导出所有标签组 */
+    TagExportDTO exportAll();
+
+    /** 导入标签数据 */
+    TagImportResultDTO importTags(TagImportDTO request);
+
+    // ========== 打标 ==========
+
+    /**
+     * 批量设置实体的标签(全量替换)
+     * @param entityType 实体类型,如 "skill"
+     * @param entityId 实体标识,如 folderName
+     * @param tagIds 标签 ID 列表
+     */
+    void setTags(String entityType, String entityId, List<Long> tagIds);
+
+    /**
+     * 获取实体关联的标签列表
+     * @param entityType 实体类型
+     * @param entityId 实体标识
+     * @return 标签简要信息列表
+     */
+    List<TagBriefVO> getEntityTags(String entityType, String entityId);
 }

+ 20 - 0
backend/src/main/java/com/agent/management/service/WfCategoryService.java

@@ -0,0 +1,20 @@
+package com.agent.management.service;
+
+import com.agent.management.model.vo.CategoryVO;
+
+import java.util.List;
+
+public interface WfCategoryService {
+
+    List<CategoryVO> getCategoryTree();
+
+    CategoryVO createCategory(Long parentId, String name);
+
+    CategoryVO updateCategory(Long id, String name);
+
+    void deleteCategory(Long id);
+
+    void moveCategory(Long id, Long newParentId);
+
+    List<Long> getDescendantCategoryIds(Long categoryId);
+}

+ 295 - 0
backend/src/main/java/com/agent/management/service/impl/AiModelServiceImpl.java

@@ -0,0 +1,295 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.model.entity.AiModel;
+import com.agent.management.repository.AiModelRepository;
+import com.agent.management.service.AiModelService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.openai.OpenAiChatModel;
+import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.openai.api.OpenAiApi;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Slf4j
+@Service
+public class AiModelServiceImpl implements AiModelService {
+
+    private final AiModelRepository modelRepo;
+
+    /** ChatClient 缓存:modelId -> ChatClient */
+    private final ConcurrentHashMap<Long, ChatClient> clientCache = new ConcurrentHashMap<>();
+
+    public AiModelServiceImpl(AiModelRepository modelRepo) {
+        this.modelRepo = modelRepo;
+    }
+
+    @Override
+    public List<AiModel> listModels() {
+        return modelRepo.findAllByOrderByIsDefaultDescCreatedAtAsc();
+    }
+
+    @Override
+    public AiModel getModel(Long id) {
+        return modelRepo.findById(id)
+                .orElseThrow(() -> new BusinessException("模型不存在"));
+    }
+
+    @Override
+    @Transactional
+    public AiModel createModel(AiModel model) {
+        validateModel(model);
+        if (Boolean.TRUE.equals(model.getIsDefault())) {
+            clearOtherDefaults(null);
+        }
+        return modelRepo.save(model);
+    }
+
+    @Override
+    @Transactional
+    public AiModel updateModel(Long id, AiModel model) {
+        AiModel existing = getModel(id);
+
+        existing.setName(model.getName());
+        existing.setBaseUrl(model.getBaseUrl());
+        existing.setCompletionsPath(model.getCompletionsPath());
+        existing.setModelName(model.getModelName());
+        existing.setTemperature(model.getTemperature());
+
+        // apiKey 为空时保留原值(前端编辑时脱敏不回传)
+        if (model.getApiKey() != null && !model.getApiKey().isBlank()) {
+            existing.setApiKey(model.getApiKey());
+        }
+
+        if (Boolean.TRUE.equals(model.getIsDefault()) && !Boolean.TRUE.equals(existing.getIsDefault())) {
+            clearOtherDefaults(id);
+            existing.setIsDefault(true);
+        } else {
+            existing.setIsDefault(model.getIsDefault());
+        }
+
+        validateModel(existing);
+        AiModel saved = modelRepo.save(existing);
+        invalidateCache(id);
+        return saved;
+    }
+
+    @Override
+    @Transactional
+    public void deleteModel(Long id) {
+        AiModel model = getModel(id);
+        modelRepo.deleteById(id);
+        invalidateCache(id);
+        log.info("[AiModel] 删除模型: {}", model.getName());
+    }
+
+    @Override
+    @Transactional
+    public void setDefault(Long id) {
+        AiModel model = getModel(id);
+        clearOtherDefaults(id);
+        model.setIsDefault(true);
+        modelRepo.save(model);
+        invalidateCache(id);
+        log.info("[AiModel] 设置默认模型: {}", model.getName());
+    }
+
+    @Override
+    public ChatClient getDefaultChatClient() {
+        AiModel defaultModel = modelRepo.findByIsDefaultTrue().orElse(null);
+        if (defaultModel == null) return null;
+        return getOrCreateClient(defaultModel);
+    }
+
+    @Override
+    public ChatClient getChatClientOrFallback(ChatClient.Builder fallbackBuilder) {
+        ChatClient client = getDefaultChatClient();
+        if (client != null) return client;
+        return fallbackBuilder.build();
+    }
+
+    @Override
+    public Map<String, Object> testConnection(AiModel model) {
+        long start = System.currentTimeMillis();
+        try {
+            ChatClient client = buildChatClient(model);
+            String response = client.prompt()
+                    .user("Hi")
+                    .call()
+                    .content();
+            long latency = System.currentTimeMillis() - start;
+            log.info("[AiModel] 测试连接成功: model={}, latency={}ms", model.getModelName(), latency);
+            return Map.of(
+                    "success", true,
+                    "latencyMs", latency,
+                    "response", response != null ? truncate(response, 100) : ""
+            );
+        } catch (Exception e) {
+            long latency = System.currentTimeMillis() - start;
+            log.warn("[AiModel] 测试连接失败: model={}, error={}", model.getModelName(), e.getMessage());
+            return Map.of(
+                    "success", false,
+                    "latencyMs", latency,
+                    "error", extractErrorMessage(e.getMessage())
+            );
+        }
+    }
+
+    @Override
+    public Map<String, Object> testConnection(Long modelId) {
+        AiModel model = getModel(modelId);
+        return testConnection(model);
+    }
+
+    @Override
+    public List<String> fetchAvailableModels(AiModel model) {
+        try {
+            String baseUrl = model.getBaseUrl().replaceAll("/+$", "");
+            String url = baseUrl + "/models";
+
+            HttpRequest request = HttpRequest.newBuilder()
+                    .uri(URI.create(url))
+                    .header("Authorization", "Bearer " + model.getApiKey())
+                    .timeout(Duration.ofSeconds(10))
+                    .GET()
+                    .build();
+
+            HttpClient client = HttpClient.newBuilder()
+                    .connectTimeout(Duration.ofSeconds(5))
+                    .build();
+
+            HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+            if (response.statusCode() != 200) {
+                throw new BusinessException("获取模型列表失败: HTTP " + response.statusCode());
+            }
+
+            // 解析 JSON: {"data": [{"id": "model-name"}, ...]}
+            return parseModelList(response.body());
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new BusinessException("获取模型列表失败: " + extractErrorMessage(e.getMessage()));
+        }
+    }
+
+    @Override
+    public ChatClient getChatClient(Long modelId) {
+        AiModel model = getModel(modelId);
+        return getOrCreateClient(model);
+    }
+
+    // ==================== 内部方法 ====================
+
+    private ChatClient getOrCreateClient(AiModel model) {
+        return clientCache.computeIfAbsent(model.getId(), id -> buildChatClient(model));
+    }
+
+    ChatClient buildChatClient(AiModel model) {
+        String completionsPath = (model.getCompletionsPath() != null && !model.getCompletionsPath().isBlank())
+                ? model.getCompletionsPath()
+                : "/chat/completions";
+
+        OpenAiApi openAiApi = OpenAiApi.builder()
+                .baseUrl(model.getBaseUrl())
+                .apiKey(model.getApiKey())
+                .completionsPath(completionsPath)
+                .build();
+
+        OpenAiChatOptions options = OpenAiChatOptions.builder()
+                .model(model.getModelName())
+                .temperature(model.getTemperature())
+                .build();
+
+        OpenAiChatModel chatModel = OpenAiChatModel.builder()
+                .openAiApi(openAiApi)
+                .defaultOptions(options)
+                .build();
+
+        return ChatClient.builder(chatModel).build();
+    }
+
+    private void invalidateCache(Long modelId) {
+        clientCache.remove(modelId);
+    }
+
+    private void clearOtherDefaults(Long excludeId) {
+        List<AiModel> defaults = modelRepo.findAll().stream()
+                .filter(m -> Boolean.TRUE.equals(m.getIsDefault()))
+                .filter(m -> excludeId == null || !m.getId().equals(excludeId))
+                .toList();
+        for (AiModel m : defaults) {
+            m.setIsDefault(false);
+            clientCache.remove(m.getId());
+        }
+        modelRepo.saveAll(defaults);
+    }
+
+    private void validateModel(AiModel model) {
+        if (model.getName() == null || model.getName().isBlank()) {
+            throw new BusinessException("模型名称不能为空");
+        }
+        if (model.getBaseUrl() == null || model.getBaseUrl().isBlank()) {
+            throw new BusinessException("接口地址不能为空");
+        }
+        if (model.getApiKey() == null || model.getApiKey().isBlank()) {
+            throw new BusinessException("API Key 不能为空");
+        }
+        if (model.getModelName() == null || model.getModelName().isBlank()) {
+            throw new BusinessException("模型标识不能为空");
+        }
+    }
+
+    /**
+     * 简易解析 /v1/models 返回的 JSON,提取模型 id 列表
+     */
+    private List<String> parseModelList(String json) {
+        List<String> models = new ArrayList<>();
+        // 手动提取 "id" 字段,避免引入额外依赖
+        // 格式: {"data":[{"id":"model-1"},{"id":"model-2"}]}
+        int dataIdx = json.indexOf("\"data\"");
+        if (dataIdx < 0) return models;
+
+        int arrayStart = json.indexOf('[', dataIdx);
+        int arrayEnd = json.indexOf(']', arrayStart);
+        if (arrayStart < 0 || arrayEnd < 0) return models;
+
+        String arrayContent = json.substring(arrayStart + 1, arrayEnd);
+        String[] objects = arrayContent.split("\\},\\{");
+        for (String obj : objects) {
+            int idIdx = obj.indexOf("\"id\"");
+            if (idIdx < 0) continue;
+            int colonIdx = obj.indexOf(':', idIdx + 3);
+            if (colonIdx < 0) continue;
+            int valueStart = obj.indexOf('"', colonIdx + 1);
+            if (valueStart < 0) continue;
+            int valueEnd = obj.indexOf('"', valueStart + 1);
+            if (valueEnd < 0) continue;
+            models.add(obj.substring(valueStart + 1, valueEnd));
+        }
+        return models;
+    }
+
+    private String extractErrorMessage(String msg) {
+        if (msg == null) return "未知错误";
+        // 截断过长的错误信息
+        if (msg.length() > 200) return msg.substring(0, 200) + "...";
+        return msg;
+    }
+
+    private String truncate(String text, int maxLen) {
+        if (text == null) return "";
+        return text.length() <= maxLen ? text : text.substring(0, maxLen) + "...";
+    }
+}

+ 202 - 0
backend/src/main/java/com/agent/management/service/impl/CategoryServiceImpl.java

@@ -0,0 +1,202 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.model.entity.Category;
+import com.agent.management.model.entity.SkillCategory;
+import com.agent.management.model.vo.CategoryVO;
+import com.agent.management.repository.CategoryRepository;
+import com.agent.management.repository.SkillCategoryRepository;
+import com.agent.management.service.CategoryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CategoryServiceImpl implements CategoryService {
+
+    private final CategoryRepository categoryRepository;
+    private final SkillCategoryRepository skillCategoryRepository;
+
+    @Override
+    public List<CategoryVO> getCategoryTree() {
+        List<Category> all = categoryRepository.findAllByOrderBySortOrderAsc();
+
+        // 统计每个分类下的技能数
+        List<SkillCategory> allMappings = skillCategoryRepository.findAll();
+        Map<Long, Long> countMap = allMappings.stream()
+                .collect(Collectors.groupingBy(SkillCategory::getCategoryId, Collectors.counting()));
+
+        // 转为 VO
+        Map<Long, CategoryVO> voMap = new LinkedHashMap<>();
+        for (Category cat : all) {
+            CategoryVO vo = new CategoryVO();
+            vo.setId(cat.getId());
+            vo.setParentId(cat.getParentId());
+            vo.setName(cat.getName());
+            vo.setSortOrder(cat.getSortOrder());
+            vo.setChildren(new ArrayList<>());
+            vo.setSkillCount(countMap.getOrDefault(cat.getId(), 0L));
+            voMap.put(cat.getId(), vo);
+        }
+
+        // 构建树
+        List<CategoryVO> roots = new ArrayList<>();
+        for (CategoryVO vo : voMap.values()) {
+            if (vo.getParentId() == null) {
+                roots.add(vo);
+            } else {
+                CategoryVO parent = voMap.get(vo.getParentId());
+                if (parent != null) {
+                    parent.getChildren().add(vo);
+                } else {
+                    // 父节点不存在,作为根节点处理
+                    vo.setParentId(null);
+                    roots.add(vo);
+                }
+            }
+        }
+
+        // 递归汇总子分类的技能数
+        for (CategoryVO root : roots) {
+            accumulateSkillCount(root);
+        }
+
+        return roots;
+    }
+
+    @Override
+    @Transactional
+    public CategoryVO createCategory(Long parentId, String name) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new BusinessException("分类名称不能为空");
+        }
+        if (parentId != null && !categoryRepository.existsById(parentId)) {
+            throw new BusinessException(404, "父分类不存在");
+        }
+
+        // 获取同级最大 sortOrder
+        List<Category> siblings = parentId == null
+                ? categoryRepository.findByParentIdOrderBySortOrderAsc(null)
+                : categoryRepository.findByParentIdOrderBySortOrderAsc(parentId);
+        int maxSort = siblings.stream()
+                .map(Category::getSortOrder)
+                .filter(Objects::nonNull)
+                .max(Integer::compareTo)
+                .orElse(0);
+
+        Category category = new Category();
+        category.setParentId(parentId);
+        category.setName(name.trim());
+        category.setSortOrder(maxSort + 1);
+        category = categoryRepository.save(category);
+
+        CategoryVO vo = new CategoryVO();
+        vo.setId(category.getId());
+        vo.setParentId(category.getParentId());
+        vo.setName(category.getName());
+        vo.setSortOrder(category.getSortOrder());
+        vo.setChildren(new ArrayList<>());
+        vo.setSkillCount(0);
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public CategoryVO updateCategory(Long id, String name) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new BusinessException("分类名称不能为空");
+        }
+        Category category = categoryRepository.findById(id)
+                .orElseThrow(() -> new BusinessException(404, "分类不存在"));
+        category.setName(name.trim());
+        category = categoryRepository.save(category);
+
+        CategoryVO vo = new CategoryVO();
+        vo.setId(category.getId());
+        vo.setParentId(category.getParentId());
+        vo.setName(category.getName());
+        vo.setSortOrder(category.getSortOrder());
+        vo.setChildren(new ArrayList<>());
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public void deleteCategory(Long id) {
+        // 检查是否有子分类
+        List<Category> children = categoryRepository.findByParentIdOrderBySortOrderAsc(id);
+        if (!children.isEmpty()) {
+            throw new BusinessException("该分类下有子分类,不能删除");
+        }
+
+        // 删除分类下技能的关联(技能变为未分类)
+        List<SkillCategory> mappings = skillCategoryRepository.findByCategoryId(id);
+        skillCategoryRepository.deleteAll(mappings);
+
+        categoryRepository.deleteById(id);
+        log.info("删除分类: {}", id);
+    }
+
+    @Override
+    @Transactional
+    public void moveCategory(Long id, Long newParentId) {
+        if (newParentId != null && !categoryRepository.existsById(newParentId)) {
+            throw new BusinessException(404, "目标父分类不存在");
+        }
+        // 防止循环引用:不能移动到自己的子分类下
+        if (newParentId != null) {
+            Set<Long> descendants = new HashSet<>(getDescendantCategoryIds(id));
+            if (descendants.contains(newParentId)) {
+                throw new BusinessException("不能移动到自身的子分类下");
+            }
+        }
+
+        Category category = categoryRepository.findById(id)
+                .orElseThrow(() -> new BusinessException(404, "分类不存在"));
+        category.setParentId(newParentId);
+        categoryRepository.save(category);
+    }
+
+    @Override
+    public List<Long> getDescendantCategoryIds(Long categoryId) {
+        List<Category> all = categoryRepository.findAllByOrderBySortOrderAsc();
+        Map<Long, List<Long>> parentChildMap = new HashMap<>();
+        for (Category cat : all) {
+            parentChildMap.computeIfAbsent(cat.getParentId(), k -> new ArrayList<>()).add(cat.getId());
+        }
+
+        List<Long> result = new ArrayList<>();
+        collectDescendants(categoryId, parentChildMap, result);
+        return result;
+    }
+
+    private void collectDescendants(Long parentId, Map<Long, List<Long>> map, List<Long> result) {
+        List<Long> children = map.get(parentId);
+        if (children != null) {
+            result.addAll(children);
+            for (Long childId : children) {
+                collectDescendants(childId, map, result);
+            }
+        }
+    }
+
+    /**
+     * 递归汇总子分类技能数到父节点
+     */
+    private long accumulateSkillCount(CategoryVO node) {
+        long total = node.getSkillCount();
+        if (node.getChildren() != null) {
+            for (CategoryVO child : node.getChildren()) {
+                total += accumulateSkillCount(child);
+            }
+        }
+        node.setSkillCount(total);
+        return total;
+    }
+}

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

@@ -10,6 +10,7 @@ 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.agent.management.service.SkillVersionService;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -36,8 +37,11 @@ public class SkillServiceImpl implements SkillService {
 
     private final SkillProperties skillProperties;
     private final SkillMarkdownParser skillMarkdownParser;
+    private final SkillVersionService skillVersionService;
 
     private static final String SKILL_MD_FILENAME = "SKILL.md";
+    private static final String VERSIONS_DIR = "_versions";
+    private static final String VERSION_META_FILE = "_version.json";
 
     @Override
     public List<SkillDTO> listSkills() {
@@ -118,6 +122,9 @@ public class SkillServiceImpl implements SkillService {
             }
 
             FileUtils.copyDirectory(skillDir.toFile(), targetDir.toFile());
+
+            // 上传后自动创建 v1 快照
+            skillVersionService.createSnapshot(folderName, "初始版本");
             log.info("新增 Skill 成功: {}", folderName);
 
         } catch (BusinessException e) {
@@ -288,6 +295,9 @@ public class SkillServiceImpl implements SkillService {
 
             String merged = mergeSkillMd(original, name, description, inputs, outputs, body);
             Files.writeString(skillMd, merged, StandardCharsets.UTF_8);
+
+            // 保存后自动创建快照
+            skillVersionService.createSnapshot(folderName, null);
             log.info("保存 SKILL.md 成功: {}", folderName);
         } catch (IOException e) {
             log.error("保存 SKILL.md 失败: {}", skillMd, e);
@@ -664,6 +674,10 @@ public class SkillServiceImpl implements SkillService {
     private List<FileNodeDTO> buildFileTree(Path dir, Path root) throws IOException {
         try (Stream<Path> paths = Files.list(dir)) {
             return paths
+                    .filter(p -> {
+                        String name = p.getFileName().toString();
+                        return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE);
+                    })
                     .sorted(Comparator.comparing((Path p) -> !Files.isDirectory(p))
                             .thenComparing(p -> p.getFileName().toString()))
                     .map(p -> {

+ 261 - 0
backend/src/main/java/com/agent/management/service/impl/SkillVersionServiceImpl.java

@@ -0,0 +1,261 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.config.SkillProperties;
+import com.agent.management.model.vo.SkillVersionVO;
+import com.agent.management.service.SkillVersionService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.io.FileUtils;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Stream;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SkillVersionServiceImpl implements SkillVersionService {
+
+    private final SkillProperties skillProperties;
+
+    private static final String VERSIONS_DIR = "_versions";
+    private static final String VERSION_META_FILE = "_version.json";
+    private static final ObjectMapper jsonMapper = new ObjectMapper()
+            .enable(SerializationFeature.INDENT_OUTPUT);
+
+    // ==================== 公有方法 ====================
+
+    @Override
+    public int createSnapshot(String folderName, String message) {
+        Path skillDir = getSkillDir(folderName);
+        Path versionsDir = skillDir.resolve(VERSIONS_DIR);
+
+        VersionMeta meta = loadMeta(skillDir);
+        int newVersion = meta.currentVersion + 1;
+
+        // 创建 v{n} 目录并复制工作区文件
+        Path snapshotDir = versionsDir.resolve("v" + newVersion);
+        copyWorkingDir(skillDir, snapshotDir);
+
+        // 更新元数据
+        VersionEntry entry = new VersionEntry();
+        entry.number = newVersion;
+        entry.createdAt = LocalDateTime.now().toString();
+        entry.message = message != null ? message : "";
+        meta.versions.add(entry);
+        meta.currentVersion = newVersion;
+        saveMeta(skillDir, meta);
+
+        log.info("Skill [{}] 创建快照 v{}", folderName, newVersion);
+        return newVersion;
+    }
+
+    @Override
+    public int getCurrentVersion(String folderName) {
+        Path skillDir = getSkillDir(folderName);
+        return loadMeta(skillDir).currentVersion;
+    }
+
+    @Override
+    public List<SkillVersionVO> listVersions(String folderName) {
+        Path skillDir = getSkillDir(folderName);
+        VersionMeta meta = loadMeta(skillDir);
+
+        List<SkillVersionVO> result = new ArrayList<>();
+        for (VersionEntry entry : meta.versions) {
+            SkillVersionVO vo = new SkillVersionVO();
+            vo.setNumber(entry.number);
+            vo.setCreatedAt(LocalDateTime.parse(entry.createdAt));
+            vo.setMessage(entry.message);
+            result.add(vo);
+        }
+        return result;
+    }
+
+    @Override
+    public String getVersionContent(String folderName, int version) {
+        Path skillDir = getSkillDir(folderName);
+        validateVersionExists(skillDir, version);
+
+        Path skillMd = skillDir.resolve(VERSIONS_DIR)
+                .resolve("v" + version)
+                .resolve("SKILL.md");
+        if (!Files.exists(skillMd)) {
+            throw new BusinessException(404, "版本 v" + version + " 无 SKILL.md");
+        }
+        try {
+            return Files.readString(skillMd, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new BusinessException("读取版本内容失败");
+        }
+    }
+
+    @Override
+    public void rollback(String folderName, int version) {
+        Path skillDir = getSkillDir(folderName);
+        VersionMeta meta = loadMeta(skillDir);
+        validateVersionExists(skillDir, version);
+
+        // 将快照内容恢复到工作区(排除 _versions 和 _version.json)
+        Path snapshotDir = skillDir.resolve(VERSIONS_DIR).resolve("v" + version);
+        restoreToWorkingDir(snapshotDir, skillDir);
+
+        // 仅更新 currentVersion,不创建新快照
+        meta.currentVersion = version;
+        saveMeta(skillDir, meta);
+
+        log.info("Skill [{}] 回滚到 v{}", folderName, version);
+    }
+
+    @Override
+    public void updateVersionMessage(String folderName, int version, String message) {
+        Path skillDir = getSkillDir(folderName);
+        VersionMeta meta = loadMeta(skillDir);
+
+        VersionEntry entry = meta.versions.stream()
+                .filter(e -> e.number == version)
+                .findFirst()
+                .orElseThrow(() -> new BusinessException(404, "版本不存在: v" + version));
+
+        entry.message = message != null ? message : "";
+        saveMeta(skillDir, meta);
+    }
+
+    // ==================== 私有方法 ====================
+
+    private Path getSkillDir(String folderName) {
+        Path dir = Paths.get(skillProperties.getBasePath()).toAbsolutePath().resolve(folderName);
+        if (!Files.exists(dir) || !Files.isDirectory(dir)) {
+            throw new BusinessException(404, "Skill 不存在: " + folderName);
+        }
+        return dir;
+    }
+
+    /**
+     * 复制工作区到快照目录(排除 _versions/ 和 _version.json)
+     */
+    private void copyWorkingDir(Path skillDir, Path snapshotDir) {
+        try {
+            Files.createDirectories(snapshotDir);
+            try (Stream<Path> paths = Files.list(skillDir)) {
+                paths.filter(p -> {
+                    String name = p.getFileName().toString();
+                    return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE);
+                }).forEach(p -> {
+                    Path target = snapshotDir.resolve(p.getFileName().toString());
+                    try {
+                        if (Files.isDirectory(p)) {
+                            FileUtils.copyDirectory(p.toFile(), target.toFile());
+                        } else {
+                            Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING);
+                        }
+                    } catch (IOException e) {
+                        log.error("复制文件失败: {} -> {}", p, target, e);
+                        throw new BusinessException("创建快照失败");
+                    }
+                });
+            }
+        } catch (IOException e) {
+            throw new BusinessException("创建快照失败");
+        }
+    }
+
+    /**
+     * 将快照内容恢复到工作区(先清空工作区文件,再从快照复制)
+     */
+    private void restoreToWorkingDir(Path snapshotDir, Path skillDir) {
+        try {
+            // 删除工作区中现有的业务文件(排除 _versions/ 和 _version.json)
+            try (Stream<Path> paths = Files.list(skillDir)) {
+                paths.filter(p -> {
+                    String name = p.getFileName().toString();
+                    return !name.equals(VERSIONS_DIR) && !name.equals(VERSION_META_FILE);
+                }).forEach(p -> {
+                    try {
+                        if (Files.isDirectory(p)) {
+                            FileUtils.deleteDirectory(p.toFile());
+                        } else {
+                            Files.delete(p);
+                        }
+                    } catch (IOException e) {
+                        log.error("删除工作区文件失败: {}", p, e);
+                    }
+                });
+            }
+
+            // 从快照复制到工作区
+            try (Stream<Path> paths = Files.list(snapshotDir)) {
+                paths.forEach(p -> {
+                    Path target = skillDir.resolve(p.getFileName().toString());
+                    try {
+                        if (Files.isDirectory(p)) {
+                            FileUtils.copyDirectory(p.toFile(), target.toFile());
+                        } else {
+                            Files.copy(p, target, StandardCopyOption.REPLACE_EXISTING);
+                        }
+                    } catch (IOException e) {
+                        log.error("恢复文件失败: {} -> {}", p, target, e);
+                        throw new BusinessException("回滚失败");
+                    }
+                });
+            }
+        } catch (IOException e) {
+            throw new BusinessException("回滚失败");
+        }
+    }
+
+    private void validateVersionExists(Path skillDir, int version) {
+        Path vDir = skillDir.resolve(VERSIONS_DIR).resolve("v" + version);
+        if (!Files.exists(vDir)) {
+            throw new BusinessException(404, "版本不存在: v" + version);
+        }
+    }
+
+    // ==================== 元数据读写 ====================
+
+    private VersionMeta loadMeta(Path skillDir) {
+        Path metaFile = skillDir.resolve(VERSION_META_FILE);
+        if (!Files.exists(metaFile)) {
+            return new VersionMeta();
+        }
+        try {
+            return jsonMapper.readValue(metaFile.toFile(), VersionMeta.class);
+        } catch (IOException e) {
+            log.warn("解析版本元数据失败,使用默认值: {}", metaFile, e);
+            return new VersionMeta();
+        }
+    }
+
+    private void saveMeta(Path skillDir, VersionMeta meta) {
+        Path metaFile = skillDir.resolve(VERSION_META_FILE);
+        try {
+            jsonMapper.writeValue(metaFile.toFile(), meta);
+        } catch (IOException e) {
+            throw new BusinessException("保存版本元数据失败");
+        }
+    }
+
+    // ==================== 内部数据结构 ====================
+
+    /** _version.json 的数据结构 */
+    @lombok.Data
+    public static class VersionMeta {
+        public int currentVersion = 0;
+        public List<VersionEntry> versions = new ArrayList<>();
+    }
+
+    @lombok.Data
+    public static class VersionEntry {
+        public int number;
+        public String createdAt;
+        public String message;
+    }
+}

+ 282 - 7
backend/src/main/java/com/agent/management/service/impl/TagServiceImpl.java

@@ -1,24 +1,36 @@
 package com.agent.management.service.impl;
 
 import com.agent.management.common.exception.BusinessException;
+import com.agent.management.model.dto.TagExportDTO;
+import com.agent.management.model.dto.TagExportDTO.TagGroupExport;
+import com.agent.management.model.dto.TagExportDTO.TagNodeExport;
+import com.agent.management.model.dto.TagImportDTO;
+import com.agent.management.model.dto.TagImportResultDTO;
 import com.agent.management.model.entity.Tag;
+import com.agent.management.model.entity.TagAssignment;
 import com.agent.management.model.entity.TagGroup;
+import com.agent.management.model.vo.TagBriefVO;
+import com.agent.management.repository.TagAssignmentRepository;
 import com.agent.management.repository.TagGroupRepository;
 import com.agent.management.repository.TagRepository;
 import com.agent.management.service.TagService;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.util.ArrayList;
-import java.util.List;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 @RequiredArgsConstructor
 public class TagServiceImpl implements TagService {
 
     private final TagGroupRepository groupRepo;
     private final TagRepository tagRepo;
+    private final TagAssignmentRepository assignmentRepo;
 
     // ========== 标签组 ==========
 
@@ -90,7 +102,19 @@ public class TagServiceImpl implements TagService {
         groupRepo.findById(groupId)
                 .orElseThrow(() -> new BusinessException("标签组不存在"));
 
-        // 如果指定了 parentId,校验父节点存在且同组
+        // 如果未指定 parentId,自动挂到该组的根节点下
+        if (parentId == null) {
+            List<Tag> rootTags = tagRepo.findByParentIdOrderBySortOrderAsc(null);
+            Tag root = rootTags.stream()
+                    .filter(t -> t.getGroupId().equals(groupId))
+                    .findFirst()
+                    .orElse(null);
+            if (root != null) {
+                parentId = root.getId();
+            }
+        }
+
+        // 校验父节点存在且同组
         if (parentId != null) {
             Tag parent = tagRepo.findById(parentId)
                     .orElseThrow(() -> new BusinessException("父标签不存在"));
@@ -99,10 +123,7 @@ public class TagServiceImpl implements TagService {
             }
         }
 
-        List<Tag> siblings = parentId == null
-                ? tagRepo.findByGroupIdOrderBySortOrderAsc(groupId)
-                        .stream().filter(t -> t.getParentId() == null).toList()
-                : tagRepo.findByParentIdOrderBySortOrderAsc(parentId);
+        List<Tag> siblings = tagRepo.findByParentIdOrderBySortOrderAsc(parentId);
 
         Tag tag = new Tag();
         tag.setGroupId(groupId);
@@ -168,4 +189,258 @@ public class TagServiceImpl implements TagService {
         }
         return false;
     }
+
+    // ========== 导入导出 ==========
+
+    @Override
+    public TagExportDTO exportAll() {
+        List<TagGroup> groups = groupRepo.findAllByOrderBySortOrderAsc();
+
+        TagExportDTO export = new TagExportDTO();
+        export.setExportedAt(LocalDateTime.now().toString());
+
+        List<TagGroupExport> groupExports = new ArrayList<>();
+        for (TagGroup group : groups) {
+            TagGroupExport ge = new TagGroupExport();
+            ge.setName(group.getName());
+            ge.setDescription(group.getDescription());
+            ge.setSortOrder(group.getSortOrder());
+
+            // 加载该组所有标签,构建树
+            List<Tag> allTags = tagRepo.findByGroupIdOrderBySortOrderAsc(group.getId());
+            ge.setTags(buildTagTree(allTags));
+
+            groupExports.add(ge);
+        }
+
+        export.setGroups(groupExports);
+        return export;
+    }
+
+    @Override
+    @Transactional
+    public TagImportResultDTO importTags(TagImportDTO request) {
+        if (request.getData() == null || request.getData().getGroups() == null) {
+            throw new BusinessException("导入数据不能为空");
+        }
+
+        TagImportResultDTO result = new TagImportResultDTO();
+        Map<String, String> strategies = request.getStrategies() != null
+                ? request.getStrategies() : Collections.emptyMap();
+
+        // 查询现有标签组,按名称索引
+        Map<String, TagGroup> existingByName = groupRepo.findAllByOrderBySortOrderAsc()
+                .stream().collect(Collectors.toMap(TagGroup::getName, g -> g, (a, b) -> a));
+
+        for (TagGroupExport groupExport : request.getData().getGroups()) {
+            String name = groupExport.getName();
+            TagGroup existing = existingByName.get(name);
+            String strategy = strategies.getOrDefault(name, "create");
+
+            if (existing != null) {
+                switch (strategy) {
+                    case "overwrite" -> importOverwrite(existing, groupExport, result);
+                    case "merge" -> importMerge(existing, groupExport, result);
+                    case "skip" -> {
+                        result.setSkipped(result.getSkipped() + 1);
+                        result.getDetails().add("跳过: " + name);
+                    }
+                    default -> {
+                        // 默认 skip 避免误操作
+                        result.setSkipped(result.getSkipped() + 1);
+                        result.getDetails().add("跳过(冲突): " + name);
+                    }
+                }
+            } else {
+                importCreate(groupExport, result);
+            }
+        }
+
+        return result;
+    }
+
+    /** 新建标签组 */
+    private void importCreate(TagGroupExport groupExport, TagImportResultDTO result) {
+        TagGroup group = createGroup(groupExport.getName(), groupExport.getDescription());
+        // 导入子标签(根节点由 createGroup 自动创建,导入 children 到根节点下)
+        Long groupId = group.getId();
+        List<Tag> rootTags = tagRepo.findByParentIdOrderBySortOrderAsc(null);
+        Tag root = rootTags.stream()
+                .filter(t -> t.getGroupId().equals(groupId))
+                .findFirst().orElse(null);
+
+        if (root != null && groupExport.getTags() != null) {
+            for (TagNodeExport child : groupExport.getTags()) {
+                importTagNode(groupId, root.getId(), child);
+            }
+        }
+
+        result.setCreated(result.getCreated() + 1);
+        result.getDetails().add("新建: " + groupExport.getName());
+    }
+
+    /** 覆盖:删除原有标签组和所有标签,重新创建 */
+    private void importOverwrite(TagGroup existing, TagGroupExport groupExport, TagImportResultDTO result) {
+        Long groupId = existing.getId();
+        // 删除旧组所有标签
+        tagRepo.deleteAllByGroupId(groupId);
+        groupRepo.deleteById(groupId);
+
+        // 重新创建
+        importCreate(groupExport, result);
+
+        result.setCreated(result.getCreated() - 1); // 修正:这是覆盖,不是新建
+        result.setOverwritten(result.getOverwritten() + 1);
+        result.getDetails().set(result.getDetails().size() - 1,
+                "覆盖: " + groupExport.getName());
+    }
+
+    /** 合并:逐层按名称匹配,已存在的保留,新的追加 */
+    private void importMerge(TagGroup existing, TagGroupExport groupExport, TagImportResultDTO result) {
+        Long groupId = existing.getId();
+
+        if (groupExport.getTags() == null) {
+            result.setMerged(result.getMerged() + 1);
+            result.getDetails().add("合并(无变更): " + groupExport.getName());
+            return;
+        }
+
+        // 找到根节点
+        List<Tag> rootTags = tagRepo.findByParentIdOrderBySortOrderAsc(null);
+        Tag root = rootTags.stream()
+                .filter(t -> t.getGroupId().equals(groupId))
+                .findFirst().orElse(null);
+
+        if (root != null) {
+            for (TagNodeExport childExport : groupExport.getTags()) {
+                mergeTagNode(groupId, root.getId(), childExport);
+            }
+        }
+
+        result.setMerged(result.getMerged() + 1);
+        result.getDetails().add("合并: " + groupExport.getName());
+    }
+
+    /** 递归导入标签节点 */
+    private void importTagNode(Long groupId, Long parentId, TagNodeExport nodeExport) {
+        Tag tag = new Tag();
+        tag.setGroupId(groupId);
+        tag.setParentId(parentId);
+        tag.setName(nodeExport.getName());
+        tag.setSortOrder(nodeExport.getSortOrder() != null ? nodeExport.getSortOrder() : 0);
+        tag = tagRepo.save(tag);
+
+        if (nodeExport.getChildren() != null) {
+            for (TagNodeExport child : nodeExport.getChildren()) {
+                importTagNode(groupId, tag.getId(), child);
+            }
+        }
+    }
+
+    /** 递归合并标签节点:按名称匹配,存在则递归合并子节点,不存在则创建 */
+    private void mergeTagNode(Long groupId, Long parentId, TagNodeExport nodeExport) {
+        // 查找同级是否有同名标签
+        List<Tag> siblings = tagRepo.findByParentIdOrderBySortOrderAsc(parentId);
+        Tag existing = siblings.stream()
+                .filter(t -> t.getName().equals(nodeExport.getName()))
+                .findFirst().orElse(null);
+
+        if (existing != null) {
+            // 同名已存在,递归合并子节点
+            if (nodeExport.getChildren() != null) {
+                for (TagNodeExport child : nodeExport.getChildren()) {
+                    mergeTagNode(groupId, existing.getId(), child);
+                }
+            }
+        } else {
+            // 不存在,创建
+            importTagNode(groupId, parentId, nodeExport);
+        }
+    }
+
+    /** 将扁平标签列表构建为嵌套树结构 */
+    private List<TagNodeExport> buildTagTree(List<Tag> allTags) {
+        // 找出根节点(parentId 为 null 的)
+        List<Tag> roots = allTags.stream()
+                .filter(t -> t.getParentId() == null)
+                .toList();
+
+        List<TagNodeExport> result = new ArrayList<>();
+        for (Tag root : roots) {
+            // 根节点本身是标签组同名节点,导出其子节点作为顶层
+            List<Tag> children = allTags.stream()
+                    .filter(t -> root.getId().equals(t.getParentId()))
+                    .sorted(Comparator.comparingInt(t -> t.getSortOrder() != null ? t.getSortOrder() : 0))
+                    .toList();
+
+            for (Tag child : children) {
+                result.add(toTagNodeExport(child, allTags));
+            }
+        }
+        return result;
+    }
+
+    /** 递归转换 Tag 实体为导出节点 */
+    private TagNodeExport toTagNodeExport(Tag tag, List<Tag> allTags) {
+        TagNodeExport node = new TagNodeExport();
+        node.setName(tag.getName());
+        node.setSortOrder(tag.getSortOrder());
+
+        List<Tag> children = allTags.stream()
+                .filter(t -> tag.getId().equals(t.getParentId()))
+                .sorted(Comparator.comparingInt(t -> t.getSortOrder() != null ? t.getSortOrder() : 0))
+                .toList();
+
+        if (!children.isEmpty()) {
+            node.setChildren(children.stream()
+                    .map(c -> toTagNodeExport(c, allTags))
+                    .toList());
+        }
+        return node;
+    }
+
+    // ========== 打标 ==========
+
+    @Override
+    @Transactional
+    public void setTags(String entityType, String entityId, List<Long> tagIds) {
+        // 先删除旧的关联
+        assignmentRepo.deleteAllByEntityTypeAndEntityId(entityType, entityId);
+
+        // 创建新的关联
+        if (tagIds != null) {
+            for (Long tagId : tagIds) {
+                TagAssignment assignment = new TagAssignment();
+                assignment.setEntityType(entityType);
+                assignment.setEntityId(entityId);
+                assignment.setTagId(tagId);
+                assignmentRepo.save(assignment);
+            }
+        }
+    }
+
+    @Override
+    public List<TagBriefVO> getEntityTags(String entityType, String entityId) {
+        List<TagAssignment> assignments = assignmentRepo.findByEntityTypeAndEntityId(entityType, entityId);
+        if (assignments.isEmpty()) {
+            return List.of();
+        }
+
+        // 批量查询标签和标签组
+        List<TagBriefVO> result = new ArrayList<>();
+        for (TagAssignment a : assignments) {
+            Tag tag = tagRepo.findById(a.getTagId()).orElse(null);
+            if (tag == null) continue;
+
+            TagGroup group = groupRepo.findById(tag.getGroupId()).orElse(null);
+
+            TagBriefVO vo = new TagBriefVO();
+            vo.setTagId(tag.getId());
+            vo.setTagName(tag.getName());
+            vo.setGroupId(tag.getGroupId());
+            vo.setGroupName(group != null ? group.getName() : "");
+            result.add(vo);
+        }
+        return result;
+    }
 }

+ 3 - 1
backend/src/main/java/com/agent/management/service/impl/TranslationServiceImpl.java

@@ -1,6 +1,7 @@
 package com.agent.management.service.impl;
 
 import com.agent.management.model.vo.TranslationVO;
+import com.agent.management.service.AiModelService;
 import com.agent.management.service.SseService;
 import com.agent.management.service.TranslationService;
 import lombok.extern.slf4j.Slf4j;
@@ -35,8 +36,9 @@ public class TranslationServiceImpl implements TranslationService {
     private final Map<String, CompletableFuture<String>> pendingTranslations = new ConcurrentHashMap<>();
 
     public TranslationServiceImpl(ChatClient.Builder chatClientBuilder,
+                                  AiModelService aiModelService,
                                   SseService sseService) {
-        this.chatClient = chatClientBuilder.build();
+        this.chatClient = aiModelService.getChatClientOrFallback(chatClientBuilder);
         this.sseService = sseService;
     }
 

+ 201 - 0
backend/src/main/java/com/agent/management/service/impl/WfCategoryServiceImpl.java

@@ -0,0 +1,201 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.model.entity.WfCategory;
+import com.agent.management.model.entity.Workflow;
+import com.agent.management.model.vo.CategoryVO;
+import com.agent.management.repository.WfCategoryRepository;
+import com.agent.management.repository.WorkflowRepository;
+import com.agent.management.service.WfCategoryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WfCategoryServiceImpl implements WfCategoryService {
+
+    private final WfCategoryRepository categoryRepository;
+    private final WorkflowRepository workflowRepository;
+
+    @Override
+    public List<CategoryVO> getCategoryTree() {
+        List<WfCategory> all = categoryRepository.findAllByOrderBySortOrderAsc();
+
+        // 统计每个分类下的工作流数量
+        List<Workflow> allWorkflows = workflowRepository.findAll();
+        Map<Long, Long> countMap = allWorkflows.stream()
+                .filter(wf -> wf.getCategoryId() != null)
+                .collect(Collectors.groupingBy(Workflow::getCategoryId, Collectors.counting()));
+
+        // 转为 VO
+        Map<Long, CategoryVO> voMap = new LinkedHashMap<>();
+        for (WfCategory cat : all) {
+            CategoryVO vo = new CategoryVO();
+            vo.setId(cat.getId());
+            vo.setParentId(cat.getParentId());
+            vo.setName(cat.getName());
+            vo.setSortOrder(cat.getSortOrder());
+            vo.setChildren(new ArrayList<>());
+            vo.setSkillCount(countMap.getOrDefault(cat.getId(), 0L));
+            voMap.put(vo.getId(), vo);
+        }
+
+        // 构建树
+        List<CategoryVO> roots = new ArrayList<>();
+        for (CategoryVO vo : voMap.values()) {
+            if (vo.getParentId() == null) {
+                roots.add(vo);
+            } else {
+                CategoryVO parent = voMap.get(vo.getParentId());
+                if (parent != null) {
+                    parent.getChildren().add(vo);
+                } else {
+                    vo.setParentId(null);
+                    roots.add(vo);
+                }
+            }
+        }
+
+        // 递归汇总子分类数量
+        for (CategoryVO root : roots) {
+            accumulateCount(root);
+        }
+
+        return roots;
+    }
+
+    @Override
+    @Transactional
+    public CategoryVO createCategory(Long parentId, String name) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new BusinessException("分类名称不能为空");
+        }
+        if (parentId != null && !categoryRepository.existsById(parentId)) {
+            throw new BusinessException(404, "父分类不存在");
+        }
+
+        List<WfCategory> siblings = parentId == null
+                ? categoryRepository.findByParentIdOrderBySortOrderAsc(null)
+                : categoryRepository.findByParentIdOrderBySortOrderAsc(parentId);
+        int maxSort = siblings.stream()
+                .map(WfCategory::getSortOrder)
+                .filter(Objects::nonNull)
+                .max(Integer::compareTo)
+                .orElse(0);
+
+        WfCategory category = new WfCategory();
+        category.setParentId(parentId);
+        category.setName(name.trim());
+        category.setSortOrder(maxSort + 1);
+        category = categoryRepository.save(category);
+
+        CategoryVO vo = new CategoryVO();
+        vo.setId(category.getId());
+        vo.setParentId(category.getParentId());
+        vo.setName(category.getName());
+        vo.setSortOrder(category.getSortOrder());
+        vo.setChildren(new ArrayList<>());
+        vo.setSkillCount(0L);
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public CategoryVO updateCategory(Long id, String name) {
+        if (name == null || name.trim().isEmpty()) {
+            throw new BusinessException("分类名称不能为空");
+        }
+        WfCategory category = categoryRepository.findById(id)
+                .orElseThrow(() -> new BusinessException(404, "分类不存在"));
+        category.setName(name.trim());
+        category = categoryRepository.save(category);
+
+        CategoryVO vo = new CategoryVO();
+        vo.setId(category.getId());
+        vo.setParentId(category.getParentId());
+        vo.setName(category.getName());
+        vo.setSortOrder(category.getSortOrder());
+        vo.setChildren(new ArrayList<>());
+        return vo;
+    }
+
+    @Override
+    @Transactional
+    public void deleteCategory(Long id) {
+        List<WfCategory> children = categoryRepository.findByParentIdOrderBySortOrderAsc(id);
+        if (!children.isEmpty()) {
+            throw new BusinessException("该分类下有子分类,不能删除");
+        }
+
+        // 将分类下的工作流设为未分类
+        List<Workflow> workflows = workflowRepository.findAll().stream()
+                .filter(wf -> id.equals(wf.getCategoryId()))
+                .toList();
+        for (Workflow wf : workflows) {
+            wf.setCategoryId(null);
+        }
+        workflowRepository.saveAll(workflows);
+
+        categoryRepository.deleteById(id);
+        log.info("删除智能体分类: {}", id);
+    }
+
+    @Override
+    @Transactional
+    public void moveCategory(Long id, Long newParentId) {
+        if (newParentId != null && !categoryRepository.existsById(newParentId)) {
+            throw new BusinessException(404, "目标父分类不存在");
+        }
+        if (newParentId != null) {
+            Set<Long> descendants = new HashSet<>(getDescendantCategoryIds(id));
+            if (descendants.contains(newParentId)) {
+                throw new BusinessException("不能移动到自身的子分类下");
+            }
+        }
+
+        WfCategory category = categoryRepository.findById(id)
+                .orElseThrow(() -> new BusinessException(404, "分类不存在"));
+        category.setParentId(newParentId);
+        categoryRepository.save(category);
+    }
+
+    @Override
+    public List<Long> getDescendantCategoryIds(Long categoryId) {
+        List<WfCategory> all = categoryRepository.findAllByOrderBySortOrderAsc();
+        Map<Long, List<Long>> parentChildMap = new HashMap<>();
+        for (WfCategory cat : all) {
+            parentChildMap.computeIfAbsent(cat.getParentId(), k -> new ArrayList<>()).add(cat.getId());
+        }
+
+        List<Long> result = new ArrayList<>();
+        collectDescendants(categoryId, parentChildMap, result);
+        return result;
+    }
+
+    private void collectDescendants(Long parentId, Map<Long, List<Long>> map, List<Long> result) {
+        List<Long> children = map.get(parentId);
+        if (children != null) {
+            result.addAll(children);
+            for (Long childId : children) {
+                collectDescendants(childId, map, result);
+            }
+        }
+    }
+
+    private long accumulateCount(CategoryVO node) {
+        long total = node.getSkillCount();
+        if (node.getChildren() != null) {
+            for (CategoryVO child : node.getChildren()) {
+                total += accumulateCount(child);
+            }
+        }
+        node.setSkillCount(total);
+        return total;
+    }
+}

+ 1 - 1
backend/src/main/resources/application.yml.example

@@ -3,7 +3,7 @@ server:
 
 skill:
   # Skill 文件存放目录,请修改为你的实际路径
-  base-path: D:/skills
+  base-path: ${SKILL_BASE_PATH:./uploads/skills}
 
 spring:
   datasource:

+ 378 - 0
docs/tag-system-spec.md

@@ -0,0 +1,378 @@
+# 标签系统规范
+
+> 版本: 1.1
+> 最后更新: 2026-06-11
+
+## 1. 概述
+
+标签系统提供三个核心能力:
+
+1. **标签管理**:通过标签组 + 树形标签组织分类体系
+2. **导入导出**:将标签组及其标签树导出为 JSON 文件,支持带冲突策略的导入
+3. **打标关联**:对技能(Skill)等业务对象进行标签标注,支持多标签、跨标签组、非叶子节点打标
+
+## 2. JSON 格式规范
+
+### 2.1 顶层结构
+
+```json
+{
+  "version": "1.0",
+  "exportedAt": "2026-06-11T12:00:00",
+  "groups": [ ... ]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `version` | string | 是 | 格式版本号,当前为 `"1.0"` |
+| `exportedAt` | string | 是 | ISO 8601 格式的导出时间 |
+| `groups` | array | 是 | 标签组数组,见 2.2 |
+
+### 2.2 标签组结构 (TagGroupExport)
+
+```json
+{
+  "name": "标签组名称",
+  "description": "可选描述",
+  "sortOrder": 0,
+  "tags": [ ... ]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `name` | string | 是 | 标签组名称,作为导入时的唯一匹配键 |
+| `description` | string | 否 | 标签组描述,可为 null 或省略 |
+| `sortOrder` | integer | 否 | 排序序号,默认 0 |
+| `tags` | array | 否 | 顶层标签节点数组,见 2.3 |
+
+**注意**:`tags` 数组中的是标签组根节点下的直接子标签,不包含标签组同名的根节点本身。
+
+### 2.3 标签节点结构 (TagNodeExport)
+
+标签节点采用**递归嵌套**结构,自然表达树形层级:
+
+```json
+{
+  "name": "标签名称",
+  "sortOrder": 0,
+  "children": [
+    {
+      "name": "子标签",
+      "sortOrder": 0,
+      "children": []
+    }
+  ]
+}
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `name` | string | 是 | 标签名称 |
+| `sortOrder` | integer | 否 | 同级排序序号,默认 0 |
+| `children` | array | 否 | 子标签数组,递归嵌套。无子节点时可省略或为空数组 |
+
+### 2.4 完整示例
+
+```json
+{
+  "version": "1.0",
+  "exportedAt": "2026-06-11T14:30:00",
+  "groups": [
+    {
+      "name": "情感分类",
+      "description": "文本情感极性标签",
+      "sortOrder": 0,
+      "tags": [
+        {
+          "name": "正面",
+          "sortOrder": 0,
+          "children": [
+            {
+              "name": "喜悦",
+              "sortOrder": 0,
+              "children": [
+                { "name": "狂喜", "sortOrder": 0 },
+                { "name": "微笑", "sortOrder": 1 }
+              ]
+            },
+            { "name": "满意", "sortOrder": 1 }
+          ]
+        },
+        {
+          "name": "负面",
+          "sortOrder": 1,
+          "children": [
+            { "name": "愤怒", "sortOrder": 0 },
+            { "name": "失望", "sortOrder": 1 }
+          ]
+        },
+        {
+          "name": "中性",
+          "sortOrder": 2
+        }
+      ]
+    },
+    {
+      "name": "主题分类",
+      "description": null,
+      "sortOrder": 1,
+      "tags": [
+        {
+          "name": "科技",
+          "sortOrder": 0,
+          "children": [
+            {
+              "name": "人工智能",
+              "sortOrder": 0,
+              "children": [
+                { "name": "大语言模型", "sortOrder": 0 },
+                { "name": "计算机视觉", "sortOrder": 1 }
+              ]
+            },
+            { "name": "云计算", "sortOrder": 1 }
+          ]
+        },
+        { "name": "财经", "sortOrder": 1 },
+        { "name": "体育", "sortOrder": 2 }
+      ]
+    }
+  ]
+}
+```
+
+## 3. 导出行为
+
+- **范围**:导出所有标签组及其全部标签树
+- **触发**:一键导出,浏览器下载 JSON 文件
+- **文件名**:`tags-export-{YYYY-MM-DD}.json`
+- **编码**:UTF-8
+- **格式化**:2 空格缩进,便于人工阅读和编辑
+- **数据完整性**:导出数据不包含数据库 ID,仅包含名称和层级关系
+
+## 4. 导入行为
+
+### 4.1 导入流程
+
+1. 用户选择 JSON 文件
+2. 前端解析文件,展示标签组列表及冲突状态
+3. 用户对冲突标签组选择处理策略
+4. 确认导入,后端执行并返回结果
+
+### 4.2 冲突检测
+
+以标签组 **名称** (`name`) 作为匹配键。若导入文件中的标签组名称与系统中已有标签组名称相同,则判定为冲突。
+
+### 4.3 冲突处理策略
+
+| 策略 | 值 | 说明 |
+|------|---|------|
+| 覆盖 | `overwrite` | 删除原有标签组及全部标签,用导入数据重新创建 |
+| 合并 | `merge` | 逐层按名称匹配:同名标签保留,新标签追加。详见 4.4 |
+| 跳过 | `skip` | 跳过该标签组,不做任何修改 |
+| 新建 | `create` | 无冲突时的默认行为,直接创建新标签组 |
+
+### 4.4 合并策略详解
+
+合并采用**递归名称匹配**算法:
+
+```
+对于导入标签树的每一层节点:
+  1. 在目标标签组的同级子节点中查找同名标签
+  2. 如果找到同名标签 → 保留该标签,递归处理其子节点
+  3. 如果未找到 → 创建新标签节点
+```
+
+**示例**(多级标签合并):
+
+现有标签组 "情感分类":
+```
+正面
+  喜悦
+    微笑
+负面
+```
+
+导入合并数据:
+```
+正面
+  喜悦
+    微笑          ← 同名,保留
+    感动          ← 新增
+  满意            ← 新增
+负面
+  愤怒            ← 新增
+```
+
+合并结果:
+```
+正面
+  喜悦
+    微笑          ← 保留(三级同名匹配)
+    感动          ← 新增
+  满意            ← 新增
+负面
+  愤怒            ← 新增
+```
+
+### 4.5 导入请求格式
+
+```json
+{
+  "data": {
+    "version": "1.0",
+    "exportedAt": "...",
+    "groups": [ ... ]
+  },
+  "strategies": {
+    "冲突标签组名称": "overwrite | merge | skip",
+    "另一个冲突标签组": "merge"
+  }
+}
+```
+
+- `data`:完整的导出数据
+- `strategies`:仅冲突标签组需要指定策略,无冲突的标签组自动新建
+
+### 4.6 导入结果
+
+```json
+{
+  "created": 2,
+  "overwritten": 1,
+  "merged": 0,
+  "skipped": 1,
+  "details": [
+    "新建: 主题分类",
+    "新建: 行业分类",
+    "覆盖: 情感分类",
+    "跳过: 优先级"
+  ]
+}
+```
+
+## 5. API 接口
+
+### 5.1 导出
+
+```
+GET /api/tags/export
+```
+
+响应:`Result<TagExportDTO>`
+
+### 5.2 导入
+
+```
+POST /api/tags/import
+```
+
+请求体:`TagImportDTO`(见 4.5)
+响应:`Result<TagImportResultDTO>`(见 4.6)
+
+### 5.3 设置实体标签
+
+```
+POST /api/tags/assignments
+```
+
+请求体:
+```json
+{
+  "entityType": "skill",
+  "entityId": "my-skill-folder",
+  "tagIds": [1, 5, 12]
+}
+```
+
+行为:全量替换该实体的所有标签关联。`tagIds` 为空数组时清除所有标签。
+
+### 5.4 获取实体标签
+
+```
+GET /api/tags/assignments?entityType=skill&entityId=my-skill-folder
+```
+
+响应:`Result<List<TagBriefVO>>`
+
+假设标签树结构如下:
+```
+情感分类
+  正面          ← tagId: 1
+    喜悦        ← tagId: 2
+      狂喜      ← tagId: 3
+  负面          ← tagId: 4
+主题分类
+  科技          ← tagId: 10
+    人工智能    ← tagId: 11
+      大语言模型 ← tagId: 12
+```
+
+为一个 Skill 同时打上"正面"(非叶子节点)、"狂喜"(三级叶子)、"大语言模型"(三级叶子)的响应示例:
+
+```json
+{
+  "data": [
+    { "tagId": 1,  "tagName": "正面",       "groupId": 1, "groupName": "情感分类" },
+    { "tagId": 3,  "tagName": "狂喜",       "groupId": 1, "groupName": "情感分类" },
+    { "tagId": 12, "tagName": "大语言模型",  "groupId": 2, "groupName": "主题分类" }
+  ]
+}
+```
+
+### 5.5 获取所有标签组及其标签(用于标签选择器)
+
+```
+GET /api/tags/all-with-tags
+```
+
+响应:`Result<List<{ id, name, tags: List<Tag> }>>`
+
+## 6. 打标规范
+
+### 6.1 数据模型
+
+打标通过 `tag_assignment` 表实现多对多关联:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT | 主键 |
+| `entity_type` | VARCHAR(32) | 业务对象类型,如 `"skill"` |
+| `entity_id` | VARCHAR(128) | 业务对象标识,如 Skill 的 folderName |
+| `tag_id` | BIGINT | 关联的标签 ID |
+| `created_at` | TIMESTAMP | 创建时间 |
+
+**唯一约束**:`(entity_type, entity_id, tag_id)` — 同一实体的同一标签不可重复关联。
+
+### 6.2 打标原则
+
+1. **多标签**:一个实体可以打 0 个、1 个或多个标签
+2. **同组多标**:可以在同一个标签组内选择多个标签(如同时标记"正面"和"中性")
+3. **跨组打标**:可以同时选择来自不同标签组的标签(如"正面"+"科技")
+4. **非叶子可标**:树形标签中的任意层级节点都可以被选中,不限于叶子节点
+5. **全量替换**:每次保存时提交全部标签 ID 列表,后端先清除旧关联再创建新关联
+
+### 6.3 支持的实体类型
+
+| entityType | entityId 来源 | 说明 |
+|------------|--------------|------|
+| `skill` | folderName | 技能 |
+| (预留) | — | 未来可扩展智能体、工作流等 |
+
+### 6.4 标签选择器交互
+
+1. 弹窗展示所有标签组,每组可展开/折叠
+2. 标签树中每个节点都可勾选(含非叶子节点)
+3. 支持关键词搜索标签
+4. 已选标签在顶部以 Tag 形式展示,可移除
+5. 确认保存后刷新列表数据
+
+## 7. 设计原则
+
+1. **名称即标识**:导出数据不包含数据库 ID,以名称作为匹配键,确保跨环境可移植
+2. **树形嵌套**:标签树使用递归 children 嵌套,而非 parentId 扁平结构,直观且便于编辑
+3. **安全优先**:冲突时默认跳过,避免误操作覆盖数据
+4. **幂等安全**:合并策略是增量操作,重复导入不会产生重复数据(同名标签不会重复创建)
+5. **通用打标**:打标机制通过 `entityType + entityId` 实现通用多对多关联,不与特定业务耦合
+6. **全量替换**:打标保存采用全量替换策略,前端提交完整标签列表,语义简洁无歧义

+ 21 - 0
frontend/src/api/category.js

@@ -0,0 +1,21 @@
+import request from '../utils/request'
+
+export function getCategoryTree() {
+  return request.get('/categories')
+}
+
+export function createCategory(data) {
+  return request.post('/categories', data)
+}
+
+export function updateCategory(id, data) {
+  return request.put(`/categories/${id}`, data)
+}
+
+export function deleteCategory(id) {
+  return request.delete(`/categories/${id}`)
+}
+
+export function moveCategory(id, parentId) {
+  return request.put(`/categories/${id}/move`, { parentId })
+}

+ 36 - 0
frontend/src/api/model.js

@@ -0,0 +1,36 @@
+import request from '../utils/request'
+
+export function getModelList() {
+  return request.get('/models')
+}
+
+export function createModel(data) {
+  return request.post('/models', data)
+}
+
+export function updateModel(id, data) {
+  return request.post(`/models/${id}/edit`, data)
+}
+
+export function deleteModel(id) {
+  return request.post(`/models/${id}/delete`)
+}
+
+export function setDefaultModel(id) {
+  return request.post(`/models/${id}/default`)
+}
+
+/** 测试已保存模型的连接 */
+export function testModelConnection(id) {
+  return request.post(`/models/${id}/test`)
+}
+
+/** 测试自定义配置的连接(新建/编辑时预验证) */
+export function testConnection(data) {
+  return request.post('/models/test', data)
+}
+
+/** 获取接口下可用的模型列表 */
+export function fetchAvailableModels(data) {
+  return request.post('/models/fetch-models', data)
+}

+ 26 - 2
frontend/src/api/skill.js

@@ -1,7 +1,7 @@
 import request from '../utils/request'
 
-export function getSkillList() {
-  return request.get('/skills')
+export function getSkillList(params = {}) {
+  return request.get('/skills', { params })
 }
 
 export function uploadSkill(file, onProgress) {
@@ -50,3 +50,27 @@ export function createSkillFile(folderName, path, type) {
 export function deleteSkillFile(folderName, path) {
   return request.delete(`/skills/${encodeURIComponent(folderName)}/files`, { params: { path } })
 }
+
+// ==================== 版本管理 ====================
+
+export function listVersions(folderName) {
+  return request.get(`/skills/${encodeURIComponent(folderName)}/versions`)
+}
+
+export function getVersionContent(folderName, version) {
+  return request.get(`/skills/${encodeURIComponent(folderName)}/versions/${version}/content`)
+}
+
+export function rollbackVersion(folderName, version) {
+  return request.post(`/skills/${encodeURIComponent(folderName)}/versions/${version}/rollback`)
+}
+
+export function updateVersionMessage(folderName, version, message) {
+  return request.put(`/skills/${encodeURIComponent(folderName)}/versions/${version}/message`, { message })
+}
+
+// ==================== 技能-分类关联 ====================
+
+export function setSkillCategory(folderName, categoryId) {
+  return request.put(`/skills/${encodeURIComponent(folderName)}/category`, { categoryId })
+}

+ 27 - 0
frontend/src/api/tag.js

@@ -39,3 +39,30 @@ export function deleteTag(id) {
 export function moveTag(id, data) {
   return request.post(`/tags/${id}/move`, data)
 }
+
+// ========== 导入导出 ==========
+
+export function exportTags() {
+  return request.get('/tags/export')
+}
+
+export function importTags(data) {
+  return request.post('/tags/import', data)
+}
+
+// ========== 打标 ==========
+
+export function setEntityTags(entityType, entityId, tagIds) {
+  return request.post('/tags/assignments', { entityType, entityId, tagIds })
+}
+
+export function getEntityTags(entityType, entityId) {
+  return request.get('/tags/assignments', { params: { entityType, entityId } })
+}
+
+/**
+ * 获取所有标签组及其全部标签(扁平列表),用于标签选择器
+ */
+export function getAllTagsForSelector() {
+  return request.get('/tags/all-with-tags')
+}

+ 21 - 0
frontend/src/api/wfCategory.js

@@ -0,0 +1,21 @@
+import request from '../utils/request'
+
+export function getWfCategoryTree() {
+  return request.get('/wf-categories')
+}
+
+export function createWfCategory(data) {
+  return request.post('/wf-categories', data)
+}
+
+export function updateWfCategory(id, data) {
+  return request.put(`/wf-categories/${id}`, data)
+}
+
+export function deleteWfCategory(id) {
+  return request.delete(`/wf-categories/${id}`)
+}
+
+export function moveWfCategory(id, parentId) {
+  return request.put(`/wf-categories/${id}/move`, { parentId })
+}

+ 6 - 2
frontend/src/api/workflow.js

@@ -1,7 +1,11 @@
 import request from '../utils/request'
 
-export function getWorkflows() {
-  return request.get('/workflows')
+export function getWorkflows(params = {}) {
+  return request.get('/workflows', { params })
+}
+
+export function setWorkflowCategory(id, categoryId) {
+  return request.put(`/workflows/${id}/category`, { categoryId })
 }
 
 export function getWorkflow(id) {

+ 3 - 1
frontend/src/components/layout/AppSidebar.vue

@@ -6,6 +6,7 @@ import {
   GridOutline,
   GitMergeOutline,
   PricetagsOutline,
+  SettingsOutline,
   ChevronDownOutline
 } from '@vicons/ionicons5'
 import { NIcon } from 'naive-ui'
@@ -22,7 +23,8 @@ const menuItems = [
       { key: '/orchestration', label: '智能体编排', icon: GitMergeOutline }
     ]
   },
-  { key: '/tags', label: '标签管理', icon: PricetagsOutline }
+  { key: '/tags', label: '标签管理', icon: PricetagsOutline },
+  { key: '/models', label: '模型管理', icon: SettingsOutline }
 ]
 
 // 分组是否展开(默认展开)

+ 230 - 0
frontend/src/components/skill/CategoryTree.vue

@@ -0,0 +1,230 @@
+<script setup>
+import { NIcon } from 'naive-ui'
+import { AddOutline, TrashOutline, ChevronForwardOutline } from '@vicons/ionicons5'
+
+const props = defineProps({
+  nodes: { type: Array, default: () => [] },
+  level: { type: Number, default: 0 },
+  selectedId: { type: [Number, null], default: null },
+  expandedIds: { type: Set, default: () => new Set() },
+  editingId: { type: [Number, null], default: null },
+  editingName: { type: String, default: '' },
+  addingParentId: { type: [Number, null, undefined], default: undefined },
+  addingName: { type: String, default: '' }
+})
+
+const emit = defineEmits([
+  'select', 'toggle', 'add', 'delete', 'edit-start', 'edit-confirm', 'edit-cancel',
+  'add-confirm', 'add-cancel'
+])
+
+const indent = 16 + props.level * 16
+</script>
+
+<template>
+  <template v-for="node in nodes" :key="node.id">
+    <div
+      class="category-item"
+      :class="{ active: selectedId === node.id }"
+    >
+      <div class="cat-left" @click="emit('select', node.id)" :style="{ paddingLeft: indent + 'px' }">
+        <button v-if="node.children?.length" class="expand-btn" @click.stop="emit('toggle', node.id)">
+          <n-icon size="12" :class="{ rotated: expandedIds.has(node.id) }">
+            <ChevronForwardOutline />
+          </n-icon>
+        </button>
+        <span v-else class="expand-placeholder"></span>
+
+        <template v-if="editingId === node.id">
+          <input
+            class="inline-input"
+            :value="editingName"
+            @input="$emit('update:editingName', $event.target.value)"
+            @keyup.enter="emit('edit-confirm')"
+            @keyup.escape="emit('edit-cancel')"
+            @blur="emit('edit-confirm')"
+            autofocus
+          />
+        </template>
+        <template v-else>
+          <span class="cat-name" @dblclick="emit('edit-start', { id: node.id, name: node.name })">{{ node.name }}</span>
+        </template>
+      </div>
+
+      <div class="cat-actions" v-if="editingId !== node.id">
+        <span class="cat-count">{{ node.skillCount }}</span>
+        <button class="icon-btn-sm" @click.stop="emit('add', node.id)" title="添加子分类">
+          <n-icon size="12"><AddOutline /></n-icon>
+        </button>
+        <button class="icon-btn-sm delete-icon" @click.stop="emit('delete', { id: node.id, name: node.name })" title="删除">
+          <n-icon size="12"><TrashOutline /></n-icon>
+        </button>
+      </div>
+    </div>
+
+    <!-- 展开的子分类 -->
+    <template v-if="node.children?.length && expandedIds.has(node.id)">
+      <CategoryTree
+        :nodes="node.children"
+        :level="level + 1"
+        :selected-id="selectedId"
+        :expanded-ids="expandedIds"
+        :editing-id="editingId"
+        :editing-name="editingName"
+        :adding-parent-id="addingParentId"
+        :adding-name="addingName"
+        @select="emit('select', $event)"
+        @toggle="emit('toggle', $event)"
+        @add="emit('add', $event)"
+        @delete="emit('delete', $event)"
+        @edit-start="emit('edit-start', $event)"
+        @edit-confirm="emit('edit-confirm')"
+        @edit-cancel="emit('edit-cancel')"
+        @add-confirm="emit('add-confirm')"
+        @add-cancel="emit('add-cancel')"
+        @update:editing-name="emit('update:editingName', $event)"
+      />
+    </template>
+
+    <!-- 当前节点的子分类下的添加输入框 -->
+    <div v-if="addingParentId === node.id && expandedIds.has(node.id)" class="category-item adding" :style="{ paddingLeft: (indent + 16) + 'px' }">
+      <span class="expand-placeholder"></span>
+      <input
+        class="inline-input"
+        :value="addingName"
+        @input="$emit('update:addingName', $event.target.value)"
+        placeholder="输入分类名称"
+        @keyup.enter="emit('add-confirm')"
+        @keyup.escape="emit('add-cancel')"
+        @blur="emit('add-cancel')"
+        autofocus
+      />
+    </div>
+  </template>
+</template>
+
+<style scoped>
+.category-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 16px 6px 0;
+  cursor: pointer;
+  transition: all 0.15s;
+  min-height: 32px;
+}
+
+.category-item:hover {
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.category-item.active {
+  background: rgba(37, 99, 235, 0.12);
+}
+
+.category-item.active .cat-name {
+  color: var(--color-accent);
+  font-weight: 500;
+}
+
+.category-item.adding {
+  cursor: default;
+}
+
+.cat-left {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  flex: 1;
+  min-width: 0;
+}
+
+.expand-btn {
+  background: none;
+  border: none;
+  padding: 2px;
+  cursor: pointer;
+  color: var(--text-tertiary);
+  display: flex;
+  align-items: center;
+  transition: transform 0.2s;
+}
+
+.expand-btn .rotated {
+  transform: rotate(90deg);
+}
+
+.expand-placeholder {
+  width: 16px;
+  min-width: 16px;
+}
+
+.cat-name {
+  font-size: 13px;
+  color: var(--text-secondary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.cat-count {
+  font-size: 11px;
+  color: var(--text-muted);
+  background: rgba(100, 116, 139, 0.1);
+  padding: 1px 6px;
+  border-radius: 8px;
+  min-width: 20px;
+  text-align: center;
+}
+
+.cat-actions {
+  display: flex;
+  align-items: center;
+  gap: 2px;
+  opacity: 0;
+  transition: opacity 0.15s;
+  padding-right: 16px;
+}
+
+.category-item:hover .cat-actions {
+  opacity: 1;
+}
+
+.icon-btn-sm {
+  background: none;
+  border: none;
+  color: var(--text-muted);
+  cursor: pointer;
+  padding: 2px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  transition: all 0.15s;
+}
+
+.icon-btn-sm:hover {
+  color: var(--color-accent);
+  background: rgba(37, 99, 235, 0.1);
+}
+
+.icon-btn-sm.delete-icon:hover {
+  color: var(--color-danger);
+}
+
+.inline-input {
+  background: rgba(37, 99, 235, 0.08);
+  border: 1px solid rgba(37, 99, 235, 0.2);
+  border-radius: 4px;
+  padding: 2px 8px;
+  font-size: 13px;
+  color: var(--text-primary);
+  outline: none;
+  width: 100%;
+  font-family: inherit;
+}
+
+.inline-input:focus {
+  border-color: var(--color-accent);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+</style>

+ 42 - 3
frontend/src/components/skill/SkillCard.vue

@@ -1,6 +1,6 @@
 <script setup>
-import { NButton, NIcon, NTooltip } from 'naive-ui'
-import { CreateOutline, TrashOutline, FolderOutline } from '@vicons/ionicons5'
+import { NButton, NIcon, NTooltip, NTag } from 'naive-ui'
+import { CreateOutline, TrashOutline, FolderOutline, PricetagsOutline } from '@vicons/ionicons5'
 
 const props = defineProps({
   skill: {
@@ -9,7 +9,7 @@ const props = defineProps({
   }
 })
 
-const emit = defineEmits(['edit', 'delete'])
+const emit = defineEmits(['edit', 'delete', 'tag'])
 </script>
 
 <template>
@@ -25,6 +25,16 @@ const emit = defineEmits(['edit', 'delete'])
           </n-icon>
         </div>
         <div class="card-actions">
+          <n-tooltip trigger="hover">
+            <template #trigger>
+              <button class="action-btn tag-btn" @click="$emit('tag', skill)">
+                <n-icon size="16">
+                  <PricetagsOutline />
+                </n-icon>
+              </button>
+            </template>
+            标签
+          </n-tooltip>
           <n-tooltip trigger="hover">
             <template #trigger>
               <button class="action-btn edit-btn" @click="$emit('edit', skill)">
@@ -57,6 +67,19 @@ const emit = defineEmits(['edit', 'delete'])
       <!-- 描述 -->
       <p class="card-desc">{{ skill.translatedDescription || skill.description }}</p>
 
+      <!-- 标签 -->
+      <div v-if="skill.tags && skill.tags.length" class="card-tags">
+        <n-tag
+          v-for="tag in skill.tags"
+          :key="tag.tagId"
+          size="small"
+          :bordered="false"
+          type="info"
+        >
+          {{ tag.tagName }}
+        </n-tag>
+      </div>
+
       <!-- 底部:文件夹标识 -->
       <div class="card-footer">
         <span class="folder-tag">{{ skill.folderName }}</span>
@@ -172,6 +195,15 @@ const emit = defineEmits(['edit', 'delete'])
   box-shadow: 0 0 10px rgba(239, 68, 68, 0.2);
 }
 
+.tag-btn {
+  background: rgba(34, 197, 94, 0.1);
+  color: #22c55e;
+}
+
+.tag-btn:hover {
+  background: rgba(34, 197, 94, 0.2);
+}
+
 /* 标题 */
 .card-title {
   font-size: 16px;
@@ -197,6 +229,13 @@ const emit = defineEmits(['edit', 'delete'])
   overflow: hidden;
 }
 
+/* 标签 */
+.card-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
 /* 底部 */
 .card-footer {
   margin-top: 4px;

+ 3 - 3
frontend/src/components/skill/SkillForm.vue

@@ -131,10 +131,10 @@ function closeModal() {
           />
         </div>
 
-        <!-- 输入字段 -->
+        <!-- 前置数据 -->
         <div class="form-group">
           <div class="section-header">
-            <label class="form-label">输入字段</label>
+            <label class="form-label">前置数据</label>
             <n-button size="tiny" quaternary @click="addField(form.inputs)">
               <template #icon><n-icon size="14"><AddOutline /></n-icon></template>
               添加
@@ -151,7 +151,7 @@ function closeModal() {
               </button>
             </div>
           </div>
-          <div v-else class="io-empty">暂无输入字段</div>
+          <div v-else class="io-empty">暂无前置数据</div>
         </div>
 
         <!-- 输出字段 -->

+ 484 - 0
frontend/src/components/tag/TagSelector.vue

@@ -0,0 +1,484 @@
+<script setup>
+import { ref, computed, watch, h, defineComponent } from 'vue'
+import { NModal, NInput, NIcon, NTag, NEmpty, NSpin } from 'naive-ui'
+import { SearchOutline, ChevronForwardOutline } from '@vicons/ionicons5'
+import { getAllTagsForSelector, setEntityTags } from '../../api/tag'
+
+const props = defineProps({
+  show: { type: Boolean, default: false },
+  entityType: { type: String, default: 'skill' },
+  entityId: { type: String, default: '' },
+  /** 当前已选中的 tagId 列表 */
+  selectedTagIds: { type: Array, default: () => [] }
+})
+
+const emit = defineEmits(['update:show', 'saved'])
+
+const loading = ref(false)
+const saving = ref(false)
+const keyword = ref('')
+const expandedGroups = ref(new Set())
+
+// 原始数据:[{ id, name, tags: [{ id, groupId, parentId, name, sortOrder, ... }] }]
+const rawData = ref([])
+// 当前选中的 tagId 集合
+const selectedIds = ref(new Set())
+
+/** 按 groupId 构建树结构 */
+const groupTrees = computed(() => {
+  return rawData.value.map(g => ({
+    id: g.id,
+    name: g.name,
+    tree: buildTree(g.tags || [])
+  }))
+})
+
+/** 扁平化所有标签(用于搜索和已选展示) */
+const allFlatTags = computed(() => {
+  const result = []
+  for (const g of rawData.value) {
+    for (const t of g.tags || []) {
+      // 跳过根节点(parentId 为 null 且名称与组名相同)
+      if (t.parentId === null) continue
+      result.push({
+        id: t.id,
+        name: t.name,
+        groupId: g.id,
+        groupName: g.name,
+        parentId: t.parentId
+      })
+    }
+  }
+  return result
+})
+
+/** 构建标签名称路径(用于搜索展示) */
+function getTagPath(tagId, flatTags) {
+  const parts = []
+  let current = flatTags.find(t => t.id === tagId)
+  while (current) {
+    parts.unshift(current.name)
+    current = flatTags.find(t => t.id === current.parentId)
+  }
+  return parts.join(' / ')
+}
+
+/** 将扁平标签列表转为树(跳过根节点,直接展示根节点的子节点) */
+function buildTree(tags) {
+  const map = {}
+  const roots = []
+  for (const t of tags) {
+    map[t.id] = { ...t, children: [] }
+  }
+  for (const t of tags) {
+    if (t.parentId === null) {
+      // 根节点,将其子节点提升为顶层
+      for (const child of tags) {
+        if (child.parentId === t.id) {
+          roots.push(map[child.id])
+        }
+      }
+    } else if (map[t.parentId]) {
+      map[t.parentId].children.push(map[t.id])
+    }
+  }
+  return roots
+}
+
+// 搜索过滤
+const searchResults = computed(() => {
+  if (!keyword.value.trim()) return []
+  const kw = keyword.value.trim().toLowerCase()
+  return allFlatTags.value.filter(t =>
+    t.name.toLowerCase().includes(kw) ||
+    t.groupName.toLowerCase().includes(kw)
+  ).map(t => ({
+    ...t,
+    path: getTagPath(t.id, allFlatTags.value)
+  }))
+})
+
+// 监听弹窗打开
+watch(() => props.show, async (val) => {
+  if (val) {
+    selectedIds.value = new Set(props.selectedTagIds)
+    keyword.value = ''
+    await loadData()
+  }
+})
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await getAllTagsForSelector()
+    rawData.value = res.data || []
+    if (rawData.value.length > 0) {
+      expandedGroups.value = new Set([rawData.value[0].id])
+    }
+  } catch {
+    // 静默
+  } finally {
+    loading.value = false
+  }
+}
+
+function toggleGroup(id) {
+  const s = new Set(expandedGroups.value)
+  if (s.has(id)) s.delete(id)
+  else s.add(id)
+  expandedGroups.value = s
+}
+
+function toggleTag(tagId) {
+  const s = new Set(selectedIds.value)
+  if (s.has(tagId)) s.delete(tagId)
+  else s.add(tagId)
+  selectedIds.value = s
+}
+
+async function handleSave() {
+  if (!props.entityId) return
+  saving.value = true
+  try {
+    await setEntityTags(props.entityType, props.entityId, [...selectedIds.value])
+    emit('saved', [...selectedIds.value])
+    emit('update:show', false)
+  } catch {
+    // axios interceptor 处理
+  } finally {
+    saving.value = false
+  }
+}
+
+/** 标签名称查找 */
+function getTagName(tagId) {
+  const t = allFlatTags.value.find(t => t.id === tagId)
+  return t ? t.name : `#${tagId}`
+}
+
+// ---- 内联递归树节点组件 ----
+const TreeNode = defineComponent({
+  name: 'TreeNode',
+  props: {
+    node: Object,
+    selectedIds: Set,
+    depth: { type: Number, default: 0 }
+  },
+  emits: ['toggle'],
+  setup(props, { emit }) {
+    return () => {
+      const isSelected = props.selectedIds.has(props.node.id)
+      const children = []
+      children.push(
+        h('div', {
+          class: ['tree-item', { selected: isSelected }],
+          style: { paddingLeft: `${props.depth * 20 + 8}px` },
+          onClick: (e) => { e.stopPropagation(); emit('toggle', props.node.id) }
+        }, [
+          h('span', { class: 'check-box' + (isSelected ? ' checked' : '') }),
+          h('span', { class: 'tree-name' }, props.node.name)
+        ])
+      )
+      if (props.node.children?.length) {
+        for (const child of props.node.children) {
+          children.push(
+            h(TreeNode, {
+              node: child,
+              selectedIds: props.selectedIds,
+              depth: props.depth + 1,
+              onToggle: (id) => emit('toggle', id)
+            })
+          )
+        }
+      }
+      return h('div', children)
+    }
+  }
+})
+</script>
+
+<template>
+  <n-modal
+    :show="show"
+    preset="card"
+    title="选择标签"
+    style="max-width: 560px"
+    :bordered="false"
+    @update:show="$emit('update:show', $event)"
+  >
+    <!-- 搜索框 -->
+    <div class="selector-search">
+      <n-input v-model:value="keyword" placeholder="搜索标签..." clearable size="small">
+        <template #prefix>
+          <n-icon size="14"><SearchOutline /></n-icon>
+        </template>
+      </n-input>
+    </div>
+
+    <!-- 已选标签 -->
+    <div v-if="selectedIds.size" class="selected-tags">
+      <span class="selected-label">已选 {{ selectedIds.size }} 个</span>
+      <div class="selected-list">
+        <n-tag
+          v-for="tagId in selectedIds"
+          :key="tagId"
+          size="small"
+          closable
+          @close="toggleTag(tagId)"
+          type="info"
+          :bordered="false"
+        >
+          {{ getTagName(tagId) }}
+        </n-tag>
+      </div>
+    </div>
+
+    <!-- 搜索模式 -->
+    <div v-if="keyword.trim()" class="search-results">
+      <div v-if="searchResults.length === 0" class="no-result">
+        <n-empty description="无匹配标签" size="small" />
+      </div>
+      <div
+        v-for="tag in searchResults"
+        :key="tag.id"
+        class="search-item"
+        :class="{ selected: selectedIds.has(tag.id) }"
+        @click="toggleTag(tag.id)"
+      >
+        <span class="si-name">{{ tag.name }}</span>
+        <span class="si-path">{{ tag.groupName }}{{ tag.path ? ' · ' + tag.path : '' }}</span>
+      </div>
+    </div>
+
+    <!-- 树形浏览 -->
+    <div v-else class="groups-container">
+      <div v-if="loading" style="text-align: center; padding: 30px;">
+        <n-spin size="small" />
+      </div>
+      <div v-else-if="rawData.length === 0">
+        <n-empty description="暂无标签,请先在标签管理中创建" size="small" />
+      </div>
+      <template v-else>
+        <div v-for="g in groupTrees" :key="g.id" class="tag-group">
+          <div class="group-header" @click="toggleGroup(g.id)">
+            <n-icon size="14" class="expand-icon" :class="{ expanded: expandedGroups.has(g.id) }">
+              <ChevronForwardOutline />
+            </n-icon>
+            <span class="group-name">{{ g.name }}</span>
+            <span class="group-count">{{ allFlatTags.filter(t => t.groupId === g.id).length }}</span>
+          </div>
+          <div v-if="expandedGroups.has(g.id)" class="group-tags">
+            <TreeNode
+              v-for="node in g.tree"
+              :key="node.id"
+              :node="node"
+              :selected-ids="selectedIds"
+              :depth="0"
+              @toggle="toggleTag"
+            />
+          </div>
+        </div>
+      </template>
+    </div>
+
+    <template #action>
+      <button class="btn-cancel" @click="$emit('update:show', false)">取消</button>
+      <button class="btn-save" :disabled="saving" @click="handleSave">
+        {{ saving ? '保存中...' : '确认' }}
+      </button>
+    </template>
+  </n-modal>
+</template>
+
+<style scoped>
+.selector-search {
+  margin-bottom: 12px;
+}
+
+.selected-tags {
+  margin-bottom: 12px;
+}
+
+.selected-label {
+  font-size: 12px;
+  color: var(--text-tertiary);
+  margin-bottom: 4px;
+  display: block;
+}
+
+.selected-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+/* 搜索结果 */
+.search-results {
+  max-height: 320px;
+  overflow-y: auto;
+}
+
+.search-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 7px 10px;
+  border-radius: 5px;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.search-item:hover {
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.search-item.selected {
+  background: rgba(37, 99, 235, 0.1);
+}
+
+.si-name {
+  font-size: 13px;
+  color: var(--text-primary);
+  font-weight: 500;
+}
+
+.si-path {
+  font-size: 11px;
+  color: var(--text-tertiary);
+  max-width: 220px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.no-result {
+  padding: 24px 0;
+}
+
+/* 标签组树 */
+.groups-container {
+  max-height: 380px;
+  overflow-y: auto;
+}
+
+.tag-group {
+  margin-bottom: 2px;
+}
+
+.group-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 10px;
+  border-radius: 5px;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.group-header:hover {
+  background: rgba(100, 116, 139, 0.06);
+}
+
+.expand-icon {
+  transition: transform 0.2s;
+  color: var(--text-tertiary);
+}
+
+.expand-icon.expanded {
+  transform: rotate(90deg);
+}
+
+.group-name {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-primary);
+  flex: 1;
+}
+
+.group-count {
+  font-size: 11px;
+  color: var(--text-tertiary);
+}
+
+.group-tags {
+  padding-left: 12px;
+}
+
+/* 树节点 */
+.tree-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 6px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: background 0.15s;
+  line-height: 1.5;
+}
+
+.tree-item:hover {
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.tree-item.selected {
+  background: rgba(37, 99, 235, 0.08);
+}
+
+.check-box {
+  width: 15px;
+  height: 15px;
+  border-radius: 3px;
+  border: 1.5px solid var(--border-color);
+  flex-shrink: 0;
+  transition: all 0.15s;
+  position: relative;
+}
+
+.check-box.checked {
+  background: var(--color-accent);
+  border-color: var(--color-accent);
+}
+
+.check-box.checked::after {
+  content: '✓';
+  color: white;
+  font-size: 10px;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -52%);
+  line-height: 1;
+}
+
+.tree-name {
+  font-size: 13px;
+  color: var(--text-primary);
+}
+
+/* 按钮 */
+.btn-cancel {
+  padding: 6px 16px;
+  border-radius: 6px;
+  border: 1px solid var(--border-color);
+  background: transparent;
+  color: var(--text-secondary);
+  cursor: pointer;
+  font-size: 13px;
+}
+
+.btn-save {
+  padding: 6px 16px;
+  border-radius: 6px;
+  border: none;
+  background: var(--color-accent);
+  color: white;
+  cursor: pointer;
+  font-size: 13px;
+  margin-left: 8px;
+}
+
+.btn-save:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+</style>

+ 6 - 2
frontend/src/components/workflow/nodes/LLMNode.vue

@@ -4,7 +4,7 @@ import BaseNode from './BaseNode.vue'
 import { SparklesOutline } from '@vicons/ionicons5'
 
 const props = defineProps({
-  data: { type: Object, default: () => ({ label: '大模型处理', model: '' }) }
+  data: { type: Object, default: () => ({ label: '大模型处理', modelId: null }) }
 })
 
 const inputSummary = computed(() => {
@@ -17,13 +17,17 @@ const outputSummary = computed(() => {
   if (!list.length) return ['result']
   return list.map(v => v.name).filter(Boolean)
 })
+
+const modelLabel = computed(() => {
+  return props.data?.modelId ? `模型 #${props.data.modelId}` : '默认模型'
+})
 </script>
 
 <template>
   <BaseNode :data="data" color="#8B5CF6">
     <template #icon><SparklesOutline /></template>
     <template #body>
-      <div class="llm-model">{{ data?.model || '未选择模型' }}</div>
+      <div class="llm-model">{{ modelLabel }}</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>

+ 58 - 0
frontend/src/components/workflow/nodes/SmartActionNode.vue

@@ -0,0 +1,58 @@
+<script setup>
+import { computed } from 'vue'
+import BaseNode from './BaseNode.vue'
+import { ColorWandOutline } from '@vicons/ionicons5'
+
+const props = defineProps({
+  data: { type: Object, default: () => ({ label: '智能操作', actionPrompt: '' }) }
+})
+
+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)
+})
+
+const promptPreview = computed(() => {
+  const p = props.data?.actionPrompt || ''
+  if (!p) return '未设置操作要求'
+  return p.length > 40 ? p.substring(0, 40) + '...' : p
+})
+</script>
+
+<template>
+  <BaseNode :data="data" color="#EC4899">
+    <template #icon><ColorWandOutline /></template>
+    <template #body>
+      <div class="action-preview">{{ promptPreview }}</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: 'SmartActionNode' }
+</script>
+
+<style scoped>
+.action-preview { font-size: 11px; color: var(--text-tertiary, #888); margin-bottom: 4px; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.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>

+ 6 - 0
frontend/src/router/index.js

@@ -34,6 +34,12 @@ const routes = [
     name: 'TagManagement',
     component: () => import('../views/TagManagement.vue'),
     meta: { title: '标签管理' }
+  },
+  {
+    path: '/models',
+    name: 'ModelManagement',
+    component: () => import('../views/ModelManagement.vue'),
+    meta: { title: '模型管理' }
   }
 ]
 

+ 51 - 3
frontend/src/stores/skill.js

@@ -1,5 +1,5 @@
 import { defineStore } from 'pinia'
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import { getSkillList, uploadSkill, editSkill, deleteSkill } from '../api/skill'
 import { connectSSE, disconnectSSE } from '../utils/sse'
 
@@ -9,6 +9,16 @@ export const useSkillStore = defineStore('skill', () => {
   const uploading = ref(false)
   const uploadProgress = ref(0)
 
+  // 分页状态
+  const currentPage = ref(1)
+  const pageSize = ref(12)
+  const total = ref(0)
+  const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
+
+  // 搜索和筛选状态
+  const searchKeyword = ref('')
+  const selectedCategoryId = ref(null)
+
   /**
    * 初始化:建立 SSE 连接 + 加载列表
    */
@@ -32,8 +42,20 @@ export const useSkillStore = defineStore('skill', () => {
   async function fetchSkills() {
     loading.value = true
     try {
-      const res = await getSkillList()
-      skills.value = res.data || []
+      const params = {
+        page: currentPage.value,
+        size: pageSize.value
+      }
+      if (searchKeyword.value.trim()) {
+        params.search = searchKeyword.value.trim()
+      }
+      if (selectedCategoryId.value) {
+        params.categoryId = selectedCategoryId.value
+      }
+      const res = await getSkillList(params)
+      const data = res.data || {}
+      skills.value = data.content || []
+      total.value = data.total || 0
     } finally {
       loading.value = false
     }
@@ -65,6 +87,23 @@ export const useSkillStore = defineStore('skill', () => {
     await fetchSkills()
   }
 
+  function setPage(page) {
+    currentPage.value = page
+    fetchSkills()
+  }
+
+  function setSearch(keyword) {
+    searchKeyword.value = keyword
+    currentPage.value = 1
+    fetchSkills()
+  }
+
+  function setCategory(categoryId) {
+    selectedCategoryId.value = categoryId
+    currentPage.value = 1
+    fetchSkills()
+  }
+
   /**
    * 销毁时断开 SSE
    */
@@ -77,11 +116,20 @@ export const useSkillStore = defineStore('skill', () => {
     loading,
     uploading,
     uploadProgress,
+    currentPage,
+    pageSize,
+    total,
+    totalPages,
+    searchKeyword,
+    selectedCategoryId,
     init,
     fetchSkills,
     createSkill,
     updateSkill,
     removeSkill,
+    setPage,
+    setSearch,
+    setCategory,
     destroy
   }
 })

+ 44 - 7
frontend/src/stores/workflow.js

@@ -9,11 +9,29 @@ export const useWorkflowStore = defineStore('workflow', () => {
   const current = ref(null)
   const loading = ref(false)
 
+  // 分页搜索
+  const currentPage = ref(1)
+  const pageSize = ref(12)
+  const total = ref(0)
+  const totalPages = ref(1)
+  const searchKeyword = ref('')
+  const selectedCategoryId = ref(null)
+
   async function fetchList() {
     loading.value = true
     try {
-      const { data } = await getWorkflows()
-      workflows.value = data
+      const params = {
+        page: currentPage.value,
+        size: pageSize.value
+      }
+      if (searchKeyword.value) params.search = searchKeyword.value
+      if (selectedCategoryId.value) params.categoryId = selectedCategoryId.value
+
+      const { data } = await getWorkflows(params)
+      workflows.value = data.content || []
+      total.value = data.total || 0
+      totalPages.value = data.totalPages || 1
+      currentPage.value = data.page || 1
     } finally {
       loading.value = false
     }
@@ -27,21 +45,40 @@ export const useWorkflowStore = defineStore('workflow', () => {
 
   async function create(name, description) {
     const { data } = await createWorkflow({ name, description })
-    workflows.value.unshift(data)
     return data
   }
 
   async function save(id, payload) {
     const { data } = await saveWorkflow(id, payload)
-    const idx = workflows.value.findIndex(w => w.id === id)
-    if (idx >= 0) workflows.value[idx] = data
     return data
   }
 
   async function remove(id) {
     await deleteWorkflow(id)
-    workflows.value = workflows.value.filter(w => w.id !== id)
+    fetchList()
   }
 
-  return { workflows, current, loading, fetchList, fetchOne, create, save, remove }
+  function setPage(p) {
+    currentPage.value = p
+    fetchList()
+  }
+
+  function setSearch(kw) {
+    searchKeyword.value = kw
+    currentPage.value = 1
+    fetchList()
+  }
+
+  function setCategory(catId) {
+    selectedCategoryId.value = catId
+    currentPage.value = 1
+    fetchList()
+  }
+
+  return {
+    workflows, current, loading,
+    currentPage, pageSize, total, totalPages, searchKeyword, selectedCategoryId,
+    fetchList, fetchOne, create, save, remove,
+    setPage, setSearch, setCategory
+  }
 })

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

@@ -0,0 +1,440 @@
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import {
+  NButton, NIcon, NDataTable, NModal, NForm, NFormItem, NInput,
+  NInputNumber, NSwitch, NSpace, NTag, NPopconfirm, NSelect,
+  NTooltip, useMessage
+} from 'naive-ui'
+import { h } from 'vue'
+import { AddOutline, ReloadOutline, SettingsOutline, FlashOutline } from '@vicons/ionicons5'
+import {
+  getModelList, createModel, updateModel, deleteModel, setDefaultModel,
+  testModelConnection, testConnection, fetchAvailableModels
+} from '../api/model'
+
+const message = useMessage()
+
+const loading = ref(false)
+const models = ref([])
+const showModal = ref(false)
+const editingId = ref(null)
+const saving = ref(false)
+const testingId = ref(null)
+const testingInModal = ref(false)
+const testResult = ref(null)
+const fetchingModels = ref(false)
+const availableModels = ref([])
+
+const form = reactive({
+  name: '',
+  baseUrl: '',
+  completionsPath: '/chat/completions',
+  apiKey: '',
+  modelName: '',
+  temperature: 0.3,
+  isDefault: false
+})
+
+// 表格列定义
+const columns = [
+  { title: '名称', key: 'name', width: 130 },
+  {
+    title: '模型标识', key: 'modelName', width: 130,
+    render: (row) => h('span', { style: 'font-family: monospace; font-size: 13px' }, row.modelName)
+  },
+  { title: '接口地址', key: 'baseUrl', ellipsis: { tooltip: true } },
+  { title: 'API Key', key: 'apiKey', width: 150 },
+  { title: '温度', key: 'temperature', width: 70, render: (row) => row.temperature?.toFixed(1) },
+  {
+    title: '状态', key: 'isDefault', width: 90,
+    render: (row) => row.isDefault
+      ? h(NTag, { type: 'success', size: 'small' }, { default: () => '默认' })
+      : h(NTag, { size: 'small' }, { default: () => '-' })
+  },
+  {
+    title: '操作', key: 'actions', width: 280,
+    render: (row) => h(NSpace, { size: 'small', align: 'center' }, {
+      default: () => [
+        h(NButton, {
+          size: 'small', quaternary: true, type: 'info',
+          loading: testingId.value === row.id,
+          onClick: () => handleTestRow(row)
+        }, {
+          icon: () => h(NIcon, null, { default: () => h(FlashOutline) }),
+          default: () => '测试'
+        }),
+        !row.isDefault && h(NButton, {
+          size: 'small', quaternary: true,
+          onClick: () => handleSetDefault(row)
+        }, { default: () => '设为默认' }),
+        h(NButton, {
+          size: 'small', quaternary: true,
+          onClick: () => handleEdit(row)
+        }, { default: () => '编辑' }),
+        h(NPopconfirm, { onPositiveClick: () => handleDelete(row) }, {
+          trigger: () => h(NButton, { size: 'small', quaternary: true, type: 'error' }, { default: () => '删除' }),
+          default: () => `确定删除「${row.name}」?`
+        })
+      ]
+    })
+  }
+]
+
+onMounted(fetchModels)
+
+async function fetchModels() {
+  loading.value = true
+  try {
+    const res = await getModelList()
+    models.value = res.data || []
+  } catch (e) {
+    message.error('加载模型列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function openCreate() {
+  editingId.value = null
+  Object.assign(form, { name: '', baseUrl: '', completionsPath: '/chat/completions', apiKey: '', modelName: '', temperature: 0.3, isDefault: false })
+  testResult.value = null
+  availableModels.value = []
+  showModal.value = true
+}
+
+function handleEdit(row) {
+  editingId.value = row.id
+  Object.assign(form, {
+    name: row.name,
+    baseUrl: row.baseUrl,
+    completionsPath: row.completionsPath || '/chat/completions',
+    apiKey: '',
+    modelName: row.modelName,
+    temperature: row.temperature,
+    isDefault: row.isDefault
+  })
+  testResult.value = null
+  availableModels.value = []
+  showModal.value = true
+}
+
+async function handleSave() {
+  if (!form.name || !form.baseUrl || !form.modelName) {
+    message.warning('请填写必填字段')
+    return
+  }
+  if (!editingId.value && !form.apiKey) {
+    message.warning('请填写 API Key')
+    return
+  }
+
+  saving.value = true
+  try {
+    if (editingId.value) {
+      await updateModel(editingId.value, form)
+      message.success('更新成功')
+    } else {
+      await createModel(form)
+      message.success('创建成功')
+    }
+    showModal.value = false
+    await fetchModels()
+  } catch (e) {
+    message.error(e.response?.data?.message || '操作失败')
+  } finally {
+    saving.value = false
+  }
+}
+
+async function handleSetDefault(row) {
+  try {
+    await setDefaultModel(row.id)
+    message.success(`已将「${row.name}」设为默认模型`)
+    await fetchModels()
+  } catch (e) {
+    message.error('设置失败')
+  }
+}
+
+async function handleDelete(row) {
+  try {
+    await deleteModel(row.id)
+    message.success('删除成功')
+    await fetchModels()
+  } catch (e) {
+    message.error('删除失败')
+  }
+}
+
+// 测试表格中已保存模型的连接
+async function handleTestRow(row) {
+  testingId.value = row.id
+  try {
+    const res = await testModelConnection(row.id)
+    const data = res.data
+    if (data.success) {
+      message.success(`连接成功 · ${data.latencyMs}ms`, { duration: 5000 })
+    } else {
+      message.error(`连接失败 · ${data.error}`, { duration: 8000 })
+    }
+  } catch (e) {
+    message.error('测试请求失败')
+  } finally {
+    testingId.value = null
+  }
+}
+
+// 在弹窗中测试连接(支持未保存的配置)
+async function handleTestInModal() {
+  if (!form.baseUrl || !form.modelName) {
+    message.warning('请先填写接口地址和模型标识')
+    return
+  }
+  if (!editingId.value && !form.apiKey) {
+    message.warning('请先填写 API Key')
+    return
+  }
+
+  testingInModal.value = true
+  testResult.value = null
+  try {
+    // 编辑模式且 apiKey 为空时,使用已保存模型的测试接口
+    let res
+    if (editingId.value && !form.apiKey) {
+      res = await testModelConnection(editingId.value)
+    } else {
+      res = await testConnection({
+        baseUrl: form.baseUrl,
+        completionsPath: form.completionsPath,
+        apiKey: form.apiKey,
+        modelName: form.modelName,
+        temperature: form.temperature
+      })
+    }
+    testResult.value = res.data
+    if (res.data.success) {
+      message.success(`连接成功 · ${res.data.latencyMs}ms`)
+    } else {
+      message.warning(`连接失败 · ${res.data.error}`)
+    }
+  } catch (e) {
+    testResult.value = { success: false, error: '请求失败' }
+    message.error('测试请求失败')
+  } finally {
+    testingInModal.value = false
+  }
+}
+
+// 获取可用模型列表
+async function handleFetchModels() {
+  if (!form.baseUrl || !form.apiKey) {
+    message.warning('请先填写接口地址和 API Key')
+    return
+  }
+
+  fetchingModels.value = true
+  try {
+    const res = await fetchAvailableModels({ baseUrl: form.baseUrl, apiKey: form.apiKey })
+    availableModels.value = (res.data || []).map(id => ({ label: id, value: id }))
+    if (availableModels.value.length > 0) {
+      message.success(`获取到 ${availableModels.value.length} 个可用模型`)
+    } else {
+      message.info('未获取到可用模型')
+    }
+  } catch (e) {
+    message.error(e.response?.data?.message || '获取模型列表失败')
+  } finally {
+    fetchingModels.value = false
+  }
+}
+
+// 选择模型
+function handleModelSelect(val) {
+  form.modelName = val
+}
+</script>
+
+<template>
+  <div class="model-management">
+    <div class="toolbar">
+      <div class="toolbar-left">
+        <span class="model-count">
+          共 <span class="count-num">{{ models.length }}</span> 个模型
+        </span>
+      </div>
+      <div class="toolbar-right">
+        <n-button quaternary @click="fetchModels" :loading="loading">
+          <template #icon><n-icon><ReloadOutline /></n-icon></template>
+          刷新
+        </n-button>
+        <n-button type="primary" @click="openCreate">
+          <template #icon><n-icon><AddOutline /></n-icon></template>
+          新增模型
+        </n-button>
+      </div>
+    </div>
+
+    <n-data-table
+      :columns="columns"
+      :data="models"
+      :loading="loading"
+      :bordered="false"
+      :single-line="false"
+      striped
+    />
+
+    <!-- 新增/编辑弹窗 -->
+    <n-modal v-model:show="showModal" preset="card" :title="editingId ? '编辑模型' : '新增模型'" style="max-width: 600px">
+      <n-form label-placement="left" label-width="90">
+        <n-form-item label="名称" required>
+          <n-input v-model:value="form.name" placeholder="如:智谱 GLM-4" />
+        </n-form-item>
+        <n-form-item label="接口地址" required>
+          <n-input v-model:value="form.baseUrl" placeholder="如:https://open.bigmodel.cn/api/paas/v4" />
+        </n-form-item>
+        <n-form-item label="补全路径">
+          <n-input v-model:value="form.completionsPath" placeholder="/chat/completions" />
+        </n-form-item>
+        <n-form-item label="API Key" :required="!editingId">
+          <n-input
+            v-model:value="form.apiKey"
+            type="password"
+            show-password-on="click"
+            :placeholder="editingId ? '留空则不修改' : '输入 API Key'"
+          />
+        </n-form-item>
+        <n-form-item label="模型标识" required>
+          <n-space vertical style="width: 100%">
+            <n-input
+              v-model:value="form.modelName"
+              placeholder="如:glm-4-flash"
+              :disabled="availableModels.length > 0"
+            />
+            <n-select
+              v-if="availableModels.length > 0"
+              :value="form.modelName"
+              :options="availableModels"
+              placeholder="从列表中选择模型"
+              @update:value="handleModelSelect"
+              filterable
+              clearable
+            />
+            <n-button size="small" quaternary :loading="fetchingModels" @click="handleFetchModels">
+              <template #icon><n-icon><FlashOutline /></n-icon></template>
+              获取可用模型
+            </n-button>
+          </n-space>
+        </n-form-item>
+        <n-form-item label="温度">
+          <n-input-number v-model:value="form.temperature" :min="0" :max="2" :step="0.1" style="width: 160px" />
+        </n-form-item>
+        <n-form-item label="设为默认">
+          <n-switch v-model:value="form.isDefault" />
+        </n-form-item>
+
+        <!-- 测试连接 -->
+        <n-form-item label="连接测试">
+          <n-space vertical style="width: 100%">
+            <n-button
+              size="small"
+              :loading="testingInModal"
+              @click="handleTestInModal"
+            >
+              <template #icon><n-icon><FlashOutline /></n-icon></template>
+              测试连接
+            </n-button>
+            <div v-if="testResult" class="test-result" :class="testResult.success ? 'success' : 'fail'">
+              <template v-if="testResult.success">
+                <span class="test-status">✓ 连接成功</span>
+                <span class="test-latency">{{ testResult.latencyMs }}ms</span>
+                <div v-if="testResult.response" class="test-response">{{ testResult.response }}</div>
+              </template>
+              <template v-else>
+                <span class="test-status">✗ 连接失败</span>
+                <span class="test-error">{{ testResult.error }}</span>
+              </template>
+            </div>
+          </n-space>
+        </n-form-item>
+      </n-form>
+      <template #footer>
+        <n-space justify="end">
+          <n-button @click="showModal = false">取消</n-button>
+          <n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
+        </n-space>
+      </template>
+    </n-modal>
+  </div>
+</template>
+
+<style scoped>
+.model-management {
+  min-height: 100%;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+}
+
+.model-count {
+  font-size: 14px;
+  color: var(--text-secondary);
+}
+
+.count-num {
+  color: var(--color-accent);
+  font-weight: 600;
+}
+
+.toolbar-right {
+  display: flex;
+  gap: 8px;
+}
+
+/* 测试连接结果 */
+.test-result {
+  padding: 8px 12px;
+  border-radius: 6px;
+  font-size: 13px;
+  width: 100%;
+}
+
+.test-result.success {
+  background: rgba(34, 197, 94, 0.08);
+  border: 1px solid rgba(34, 197, 94, 0.2);
+}
+
+.test-result.fail {
+  background: rgba(239, 68, 68, 0.08);
+  border: 1px solid rgba(239, 68, 68, 0.2);
+}
+
+.test-status {
+  font-weight: 600;
+}
+
+.test-latency {
+  margin-left: 12px;
+  color: var(--text-secondary);
+}
+
+.test-error {
+  margin-left: 8px;
+  color: #ef4444;
+}
+
+.test-response {
+  margin-top: 6px;
+  padding-top: 6px;
+  border-top: 1px solid rgba(0, 0, 0, 0.06);
+  color: var(--text-secondary);
+  font-size: 12px;
+  max-height: 60px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 505 - 60
frontend/src/views/SkillManagement.vue

@@ -1,11 +1,15 @@
 <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 { NButton, NIcon, NSpin, NEmpty, useDialog, useMessage } from 'naive-ui'
+import { AddOutline, ReloadOutline, SearchOutline } from '@vicons/ionicons5'
 import { useSkillStore } from '../stores/skill'
+import { getCategoryTree, createCategory, updateCategory, deleteCategory as deleteCategoryApi } from '../api/category'
+import { setSkillCategory } from '../api/skill'
 import SkillCard from '../components/skill/SkillCard.vue'
 import SkillUpload from '../components/skill/SkillUpload.vue'
+import TagSelector from '../components/tag/TagSelector.vue'
+import CategoryTree from '../components/skill/CategoryTree.vue'
 
 const router = useRouter()
 const skillStore = useSkillStore()
@@ -13,23 +17,133 @@ const dialog = useDialog()
 const message = useMessage()
 
 const showUpload = ref(false)
+const showTagSelector = ref(false)
+const taggingSkill = ref(null)
+
+// 分类树相关
+const categories = ref([])
+const expandedIds = ref(new Set())
+
+// 分类树内联编辑状态
+const editingId = ref(null)
+const editingName = ref('')
+const addingParentId = ref(undefined) // undefined = 未激活,null = 添加根分类,数字 = 添加子分类
+const addingName = ref('')
 
 onMounted(() => {
   skillStore.init()
+  loadCategories()
 })
 
 onUnmounted(() => {
   skillStore.destroy()
 })
 
+// ==================== 分类树操作 ====================
+
+async function loadCategories() {
+  try {
+    const res = await getCategoryTree()
+    categories.value = res.data || []
+  } catch (err) {
+    console.error('加载分类失败', err)
+  }
+}
+
+function toggleExpand(id) {
+  if (expandedIds.value.has(id)) {
+    expandedIds.value.delete(id)
+  } else {
+    expandedIds.value.add(id)
+  }
+}
+
+function selectCategory(categoryId) {
+  if (skillStore.selectedCategoryId === categoryId) {
+    skillStore.setCategory(null)
+  } else {
+    skillStore.setCategory(categoryId)
+  }
+}
+
+function startAdd(parentId) {
+  addingParentId.value = parentId
+  addingName.value = ''
+}
+
+function cancelAdd() {
+  addingParentId.value = undefined
+  addingName.value = ''
+}
+
+async function confirmAdd() {
+  if (!addingName.value.trim()) return
+  try {
+    await createCategory({ parentId: addingParentId.value, name: addingName.value.trim() })
+    await loadCategories()
+    if (addingParentId.value != null) {
+      expandedIds.value.add(addingParentId.value)
+    }
+    cancelAdd()
+  } catch (err) {
+    message.error(err.message || '创建失败')
+  }
+}
+
+function startEdit({ id, name }) {
+  editingId.value = id
+  editingName.value = name
+}
+
+function cancelEdit() {
+  editingId.value = null
+  editingName.value = ''
+}
+
+async function confirmEdit() {
+  if (!editingName.value.trim()) return
+  try {
+    await updateCategory(editingId.value, { name: editingName.value.trim() })
+    await loadCategories()
+    cancelEdit()
+  } catch (err) {
+    message.error(err.message || '更新失败')
+  }
+}
+
+function handleDeleteCategory({ id, name }) {
+  dialog.warning({
+    title: '确认删除',
+    content: `确定要删除分类「${name}」吗?该分类下的技能将变为未分类。`,
+    positiveText: '确认删除',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      try {
+        await deleteCategoryApi(id)
+        await loadCategories()
+        if (skillStore.selectedCategoryId === id) {
+          skillStore.setCategory(null)
+        }
+        message.success('删除成功')
+      } catch (err) {
+        message.error(err.message || '删除失败')
+      }
+    }
+  })
+}
+
+// ==================== 技能操作 ====================
+
 function handleRefresh() {
   skillStore.fetchSkills()
+  loadCategories()
 }
 
 async function handleUpload(file) {
   try {
     await skillStore.createSkill(file)
     showUpload.value = false
+    loadCategories()
     message.success('技能上传成功')
   } catch (err) {
     message.error(err.message || '上传失败')
@@ -49,6 +163,7 @@ function handleDelete(skill) {
     onPositiveClick: async () => {
       try {
         await skillStore.removeSkill(skill.folderName)
+        loadCategories()
         message.success('删除成功')
       } catch (err) {
         message.error(err.message || '删除失败')
@@ -56,70 +171,183 @@ function handleDelete(skill) {
     }
   })
 }
+
+function handleTag(skill) {
+  taggingSkill.value = skill
+  showTagSelector.value = true
+}
+
+async function onTagsSaved() {
+  await skillStore.fetchSkills()
+  message.success('标签已更新')
+}
+
+// 搜索防抖
+let searchTimer = null
+function onSearchInput(value) {
+  clearTimeout(searchTimer)
+  searchTimer = setTimeout(() => {
+    skillStore.setSearch(value)
+  }, 300)
+}
 </script>
 
 <template>
   <div class="skill-management">
-    <!-- 操作栏 -->
-    <div class="toolbar">
-      <div class="toolbar-left">
-        <span class="skill-count">
-          共 <span class="count-num">{{ skillStore.skills.length }}</span> 个技能
-        </span>
-      </div>
-      <div class="toolbar-right">
-        <n-button quaternary @click="handleRefresh" :loading="skillStore.loading">
-          <template #icon>
-            <n-icon><ReloadOutline /></n-icon>
-          </template>
-          刷新
-        </n-button>
-        <n-button type="primary" @click="showUpload = true">
-          <template #icon>
-            <n-icon><AddOutline /></n-icon>
-          </template>
-          新增技能
-        </n-button>
-      </div>
-    </div>
+    <div class="main-layout">
+      <!-- 左侧分类树 -->
+      <div class="sidebar">
+        <div class="sidebar-header">
+          <span class="sidebar-title">分类</span>
+          <button class="icon-btn" @click="startAdd(null)" title="新建根分类">
+            <n-icon size="16"><AddOutline /></n-icon>
+          </button>
+        </div>
 
-    <!-- 加载状态 -->
-    <div v-if="skillStore.loading && skillStore.skills.length === 0" class="loading-area">
-      <n-spin size="large" />
-      <p class="loading-text">正在加载技能列表...</p>
-    </div>
+        <div class="category-list">
+          <!-- 全部 -->
+          <div
+            class="all-item"
+            :class="{ active: !skillStore.selectedCategoryId }"
+            @click="selectCategory(null)"
+          >
+            <span class="cat-name">全部技能</span>
+            <span class="cat-count">{{ skillStore.total }}</span>
+          </div>
 
-    <!-- 空状态 -->
-    <div v-else-if="skillStore.skills.length === 0" class="empty-area">
-      <div class="empty-illustration">
-        <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
-          <rect x="20" y="30" width="80" height="60" rx="8" stroke="rgba(56, 189, 248, 0.15)" stroke-width="2" fill="none"/>
-          <path d="M45 55H75" stroke="rgba(56, 189, 248, 0.2)" stroke-width="2" stroke-linecap="round"/>
-          <path d="M45 65H65" stroke="rgba(56, 189, 248, 0.15)" stroke-width="2" stroke-linecap="round"/>
-          <circle cx="60" cy="60" r="30" stroke="rgba(37, 99, 235, 0.1)" stroke-width="1" fill="none"/>
-        </svg>
+          <!-- 递归分类树 -->
+          <CategoryTree
+            :nodes="categories"
+            :level="0"
+            :selected-id="skillStore.selectedCategoryId"
+            :expanded-ids="expandedIds"
+            :editing-id="editingId"
+            v-model:editing-name="editingName"
+            :adding-parent-id="addingParentId"
+            v-model:adding-name="addingName"
+            @select="selectCategory"
+            @toggle="toggleExpand"
+            @add="startAdd"
+            @delete="handleDeleteCategory"
+            @edit-start="startEdit"
+            @edit-confirm="confirmEdit"
+            @edit-cancel="cancelEdit"
+            @add-confirm="confirmAdd"
+            @add-cancel="cancelAdd"
+          />
+
+          <!-- 根分类添加输入框 -->
+          <div v-if="addingParentId === null" class="root-adding">
+            <input
+              class="inline-input"
+              v-model="addingName"
+              placeholder="输入分类名称"
+              @keyup.enter="confirmAdd"
+              @keyup.escape="cancelAdd"
+              @blur="cancelAdd"
+              autofocus
+            />
+          </div>
+        </div>
       </div>
-      <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>
 
-    <!-- 卡片网格 -->
-    <div v-else class="skill-grid">
-      <SkillCard
-        v-for="(skill, index) in skillStore.skills"
-        :key="skill.folderName"
-        :skill="skill"
-        :style="{ animationDelay: `${index * 60}ms` }"
-        class="fade-in"
-        @edit="handleEdit"
-        @delete="handleDelete"
-      />
+      <!-- 右侧内容 -->
+      <div class="content">
+        <!-- 工具栏 -->
+        <div class="toolbar">
+          <div class="toolbar-left">
+            <div class="search-box">
+              <n-icon size="16" class="search-icon"><SearchOutline /></n-icon>
+              <input
+                class="search-input"
+                :value="skillStore.searchKeyword"
+                @input="onSearchInput($event.target.value)"
+                placeholder="搜索技能名称或描述..."
+              />
+            </div>
+            <span class="skill-count">
+              共 <span class="count-num">{{ skillStore.total }}</span> 个技能
+            </span>
+          </div>
+          <div class="toolbar-right">
+            <n-button quaternary @click="handleRefresh" :loading="skillStore.loading">
+              <template #icon>
+                <n-icon><ReloadOutline /></n-icon>
+              </template>
+              刷新
+            </n-button>
+            <n-button type="primary" @click="showUpload = true">
+              <template #icon>
+                <n-icon><AddOutline /></n-icon>
+              </template>
+              新增技能
+            </n-button>
+          </div>
+        </div>
+
+        <!-- 加载状态 -->
+        <div v-if="skillStore.loading && skillStore.skills.length === 0" class="loading-area">
+          <n-spin size="large" />
+          <p class="loading-text">正在加载技能列表...</p>
+        </div>
+
+        <!-- 空状态 -->
+        <div v-else-if="skillStore.skills.length === 0" class="empty-area">
+          <div class="empty-illustration">
+            <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <rect x="20" y="30" width="80" height="60" rx="8" stroke="rgba(56, 189, 248, 0.15)" stroke-width="2" fill="none"/>
+              <path d="M45 55H75" stroke="rgba(56, 189, 248, 0.2)" stroke-width="2" stroke-linecap="round"/>
+              <path d="M45 65H65" stroke="rgba(56, 189, 248, 0.15)" stroke-width="2" stroke-linecap="round"/>
+              <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>
+          <n-button type="primary" @click="showUpload = true" style="margin-top: 16px">
+            <template #icon>
+              <n-icon><AddOutline /></n-icon>
+            </template>
+            新增技能
+          </n-button>
+        </div>
+
+        <!-- 卡片网格 -->
+        <div v-else class="skill-grid">
+          <SkillCard
+            v-for="(skill, index) in skillStore.skills"
+            :key="skill.folderName"
+            :skill="skill"
+            :style="{ animationDelay: `${index * 60}ms` }"
+            class="fade-in"
+            @edit="handleEdit"
+            @delete="handleDelete"
+            @tag="handleTag"
+          />
+        </div>
+
+        <!-- 分页 -->
+        <div v-if="skillStore.totalPages > 1" class="pagination">
+          <button
+            class="page-btn"
+            :disabled="skillStore.currentPage <= 1"
+            @click="skillStore.setPage(skillStore.currentPage - 1)"
+          >上一页</button>
+          <template v-for="p in skillStore.totalPages" :key="p">
+            <button
+              v-if="p === 1 || p === skillStore.totalPages || Math.abs(p - skillStore.currentPage) <= 1"
+              class="page-btn"
+              :class="{ active: p === skillStore.currentPage }"
+              @click="skillStore.setPage(p)"
+            >{{ p }}</button>
+            <span v-else-if="Math.abs(p - skillStore.currentPage) === 2" class="page-ellipsis">...</span>
+          </template>
+          <button
+            class="page-btn"
+            :disabled="skillStore.currentPage >= skillStore.totalPages"
+            @click="skillStore.setPage(skillStore.currentPage + 1)"
+          >下一页</button>
+        </div>
+      </div>
     </div>
 
     <!-- 上传弹窗 -->
@@ -129,6 +357,15 @@ function handleDelete(skill) {
       :progress="skillStore.uploadProgress"
       @upload="handleUpload"
     />
+
+    <!-- 标签选择器 -->
+    <TagSelector
+      v-model:show="showTagSelector"
+      entity-type="skill"
+      :entity-id="taggingSkill?.folderName || ''"
+      :selected-tag-ids="taggingSkill?.tags?.map(t => t.tagId) || []"
+      @saved="onTagsSaved"
+    />
   </div>
 </template>
 
@@ -137,12 +374,176 @@ function handleDelete(skill) {
   min-height: 100%;
 }
 
-/* 操作栏 */
+.main-layout {
+  display: flex;
+  gap: 24px;
+  height: 100%;
+}
+
+/* ==================== 左侧分类树 ==================== */
+.sidebar {
+  width: 240px;
+  min-width: 240px;
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-lg);
+  padding: 16px 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.sidebar-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px 12px;
+  border-bottom: 1px solid rgba(56, 189, 248, 0.06);
+}
+
+.sidebar-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+.category-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+}
+
+.all-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 16px;
+  cursor: pointer;
+  transition: all 0.15s;
+  min-height: 32px;
+}
+
+.all-item:hover {
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.all-item.active {
+  background: rgba(37, 99, 235, 0.12);
+}
+
+.all-item.active .cat-name {
+  color: var(--color-accent);
+  font-weight: 500;
+}
+
+.cat-name {
+  font-size: 13px;
+  color: var(--text-secondary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.cat-count {
+  font-size: 11px;
+  color: var(--text-muted);
+  background: rgba(100, 116, 139, 0.1);
+  padding: 1px 6px;
+  border-radius: 8px;
+  min-width: 20px;
+  text-align: center;
+}
+
+.icon-btn {
+  background: none;
+  border: none;
+  color: var(--text-tertiary);
+  cursor: pointer;
+  padding: 4px;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  transition: all 0.15s;
+}
+
+.icon-btn:hover {
+  color: var(--color-accent);
+  background: rgba(37, 99, 235, 0.1);
+}
+
+.root-adding {
+  padding: 6px 16px;
+}
+
+.inline-input {
+  background: rgba(37, 99, 235, 0.08);
+  border: 1px solid rgba(37, 99, 235, 0.2);
+  border-radius: 4px;
+  padding: 2px 8px;
+  font-size: 13px;
+  color: var(--text-primary);
+  outline: none;
+  width: 100%;
+  font-family: inherit;
+}
+
+.inline-input:focus {
+  border-color: var(--color-accent);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+
+/* ==================== 右侧内容 ==================== */
+.content {
+  flex: 1;
+  min-width: 0;
+}
+
 .toolbar {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  margin-bottom: 28px;
+  margin-bottom: 20px;
+}
+
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.search-box {
+  position: relative;
+  width: 280px;
+}
+
+.search-icon {
+  position: absolute;
+  left: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: var(--text-muted);
+}
+
+.search-input {
+  width: 100%;
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-sm);
+  padding: 8px 12px 8px 32px;
+  font-size: 13px;
+  color: var(--text-primary);
+  outline: none;
+  transition: border-color 0.2s;
+  font-family: inherit;
+}
+
+.search-input:focus {
+  border-color: var(--color-accent);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+
+.search-input::placeholder {
+  color: var(--text-muted);
 }
 
 .skill-count {
@@ -207,4 +608,48 @@ function handleDelete(skill) {
   grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
   gap: 20px;
 }
+
+/* 分页 */
+.pagination {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  margin-top: 32px;
+  padding-top: 20px;
+  border-top: 1px solid rgba(56, 189, 248, 0.06);
+}
+
+.page-btn {
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-sm);
+  padding: 6px 14px;
+  font-size: 13px;
+  color: var(--text-secondary);
+  cursor: pointer;
+  transition: all 0.15s;
+  font-family: inherit;
+}
+
+.page-btn:hover:not(:disabled) {
+  border-color: var(--color-accent);
+  color: var(--color-accent);
+}
+
+.page-btn.active {
+  background: var(--color-accent);
+  border-color: var(--color-accent);
+  color: #fff;
+}
+
+.page-btn:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+
+.page-ellipsis {
+  color: var(--text-muted);
+  padding: 0 4px;
+}
 </style>

+ 202 - 5
frontend/src/views/TagManagement.vue

@@ -3,12 +3,13 @@ import { ref, computed, onMounted } from 'vue'
 import { useTagStore } from '../stores/tag'
 import {
   NButton, NInput, NModal, NForm, NFormItem, NEmpty, NSpin,
-  NPopconfirm, NIcon, useMessage
+  NPopconfirm, NIcon, NSelect, NTag, useMessage
 } from 'naive-ui'
 import {
-  AddOutline, CreateOutline, TrashOutline
+  AddOutline, CreateOutline, TrashOutline, DownloadOutline, CloudUploadOutline
 } from '@vicons/ionicons5'
 import TreeNodeItem from '../components/tag/TreeNodeItem.vue'
+import { exportTags, importTags } from '../api/tag'
 
 const tagStore = useTagStore()
 const message = useMessage()
@@ -127,6 +128,98 @@ function toggleExpand(id) {
   expandedNodes.value = new Set(expandedNodes.value)
 }
 
+// ========== 导入导出 ==========
+const showImportModal = ref(false)
+const importData = ref(null)
+const importLoading = ref(false)
+const importResult = ref(null)
+
+const strategyOptions = [
+  { label: '覆盖标签组', value: 'overwrite' },
+  { label: '合并标签', value: 'merge' },
+  { label: '跳过', value: 'skip' }
+]
+
+async function handleExport() {
+  try {
+    const res = await exportTags()
+    const blob = new Blob([JSON.stringify(res.data, null, 2)], { type: 'application/json' })
+    const url = URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = url
+    a.download = `tags-export-${new Date().toISOString().slice(0, 10)}.json`
+    a.click()
+    URL.revokeObjectURL(url)
+    message.success('导出成功')
+  } catch (e) {
+    message.error('导出失败')
+  }
+}
+
+function handleImportFileSelect(e) {
+  const file = e.target.files?.[0]
+  if (!file) return
+  const reader = new FileReader()
+  reader.onload = (ev) => {
+    try {
+      const data = JSON.parse(ev.target.result)
+      if (!data.groups || !Array.isArray(data.groups)) {
+        message.error('无效的标签导出文件')
+        return
+      }
+      // 标记冲突状态
+      const existingNames = new Set(tagStore.groups.map(g => g.name))
+      importData.value = {
+        raw: data,
+        groups: data.groups.map(g => ({
+          name: g.name,
+          tagCount: countTags(g.tags),
+          conflict: existingNames.has(g.name),
+          strategy: existingNames.has(g.name) ? 'overwrite' : 'create'
+        }))
+      }
+      importResult.value = null
+      showImportModal.value = true
+    } catch {
+      message.error('文件解析失败,请确认是有效的 JSON 文件')
+    }
+  }
+  reader.readAsText(file)
+  // 重置 input 以支持重复选择同一文件
+  e.target.value = ''
+}
+
+function countTags(tags) {
+  if (!tags) return 0
+  let count = 0
+  for (const t of tags) {
+    count += 1 + countTags(t.children)
+  }
+  return count
+}
+
+async function confirmImport() {
+  if (!importData.value) return
+  importLoading.value = true
+  importResult.value = null
+  try {
+    const strategies = {}
+    for (const g of importData.value.groups) {
+      if (g.conflict) {
+        strategies[g.name] = g.strategy
+      }
+    }
+    const res = await importTags({ data: importData.value.raw, strategies })
+    importResult.value = res.data
+    await tagStore.fetchGroups()
+    message.success('导入完成')
+  } catch (e) {
+    message.error(e.response?.data?.message || '导入失败')
+  } finally {
+    importLoading.value = false
+  }
+}
+
 onMounted(() => {
   tagStore.fetchGroups()
 })
@@ -138,9 +231,18 @@ onMounted(() => {
     <div class="group-panel">
       <div class="panel-header">
         <h3 class="panel-title">标签组</h3>
-        <button class="icon-btn" title="新建标签组" @click="openCreateGroup">
-          <n-icon size="18"><AddOutline /></n-icon>
-        </button>
+        <div style="display: flex; gap: 4px;">
+          <button class="icon-btn" title="导出" @click="handleExport">
+            <n-icon size="16"><DownloadOutline /></n-icon>
+          </button>
+          <label class="icon-btn" title="导入">
+            <n-icon size="16"><CloudUploadOutline /></n-icon>
+            <input type="file" accept=".json" style="display: none;" @change="handleImportFileSelect" />
+          </label>
+          <button class="icon-btn" title="新建标签组" @click="openCreateGroup">
+            <n-icon size="18"><AddOutline /></n-icon>
+          </button>
+        </div>
       </div>
       <div class="group-list">
         <div
@@ -252,6 +354,41 @@ onMounted(() => {
         <n-button type="primary" @click="saveGroup" style="margin-left: 8px">保存</n-button>
       </template>
     </n-modal>
+
+    <!-- 导入对话框 -->
+    <n-modal v-model:show="showImportModal" preset="card" title="导入标签" style="max-width: 560px" :bordered="false">
+      <div v-if="importData" class="import-content">
+        <p class="import-hint">文件包含 {{ importData.groups.length }} 个标签组,请处理冲突:</p>
+        <div class="import-group-list">
+          <div v-for="g in importData.groups" :key="g.name" class="import-group-item">
+            <div class="import-group-info">
+              <span class="import-group-name">{{ g.name }}</span>
+              <span class="import-group-count">{{ g.tagCount }} 个标签</span>
+              <n-tag v-if="!g.conflict" type="success" size="small">新建</n-tag>
+              <n-tag v-else type="warning" size="small">冲突</n-tag>
+            </div>
+            <div v-if="g.conflict" class="import-strategy">
+              <n-select
+                v-model:value="g.strategy"
+                :options="strategyOptions"
+                size="small"
+                style="width: 150px;"
+              />
+            </div>
+          </div>
+        </div>
+        <!-- 导入结果 -->
+        <div v-if="importResult" class="import-result">
+          <p v-for="d in importResult.details" :key="d" class="import-detail">{{ d }}</p>
+        </div>
+      </div>
+      <template #action>
+        <n-button @click="showImportModal = false">{{ importResult ? '关闭' : '取消' }}</n-button>
+        <n-button v-if="!importResult" type="primary" :loading="importLoading" @click="confirmImport">
+          确认导入
+        </n-button>
+      </template>
+    </n-modal>
   </div>
 </template>
 
@@ -420,4 +557,64 @@ onMounted(() => {
   color: var(--color-danger);
   background: rgba(239, 68, 68, 0.1);
 }
+
+/* ===== 导入对话框 ===== */
+.import-content {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.import-hint {
+  font-size: 13px;
+  color: var(--text-secondary);
+  margin: 0;
+}
+
+.import-group-list {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  max-height: 320px;
+  overflow-y: auto;
+}
+
+.import-group-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-md);
+}
+
+.import-group-info {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.import-group-name {
+  font-size: 14px;
+  font-weight: 500;
+  color: var(--text-primary);
+}
+
+.import-group-count {
+  font-size: 12px;
+  color: var(--text-tertiary);
+}
+
+.import-result {
+  padding: 12px;
+  border-radius: var(--radius-md);
+  background: rgba(34, 197, 94, 0.06);
+  border: 1px solid rgba(34, 197, 94, 0.15);
+}
+
+.import-detail {
+  font-size: 13px;
+  color: var(--text-secondary);
+  margin: 2px 0;
+}
 </style>

+ 624 - 93
frontend/src/views/skill/SkillEdit.vue

@@ -1,14 +1,15 @@
 <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 { NButton, NInput, NIcon, NSelect, NCheckbox, NTooltip, NSpin, NEmpty, useMessage, useDialog } from 'naive-ui'
+import { ArrowBackOutline, SaveOutline, CodeSlashOutline, GlobeOutline, TimeOutline } 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
+  readSkillFile, saveSkillFile, createSkillFile, deleteSkillFile,
+  listVersions, getVersionContent, rollbackVersion, updateVersionMessage
 } from '../../api/skill'
 import CodeEditor from '../../components/skill/CodeEditor.vue'
 import FileTree from '../../components/skill/FileTree.vue'
@@ -16,6 +17,7 @@ import FileTree from '../../components/skill/FileTree.vue'
 const route = useRoute()
 const router = useRouter()
 const message = useMessage()
+const dialog = useDialog()
 
 const folderName = route.params.folderName
 const loading = ref(true)
@@ -38,6 +40,17 @@ const currentFilePath = ref('')
 const currentFileContent = ref('')
 const currentFileDirty = ref(false)
 
+// ==================== 版本历史 ====================
+const showVersionPanel = ref(false)
+const versions = ref([])
+const versionsLoading = ref(false)
+const selectedVersion = ref(null)         // 当前查看的版本
+const versionContent = ref('')            // 选中版本的内容
+const diffVersion = ref(null)             // diff 对比的旧版本
+const diffContent = ref('')              // diff 对比的旧版本内容
+const editingMessage = ref(false)         // 是否正在编辑备注
+const editingMessageText = ref('')        // 编辑中的备注文本
+
 // ==================== 翻译 ====================
 const isTranslating = ref(false)
 
@@ -215,6 +228,10 @@ async function handleSave() {
       originalBody.value = body.value
       message.success('保存成功')
     }
+    // 保存后刷新版本列表
+    if (showVersionPanel.value) {
+      await loadVersions()
+    }
   } catch (e) {
     message.error('保存失败')
   } finally {
@@ -254,6 +271,161 @@ function goBack() {
   router.push('/management')
 }
 
+// ==================== 版本历史 ====================
+async function loadVersions() {
+  versionsLoading.value = true
+  try {
+    const res = await listVersions(folderName)
+    versions.value = (res.data || []).reverse() // 最新的排在前面
+  } catch (e) {
+    message.error('加载版本历史失败')
+  } finally {
+    versionsLoading.value = false
+  }
+}
+
+async function toggleVersionPanel() {
+  showVersionPanel.value = !showVersionPanel.value
+  if (showVersionPanel.value) {
+    await loadVersions()
+  } else {
+    // 关闭时重置状态
+    selectedVersion.value = null
+    versionContent.value = ''
+    diffVersion.value = null
+    diffContent.value = ''
+  }
+}
+
+async function handleVersionSelect(v) {
+  selectedVersion.value = v
+  diffVersion.value = null
+  diffContent.value = ''
+  try {
+    const res = await getVersionContent(folderName, v.number)
+    versionContent.value = res.data || ''
+  } catch (e) {
+    message.error('读取版本内容失败')
+  }
+}
+
+async function handleDiffSelect(v) {
+  diffVersion.value = v
+  try {
+    const res = await getVersionContent(folderName, v.number)
+    diffContent.value = res.data || ''
+  } catch (e) {
+    message.error('读取版本内容失败')
+  }
+}
+
+function handleRollback(v) {
+  dialog.warning({
+    title: '确认回滚',
+    content: `确定要回滚到 v${v.number} 吗?当前工作区内容将被替换。`,
+    positiveText: '确认回滚',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      try {
+        await rollbackVersion(folderName, v.number)
+        message.success('回滚成功')
+        // 重新加载技能详情
+        await loadSkillDetail()
+        if (advancedMode.value) {
+          await loadFileTree()
+        }
+        await loadVersions()
+        // 重置版本查看状态
+        selectedVersion.value = null
+        versionContent.value = ''
+        diffVersion.value = null
+        diffContent.value = ''
+      } catch (e) {
+        message.error('回滚失败')
+      }
+    }
+  })
+}
+
+function startEditMessage(v) {
+  editingMessage.value = v.number
+  editingMessageText.value = v.message || ''
+}
+
+async function saveMessage(v) {
+  try {
+    await updateVersionMessage(folderName, v.number, editingMessageText.value)
+    v.message = editingMessageText.value
+    editingMessage.value = false
+    message.success('备注已更新')
+  } catch (e) {
+    message.error('更新备注失败')
+  }
+}
+
+function cancelEditMessage() {
+  editingMessage.value = false
+}
+
+function formatTime(dateStr) {
+  if (!dateStr) return ''
+  const d = new Date(dateStr)
+  const pad = n => String(n).padStart(2, '0')
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
+}
+
+/** 简单的行级 diff:返回 [{type: 'same'|'add'|'del', text}] */
+function computeDiff(oldText, newText) {
+  const oldLines = (oldText || '').split('\n')
+  const newLines = (newText || '').split('\n')
+
+  // LCS-based diff 简化版:逐行对比
+  const m = oldLines.length
+  const n = newLines.length
+
+  // 使用简单的双序列对比(对小文件足够)
+  const result = []
+  const lcs = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1))
+
+  for (let i = 1; i <= m; i++) {
+    for (let j = 1; j <= n; j++) {
+      if (oldLines[i - 1] === newLines[j - 1]) {
+        lcs[i][j] = lcs[i - 1][j - 1] + 1
+      } else {
+        lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1])
+      }
+    }
+  }
+
+  // 回溯
+  const ops = []
+  let i = m, j = n
+  while (i > 0 || j > 0) {
+    if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
+      ops.unshift({ type: 'same', text: oldLines[i - 1] })
+      i--; j--
+    } else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
+      ops.unshift({ type: 'add', text: newLines[j - 1] })
+      j--
+    } else {
+      ops.unshift({ type: 'del', text: oldLines[i - 1] })
+      i--
+    }
+  }
+  return ops
+}
+
+const diffResult = computed(() => {
+  if (!diffVersion.value || !selectedVersion.value) return []
+  const oldText = diffVersion.value.number < selectedVersion.value.number
+    ? diffContent.value
+    : versionContent.value
+  const newText = diffVersion.value.number < selectedVersion.value.number
+    ? versionContent.value
+    : diffContent.value
+  return computeDiff(oldText, newText)
+})
+
 // ==================== 生命周期 ====================
 onMounted(() => {
   loadSkillDetail()
@@ -282,6 +454,11 @@ onMounted(() => {
           检测到英文内容,点击翻译为中文
         </n-tooltip>
 
+        <n-button quaternary @click="toggleVersionPanel" :type="showVersionPanel ? 'primary' : 'default'">
+          <template #icon><n-icon><TimeOutline /></n-icon></template>
+          版本历史
+        </n-button>
+
         <n-button
           :type="advancedMode ? 'warning' : 'default'"
           quaternary
@@ -298,113 +475,210 @@ onMounted(() => {
       </div>
     </div>
 
-    <!-- 加载中 -->
-    <div v-if="loading" class="loading-area">
-      <n-spin size="large" />
-    </div>
+    <div class="edit-body">
+      <!-- 主编辑区域 -->
+      <div class="main-content">
+        <!-- 加载中 -->
+        <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 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="form-group" style="flex: 2;">
-            <label class="form-label">技能描述</label>
-            <n-input v-model:value="form.description" placeholder="技能描述" :maxlength="500" />
+
+          <!-- 正文编辑器 -->
+          <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 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 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 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 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 v-else class="io-empty">暂无输入字段</div>
         </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>
+      <!-- ==================== 版本历史面板 ==================== -->
+      <transition name="slide">
+        <div v-if="showVersionPanel" class="version-panel">
+          <div class="vp-header">
+            <h3 class="vp-title">版本历史</h3>
+            <n-button quaternary size="tiny" @click="toggleVersionPanel">✕</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 class="vp-list">
+            <n-spin v-if="versionsLoading" size="small" />
+            <n-empty v-else-if="!versions.length" description="暂无版本" size="small" />
+            <div v-else class="version-items">
+              <div
+                v-for="v in versions"
+                :key="v.number"
+                class="version-item"
+                :class="{
+                  active: selectedVersion?.number === v.number,
+                  'diff-selected': diffVersion?.number === v.number
+                }"
+              >
+                <div class="vi-main" @click="handleVersionSelect(v)">
+                  <div class="vi-row">
+                    <span class="vi-version">v{{ v.number }}</span>
+                    <span class="vi-time">{{ formatTime(v.createdAt) }}</span>
+                  </div>
+                  <div v-if="editingMessage !== v.number" class="vi-message" @dblclick="startEditMessage(v)">
+                    {{ v.message || '双击编辑备注' }}
+                  </div>
+                  <div v-else class="vi-message-edit">
+                    <n-input
+                      v-model:value="editingMessageText"
+                      size="tiny"
+                      placeholder="版本备注"
+                      @keyup.enter="saveMessage(v)"
+                      @keyup.escape="cancelEditMessage"
+                    />
+                    <n-button size="tiny" type="primary" @click="saveMessage(v)">✓</n-button>
+                    <n-button size="tiny" @click="cancelEditMessage">✕</n-button>
+                  </div>
+                </div>
+                <div class="vi-actions">
+                  <button
+                    v-if="selectedVersion && selectedVersion.number !== v.number"
+                    class="vi-action-btn diff-btn"
+                    @click.stop="handleDiffSelect(v)"
+                    title="与选中版本对比"
+                  >⟷</button>
+                  <button
+                    class="vi-action-btn rollback-btn"
+                    @click.stop="handleRollback(v)"
+                    title="回滚到此版本"
+                  >↩</button>
+                </div>
+              </div>
             </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>
+          <!-- 版本内容 / Diff 对比 -->
+          <div v-if="selectedVersion" class="vp-content">
+            <div class="vp-content-header">
+              <span v-if="!diffVersion">
+                v{{ selectedVersion.number }} 的内容
+              </span>
+              <span v-else>
+                v{{ Math.min(selectedVersion.number, diffVersion.number) }}
+                →
+                v{{ Math.max(selectedVersion.number, diffVersion.number) }}
+                变更对比
+              </span>
+              <n-button v-if="diffVersion" size="tiny" quaternary @click="diffVersion = null; diffContent = ''">
+                退出对比
+              </n-button>
+            </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>
+            <!-- Diff 视图 -->
+            <div v-if="diffVersion" class="diff-view">
+              <div v-for="(line, idx) in diffResult" :key="idx" class="diff-line" :class="'diff-' + line.type">
+                <span class="diff-prefix">{{ line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' ' }}</span>
+                <span class="diff-text">{{ line.text }}</span>
+              </div>
+            </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 v-else class="version-content-view">
+              <pre>{{ versionContent }}</pre>
+            </div>
+          </div>
         </div>
-      </div>
+      </transition>
     </div>
   </div>
 </template>
@@ -416,6 +690,19 @@ onMounted(() => {
   flex-direction: column;
 }
 
+.edit-body {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+}
+
+.main-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
 /* ==================== 工具栏 ==================== */
 .edit-toolbar {
   display: flex;
@@ -592,4 +879,248 @@ onMounted(() => {
   color: var(--text-muted);
   font-size: 14px;
 }
+
+/* ==================== 版本历史面板 ==================== */
+.version-panel {
+  width: 400px;
+  border-left: 1px solid var(--border-color);
+  background: var(--bg-secondary);
+  display: flex;
+  flex-direction: column;
+  flex-shrink: 0;
+  overflow: hidden;
+}
+
+.vp-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  border-bottom: 1px solid var(--border-color);
+}
+
+.vp-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0;
+}
+
+.vp-list {
+  flex-shrink: 0;
+  max-height: 280px;
+  overflow-y: auto;
+  padding: 8px;
+}
+
+.version-items {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.version-item {
+  display: flex;
+  align-items: stretch;
+  border-radius: 6px;
+  border: 1px solid transparent;
+  transition: all 0.15s;
+  cursor: pointer;
+}
+
+.version-item:hover {
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.version-item.active {
+  background: rgba(37, 99, 235, 0.1);
+  border-color: rgba(37, 99, 235, 0.3);
+}
+
+.version-item.diff-selected {
+  background: rgba(245, 158, 11, 0.1);
+  border-color: rgba(245, 158, 11, 0.3);
+}
+
+.vi-main {
+  flex: 1;
+  padding: 8px 10px;
+  min-width: 0;
+}
+
+.vi-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.vi-version {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--color-accent);
+}
+
+.vi-time {
+  font-size: 11px;
+  color: var(--text-tertiary);
+}
+
+.vi-message {
+  font-size: 12px;
+  color: var(--text-secondary);
+  margin-top: 2px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.vi-message-edit {
+  display: flex;
+  gap: 4px;
+  margin-top: 4px;
+  align-items: center;
+}
+
+.vi-actions {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 2px;
+  padding: 4px 6px;
+  opacity: 0;
+  transition: opacity 0.15s;
+}
+
+.version-item:hover .vi-actions {
+  opacity: 1;
+}
+
+.vi-action-btn {
+  width: 24px;
+  height: 24px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 13px;
+  background: transparent;
+  transition: background 0.15s;
+}
+
+.diff-btn {
+  color: #f59e0b;
+}
+.diff-btn:hover {
+  background: rgba(245, 158, 11, 0.15);
+}
+
+.rollback-btn {
+  color: var(--color-danger);
+}
+.rollback-btn:hover {
+  background: rgba(239, 68, 68, 0.15);
+}
+
+/* 版本内容区域 */
+.vp-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border-top: 1px solid var(--border-color);
+}
+
+.vp-content-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 16px;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-secondary);
+  border-bottom: 1px solid var(--border-color);
+  flex-shrink: 0;
+}
+
+.version-content-view {
+  flex: 1;
+  overflow: auto;
+  padding: 12px 16px;
+}
+
+.version-content-view pre {
+  font-size: 12px;
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: var(--text-secondary);
+  margin: 0;
+  font-family: 'Consolas', 'Monaco', monospace;
+}
+
+/* Diff 视图 */
+.diff-view {
+  flex: 1;
+  overflow: auto;
+  padding: 4px 0;
+  font-family: 'Consolas', 'Monaco', monospace;
+  font-size: 12px;
+  line-height: 1.6;
+}
+
+.diff-line {
+  display: flex;
+  min-height: 20px;
+}
+
+.diff-prefix {
+  width: 20px;
+  text-align: center;
+  color: var(--text-tertiary);
+  flex-shrink: 0;
+  user-select: none;
+}
+
+.diff-text {
+  white-space: pre-wrap;
+  word-break: break-all;
+  padding-right: 8px;
+}
+
+.diff-same {
+  color: var(--text-secondary);
+}
+
+.diff-add {
+  background: rgba(34, 197, 94, 0.12);
+}
+
+.diff-add .diff-prefix,
+.diff-add .diff-text {
+  color: #22c55e;
+}
+
+.diff-del {
+  background: rgba(239, 68, 68, 0.12);
+}
+
+.diff-del .diff-prefix,
+.diff-del .diff-text {
+  color: #ef4444;
+}
+
+/* 面板滑入动画 */
+.slide-enter-active,
+.slide-leave-active {
+  transition: transform 0.25s ease, opacity 0.25s ease;
+}
+
+.slide-enter-from,
+.slide-leave-to {
+  transform: translateX(100%);
+  opacity: 0;
+}
 </style>

+ 293 - 38
frontend/src/views/workflow/WorkflowEditor.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { ref, computed, reactive, onMounted, markRaw } from 'vue'
+import { ref, computed, reactive, onMounted, markRaw, nextTick } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { VueFlow, useVueFlow } from '@vue-flow/core'
 import { Background } from '@vue-flow/background'
@@ -9,17 +9,19 @@ import {
 import {
   ArrowBackOutline, SaveOutline, ChatbubbleEllipsesOutline,
   SparklesOutline, RocketOutline, FlaskOutline, GitBranchOutline, ExitOutline,
-  PlayOutline, CloseOutline
+  PlayOutline, CloseOutline, ColorWandOutline
 } from '@vicons/ionicons5'
 import { useWorkflowStore } from '../../stores/workflow'
 import { useSkillStore } from '../../stores/skill'
 import { runWorkflow } from '../../api/workflow'
+import { getModelList } from '../../api/model'
 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 SmartActionNode from '../../components/workflow/nodes/SmartActionNode.vue'
 import { inferNewEdge, refreshMappingsForNode, getEdgeStyle, IO_TYPES, extractTemplateVariables } from '../../utils/ioInference'
 
 import '@vue-flow/core/dist/style.css'
@@ -64,9 +66,9 @@ const canRun = computed(() => !!workflowId && !running.value && getNodes.value.l
 
 // ========== Vue Flow ==========
 const {
-  onConnect, addEdges, addNodes, removeEdges,
+  onConnect, addEdges, addNodes, removeNodes, removeEdges,
   updateNodeData: vfUpdateNode,
-  project, vueFlowRef, getNodes, getEdges
+  project, vueFlowRef, getNodes, getEdges, fitView, onInit
 } = useVueFlow()
 
 const selectedNode = ref(null)
@@ -104,25 +106,28 @@ const nodeTypes = [
   { 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: 'smartAction', label: '智能操作', icon: markRaw(ColorWandOutline), color: '#EC4899' },
   { type: 'condition', label: '条件分支', icon: markRaw(GitBranchOutline), color: '#F97316' },
   { type: 'output', label: '输出', icon: markRaw(ExitOutline), color: '#EF4444' }
 ]
 
 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) }
+  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) },
+  smartAction: { color: '#EC4899', typeLabel: '智能操作', icon: markRaw(ColorWandOutline) },
+  condition:   { color: '#F97316', typeLabel: '条件',     icon: markRaw(GitBranchOutline) },
+  output:      { color: '#EF4444', typeLabel: '输出',     icon: markRaw(ExitOutline) }
 }
 
 function getDefaultData(type) {
   switch (type) {
     case 'userInput': return { label: '用户输入', variables: [] }
-    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 'llm': return { label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
+    case 'agent': return { label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
+    case 'skill': return { label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
+    case 'smartAction': return { label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
     case 'condition': return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
     case 'output': return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
     default: return { label: '节点' }
@@ -170,6 +175,42 @@ function onNodeClick({ node }) {
 function onPaneClick() {
   selectedNode.value = null
   selectedEdgeId.value = null
+  contextMenu.value = null
+}
+
+// ========== 节点右键菜单 ==========
+const contextMenu = ref(null) // { x, y, nodeId }
+
+function onNodeContextMenu({ event, node }) {
+  event.preventDefault()
+  contextMenu.value = { x: event.clientX, y: event.clientY, nodeId: node.id }
+}
+
+function closeContextMenu() {
+  contextMenu.value = null
+}
+
+function deleteNodeById(nodeId) {
+  // 收集关联边的上下游节点,删除后刷新映射
+  const affectedNodeIds = new Set()
+  for (const edge of getEdges.value) {
+    if (edge.source === nodeId || edge.target === nodeId) {
+      affectedNodeIds.add(edge.source)
+      affectedNodeIds.add(edge.target)
+    }
+  }
+  removeNodes(nodeId)
+  // 清除选中状态
+  if (selectedNode.value?.id === nodeId) {
+    selectedNode.value = null
+  }
+  closeContextMenu()
+  // 刷新受影响节点的边映射
+  for (const id of affectedNodeIds) {
+    if (id !== nodeId) {
+      applyEdgeMappingUpdates(refreshMappingsForNode(id, getNodes.value, getEdges.value))
+    }
+  }
 }
 
 // ========== 连接线选中与删除 ==========
@@ -196,7 +237,11 @@ function onKeyDown(e) {
     // 避免在输入框内触发删除
     const tag = e.target?.tagName
     if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
-    deleteSelectedEdge()
+    if (selectedNode.value) {
+      deleteNodeById(selectedNode.value.id)
+    } else {
+      deleteSelectedEdge()
+    }
   }
 }
 
@@ -215,13 +260,32 @@ const agentOptions = computed(() =>
   }))
 )
 
-const modelOptions = [
-  { label: 'GPT-4o', value: 'gpt-4o' },
-  { label: 'GPT-4o-mini', value: 'gpt-4o-mini' },
-  { label: 'Claude 3.5 Sonnet', value: 'claude-3.5-sonnet' },
-  { label: 'Claude 3 Opus', value: 'claude-3-opus' },
-  { label: 'DeepSeek V3', value: 'deepseek-v3' },
-  { label: 'GLM-4', value: 'glm-4' }
+// ========== 数据库模型选项 ==========
+const dbModels = ref([])
+const defaultModelId = ref(null)
+
+const modelOptions = computed(() =>
+  dbModels.value.map(m => ({
+    label: `${m.name} (${m.modelName})`,
+    value: m.id
+  }))
+)
+
+async function fetchDbModels() {
+  try {
+    const res = await getModelList()
+    dbModels.value = res.data || []
+    const defaultModel = dbModels.value.find(m => m.isDefault)
+    defaultModelId.value = defaultModel?.id || null
+  } catch (e) {
+    console.warn('加载模型列表失败', e)
+  }
+}
+
+// 失败策略选项
+const failStrategyOptions = [
+  { label: '终止工作流', value: 'abort' },
+  { label: '跳过继续', value: 'skip' }
 ]
 
 // ========== 条件分支操作 ==========
@@ -361,7 +425,7 @@ function removeAgentField(field, index) {
   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: '' }]
@@ -530,12 +594,13 @@ function closeResult() {
 }
 
 function statusText(s) {
-  return { running: '执行中', success: '成功', failed: '失败', skipped: '跳过' }[s] || s
+  return { running: '执行中', success: '成功', failed: '失败', skipped: '跳过', no_run: '未运行' }[s] || s
 }
 
 // ========== 初始化 ==========
 onMounted(async () => {
   skillStore.fetchSkills()
+  fetchDbModels()
   if (workflowId) {
     try {
       const wf = await wfStore.fetchOne(workflowId)
@@ -567,6 +632,9 @@ onMounted(async () => {
       goBack()
     }
   }
+  // 初始视图:fitView 后缩放到 0.5,让整体更小
+  await nextTick()
+  fitView({ padding: 0.5 })
 })
 </script>
 
@@ -624,11 +692,11 @@ onMounted(async () => {
       <div class="canvas-area" @dragover="onDragOver" @drop="onDrop">
         <VueFlow
           :default-viewport="{ zoom: 1, x: 0, y: 0 }"
-          :min-zoom="0.3"
+          :min-zoom="0.1"
           :max-zoom="2"
-          fit-view-on-init
           :elements-selectable="true"
           @node-click="onNodeClick"
+          @node-contextmenu="onNodeContextMenu"
           @pane-click="onPaneClick"
           @edge-click="onEdgeClick"
           :snap-to-grid="true"
@@ -640,6 +708,7 @@ onMounted(async () => {
           <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>
+          <template #node-smartAction="nodeProps"><SmartActionNode :data="nodeProps.data" /></template>
           <Background :gap="20" :size="1" pattern-color="rgba(255,255,255,0.05)" />
         </VueFlow>
       </div>
@@ -685,11 +754,12 @@ onMounted(async () => {
                 <div class="prop-section">
                   <label class="prop-label">模型</label>
                   <n-select
-                    :value="selectedData?.model"
+                    :value="selectedData?.modelId"
                     :options="modelOptions"
-                    placeholder="选择模型"
+                    placeholder="选择模型(默认使用默认模型)"
                     size="small"
-                    @update:value="v => onFieldChange('model', v)"
+                    clearable
+                    @update:value="v => onFieldChange('modelId', v)"
                   />
                 </div>
                 <div class="prop-section">
@@ -712,10 +782,10 @@ onMounted(async () => {
                     @update:value="v => onFieldChange('userPrompt', v)"
                   />
                 </div>
-                <!-- 输入变量 -->
+                <!-- 前置数据 -->
                 <div class="prop-section">
                   <div class="prop-header">
-                    <span class="prop-label">输入变量</span>
+                    <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>
@@ -751,6 +821,16 @@ onMounted(async () => {
                   </div>
                   <div v-if="!selectedData?.outputs?.length" class="empty-hint">默认输出 result 变量</div>
                 </div>
+                <!-- 失败策略 -->
+                <div class="prop-section">
+                  <label class="prop-label">失败策略</label>
+                  <n-select
+                    :value="selectedData?.failStrategy || 'abort'"
+                    :options="failStrategyOptions"
+                    size="small"
+                    @update:value="v => onFieldChange('failStrategy', v)"
+                  />
+                </div>
               </template>
 
               <!-- 智能体节点 -->
@@ -766,10 +846,21 @@ onMounted(async () => {
                     @update:value="v => onFieldChange('agentId', v)"
                   />
                 </div>
-                <!-- 输入变量 -->
+                <div class="prop-section">
+                  <label class="prop-label">模型</label>
+                  <n-select
+                    :value="selectedData?.modelId"
+                    :options="modelOptions"
+                    placeholder="默认使用默认模型"
+                    size="small"
+                    clearable
+                    @update:value="v => onFieldChange('modelId', v)"
+                  />
+                </div>
+                <!-- 前置数据 -->
                 <div class="prop-section">
                   <div class="prop-header">
-                    <span class="prop-label">输入变量</span>
+                    <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">
@@ -802,6 +893,16 @@ onMounted(async () => {
                   </div>
                   <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
                 </div>
+                <!-- 失败策略 -->
+                <div class="prop-section">
+                  <label class="prop-label">失败策略</label>
+                  <n-select
+                    :value="selectedData?.failStrategy || 'abort'"
+                    :options="failStrategyOptions"
+                    size="small"
+                    @update:value="v => onFieldChange('failStrategy', v)"
+                  />
+                </div>
               </template>
 
               <!-- 技能节点 -->
@@ -817,10 +918,21 @@ onMounted(async () => {
                     @update:value="v => onFieldChange('skillId', v)"
                   />
                 </div>
-                <!-- 输入变量 -->
+                <div class="prop-section">
+                  <label class="prop-label">模型</label>
+                  <n-select
+                    :value="selectedData?.modelId"
+                    :options="modelOptions"
+                    placeholder="默认使用默认模型"
+                    size="small"
+                    clearable
+                    @update:value="v => onFieldChange('modelId', v)"
+                  />
+                </div>
+                <!-- 前置数据 -->
                 <div class="prop-section">
                   <div class="prop-header">
-                    <span class="prop-label">输入变量</span>
+                    <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">
@@ -853,6 +965,87 @@ onMounted(async () => {
                   </div>
                   <div v-if="!selectedData?.outputs?.length" class="empty-hint">选择技能后自动填充</div>
                 </div>
+                <!-- 失败策略 -->
+                <div class="prop-section">
+                  <label class="prop-label">失败策略</label>
+                  <n-select
+                    :value="selectedData?.failStrategy || 'abort'"
+                    :options="failStrategyOptions"
+                    size="small"
+                    @update:value="v => onFieldChange('failStrategy', v)"
+                  />
+                </div>
+              </template>
+
+              <!-- 智能操作节点 -->
+              <template v-if="selectedNode?.type === 'smartAction'">
+                <div class="prop-section">
+                  <label class="prop-label">操作要求</label>
+                  <n-input
+                    :value="selectedData?.actionPrompt"
+                    type="textarea" :rows="5"
+                    placeholder="描述你希望 AI 执行的操作..."
+                    size="small"
+                    @update:value="v => onFieldChange('actionPrompt', v)"
+                  />
+                </div>
+                <div class="prop-section">
+                  <label class="prop-label">模型</label>
+                  <n-select
+                    :value="selectedData?.modelId"
+                    :options="modelOptions"
+                    placeholder="默认使用默认模型"
+                    size="small"
+                    clearable
+                    @update:value="v => onFieldChange('modelId', 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">默认输出 result 变量</div>
+                </div>
+                <!-- 失败策略 -->
+                <div class="prop-section">
+                  <label class="prop-label">失败策略</label>
+                  <n-select
+                    :value="selectedData?.failStrategy || 'abort'"
+                    :options="failStrategyOptions"
+                    size="small"
+                    @update:value="v => onFieldChange('failStrategy', v)"
+                  />
+                </div>
               </template>
 
               <!-- 条件分支节点 -->
@@ -877,7 +1070,7 @@ onMounted(async () => {
                 </div>
                 <div class="prop-section">
                   <div class="prop-header">
-                    <span class="prop-label">接收变量</span>
+                    <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">
@@ -891,7 +1084,7 @@ onMounted(async () => {
                     </div>
                   </div>
                   <div v-if="!selectedData?.inputs?.length" class="empty-hint">连接上游节点后自动推断,或手动添加</div>
-                  <div class="passthrough-hint">所有接收变量将透传至下游节点</div>
+                  <div class="passthrough-hint">所有前置数据将透传至下游节点</div>
                 </div>
               </template>
 
@@ -943,9 +1136,9 @@ onMounted(async () => {
                     <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 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>
@@ -1030,6 +1223,22 @@ onMounted(async () => {
       </div>
     </div>
 
+    <!-- 右键菜单 -->
+    <Teleport to="body">
+      <div
+        v-if="contextMenu"
+        class="context-menu-overlay"
+        @click="closeContextMenu"
+        @contextmenu.prevent="closeContextMenu"
+      >
+        <div class="context-menu" :style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
+          <button class="ctx-item ctx-danger" @click="deleteNodeById(contextMenu.nodeId)">
+            删除节点
+          </button>
+        </div>
+      </div>
+    </Teleport>
+
   </div>
 </template>
 
@@ -1291,6 +1500,7 @@ onMounted(async () => {
 .nrc-status.status-success   { background: rgba(16,185,129,0.15); color: #34d399; }
 .nrc-status.status-failed    { background: rgba(239,68,68,0.15);  color: #f87171; }
 .nrc-status.status-skipped   { background: rgba(107,114,128,0.15); color: #9ca3af; }
+.nrc-status.status-no_run    { background: rgba(107,114,128,0.10); color: #6b7280; }
 
 .nrc-vars {
   padding: 6px 10px;
@@ -1591,4 +1801,49 @@ onMounted(async () => {
   color: var(--text-tertiary, #666);
   font-size: 10px;
 }
+
+/* 右键菜单 */
+.context-menu-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 200;
+}
+
+.context-menu {
+  position: fixed;
+  background: #1e1b2e;
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  border-radius: 8px;
+  padding: 4px;
+  min-width: 140px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+}
+
+.ctx-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  padding: 8px 12px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  color: var(--text-primary, #e0e0e0);
+  font-size: 13px;
+  cursor: pointer;
+  transition: background 0.12s;
+  text-align: left;
+}
+
+.ctx-item:hover {
+  background: rgba(255, 255, 255, 0.08);
+}
+
+.ctx-item.ctx-danger {
+  color: #f87171;
+}
+
+.ctx-item.ctx-danger:hover {
+  background: rgba(239, 68, 68, 0.15);
+}
 </style>

+ 730 - 121
frontend/src/views/workflow/WorkflowList.vue

@@ -1,14 +1,27 @@
 <script setup>
-import { onMounted } from 'vue'
+import { ref, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useWorkflowStore } from '../../stores/workflow'
-import { NButton, NIcon, NPopconfirm, NEmpty, NSpin, useMessage } from 'naive-ui'
-import { AddOutline, CreateOutline, TrashOutline, GitMergeOutline } from '@vicons/ionicons5'
+import { NButton, NIcon, NPopconfirm, NEmpty, NSpin, NTag, useDialog, useMessage } from 'naive-ui'
+import { AddOutline, CreateOutline, TrashOutline, PricetagsOutline, ReloadOutline, SearchOutline } from '@vicons/ionicons5'
+import { getWfCategoryTree, createWfCategory, updateWfCategory, deleteWfCategory as deleteCategoryApi } from '../../api/wfCategory'
+import TagSelector from '../../components/tag/TagSelector.vue'
+import CategoryTree from '../../components/skill/CategoryTree.vue'
 
 const router = useRouter()
 const wfStore = useWorkflowStore()
+const dialog = useDialog()
 const message = useMessage()
 
+// 标签选择器状态
+const showTagSelector = ref(false)
+const taggingWf = ref(null)
+
+function openTagSelector(wf) {
+  taggingWf.value = wf
+  showTagSelector.value = true
+}
+
 function createAndEdit() {
   router.push('/orchestration/edit')
 }
@@ -20,107 +33,568 @@ function editWorkflow(id) {
 async function handleDelete(id) {
   try {
     await wfStore.remove(id)
+    loadCategories()
     message.success('工作流已删除')
   } catch (e) {
     message.error(e.response?.data?.message || '删除失败')
   }
 }
 
+async function onTagsSaved() {
+  await wfStore.fetchList()
+  message.success('标签已更新')
+}
+
+// ==================== 分类树 ====================
+const categories = ref([])
+const expandedIds = ref(new Set())
+const editingId = ref(null)
+const editingName = ref('')
+const addingParentId = ref(undefined)
+const addingName = ref('')
+
+async function loadCategories() {
+  try {
+    const res = await getWfCategoryTree()
+    categories.value = res.data || []
+  } catch (err) {
+    console.error('加载分类失败', err)
+  }
+}
+
+function toggleExpand(id) {
+  if (expandedIds.value.has(id)) {
+    expandedIds.value.delete(id)
+  } else {
+    expandedIds.value.add(id)
+  }
+}
+
+function selectCategory(categoryId) {
+  if (wfStore.selectedCategoryId === categoryId) {
+    wfStore.setCategory(null)
+  } else {
+    wfStore.setCategory(categoryId)
+  }
+}
+
+function startAdd(parentId) {
+  addingParentId.value = parentId
+  addingName.value = ''
+}
+
+function cancelAdd() {
+  addingParentId.value = undefined
+  addingName.value = ''
+}
+
+async function confirmAdd() {
+  if (!addingName.value.trim()) return
+  try {
+    await createWfCategory({ parentId: addingParentId.value, name: addingName.value.trim() })
+    await loadCategories()
+    if (addingParentId.value != null) {
+      expandedIds.value.add(addingParentId.value)
+    }
+    cancelAdd()
+  } catch (err) {
+    message.error(err.message || '创建失败')
+  }
+}
+
+function startEdit({ id, name }) {
+  editingId.value = id
+  editingName.value = name
+}
+
+function cancelEdit() {
+  editingId.value = null
+  editingName.value = ''
+}
+
+async function confirmEdit() {
+  if (!editingName.value.trim()) return
+  try {
+    await updateWfCategory(editingId.value, { name: editingName.value.trim() })
+    await loadCategories()
+    cancelEdit()
+  } catch (err) {
+    message.error(err.message || '更新失败')
+  }
+}
+
+function handleDeleteCategory({ id, name }) {
+  dialog.warning({
+    title: '确认删除',
+    content: `确定要删除分类「${name}」吗?该分类下的智能体将变为未分类。`,
+    positiveText: '确认删除',
+    negativeText: '取消',
+    onPositiveClick: async () => {
+      try {
+        await deleteCategoryApi(id)
+        await loadCategories()
+        if (wfStore.selectedCategoryId === id) {
+          wfStore.setCategory(null)
+        } else {
+          wfStore.fetchList()
+        }
+        message.success('删除成功')
+      } catch (err) {
+        message.error(err.message || '删除失败')
+      }
+    }
+  })
+}
+
+// ==================== 工具函数 ====================
+
+const nodeTypeColors = {
+  userInput:   '#10B981',
+  llm:         '#8B5CF6',
+  skill:       '#06B6D4',
+  agent:       '#F59E0B',
+  smartAction: '#EC4899',
+  condition:   '#F97316',
+  output:      '#EF4444'
+}
+
+function parseNodes(graphData) {
+  if (!graphData) return []
+  try {
+    const data = typeof graphData === 'string' ? JSON.parse(graphData) : graphData
+    return (data.nodes || []).map(n => ({
+      type: n.type,
+      color: nodeTypeColors[n.type] || '#64748B',
+      label: n.data?.label || n.label || n.type
+    }))
+  } catch {
+    return []
+  }
+}
+
+function formatTime(dt) {
+  if (!dt) return ''
+  return dt.replace('T', ' ').slice(0, 16)
+}
+
+// 搜索防抖
+let searchTimer = null
+function onSearchInput(value) {
+  clearTimeout(searchTimer)
+  searchTimer = setTimeout(() => {
+    wfStore.setSearch(value)
+  }, 300)
+}
+
+// ==================== 初始化 ====================
+
 onMounted(() => {
   wfStore.fetchList()
+  loadCategories()
 })
 </script>
 
 <template>
   <div class="workflow-list-page">
-    <!-- 工具栏 -->
-    <div class="page-toolbar">
-      <div class="toolbar-left">
-        <span class="toolbar-count">共 {{ wfStore.workflows.length }} 个工作流</span>
-      </div>
-      <div class="toolbar-right">
-        <button class="icon-btn" title="刷新" @click="wfStore.fetchList()">
-          <n-icon size="18"><GitMergeOutline /></n-icon>
-        </button>
-        <button class="primary-btn" @click="createAndEdit">
-          <n-icon size="16"><AddOutline /></n-icon>
-          <span>创建工作流</span>
-        </button>
-      </div>
-    </div>
+    <div class="main-layout">
+      <!-- 左侧分类树 -->
+      <div class="sidebar">
+        <div class="sidebar-header">
+          <span class="sidebar-title">分类</span>
+          <button class="icon-btn" @click="startAdd(null)" title="新建根分类">
+            <n-icon size="16"><AddOutline /></n-icon>
+          </button>
+        </div>
 
-    <!-- 加载中 -->
-    <div v-if="wfStore.loading" class="loading-area">
-      <n-spin size="medium" />
-    </div>
+        <div class="category-list">
+          <div
+            class="all-item"
+            :class="{ active: !wfStore.selectedCategoryId }"
+            @click="selectCategory(null)"
+          >
+            <span class="cat-name">全部智能体</span>
+            <span class="cat-count">{{ wfStore.total }}</span>
+          </div>
 
-    <!-- 空状态 -->
-    <div v-else-if="!wfStore.workflows.length" class="empty-area">
-      <n-empty description="暂无工作流,点击上方按钮创建">
-        <template #extra>
-          <n-button type="primary" @click="createAndEdit">创建工作流</n-button>
-        </template>
-      </n-empty>
-    </div>
+          <CategoryTree
+            :nodes="categories"
+            :level="0"
+            :selected-id="wfStore.selectedCategoryId"
+            :expanded-ids="expandedIds"
+            :editing-id="editingId"
+            v-model:editing-name="editingName"
+            :adding-parent-id="addingParentId"
+            v-model:adding-name="addingName"
+            @select="selectCategory"
+            @toggle="toggleExpand"
+            @add="startAdd"
+            @delete="handleDeleteCategory"
+            @edit-start="startEdit"
+            @edit-confirm="confirmEdit"
+            @edit-cancel="cancelEdit"
+            @add-confirm="confirmAdd"
+            @add-cancel="cancelAdd"
+          />
 
-    <!-- 列表 -->
-    <div v-else class="workflow-cards">
-      <div
-        v-for="wf in wfStore.workflows"
-        :key="wf.id"
-        class="workflow-card"
-        @click="editWorkflow(wf.id)"
-      >
-        <div class="card-icon">
-          <n-icon size="28"><GitMergeOutline /></n-icon>
+          <div v-if="addingParentId === null" class="root-adding">
+            <input
+              class="inline-input"
+              v-model="addingName"
+              placeholder="输入分类名称"
+              @keyup.enter="confirmAdd"
+              @keyup.escape="cancelAdd"
+              @blur="cancelAdd"
+              autofocus
+            />
+          </div>
         </div>
-        <div class="card-body">
-          <h3 class="card-title">{{ wf.name }}</h3>
-          <p class="card-desc" v-if="wf.description">{{ wf.description }}</p>
-          <span class="card-time">更新于 {{ wf.updatedAt?.replace('T', ' ').slice(0, 16) }}</span>
+      </div>
+
+      <!-- 右侧内容 -->
+      <div class="content">
+        <!-- 工具栏 -->
+        <div class="toolbar">
+          <div class="toolbar-left">
+            <div class="search-box">
+              <n-icon size="16" class="search-icon"><SearchOutline /></n-icon>
+              <input
+                class="search-input"
+                :value="wfStore.searchKeyword"
+                @input="onSearchInput($event.target.value)"
+                placeholder="搜索智能体名称或描述..."
+              />
+            </div>
+            <span class="skill-count">
+              共 <span class="count-num">{{ wfStore.total }}</span> 个智能体
+            </span>
+          </div>
+          <div class="toolbar-right">
+            <button class="icon-btn" title="刷新" @click="wfStore.fetchList(); loadCategories()">
+              <n-icon size="18"><ReloadOutline /></n-icon>
+            </button>
+            <button class="primary-btn" @click="createAndEdit">
+              <n-icon size="16"><AddOutline /></n-icon>
+              <span>创建智能体</span>
+            </button>
+          </div>
         </div>
-        <div class="card-actions" @click.stop>
-          <button class="icon-btn-sm" title="编辑" @click="editWorkflow(wf.id)">
-            <n-icon size="16"><CreateOutline /></n-icon>
-          </button>
-          <n-popconfirm
-            @positive-click="handleDelete(wf.id)"
-            positive-text="确定"
-            negative-text="取消"
-          >
-            <template #trigger>
-              <button class="icon-btn-sm danger" title="删除">
-                <n-icon size="16"><TrashOutline /></n-icon>
-              </button>
+
+        <!-- 加载状态 -->
+        <div v-if="wfStore.loading && wfStore.workflows.length === 0" class="loading-area">
+          <n-spin size="medium" />
+        </div>
+
+        <!-- 空状态 -->
+        <div v-else-if="!wfStore.workflows.length" class="empty-area">
+          <n-empty description="暂无智能体,点击上方按钮创建">
+            <template #extra>
+              <n-button type="primary" @click="createAndEdit">创建智能体</n-button>
             </template>
-            确定删除工作流「{{ wf.name }}」?
-          </n-popconfirm>
+          </n-empty>
+        </div>
+
+        <!-- 卡片网格 -->
+        <div v-else class="workflow-grid">
+          <div
+            v-for="(wf, index) in wfStore.workflows"
+            :key="wf.id"
+            class="workflow-card glow-border"
+            :style="{ animationDelay: `${index * 60}ms` }"
+            @click="editWorkflow(wf.id)"
+          >
+            <div class="card-glow-bg"></div>
+            <div class="card-content">
+              <div class="card-header">
+                <h3 class="card-title">{{ wf.name }}</h3>
+                <div class="card-actions">
+                  <button class="action-btn tag-btn" @click.stop="openTagSelector(wf)" title="标签">
+                    <n-icon size="14"><PricetagsOutline /></n-icon>
+                  </button>
+                  <button class="action-btn edit-btn" @click.stop="editWorkflow(wf.id)" title="编辑">
+                    <n-icon size="14"><CreateOutline /></n-icon>
+                  </button>
+                  <n-popconfirm
+                    @positive-click="handleDelete(wf.id)"
+                    positive-text="确定"
+                    negative-text="取消"
+                  >
+                    <template #trigger>
+                      <button class="action-btn delete-btn" @click.stop title="删除">
+                        <n-icon size="14"><TrashOutline /></n-icon>
+                      </button>
+                    </template>
+                    确定删除智能体「{{ wf.name }}」?
+                  </n-popconfirm>
+                </div>
+              </div>
+
+              <p v-if="wf.description" class="card-desc">{{ wf.description }}</p>
+
+              <div class="card-thumbnail">
+                <template v-if="parseNodes(wf.graphData).length">
+                  <div class="node-strip">
+                    <div
+                      v-for="(node, ni) in parseNodes(wf.graphData).slice(0, 10)"
+                      :key="ni"
+                      class="node-dot"
+                      :style="{ background: node.color }"
+                      :title="node.label"
+                    >
+                      <span class="node-initial">{{ (node.label || node.type)[0] }}</span>
+                    </div>
+                    <span v-if="parseNodes(wf.graphData).length > 10" class="node-more">
+                      +{{ parseNodes(wf.graphData).length - 10 }}
+                    </span>
+                  </div>
+                </template>
+                <div v-else class="empty-thumbnail">
+                  <span>空工作流</span>
+                </div>
+              </div>
+
+              <div v-if="wf.tags && wf.tags.length" class="card-tags">
+                <n-tag
+                  v-for="tag in wf.tags"
+                  :key="tag.tagId"
+                  size="small"
+                  :bordered="false"
+                  type="info"
+                >
+                  {{ tag.tagName }}
+                </n-tag>
+              </div>
+
+              <div class="card-footer">
+                <span class="card-time">{{ formatTime(wf.createdAt) }}</span>
+                <span class="node-count">{{ parseNodes(wf.graphData).length }} 个节点</span>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- 分页 -->
+        <div v-if="wfStore.totalPages > 1" class="pagination">
+          <button
+            class="page-btn"
+            :disabled="wfStore.currentPage <= 1"
+            @click="wfStore.setPage(wfStore.currentPage - 1)"
+          >上一页</button>
+          <template v-for="p in wfStore.totalPages" :key="p">
+            <button
+              v-if="p === 1 || p === wfStore.totalPages || Math.abs(p - wfStore.currentPage) <= 1"
+              class="page-btn"
+              :class="{ active: p === wfStore.currentPage }"
+              @click="wfStore.setPage(p)"
+            >{{ p }}</button>
+            <span v-else-if="Math.abs(p - wfStore.currentPage) === 2" class="page-ellipsis">...</span>
+          </template>
+          <button
+            class="page-btn"
+            :disabled="wfStore.currentPage >= wfStore.totalPages"
+            @click="wfStore.setPage(wfStore.currentPage + 1)"
+          >下一页</button>
         </div>
       </div>
     </div>
+
+    <!-- 标签选择器 -->
+    <TagSelector
+      v-model:show="showTagSelector"
+      entity-type="workflow"
+      :entity-id="taggingWf?.id ? String(taggingWf.id) : ''"
+      :selected-tag-ids="taggingWf?.tags?.map(t => t.tagId) || []"
+      @saved="onTagsSaved"
+    />
   </div>
 </template>
 
 <style scoped>
 .workflow-list-page {
-  height: calc(100vh - var(--header-height) - 48px);
+  min-height: 100%;
+}
+
+.main-layout {
+  display: flex;
+  gap: 24px;
+  height: 100%;
+}
+
+/* ==================== 左侧分类树 ==================== */
+.sidebar {
+  width: 240px;
+  min-width: 240px;
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-lg);
+  padding: 16px 0;
   display: flex;
   flex-direction: column;
+  overflow: hidden;
 }
 
-.page-toolbar {
+.sidebar-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px 12px;
+  border-bottom: 1px solid rgba(56, 189, 248, 0.06);
+}
+
+.sidebar-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+.category-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px 0;
+}
+
+.all-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 16px;
+  cursor: pointer;
+  transition: all 0.15s;
+  min-height: 32px;
+}
+
+.all-item:hover {
+  background: rgba(37, 99, 235, 0.06);
+}
+
+.all-item.active {
+  background: rgba(37, 99, 235, 0.12);
+}
+
+.all-item.active .cat-name {
+  color: var(--color-accent);
+  font-weight: 500;
+}
+
+.cat-name {
+  font-size: 13px;
+  color: var(--text-secondary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.cat-count {
+  font-size: 11px;
+  color: var(--text-muted);
+  background: rgba(100, 116, 139, 0.1);
+  padding: 1px 6px;
+  border-radius: 8px;
+  min-width: 20px;
+  text-align: center;
+}
+
+.icon-btn {
+  background: none;
+  border: none;
+  color: var(--text-tertiary);
+  cursor: pointer;
+  padding: 4px;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  transition: all 0.15s;
+}
+
+.icon-btn:hover {
+  color: var(--color-accent);
+  background: rgba(37, 99, 235, 0.1);
+}
+
+.root-adding {
+  padding: 6px 16px;
+}
+
+.inline-input {
+  background: rgba(37, 99, 235, 0.08);
+  border: 1px solid rgba(37, 99, 235, 0.2);
+  border-radius: 4px;
+  padding: 2px 8px;
+  font-size: 13px;
+  color: var(--text-primary);
+  outline: none;
+  width: 100%;
+  font-family: inherit;
+}
+
+.inline-input:focus {
+  border-color: var(--color-accent);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+
+/* ==================== 右侧内容 ==================== */
+.content {
+  flex: 1;
+  min-width: 0;
+}
+
+.toolbar {
   display: flex;
   align-items: center;
   justify-content: space-between;
   margin-bottom: 20px;
-  flex-shrink: 0;
 }
 
-.toolbar-count {
+.toolbar-left {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+
+.search-box {
+  position: relative;
+  width: 280px;
+}
+
+.search-icon {
+  position: absolute;
+  left: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: var(--text-muted);
+}
+
+.search-input {
+  width: 100%;
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-sm);
+  padding: 8px 12px 8px 32px;
+  font-size: 13px;
+  color: var(--text-primary);
+  outline: none;
+  transition: border-color 0.2s;
+  font-family: inherit;
+}
+
+.search-input:focus {
+  border-color: var(--color-accent);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+
+.search-input::placeholder {
+  color: var(--text-muted);
+}
+
+.skill-count {
   font-size: 14px;
   color: var(--text-secondary);
 }
 
+.count-num {
+  color: var(--color-accent);
+  font-weight: 600;
+}
+
 .toolbar-right {
   display: flex;
   align-items: center;
@@ -146,128 +620,263 @@ onMounted(() => {
 }
 
 .loading-area, .empty-area {
-  flex: 1;
   display: flex;
   align-items: center;
   justify-content: center;
+  min-height: 300px;
 }
 
-.workflow-cards {
-  flex: 1;
-  overflow-y: auto;
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
+/* 卡片网格 */
+.workflow-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+  gap: 20px;
 }
 
 .workflow-card {
-  display: flex;
-  align-items: center;
-  gap: 16px;
-  padding: 16px 20px;
+  position: relative;
+  border-radius: var(--radius-lg);
   background: var(--bg-card);
+  backdrop-filter: blur(12px);
   border: 1px solid var(--border-color);
-  border-radius: var(--radius-lg);
-  cursor: pointer;
   transition: all var(--transition-normal);
+  overflow: hidden;
+  cursor: pointer;
 }
 
 .workflow-card:hover {
+  background: var(--bg-card-hover);
   border-color: var(--border-color-hover);
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  box-shadow: var(--shadow-card-hover);
+  transform: translateY(-4px);
+}
+
+.card-glow-bg {
+  position: absolute;
+  top: -50%;
+  left: -50%;
+  width: 200%;
+  height: 200%;
+  background: radial-gradient(circle at 30% 30%, rgba(37, 99, 235, 0.06) 0%, transparent 60%);
+  pointer-events: none;
+  transition: opacity var(--transition-normal);
+  opacity: 0;
 }
 
-.card-icon {
-  width: 48px;
-  height: 48px;
+.workflow-card:hover .card-glow-bg {
+  opacity: 1;
+}
+
+.card-content {
+  position: relative;
+  padding: 24px;
   display: flex;
-  align-items: center;
-  justify-content: center;
-  background: rgba(37, 99, 235, 0.1);
-  border-radius: var(--radius-md);
-  color: var(--color-accent);
-  flex-shrink: 0;
+  flex-direction: column;
+  gap: 12px;
+  z-index: 1;
 }
 
-.card-body {
-  flex: 1;
-  min-width: 0;
+.card-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
 }
 
 .card-title {
-  font-size: 15px;
+  font-size: 16px;
   font-weight: 600;
   color: var(--text-primary);
+  line-height: 1.4;
   margin: 0;
-}
-
-.card-desc {
-  font-size: 13px;
-  color: var(--text-secondary);
-  margin: 4px 0 0;
+  flex: 1;
+  min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
 
-.card-time {
-  font-size: 12px;
-  color: var(--text-tertiary);
-  margin-top: 4px;
-  display: block;
-}
-
 .card-actions {
   display: flex;
   gap: 4px;
   opacity: 0;
   transition: opacity var(--transition-fast);
+  flex-shrink: 0;
 }
 
 .workflow-card:hover .card-actions {
   opacity: 1;
 }
 
-.icon-btn {
-  width: 32px;
-  height: 32px;
+.action-btn {
+  width: 28px;
+  height: 28px;
   display: flex;
   align-items: center;
   justify-content: center;
+  border: none;
   border-radius: var(--radius-sm);
-  border: 1px solid var(--border-color);
-  background: transparent;
-  color: var(--text-secondary);
   cursor: pointer;
   transition: all var(--transition-fast);
 }
 
-.icon-btn:hover {
+.edit-btn {
   background: rgba(37, 99, 235, 0.1);
-  color: var(--color-accent);
+  color: var(--color-primary-light);
 }
 
-.icon-btn-sm {
+.edit-btn:hover {
+  background: rgba(37, 99, 235, 0.2);
+}
+
+.delete-btn {
+  background: rgba(239, 68, 68, 0.1);
+  color: var(--color-danger);
+}
+
+.delete-btn:hover {
+  background: rgba(239, 68, 68, 0.2);
+}
+
+.tag-btn {
+  background: rgba(34, 197, 94, 0.1);
+  color: #22c55e;
+}
+
+.tag-btn:hover {
+  background: rgba(34, 197, 94, 0.2);
+}
+
+.card-desc {
+  font-size: 13px;
+  color: var(--text-secondary);
+  line-height: 1.6;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  margin: 0;
+}
+
+.card-thumbnail {
+  padding: 12px;
+  background: rgba(15, 23, 42, 0.3);
+  border-radius: var(--radius-sm);
+  border: 1px solid rgba(56, 189, 248, 0.06);
+}
+
+.node-strip {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+
+.node-dot {
   width: 28px;
   height: 28px;
+  border-radius: 50%;
   display: flex;
   align-items: center;
   justify-content: center;
-  border-radius: 4px;
-  border: none;
-  background: transparent;
+  font-size: 11px;
+  color: rgba(255, 255, 255, 0.9);
+  font-weight: 600;
+  flex-shrink: 0;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
+  transition: transform 0.15s;
+}
+
+.workflow-card:hover .node-dot {
+  transform: scale(1.05);
+}
+
+.node-initial {
+  line-height: 1;
+}
+
+.node-more {
+  font-size: 11px;
   color: var(--text-tertiary);
+  padding-left: 2px;
+}
+
+.empty-thumbnail {
+  text-align: center;
+  color: var(--text-muted);
+  font-size: 12px;
+  padding: 4px 0;
+}
+
+.card-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.card-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 4px;
+  padding-top: 12px;
+  border-top: 1px solid rgba(56, 189, 248, 0.06);
+}
+
+.card-time {
+  font-size: 12px;
+  color: var(--text-tertiary);
+}
+
+.node-count {
+  font-size: 11px;
+  color: var(--text-muted);
+  background: rgba(100, 116, 139, 0.1);
+  padding: 2px 8px;
+  border-radius: 8px;
+}
+
+/* 分页 */
+.pagination {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  margin-top: 32px;
+  padding-top: 20px;
+  border-top: 1px solid rgba(56, 189, 248, 0.06);
+}
+
+.page-btn {
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-sm);
+  padding: 6px 14px;
+  font-size: 13px;
+  color: var(--text-secondary);
   cursor: pointer;
-  transition: all var(--transition-fast);
+  transition: all 0.15s;
+  font-family: inherit;
 }
 
-.icon-btn-sm:hover {
+.page-btn:hover:not(:disabled) {
+  border-color: var(--color-accent);
   color: var(--color-accent);
-  background: rgba(37, 99, 235, 0.1);
 }
 
-.icon-btn-sm.danger:hover {
-  color: var(--color-danger);
-  background: rgba(239, 68, 68, 0.1);
+.page-btn.active {
+  background: var(--color-accent);
+  border-color: var(--color-accent);
+  color: #fff;
+}
+
+.page-btn:disabled {
+  opacity: 0.4;
+  cursor: not-allowed;
+}
+
+.page-ellipsis {
+  color: var(--text-muted);
+  padding: 0 4px;
 }
 </style>

+ 78 - 0
prompt.md

@@ -243,3 +243,81 @@ Skill编辑时,正文内容不要包含---内部的部分,以及后面的一
 
 ---
 
+增加大模型管理(作为独立菜单)、设置默认大模型功能,大模型包含URL和API Key等配置字段
+
+---
+
+增加一个“测试连接”功能吧,同时参考CC-Switch项目的功能,进行功能完善。
+
+---
+
+1. 工作流中,大模型的配置,下拉框改为“大模型管理”中已存在的模型,默认选中默认模型。
+2. 技能管理中,每个Skill的输入,默认不选中“必选”框。
+3. 工作流中,左侧新增“智能操作”节点,意思是非预置的智能体。属性表单中至少应包括“操作要求“,大块文本框类型。与其他节点一致,可选添加输入和输出字段。
+4. 工作流中,画布里的智能体节点、技能节点、智能操作节点,均增加模型选择配置,以支持简单任务路由至简单模型。
+5. 每个智能体节点、技能节点、智能操作节点、大模型处理节点,均支持配置“失败后是否终止工作流”,单选框,“终止”或“跳过”。相应地,后端执行引擎需要区分节点的运行结果,“运行成功”、“无需运行”(例如创建文件的操作,文件已存在,则无需运行,可视为成功)、“运行失败”。只有“运行失败”状态需要触发终止或跳过的判断。
+
+---
+
+我想将所有的“输入”改为“前置数据”,因为某个步骤的输入不一定是由上个节点传入的,可能是更前面的节点。是否可行?
+
+---
+
+为什么技能加载不出来了?
+后端报错:
+
+---
+
+在大模型配置时,我希望将URL直接配置到/completions,因为openai标准默认会在URL后添加/v1/chat/completions,但GLM的URL却是以/v4/chat/completions结尾的,会变成/v4/v1/chat/completions,出现问题。
+
+---
+
+软件包含标签管理功能,我希望对标签和标签组的导出格式形成一个规范,以json格式存储,一个文件即可存储多个标签组的数据。然后做一个功能,可以一键导出和一键导入,导入时,如果标签组有重名,询问是覆盖标签组、合并标签组内的标签、还是跳过该标签组。
+最后我既需要完成标签导入导出的功能,又需要出具一个标签存储和导入导出的规范,以markdown形式存储到docs中。
+
+---
+
+实现技能和智能体的打标功能,可以打0个、1个或多个标签,可以打同一个标签组内的多个标签,可以打不同标签组内的多个标签,可以打非叶子节点的标签。将这个通用的打标原则也补充到@docs/tag-import-export-spec.md规范中。(md可以改名)
+
+---
+
+你的@tag-system-spec.md中,示例未包含多级标签的情况,请补充。
+
+---
+
+将标签组和标签设计、存储、导入、导出、打标的相关内容生成一个skill。
+
+---
+
+刚刚你实现了自定义completionsPath的功能,我想问,假如说我application.yml中的相关配置发生了变化,对应大模型的配置能否也跟着变化?这样更加灵活。
+
+---
+
+帮我规划技能和智能体版本管理功能。我们简化模型,假如某技能有3个版本1,2,3,然后当前版本从3回退到2,然后又在此基础上做了修改,那么按时间顺序,版本升级到4即可。
+
+---
+
+基本可以,但高级编辑模式下,只需要在skill保存时生成版本,文件修改删除时不用。可实现版本对比(diff)、版本备注编辑,不用选择性回滚单个文件。
+
+---
+
+现在点击节点,点击右键,弹出的是浏览器自身的菜单。请实现工作流中节点的删除操作。
+
+---
+
+我希望技能可以分类管理,类型可以包含无限层级。类型直接在技能卡片左侧新增的类型树中管理即可,无需单独界面。类型创建和编辑时,输入框要和当前界面风格保持一致。
+另外,将技能的搜索和分页显示做了吧,搜索只需要一个输入框即可,可以在名称和描述中进行搜索。
+
+---
+
+智能体也做成卡片形式,除智能体名称外,显示智能体工作流缩略图(可以用带颜色的圆圈来表示对应的工作流节点,简化显示)和创建时间。
+另外,技能和智能体的标签设置功能做了没?请确认是否已实现技能和智能体的打标功能,可以打0个、1个或多个标签,可以打同一个标签组内的多个标签,可以打不同标签组内的多个标签,可以打非叶子节点的标签。
+
+---
+
+打标签时,标签显示比较奇怪,标签组名称缩进过大,标签缩进过小且行距过小;另外,标签组中新增一级标签后,打标签时不显示。请统一修复。
+另外,工作流创建时,默认缩放级别在当前基础上缩小5级吧。
+
+---
+
+智能体页面也像技能一样,新增分类管理、搜索和分页显示吧,它的分类和技能分类不互通,各自具有独立的分类。

+ 272 - 0
skills/tag-system/SKILL.md

@@ -0,0 +1,272 @@
+---
+name: tag-system
+description: 标签系统的设计、存储、导入导出和打标模式。适用于需要实现标签/分类功能的 Spring Boot + Vue 项目。
+version: 1.0.0
+source: local-code-analysis
+---
+
+# 标签系统 (Tag System)
+
+本项目的标签系统提供标签组管理、树形标签、导入导出、通用打标四大能力。
+
+## 1. 数据模型
+
+### 1.1 三表结构
+
+```
+tag_group          标签组(分类维度)
+  ├── id, name, description, sortOrder, createdAt, updatedAt
+
+tag                标签节点(自引用树,每个组有一个同名根节点 parentId=null)
+  ├── id, groupId, parentId, name, sortOrder, createdAt, updatedAt
+
+tag_assignment     打标关联(通用多对多)
+  ├── id, entityType, entityId, tagId, createdAt
+  └── 唯一约束: (entity_type, entity_id, tag_id)
+```
+
+### 1.2 关键约定
+
+- **同名根节点**:创建标签组时自动生成一个 `parentId=null` 的根标签,名称与组名相同。用户操作的标签都是根节点的子孙。
+- **扁平存储,前端建树**:`listTags` 返回扁平列表,前端 `buildTree()` 根据 `parentId` 构建树结构。
+- **导出跳过根节点**:导出时 `buildTagTree` 只输出根节点的子节点,不包含根节点本身。
+- **无 JPA 关系注解**:三张表通过逻辑外键(groupId、parentId、tagId)关联,不使用 `@OneToMany`/`@ManyToOne`。
+
+## 2. 后端代码结构
+
+```
+model/entity/
+  TagGroup.java          标签组实体 @Table(name = "tag_group")
+  Tag.java               标签节点实体 @Table(name = "tag")
+  TagAssignment.java     打标关联实体 @Table(name = "tag_assignment")
+
+model/dto/
+  TagExportDTO.java       导出格式(嵌套树结构)
+    └── TagGroupExport    { name, description, sortOrder, tags: List<TagNodeExport> }
+    └── TagNodeExport     { name, sortOrder, children: List<TagNodeExport> }  递归嵌套
+  TagImportDTO.java       导入请求 { data: TagExportDTO, strategies: Map<name, strategy> }
+  TagImportResultDTO.java 导入结果 { created, overwritten, merged, skipped, details }
+
+model/vo/
+  TagBriefVO.java         标签简要信息 { tagId, tagName, groupId, groupName }
+
+repository/
+  TagGroupRepository      findAllByOrderBySortOrderAsc
+  TagRepository           findByGroupIdOrderBySortOrderAsc, findByParentIdOrderBySortOrderAsc, deleteAllByGroupId
+  TagAssignmentRepository findByEntityTypeAndEntityId, deleteAllByEntityTypeAndEntityId
+
+service/TagService        接口
+service/impl/TagServiceImpl  实现(包含导入导出、合并算法、打标)
+
+controller/TagController  REST API(/api/tags/*)
+  └── 请求体 DTO 均定义为 static inner class(CreateGroupReq, SetTagsReq 等)
+```
+
+## 3. API 端点一览
+
+### 3.1 标签组 CRUD
+
+```
+GET    /api/tags/groups              列出所有标签组
+POST   /api/tags/groups              创建标签组
+POST   /api/tags/groups/{id}/edit    更新标签组
+POST   /api/tags/groups/{id}/delete  删除标签组
+```
+
+### 3.2 标签节点 CRUD
+
+```
+GET    /api/tags/groups/{groupId}/tags     获取该组全部标签(扁平列表)
+POST   /api/tags/groups/{groupId}/tags     创建标签节点
+POST   /api/tags/{id}/edit                 重命名标签
+POST   /api/tags/{id}/delete               删除标签(递归删除子孙)
+POST   /api/tags/{id}/move                 移动标签(含防循环检测)
+```
+
+### 3.3 导入导出
+
+```
+GET    /api/tags/export     导出所有标签组(嵌套树 JSON)
+POST   /api/tags/import     导入标签(支持 overwrite/merge/skip 策略)
+```
+
+### 3.4 打标
+
+```
+POST   /api/tags/assignments              设置实体标签(全量替换)
+GET    /api/tags/assignments?entityType=&entityId=  获取实体标签
+GET    /api/tags/all-with-tags            所有标签组+标签(标签选择器用)
+```
+
+### 3.5 设计约定
+
+- **写操作用 POST**(非 RESTful DELETE/PUT),保持项目统一风格。
+- **请求体定义为 Controller 内部 static class**,使用 `@Data`(Lombok)。
+
+## 4. 导入导出规范
+
+### 4.1 导出格式
+
+```json
+{
+  "version": "1.0",
+  "exportedAt": "2026-06-11T14:30:00",
+  "groups": [
+    {
+      "name": "标签组名",
+      "description": "可选",
+      "sortOrder": 0,
+      "tags": [
+        {
+          "name": "标签名",
+          "sortOrder": 0,
+          "children": [ { "name": "子标签", "children": [] } ]
+        }
+      ]
+    }
+  ]
+}
+```
+
+- 导出数据不包含数据库 ID,以 `name` 作为匹配键。
+- `tags` 是根节点的子节点,不包含标签组同名根节点。
+
+### 4.2 导入策略
+
+| 策略 | 值 | 行为 |
+|------|---|------|
+| 新建 | `create` | 无冲突时自动新建 |
+| 覆盖 | `overwrite` | 删除旧组+旧标签,用导入数据重建 |
+| 合并 | `merge` | 递归按名称匹配:同名保留+递归子节点,新名追加 |
+| 跳过 | `skip` | 不做任何修改 |
+
+### 4.3 合并算法
+
+```
+对于导入标签树的每一层节点:
+  1. 在目标标签组的同级子节点中查找同名标签
+  2. 找到 → 保留该标签,递归处理其子节点
+  3. 未找到 → 创建新标签节点
+```
+
+幂等安全:同名标签不会重复创建。
+
+## 5. 打标规范
+
+### 5.1 打标原则
+
+1. **多标签**:一个实体可以打 0~N 个标签
+2. **同组多标**:同一标签组内可选多个标签
+3. **跨组打标**:可选择不同标签组的标签
+4. **非叶子可标**:树形标签中任意层级节点都可选中
+5. **全量替换**:每次保存提交完整 tagIds 列表,后端先删旧关联再建新关联
+
+### 5.2 实现
+
+```java
+// TagServiceImpl.setTags()
+assignmentRepo.deleteAllByEntityTypeAndEntityId(entityType, entityId);
+for (Long tagId : tagIds) {
+    // 逐条插入
+}
+```
+
+### 5.3 实体类型
+
+| entityType | entityId 来源 | 说明 |
+|------------|--------------|------|
+| `skill` | folderName | 技能(文件系统存储,无数据库 ID) |
+
+新增实体类型只需在调用方传入不同的 `entityType` 和 `entityId`,无需后端改动。
+
+## 6. 前端架构
+
+```
+api/tag.js                    所有标签 API 封装
+stores/tag.js                 Pinia store(groups, activeGroupId, currentTags, buildTree)
+components/tag/
+  TreeNodeItem.vue            递归树节点(编辑模式)
+  TagSelector.vue             标签选择器弹窗(打标模式)
+views/TagManagement.vue       标签管理页面(左右分栏)
+```
+
+### 6.1 TagSelector 组件使用方式
+
+```vue
+<TagSelector
+  v-model:show="showTagSelector"
+  entity-type="skill"
+  :entity-id="skill.folderName"
+  :selected-tag-ids="skill.tags?.map(t => t.tagId) || []"
+  @saved="onTagsSaved"
+/>
+```
+
+### 6.2 标签选择器交互
+
+1. 弹窗打开时调用 `getAllTagsForSelector()` 加载所有标签
+2. 树形浏览:按标签组展开/折叠,checkbox 勾选任意层级节点
+3. 搜索模式:关键词过滤标签名和组名
+4. 已选标签在顶部展示为可移除 Tag
+5. 确认后调用 `setEntityTags()` 全量替换,触发 `@saved` 回调
+
+### 6.3 buildTree 算法
+
+```javascript
+// 将扁平 parentId 列表构建为嵌套树
+function buildTree() {
+  const map = {}
+  const roots = []
+  currentTags.value.forEach(t => { map[t.id] = { ...t, children: [] } })
+  currentTags.value.forEach(t => {
+    if (t.parentId == null) roots.push(map[t.id])
+    else if (map[t.parentId]) map[t.parentId].children.push(map[t.id])
+  })
+  return roots
+}
+```
+
+## 7. 关键实现细节
+
+### 7.1 根节点处理
+
+标签组的根节点是隐式的(`parentId=null`,名称等于组名):
+- **CRUD**:前端在根节点的 children 上操作
+- **导出**:`buildTagTree` 跳过根节点,直接输出其子节点
+- **导入**:`importCreate` 找到自动创建的根节点,将导入的标签作为其子节点插入
+
+### 7.2 防循环移动
+
+```java
+// TagServiceImpl.isDescendant()
+// 检查 targetId 是否是 ancestorId 的子孙,防止 moveTag 形成环
+while (current != null) {
+    if (current.equals(ancestorId)) return true;
+    // 向上遍历 parentId 链
+}
+```
+
+### 7.3 Skill 列表携带标签
+
+```java
+// SkillController.toVOWithCache()
+vo.setTags(tagService.getEntityTags("skill", dto.getFolderName()));
+```
+
+Skill 无数据库实体,使用 `folderName` 作为 `entityId`。
+
+## 8. 扩展指南
+
+### 新增业务对象打标
+
+1. 前端在对应页面引入 `TagSelector` 组件
+2. 传入 `entityType="your-type"` 和 `entityId="your-id"`
+3. 无需后端改动,通用 API 自动支持
+
+### 新增标签组级字段
+
+1. 在 `TagGroup` 实体添加字段
+2. 更新 `CreateGroupReq` / `UpdateGroupReq`
+3. 更新 `TagGroupExport` 和 `importCreate` / `importOverwrite`
+4. 更新前端 `TagManagement.vue` 表单
+5. 更新 `docs/tag-system-spec.md` 格式规范