252 lines
7.3 KiB
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
|
|
}
|
|
}
|
|
}
|