Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
251
internal/server/handlers_skills.go
Normal file
251
internal/server/handlers_skills.go
Normal file
@@ -0,0 +1,251 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user