Files
X-Agents/server/internal/handler/skill_handler.go
DESKTOP-72TV0V4\caoxiaozhu 7795685f43 feat: 更新后端agent和skill服务
- agent_handler.go: 新增ListAgents、CreateAgent接口
- skill_handler.go: 更新skill内容获取和保存功能
- agent_service.go: 新增agent服务逻辑
- main.go: 新增agent路由

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:16:53 +08:00

544 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"fmt"
"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 {
// 读取文件内容,解析 YAML front matter 获取 name
fileContent, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file: " + err.Error()})
return
}
defer fileContent.Close()
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
status := req.Status
if skillName == "" {
skillName = existingSkill.SkillName
}
if skillDesc == "" {
skillDesc = existingSkill.SkillDesc
}
if skillType == "" {
skillType = existingSkill.SkillType
}
if status == "" {
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"})
}