Files
backend/internal/server/handlers_skilltest.go

127 lines
4.0 KiB
Go

package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"code.littlelan.cn/CarrotAssistant/backend/internal/agent"
"code.littlelan.cn/CarrotAssistant/backend/internal/llm"
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
)
// testSkillReq is the body for POST /admin/skills/:id/test. The skill's
// current on-disk content is used (typically a draft), and the admin supplies
// a single message to run one turn. Results stream back as JSON events rather
// than SSE so the admin SPA can consume them with a normal fetch.
type testSkillReq struct {
Message string `json:"message" binding:"required"`
}
// testEvent is one entry in the test response stream (a flat array, since the
// admin test panel doesn't need real-time streaming).
type testEvent struct {
Kind string `json:"kind"` // token | tool_call | tool_result | error
Text string `json:"text,omitempty"` // for token
Tool string `json:"tool,omitempty"` // tool name
Args map[string]any `json:"args,omitempty"`
Result string `json:"result,omitempty"`
Message string `json:"message,omitempty"`
}
// POST /admin/skills/:id/test — runs one agent turn against the skill's
// current markdown content with the given user message, without persisting
// anything. Useful for validating a draft before publishing.
func (d Deps) handleTestSkill(c *gin.Context) {
row, ok := d.loadSkill(c)
if !ok {
return
}
var req testSkillReq
if !bind(c, &req) {
return
}
var app store.App
if err := d.DB.First(&app, "id = ?", row.AppID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "应用不存在"})
return
}
cfg, err := d.resolveLLMConfig(&app)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
sk, err := skill.NewManager(d.SkillsDir).Load(row)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取 skill 失败: " + err.Error()})
return
}
// Collect events into a slice; the test panel renders them all at once.
var events []testEvent
collect := func(ev agent.Event) {
te := testEvent{Kind: string(ev.Kind)}
switch ev.Kind {
case agent.KindToken:
te.Text = ev.Text
case agent.KindToolCall:
te.Tool = ev.ToolCall.Name
te.Args = ev.ToolCall.Arguments
case agent.KindToolResult:
te.Tool = ev.ToolResult.CallID
te.Result = ev.ToolResult.Result
case agent.KindError:
te.Message = ev.Message
case agent.KindConfirmation:
te.Kind = "confirmation"
te.Tool = ev.Pending.Name
te.Args = ev.Pending.Arguments
te.Result = ev.Pending.Detail
}
events = append(events, te)
}
history := []llm.Message{{Role: llm.RoleSystem, Content: sk.SystemPrompt}}
runner := agent.NewRunner(llm.New(cfg), sk, history, collect)
runErr := runner.Run(c.Request.Context(), req.Message)
// A pending confirmation during a test is surfaced as an event, not an
// error; the admin can re-run after editing the skill.
if runErr != nil && !errors.Is(runErr, agent.ErrPendingConfirmation) {
events = append(events, testEvent{Kind: "error", Message: runErr.Error()})
}
// Also surface the assembled final assistant text so the panel can show
// the complete answer even when streaming assembled it from tokens.
if tail := runner.HistoryTail(); len(tail) > 0 {
for _, m := range tail {
if m.Role == llm.RoleAssistant && m.Content != "" {
events = append(events, testEvent{Kind: "final", Text: m.Content})
break
}
}
}
// Audit (best-effort, never fail the response).
go d.auditBy("system", &app.ID, nil, "skill.test", map[string]any{
"skill_id": row.ID, "events": len(events),
})
c.JSON(http.StatusOK, gin.H{"events": events})
}
// marshalForLog is a small helper kept here to avoid pulling encoding/json into
// the chat handler file. Unused for now but reserved for future audit detail.
func marshalForLog(v any) string {
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprint(v)
}
return string(b)
}