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