mirror of
https://github.com/likelovewant/ollama-for-amd.git
synced 2025-12-26 00:18:02 +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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user