package server import ( "errors" "net/http" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "code.littlelan.cn/CarrotAssistant/backend/internal/skill" "code.littlelan.cn/CarrotAssistant/backend/internal/store" ) // skillResponse is what GET /skills/:id returns: the DB metadata merged with // the parsed markdown content (frontmatter + body). type skillResponse struct { store.Skill Content *skill.Skill `json:"content"` } // GET /admin/apps/:id/skills func (d Deps) handleListSkills(c *gin.Context) { appID := c.Param("id") var skills []store.Skill d.DB.Where("app_id = ?", appID).Order("id DESC").Find(&skills) c.JSON(http.StatusOK, skills) } // GET /admin/skills/:id func (d Deps) handleGetSkill(c *gin.Context) { row, ok := d.loadSkill(c) if !ok { return } mgr := skill.NewManager(d.SkillsDir) content, err := mgr.Load(row) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "读取 skill 文件失败: " + err.Error()}) return } c.JSON(http.StatusOK, skillResponse{Skill: *row, Content: content}) } type generateReq struct { SourceType string `json:"source_type" binding:"required"` // openapi | manual | markdown | source OpenAPI string `json:"openapi"` BaseURL string `json:"base_url"` Manual skill.ManualInput `json:"manual"` } // POST /admin/apps/:id/skills/generate — runs the importer for source_type, // synthesises a draft skill, writes it to disk, and creates a draft row. func (d Deps) handleGenerateSkill(c *gin.Context) { a, ok := d.loadApp(c) if !ok { return } var req generateReq if !bind(c, &req) { return } candidates, err := d.importCandidates(req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Synthesise. Refiner is nil for now (Phase 3 adds the LLM refiner); the // deterministic draft still produces a usable skill. sk, err := skill.Synthesize(skill.SynthesizeInput{ AppName: a.Name, AppDescription: a.Description, Source: req.SourceType, Candidates: candidates, }) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } skillSlug := skill.SkillSlug(sk.Name, a.Slug) if d.skillSlugTaken(a.ID, skillSlug) { skillSlug = d.uniqueSkillSlug(a.ID, skillSlug) } mgr := skill.NewManager(d.SkillsDir) if err := mgr.EnsureAppDir(a.Slug); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"}) return } relPath, err := mgr.Write(a.Slug, skillSlug, sk) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "写入 skill 失败: " + err.Error()}) return } row := store.Skill{ AppID: a.ID, Slug: skillSlug, Name: sk.Name, Description: sk.Description, FilePath: relPath, Version: 1, Status: "draft", Source: req.SourceType, } if err := d.DB.Create(&row).Error; err != nil { // Roll back the file we just wrote so a retry is clean. _ = mgr.Delete(relPath) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) return } d.audit(c, &a.ID, nil, "skill.generate", map[string]any{ "skill_id": row.ID, "source": req.SourceType, "tools": len(sk.Tools), }) c.JSON(http.StatusOK, skillResponse{Skill: row, Content: sk}) } type updateSkillReq struct { Content skill.Skill `json:"content" binding:"required"` } // PUT /admin/skills/:id — overwrites the markdown with the edited content. // The DB row's name/description are refreshed from the frontmatter. func (d Deps) handleUpdateSkill(c *gin.Context) { row, ok := d.loadSkill(c) if !ok { return } var req updateSkillReq if !bind(c, &req) { return } mgr := skill.NewManager(d.SkillsDir) // Load the app slug to preserve the file path mapping. var a store.App if err := d.DB.First(&a, "id = ?", row.AppID).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "应用不存在"}) return } _, err := mgr.Write(a.Slug, row.Slug, &req.Content) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "写入失败: " + err.Error()}) return } row.Name = req.Content.Name row.Description = req.Content.Description if err := d.DB.Save(row).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"}) return } d.audit(c, &row.AppID, nil, "skill.update", map[string]any{"skill_id": row.ID}) c.JSON(http.StatusOK, skillResponse{Skill: *row, Content: &req.Content}) } // POST /admin/skills/:id/publish — promotes a draft to the single published // skill for its app (any prior published skill is archived). Returns the row. func (d Deps) handlePublishSkill(c *gin.Context) { row, ok := d.loadSkill(c) if !ok { return } err := d.DB.Transaction(func(tx *gorm.DB) error { // Archive previously published skills for this app. if err := tx.Model(&store.Skill{}). Where("app_id = ? AND status = ?", row.AppID, "published"). Update("status", "archived").Error; err != nil { return err } row.Status = "published" return tx.Save(row).Error }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败"}) return } d.audit(c, &row.AppID, nil, "skill.publish", map[string]any{"skill_id": row.ID}) c.JSON(http.StatusOK, row) } // DELETE /admin/skills/:id — removes the row and its markdown file. func (d Deps) handleDeleteSkill(c *gin.Context) { row, ok := d.loadSkill(c) if !ok { return } mgr := skill.NewManager(d.SkillsDir) _ = mgr.Delete(row.FilePath) if err := d.DB.Delete(row).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"}) return } d.audit(c, &row.AppID, nil, "skill.delete", map[string]any{"skill_id": row.ID}) c.JSON(http.StatusOK, gin.H{"ok": true}) } // --- helpers --------------------------------------------------------------- // importCandidates dispatches to the right importer by source_type. markdown // and source require an LLM and are reported as unavailable until Phase 3. func (d Deps) importCandidates(req generateReq) ([]skill.CandidateTool, error) { switch req.SourceType { case "openapi": raw := strings.TrimSpace(req.OpenAPI) if raw == "" { return nil, errors.New("openapi 内容为空") } return skill.ParseOpenAPI([]byte(raw), req.BaseURL) case "manual": ct, err := skill.ManualCandidate(req.Manual) if err != nil { return nil, err } return []skill.CandidateTool{ct}, nil case "markdown", "source": return nil, errors.New("该来源需要 LLM,将在后续阶段支持") } return nil, errors.New("不支持的 source_type: " + req.SourceType) } // loadSkill fetches :id, writing 404 on miss. func (d Deps) loadSkill(c *gin.Context) (*store.Skill, bool) { id := c.Param("id") var s store.Skill if err := d.DB.First(&s, "id = ?", id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "skill 未找到"}) return nil, false } c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"}) return nil, false } return &s, true } func (d Deps) skillSlugTaken(appID int64, slug string) bool { var count int64 d.DB.Model(&store.Skill{}).Where("app_id = ? AND slug = ?", appID, slug).Count(&count) return count > 0 } func (d Deps) uniqueSkillSlug(appID int64, base string) string { for i := 2; ; i++ { cand := base + "-" + itoa(i) if !d.skillSlugTaken(appID, cand) { return cand } } }