Files
backend/internal/server/handlers_skills.go

252 lines
7.3 KiB
Go

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
}
}
}