2026-03-11 16:25:48 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-13 14:33:54 +08:00
|
|
|
|
"archive/zip"
|
2026-03-12 16:42:33 +08:00
|
|
|
|
"fmt"
|
2026-03-13 14:33:54 +08:00
|
|
|
|
"io"
|
2026-03-12 17:16:53 +08:00
|
|
|
|
"log"
|
2026-03-11 16:25:48 +08:00
|
|
|
|
"net/http"
|
2026-03-12 15:23:22 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
2026-03-12 17:16:53 +08:00
|
|
|
|
"regexp"
|
|
|
|
|
|
"strings"
|
2026-03-11 16:25:48 +08:00
|
|
|
|
|
|
|
|
|
|
"x-agents/server/internal/model"
|
|
|
|
|
|
"x-agents/server/internal/service"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
2026-03-12 15:23:22 +08:00
|
|
|
|
"github.com/google/uuid"
|
2026-03-11 16:25:48 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 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 创建技能
|
2026-03-12 15:23:22 +08:00
|
|
|
|
// @Description 创建新的技能,支持文件上传。管理员用户(admin)上传为system技能,存到core/agents/skills/system/;其他用户上传为user技能,存到core/agents/skills/user/
|
2026-03-11 16:25:48 +08:00
|
|
|
|
// @Tags 技能管理
|
2026-03-12 15:23:22 +08:00
|
|
|
|
// @Accept multipart/form-data
|
2026-03-11 16:25:48 +08:00
|
|
|
|
// @Produce json
|
2026-03-12 15:23:22 +08:00
|
|
|
|
// @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)"
|
2026-03-11 16:25:48 +08:00
|
|
|
|
// @Success 200 {object} map[string]interface{} "{"message": "skill created", "skill": {}}"
|
|
|
|
|
|
// @Router /skill/add [post]
|
|
|
|
|
|
func (h *SkillHandler) Create(c *gin.Context) {
|
2026-03-12 15:23:22 +08:00
|
|
|
|
// 获取当前用户信息
|
|
|
|
|
|
username, exists := c.Get("username")
|
|
|
|
|
|
if !exists {
|
|
|
|
|
|
username = ""
|
|
|
|
|
|
}
|
|
|
|
|
|
userID, _ := c.Get("userID")
|
|
|
|
|
|
|
2026-03-12 17:16:53 +08:00
|
|
|
|
// skillName 和 skillDesc 改为可选,如果没传则从文件内容解析
|
|
|
|
|
|
// 先从前端获取,但如果上传了文件则以文件内容解析的为准
|
2026-03-12 15:23:22 +08:00
|
|
|
|
skillName := c.PostForm("skill_name")
|
|
|
|
|
|
skillDesc := c.PostForm("skill_desc")
|
|
|
|
|
|
skillType := c.PostForm("skill_type")
|
|
|
|
|
|
|
2026-03-12 17:16:53 +08:00
|
|
|
|
// 记录原始输入(用于调试)
|
|
|
|
|
|
originalSkillName := skillName
|
2026-03-12 15:23:22 +08:00
|
|
|
|
|
|
|
|
|
|
// 确定技能类型:优先使用传入的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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:16:53 +08:00
|
|
|
|
// 用于存储最终路径
|
|
|
|
|
|
var skillPath string
|
2026-03-11 16:25:48 +08:00
|
|
|
|
|
2026-03-12 15:23:22 +08:00
|
|
|
|
// 处理文件上传
|
|
|
|
|
|
file, err := c.FormFile("file")
|
|
|
|
|
|
if err == nil {
|
2026-03-13 14:33:54 +08:00
|
|
|
|
// 重新打开文件以便多次读取
|
2026-03-12 17:16:53 +08:00
|
|
|
|
fileContent, err := file.Open()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file: " + err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer fileContent.Close()
|
|
|
|
|
|
|
2026-03-13 14:33:54 +08:00
|
|
|
|
// 检测是否是 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
|
|
|
|
|
|
}
|
2026-03-12 15:23:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-03-12 17:16:53 +08:00
|
|
|
|
// 如果没有上传文件但提供了 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 15:23:22 +08:00
|
|
|
|
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"),
|
2026-03-13 08:31:57 +08:00
|
|
|
|
Status: "active",
|
2026-03-12 15:23:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 记录创建者
|
|
|
|
|
|
if userIDStr, ok := userID.(string); ok {
|
|
|
|
|
|
skill.CreatedBy = userIDStr
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := h.skillService.CreateSkill(skill); err != nil {
|
2026-03-11 16:25:48 +08:00
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "skill created", "skill": skill})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:16:53 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 15:23:22 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-11 16:25:48 +08:00
|
|
|
|
// 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]
|
2026-03-12 16:42:33 +08:00
|
|
|
|
// 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]
|
2026-03-11 16:25:48 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 16:42:33 +08:00
|
|
|
|
// 获取现有技能信息
|
|
|
|
|
|
existingSkill, err := h.skillService.GetSkillByID(id)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "skill not found"})
|
2026-03-11 16:25:48 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 17:16:53 +08:00
|
|
|
|
// 支持 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")
|
|
|
|
|
|
}
|
2026-03-12 16:42:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 如果没有传则使用现有值
|
2026-03-12 17:16:53 +08:00
|
|
|
|
skillName := req.SkillName
|
|
|
|
|
|
skillDesc := req.SkillDesc
|
|
|
|
|
|
skillType := req.SkillType
|
2026-03-13 08:31:57 +08:00
|
|
|
|
var status string
|
2026-03-12 17:16:53 +08:00
|
|
|
|
|
2026-03-12 16:42:33 +08:00
|
|
|
|
if skillName == "" {
|
|
|
|
|
|
skillName = existingSkill.SkillName
|
|
|
|
|
|
}
|
|
|
|
|
|
if skillDesc == "" {
|
|
|
|
|
|
skillDesc = existingSkill.SkillDesc
|
|
|
|
|
|
}
|
|
|
|
|
|
if skillType == "" {
|
|
|
|
|
|
skillType = existingSkill.SkillType
|
|
|
|
|
|
}
|
2026-03-13 08:31:57 +08:00
|
|
|
|
// 处理 status:如果是空字符串则使用现有值,否则使用传入的值
|
2026-03-12 23:18:46 +08:00
|
|
|
|
if req.Status == "" {
|
2026-03-12 17:16:53 +08:00
|
|
|
|
status = existingSkill.Status
|
2026-03-12 23:18:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 支持传入数字或字符串
|
|
|
|
|
|
if req.Status == "1" || req.Status == "active" {
|
2026-03-13 08:31:57 +08:00
|
|
|
|
status = "active"
|
2026-03-12 23:18:46 +08:00
|
|
|
|
} else if req.Status == "0" || req.Status == "inactive" {
|
2026-03-13 08:31:57 +08:00
|
|
|
|
status = "inactive"
|
2026-03-12 23:18:46 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
status = existingSkill.Status
|
|
|
|
|
|
}
|
2026-03-12 17:16:53 +08:00
|
|
|
|
}
|
2026-03-12 16:42:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取项目根目录
|
|
|
|
|
|
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
|
2026-03-12 17:16:53 +08:00
|
|
|
|
existingSkill.Status = status
|
2026-03-12 16:42:33 +08:00
|
|
|
|
|
|
|
|
|
|
if err := h.skillService.UpdateSkill(existingSkill); err != nil {
|
2026-03-11 16:25:48 +08:00
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 16:42:33 +08:00
|
|
|
|
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)
|
2026-03-11 16:25:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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"})
|
|
|
|
|
|
}
|