Files
vessel/backend/internal/models/chat.go
vikingowl 6868027a1c feat: add model-specific prompts and custom model creation
Adds two related features for enhanced model customization:

**Model-Specific System Prompts:**
- Assign prompts to models via Settings > Model Prompts
- Capability-based default prompts (vision, tools, thinking, code)
- Auto-select appropriate prompt when switching models in chat
- Per-model prompt mappings stored in IndexedDB

**Custom Ollama Model Creation:**
- Create custom models with embedded system prompts via Models page
- Edit system prompts of existing custom models
- Streaming progress during model creation
- Visual "Custom" badge for models with embedded prompts
- Backend handler for Ollama /api/create endpoint

New files:
- ModelEditorDialog.svelte: Create/edit dialog for custom models
- model-creation.svelte.ts: State management for model operations
- model-prompt-mappings.svelte.ts: Model-to-prompt mapping store
- model-info-service.ts: Fetches and caches model info from Ollama
- modelfile-parser.ts: Parses system prompts from Modelfiles
2026-01-03 21:12:49 +01:00

498 lines
14 KiB
Go

package models
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
)
// Chat represents a chat conversation
type Chat struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
SystemPromptID *string `json:"system_prompt_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncVersion int64 `json:"sync_version"`
Messages []Message `json:"messages,omitempty"`
}
// Message represents a chat message
type Message struct {
ID string `json:"id"`
ChatID string `json:"chat_id"`
ParentID *string `json:"parent_id,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
SiblingIndex int `json:"sibling_index"`
CreatedAt time.Time `json:"created_at"`
SyncVersion int64 `json:"sync_version"`
Attachments []Attachment `json:"attachments,omitempty"`
}
// Attachment represents a file attached to a message
type Attachment struct {
ID string `json:"id"`
MessageID string `json:"message_id"`
MimeType string `json:"mime_type"`
Data []byte `json:"data,omitempty"`
Filename string `json:"filename"`
}
// CreateChat creates a new chat in the database
func CreateChat(db *sql.DB, chat *Chat) error {
if chat.ID == "" {
chat.ID = uuid.New().String()
}
now := time.Now().UTC()
chat.CreatedAt = now
chat.UpdatedAt = now
chat.SyncVersion = 1
_, err := db.Exec(`
INSERT INTO chats (id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
chat.ID, chat.Title, chat.Model, chat.Pinned, chat.Archived, chat.SystemPromptID,
chat.CreatedAt.Format(time.RFC3339), chat.UpdatedAt.Format(time.RFC3339), chat.SyncVersion,
)
if err != nil {
return fmt.Errorf("failed to create chat: %w", err)
}
return nil
}
// GetChat retrieves a chat by ID with its messages
func GetChat(db *sql.DB, id string) (*Chat, error) {
chat := &Chat{}
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
err := db.QueryRow(`
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version
FROM chats WHERE id = ?`, id).Scan(
&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt, &chat.SyncVersion,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
// Get messages
messages, err := GetMessagesByChatID(db, id)
if err != nil {
return nil, err
}
chat.Messages = messages
return chat, nil
}
// ListChats retrieves all chats ordered by updated_at
func ListChats(db *sql.DB, includeArchived bool) ([]Chat, error) {
query := `
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version
FROM chats`
if !includeArchived {
query += " WHERE archived = 0"
}
query += " ORDER BY pinned DESC, updated_at DESC"
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to list chats: %w", err)
}
defer rows.Close()
var chats []Chat
for rows.Next() {
var chat Chat
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt, &chat.SyncVersion); err != nil {
return nil, fmt.Errorf("failed to scan chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
chats = append(chats, chat)
}
return chats, nil
}
// UpdateChat updates an existing chat
func UpdateChat(db *sql.DB, chat *Chat) error {
chat.UpdatedAt = time.Now().UTC()
chat.SyncVersion++
result, err := db.Exec(`
UPDATE chats SET title = ?, model = ?, pinned = ?, archived = ?, system_prompt_id = ?,
updated_at = ?, sync_version = ?
WHERE id = ?`,
chat.Title, chat.Model, chat.Pinned, chat.Archived, chat.SystemPromptID,
chat.UpdatedAt.Format(time.RFC3339), chat.SyncVersion, chat.ID,
)
if err != nil {
return fmt.Errorf("failed to update chat: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("chat not found")
}
return nil
}
// DeleteChat deletes a chat and its associated messages
func DeleteChat(db *sql.DB, id string) error {
result, err := db.Exec("DELETE FROM chats WHERE id = ?", id)
if err != nil {
return fmt.Errorf("failed to delete chat: %w", err)
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return fmt.Errorf("chat not found")
}
return nil
}
// CreateMessage creates a new message in the database
func CreateMessage(db *sql.DB, msg *Message) error {
if msg.ID == "" {
msg.ID = uuid.New().String()
}
msg.CreatedAt = time.Now().UTC()
msg.SyncVersion = 1
_, err := db.Exec(`
INSERT INTO messages (id, chat_id, parent_id, role, content, sibling_index, created_at, sync_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
msg.ID, msg.ChatID, msg.ParentID, msg.Role, msg.Content,
msg.SiblingIndex, msg.CreatedAt.Format(time.RFC3339), msg.SyncVersion,
)
if err != nil {
return fmt.Errorf("failed to create message: %w", err)
}
// Update chat's updated_at timestamp
db.Exec("UPDATE chats SET updated_at = ?, sync_version = sync_version + 1 WHERE id = ?",
time.Now().UTC().Format(time.RFC3339), msg.ChatID)
return nil
}
// GetMessagesByChatID retrieves all messages for a chat
func GetMessagesByChatID(db *sql.DB, chatID string) ([]Message, error) {
rows, err := db.Query(`
SELECT id, chat_id, parent_id, role, content, sibling_index, created_at, sync_version
FROM messages WHERE chat_id = ? ORDER BY created_at ASC`, chatID)
if err != nil {
return nil, fmt.Errorf("failed to get messages: %w", err)
}
defer rows.Close()
var messages []Message
for rows.Next() {
var msg Message
var createdAt string
var parentID sql.NullString
if err := rows.Scan(&msg.ID, &msg.ChatID, &parentID, &msg.Role,
&msg.Content, &msg.SiblingIndex, &createdAt, &msg.SyncVersion); err != nil {
return nil, fmt.Errorf("failed to scan message: %w", err)
}
if parentID.Valid {
msg.ParentID = &parentID.String
}
msg.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
messages = append(messages, msg)
}
return messages, nil
}
// GetChangedChats retrieves chats changed since a given sync version
func GetChangedChats(db *sql.DB, sinceVersion int64) ([]Chat, error) {
rows, err := db.Query(`
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at, sync_version
FROM chats WHERE sync_version > ? ORDER BY sync_version ASC`, sinceVersion)
if err != nil {
return nil, fmt.Errorf("failed to get changed chats: %w", err)
}
defer rows.Close()
var chats []Chat
for rows.Next() {
var chat Chat
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt, &chat.SyncVersion); err != nil {
return nil, fmt.Errorf("failed to scan chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
// Get messages for this chat
messages, err := GetMessagesByChatID(db, chat.ID)
if err != nil {
return nil, err
}
chat.Messages = messages
chats = append(chats, chat)
}
return chats, nil
}
// DateGroup represents a date-based grouping label
type DateGroup string
const (
DateGroupToday DateGroup = "Today"
DateGroupYesterday DateGroup = "Yesterday"
DateGroupThisWeek DateGroup = "This Week"
DateGroupLastWeek DateGroup = "Last Week"
DateGroupThisMonth DateGroup = "This Month"
DateGroupLastMonth DateGroup = "Last Month"
DateGroupOlder DateGroup = "Older"
)
// GroupedChat represents a chat in a grouped list (without messages for efficiency)
type GroupedChat struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
Pinned bool `json:"pinned"`
Archived bool `json:"archived"`
SystemPromptID *string `json:"system_prompt_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChatGroup represents a group of chats with a date label
type ChatGroup struct {
Group string `json:"group"`
Chats []GroupedChat `json:"chats"`
}
// GroupedChatsResponse represents the paginated grouped chats response
type GroupedChatsResponse struct {
Groups []ChatGroup `json:"groups"`
Total int `json:"total"`
TotalPinned int `json:"totalPinned"`
}
// getDateGroup determines which date group a timestamp belongs to
func getDateGroup(t time.Time, now time.Time) DateGroup {
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startOfYesterday := startOfToday.AddDate(0, 0, -1)
// Calculate start of this week (Monday)
daysFromMonday := int(now.Weekday()) - 1
if daysFromMonday < 0 {
daysFromMonday = 6 // Sunday
}
startOfThisWeek := startOfToday.AddDate(0, 0, -daysFromMonday)
startOfLastWeek := startOfThisWeek.AddDate(0, 0, -7)
// Start of this month and last month
startOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
startOfLastMonth := startOfThisMonth.AddDate(0, -1, 0)
if t.After(startOfToday) || t.Equal(startOfToday) {
return DateGroupToday
}
if t.After(startOfYesterday) || t.Equal(startOfYesterday) {
return DateGroupYesterday
}
if t.After(startOfThisWeek) || t.Equal(startOfThisWeek) {
return DateGroupThisWeek
}
if t.After(startOfLastWeek) || t.Equal(startOfLastWeek) {
return DateGroupLastWeek
}
if t.After(startOfThisMonth) || t.Equal(startOfThisMonth) {
return DateGroupThisMonth
}
if t.After(startOfLastMonth) || t.Equal(startOfLastMonth) {
return DateGroupLastMonth
}
return DateGroupOlder
}
// ListChatsGrouped retrieves chats grouped by date with search/filter support
func ListChatsGrouped(db *sql.DB, search string, includeArchived bool, limit, offset int) (*GroupedChatsResponse, error) {
// Build query with optional search filter
query := `
SELECT id, title, model, pinned, archived, system_prompt_id, created_at, updated_at
FROM chats
WHERE 1=1`
args := []interface{}{}
if !includeArchived {
query += " AND archived = 0"
}
if search != "" {
query += " AND title LIKE ?"
args = append(args, "%"+search+"%")
}
// Always sort: pinned first, then by updated_at desc
query += " ORDER BY pinned DESC, updated_at DESC"
// Apply pagination if specified
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
if offset > 0 {
query += " OFFSET ?"
args = append(args, offset)
}
}
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to list grouped chats: %w", err)
}
defer rows.Close()
// Collect all chats first
var chats []GroupedChat
now := time.Now()
for rows.Next() {
var chat GroupedChat
var createdAt, updatedAt string
var pinned, archived int
var systemPromptID sql.NullString
if err := rows.Scan(&chat.ID, &chat.Title, &chat.Model, &pinned, &archived, &systemPromptID,
&createdAt, &updatedAt); err != nil {
return nil, fmt.Errorf("failed to scan chat: %w", err)
}
chat.Pinned = pinned == 1
chat.Archived = archived == 1
if systemPromptID.Valid {
chat.SystemPromptID = &systemPromptID.String
}
chat.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
chat.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
chats = append(chats, chat)
}
// Group chats by date
orderedGroups := []DateGroup{
DateGroupToday,
DateGroupYesterday,
DateGroupThisWeek,
DateGroupLastWeek,
DateGroupThisMonth,
DateGroupLastMonth,
DateGroupOlder,
}
groupMap := make(map[DateGroup][]GroupedChat)
for _, g := range orderedGroups {
groupMap[g] = []GroupedChat{}
}
totalPinned := 0
for _, chat := range chats {
if chat.Pinned {
totalPinned++
}
group := getDateGroup(chat.UpdatedAt, now)
groupMap[group] = append(groupMap[group], chat)
}
// Build result with non-empty groups only
var groups []ChatGroup
for _, g := range orderedGroups {
if len(groupMap[g]) > 0 {
groups = append(groups, ChatGroup{
Group: string(g),
Chats: groupMap[g],
})
}
}
// Get total count for pagination
countQuery := "SELECT COUNT(*) FROM chats WHERE 1=1"
countArgs := []interface{}{}
if !includeArchived {
countQuery += " AND archived = 0"
}
if search != "" {
countQuery += " AND title LIKE ?"
countArgs = append(countArgs, "%"+search+"%")
}
var total int
err = db.QueryRow(countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, fmt.Errorf("failed to count chats: %w", err)
}
return &GroupedChatsResponse{
Groups: groups,
Total: total,
TotalPinned: totalPinned,
}, nil
}
// GetMaxSyncVersion returns the maximum sync version across all tables
func GetMaxSyncVersion(db *sql.DB) (int64, error) {
var maxVersion int64
err := db.QueryRow(`
SELECT MAX(sync_version) FROM (
SELECT MAX(sync_version) as sync_version FROM chats
UNION ALL
SELECT MAX(sync_version) FROM messages
)`).Scan(&maxVersion)
if err != nil {
return 0, fmt.Errorf("failed to get max sync version: %w", err)
}
return maxVersion, nil
}