package handler import ( "archive/zip" "fmt" "io" "log" "net/http" "os" "path/filepath" "regexp" "strings" "x-agents/server/internal/model" "x-agents/server/internal/service" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // SkillHandler 技能处理器 type SkillHandler struct { skillService *service.SkillService } // NewSkillHandler 创建技能处理器 func NewSkillHandler(skillService *service.SkillService) *SkillHandler { return &SkillHandler{skillService: skillService} } // List 获取技能列表 // @Summary 获取技能列表 // @Description 获取所有技能列表,支持按类型筛选(system/user) // @Tags 技能管理 // @Accept json // @Produce json // @Param type query string false "技能类型: system(系统技能)/user(用户技能)" // @Success 200 {object} map[string]interface{} "{"list": [], "total": 0}" // @Router /skill/list [get] func (h *SkillHandler) List(c *gin.Context) { skillType := c.Query("type") var skills []model.Skill var err error if skillType != "" { skills, err = h.skillService.GetSkillsByType(skillType) } else { skills, err = h.skillService.GetAllSkills() } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"list": skills, "total": len(skills)}) } // Sync 手动同步 skills // @Summary 手动同步技能 // @Description 从文件系统扫描 skills 目录并同步到数据库。扫描 account/admin/skills(系统技能) 和 account/{username}/skills(用户技能) // @Tags 技能管理 // @Accept json // @Produce json // @Success 200 {object} map[string]interface{} "{"message": "skills synced", "count": 0}" // @Router /skill/sync [get] func (h *SkillHandler) Sync(c *gin.Context) { if err := h.skillService.InitSkills(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } skills, _ := h.skillService.GetAllSkills() c.JSON(http.StatusOK, gin.H{"message": "skills synced", "count": len(skills)}) } // GetByID 根据ID获取技能 // @Summary 获取技能详情 // @Description 根据ID获取技能详情 // @Tags 技能管理 // @Accept json // @Produce json // @Param id path string true "技能ID" // @Success 200 {object} map[string]interface{} "{"skill": {}}" // @Router /skill/{id} [get] func (h *SkillHandler) GetByID(c *gin.Context) { id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "skill id is required"}) return } skill, err := h.skillService.GetSkillByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) return } c.JSON(http.StatusOK, gin.H{"skill": skill}) } // Create 创建技能 // @Summary 创建技能 // @Description 创建新的技能,支持文件上传。管理员用户(admin)上传为system技能,存到core/agents/skills/system/;其他用户上传为user技能,存到core/agents/skills/user/ // @Tags 技能管理 // @Accept multipart/form-data // @Produce json // @Param skill_name formData string true "技能名称" // @Param skill_desc formData string false "技能描述" // @Param skill_type formData string false "技能类型(system/user),不传则根据用户角色自动判断" // @Param file formData file false "技能文件(SKILL.md)" // @Success 200 {object} map[string]interface{} "{"message": "skill created", "skill": {}}" // @Router /skill/add [post] func (h *SkillHandler) Create(c *gin.Context) { // 获取当前用户信息 username, exists := c.Get("username") if !exists { username = "" } userID, _ := c.Get("userID") // skillName 和 skillDesc 改为可选,如果没传则从文件内容解析 // 先从前端获取,但如果上传了文件则以文件内容解析的为准 skillName := c.PostForm("skill_name") skillDesc := c.PostForm("skill_desc") skillType := c.PostForm("skill_type") // 记录原始输入(用于调试) originalSkillName := skillName // 确定技能类型:优先使用传入的type,否则根据用户角色判断 if skillType == "" { if username == "admin" { skillType = "system" } else { skillType = "user" } } // 获取项目根目录 projectRoot := h.getProjectRoot() if projectRoot == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot find project root"}) return } // 根据技能类型确定存储路径 skillDir := "user" if skillType == "system" { skillDir = "system" } // 用于存储最终路径 var skillPath string // 处理文件上传 file, err := c.FormFile("file") if err == nil { // 重新打开文件以便多次读取 fileContent, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file: " + err.Error()}) return } defer fileContent.Close() // 检测是否是 zip 文件 isZip := strings.HasSuffix(strings.ToLower(file.Filename), ".zip") var contentStr string if isZip { // 解压 zip 文件 log.Printf("[SkillHandler] Processing ZIP file: %s", file.Filename) // 读取整个 zip 内容 zipData, err := io.ReadAll(fileContent) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read zip file: " + err.Error()}) return } // 创建临时 zip reader zipReader, err := zip.NewReader(strings.NewReader(string(zipData)), int64(len(zipData))) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse zip file: " + err.Error()}) return } // 先创建技能目录(使用传入的 skill_name 或从 zip 文件名推断) if skillName == "" { skillName = strings.TrimSuffix(filepath.Base(file.Filename), ".zip") } skillName = strings.TrimSpace(skillName) skillPath = filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skillName) if err := os.MkdirAll(skillPath, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create skill directory: " + err.Error()}) return } // 解压所有文件 for _, zipFile := range zipReader.File { fileName := zipFile.Name // 跳过目录 if strings.HasSuffix(fileName, "/") { continue } // 确保文件路径安全,去除任何绝对路径或路径遍历 fileName = strings.TrimPrefix(fileName, "/") if strings.Contains(fileName, "..") { continue } targetPath := filepath.Join(skillPath, fileName) // 创建父目录 parentDir := filepath.Dir(targetPath) if err := os.MkdirAll(parentDir, 0755); err != nil { log.Printf("[SkillHandler] Warning: failed to create directory %s: %v", parentDir, err) continue } // 读取并解压文件 zipFileContent, err := zipFile.Open() if err != nil { log.Printf("[SkillHandler] Warning: failed to open %s in zip: %v", fileName, err) continue } content, err := io.ReadAll(zipFileContent) zipFileContent.Close() if err != nil { log.Printf("[SkillHandler] Warning: failed to read %s from zip: %v", fileName, err) continue } if err := os.WriteFile(targetPath, content, 0644); err != nil { log.Printf("[SkillHandler] Warning: failed to write %s: %v", targetPath, err) continue } log.Printf("[SkillHandler] Extracted: %s", targetPath) // 如果是 SKILL.md,解析元数据 if strings.HasSuffix(strings.ToLower(fileName), "skill.md") { contentStr = string(content) } } // 如果没有找到 SKILL.md,尝试找其他 .md 文件 if contentStr == "" { files, _ := filepath.Glob(filepath.Join(skillPath, "*.md")) if len(files) > 0 { content, err := os.ReadFile(files[0]) if err == nil { contentStr = string(content) } } } // 解析元数据 if contentStr != "" { parsedName, parsedDesc := parseSkillMeta(contentStr) if parsedName != "" { // 如果从 zip 中解析到 skill name,可能需要重命名目录 if parsedName != skillName { newSkillPath := filepath.Join(projectRoot, "core", "agents", "skills", skillDir, parsedName) if err := os.Rename(skillPath, newSkillPath); err != nil { log.Printf("[SkillHandler] Warning: failed to rename skill directory: %v", err) } else { skillPath = newSkillPath } skillName = parsedName } } if parsedDesc != "" { skillDesc = parsedDesc } } log.Printf("[SkillHandler] ZIP imported successfully: %s", skillName) } else { // 普通 md 文件处理(原有逻辑) content := make([]byte, 1024*1024) // 最多读取 1MB n, _ := fileContent.Read(content) contentStr = string(content[:n]) // 解析 name parsedName, parsedDesc := parseSkillMeta(contentStr) log.Printf("[SkillHandler] Original skill_name from form: %s, parsed name from file: %s", originalSkillName, parsedName) // 优先使用文件解析出的 name if parsedName != "" { skillName = parsedName } else if skillName == "" { // 如果解析不到且表单也没传,用文件名 skillName = filepath.Base(file.Filename) } if parsedDesc != "" { skillDesc = parsedDesc } // 清理 skillName,只保留纯文件名,去除所有路径 skillName = filepath.Base(skillName) // 去除 .md 后缀 skillName = strings.TrimSuffix(skillName, ".md") // 去除空格 skillName = strings.TrimSpace(skillName) // 如果包含路径分隔符,取最后一部分 if idx := strings.LastIndexAny(skillName, "/\\"); idx >= 0 { skillName = skillName[idx+1:] } log.Printf("[SkillHandler] Final skill name: %s", skillName) // 创建技能目录 skillPath = filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skillName) if err := os.MkdirAll(skillPath, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create skill directory: " + err.Error()}) return } // 保存文件(使用之前读取的内容) skillFilePath := filepath.Join(skillPath, "SKILL.md") if err := os.WriteFile(skillFilePath, []byte(contentStr), 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save skill file: " + err.Error()}) return } } } else { // 如果没有上传文件但提供了 name 和 desc,创建默认文件 if skillName == "" || skillDesc == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "skill_name and skill_desc are required when no file uploaded"}) return } // 清理 skillName skillName = filepath.Base(skillName) skillName = strings.TrimSuffix(skillName, ".md") skillName = strings.TrimSpace(skillName) if idx := strings.LastIndexAny(skillName, "/\\"); idx >= 0 { skillName = skillName[idx+1:] } skillPath = filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skillName) if err := os.MkdirAll(skillPath, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create skill directory: " + err.Error()}) return } defaultContent := "---\nname: " + skillName + "\ndescription: " + skillDesc + "\n---\n\n# " + skillName + "\n\n" + skillDesc skillFilePath := filepath.Join(skillPath, "SKILL.md") if err := os.WriteFile(skillFilePath, []byte(defaultContent), 0644); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create skill file: " + err.Error()}) return } } // 创建技能记录 skill := &model.Skill{ ID: uuid.New().String(), SkillName: skillName, SkillType: skillType, SkillDesc: skillDesc, Path: filepath.Join(skillPath, "SKILL.md"), Status: "active", } // 记录创建者 if userIDStr, ok := userID.(string); ok { skill.CreatedBy = userIDStr } if err := h.skillService.CreateSkill(skill); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "skill created", "skill": skill}) } // parseSkillMeta 解析 SKILL.md 内容,提取 name 和 description func parseSkillMeta(content string) (name string, description string) { contentStr := strings.TrimSpace(content) if !strings.HasPrefix(contentStr, "---") { return "", "" } // 找到第二个 --- lines := strings.Split(contentStr, "\n") var endIdx int for i := 1; i < len(lines); i++ { if strings.TrimSpace(lines[i]) == "---" { endIdx = i break } } if endIdx == 0 { return "", "" } // 提取 YAML 内容 yamlContent := strings.Join(lines[1:endIdx], "\n") // 解析 name nameMatch := regexp.MustCompile(`name:\s*(.+)`).FindStringSubmatch(yamlContent) if len(nameMatch) > 1 { name = strings.TrimSpace(nameMatch[1]) } // 解析 description descMatch := regexp.MustCompile(`description:\s*(.+)`).FindStringSubmatch(yamlContent) if len(descMatch) > 1 { description = strings.TrimSpace(descMatch[1]) } return name, description } // getProjectRoot 获取项目根目录 func (h *SkillHandler) getProjectRoot() string { execPath, _ := os.Getwd() projectRoot := execPath // 如果当前目录名为 server,向上找一级 baseName := filepath.Base(execPath) if baseName == "server" { projectRoot = filepath.Dir(execPath) } // 尝试向上查找包含 .git 的目录 if _, err := os.Stat(filepath.Join(projectRoot, ".git")); os.IsNotExist(err) { for i := 0; i < 3; i++ { parent := filepath.Dir(projectRoot) if parent == projectRoot { break } if _, err := os.Stat(filepath.Join(parent, ".git")); err == nil { projectRoot = parent break } projectRoot = parent } } return projectRoot } // Update 更新技能 // @Summary 更新技能 // @Description 更新技能信息 // @Tags 技能管理 // @Accept json // @Produce json // @Param id path string true "技能ID" // @Param skill body model.Skill true "技能信息" // @Success 200 {object} map[string]interface{} "{"message": "skill updated"}" // @Router /skill/{id} [put] // Update 更新技能 // @Summary 更新技能 // @Description 更新技能信息,支持文件上传 // @Tags 技能管理 // @Accept multipart/form-data // @Produce json // @Param id path string true "技能ID" // @Param skill_name formData string false "技能名称" // @Param skill_desc formData string false "技能描述" // @Param skill_type formData string false "技能类型" // @Param file formData file false "技能文件(SKILL.md)" // @Success 200 {object} map[string]interface{} "{"message": "skill updated"}" // @Router /skill/{id} [put] func (h *SkillHandler) Update(c *gin.Context) { id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "skill id is required"}) return } // 获取现有技能信息 existingSkill, err := h.skillService.GetSkillByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) return } // 支持 JSON 格式的请求 var req struct { SkillName string `json:"skill_name"` SkillDesc string `json:"skill_desc"` SkillType string `json:"skill_type"` Status string `json:"status"` } // 尝试解析 JSON,如果失败则用 PostForm if err := c.ShouldBindJSON(&req); err != nil { req.SkillName = c.PostForm("skill_name") req.SkillDesc = c.PostForm("skill_desc") req.SkillType = c.PostForm("skill_type") req.Status = c.PostForm("status") } // 如果没有传则使用现有值 skillName := req.SkillName skillDesc := req.SkillDesc skillType := req.SkillType var status string if skillName == "" { skillName = existingSkill.SkillName } if skillDesc == "" { skillDesc = existingSkill.SkillDesc } if skillType == "" { skillType = existingSkill.SkillType } // 处理 status:如果是空字符串则使用现有值,否则使用传入的值 if req.Status == "" { status = existingSkill.Status } else { // 支持传入数字或字符串 if req.Status == "1" || req.Status == "active" { status = "active" } else if req.Status == "0" || req.Status == "inactive" { status = "inactive" } else { status = existingSkill.Status } } // 获取项目根目录 projectRoot := h.getProjectRoot() if projectRoot == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot find project root"}) return } // 如果技能名称改变了,需要重命名文件夹 oldPath := existingSkill.Path if skillName != existingSkill.SkillName && oldPath != "" { // 旧目录 oldDir := filepath.Dir(oldPath) // 新目录 skillDir := "user" if skillType == "system" { skillDir = "system" } newDir := filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skillName) // 重命名目录 if err := os.Rename(oldDir, newDir); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to rename skill directory: " + err.Error()}) return } // 更新路径 existingSkill.Path = filepath.Join(newDir, "SKILL.md") } // 处理文件上传 file, err := c.FormFile("file") if err == nil { // 保存上传的文件为 SKILL.md skillFilePath := filepath.Join(filepath.Dir(existingSkill.Path), "SKILL.md") if err := c.SaveUploadedFile(file, skillFilePath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save skill file: " + err.Error()}) return } } // 更新技能信息 existingSkill.SkillName = skillName existingSkill.SkillDesc = skillDesc existingSkill.SkillType = skillType existingSkill.Status = status if err := h.skillService.UpdateSkill(existingSkill); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "skill updated", "skill": existingSkill}) } // GetSkillContent 获取技能文件内容 // @Summary 获取技能文件内容 // @Description 获取指定技能对应的 SKILL.md 文件内容 // @Tags 技能管理 // @Accept json // @Produce text/plain // @Param id path string true "技能ID" // @Success 200 {string} string "文件内容" // @Router /skill/content [get] func (h *SkillHandler) GetSkillContent(c *gin.Context) { id := c.Query("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "skill id is required"}) return } // 获取技能信息 skill, err := h.skillService.GetSkillByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"}) return } // 确定文件路径 filePath := skill.Path if filePath == "" { // 如果 path 为空,根据 skill_name 和 skill_type 构造默认路径 projectRoot := h.getProjectRoot() if projectRoot != "" { skillDir := "user" if skill.SkillType == "system" { skillDir = "system" } filePath = filepath.Join(projectRoot, "core", "agents", "skills", skillDir, skill.SkillName, "SKILL.md") fmt.Printf("GetSkillContent: path is empty, constructed path: %s\n", filePath) } } // 如果仍然没有路径,返回错误 if filePath == "" { c.JSON(http.StatusNotFound, gin.H{"error": "skill path is empty"}) return } // 检查文件是否存在 if _, err := os.Stat(filePath); os.IsNotExist(err) { fmt.Printf("GetSkillContent error: file not found, path=%s\n", filePath) c.JSON(http.StatusNotFound, gin.H{"error": "skill file not found: " + filePath}) return } // 读取文件内容 content, err := os.ReadFile(filePath) if err != nil { fmt.Printf("GetSkillContent error: read file failed, path=%s, err=%s\n", filePath, err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) return } fmt.Printf("GetSkillContent success: id=%s, path=%s, size=%d\n", id, filePath, len(content)) c.Data(http.StatusOK, "text/plain; charset=utf-8", content) } // Delete 删除技能 // @Summary 删除技能 // @Description 删除技能 // @Tags 技能管理 // @Accept json // @Produce json // @Param id path string true "技能ID" // @Success 200 {object} map[string]interface{} "{"message": "skill deleted"}" // @Router /skill/{id} [delete] func (h *SkillHandler) Delete(c *gin.Context) { id := c.Param("id") if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "skill id is required"}) return } if err := h.skillService.DeleteSkill(id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "skill deleted"}) }