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

45
internal/store/db.go Normal file
View File

@@ -0,0 +1,45 @@
package store
import (
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Open opens (or creates) the SQLite database at path and runs GORM
// AutoMigrate for all models. SQLite with WAL is used for single-instance
// durability; the abstraction lives entirely in this package so a future
// migration to Postgres stays localised.
func Open(path string) (*gorm.DB, error) {
// _busy_timeout helps under concurrent writes; _foreign_keys enforces FK
// constraints that GORM expects; _journal_mode=WAL improves read/write
// concurrency for the single-file database.
dsn := fmt.Sprintf("%s?_busy_timeout=5000&_foreign_keys=on&_journal_mode=WAL", path)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
// Silent: our handlers emit their own HTTP errors and frequently run
// "first-or-create" / 404 lookups whose ErrRecordNotFound would
// otherwise spam the operator's logs.
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.AutoMigrate(
&AdminUser{},
&App{},
&ModelConfig{},
&Skill{},
&Session{},
&Message{},
&AuditLog{},
); err != nil {
return nil, fmt.Errorf("auto-migrate: %w", err)
}
// Compound uniqueness: a skill slug is unique per app. Enforced in code
// to avoid depending on DB-specific index syntax during migration.
return db, nil
}

110
internal/store/models.go Normal file
View File

@@ -0,0 +1,110 @@
// Package store defines the persistence layer for CarrotAssistant.
//
// All persistent structured state lives here: admin accounts, applications,
// model configurations, skill metadata, chat sessions and messages, and
// audit logs. Skill *content* itself (the markdown file) lives on disk and
// is managed by the skill package; this package only stores the metadata
// needed to locate and govern that file (status, version, ownership).
package store
import (
"time"
"gorm.io/datatypes"
)
// AdminUser is an operator who can log in to the admin console.
type AdminUser struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// App is a target application (a forum, a resource site, ...). Each App owns
// a set of Skills and exposes a Chat API guarded by its access token.
type App struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
Name string `gorm:"size:128;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
TokenHash string `gorm:"size:255;not null" json:"-"` // SHA-256 hash of the access token; never store plaintext
ModelConfigID *int64 `gorm:"index" json:"model_config_id,omitempty"`
Status string `gorm:"size:32;not null;default:'active'" json:"status"` // active | disabled
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ModelConfig describes an OpenAI-compatible LLM endpoint. Any provider
// speaking the OpenAI Chat Completions + tool-calling protocol (OpenAI,
// DeepSeek, Moonshot, vLLM, Ollama, ...) can be wired in here.
type ModelConfig struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
BaseURL string `gorm:"size:255;not null" json:"base_url"`
APIKeyEncrypted string `gorm:"size:512;not null" json:"-"` // AES-GCM ciphertext, base64
APIKeyPreview string `gorm:"size:16" json:"api_key_preview"` // last 4 chars, display only
DefaultModel string `gorm:"size:64;not null" json:"default_model"`
SupportsTools bool `gorm:"not null;default:true" json:"supports_tools"`
MaxTokens int `gorm:"default:0" json:"max_tokens"` // 0 = leave unset
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Skill is the metadata row for a skill markdown file. The markdown content
// (system prompt + tool definitions) lives at FilePath; this row only governs
// lifecycle (draft/published/archived), versioning, and ownership.
type Skill struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
AppID int64 `gorm:"index;not null" json:"app_id"`
Slug string `gorm:"size:64;not null" json:"slug"` // unique within app
Name string `gorm:"size:128;not null" json:"name"`
Description string `gorm:"size:512" json:"description"`
FilePath string `gorm:"size:512;not null" json:"file_path"` // path under SkillsDir
Version int `gorm:"not null;default:1" json:"version"`
Status string `gorm:"size:32;not null;default:'draft'" json:"status"` // draft | published | archived
Source string `gorm:"size:32" json:"source"` // openapi | markdown | source | manual
PublishedAt *time.Time `json:"published_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Session is a chat conversation between an end-user and an App's assistant.
type Session struct {
ID string `gorm:"primaryKey;type:text" json:"id"` // UUID
AppID int64 `gorm:"index;not null" json:"app_id"`
Title string `gorm:"size:255" json:"title"`
MessageCount int `gorm:"not null;default:0" json:"message_count"`
// PendingConfirmation, when non-empty, holds a JSON blob describing a
// mutating tool call awaiting the user's approval. The chat stream pauses
// until POST /chat/confirm resolves it. Empty = no pending call.
PendingConfirmation datatypes.JSON `json:"pending_confirmation,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Message is a single message within a Session. Role follows the OpenAI
// convention: system | user | assistant | tool. ToolCalls stores the
// assistant's tool call payload (JSON) when present.
type Message struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
SessionID string `gorm:"index;not null" json:"session_id"`
Role string `gorm:"size:32;not null" json:"role"` // system | user | assistant | tool
Content string `gorm:"type:text" json:"content"`
ToolCalls datatypes.JSON `json:"tool_calls,omitempty"`
ToolCallID string `gorm:"size:64" json:"tool_call_id,omitempty"` // for role=tool, the call id it answers
Tokens int `gorm:"default:0" json:"tokens"`
CreatedAt time.Time `json:"created_at"`
}
// AuditLog records security-relevant actions for traceability.
type AuditLog struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
AppID *int64 `gorm:"index" json:"app_id,omitempty"`
SessionID *string `gorm:"index" json:"session_id,omitempty"`
Actor string `gorm:"size:128" json:"actor"` // admin username, "system", app slug, ...
Action string `gorm:"size:64;not null;index" json:"action"`
Detail datatypes.JSON `json:"detail"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}