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