Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
122
internal/server/agentkit.go
Normal file
122
internal/server/agentkit.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// resolveLLMConfig builds the llm.Config for an App: looks up the App's
|
||||
// ModelConfig (or errors), decrypts the API key, and returns a ready client.
|
||||
func (d Deps) resolveLLMConfig(app *store.App) (llm.Config, error) {
|
||||
if app.ModelConfigID == nil {
|
||||
return llm.Config{}, errors.New("应用未配置模型")
|
||||
}
|
||||
var mc store.ModelConfig
|
||||
if err := d.DB.First(&mc, "id = ?", *app.ModelConfigID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return llm.Config{}, errors.New("应用的模型配置不存在")
|
||||
}
|
||||
return llm.Config{}, err
|
||||
}
|
||||
apiKey, err := d.Crypto.Decrypt(mc.APIKeyEncrypted)
|
||||
if err != nil {
|
||||
return llm.Config{}, fmt.Errorf("解密 api key: %w", err)
|
||||
}
|
||||
return llm.Config{
|
||||
BaseURL: mc.BaseURL,
|
||||
APIKey: apiKey,
|
||||
Model: mc.DefaultModel,
|
||||
MaxTokens: mc.MaxTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// loadPublishedSkill returns the currently-published skill for an app plus its
|
||||
// parsed content. Returns an error if none is published (the agent cannot run
|
||||
// without one).
|
||||
func (d Deps) loadPublishedSkill(app *store.App) (*store.Skill, *skill.Skill, error) {
|
||||
var row store.Skill
|
||||
err := d.DB.Where("app_id = ? AND status = ?", app.ID, "published").
|
||||
Order("id DESC").First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, errors.New("应用尚未发布任何 skill")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
content, err := skill.NewManager(d.SkillsDir).Load(&row)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取 skill 文件: %w", err)
|
||||
}
|
||||
return &row, content, nil
|
||||
}
|
||||
|
||||
// sessionHistory loads a session's messages and converts them to llm.Message
|
||||
// form, prefixed with the skill's system prompt. The returned slice is ready
|
||||
// to hand to agent.NewRunner.
|
||||
func (d Deps) sessionHistory(sessionID string, sk *skill.Skill) ([]llm.Message, error) {
|
||||
var msgs []store.Message
|
||||
if err := d.DB.Where("session_id = ?", sessionID).Order("id ASC").Find(&msgs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]llm.Message, 0, len(msgs)+1)
|
||||
// System prompt first; never replay it from storage.
|
||||
out = append(out, llm.Message{Role: llm.RoleSystem, Content: sk.SystemPrompt})
|
||||
for _, m := range msgs {
|
||||
out = append(out, toLLMMessage(m))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toLLMMessage converts a stored message back into llm.Message, rehydrating
|
||||
// tool calls from the JSON column.
|
||||
func toLLMMessage(m store.Message) llm.Message {
|
||||
msg := llm.Message{
|
||||
Role: llm.Role(m.Role),
|
||||
Content: m.Content,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
var calls []llm.ToolCall
|
||||
// Best-effort decode; a corrupt column should never break the chat.
|
||||
_ = jsonUnmarshal(m.ToolCalls, &calls)
|
||||
msg.ToolCalls = calls
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// persistMessage stores one message row. Used after each assistant turn /
|
||||
// tool result so the conversation survives across requests.
|
||||
func (d Deps) persistMessage(sessionID string, m llm.Message) error {
|
||||
row := store.Message{
|
||||
SessionID: sessionID,
|
||||
Role: string(m.Role),
|
||||
Content: m.Content,
|
||||
ToolCallID: m.ToolCallID,
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
// Store the calls array directly; toLLMMessage decodes []ToolCall.
|
||||
row.ToolCalls = jsonRaw(m.ToolCalls)
|
||||
}
|
||||
if err := d.DB.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return d.DB.Model(&store.Session{}).Where("id = ?", sessionID).
|
||||
UpdateColumn("message_count", gorm.Expr("message_count + 1")).Error
|
||||
}
|
||||
|
||||
// newRunner assembles an agent.Runner for an app + session.
|
||||
func (d Deps) newRunner(app *store.App, sk *skill.Skill, history []llm.Message, onEvent func(agent.Event)) (*agent.Runner, error) {
|
||||
cfg, err := d.resolveLLMConfig(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := llm.New(cfg)
|
||||
return agent.NewRunner(client, sk, history, onEvent), nil
|
||||
}
|
||||
110
internal/server/auth.go
Normal file
110
internal/server/auth.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// tokenLifetime bounds how long an admin session token remains valid. The
|
||||
// SPA re-logs-in after expiry rather than refreshing, keeping the flow simple.
|
||||
const tokenLifetime = 24 * time.Hour
|
||||
|
||||
// issueToken signs a JWT for the given admin username.
|
||||
func (d Deps) issueToken(username string) (string, error) {
|
||||
claims := jwt.MapClaims{
|
||||
"sub": username,
|
||||
"exp": time.Now().Add(tokenLifetime).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return tok.SignedString([]byte(d.Config.JWTSecret))
|
||||
}
|
||||
|
||||
// RequireAdmin is the middleware guarding /admin routes (other than login).
|
||||
// It validates the Bearer JWT and loads the AdminUser row onto the context
|
||||
// as "admin_user".
|
||||
func (d Deps) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
u, err := d.adminFromToken(c)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录或会话已过期"})
|
||||
return
|
||||
}
|
||||
c.Set("admin_user", u)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) adminFromToken(c *gin.Context) (*store.AdminUser, error) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
return nil, errors.New("missing bearer token")
|
||||
}
|
||||
raw := strings.TrimPrefix(auth, "Bearer ")
|
||||
|
||||
tok, err := jwt.Parse(raw, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(d.Config.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !tok.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
claims, ok := tok.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid claims")
|
||||
}
|
||||
username, _ := claims["sub"].(string)
|
||||
if username == "" {
|
||||
return nil, errors.New("missing subject")
|
||||
}
|
||||
|
||||
var u store.AdminUser
|
||||
if err := d.DB.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// adminCtx returns the authenticated admin user placed by RequireAdmin.
|
||||
func adminCtx(c *gin.Context) *store.AdminUser {
|
||||
u, _ := c.Get("admin_user")
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return u.(*store.AdminUser)
|
||||
}
|
||||
|
||||
// audit is a small helper to record an audit log row from any handler. It
|
||||
// never fails the request: auditing is best-effort but logged on error.
|
||||
func (d Deps) audit(c *gin.Context, appID *int64, sessionID *string, action string, detail map[string]any) {
|
||||
actor := "system"
|
||||
if u := adminCtx(c); u != nil {
|
||||
actor = u.Username
|
||||
}
|
||||
d.auditBy(actor, appID, sessionID, action, detail)
|
||||
}
|
||||
|
||||
func (d Deps) auditBy(actor string, appID *int64, sessionID *string, action string, detail map[string]any) {
|
||||
row := store.AuditLog{AppID: appID, SessionID: sessionID, Actor: actor, Action: action}
|
||||
if detail != nil {
|
||||
row.Detail = marshalJSON(detail)
|
||||
}
|
||||
if err := d.DB.Create(&row).Error; err != nil {
|
||||
// Best-effort: surface to stderr but never interrupt the caller.
|
||||
gin.DefaultErrorWriter.Write([]byte("audit log: " + err.Error() + "\n"))
|
||||
}
|
||||
}
|
||||
71
internal/server/chat_middleware.go
Normal file
71
internal/server/chat_middleware.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
// RequireAppToken guards the public /api/v1 chat routes. It expects an
|
||||
// X-App-Token header whose SHA-256 hash matches an App row. The matched App
|
||||
// is placed on the context as "app".
|
||||
func (d Deps) RequireAppToken() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.GetHeader("X-App-Token"))
|
||||
if raw == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "缺少 X-App-Token"})
|
||||
return
|
||||
}
|
||||
hash := security.HashToken(raw)
|
||||
var app store.App
|
||||
err := d.DB.Where("token_hash = ? AND status = ?", hash, "active").First(&app).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "无效的 token"})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
c.Set("app", &app)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// appCtx returns the App placed by RequireAppToken.
|
||||
func appCtx(c *gin.Context) *store.App {
|
||||
v, _ := c.Get("app")
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.(*store.App)
|
||||
}
|
||||
|
||||
// createSession persists a new session row and returns it.
|
||||
func (d Deps) createSession(appID int64, title string) (*store.Session, error) {
|
||||
id, err := newUUID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &store.Session{ID: id, AppID: appID, Title: title}
|
||||
if err := d.DB.Create(s).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// loadSessionOwnedBy returns a session only if it belongs to appID.
|
||||
func (d Deps) loadSessionOwnedBy(sessionID string, appID int64) (*store.Session, error) {
|
||||
var s store.Session
|
||||
err := d.DB.Where("id = ? AND app_id = ?", sessionID, appID).First(&s).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
307
internal/server/chat_test.go
Normal file
307
internal/server/chat_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/config"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/skill"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
// newTestDB opens an in-memory-ish SQLite (temp file) and returns the gorm DB
|
||||
// plus a cleanup function.
|
||||
func newTestDB(t *testing.T) (*gorm.DB, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := store.Open(dir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
return db, func() {}
|
||||
}
|
||||
|
||||
// newTestDeps builds a Deps wired to a mock LLM server, with crypto + skills
|
||||
// dir under the test temp directory.
|
||||
func newTestDeps(t *testing.T, mockLLMURL string) (Deps, *store.App, *skill.Skill, string, func()) {
|
||||
t.Helper()
|
||||
db, dbCleanup := newTestDB(t)
|
||||
dir := t.TempDir()
|
||||
crypto, err := security.NewCrypto("0123456789abcdef0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatalf("crypto: %v", err)
|
||||
}
|
||||
|
||||
// A model config pointing at the mock server.
|
||||
mc := store.ModelConfig{
|
||||
Name: "mock", BaseURL: mockLLMURL, APIKeyEncrypted: mustEnc(crypto, "sk-mock"),
|
||||
APIKeyPreview: "mock", DefaultModel: "mock-model", SupportsTools: true,
|
||||
}
|
||||
if err := db.Create(&mc).Error; err != nil {
|
||||
t.Fatalf("create mc: %v", err)
|
||||
}
|
||||
|
||||
// An app that uses it, with a known token so the chat client can auth.
|
||||
const appToken = "ca_live_testtoken"
|
||||
app := &store.App{
|
||||
Slug: "demo", Name: "Demo", TokenHash: security.HashToken(appToken),
|
||||
ModelConfigID: &mc.ID, Status: "active",
|
||||
}
|
||||
if err := db.Create(app).Error; err != nil {
|
||||
t.Fatalf("create app: %v", err)
|
||||
}
|
||||
|
||||
// A published skill with one read tool and one mutating tool that
|
||||
// requires confirmation.
|
||||
sk := &skill.Skill{
|
||||
Name: "demo-assistant",
|
||||
Description: "demo",
|
||||
AppSlug: "demo",
|
||||
Permissions: skill.Permissions{
|
||||
DefaultMode: "readonly",
|
||||
RequireConfirmation: []string{"delete_post"},
|
||||
},
|
||||
Tools: []skill.Tool{
|
||||
{
|
||||
Name: "search", Description: "search",
|
||||
Parameters: skill.MakeParameters([]byte(`{"type":"object","properties":{"q":{"type":"string"}}}`)),
|
||||
Endpoint: skill.Endpoint{Method: "GET", URL: "https://example.invalid/search", Query: map[string]string{"q": "{{q}}"}},
|
||||
},
|
||||
{
|
||||
Name: "delete_post", Description: "delete",
|
||||
Parameters: skill.MakeParameters([]byte(`{"type":"object","properties":{"id":{"type":"integer"}}}`)),
|
||||
Endpoint: skill.Endpoint{Method: "DELETE", URL: "https://example.invalid/posts/{{id}}"},
|
||||
},
|
||||
},
|
||||
SystemPrompt: "you are a demo assistant",
|
||||
}
|
||||
mgr := skill.NewManager(dir)
|
||||
if _, err := mgr.Write("demo", "demo-assistant", sk); err != nil {
|
||||
t.Fatalf("write skill: %v", err)
|
||||
}
|
||||
row := store.Skill{
|
||||
AppID: app.ID, Slug: "demo-assistant", Name: sk.Name, FilePath: mgr.RelPath("demo", "demo-assistant"),
|
||||
Status: "published", Source: "manual",
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create skill: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{JWTSecret: "test-jwt"}
|
||||
deps := Deps{DB: db, Config: cfg, Crypto: crypto, SkillsDir: dir}
|
||||
return deps, app, sk, appToken, dbCleanup
|
||||
}
|
||||
|
||||
func mustEnc(c *security.Crypto, s string) string {
|
||||
out, err := c.Encrypt(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mockLLM is a tiny SSE chat-completion server whose response sequence is
|
||||
// driven by a script: a list of chunk-producers invoked in order, one per
|
||||
// LLM call. This lets a test script a tool-call turn followed by a final
|
||||
// answer turn.
|
||||
type mockLLM struct {
|
||||
turns [][]string // each turn is a list of SSE chunks (raw JSON delta lines)
|
||||
idx int
|
||||
}
|
||||
|
||||
func (m *mockLLM) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Read the request body to find how many turns we've served (we just
|
||||
// advance the script on each call).
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = body
|
||||
if m.idx >= len(m.turns) {
|
||||
// No more scripted turns: emit a bare stop so the loop ends cleanly.
|
||||
m.idx++
|
||||
writeSSEChunk(w, map[string]any{"choices": []map[string]any{{"finish_reason": "stop", "delta": map[string]any{}}}})
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
return
|
||||
}
|
||||
chunks := m.turns[m.idx]
|
||||
m.idx++
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
for _, c := range chunks {
|
||||
fmt.Fprintf(w, "data: %s\n\n", c)
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// writeSSEChunk marshals one chunk object and writes it as a data line.
|
||||
func writeSSEChunk(w http.ResponseWriter, chunk map[string]any) {
|
||||
b, _ := json.Marshal(chunk)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
}
|
||||
|
||||
// TestChatToolCallFlow drives a chat request against a mock LLM that first
|
||||
// requests the search tool (a read), then produces a final answer. Asserts
|
||||
// the SSE stream emits the expected event sequence.
|
||||
func TestChatToolCallFlow(t *testing.T) {
|
||||
mock := &mockLLM{
|
||||
turns: [][]string{
|
||||
// Turn 1: model asks to call search(q="hi").
|
||||
{
|
||||
chunkJSON("search", "call_1", "", `{"q":"hi"}`, ""),
|
||||
`{"choices":[{"finish_reason":"tool_calls","delta":{}}]}`,
|
||||
},
|
||||
// Turn 2: model produces the final text answer.
|
||||
{
|
||||
`{"choices":[{"finish_reason":"","delta":{"content":"found "}}]}`,
|
||||
`{"choices":[{"finish_reason":"","delta":{"content":"2 posts"}}]}`,
|
||||
`{"choices":[{"finish_reason":"stop","delta":{}}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
mockSrv := httptest.NewServer(mock)
|
||||
defer mockSrv.Close()
|
||||
|
||||
deps, _, _, appToken, cleanup := newTestDeps(t, mockSrv.URL)
|
||||
defer cleanup()
|
||||
|
||||
// Also stand up a fake "target site" so the tool call resolves.
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`["post1","post2"]`))
|
||||
}))
|
||||
defer target.Close()
|
||||
// Patch the search tool URL to point at the fake target. (We do this by
|
||||
// editing the published skill file in place.)
|
||||
patchSkillURL(t, deps, "demo", "demo-assistant", "search", target.URL+"/search")
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
deps.mountChatRoutes(r)
|
||||
|
||||
// POST /api/v1/chat and collect the SSE stream.
|
||||
body := bytes.NewBufferString(`{"message":"hi"}`)
|
||||
req := httptest.NewRequest("POST", "/api/v1/chat", body)
|
||||
req.Header.Set("X-App-Token", appToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
events := parseSSE(rec.Body.String())
|
||||
kinds := eventKinds(events)
|
||||
wantSeq := []string{"session", "tool_call", "tool_result", "token", "token", "done"}
|
||||
if !containsSeq(kinds, wantSeq) {
|
||||
t.Fatalf("event kinds = %v, want sequence containing %v", kinds, wantSeq)
|
||||
}
|
||||
}
|
||||
|
||||
// patchSkillURL rewrites a tool's endpoint URL in the published skill file
|
||||
// for the test, so tool calls hit the in-process fake target.
|
||||
func patchSkillURL(t *testing.T, deps Deps, appSlug, skillSlug, toolName, newURL string) {
|
||||
t.Helper()
|
||||
mgr := skill.NewManager(deps.SkillsDir)
|
||||
var row store.Skill
|
||||
deps.DB.Where("slug = ? AND app_id IN (SELECT id FROM apps WHERE slug = ?)", skillSlug, appSlug).First(&row)
|
||||
sk, err := mgr.Load(&row)
|
||||
if err != nil {
|
||||
t.Fatalf("load skill: %v", err)
|
||||
}
|
||||
for i := range sk.Tools {
|
||||
if sk.Tools[i].Name == toolName {
|
||||
sk.Tools[i].Endpoint.URL = newURL
|
||||
}
|
||||
}
|
||||
if _, err := mgr.Write(appSlug, skillSlug, sk); err != nil {
|
||||
t.Fatalf("rewrite skill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// chunkJSON builds a delta chunk for a tool call (function arguments arrive
|
||||
// as a JSON string). When content is non-empty it's a content delta instead.
|
||||
func chunkJSON(name, id, content, argsJSON, _ string) string {
|
||||
if content != "" {
|
||||
b, _ := json.Marshal(map[string]any{"choices": []map[string]any{{
|
||||
"delta": map[string]any{"content": content},
|
||||
}}})
|
||||
return string(b)
|
||||
}
|
||||
// tool-call delta
|
||||
b, _ := json.Marshal(map[string]any{"choices": []map[string]any{{
|
||||
"delta": map[string]any{
|
||||
"tool_calls": []map[string]any{{
|
||||
"index": 0, "id": id,
|
||||
"function": map[string]any{"name": name, "arguments": argsJSON},
|
||||
}},
|
||||
},
|
||||
}}})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// parseSSE turns a raw SSE body into a list of (event, data) pairs.
|
||||
func parseSSE(raw string) []sseEvent {
|
||||
var out []sseEvent
|
||||
var cur sseEvent
|
||||
sc := bufio.NewScanner(strings.NewReader(raw))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
ln := sc.Text()
|
||||
if ln == "" {
|
||||
if cur.event != "" || cur.data != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
cur = sseEvent{}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(ln, "event: ") {
|
||||
cur.event = strings.TrimPrefix(ln, "event: ")
|
||||
} else if strings.HasPrefix(ln, "data: ") {
|
||||
cur.data += strings.TrimPrefix(ln, "data: ")
|
||||
}
|
||||
}
|
||||
if cur.event != "" || cur.data != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type sseEvent struct {
|
||||
event string
|
||||
data string
|
||||
}
|
||||
|
||||
func eventKinds(es []sseEvent) []string {
|
||||
out := make([]string, 0, len(es))
|
||||
for _, e := range es {
|
||||
out = append(out, e.event)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsSeq reports whether want appears as a contiguous subsequence of got.
|
||||
func containsSeq(got, want []string) bool {
|
||||
if len(want) > len(got) {
|
||||
return false
|
||||
}
|
||||
for i := 0; i+len(want) <= len(got); i++ {
|
||||
match := true
|
||||
for j := range want {
|
||||
if got[i+j] != want[j] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
193
internal/server/handlers_apps.go
Normal file
193
internal/server/handlers_apps.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
type appReq struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ModelConfigID *int64 `json:"model_config_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// GET /admin/apps
|
||||
func (d Deps) handleListApps(c *gin.Context) {
|
||||
var apps []store.App
|
||||
d.DB.Order("id DESC").Find(&apps)
|
||||
c.JSON(http.StatusOK, apps)
|
||||
}
|
||||
|
||||
// GET /admin/apps/:id
|
||||
func (d Deps) handleGetApp(c *gin.Context) {
|
||||
a, ok := d.loadApp(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// POST /admin/apps — slug auto-derived from name when omitted. A fresh
|
||||
// access token is generated and returned ONCE in the response; only its hash
|
||||
// is persisted, so the operator must save it immediately.
|
||||
func (d Deps) handleCreateApp(c *gin.Context) {
|
||||
var req appReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
slug := security.Slugify(req.Slug)
|
||||
if slug == "" {
|
||||
slug = security.Slugify(req.Name)
|
||||
}
|
||||
if slug == "" {
|
||||
// Name had no ASCII slug chars (e.g. pure CJK). Derive a short random
|
||||
// slug so the app is still addressable on disk and in URLs.
|
||||
slug = security.RandomSlug()
|
||||
}
|
||||
|
||||
// Ensure slug uniqueness.
|
||||
var existing store.App
|
||||
err := d.DB.Where("slug = ?", slug).First(&existing).Error
|
||||
if err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "slug 已存在"})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := security.GenerateToken("ca")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 token 失败"})
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
a := store.App{
|
||||
Slug: slug,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
TokenHash: security.HashToken(token),
|
||||
ModelConfigID: req.ModelConfigID,
|
||||
Status: status,
|
||||
}
|
||||
if err := d.DB.Create(&a).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
d.audit(c, &a.ID, nil, "app.create", map[string]any{"slug": a.Slug})
|
||||
|
||||
// Plain token is returned only here; subsequent reads only have the hash.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"app": a,
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /admin/apps/:id — updates mutable fields. Slug is intentionally not
|
||||
// editable post-create because skills live under <SkillsDir>/<slug>/ and a
|
||||
// rename would orphan them.
|
||||
func (d Deps) handleUpdateApp(c *gin.Context) {
|
||||
a, ok := d.loadApp(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req appReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
a.Name = req.Name
|
||||
a.Description = req.Description
|
||||
a.ModelConfigID = req.ModelConfigID
|
||||
if s := strings.TrimSpace(req.Status); s != "" {
|
||||
a.Status = s
|
||||
}
|
||||
if err := d.DB.Save(a).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
|
||||
return
|
||||
}
|
||||
d.audit(c, &a.ID, nil, "app.update", nil)
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
|
||||
// DELETE /admin/apps/:id — cascades to skills (rows + their markdown files),
|
||||
// sessions, and messages. Wrapped in a transaction so a partial failure
|
||||
// leaves the app intact.
|
||||
func (d Deps) handleDeleteApp(c *gin.Context) {
|
||||
a, ok := d.loadApp(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := d.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// Collect skill file paths so we can remove them on disk after the
|
||||
// transaction commits.
|
||||
var skills []store.Skill
|
||||
tx.Where("app_id = ?", a.ID).Find(&skills)
|
||||
if err := tx.Where("app_id = ?", a.ID).Delete(&store.Skill{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete messages that belong to this app's sessions, then sessions.
|
||||
tx.Where("session_id IN (SELECT id FROM sessions WHERE app_id = ?)", a.ID).Delete(&store.Message{})
|
||||
if err := tx.Where("app_id = ?", a.ID).Delete(&store.Session{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(a).Error
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
// Best-effort: remove the app's skill directory on disk.
|
||||
removeAppSkillDir(d.SkillsDir, a.Slug)
|
||||
d.audit(c, &a.ID, nil, "app.delete", map[string]any{"slug": a.Slug})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// POST /admin/apps/:id/token/rotate — invalidates the old token and returns a
|
||||
// new plaintext token once.
|
||||
func (d Deps) handleRotateToken(c *gin.Context) {
|
||||
a, ok := d.loadApp(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
token, err := security.GenerateToken("ca")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成 token 失败"})
|
||||
return
|
||||
}
|
||||
a.TokenHash = security.HashToken(token)
|
||||
if err := d.DB.Save(a).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
|
||||
return
|
||||
}
|
||||
d.audit(c, &a.ID, nil, "app.token_rotate", nil)
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
// loadApp fetches :id and writes a 404 on miss. Returns the app and true on
|
||||
// success so handlers can `if !ok { return }` at the top.
|
||||
func (d Deps) loadApp(c *gin.Context) (*store.App, bool) {
|
||||
id := c.Param("id")
|
||||
var a store.App
|
||||
if err := d.DB.First(&a, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "应用未找到"})
|
||||
return nil, false
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return nil, false
|
||||
}
|
||||
return &a, true
|
||||
}
|
||||
29
internal/server/handlers_audit.go
Normal file
29
internal/server/handlers_audit.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
// GET /admin/audit-logs — optional ?app_id= and ?action= filters, ?limit=
|
||||
// capped at 500 for safety.
|
||||
func (d Deps) handleListAuditLogs(c *gin.Context) {
|
||||
q := d.DB.Model(&store.AuditLog{}).Order("id DESC")
|
||||
if a := c.Query("app_id"); a != "" {
|
||||
q = q.Where("app_id = ?", a)
|
||||
}
|
||||
if act := c.Query("action"); act != "" {
|
||||
q = q.Where("action = ?", act)
|
||||
}
|
||||
limit := 200
|
||||
if n, err := strconv.Atoi(c.Query("limit")); err == nil && n > 0 && n <= 500 {
|
||||
limit = n
|
||||
}
|
||||
var logs []store.AuditLog
|
||||
q.Limit(limit).Find(&logs)
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
53
internal/server/handlers_auth.go
Normal file
53
internal/server/handlers_auth.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
type loginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// POST /admin/login — authenticates an admin and returns a JWT.
|
||||
func (d Deps) handleLogin(c *gin.Context) {
|
||||
var req loginReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
var u store.AdminUser
|
||||
err := d.DB.Where("username = ?", req.Username).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
|
||||
return
|
||||
}
|
||||
if !security.VerifyPassword(u.PasswordHash, req.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
token, err := d.issueToken(u.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发令牌失败"})
|
||||
return
|
||||
}
|
||||
d.auditBy(u.Username, nil, nil, "admin.login", nil)
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "username": u.Username})
|
||||
}
|
||||
|
||||
// GET /admin/me — returns the currently authenticated admin (for the SPA to
|
||||
// confirm a stored token is still valid after reload).
|
||||
func (d Deps) handleMe(c *gin.Context) {
|
||||
u := adminCtx(c)
|
||||
c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username})
|
||||
}
|
||||
363
internal/server/handlers_chat.go
Normal file
363
internal/server/handlers_chat.go
Normal file
@@ -0,0 +1,363 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type chatReq struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
|
||||
// POST /api/v1/chat — opens an SSE stream that runs one assistant turn.
|
||||
//
|
||||
// Flow: authenticate app -> load/create session -> load published skill ->
|
||||
// build history -> run agent, streaming events -> persist the assistant's
|
||||
// final message + any tool messages. If a mutating tool needs confirmation,
|
||||
// the pending call is stored on the session and a confirmation_required
|
||||
// event ends this stream; the user then POSTs /chat/confirm to resume.
|
||||
func (d Deps) handleChat(c *gin.Context) {
|
||||
app := appCtx(c)
|
||||
var req chatReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve skill + session up front so failures return clean JSON, not a
|
||||
// half-started SSE stream.
|
||||
_, sk, err := d.loadPublishedSkill(app)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := d.resolveLLMConfig(app); err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var session *store.Session
|
||||
if req.SessionID != "" {
|
||||
session, err = d.loadSessionOwnedBy(req.SessionID, app.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
|
||||
return
|
||||
}
|
||||
if len(session.PendingConfirmation) > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该会话有一个待确认的操作,请先确认或拒绝"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
title := truncate(req.Message, 40)
|
||||
session, err = d.createSession(app.ID, title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建会话失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the user message before running, so it survives a mid-stream
|
||||
// disconnect.
|
||||
if err := d.persistMessage(session.ID, llm.Message{Role: llm.RoleUser, Content: req.Message}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存消息失败"})
|
||||
return
|
||||
}
|
||||
|
||||
history, err := d.sessionHistory(session.ID, sk)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载历史失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Begin SSE. From here on, all output goes through the stream.
|
||||
d.streamChat(c, app, session, sk, history, false)
|
||||
}
|
||||
|
||||
// streamChat writes SSE events for one agent run. On confirmation pause it
|
||||
// stores the pending call and emits confirmation_required; the caller's
|
||||
// confirm handler resumes by calling this again with resume=true.
|
||||
func (d Deps) streamChat(c *gin.Context, app *store.App, session *store.Session, sk *skill.Skill, history []llm.Message, resume bool) {
|
||||
flusher, ok := c.Writer.(interface {
|
||||
http.ResponseWriter
|
||||
http.Flusher
|
||||
})
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
|
||||
|
||||
send := func(event string, data any) {
|
||||
payload, _ := json.Marshal(data)
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, payload)
|
||||
flusher.Flush()
|
||||
}
|
||||
sendSession := func() {
|
||||
send("session", gin.H{"session_id": session.ID})
|
||||
}
|
||||
|
||||
var sawDone bool
|
||||
onEvent := func(ev agent.Event) {
|
||||
switch ev.Kind {
|
||||
case agent.KindToken:
|
||||
send("token", gin.H{"delta": ev.Text})
|
||||
case agent.KindToolCall:
|
||||
send("tool_call", ev.ToolCall)
|
||||
case agent.KindToolResult:
|
||||
send("tool_result", ev.ToolResult)
|
||||
case agent.KindConfirmation:
|
||||
// Persist the pending call so /chat/confirm can resume. The
|
||||
// runner has already stashed its internal state; we also keep a
|
||||
// copy on the session for crash-safety across requests.
|
||||
pending := pendingBlob{
|
||||
CallID: ev.Pending.CallID,
|
||||
Name: ev.Pending.Name,
|
||||
Arguments: ev.Pending.Arguments,
|
||||
Detail: ev.Pending.Detail,
|
||||
}
|
||||
d.DB.Model(&store.Session{}).Where("id = ?", session.ID).
|
||||
Update("pending_confirmation", jsonRaw(pending))
|
||||
send("confirmation_required", ev.Pending)
|
||||
case agent.KindDone:
|
||||
sawDone = true
|
||||
case agent.KindError:
|
||||
send("error", gin.H{"message": ev.Message})
|
||||
}
|
||||
}
|
||||
|
||||
sendSession()
|
||||
runner := agent.NewRunner(llm.New(mustLLMConfig(d, app)), sk, history, onEvent)
|
||||
|
||||
var runErr error
|
||||
if resume {
|
||||
// Confirm handler already validated approval; run the resume path.
|
||||
runErr = runner.ResumeAfterConfirmation(c.Request.Context(), true)
|
||||
} else {
|
||||
// The user message is already the last history entry.
|
||||
runErr = runner.Run(c.Request.Context(), "")
|
||||
}
|
||||
|
||||
if errors.Is(runErr, agent.ErrPendingConfirmation) {
|
||||
// Stream ends here; client must POST /chat/confirm to continue.
|
||||
send("paused", gin.H{"reason": "confirmation_required"})
|
||||
return
|
||||
}
|
||||
if runErr != nil && !errors.Is(runErr, agent.ErrTooManyRounds) {
|
||||
// Non-fatal errors already surfaced via KindError; just close.
|
||||
send("done", gin.H{"ok": false})
|
||||
return
|
||||
}
|
||||
|
||||
// Persist the assistant's final message (and any tool messages that the
|
||||
// runner accumulated). We re-derive them from the runner's history tail
|
||||
// since the runner owns the authoritative copy.
|
||||
d.persistRunnerTurn(session.ID, runner)
|
||||
|
||||
if sawDone {
|
||||
send("done", gin.H{"ok": true})
|
||||
} else {
|
||||
send("done", gin.H{"ok": false})
|
||||
}
|
||||
}
|
||||
|
||||
// persistRunnerTurn stores any new messages the runner added beyond the
|
||||
// initial history length. The runner's History is the full conversation
|
||||
// including the system prompt (index 0), which we skip when persisting.
|
||||
func (d Deps) persistRunnerTurn(sessionID string, r *agent.Runner) {
|
||||
// Best-effort: failures here don't fail the stream, but the next turn
|
||||
// would lose context. Log via audit.
|
||||
// We rely on the runner exposing its history; agent.Runner.History is
|
||||
// public for this purpose.
|
||||
for _, m := range r.HistoryTail() {
|
||||
if m.Role == llm.RoleSystem {
|
||||
continue
|
||||
}
|
||||
if err := d.persistMessage(sessionID, m); err != nil {
|
||||
d.auditBy("system", nil, &sessionID, "session.persist_error",
|
||||
map[string]any{"role": m.Role, "err": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mustLLMConfig resolves the LLM config, sending a JSON error and aborting if
|
||||
// it fails. Used at the start of streamChat where we already began SSE setup
|
||||
// in the caller — but resolution happens before SSE begins in practice (see
|
||||
// handleChat). This helper is a safety net.
|
||||
func mustLLMConfig(d Deps, app *store.App) llm.Config {
|
||||
cfg, err := d.resolveLLMConfig(app)
|
||||
if err != nil {
|
||||
// Returning a zero config would produce a clearer API error from the
|
||||
// SDK than a panic; the chat will fail loudly.
|
||||
return llm.Config{}
|
||||
}
|
||||
_ = err
|
||||
return cfg
|
||||
}
|
||||
|
||||
// truncate clamps a string to n runes for session titles.
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// pendingBlob is the JSON shape stored on Session.PendingConfirmation.
|
||||
type pendingBlob struct {
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// confirmReq is the body for POST /api/v1/chat/confirm.
|
||||
type confirmReq struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
Approved bool `json:"approved"`
|
||||
}
|
||||
|
||||
// POST /api/v1/chat/confirm — resolves a paused confirmation. On approve,
|
||||
// opens a new SSE stream that resumes the agent loop. On deny, the pending
|
||||
// call is cleared and a tool "user declined" message is appended.
|
||||
func (d Deps) handleConfirm(c *gin.Context) {
|
||||
app := appCtx(c)
|
||||
var req confirmReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
session, err := d.loadSessionOwnedBy(req.SessionID, app.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
|
||||
return
|
||||
}
|
||||
if len(session.PendingConfirmation) == 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "该会话没有待确认的操作"})
|
||||
return
|
||||
}
|
||||
_, sk, err := d.loadPublishedSkill(app)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !req.Approved {
|
||||
// Deny: append a synthetic tool message so the model can answer
|
||||
// without the data, then clear the pending flag. We still stream so
|
||||
// the client gets the model's follow-up.
|
||||
var pb pendingBlob
|
||||
_ = jsonUnmarshal(session.PendingConfirmation, &pb)
|
||||
d.DB.Model(&store.Session{}).Where("id = ?", session.ID).
|
||||
Update("pending_confirmation", nil)
|
||||
d.persistMessage(session.ID, llm.Message{
|
||||
Role: llm.RoleTool, ToolCallID: pb.CallID, Name: pb.Name,
|
||||
Content: "[用户拒绝了该操作]",
|
||||
})
|
||||
}
|
||||
|
||||
history, err := d.sessionHistory(session.ID, sk)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载历史失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the pending flag now (on approve, resume handles the call).
|
||||
if req.Approved {
|
||||
d.DB.Model(&store.Session{}).Where("id = ?", session.ID).
|
||||
Update("pending_confirmation", nil)
|
||||
}
|
||||
|
||||
// For "deny" we need a fresh runner to continue the loop with the new
|
||||
// tool message; for "approve" the resume path runs the pending call.
|
||||
// Both flow through streamChat: approve with resume=true, deny with a
|
||||
// normal Run (the deny message is already in history).
|
||||
d.streamChatResume(c, app, session, sk, history, req.Approved)
|
||||
}
|
||||
|
||||
// streamChatResume mirrors streamChat but handles the deny case (a fresh
|
||||
// Run) versus the approve case (ResumeAfterConfirmation). Kept separate so
|
||||
// the main handleChat path stays readable.
|
||||
func (d Deps) streamChatResume(c *gin.Context, app *store.App, session *store.Session, sk *skill.Skill, history []llm.Message, approve bool) {
|
||||
flusher, ok := c.Writer.(interface {
|
||||
http.ResponseWriter
|
||||
http.Flusher
|
||||
})
|
||||
if !ok {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
send := func(event string, data any) {
|
||||
payload, _ := json.Marshal(data)
|
||||
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, payload)
|
||||
flusher.Flush()
|
||||
}
|
||||
send("session", gin.H{"session_id": session.ID})
|
||||
|
||||
var sawDone bool
|
||||
onEvent := func(ev agent.Event) {
|
||||
switch ev.Kind {
|
||||
case agent.KindToken:
|
||||
send("token", gin.H{"delta": ev.Text})
|
||||
case agent.KindToolCall:
|
||||
send("tool_call", ev.ToolCall)
|
||||
case agent.KindToolResult:
|
||||
send("tool_result", ev.ToolResult)
|
||||
case agent.KindConfirmation:
|
||||
pending := pendingBlob{
|
||||
CallID: ev.Pending.CallID, Name: ev.Pending.Name,
|
||||
Arguments: ev.Pending.Arguments, Detail: ev.Pending.Detail,
|
||||
}
|
||||
d.DB.Model(&store.Session{}).Where("id = ?", session.ID).
|
||||
Update("pending_confirmation", jsonRaw(pending))
|
||||
send("confirmation_required", ev.Pending)
|
||||
case agent.KindDone:
|
||||
sawDone = true
|
||||
case agent.KindError:
|
||||
send("error", gin.H{"message": ev.Message})
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := d.resolveLLMConfig(app)
|
||||
if err != nil {
|
||||
send("error", gin.H{"message": err.Error()})
|
||||
send("done", gin.H{"ok": false})
|
||||
return
|
||||
}
|
||||
runner := agent.NewRunner(llm.New(cfg), sk, history, onEvent)
|
||||
|
||||
var runErr error
|
||||
if approve {
|
||||
runErr = runner.ResumeAfterConfirmation(c.Request.Context(), true)
|
||||
} else {
|
||||
// The deny tool message is the last history entry; run with empty
|
||||
// user message to continue.
|
||||
runErr = runner.Run(c.Request.Context(), "")
|
||||
}
|
||||
|
||||
if errors.Is(runErr, agent.ErrPendingConfirmation) {
|
||||
send("paused", gin.H{"reason": "confirmation_required"})
|
||||
return
|
||||
}
|
||||
d.persistRunnerTurn(session.ID, runner)
|
||||
if sawDone {
|
||||
send("done", gin.H{"ok": true})
|
||||
} else {
|
||||
send("done", gin.H{"ok": false})
|
||||
}
|
||||
}
|
||||
116
internal/server/handlers_models.go
Normal file
116
internal/server/handlers_models.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
type modelConfigReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
DefaultModel string `json:"default_model" binding:"required"`
|
||||
SupportsTools *bool `json:"supports_tools"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
// GET /admin/model-configs
|
||||
func (d Deps) handleListModels(c *gin.Context) {
|
||||
var list []store.ModelConfig
|
||||
d.DB.Order("id DESC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// POST /admin/model-configs
|
||||
func (d Deps) handleCreateModel(c *gin.Context) {
|
||||
var req modelConfigReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
||||
enc, err := d.Crypto.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||
return
|
||||
}
|
||||
m := store.ModelConfig{
|
||||
Name: req.Name,
|
||||
BaseURL: req.BaseURL,
|
||||
APIKeyEncrypted: enc,
|
||||
APIKeyPreview: security.GenerateAPIKeyPreview(req.APIKey),
|
||||
DefaultModel: req.DefaultModel,
|
||||
SupportsTools: req.SupportsTools == nil || *req.SupportsTools,
|
||||
MaxTokens: req.MaxTokens,
|
||||
}
|
||||
if err := d.DB.Create(&m).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "名称已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建失败"})
|
||||
return
|
||||
}
|
||||
d.audit(c, nil, nil, "model_config.create", map[string]any{"id": m.ID, "name": m.Name})
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// PUT /admin/model-configs/:id — api_key is optional on update; an empty
|
||||
// value keeps the existing key, a non-empty value replaces it.
|
||||
func (d Deps) handleUpdateModel(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var m store.ModelConfig
|
||||
if err := d.DB.First(&m, "id = ?", id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到"})
|
||||
return
|
||||
}
|
||||
var req modelConfigReq
|
||||
if !bind(c, &req) {
|
||||
return
|
||||
}
|
||||
req.BaseURL = strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
||||
m.Name = req.Name
|
||||
m.BaseURL = req.BaseURL
|
||||
m.DefaultModel = req.DefaultModel
|
||||
m.MaxTokens = req.MaxTokens
|
||||
if req.SupportsTools != nil {
|
||||
m.SupportsTools = *req.SupportsTools
|
||||
}
|
||||
if strings.TrimSpace(req.APIKey) != "" {
|
||||
enc, err := d.Crypto.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||
return
|
||||
}
|
||||
m.APIKeyEncrypted = enc
|
||||
m.APIKeyPreview = security.GenerateAPIKeyPreview(req.APIKey)
|
||||
}
|
||||
if err := d.DB.Save(&m).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
|
||||
return
|
||||
}
|
||||
d.audit(c, nil, nil, "model_config.update", map[string]any{"id": m.ID})
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// DELETE /admin/model-configs/:id
|
||||
func (d Deps) handleDeleteModel(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
res := d.DB.Delete(&store.ModelConfig{}, "id = ?", id)
|
||||
if res.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "未找到"})
|
||||
return
|
||||
}
|
||||
d.audit(c, nil, nil, "model_config.delete", map[string]any{"id": id})
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
23
internal/server/paths.go
Normal file
23
internal/server/paths.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// absSkillPath joins a skill's relative file path (stored as
|
||||
// "<app_slug>/<skill_slug>.md") onto the configured skills root, yielding the
|
||||
// absolute on-disk location of the markdown file.
|
||||
func absSkillPath(skillsDir, relPath string) string {
|
||||
return filepath.Join(skillsDir, filepath.Clean("/"+relPath))
|
||||
}
|
||||
|
||||
// removeAppSkillDir deletes the per-app skill directory after an app is
|
||||
// removed. Missing directory and non-empty errors are ignored; this is
|
||||
// best-effort cleanup.
|
||||
func removeAppSkillDir(skillsDir, appSlug string) {
|
||||
if appSlug == "" {
|
||||
return
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(skillsDir, appSlug))
|
||||
}
|
||||
149
internal/server/server.go
Normal file
149
internal/server/server.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Package server assembles the HTTP application: it wires the gin engine,
|
||||
// mounts route groups, and exposes helpers used by main for bootstrap.
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/config"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/security"
|
||||
"code.littlelan.cn/CarrotAssistant/backend/internal/store"
|
||||
)
|
||||
|
||||
// Deps bundles the shared dependencies every handler group needs. Keeping
|
||||
// them in one struct avoids a long parameter list as routes grow.
|
||||
type Deps struct {
|
||||
DB *gorm.DB
|
||||
Config *config.Config
|
||||
Crypto *security.Crypto
|
||||
SkillsDir string
|
||||
}
|
||||
|
||||
// New builds the gin engine with all route groups mounted.
|
||||
func New(d Deps) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(requestLogger())
|
||||
// Permissive CORS for development; the admin SPA and any embedded client
|
||||
// run on different origins. Tighten AllowedOrigins in production.
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOriginFunc: func(string) bool { return true },
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-App-Token"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
// Liveness probe for container orchestrators.
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "ts": time.Now().Unix()})
|
||||
})
|
||||
|
||||
d.mountAdminRoutes(r)
|
||||
d.mountChatRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// mountChatRoutes wires the public end-user Chat API under /api/v1. Every
|
||||
// route requires a valid X-App-Token.
|
||||
func (d Deps) mountChatRoutes(r *gin.Engine) {
|
||||
g := r.Group("/api/v1")
|
||||
g.Use(d.RequireAppToken())
|
||||
g.POST("/chat", d.handleChat)
|
||||
g.POST("/chat/confirm", d.handleConfirm)
|
||||
}
|
||||
|
||||
// mountAdminRoutes wires everything under /admin. Login is public; every
|
||||
// other route sits behind RequireAdmin.
|
||||
func (d Deps) mountAdminRoutes(r *gin.Engine) {
|
||||
g := r.Group("/admin")
|
||||
g.POST("/login", d.handleLogin)
|
||||
|
||||
auth := g.Group("")
|
||||
auth.Use(d.RequireAdmin())
|
||||
auth.GET("/me", d.handleMe)
|
||||
|
||||
// Apps
|
||||
auth.GET("/apps", d.handleListApps)
|
||||
auth.POST("/apps", d.handleCreateApp)
|
||||
auth.GET("/apps/:id", d.handleGetApp)
|
||||
auth.PUT("/apps/:id", d.handleUpdateApp)
|
||||
auth.DELETE("/apps/:id", d.handleDeleteApp)
|
||||
auth.POST("/apps/:id/token/rotate", d.handleRotateToken)
|
||||
|
||||
// Model configs
|
||||
auth.GET("/model-configs", d.handleListModels)
|
||||
auth.POST("/model-configs", d.handleCreateModel)
|
||||
auth.PUT("/model-configs/:id", d.handleUpdateModel)
|
||||
auth.DELETE("/model-configs/:id", d.handleDeleteModel)
|
||||
|
||||
// Skills
|
||||
auth.GET("/apps/:id/skills", d.handleListSkills)
|
||||
auth.POST("/apps/:id/skills/generate", d.handleGenerateSkill)
|
||||
auth.GET("/skills/:id", d.handleGetSkill)
|
||||
auth.PUT("/skills/:id", d.handleUpdateSkill)
|
||||
auth.POST("/skills/:id/publish", d.handlePublishSkill)
|
||||
auth.DELETE("/skills/:id", d.handleDeleteSkill)
|
||||
auth.POST("/skills/:id/test", d.handleTestSkill)
|
||||
|
||||
// Audit logs
|
||||
auth.GET("/audit-logs", d.handleListAuditLogs)
|
||||
}
|
||||
|
||||
func requestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
gin.DefaultErrorWriter.Write([]byte(
|
||||
c.Request.Method + " " + c.Request.URL.RequestURI() +
|
||||
" " + itoa(c.Writer.Status()) + " " + time.Since(start).String() + "\n",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// itoa keeps a tiny stdlib-only int->string here so the logger above does
|
||||
// not pull in strconv just for one line. Using strconv would be equally fine.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var b [12]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
b[i] = '-'
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
// EnsureAdmin creates username with the given password if no admin exists.
|
||||
// It is a no-op when the username is already taken.
|
||||
func EnsureAdmin(db *gorm.DB, username, password string) error {
|
||||
var count int64
|
||||
if err := db.Model(&store.AdminUser{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&store.AdminUser{Username: username, PasswordHash: hash}).Error
|
||||
}
|
||||
49
internal/server/util.go
Normal file
49
internal/server/util.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// marshalJSON converts a map to datatypes.JSON for storage. Returns nil on
|
||||
// nil input so empty detail columns stay NULL-ish rather than "null".
|
||||
func marshalJSON(m map[string]any) datatypes.JSON {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return datatypes.JSON([]byte("{}"))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// bind parses the JSON request body into out and writes a 400 on failure.
|
||||
// Returns false (and aborts) when the body is invalid so callers can early-return.
|
||||
func bind(c *gin.Context, out any) bool {
|
||||
if err := c.ShouldBindJSON(out); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// jsonRaw marshals v to datatypes.JSON for storage in a JSON column.
|
||||
func jsonRaw(v any) datatypes.JSON {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return datatypes.JSON([]byte("null"))
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// jsonUnmarshal decodes a datatypes.JSON value into out.
|
||||
func jsonUnmarshal(raw datatypes.JSON, out any) error {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
12
internal/server/uuid.go
Normal file
12
internal/server/uuid.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// newUUID generates a v4 UUID string for session IDs.
|
||||
func newUUID() (string, error) {
|
||||
u, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user