Initial commit: CarrotAssistant backend (Go + Gin + SQLite, OpenAI-compatible agent runtime)
This commit is contained in:
45
internal/store/db.go
Normal file
45
internal/store/db.go
Normal 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
110
internal/store/models.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user