Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)

This commit is contained in:
2026-07-06 16:10:55 +08:00
commit 5f84739b18
40 changed files with 4781 additions and 0 deletions

122
internal/server/agentkit.go Normal file
View 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
View 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"))
}
}

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

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

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

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

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

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

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

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

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