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
498 lines
14 KiB
Go
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
|
|
}
|