app: add code for macOS and Windows apps under 'app' (#12933)

* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------

Co-authored-by: jmorganca <jmorganca@gmail.com>
This commit is contained in:
Daniel Hiltgen
2025-11-04 11:40:17 -08:00
committed by GitHub
parent a4770107a6
commit d3b4b9970a
212 changed files with 102976 additions and 1482 deletions

1222
app/store/database.go Normal file

File diff suppressed because it is too large Load Diff

407
app/store/database_test.go Normal file
View File

@@ -0,0 +1,407 @@
//go:build windows || darwin
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
_ "github.com/mattn/go-sqlite3"
)
func TestSchemaMigrations(t *testing.T) {
t.Run("schema comparison after migration", func(t *testing.T) {
tmpDir := t.TempDir()
migratedDBPath := filepath.Join(tmpDir, "migrated.db")
migratedDB := loadV2Schema(t, migratedDBPath)
defer migratedDB.Close()
if err := migratedDB.migrate(); err != nil {
t.Fatalf("migration failed: %v", err)
}
// Create fresh database with current schema
freshDBPath := filepath.Join(tmpDir, "fresh.db")
freshDB, err := newDatabase(freshDBPath)
if err != nil {
t.Fatalf("failed to create fresh database: %v", err)
}
defer freshDB.Close()
// Extract tables and indexes from both databases, directly comparing their schemas won't work due to ordering
migratedSchema := schemaMap(migratedDB)
freshSchema := schemaMap(freshDB)
if !cmp.Equal(migratedSchema, freshSchema) {
t.Errorf("Schema difference found:\n%s", cmp.Diff(freshSchema, migratedSchema))
}
// Verify both databases have the same final schema version
migratedVersion, _ := migratedDB.getSchemaVersion()
freshVersion, _ := freshDB.getSchemaVersion()
if migratedVersion != freshVersion {
t.Errorf("schema version mismatch: migrated=%d, fresh=%d", migratedVersion, freshVersion)
}
})
t.Run("idempotent migrations", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db := loadV2Schema(t, dbPath)
defer db.Close()
// Run migration twice
if err := db.migrate(); err != nil {
t.Fatalf("first migration failed: %v", err)
}
if err := db.migrate(); err != nil {
t.Fatalf("second migration failed: %v", err)
}
// Verify schema version is still correct
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != currentSchemaVersion {
t.Errorf("expected schema version %d after double migration, got %d", currentSchemaVersion, version)
}
})
t.Run("init database has correct schema version", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Get the schema version from the newly initialized database
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
// Verify it matches the currentSchemaVersion constant
if version != currentSchemaVersion {
t.Errorf("expected schema version %d in initialized database, got %d", currentSchemaVersion, version)
}
})
}
func TestChatDeletionWithCascade(t *testing.T) {
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Create test chat
testChatID := "test-chat-cascade-123"
testChat := Chat{
ID: testChatID,
Title: "Test Chat for Cascade Delete",
CreatedAt: time.Now(),
Messages: []Message{
{
Role: "user",
Content: "Hello, this is a test message",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
Role: "assistant",
Content: "Hi there! This is a response.",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
},
}
// Save the chat with messages
if err := db.saveChat(testChat); err != nil {
t.Fatalf("failed to save test chat: %v", err)
}
// Verify chat and messages exist
chatCount := countRows(t, db, "chats")
messageCount := countRows(t, db, "messages")
if chatCount != 1 {
t.Errorf("expected 1 chat, got %d", chatCount)
}
if messageCount != 2 {
t.Errorf("expected 2 messages, got %d", messageCount)
}
// Verify specific chat exists
var exists bool
err = db.conn.QueryRow("SELECT EXISTS(SELECT 1 FROM chats WHERE id = ?)", testChatID).Scan(&exists)
if err != nil {
t.Fatalf("failed to check chat existence: %v", err)
}
if !exists {
t.Error("test chat should exist before deletion")
}
// Verify messages exist for this chat
messageCountForChat := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if messageCountForChat != 2 {
t.Errorf("expected 2 messages for test chat, got %d", messageCountForChat)
}
// Delete the chat
if err := db.deleteChat(testChatID); err != nil {
t.Fatalf("failed to delete chat: %v", err)
}
// Verify chat is deleted
chatCountAfter := countRows(t, db, "chats")
if chatCountAfter != 0 {
t.Errorf("expected 0 chats after deletion, got %d", chatCountAfter)
}
// Verify messages are CASCADE deleted
messageCountAfter := countRows(t, db, "messages")
if messageCountAfter != 0 {
t.Errorf("expected 0 messages after CASCADE deletion, got %d", messageCountAfter)
}
// Verify specific chat no longer exists
err = db.conn.QueryRow("SELECT EXISTS(SELECT 1 FROM chats WHERE id = ?)", testChatID).Scan(&exists)
if err != nil {
t.Fatalf("failed to check chat existence after deletion: %v", err)
}
if exists {
t.Error("test chat should not exist after deletion")
}
// Verify no orphaned messages remain
orphanedCount := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if orphanedCount != 0 {
t.Errorf("expected 0 orphaned messages, got %d", orphanedCount)
}
})
t.Run("foreign keys are enabled", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Verify foreign keys are enabled
var foreignKeysEnabled int
err = db.conn.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled)
if err != nil {
t.Fatalf("failed to check foreign keys: %v", err)
}
if foreignKeysEnabled != 1 {
t.Errorf("expected foreign keys to be enabled (1), got %d", foreignKeysEnabled)
}
})
// This test is only relevant for v8 migrations, but we keep it here for now
// since it's a useful test to ensure that we don't introduce any new orphaned data
t.Run("cleanup orphaned data", func(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// First disable foreign keys to simulate the bug from ollama/ollama#11785
_, err = db.conn.Exec("PRAGMA foreign_keys = OFF")
if err != nil {
t.Fatalf("failed to disable foreign keys: %v", err)
}
// Create a chat and message
testChatID := "orphaned-test-chat"
testMessageID := int64(999)
_, err = db.conn.Exec("INSERT INTO chats (id, title) VALUES (?, ?)", testChatID, "Orphaned Test Chat")
if err != nil {
t.Fatalf("failed to insert test chat: %v", err)
}
_, err = db.conn.Exec("INSERT INTO messages (id, chat_id, role, content) VALUES (?, ?, ?, ?)",
testMessageID, testChatID, "user", "test message")
if err != nil {
t.Fatalf("failed to insert test message: %v", err)
}
// Delete chat but keep message (simulating the bug from ollama/ollama#11785)
_, err = db.conn.Exec("DELETE FROM chats WHERE id = ?", testChatID)
if err != nil {
t.Fatalf("failed to delete chat: %v", err)
}
// Verify we have orphaned message
orphanedCount := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if orphanedCount != 1 {
t.Errorf("expected 1 orphaned message, got %d", orphanedCount)
}
// Run cleanup
if err := db.cleanupOrphanedData(); err != nil {
t.Fatalf("failed to cleanup orphaned data: %v", err)
}
// Verify orphaned message is gone
orphanedCountAfter := countRowsWithCondition(t, db, "messages", "chat_id = ?", testChatID)
if orphanedCountAfter != 0 {
t.Errorf("expected 0 orphaned messages after cleanup, got %d", orphanedCountAfter)
}
})
}
func countRows(t *testing.T, db *database, table string) int {
t.Helper()
var count int
err := db.conn.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count)
if err != nil {
t.Fatalf("failed to count rows in %s: %v", table, err)
}
return count
}
func countRowsWithCondition(t *testing.T, db *database, table, condition string, args ...interface{}) int {
t.Helper()
var count int
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", table, condition)
err := db.conn.QueryRow(query, args...).Scan(&count)
if err != nil {
t.Fatalf("failed to count rows with condition: %v", err)
}
return count
}
// Test helpers for schema migration testing
// schemaMap returns both tables/columns and indexes (ignoring order)
func schemaMap(db *database) map[string]interface{} {
result := make(map[string]any)
result["tables"] = columnMap(db)
result["indexes"] = indexMap(db)
return result
}
// columnMap returns a map of table names to their column sets (ignoring order)
func columnMap(db *database) map[string][]string {
result := make(map[string][]string)
// Get all table names
tableQuery := `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`
rows, _ := db.conn.Query(tableQuery)
defer rows.Close()
for rows.Next() {
var tableName string
rows.Scan(&tableName)
// Get columns for this table
colQuery := fmt.Sprintf("PRAGMA table_info(%s)", tableName)
colRows, _ := db.conn.Query(colQuery)
var columns []string
for colRows.Next() {
var cid int
var name, dataType sql.NullString
var notNull, primaryKey int
var defaultValue sql.NullString
colRows.Scan(&cid, &name, &dataType, &notNull, &defaultValue, &primaryKey)
// Create a normalized column description
colDesc := fmt.Sprintf("%s %s", name.String, dataType.String)
if notNull == 1 {
colDesc += " NOT NULL"
}
if defaultValue.Valid && defaultValue.String != "" {
// Skip DEFAULT for schema_version as it doesn't get updated during migrations
if name.String != "schema_version" {
colDesc += " DEFAULT " + defaultValue.String
}
}
if primaryKey == 1 {
colDesc += " PRIMARY KEY"
}
columns = append(columns, colDesc)
}
colRows.Close()
// Sort columns to ignore order differences
sort.Strings(columns)
result[tableName] = columns
}
return result
}
// indexMap returns a map of index names to their definitions
func indexMap(db *database) map[string]string {
result := make(map[string]string)
// Get all indexes (excluding auto-created primary key indexes)
indexQuery := `SELECT name, sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name`
rows, _ := db.conn.Query(indexQuery)
defer rows.Close()
for rows.Next() {
var name, sql string
rows.Scan(&name, &sql)
// Normalize the SQL by removing extra whitespace
sql = strings.Join(strings.Fields(sql), " ")
result[name] = sql
}
return result
}
// loadV2Schema loads the version 2 schema from testdata/schema.sql
func loadV2Schema(t *testing.T, dbPath string) *database {
t.Helper()
// Read the v1 schema file
schemaFile := filepath.Join("testdata", "schema.sql")
schemaSQL, err := os.ReadFile(schemaFile)
if err != nil {
t.Fatalf("failed to read schema file: %v", err)
}
// Open database connection
conn, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate")
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
// Execute the v1 schema
_, err = conn.Exec(string(schemaSQL))
if err != nil {
conn.Close()
t.Fatalf("failed to execute v1 schema: %v", err)
}
return &database{conn: conn}
}

128
app/store/image.go Normal file
View File

@@ -0,0 +1,128 @@
//go:build windows || darwin
package store
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
type Image struct {
Filename string `json:"filename"`
Path string `json:"path"`
Size int64 `json:"size,omitempty"`
MimeType string `json:"mime_type,omitempty"`
}
// Bytes loads image data from disk for a given ImageData reference
func (i *Image) Bytes() ([]byte, error) {
return ImgBytes(i.Path)
}
// ImgBytes reads image data from the specified file path
func ImgBytes(path string) ([]byte, error) {
if path == "" {
return nil, fmt.Errorf("empty image path")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read image file %s: %w", path, err)
}
return data, nil
}
// ImgDir returns the directory path for storing images for a specific chat
func (s *Store) ImgDir() string {
dbPath := s.DBPath
if dbPath == "" {
dbPath = defaultDBPath
}
storeDir := filepath.Dir(dbPath)
return filepath.Join(storeDir, "cache", "images")
}
// ImgToFile saves image data to disk and returns ImageData reference
func (s *Store) ImgToFile(chatID string, imageBytes []byte, filename, mimeType string) (Image, error) {
baseImageDir := s.ImgDir()
if err := os.MkdirAll(baseImageDir, 0o755); err != nil {
return Image{}, fmt.Errorf("create base image directory: %w", err)
}
// Root prevents path traversal issues
root, err := os.OpenRoot(baseImageDir)
if err != nil {
return Image{}, fmt.Errorf("open image root directory: %w", err)
}
defer root.Close()
// Create chat-specific subdirectory within the root
chatDir := sanitize(chatID)
if err := root.Mkdir(chatDir, 0o755); err != nil && !os.IsExist(err) {
return Image{}, fmt.Errorf("create chat directory: %w", err)
}
// Generate a unique filename to avoid conflicts
// Use hash of content + original filename for uniqueness
hash := sha256.Sum256(imageBytes)
hashStr := hex.EncodeToString(hash[:])[:16] // Use first 16 chars of hash
// Extract file extension from original filename or mime type
ext := filepath.Ext(filename)
if ext == "" {
switch mimeType {
case "image/jpeg":
ext = ".jpg"
case "image/png":
ext = ".png"
case "image/webp":
ext = ".webp"
default:
ext = ".img"
}
}
// Create unique filename: hash + original name + extension
baseFilename := sanitize(strings.TrimSuffix(filename, ext))
uniqueFilename := fmt.Sprintf("%s_%s%s", hashStr, baseFilename, ext)
relativePath := filepath.Join(chatDir, uniqueFilename)
file, err := root.Create(relativePath)
if err != nil {
return Image{}, fmt.Errorf("create image file: %w", err)
}
defer file.Close()
if _, err := file.Write(imageBytes); err != nil {
return Image{}, fmt.Errorf("write image data: %w", err)
}
return Image{
Filename: uniqueFilename,
Path: filepath.Join(baseImageDir, relativePath),
Size: int64(len(imageBytes)),
MimeType: mimeType,
}, nil
}
// sanitize removes unsafe characters from filenames
func sanitize(filename string) string {
// Convert to safe characters only
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
return r
}
return '_'
}, filename)
// Clean up and validate
safe = strings.Trim(safe, "_")
if safe == "" {
return "image"
}
return safe
}

231
app/store/migration_test.go Normal file
View File

@@ -0,0 +1,231 @@
//go:build windows || darwin
package store
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestConfigMigration(t *testing.T) {
tmpDir := t.TempDir()
// Create a legacy config.json
legacyConfig := legacyData{
ID: "test-device-id-12345",
FirstTimeRun: true, // In old system, true meant "has completed first run"
}
configData, err := json.MarshalIndent(legacyConfig, "", " ")
if err != nil {
t.Fatal(err)
}
configPath := filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(configPath, configData, 0o644); err != nil {
t.Fatal(err)
}
// Override the legacy config path for testing
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = configPath
defer func() { legacyConfigPath = oldLegacyConfigPath }()
// Create store with database in same directory
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s.Close()
// First access should trigger migration
id, err := s.ID()
if err != nil {
t.Fatalf("failed to get ID: %v", err)
}
if id != "test-device-id-12345" {
t.Errorf("expected migrated ID 'test-device-id-12345', got '%s'", id)
}
// Check HasCompletedFirstRun
hasCompleted, err := s.HasCompletedFirstRun()
if err != nil {
t.Fatalf("failed to get has completed first run: %v", err)
}
if !hasCompleted {
t.Error("expected has completed first run to be true after migration")
}
// Verify migration is marked as complete
migrated, err := s.db.isConfigMigrated()
if err != nil {
t.Fatalf("failed to check migration status: %v", err)
}
if !migrated {
t.Error("expected config to be marked as migrated")
}
// Create a new store instance to verify migration doesn't run again
s2 := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s2.Close()
// Delete the config file to ensure we're not reading from it
os.Remove(configPath)
// Verify data is still there
id2, err := s2.ID()
if err != nil {
t.Fatalf("failed to get ID from second store: %v", err)
}
if id2 != "test-device-id-12345" {
t.Errorf("expected persisted ID 'test-device-id-12345', got '%s'", id2)
}
}
func TestNoConfigToMigrate(t *testing.T) {
tmpDir := t.TempDir()
// Override the legacy config path for testing
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = filepath.Join(tmpDir, "config.json")
defer func() { legacyConfigPath = oldLegacyConfigPath }()
// Create store without any config.json
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s.Close()
// Should generate a new ID
id, err := s.ID()
if err != nil {
t.Fatalf("failed to get ID: %v", err)
}
if id == "" {
t.Error("expected auto-generated ID, got empty string")
}
// HasCompletedFirstRun should be false (default)
hasCompleted, err := s.HasCompletedFirstRun()
if err != nil {
t.Fatalf("failed to get has completed first run: %v", err)
}
if hasCompleted {
t.Error("expected has completed first run to be false by default")
}
// Migration should still be marked as complete
migrated, err := s.db.isConfigMigrated()
if err != nil {
t.Fatalf("failed to check migration status: %v", err)
}
if !migrated {
t.Error("expected config to be marked as migrated even with no config.json")
}
}
const (
v1Schema = `
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
device_id TEXT NOT NULL DEFAULT '',
has_completed_first_run BOOLEAN NOT NULL DEFAULT 0,
expose BOOLEAN NOT NULL DEFAULT 0,
browser BOOLEAN NOT NULL DEFAULT 0,
models TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '',
agent BOOLEAN NOT NULL DEFAULT 0,
tools BOOLEAN NOT NULL DEFAULT 0,
working_dir TEXT NOT NULL DEFAULT '',
window_width INTEGER NOT NULL DEFAULT 0,
window_height INTEGER NOT NULL DEFAULT 0,
config_migrated BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 1
);
-- Insert default settings row if it doesn't exist
INSERT OR IGNORE INTO settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT NOT NULL DEFAULT '',
stream BOOLEAN NOT NULL DEFAULT 0,
model_name TEXT,
model_cloud BOOLEAN,
model_ollama_host BOOLEAN,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
thinking_time_start TIMESTAMP,
thinking_time_end TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
type TEXT NOT NULL,
function_name TEXT NOT NULL,
function_arguments TEXT NOT NULL,
function_result TEXT,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);
`
)
func TestMigrationFromEpoc(t *testing.T) {
tmpDir := t.TempDir()
s := Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
defer s.Close()
// Open database connection
conn, err := sql.Open("sqlite3", s.DBPath+"?_foreign_keys=on&_journal_mode=WAL")
if err != nil {
t.Fatal(err)
}
// Test the connection
if err := conn.Ping(); err != nil {
conn.Close()
t.Fatal(err)
}
s.db = &database{conn: conn}
t.Logf("DB created: %s", s.DBPath)
_, err = s.db.conn.Exec(v1Schema)
if err != nil {
t.Fatal(err)
}
version, err := s.db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != 1 {
t.Fatalf("expected: %d\n got: %d", 1, version)
}
t.Logf("v1 schema created")
if err := s.db.migrate(); err != nil {
t.Fatal(err)
}
t.Logf("migrations completed")
version, err = s.db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != currentSchemaVersion {
t.Fatalf("expected: %d\n got: %d", currentSchemaVersion, version)
}
}

61
app/store/schema.sql Normal file
View File

@@ -0,0 +1,61 @@
-- This is the version 2 schema for the app database, the first released schema to users.
-- Do not modify this file. It is used to test that the database schema stays in a consistent state between schema migrations.
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
device_id TEXT NOT NULL DEFAULT '',
has_completed_first_run BOOLEAN NOT NULL DEFAULT 0,
expose BOOLEAN NOT NULL DEFAULT 0,
survey BOOLEAN NOT NULL DEFAULT TRUE,
browser BOOLEAN NOT NULL DEFAULT 0,
models TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '',
agent BOOLEAN NOT NULL DEFAULT 0,
tools BOOLEAN NOT NULL DEFAULT 0,
working_dir TEXT NOT NULL DEFAULT '',
context_length INTEGER NOT NULL DEFAULT 4096,
window_width INTEGER NOT NULL DEFAULT 0,
window_height INTEGER NOT NULL DEFAULT 0,
config_migrated BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 2
);
-- Insert default settings row if it doesn't exist
INSERT OR IGNORE INTO settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT NOT NULL DEFAULT '',
stream BOOLEAN NOT NULL DEFAULT 0,
model_name TEXT,
model_cloud BOOLEAN,
model_ollama_host BOOLEAN,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
thinking_time_start TIMESTAMP,
thinking_time_end TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
type TEXT NOT NULL,
function_name TEXT NOT NULL,
function_arguments TEXT NOT NULL,
function_result TEXT,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);

60
app/store/schema_test.go Normal file
View File

@@ -0,0 +1,60 @@
//go:build windows || darwin
package store
import (
"path/filepath"
"testing"
)
func TestSchemaVersioning(t *testing.T) {
tmpDir := t.TempDir()
// Override legacy config path to avoid migration logs
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = filepath.Join(tmpDir, "config.json")
defer func() { legacyConfigPath = oldLegacyConfigPath }()
t.Run("new database has correct schema version", func(t *testing.T) {
dbPath := filepath.Join(tmpDir, "new_db.sqlite")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Check schema version
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != currentSchemaVersion {
t.Errorf("expected schema version %d, got %d", currentSchemaVersion, version)
}
})
t.Run("can update schema version", func(t *testing.T) {
dbPath := filepath.Join(tmpDir, "update_db.sqlite")
db, err := newDatabase(dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer db.Close()
// Set a different version
testVersion := 42
if err := db.setSchemaVersion(testVersion); err != nil {
t.Fatalf("failed to set schema version: %v", err)
}
// Verify it was updated
version, err := db.getSchemaVersion()
if err != nil {
t.Fatalf("failed to get schema version: %v", err)
}
if version != testVersion {
t.Errorf("expected schema version %d, got %d", testVersion, version)
}
})
}

View File

@@ -1,97 +1,495 @@
//go:build windows || darwin
// Package store provides a simple JSON file store for the desktop application
// to save and load data such as ollama server configuration, messages,
// login information and more.
package store
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/google/uuid"
"github.com/ollama/ollama/app/types/not"
)
type File struct {
Filename string `json:"filename"`
Data []byte `json:"data"`
}
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Plan string `json:"plan"`
CachedAt time.Time `json:"cachedAt"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Thinking string `json:"thinking"`
Stream bool `json:"stream"`
Model string `json:"model,omitempty"`
Attachments []File `json:"attachments,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCall *ToolCall `json:"tool_call,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolResult *json.RawMessage `json:"tool_result,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ThinkingTimeStart *time.Time `json:"thinkingTimeStart,omitempty" ts_type:"Date | undefined" ts_transform:"__VALUE__ && new Date(__VALUE__)"`
ThinkingTimeEnd *time.Time `json:"thinkingTimeEnd,omitempty" ts_type:"Date | undefined" ts_transform:"__VALUE__ && new Date(__VALUE__)"`
}
// MessageOptions contains optional parameters for creating a Message
type MessageOptions struct {
Model string
Attachments []File
Stream bool
Thinking string
ToolCalls []ToolCall
ToolCall *ToolCall
ToolResult *json.RawMessage
ThinkingTimeStart *time.Time
ThinkingTimeEnd *time.Time
}
// NewMessage creates a new Message with the given options
func NewMessage(role, content string, opts *MessageOptions) Message {
now := time.Now()
msg := Message{
Role: role,
Content: content,
CreatedAt: now,
UpdatedAt: now,
}
if opts != nil {
msg.Model = opts.Model
msg.Attachments = opts.Attachments
msg.Stream = opts.Stream
msg.Thinking = opts.Thinking
msg.ToolCalls = opts.ToolCalls
msg.ToolCall = opts.ToolCall
msg.ToolResult = opts.ToolResult
msg.ThinkingTimeStart = opts.ThinkingTimeStart
msg.ThinkingTimeEnd = opts.ThinkingTimeEnd
}
return msg
}
type ToolCall struct {
Type string `json:"type"`
Function ToolFunction `json:"function"`
}
type ToolFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
Result any `json:"result,omitempty"`
}
type Model struct {
Model string `json:"model"` // Model name
Digest string `json:"digest,omitempty"` // Model digest from the registry
ModifiedAt *time.Time `json:"modified_at,omitempty"` // When the model was last modified locally
}
type Chat struct {
ID string `json:"id"`
Messages []Message `json:"messages"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
BrowserState json.RawMessage `json:"browser_state,omitempty" ts_type:"BrowserStateData"`
}
// NewChat creates a new Chat with the ID, with CreatedAt timestamp initialized
func NewChat(id string) *Chat {
return &Chat{
ID: id,
Messages: []Message{},
CreatedAt: time.Now(),
}
}
type Settings struct {
// Expose is a boolean that indicates if the ollama server should
// be exposed to the network
Expose bool
// Browser is a boolean that indicates if the ollama server should
// be exposed to browser windows (e.g. CORS set to allow all origins)
Browser bool
// Survey is a boolean that indicates if the user allows anonymous
// inference information to be shared with Ollama
Survey bool
// Models is a string that contains the models to load on startup
Models string
// TODO(parthsareen): temporary for experimentation
// Agent indicates if the app should use multi-turn tools to fulfill user requests
Agent bool
// Tools indicates if the app should use single-turn tools to fulfill user requests
Tools bool
// WorkingDir specifies the working directory for all agent operations
WorkingDir string
// ContextLength specifies the context length for the ollama server (using OLLAMA_CONTEXT_LENGTH)
ContextLength int
// AirplaneMode when true, turns off Ollama Turbo features and only uses local models
AirplaneMode bool
// TurboEnabled indicates if Ollama Turbo features are enabled
TurboEnabled bool
// Maps gpt-oss specific frontend name' BrowserToolEnabled' to db field 'websearch_enabled'
WebSearchEnabled bool
// ThinkEnabled indicates if thinking is enabled
ThinkEnabled bool
// ThinkLevel indicates the level of thinking to use for models that support multiple levels
ThinkLevel string
// SelectedModel stores the last model that the user selected
SelectedModel string
// SidebarOpen indicates if the chat sidebar is open
SidebarOpen bool
}
type Store struct {
// DBPath allows overriding the default database path (mainly for testing)
DBPath string
// dbMu protects database initialization only
dbMu sync.Mutex
db *database
}
var defaultDBPath = func() string {
switch runtime.GOOS {
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "db.sqlite")
case "darwin":
return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "db.sqlite")
default:
return filepath.Join(os.Getenv("HOME"), ".ollama", "db.sqlite")
}
}()
// legacyConfigPath is the path to the old config.json file
var legacyConfigPath = func() string {
switch runtime.GOOS {
case "windows":
return filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "config.json")
case "darwin":
return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "config.json")
default:
return filepath.Join(os.Getenv("HOME"), ".ollama", "config.json")
}
}()
// legacyData represents the old config.json structure (only fields we need to migrate)
type legacyData struct {
ID string `json:"id"`
FirstTimeRun bool `json:"first-time-run"`
}
var (
lock sync.Mutex
store Store
)
func GetID() string {
lock.Lock()
defer lock.Unlock()
if store.ID == "" {
initStore()
func (s *Store) ensureDB() error {
// Fast path: check if db is already initialized
if s.db != nil {
return nil
}
return store.ID
}
func GetFirstTimeRun() bool {
lock.Lock()
defer lock.Unlock()
if store.ID == "" {
initStore()
// Slow path: initialize database with lock
s.dbMu.Lock()
defer s.dbMu.Unlock()
// Double-check after acquiring lock
if s.db != nil {
return nil
}
return store.FirstTimeRun
}
func SetFirstTimeRun(val bool) {
lock.Lock()
defer lock.Unlock()
if store.FirstTimeRun == val {
return
dbPath := s.DBPath
if dbPath == "" {
dbPath = defaultDBPath
}
store.FirstTimeRun = val
writeStore(getStorePath())
}
// lock must be held
func initStore() {
storeFile, err := os.Open(getStorePath())
if err == nil {
defer storeFile.Close()
err = json.NewDecoder(storeFile).Decode(&store)
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return fmt.Errorf("create db directory: %w", err)
}
database, err := newDatabase(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
// Generate device ID if needed
id, err := database.getID()
if err != nil || id == "" {
// Generate new UUID for device
u, err := uuid.NewV7()
if err == nil {
slog.Debug(fmt.Sprintf("loaded existing store %s - ID: %s", getStorePath(), store.ID))
return
database.setID(u.String())
}
} else if !errors.Is(err, os.ErrNotExist) {
slog.Debug(fmt.Sprintf("unexpected error searching for store: %s", err))
}
slog.Debug("initializing new store")
store.ID = uuid.NewString()
writeStore(getStorePath())
s.db = database
// Check if we need to migrate from config.json
migrated, err := database.isConfigMigrated()
if err != nil || !migrated {
if err := s.migrateFromConfig(database); err != nil {
slog.Warn("failed to migrate from config.json", "error", err)
}
}
return nil
}
func writeStore(storeFilename string) {
ollamaDir := filepath.Dir(storeFilename)
_, err := os.Stat(ollamaDir)
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(ollamaDir, 0o755); err != nil {
slog.Error(fmt.Sprintf("create ollama dir %s: %v", ollamaDir, err))
return
// migrateFromConfig attempts to migrate ID and FirstTimeRun from config.json
func (s *Store) migrateFromConfig(database *database) error {
configPath := legacyConfigPath
// Check if config.json exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
// No config to migrate, mark as migrated
return database.setConfigMigrated(true)
}
// Read the config file
b, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read legacy config: %w", err)
}
var legacy legacyData
if err := json.Unmarshal(b, &legacy); err != nil {
// If we can't parse it, just mark as migrated and move on
slog.Warn("failed to parse legacy config.json", "error", err)
return database.setConfigMigrated(true)
}
// Migrate the ID if present
if legacy.ID != "" {
if err := database.setID(legacy.ID); err != nil {
return fmt.Errorf("migrate device ID: %w", err)
}
slog.Info("migrated device ID from config.json")
}
hasCompleted := legacy.FirstTimeRun // If old FirstTimeRun is true, it means first run was completed
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
return fmt.Errorf("migrate first time run: %w", err)
}
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
// Mark as migrated
if err := database.setConfigMigrated(true); err != nil {
return fmt.Errorf("mark config as migrated: %w", err)
}
slog.Info("successfully migrated settings from config.json")
return nil
}
func (s *Store) ID() (string, error) {
if err := s.ensureDB(); err != nil {
return "", err
}
return s.db.getID()
}
func (s *Store) HasCompletedFirstRun() (bool, error) {
if err := s.ensureDB(); err != nil {
return false, err
}
return s.db.getHasCompletedFirstRun()
}
func (s *Store) SetHasCompletedFirstRun(hasCompleted bool) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.setHasCompletedFirstRun(hasCompleted)
}
func (s *Store) Settings() (Settings, error) {
if err := s.ensureDB(); err != nil {
return Settings{}, fmt.Errorf("load settings: %w", err)
}
settings, err := s.db.getSettings()
if err != nil {
return Settings{}, err
}
// Set default models directory if not set
if settings.Models == "" {
dir := os.Getenv("OLLAMA_MODELS")
if dir != "" {
settings.Models = dir
} else {
home, err := os.UserHomeDir()
if err == nil {
settings.Models = filepath.Join(home, ".ollama", "models")
}
}
}
payload, err := json.Marshal(store)
if err != nil {
slog.Error(fmt.Sprintf("failed to marshal store: %s", err))
return
}
fp, err := os.OpenFile(storeFilename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
slog.Error(fmt.Sprintf("write store payload %s: %v", storeFilename, err))
return
}
defer fp.Close()
if n, err := fp.Write(payload); err != nil || n != len(payload) {
slog.Error(fmt.Sprintf("write store payload %s: %d vs %d -- %v", storeFilename, n, len(payload), err))
return
}
slog.Debug("Store contents: " + string(payload))
slog.Info(fmt.Sprintf("wrote store: %s", storeFilename))
return settings, nil
}
func (s *Store) SetSettings(settings Settings) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.setSettings(settings)
}
func (s *Store) Chats() ([]Chat, error) {
if err := s.ensureDB(); err != nil {
return nil, err
}
return s.db.getAllChats()
}
func (s *Store) Chat(id string) (*Chat, error) {
return s.ChatWithOptions(id, true)
}
func (s *Store) ChatWithOptions(id string, loadAttachmentData bool) (*Chat, error) {
if err := s.ensureDB(); err != nil {
return nil, err
}
chat, err := s.db.getChatWithOptions(id, loadAttachmentData)
if err != nil {
return nil, fmt.Errorf("%w: chat %s", not.Found, id)
}
return chat, nil
}
func (s *Store) SetChat(chat Chat) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.saveChat(chat)
}
func (s *Store) DeleteChat(id string) error {
if err := s.ensureDB(); err != nil {
return err
}
// Delete from database
if err := s.db.deleteChat(id); err != nil {
return fmt.Errorf("%w: chat %s", not.Found, id)
}
// Also delete associated images
chatImgDir := filepath.Join(s.ImgDir(), id)
if err := os.RemoveAll(chatImgDir); err != nil {
// Log error but don't fail the deletion
slog.Warn("failed to delete chat images", "chat_id", id, "error", err)
}
return nil
}
func (s *Store) WindowSize() (int, int, error) {
if err := s.ensureDB(); err != nil {
return 0, 0, err
}
return s.db.getWindowSize()
}
func (s *Store) SetWindowSize(width, height int) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.setWindowSize(width, height)
}
func (s *Store) UpdateLastMessage(chatID string, message Message) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.updateLastMessage(chatID, message)
}
func (s *Store) AppendMessage(chatID string, message Message) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.appendMessage(chatID, message)
}
func (s *Store) UpdateChatBrowserState(chatID string, state json.RawMessage) error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.updateChatBrowserState(chatID, state)
}
func (s *Store) User() (*User, error) {
if err := s.ensureDB(); err != nil {
return nil, err
}
return s.db.getUser()
}
func (s *Store) SetUser(user User) error {
if err := s.ensureDB(); err != nil {
return err
}
user.CachedAt = time.Now()
return s.db.setUser(user)
}
func (s *Store) ClearUser() error {
if err := s.ensureDB(); err != nil {
return err
}
return s.db.clearUser()
}
func (s *Store) Close() error {
s.dbMu.Lock()
defer s.dbMu.Unlock()
if s.db != nil {
return s.db.Close()
}
return nil
}

View File

@@ -1,13 +0,0 @@
package store
import (
"os"
"path/filepath"
)
func getStorePath() string {
// TODO - system wide location?
home := os.Getenv("HOME")
return filepath.Join(home, "Library", "Application Support", "Ollama", "config.json")
}

View File

@@ -1,16 +0,0 @@
package store
import (
"os"
"path/filepath"
)
func getStorePath() string {
if os.Geteuid() == 0 {
// TODO where should we store this on linux for system-wide operation?
return "/etc/ollama/config.json"
}
home := os.Getenv("HOME")
return filepath.Join(home, ".ollama", "config.json")
}

192
app/store/store_test.go Normal file
View File

@@ -0,0 +1,192 @@
//go:build windows || darwin
package store
import (
"path/filepath"
"testing"
)
func TestStore(t *testing.T) {
s, cleanup := setupTestStore(t)
defer cleanup()
t.Run("default id", func(t *testing.T) {
// ID should be automatically generated
id, err := s.ID()
if err != nil {
t.Fatal(err)
}
if id == "" {
t.Error("expected non-empty ID")
}
// Verify ID is persisted
id2, err := s.ID()
if err != nil {
t.Fatal(err)
}
if id != id2 {
t.Errorf("expected ID %s, got %s", id, id2)
}
})
t.Run("has completed first run", func(t *testing.T) {
// Default should be false (hasn't completed first run yet)
hasCompleted, err := s.HasCompletedFirstRun()
if err != nil {
t.Fatal(err)
}
if hasCompleted {
t.Error("expected has completed first run to be false by default")
}
if err := s.SetHasCompletedFirstRun(true); err != nil {
t.Fatal(err)
}
hasCompleted, err = s.HasCompletedFirstRun()
if err != nil {
t.Fatal(err)
}
if !hasCompleted {
t.Error("expected has completed first run to be true")
}
})
t.Run("settings", func(t *testing.T) {
sc := Settings{
Expose: true,
Browser: true,
Survey: true,
Models: "/tmp/models",
Agent: true,
Tools: false,
WorkingDir: "/tmp/work",
}
if err := s.SetSettings(sc); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
// Compare fields individually since Models might get a default
if loaded.Expose != sc.Expose || loaded.Browser != sc.Browser ||
loaded.Agent != sc.Agent || loaded.Survey != sc.Survey ||
loaded.Tools != sc.Tools || loaded.WorkingDir != sc.WorkingDir {
t.Errorf("expected %v, got %v", sc, loaded)
}
})
t.Run("window size", func(t *testing.T) {
if err := s.SetWindowSize(1024, 768); err != nil {
t.Fatal(err)
}
width, height, err := s.WindowSize()
if err != nil {
t.Fatal(err)
}
if width != 1024 || height != 768 {
t.Errorf("expected 1024x768, got %dx%d", width, height)
}
})
t.Run("create and retrieve chat", func(t *testing.T) {
chat := NewChat("test-chat-1")
chat.Title = "Test Chat"
chat.Messages = append(chat.Messages, NewMessage("user", "Hello", nil))
chat.Messages = append(chat.Messages, NewMessage("assistant", "Hi there!", &MessageOptions{
Model: "llama4",
}))
if err := s.SetChat(*chat); err != nil {
t.Fatalf("failed to save chat: %v", err)
}
retrieved, err := s.Chat("test-chat-1")
if err != nil {
t.Fatalf("failed to retrieve chat: %v", err)
}
if retrieved.ID != chat.ID {
t.Errorf("expected ID %s, got %s", chat.ID, retrieved.ID)
}
if retrieved.Title != chat.Title {
t.Errorf("expected title %s, got %s", chat.Title, retrieved.Title)
}
if len(retrieved.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(retrieved.Messages))
}
if retrieved.Messages[0].Content != "Hello" {
t.Errorf("expected first message 'Hello', got %s", retrieved.Messages[0].Content)
}
if retrieved.Messages[1].Content != "Hi there!" {
t.Errorf("expected second message 'Hi there!', got %s", retrieved.Messages[1].Content)
}
})
t.Run("list chats", func(t *testing.T) {
chat2 := NewChat("test-chat-2")
chat2.Title = "Another Chat"
chat2.Messages = append(chat2.Messages, NewMessage("user", "Test", nil))
if err := s.SetChat(*chat2); err != nil {
t.Fatalf("failed to save chat: %v", err)
}
chats, err := s.Chats()
if err != nil {
t.Fatalf("failed to list chats: %v", err)
}
if len(chats) != 2 {
t.Fatalf("expected 2 chats, got %d", len(chats))
}
})
t.Run("delete chat", func(t *testing.T) {
if err := s.DeleteChat("test-chat-1"); err != nil {
t.Fatalf("failed to delete chat: %v", err)
}
// Verify it's gone
_, err := s.Chat("test-chat-1")
if err == nil {
t.Error("expected error retrieving deleted chat")
}
// Verify other chat still exists
chats, err := s.Chats()
if err != nil {
t.Fatalf("failed to list chats: %v", err)
}
if len(chats) != 1 {
t.Fatalf("expected 1 chat after deletion, got %d", len(chats))
}
})
}
// setupTestStore creates a temporary store for testing
func setupTestStore(t *testing.T) (*Store, func()) {
t.Helper()
tmpDir := t.TempDir()
// Override legacy config path to ensure no migration happens
oldLegacyConfigPath := legacyConfigPath
legacyConfigPath = filepath.Join(tmpDir, "config.json")
s := &Store{DBPath: filepath.Join(tmpDir, "db.sqlite")}
cleanup := func() {
s.Close()
legacyConfigPath = oldLegacyConfigPath
}
return s, cleanup
}

View File

@@ -1,11 +0,0 @@
package store
import (
"os"
"path/filepath"
)
func getStorePath() string {
localAppData := os.Getenv("LOCALAPPDATA")
return filepath.Join(localAppData, "Ollama", "config.json")
}

61
app/store/testdata/schema.sql vendored Normal file
View File

@@ -0,0 +1,61 @@
-- This is the version 2 schema for the app database, the first released schema to users.
-- Do not modify this file. It is used to test that the database schema stays in a consistent state between schema migrations.
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
device_id TEXT NOT NULL DEFAULT '',
has_completed_first_run BOOLEAN NOT NULL DEFAULT 0,
expose BOOLEAN NOT NULL DEFAULT 0,
survey BOOLEAN NOT NULL DEFAULT TRUE,
browser BOOLEAN NOT NULL DEFAULT 0,
models TEXT NOT NULL DEFAULT '',
remote TEXT NOT NULL DEFAULT '',
agent BOOLEAN NOT NULL DEFAULT 0,
tools BOOLEAN NOT NULL DEFAULT 0,
working_dir TEXT NOT NULL DEFAULT '',
context_length INTEGER NOT NULL DEFAULT 4096,
window_width INTEGER NOT NULL DEFAULT 0,
window_height INTEGER NOT NULL DEFAULT 0,
config_migrated BOOLEAN NOT NULL DEFAULT 0,
schema_version INTEGER NOT NULL DEFAULT 2
);
-- Insert default settings row if it doesn't exist
INSERT OR IGNORE INTO settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
thinking TEXT NOT NULL DEFAULT '',
stream BOOLEAN NOT NULL DEFAULT 0,
model_name TEXT,
model_cloud BOOLEAN,
model_ollama_host BOOLEAN,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
thinking_time_start TIMESTAMP,
thinking_time_end TIMESTAMP,
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
type TEXT NOT NULL,
function_name TEXT NOT NULL,
function_arguments TEXT NOT NULL,
function_result TEXT,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);