mirror of
https://github.com/likelovewant/ollama-for-amd.git
synced 2025-12-21 14:26:30 +00:00
* 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>
496 lines
12 KiB
Go
496 lines
12 KiB
Go
//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"
|
|
"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"`
|
|
}
|
|
|
|
func (s *Store) ensureDB() error {
|
|
// Fast path: check if db is already initialized
|
|
if s.db != nil {
|
|
return nil
|
|
}
|
|
|
|
// Slow path: initialize database with lock
|
|
s.dbMu.Lock()
|
|
defer s.dbMu.Unlock()
|
|
|
|
// Double-check after acquiring lock
|
|
if s.db != nil {
|
|
return nil
|
|
}
|
|
|
|
dbPath := s.DBPath
|
|
if dbPath == "" {
|
|
dbPath = defaultDBPath
|
|
}
|
|
|
|
// 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 {
|
|
database.setID(u.String())
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|