mirror of
https://github.com/likelovewant/ollama-for-amd.git
synced 2025-12-21 22:33:56 +00:00
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:
1222
app/store/database.go
Normal file
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
407
app/store/database_test.go
Normal 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, ¬Null, &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
128
app/store/image.go
Normal 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
231
app/store/migration_test.go
Normal 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
61
app/store/schema.sql
Normal 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
60
app/store/schema_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
192
app/store/store_test.go
Normal 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
|
||||
}
|
||||
@@ -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
61
app/store/testdata/schema.sql
vendored
Normal 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);
|
||||
Reference in New Issue
Block a user