From f8a8cf3e95fc90c858652454e2907cc2c63ee672 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 26 Aug 2025 14:22:04 +0200 Subject: [PATCH] feat(launcher): add LocalAI launcher app (#6127) * Add launcher (WIP) Signed-off-by: Ettore Di Giacinto * Update gomod Signed-off-by: Ettore Di Giacinto * Cleanup, focus on systray Signed-off-by: Ettore Di Giacinto * Separate launcher from main Signed-off-by: Ettore Di Giacinto * Add a way to identify the binary version Signed-off-by: Ettore Di Giacinto * Implement save config, and start on boot Signed-off-by: Ettore Di Giacinto * Small fixups Signed-off-by: Ettore Di Giacinto * Save installed version as metadata Signed-off-by: Ettore Di Giacinto * Stop LocalAI on quit Signed-off-by: Ettore Di Giacinto * Fix goreleaser Signed-off-by: Ettore Di Giacinto * Check first if binary is there Signed-off-by: Ettore Di Giacinto * do not show version if we don't have it Signed-off-by: Ettore Di Giacinto * Try to build on CI Signed-off-by: Ettore Di Giacinto * use fyne package Signed-off-by: Ettore Di Giacinto * Add to release Signed-off-by: Ettore Di Giacinto * Fixups Signed-off-by: Ettore Di Giacinto * Fyne.Do Signed-off-by: Ettore Di Giacinto * show WEBUI button only if LocalAI is started Signed-off-by: Ettore Di Giacinto * Default to localhost Signed-off-by: Ettore Di Giacinto * CI Signed-off-by: Ettore Di Giacinto * Show rel notes Signed-off-by: Ettore Di Giacinto * Update logo Signed-off-by: Ettore Di Giacinto * Small improvements and fix tests Signed-off-by: Ettore Di Giacinto * Try to fix e2e tests Signed-off-by: Ettore Di Giacinto --------- Signed-off-by: Ettore Di Giacinto --- .github/workflows/build-test.yaml | 44 + .github/workflows/release.yaml | 50 +- .gitignore | 2 +- .goreleaser.yaml | 2 +- Makefile | 29 +- cli/launcher/icon.go | 16 + cli/launcher/internal/launcher.go | 858 ++++++++++++++++++ cli/launcher/internal/launcher_suite_test.go | 13 + cli/launcher/internal/launcher_test.go | 205 +++++ cli/launcher/internal/release_manager.go | 502 ++++++++++ cli/launcher/internal/release_manager_test.go | 178 ++++ cli/launcher/internal/systray_manager.go | 513 +++++++++++ cli/launcher/internal/ui.go | 677 ++++++++++++++ cli/launcher/logo.png | Bin 0 -> 6107 bytes cli/launcher/main.go | 86 ++ main.go => cli/local-ai/main.go | 10 +- core/cli/run.go | 8 + go.mod | 27 +- go.sum | 63 +- tests/e2e-aio/e2e_test.go | 4 +- 20 files changed, 3267 insertions(+), 20 deletions(-) create mode 100644 cli/launcher/icon.go create mode 100644 cli/launcher/internal/launcher.go create mode 100644 cli/launcher/internal/launcher_suite_test.go create mode 100644 cli/launcher/internal/launcher_test.go create mode 100644 cli/launcher/internal/release_manager.go create mode 100644 cli/launcher/internal/release_manager_test.go create mode 100644 cli/launcher/internal/systray_manager.go create mode 100644 cli/launcher/internal/ui.go create mode 100644 cli/launcher/logo.png create mode 100644 cli/launcher/main.go rename main.go => cli/local-ai/main.go (91%) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index f6c045818..a35aee706 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -21,3 +21,47 @@ jobs: - name: Run GoReleaser run: | make dev-dist + launcher-build-darwin: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23 + - name: Build launcher for macOS ARM64 + run: | + make build-launcher-darwin + ls -liah dist + - name: Upload macOS launcher artifacts + uses: actions/upload-artifact@v4 + with: + name: launcher-macos + path: dist/ + retention-days: 30 + + launcher-build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23 + - name: Build launcher for Linux + run: | + sudo apt-get update + sudo apt-get install golang gcc libgl1-mesa-dev xorg-dev libxkbcommon-dev + make build-launcher-linux + - name: Upload Linux launcher artifacts + uses: actions/upload-artifact@v4 + with: + name: launcher-linux + path: local-ai-launcher-linux.tar.xz + retention-days: 30 \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 19ab93a98..58d51e621 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -23,4 +23,52 @@ jobs: version: v2.11.0 args: release --clean env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + launcher-build-darwin: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23 + - name: Build launcher for macOS ARM64 + run: | + make build-launcher-darwin + - name: Upload DMG to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + url: ${{ github.event.release.upload_url }} + asset_path: ./dist/LocalAI-Launcher.dmg + asset_name: LocalAI.dmg + asset_content_type: application/octet-stream + launcher-build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.23 + - name: Build launcher for Linux + run: | + sudo apt-get update + sudo apt-get install golang gcc libgl1-mesa-dev xorg-dev libxkbcommon-dev + make build-launcher-linux + - name: Upload Linux launcher artifacts + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + url: ${{ github.event.release.upload_url }} + asset_path: ./local-ai-launcher-linux.tar.xz + asset_name: LocalAI-Launcher-linux.tar.xz + asset_content_type: application/octet-stream \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9a93527a4..caae10a21 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,7 @@ go-bert # LocalAI build binary LocalAI -local-ai +/local-ai # prevent above rules from omitting the helm chart !charts/* # prevent above rules from omitting the api/localai folder diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5bd6aa0bc..019f76b3c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -8,7 +8,7 @@ source: enabled: true name_template: '{{ .ProjectName }}-{{ .Tag }}-source' builds: - - + - main: ./cli/local-ai env: - CGO_ENABLED=0 ldflags: diff --git a/Makefile b/Makefile index 48bf7c9e1..a36bde58b 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ GOCMD=go GOTEST=$(GOCMD) test GOVET=$(GOCMD) vet BINARY_NAME=local-ai +LAUNCHER_BINARY_NAME=local-ai-launcher GORELEASER?= @@ -90,7 +91,17 @@ build: protogen-go install-go-tools ## Build the project $(info ${GREEN}I LD_FLAGS: ${YELLOW}$(LD_FLAGS)${RESET}) $(info ${GREEN}I UPX: ${YELLOW}$(UPX)${RESET}) rm -rf $(BINARY_NAME) || true - CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(BINARY_NAME) ./ + CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(BINARY_NAME) ./cli/local-ai + +build-launcher: ## Build the launcher application + $(info ${GREEN}I local-ai launcher build info:${RESET}) + $(info ${GREEN}I BUILD_TYPE: ${YELLOW}$(BUILD_TYPE)${RESET}) + $(info ${GREEN}I GO_TAGS: ${YELLOW}$(GO_TAGS)${RESET}) + $(info ${GREEN}I LD_FLAGS: ${YELLOW}$(LD_FLAGS)${RESET}) + rm -rf $(LAUNCHER_BINARY_NAME) || true + CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(LAUNCHER_BINARY_NAME) ./cli/launcher + +build-all: build build-launcher ## Build both server and launcher dev-dist: $(GORELEASER) build --snapshot --clean @@ -507,3 +518,19 @@ docs-clean: .PHONY: docs docs: docs/static/gallery.html cd docs && hugo serve + +######################################################## +## Platform-specific builds +######################################################## + +## fyne cross-platform build +build-launcher-darwin: build-launcher + go run github.com/tiagomelo/macos-dmg-creator/cmd/createdmg@latest \ + --appName "LocalAI" \ + --appBinaryPath "$(LAUNCHER_BINARY_NAME)" \ + --bundleIdentifier "com.localai.launcher" \ + --iconPath "core/http/static/logo.png" \ + --outputDir "dist/" + +build-launcher-linux: + cd cli/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv launcher.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz \ No newline at end of file diff --git a/cli/launcher/icon.go b/cli/launcher/icon.go new file mode 100644 index 000000000..514f7ac5a --- /dev/null +++ b/cli/launcher/icon.go @@ -0,0 +1,16 @@ +package main + +import ( + _ "embed" + + "fyne.io/fyne/v2" +) + +//go:embed logo.png +var logoData []byte + +// resourceIconPng is the LocalAI logo icon +var resourceIconPng = &fyne.StaticResource{ + StaticName: "logo.png", + StaticContent: logoData, +} diff --git a/cli/launcher/internal/launcher.go b/cli/launcher/internal/launcher.go new file mode 100644 index 000000000..a807b388b --- /dev/null +++ b/cli/launcher/internal/launcher.go @@ -0,0 +1,858 @@ +package launcher + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" +) + +// Config represents the launcher configuration +type Config struct { + ModelsPath string `json:"models_path"` + BackendsPath string `json:"backends_path"` + Address string `json:"address"` + AutoStart bool `json:"auto_start"` + StartOnBoot bool `json:"start_on_boot"` + LogLevel string `json:"log_level"` + EnvironmentVars map[string]string `json:"environment_vars"` +} + +// Launcher represents the main launcher application +type Launcher struct { + // Core components + releaseManager *ReleaseManager + config *Config + ui *LauncherUI + systray *SystrayManager + ctx context.Context + window fyne.Window + app fyne.App + + // Process management + localaiCmd *exec.Cmd + isRunning bool + logBuffer *strings.Builder + logMutex sync.RWMutex + statusChannel chan string + + // Logging + logFile *os.File + logPath string + + // UI state + lastUpdateCheck time.Time +} + +// NewLauncher creates a new launcher instance +func NewLauncher(ui *LauncherUI, window fyne.Window, app fyne.App) *Launcher { + return &Launcher{ + releaseManager: NewReleaseManager(), + config: &Config{}, + logBuffer: &strings.Builder{}, + statusChannel: make(chan string, 100), + ctx: context.Background(), + ui: ui, + window: window, + app: app, + } +} + +// setupLogging sets up log file for LocalAI process output +func (l *Launcher) setupLogging() error { + // Create logs directory in data folder + dataPath := l.GetDataPath() + logsDir := filepath.Join(dataPath, "logs") + if err := os.MkdirAll(logsDir, 0755); err != nil { + return fmt.Errorf("failed to create logs directory: %w", err) + } + + // Create log file with timestamp + timestamp := time.Now().Format("2006-01-02_15-04-05") + l.logPath = filepath.Join(logsDir, fmt.Sprintf("localai_%s.log", timestamp)) + + logFile, err := os.Create(l.logPath) + if err != nil { + return fmt.Errorf("failed to create log file: %w", err) + } + + l.logFile = logFile + return nil +} + +// Initialize sets up the launcher +func (l *Launcher) Initialize() error { + if l.app == nil { + return fmt.Errorf("app is nil") + } + log.Printf("Initializing launcher...") + + // Setup logging + if err := l.setupLogging(); err != nil { + return fmt.Errorf("failed to setup logging: %w", err) + } + + // Load configuration + log.Printf("Loading configuration...") + if err := l.loadConfig(); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + log.Printf("Configuration loaded, current state: ModelsPath=%s, BackendsPath=%s, Address=%s, LogLevel=%s", + l.config.ModelsPath, l.config.BackendsPath, l.config.Address, l.config.LogLevel) + + // Clean up any partial downloads + log.Printf("Cleaning up partial downloads...") + if err := l.releaseManager.CleanupPartialDownloads(); err != nil { + log.Printf("Warning: failed to cleanup partial downloads: %v", err) + } + + if l.config.StartOnBoot { + l.StartLocalAI() + } + // Set default paths if not configured (only if not already loaded from config) + if l.config.ModelsPath == "" { + homeDir, _ := os.UserHomeDir() + l.config.ModelsPath = filepath.Join(homeDir, ".localai", "models") + log.Printf("Setting default ModelsPath: %s", l.config.ModelsPath) + } + if l.config.BackendsPath == "" { + homeDir, _ := os.UserHomeDir() + l.config.BackendsPath = filepath.Join(homeDir, ".localai", "backends") + log.Printf("Setting default BackendsPath: %s", l.config.BackendsPath) + } + if l.config.Address == "" { + l.config.Address = "127.0.0.1:8080" + log.Printf("Setting default Address: %s", l.config.Address) + } + if l.config.LogLevel == "" { + l.config.LogLevel = "info" + log.Printf("Setting default LogLevel: %s", l.config.LogLevel) + } + if l.config.EnvironmentVars == nil { + l.config.EnvironmentVars = make(map[string]string) + log.Printf("Initializing empty EnvironmentVars map") + } + + // Create directories + os.MkdirAll(l.config.ModelsPath, 0755) + os.MkdirAll(l.config.BackendsPath, 0755) + + // Save the configuration with default values + if err := l.saveConfig(); err != nil { + log.Printf("Warning: failed to save default configuration: %v", err) + } + + // System tray is now handled in main.go using Fyne's built-in approach + + // Check if LocalAI is installed + if !l.releaseManager.IsLocalAIInstalled() { + log.Printf("No LocalAI installation found") + fyne.Do(func() { + l.updateStatus("No LocalAI installation found") + if l.ui != nil { + // Show dialog offering to download LocalAI + l.showDownloadLocalAIDialog() + } + }) + } + + // Check for updates periodically + go l.periodicUpdateCheck() + + return nil +} + +// StartLocalAI starts the LocalAI server +func (l *Launcher) StartLocalAI() error { + if l.isRunning { + return fmt.Errorf("LocalAI is already running") + } + + // Verify binary integrity before starting + if err := l.releaseManager.VerifyInstalledBinary(); err != nil { + // Binary is corrupted, remove it and offer to reinstall + binaryPath := l.releaseManager.GetBinaryPath() + if removeErr := os.Remove(binaryPath); removeErr != nil { + log.Printf("Failed to remove corrupted binary: %v", removeErr) + } + return fmt.Errorf("LocalAI binary is corrupted: %v. Please reinstall LocalAI", err) + } + + binaryPath := l.releaseManager.GetBinaryPath() + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + return fmt.Errorf("LocalAI binary not found. Please download a release first") + } + + // Build command arguments + args := []string{ + "run", + "--models-path", l.config.ModelsPath, + "--backends-path", l.config.BackendsPath, + "--address", l.config.Address, + "--log-level", l.config.LogLevel, + } + + l.localaiCmd = exec.CommandContext(l.ctx, binaryPath, args...) + + // Apply environment variables + if len(l.config.EnvironmentVars) > 0 { + env := os.Environ() + for key, value := range l.config.EnvironmentVars { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + l.localaiCmd.Env = env + } + + // Setup logging + stdout, err := l.localaiCmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderr, err := l.localaiCmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Start the process + if err := l.localaiCmd.Start(); err != nil { + return fmt.Errorf("failed to start LocalAI: %w", err) + } + + l.isRunning = true + + fyne.Do(func() { + l.updateStatus("LocalAI is starting...") + l.updateRunningState(true) + }) + + // Start log monitoring + go l.monitorLogs(stdout, "STDOUT") + go l.monitorLogs(stderr, "STDERR") + + // Monitor process with startup timeout + go func() { + // Wait for process to start or fail + err := l.localaiCmd.Wait() + l.isRunning = false + fyne.Do(func() { + l.updateRunningState(false) + if err != nil { + l.updateStatus(fmt.Sprintf("LocalAI stopped with error: %v", err)) + } else { + l.updateStatus("LocalAI stopped") + } + }) + }() + + // Add startup timeout detection + go func() { + time.Sleep(10 * time.Second) // Wait 10 seconds for startup + if l.isRunning { + // Check if process is still alive + if l.localaiCmd.Process != nil { + if err := l.localaiCmd.Process.Signal(syscall.Signal(0)); err != nil { + // Process is dead, mark as not running + l.isRunning = false + fyne.Do(func() { + l.updateRunningState(false) + l.updateStatus("LocalAI failed to start properly") + }) + } + } + } + }() + + return nil +} + +// StopLocalAI stops the LocalAI server +func (l *Launcher) StopLocalAI() error { + if !l.isRunning || l.localaiCmd == nil { + return fmt.Errorf("LocalAI is not running") + } + + // Gracefully terminate the process + if err := l.localaiCmd.Process.Signal(os.Interrupt); err != nil { + // If graceful termination fails, force kill + if killErr := l.localaiCmd.Process.Kill(); killErr != nil { + return fmt.Errorf("failed to kill LocalAI process: %w", killErr) + } + } + + l.isRunning = false + fyne.Do(func() { + l.updateRunningState(false) + l.updateStatus("LocalAI stopped") + }) + return nil +} + +// IsRunning returns whether LocalAI is currently running +func (l *Launcher) IsRunning() bool { + return l.isRunning +} + +// Shutdown performs cleanup when the application is closing +func (l *Launcher) Shutdown() error { + log.Printf("Launcher shutting down, stopping LocalAI...") + + // Stop LocalAI if it's running + if l.isRunning { + if err := l.StopLocalAI(); err != nil { + log.Printf("Error stopping LocalAI during shutdown: %v", err) + } + } + + // Close log file if open + if l.logFile != nil { + if err := l.logFile.Close(); err != nil { + log.Printf("Error closing log file: %v", err) + } + l.logFile = nil + } + + log.Printf("Launcher shutdown complete") + return nil +} + +// GetLogs returns the current log buffer +func (l *Launcher) GetLogs() string { + l.logMutex.RLock() + defer l.logMutex.RUnlock() + return l.logBuffer.String() +} + +// GetRecentLogs returns the most recent logs (last 50 lines) for better error display +func (l *Launcher) GetRecentLogs() string { + l.logMutex.RLock() + defer l.logMutex.RUnlock() + + content := l.logBuffer.String() + lines := strings.Split(content, "\n") + + // Get last 50 lines + if len(lines) > 50 { + lines = lines[len(lines)-50:] + } + + return strings.Join(lines, "\n") +} + +// GetConfig returns the current configuration +func (l *Launcher) GetConfig() *Config { + return l.config +} + +// SetConfig updates the configuration +func (l *Launcher) SetConfig(config *Config) error { + l.config = config + return l.saveConfig() +} + +func (l *Launcher) GetUI() *LauncherUI { + return l.ui +} + +func (l *Launcher) SetSystray(systray *SystrayManager) { + l.systray = systray +} + +// GetReleaseManager returns the release manager +func (l *Launcher) GetReleaseManager() *ReleaseManager { + return l.releaseManager +} + +// GetWebUIURL returns the URL for the WebUI +func (l *Launcher) GetWebUIURL() string { + address := l.config.Address + if strings.HasPrefix(address, ":") { + address = "localhost" + address + } + if !strings.HasPrefix(address, "http") { + address = "http://" + address + } + return address +} + +// GetDataPath returns the path where LocalAI data and logs are stored +func (l *Launcher) GetDataPath() string { + // LocalAI typically stores data in the current working directory or a models directory + // First check if models path is configured + if l.config != nil && l.config.ModelsPath != "" { + // Return the parent directory of models path + return filepath.Dir(l.config.ModelsPath) + } + + // Fallback to home directory LocalAI folder + homeDir, err := os.UserHomeDir() + if err != nil { + return "." + } + return filepath.Join(homeDir, ".localai") +} + +// CheckForUpdates checks if there are any available updates +func (l *Launcher) CheckForUpdates() (bool, string, error) { + log.Printf("CheckForUpdates: checking for available updates...") + available, version, err := l.releaseManager.IsUpdateAvailable() + if err != nil { + log.Printf("CheckForUpdates: error occurred: %v", err) + return false, "", err + } + log.Printf("CheckForUpdates: result - available=%v, version=%s", available, version) + l.lastUpdateCheck = time.Now() + return available, version, nil +} + +// DownloadUpdate downloads the latest version +func (l *Launcher) DownloadUpdate(version string, progressCallback func(float64)) error { + return l.releaseManager.DownloadRelease(version, progressCallback) +} + +// GetCurrentVersion returns the current installed version +func (l *Launcher) GetCurrentVersion() string { + return l.releaseManager.GetInstalledVersion() +} + +// GetCurrentStatus returns the current status +func (l *Launcher) GetCurrentStatus() string { + select { + case status := <-l.statusChannel: + return status + default: + if l.isRunning { + return "LocalAI is running" + } + return "Ready" + } +} + +// GetLastStatus returns the last known status without consuming from channel +func (l *Launcher) GetLastStatus() string { + if l.isRunning { + return "LocalAI is running" + } + + // Check if LocalAI is installed + if !l.releaseManager.IsLocalAIInstalled() { + return "LocalAI not installed" + } + + return "Ready" +} + +func (l *Launcher) githubReleaseNotesURL(version string) (*url.URL, error) { + // Construct GitHub release URL + releaseURL := fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", + l.releaseManager.GitHubOwner, + l.releaseManager.GitHubRepo, + version) + + // Convert string to *url.URL + return url.Parse(releaseURL) +} + +// showDownloadLocalAIDialog shows a dialog offering to download LocalAI +func (l *Launcher) showDownloadLocalAIDialog() { + if l.app == nil { + log.Printf("Cannot show download dialog: app is nil") + return + } + + fyne.DoAndWait(func() { + // Create a standalone window for the download dialog + dialogWindow := l.app.NewWindow("LocalAI Installation Required") + dialogWindow.Resize(fyne.NewSize(500, 350)) + dialogWindow.CenterOnScreen() + dialogWindow.SetCloseIntercept(func() { + dialogWindow.Close() + }) + + // Create the dialog content + titleLabel := widget.NewLabel("LocalAI Not Found") + titleLabel.TextStyle = fyne.TextStyle{Bold: true} + titleLabel.Alignment = fyne.TextAlignCenter + + messageLabel := widget.NewLabel("LocalAI is not installed on your system.\n\nWould you like to download and install the latest version?") + messageLabel.Wrapping = fyne.TextWrapWord + messageLabel.Alignment = fyne.TextAlignCenter + + // Buttons + downloadButton := widget.NewButton("Download & Install", func() { + dialogWindow.Close() + l.downloadAndInstallLocalAI() + if l.systray != nil { + l.systray.recreateMenu() + } + }) + downloadButton.Importance = widget.HighImportance + + // Release notes button + releaseNotesButton := widget.NewButton("View Release Notes", func() { + // Get latest release info and open release notes + go func() { + release, err := l.releaseManager.GetLatestRelease() + if err != nil { + log.Printf("Failed to get latest release info: %v", err) + return + } + + releaseNotesURL, err := l.githubReleaseNotesURL(release.Version) + if err != nil { + log.Printf("Failed to parse URL: %v", err) + return + } + + l.app.OpenURL(releaseNotesURL) + }() + }) + + skipButton := widget.NewButton("Skip for Now", func() { + dialogWindow.Close() + }) + + // Layout - put release notes button above the main action buttons + actionButtons := container.NewHBox(skipButton, downloadButton) + content := container.NewVBox( + titleLabel, + widget.NewSeparator(), + messageLabel, + widget.NewSeparator(), + releaseNotesButton, + widget.NewSeparator(), + actionButtons, + ) + + dialogWindow.SetContent(content) + dialogWindow.Show() + }) +} + +// downloadAndInstallLocalAI downloads and installs the latest LocalAI version +func (l *Launcher) downloadAndInstallLocalAI() { + if l.app == nil { + log.Printf("Cannot download LocalAI: app is nil") + return + } + + // First check what the latest version is + go func() { + log.Printf("Checking for latest LocalAI version...") + available, version, err := l.CheckForUpdates() + if err != nil { + log.Printf("Failed to check for updates: %v", err) + l.showDownloadError("Failed to check for latest version", err.Error()) + return + } + + if !available { + log.Printf("No updates available, but LocalAI is not installed") + l.showDownloadError("No Version Available", "Could not determine the latest LocalAI version. Please check your internet connection and try again.") + return + } + + log.Printf("Latest version available: %s", version) + // Show progress window with the specific version + l.showDownloadProgress(version, fmt.Sprintf("Downloading LocalAI %s...", version)) + }() +} + +// showDownloadError shows an error dialog for download failures +func (l *Launcher) showDownloadError(title, message string) { + fyne.DoAndWait(func() { + // Create error window + errorWindow := l.app.NewWindow("Download Error") + errorWindow.Resize(fyne.NewSize(400, 200)) + errorWindow.CenterOnScreen() + errorWindow.SetCloseIntercept(func() { + errorWindow.Close() + }) + + // Error content + titleLabel := widget.NewLabel(title) + titleLabel.TextStyle = fyne.TextStyle{Bold: true} + titleLabel.Alignment = fyne.TextAlignCenter + + messageLabel := widget.NewLabel(message) + messageLabel.Wrapping = fyne.TextWrapWord + messageLabel.Alignment = fyne.TextAlignCenter + + // Close button + closeButton := widget.NewButton("Close", func() { + errorWindow.Close() + }) + + // Layout + content := container.NewVBox( + titleLabel, + widget.NewSeparator(), + messageLabel, + widget.NewSeparator(), + closeButton, + ) + + errorWindow.SetContent(content) + errorWindow.Show() + }) +} + +// showDownloadProgress shows a standalone progress window for downloading LocalAI +func (l *Launcher) showDownloadProgress(version, title string) { + fyne.DoAndWait(func() { + // Create progress window + progressWindow := l.app.NewWindow("Downloading LocalAI") + progressWindow.Resize(fyne.NewSize(400, 250)) + progressWindow.CenterOnScreen() + progressWindow.SetCloseIntercept(func() { + progressWindow.Close() + }) + + // Progress bar + progressBar := widget.NewProgressBar() + progressBar.SetValue(0) + + // Status label + statusLabel := widget.NewLabel("Preparing download...") + + // Release notes button + releaseNotesButton := widget.NewButton("View Release Notes", func() { + releaseNotesURL, err := l.githubReleaseNotesURL(version) + if err != nil { + log.Printf("Failed to parse URL: %v", err) + return + } + + l.app.OpenURL(releaseNotesURL) + }) + + // Progress container + progressContainer := container.NewVBox( + widget.NewLabel(title), + progressBar, + statusLabel, + widget.NewSeparator(), + releaseNotesButton, + ) + + progressWindow.SetContent(progressContainer) + progressWindow.Show() + + // Start download in background + go func() { + err := l.DownloadUpdate(version, func(progress float64) { + // Update progress bar + fyne.Do(func() { + progressBar.SetValue(progress) + percentage := int(progress * 100) + statusLabel.SetText(fmt.Sprintf("Downloading... %d%%", percentage)) + }) + }) + + // Handle completion + fyne.Do(func() { + if err != nil { + statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) + // Show error dialog + dialog.ShowError(err, progressWindow) + } else { + statusLabel.SetText("Download completed successfully!") + progressBar.SetValue(1.0) + + // Show success dialog + dialog.ShowConfirm("Installation Complete", + "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.", + func(close bool) { + progressWindow.Close() + // Update status and refresh systray menu + l.updateStatus("LocalAI installed successfully") + + if l.systray != nil { + l.systray.recreateMenu() + } + }, progressWindow) + } + }) + }() + }) +} + +// monitorLogs monitors the output of LocalAI and adds it to the log buffer +func (l *Launcher) monitorLogs(reader io.Reader, prefix string) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + timestamp := time.Now().Format("15:04:05") + logLine := fmt.Sprintf("[%s] %s: %s\n", timestamp, prefix, line) + + l.logMutex.Lock() + l.logBuffer.WriteString(logLine) + // Keep log buffer size reasonable + if l.logBuffer.Len() > 100000 { // 100KB + content := l.logBuffer.String() + // Keep last 50KB + if len(content) > 50000 { + l.logBuffer.Reset() + l.logBuffer.WriteString(content[len(content)-50000:]) + } + } + l.logMutex.Unlock() + + // Write to log file if available + if l.logFile != nil { + if _, err := l.logFile.WriteString(logLine); err != nil { + log.Printf("Failed to write to log file: %v", err) + } + } + + fyne.Do(func() { + // Notify UI of new log content + if l.ui != nil { + l.ui.OnLogUpdate(logLine) + } + + // Check for startup completion + if strings.Contains(line, "API server listening") { + l.updateStatus("LocalAI is running") + } + }) + } +} + +// updateStatus updates the status and notifies UI +func (l *Launcher) updateStatus(status string) { + select { + case l.statusChannel <- status: + default: + // Channel full, skip + } + + if l.ui != nil { + l.ui.UpdateStatus(status) + } + + if l.systray != nil { + l.systray.UpdateStatus(status) + } +} + +// updateRunningState updates the running state in UI and systray +func (l *Launcher) updateRunningState(isRunning bool) { + if l.ui != nil { + l.ui.UpdateRunningState(isRunning) + } + + if l.systray != nil { + l.systray.UpdateRunningState(isRunning) + } +} + +// periodicUpdateCheck checks for updates periodically +func (l *Launcher) periodicUpdateCheck() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + available, version, err := l.CheckForUpdates() + if err == nil && available { + fyne.Do(func() { + l.updateStatus(fmt.Sprintf("Update available: %s", version)) + if l.systray != nil { + l.systray.NotifyUpdateAvailable(version) + } + if l.ui != nil { + l.ui.NotifyUpdateAvailable(version) + } + }) + } + case <-l.ctx.Done(): + return + } + } +} + +// loadConfig loads configuration from file +func (l *Launcher) loadConfig() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + configPath := filepath.Join(homeDir, ".localai", "launcher.json") + log.Printf("Loading config from: %s", configPath) + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + log.Printf("Config file not found, creating default config") + // Create default config + return l.saveConfig() + } + + // Load existing config + configData, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + log.Printf("Config file content: %s", string(configData)) + + log.Printf("loadConfig: about to unmarshal JSON data") + if err := json.Unmarshal(configData, l.config); err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + log.Printf("loadConfig: JSON unmarshaled successfully") + + log.Printf("Loaded config: ModelsPath=%s, BackendsPath=%s, Address=%s, LogLevel=%s", + l.config.ModelsPath, l.config.BackendsPath, l.config.Address, l.config.LogLevel) + log.Printf("Environment vars: %v", l.config.EnvironmentVars) + + return nil +} + +// saveConfig saves configuration to file +func (l *Launcher) saveConfig() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + configDir := filepath.Join(homeDir, ".localai") + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Marshal config to JSON + log.Printf("saveConfig: marshaling config with EnvironmentVars: %v", l.config.EnvironmentVars) + configData, err := json.MarshalIndent(l.config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + log.Printf("saveConfig: JSON marshaled successfully, length: %d", len(configData)) + + configPath := filepath.Join(configDir, "launcher.json") + log.Printf("Saving config to: %s", configPath) + log.Printf("Config content: %s", string(configData)) + + if err := os.WriteFile(configPath, configData, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + log.Printf("Config saved successfully") + return nil +} diff --git a/cli/launcher/internal/launcher_suite_test.go b/cli/launcher/internal/launcher_suite_test.go new file mode 100644 index 000000000..3648197b3 --- /dev/null +++ b/cli/launcher/internal/launcher_suite_test.go @@ -0,0 +1,13 @@ +package launcher_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestLauncher(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Launcher Suite") +} diff --git a/cli/launcher/internal/launcher_test.go b/cli/launcher/internal/launcher_test.go new file mode 100644 index 000000000..aed84836f --- /dev/null +++ b/cli/launcher/internal/launcher_test.go @@ -0,0 +1,205 @@ +package launcher_test + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "fyne.io/fyne/v2/app" + + launcher "github.com/mudler/LocalAI/cli/launcher/internal" +) + +var _ = Describe("Launcher", func() { + var ( + launcherInstance *launcher.Launcher + tempDir string + ) + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "launcher-test-*") + Expect(err).ToNot(HaveOccurred()) + + ui := launcher.NewLauncherUI() + app := app.NewWithID("com.localai.launcher") + + launcherInstance = launcher.NewLauncher(ui, nil, app) + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + Describe("NewLauncher", func() { + It("should create a launcher with default configuration", func() { + Expect(launcherInstance.GetConfig()).ToNot(BeNil()) + }) + }) + + Describe("Initialize", func() { + It("should set default paths when not configured", func() { + err := launcherInstance.Initialize() + Expect(err).ToNot(HaveOccurred()) + + config := launcherInstance.GetConfig() + Expect(config.ModelsPath).ToNot(BeEmpty()) + Expect(config.BackendsPath).ToNot(BeEmpty()) + Expect(config.Address).To(Equal("127.0.0.1:8080")) + Expect(config.LogLevel).To(Equal("info")) + }) + + It("should create models and backends directories", func() { + // Set custom paths for testing + config := launcherInstance.GetConfig() + config.ModelsPath = filepath.Join(tempDir, "models") + config.BackendsPath = filepath.Join(tempDir, "backends") + launcherInstance.SetConfig(config) + + err := launcherInstance.Initialize() + Expect(err).ToNot(HaveOccurred()) + + // Check if directories were created + _, err = os.Stat(config.ModelsPath) + Expect(err).ToNot(HaveOccurred()) + + _, err = os.Stat(config.BackendsPath) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("Configuration", func() { + It("should get and set configuration", func() { + config := launcherInstance.GetConfig() + config.ModelsPath = "/test/models" + config.BackendsPath = "/test/backends" + config.Address = ":9090" + config.LogLevel = "debug" + + err := launcherInstance.SetConfig(config) + Expect(err).ToNot(HaveOccurred()) + + retrievedConfig := launcherInstance.GetConfig() + Expect(retrievedConfig.ModelsPath).To(Equal("/test/models")) + Expect(retrievedConfig.BackendsPath).To(Equal("/test/backends")) + Expect(retrievedConfig.Address).To(Equal(":9090")) + Expect(retrievedConfig.LogLevel).To(Equal("debug")) + }) + }) + + Describe("WebUI URL", func() { + It("should return correct WebUI URL for localhost", func() { + config := launcherInstance.GetConfig() + config.Address = ":8080" + launcherInstance.SetConfig(config) + + url := launcherInstance.GetWebUIURL() + Expect(url).To(Equal("http://localhost:8080")) + }) + + It("should return correct WebUI URL for full address", func() { + config := launcherInstance.GetConfig() + config.Address = "127.0.0.1:8080" + launcherInstance.SetConfig(config) + + url := launcherInstance.GetWebUIURL() + Expect(url).To(Equal("http://127.0.0.1:8080")) + }) + + It("should handle http prefix correctly", func() { + config := launcherInstance.GetConfig() + config.Address = "http://localhost:8080" + launcherInstance.SetConfig(config) + + url := launcherInstance.GetWebUIURL() + Expect(url).To(Equal("http://localhost:8080")) + }) + }) + + Describe("Process Management", func() { + It("should not be running initially", func() { + Expect(launcherInstance.IsRunning()).To(BeFalse()) + }) + + It("should handle start when binary doesn't exist", func() { + err := launcherInstance.StartLocalAI() + Expect(err).To(HaveOccurred()) + // Could be either "not found" or "permission denied" depending on test environment + errMsg := err.Error() + hasExpectedError := strings.Contains(errMsg, "LocalAI binary") || + strings.Contains(errMsg, "permission denied") + Expect(hasExpectedError).To(BeTrue(), "Expected error about binary not found or permission denied, got: %s", errMsg) + }) + + It("should handle stop when not running", func() { + err := launcherInstance.StopLocalAI() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("LocalAI is not running")) + }) + }) + + Describe("Logs", func() { + It("should return empty logs initially", func() { + logs := launcherInstance.GetLogs() + Expect(logs).To(BeEmpty()) + }) + }) + + Describe("Version Management", func() { + It("should return empty version when no binary installed", func() { + version := launcherInstance.GetCurrentVersion() + Expect(version).To(BeEmpty()) // No binary installed in test environment + }) + + It("should handle update checks", func() { + // This test would require mocking HTTP responses + // For now, we'll just test that the method doesn't panic + _, _, err := launcherInstance.CheckForUpdates() + // We expect either success or a network error, not a panic + if err != nil { + // Network error is acceptable in tests + Expect(err.Error()).To(ContainSubstring("failed to fetch")) + } + }) + }) +}) + +var _ = Describe("Config", func() { + It("should have proper JSON tags", func() { + config := &launcher.Config{ + ModelsPath: "/test/models", + BackendsPath: "/test/backends", + Address: ":8080", + AutoStart: true, + LogLevel: "info", + EnvironmentVars: map[string]string{"TEST": "value"}, + } + + Expect(config.ModelsPath).To(Equal("/test/models")) + Expect(config.BackendsPath).To(Equal("/test/backends")) + Expect(config.Address).To(Equal(":8080")) + Expect(config.AutoStart).To(BeTrue()) + Expect(config.LogLevel).To(Equal("info")) + Expect(config.EnvironmentVars).To(HaveKeyWithValue("TEST", "value")) + }) + + It("should initialize environment variables map", func() { + config := &launcher.Config{} + Expect(config.EnvironmentVars).To(BeNil()) + + ui := launcher.NewLauncherUI() + app := app.NewWithID("com.localai.launcher") + + launcher := launcher.NewLauncher(ui, nil, app) + + err := launcher.Initialize() + Expect(err).ToNot(HaveOccurred()) + + retrievedConfig := launcher.GetConfig() + Expect(retrievedConfig.EnvironmentVars).ToNot(BeNil()) + Expect(retrievedConfig.EnvironmentVars).To(BeEmpty()) + }) +}) diff --git a/cli/launcher/internal/release_manager.go b/cli/launcher/internal/release_manager.go new file mode 100644 index 000000000..bc289ea68 --- /dev/null +++ b/cli/launcher/internal/release_manager.go @@ -0,0 +1,502 @@ +package launcher + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/mudler/LocalAI/internal" +) + +// Release represents a LocalAI release +type Release struct { + Version string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt time.Time `json:"published_at"` + Assets []Asset `json:"assets"` +} + +// Asset represents a release asset +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// ReleaseManager handles LocalAI release management +type ReleaseManager struct { + // GitHubOwner is the GitHub repository owner + GitHubOwner string + // GitHubRepo is the GitHub repository name + GitHubRepo string + // BinaryPath is where the LocalAI binary is stored locally + BinaryPath string + // CurrentVersion is the currently installed version + CurrentVersion string + // ChecksumsPath is where checksums are stored + ChecksumsPath string + // MetadataPath is where version metadata is stored + MetadataPath string +} + +// NewReleaseManager creates a new release manager +func NewReleaseManager() *ReleaseManager { + homeDir, _ := os.UserHomeDir() + binaryPath := filepath.Join(homeDir, ".localai", "bin") + checksumsPath := filepath.Join(homeDir, ".localai", "checksums") + metadataPath := filepath.Join(homeDir, ".localai", "metadata") + + return &ReleaseManager{ + GitHubOwner: "mudler", + GitHubRepo: "LocalAI", + BinaryPath: binaryPath, + CurrentVersion: internal.PrintableVersion(), + ChecksumsPath: checksumsPath, + MetadataPath: metadataPath, + } +} + +// GetLatestRelease fetches the latest release information from GitHub +func (rm *ReleaseManager) GetLatestRelease() (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", rm.GitHubOwner, rm.GitHubRepo) + + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch latest release: status %d", resp.StatusCode) + } + + // Parse the JSON response properly + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + release := &Release{} + if err := json.Unmarshal(body, release); err != nil { + return nil, fmt.Errorf("failed to parse JSON response: %w", err) + } + + // Validate the release data + if release.Version == "" { + return nil, fmt.Errorf("no version found in release data") + } + + return release, nil +} + +// DownloadRelease downloads a specific version of LocalAI +func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(float64)) error { + // Ensure the binary directory exists + if err := os.MkdirAll(rm.BinaryPath, 0755); err != nil { + return fmt.Errorf("failed to create binary directory: %w", err) + } + + // Determine the binary name based on OS and architecture + binaryName := rm.GetBinaryName(version) + localPath := filepath.Join(rm.BinaryPath, "local-ai") + + // Download the binary + downloadURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", + rm.GitHubOwner, rm.GitHubRepo, version, binaryName) + + if err := rm.downloadFile(downloadURL, localPath, progressCallback); err != nil { + return fmt.Errorf("failed to download binary: %w", err) + } + + // Download and verify checksums + checksumURL := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/LocalAI-%s-checksums.txt", + rm.GitHubOwner, rm.GitHubRepo, version, version) + + checksumPath := filepath.Join(rm.BinaryPath, "checksums.txt") + if err := rm.downloadFile(checksumURL, checksumPath, nil); err != nil { + return fmt.Errorf("failed to download checksums: %w", err) + } + + // Verify the checksum + if err := rm.VerifyChecksum(localPath, checksumPath, binaryName); err != nil { + return fmt.Errorf("checksum verification failed: %w", err) + } + + // Save checksums persistently for future verification + if err := rm.saveChecksums(version, checksumPath, binaryName); err != nil { + log.Printf("Warning: failed to save checksums: %v", err) + } + + // Make the binary executable + if err := os.Chmod(localPath, 0755); err != nil { + return fmt.Errorf("failed to make binary executable: %w", err) + } + + return nil +} + +// GetBinaryName returns the appropriate binary name for the current platform +func (rm *ReleaseManager) GetBinaryName(version string) string { + versionStr := strings.TrimPrefix(version, "v") + os := runtime.GOOS + arch := runtime.GOARCH + + // Map Go arch names to the release naming convention + switch arch { + case "amd64": + arch = "amd64" + case "arm64": + arch = "arm64" + default: + arch = "amd64" // fallback + } + + return fmt.Sprintf("local-ai-v%s-%s-%s", versionStr, os, arch) +} + +// downloadFile downloads a file from a URL to a local path with optional progress callback +func (rm *ReleaseManager) downloadFile(url, filepath string, progressCallback func(float64)) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + + // Create a progress reader if callback is provided + var reader io.Reader = resp.Body + if progressCallback != nil && resp.ContentLength > 0 { + reader = &progressReader{ + Reader: resp.Body, + Total: resp.ContentLength, + Callback: progressCallback, + } + } + + _, err = io.Copy(out, reader) + return err +} + +// saveChecksums saves checksums persistently for future verification +func (rm *ReleaseManager) saveChecksums(version, checksumPath, binaryName string) error { + // Ensure checksums directory exists + if err := os.MkdirAll(rm.ChecksumsPath, 0755); err != nil { + return fmt.Errorf("failed to create checksums directory: %w", err) + } + + // Read the downloaded checksums file + checksumData, err := os.ReadFile(checksumPath) + if err != nil { + return fmt.Errorf("failed to read checksums file: %w", err) + } + + // Save to persistent location with version info + persistentPath := filepath.Join(rm.ChecksumsPath, fmt.Sprintf("checksums-%s.txt", version)) + if err := os.WriteFile(persistentPath, checksumData, 0644); err != nil { + return fmt.Errorf("failed to write persistent checksums: %w", err) + } + + // Also save a "latest" checksums file for the current version + latestPath := filepath.Join(rm.ChecksumsPath, "checksums-latest.txt") + if err := os.WriteFile(latestPath, checksumData, 0644); err != nil { + return fmt.Errorf("failed to write latest checksums: %w", err) + } + + // Save version metadata + if err := rm.saveVersionMetadata(version); err != nil { + log.Printf("Warning: failed to save version metadata: %v", err) + } + + log.Printf("Checksums saved for version %s", version) + return nil +} + +// saveVersionMetadata saves the installed version information +func (rm *ReleaseManager) saveVersionMetadata(version string) error { + // Ensure metadata directory exists + if err := os.MkdirAll(rm.MetadataPath, 0755); err != nil { + return fmt.Errorf("failed to create metadata directory: %w", err) + } + + // Create metadata structure + metadata := struct { + Version string `json:"version"` + InstalledAt time.Time `json:"installed_at"` + BinaryPath string `json:"binary_path"` + }{ + Version: version, + InstalledAt: time.Now(), + BinaryPath: rm.GetBinaryPath(), + } + + // Marshal to JSON + metadataData, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + + // Save metadata file + metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") + if err := os.WriteFile(metadataPath, metadataData, 0644); err != nil { + return fmt.Errorf("failed to write metadata file: %w", err) + } + + log.Printf("Version metadata saved: %s", version) + return nil +} + +// progressReader wraps an io.Reader to provide download progress +type progressReader struct { + io.Reader + Total int64 + Current int64 + Callback func(float64) +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.Reader.Read(p) + pr.Current += int64(n) + if pr.Callback != nil { + progress := float64(pr.Current) / float64(pr.Total) + pr.Callback(progress) + } + return n, err +} + +// VerifyChecksum verifies the downloaded file against the provided checksums +func (rm *ReleaseManager) VerifyChecksum(filePath, checksumPath, binaryName string) error { + // Calculate the SHA256 of the downloaded file + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open file for checksum: %w", err) + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return fmt.Errorf("failed to calculate checksum: %w", err) + } + + calculatedHash := hex.EncodeToString(hasher.Sum(nil)) + + // Read the checksums file + checksumFile, err := os.Open(checksumPath) + if err != nil { + return fmt.Errorf("failed to open checksums file: %w", err) + } + defer checksumFile.Close() + + scanner := bufio.NewScanner(checksumFile) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.Contains(line, binaryName) { + parts := strings.Fields(line) + if len(parts) >= 2 { + expectedHash := parts[0] + if calculatedHash == expectedHash { + return nil // Checksum verified + } + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) + } + } + } + + return fmt.Errorf("checksum not found for %s", binaryName) +} + +// GetInstalledVersion returns the currently installed version +func (rm *ReleaseManager) GetInstalledVersion() string { + + // Fallback: Check if the LocalAI binary exists and try to get its version + binaryPath := rm.GetBinaryPath() + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + return "" // No version installed + } + + // try to get version from metadata + if version := rm.loadVersionMetadata(); version != "" { + return version + } + + // Try to run the binary to get the version (fallback method) + version, err := exec.Command(binaryPath, "--version").Output() + if err != nil { + // If binary exists but --version fails, try to determine from filename or other means + log.Printf("Binary exists but --version failed: %v", err) + return "" + } + + stringVersion := strings.TrimSpace(string(version)) + stringVersion = strings.TrimRight(stringVersion, "\n") + + return stringVersion +} + +// loadVersionMetadata loads the installed version from metadata file +func (rm *ReleaseManager) loadVersionMetadata() string { + metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") + + // Check if metadata file exists + if _, err := os.Stat(metadataPath); os.IsNotExist(err) { + return "" + } + + // Read metadata file + metadataData, err := os.ReadFile(metadataPath) + if err != nil { + log.Printf("Failed to read metadata file: %v", err) + return "" + } + + // Parse metadata + var metadata struct { + Version string `json:"version"` + InstalledAt time.Time `json:"installed_at"` + BinaryPath string `json:"binary_path"` + } + + if err := json.Unmarshal(metadataData, &metadata); err != nil { + log.Printf("Failed to parse metadata file: %v", err) + return "" + } + + // Verify that the binary path in metadata matches current binary path + if metadata.BinaryPath != rm.GetBinaryPath() { + log.Printf("Binary path mismatch in metadata, ignoring") + return "" + } + + log.Printf("Loaded version from metadata: %s (installed at %s)", metadata.Version, metadata.InstalledAt.Format("2006-01-02 15:04:05")) + return metadata.Version +} + +// GetBinaryPath returns the path to the LocalAI binary +func (rm *ReleaseManager) GetBinaryPath() string { + return filepath.Join(rm.BinaryPath, "local-ai") +} + +// IsUpdateAvailable checks if an update is available +func (rm *ReleaseManager) IsUpdateAvailable() (bool, string, error) { + log.Printf("IsUpdateAvailable: checking for updates...") + + latest, err := rm.GetLatestRelease() + if err != nil { + log.Printf("IsUpdateAvailable: failed to get latest release: %v", err) + return false, "", err + } + log.Printf("IsUpdateAvailable: latest release version: %s", latest.Version) + + current := rm.GetInstalledVersion() + log.Printf("IsUpdateAvailable: current installed version: %s", current) + + if current == "" { + // No version installed, offer to download latest + log.Printf("IsUpdateAvailable: no version installed, offering latest: %s", latest.Version) + return true, latest.Version, nil + } + + updateAvailable := latest.Version != current + log.Printf("IsUpdateAvailable: update available: %v (latest: %s, current: %s)", updateAvailable, latest.Version, current) + return updateAvailable, latest.Version, nil +} + +// IsLocalAIInstalled checks if LocalAI binary exists and is valid +func (rm *ReleaseManager) IsLocalAIInstalled() bool { + binaryPath := rm.GetBinaryPath() + if _, err := os.Stat(binaryPath); os.IsNotExist(err) { + return false + } + + // Verify the binary integrity + if err := rm.VerifyInstalledBinary(); err != nil { + log.Printf("Binary integrity check failed: %v", err) + // Remove corrupted binary + if removeErr := os.Remove(binaryPath); removeErr != nil { + log.Printf("Failed to remove corrupted binary: %v", removeErr) + } + return false + } + + return true +} + +// VerifyInstalledBinary verifies the installed binary against saved checksums +func (rm *ReleaseManager) VerifyInstalledBinary() error { + binaryPath := rm.GetBinaryPath() + + // Check if we have saved checksums + latestChecksumsPath := filepath.Join(rm.ChecksumsPath, "checksums-latest.txt") + if _, err := os.Stat(latestChecksumsPath); os.IsNotExist(err) { + return fmt.Errorf("no saved checksums found") + } + + // Get the binary name for the current version from metadata + currentVersion := rm.loadVersionMetadata() + if currentVersion == "" { + return fmt.Errorf("cannot determine current version from metadata") + } + + binaryName := rm.GetBinaryName(currentVersion) + + // Verify against saved checksums + return rm.VerifyChecksum(binaryPath, latestChecksumsPath, binaryName) +} + +// CleanupPartialDownloads removes any partial or corrupted downloads +func (rm *ReleaseManager) CleanupPartialDownloads() error { + binaryPath := rm.GetBinaryPath() + + // Check if binary exists but is corrupted + if _, err := os.Stat(binaryPath); err == nil { + // Binary exists, verify it + if verifyErr := rm.VerifyInstalledBinary(); verifyErr != nil { + log.Printf("Found corrupted binary, removing: %v", verifyErr) + if removeErr := os.Remove(binaryPath); removeErr != nil { + log.Printf("Failed to remove corrupted binary: %v", removeErr) + } + // Clear metadata since binary is corrupted + rm.clearVersionMetadata() + } + } + + // Clean up any temporary checksum files + tempChecksumsPath := filepath.Join(rm.BinaryPath, "checksums.txt") + if _, err := os.Stat(tempChecksumsPath); err == nil { + if removeErr := os.Remove(tempChecksumsPath); removeErr != nil { + log.Printf("Failed to remove temporary checksums: %v", removeErr) + } + } + + return nil +} + +// clearVersionMetadata clears the version metadata (used when binary is corrupted or removed) +func (rm *ReleaseManager) clearVersionMetadata() { + metadataPath := filepath.Join(rm.MetadataPath, "installed-version.json") + if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) { + log.Printf("Failed to clear version metadata: %v", err) + } else { + log.Printf("Version metadata cleared") + } +} diff --git a/cli/launcher/internal/release_manager_test.go b/cli/launcher/internal/release_manager_test.go new file mode 100644 index 000000000..ae42d17b3 --- /dev/null +++ b/cli/launcher/internal/release_manager_test.go @@ -0,0 +1,178 @@ +package launcher_test + +import ( + "os" + "path/filepath" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + launcher "github.com/mudler/LocalAI/cli/launcher/internal" +) + +var _ = Describe("ReleaseManager", func() { + var ( + rm *launcher.ReleaseManager + tempDir string + ) + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "launcher-test-*") + Expect(err).ToNot(HaveOccurred()) + + rm = launcher.NewReleaseManager() + // Override binary path for testing + rm.BinaryPath = tempDir + }) + + AfterEach(func() { + os.RemoveAll(tempDir) + }) + + Describe("NewReleaseManager", func() { + It("should create a release manager with correct defaults", func() { + newRM := launcher.NewReleaseManager() + Expect(newRM.GitHubOwner).To(Equal("mudler")) + Expect(newRM.GitHubRepo).To(Equal("LocalAI")) + Expect(newRM.BinaryPath).To(ContainSubstring(".localai")) + }) + }) + + Describe("GetBinaryName", func() { + It("should return correct binary name for current platform", func() { + binaryName := rm.GetBinaryName("v3.4.0") + expectedOS := runtime.GOOS + expectedArch := runtime.GOARCH + + expected := "local-ai-v3.4.0-" + expectedOS + "-" + expectedArch + Expect(binaryName).To(Equal(expected)) + }) + + It("should handle version with and without 'v' prefix", func() { + withV := rm.GetBinaryName("v3.4.0") + withoutV := rm.GetBinaryName("3.4.0") + + // Both should produce the same result + Expect(withV).To(Equal(withoutV)) + }) + }) + + Describe("GetBinaryPath", func() { + It("should return the correct binary path", func() { + path := rm.GetBinaryPath() + expected := filepath.Join(tempDir, "local-ai") + Expect(path).To(Equal(expected)) + }) + }) + + Describe("GetInstalledVersion", func() { + It("should return empty when no binary exists", func() { + version := rm.GetInstalledVersion() + Expect(version).To(BeEmpty()) // No binary installed in test + }) + + It("should return empty version when binary exists but no metadata", func() { + // Create a fake binary for testing + err := os.MkdirAll(rm.BinaryPath, 0755) + Expect(err).ToNot(HaveOccurred()) + + binaryPath := rm.GetBinaryPath() + err = os.WriteFile(binaryPath, []byte("fake binary"), 0755) + Expect(err).ToNot(HaveOccurred()) + + version := rm.GetInstalledVersion() + Expect(version).To(BeEmpty()) + }) + }) + + Context("with mocked responses", func() { + // Note: In a real implementation, we'd mock HTTP responses + // For now, we'll test the structure and error handling + + Describe("GetLatestRelease", func() { + It("should handle network errors gracefully", func() { + // This test would require mocking HTTP client + // For demonstration, we're just testing the method exists + _, err := rm.GetLatestRelease() + // We expect either success or a network error, not a panic + // In a real test, we'd mock the HTTP response + if err != nil { + Expect(err.Error()).To(ContainSubstring("failed to fetch")) + } + }) + }) + + Describe("DownloadRelease", func() { + It("should create binary directory if it doesn't exist", func() { + // Remove the temp directory to test creation + os.RemoveAll(tempDir) + + // This will fail due to network, but should create the directory + rm.DownloadRelease("v3.4.0", nil) + + // Check if directory was created + _, err := os.Stat(tempDir) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("VerifyChecksum functionality", func() { + var ( + testFile string + checksumFile string + ) + + BeforeEach(func() { + testFile = filepath.Join(tempDir, "test-binary") + checksumFile = filepath.Join(tempDir, "checksums.txt") + }) + + It("should verify checksums correctly", func() { + // Create a test file with known content + testContent := []byte("test content for checksum") + err := os.WriteFile(testFile, testContent, 0644) + Expect(err).ToNot(HaveOccurred()) + + // Calculate expected SHA256 + // This is a simplified test - in practice we'd use the actual checksum + checksumContent := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 test-binary\n" + err = os.WriteFile(checksumFile, []byte(checksumContent), 0644) + Expect(err).ToNot(HaveOccurred()) + + // Test checksum verification + // Note: This will fail because our content doesn't match the empty string hash + // In a real test, we'd calculate the actual hash + err = rm.VerifyChecksum(testFile, checksumFile, "test-binary") + // We expect this to fail since we're using a dummy checksum + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("checksum mismatch")) + }) + + It("should handle missing checksum file", func() { + // Create test file but no checksum file + err := os.WriteFile(testFile, []byte("test"), 0644) + Expect(err).ToNot(HaveOccurred()) + + err = rm.VerifyChecksum(testFile, checksumFile, "test-binary") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to open checksums file")) + }) + + It("should handle missing binary in checksums", func() { + // Create files but checksum doesn't contain our binary + err := os.WriteFile(testFile, []byte("test"), 0644) + Expect(err).ToNot(HaveOccurred()) + + checksumContent := "hash other-binary\n" + err = os.WriteFile(checksumFile, []byte(checksumContent), 0644) + Expect(err).ToNot(HaveOccurred()) + + err = rm.VerifyChecksum(testFile, checksumFile, "test-binary") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("checksum not found")) + }) + }) +}) diff --git a/cli/launcher/internal/systray_manager.go b/cli/launcher/internal/systray_manager.go new file mode 100644 index 000000000..9bc36fd43 --- /dev/null +++ b/cli/launcher/internal/systray_manager.go @@ -0,0 +1,513 @@ +package launcher + +import ( + "fmt" + "log" + "net/url" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/widget" +) + +// SystrayManager manages the system tray functionality +type SystrayManager struct { + launcher *Launcher + window fyne.Window + app fyne.App + desk desktop.App + + // Menu items that need dynamic updates + startStopItem *fyne.MenuItem + hasUpdateAvailable bool + latestVersion string + icon *fyne.StaticResource +} + +// NewSystrayManager creates a new systray manager +func NewSystrayManager(launcher *Launcher, window fyne.Window, desktop desktop.App, app fyne.App, icon *fyne.StaticResource) *SystrayManager { + sm := &SystrayManager{ + launcher: launcher, + window: window, + app: app, + desk: desktop, + icon: icon, + } + sm.setupMenu(desktop) + return sm +} + +// setupMenu sets up the system tray menu +func (sm *SystrayManager) setupMenu(desk desktop.App) { + sm.desk = desk + + // Create the start/stop toggle item + sm.startStopItem = fyne.NewMenuItem("Start LocalAI", func() { + sm.toggleLocalAI() + }) + + desk.SetSystemTrayIcon(sm.icon) + + // Initialize the menu state using recreateMenu + sm.recreateMenu() +} + +// toggleLocalAI starts or stops LocalAI based on current state +func (sm *SystrayManager) toggleLocalAI() { + if sm.launcher.IsRunning() { + go func() { + if err := sm.launcher.StopLocalAI(); err != nil { + log.Printf("Failed to stop LocalAI: %v", err) + sm.showErrorDialog("Failed to Stop LocalAI", err.Error()) + } + }() + } else { + go func() { + if err := sm.launcher.StartLocalAI(); err != nil { + log.Printf("Failed to start LocalAI: %v", err) + sm.showStartupErrorDialog(err) + } + }() + } +} + +// openWebUI opens the LocalAI WebUI in the default browser +func (sm *SystrayManager) openWebUI() { + if !sm.launcher.IsRunning() { + return // LocalAI is not running + } + + webURL := sm.launcher.GetWebUIURL() + if parsedURL, err := url.Parse(webURL); err == nil { + sm.app.OpenURL(parsedURL) + } +} + +// openDocumentation opens the LocalAI documentation +func (sm *SystrayManager) openDocumentation() { + if parsedURL, err := url.Parse("https://localai.io"); err == nil { + sm.app.OpenURL(parsedURL) + } +} + +// updateStartStopItem updates the start/stop menu item based on current state +func (sm *SystrayManager) updateStartStopItem() { + // Since Fyne menu items can't change text dynamically, we recreate the menu + sm.recreateMenu() +} + +// recreateMenu recreates the entire menu with updated state +func (sm *SystrayManager) recreateMenu() { + if sm.desk == nil { + return + } + + // Determine the action based on LocalAI installation and running state + var actionItem *fyne.MenuItem + if !sm.launcher.GetReleaseManager().IsLocalAIInstalled() { + // LocalAI not installed - show install option + actionItem = fyne.NewMenuItem("📥 Install Latest Version", func() { + sm.launcher.showDownloadLocalAIDialog() + }) + } else if sm.launcher.IsRunning() { + // LocalAI is running - show stop option + actionItem = fyne.NewMenuItem("🛑 Stop LocalAI", func() { + sm.toggleLocalAI() + }) + } else { + // LocalAI is installed but not running - show start option + actionItem = fyne.NewMenuItem("▶️ Start LocalAI", func() { + sm.toggleLocalAI() + }) + } + + menuItems := []*fyne.MenuItem{} + + // Add status at the top (clickable for details) + status := sm.launcher.GetLastStatus() + statusText := sm.truncateText(status, 30) + statusItem := fyne.NewMenuItem("📊 Status: "+statusText, func() { + sm.showStatusDetails(status, "") + }) + menuItems = append(menuItems, statusItem) + + // Only show version if LocalAI is installed + if sm.launcher.GetReleaseManager().IsLocalAIInstalled() { + version := sm.launcher.GetCurrentVersion() + versionText := sm.truncateText(version, 25) + versionItem := fyne.NewMenuItem("🔧 Version: "+versionText, func() { + sm.showStatusDetails(status, version) + }) + menuItems = append(menuItems, versionItem) + } + + menuItems = append(menuItems, fyne.NewMenuItemSeparator()) + + // Add update notification if available + if sm.hasUpdateAvailable { + updateItem := fyne.NewMenuItem("🔔 New version available ("+sm.latestVersion+")", func() { + sm.downloadUpdate() + }) + menuItems = append(menuItems, updateItem) + menuItems = append(menuItems, fyne.NewMenuItemSeparator()) + } + + // Core actions + menuItems = append(menuItems, + actionItem, + ) + + // Only show WebUI option if LocalAI is installed + if sm.launcher.GetReleaseManager().IsLocalAIInstalled() && sm.launcher.IsRunning() { + menuItems = append(menuItems, + fyne.NewMenuItem("Open WebUI", func() { + sm.openWebUI() + }), + ) + } + + menuItems = append(menuItems, + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem("Check for Updates", func() { + sm.checkForUpdates() + }), + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem("Settings", func() { + sm.showSettings() + }), + fyne.NewMenuItem("Open Data Folder", func() { + sm.openDataFolder() + }), + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem("Documentation", func() { + sm.openDocumentation() + }), + fyne.NewMenuItemSeparator(), + fyne.NewMenuItem("Quit", func() { + // Perform cleanup before quitting + if err := sm.launcher.Shutdown(); err != nil { + log.Printf("Error during shutdown: %v", err) + } + sm.app.Quit() + }), + ) + + menu := fyne.NewMenu("LocalAI", menuItems...) + sm.desk.SetSystemTrayMenu(menu) +} + +// UpdateRunningState updates the systray based on running state +func (sm *SystrayManager) UpdateRunningState(isRunning bool) { + sm.updateStartStopItem() +} + +// UpdateStatus updates the systray menu to reflect status changes +func (sm *SystrayManager) UpdateStatus(status string) { + sm.recreateMenu() +} + +// checkForUpdates checks for available updates +func (sm *SystrayManager) checkForUpdates() { + go func() { + log.Printf("Checking for updates...") + available, version, err := sm.launcher.CheckForUpdates() + if err != nil { + log.Printf("Failed to check for updates: %v", err) + return + } + + log.Printf("Update check result: available=%v, version=%s", available, version) + if available { + sm.hasUpdateAvailable = true + sm.latestVersion = version + sm.recreateMenu() + } + }() +} + +// downloadUpdate downloads the latest update +func (sm *SystrayManager) downloadUpdate() { + if !sm.hasUpdateAvailable { + return + } + + // Show progress window + sm.showDownloadProgress(sm.latestVersion) +} + +// showSettings shows the settings window +func (sm *SystrayManager) showSettings() { + sm.window.Show() + sm.window.RequestFocus() +} + +// openDataFolder opens the data folder in file manager +func (sm *SystrayManager) openDataFolder() { + dataPath := sm.launcher.GetDataPath() + if parsedURL, err := url.Parse("file://" + dataPath); err == nil { + sm.app.OpenURL(parsedURL) + } +} + +// NotifyUpdateAvailable sets update notification in systray +func (sm *SystrayManager) NotifyUpdateAvailable(version string) { + sm.hasUpdateAvailable = true + sm.latestVersion = version + sm.recreateMenu() +} + +// truncateText truncates text to specified length and adds ellipsis if needed +func (sm *SystrayManager) truncateText(text string, maxLength int) string { + if len(text) <= maxLength { + return text + } + return text[:maxLength-3] + "..." +} + +// showStatusDetails shows a detailed status window with full information +func (sm *SystrayManager) showStatusDetails(status, version string) { + fyne.DoAndWait(func() { + // Create status details window + statusWindow := sm.app.NewWindow("LocalAI Status Details") + statusWindow.Resize(fyne.NewSize(500, 400)) + statusWindow.CenterOnScreen() + + // Status information + statusLabel := widget.NewLabel("Current Status:") + statusValue := widget.NewLabel(status) + statusValue.Wrapping = fyne.TextWrapWord + + // Version information (only show if version exists) + var versionContainer fyne.CanvasObject + if version != "" { + versionLabel := widget.NewLabel("Installed Version:") + versionValue := widget.NewLabel(version) + versionValue.Wrapping = fyne.TextWrapWord + versionContainer = container.NewVBox(versionLabel, versionValue) + } + + // Running state + runningLabel := widget.NewLabel("Running State:") + runningValue := widget.NewLabel("") + if sm.launcher.IsRunning() { + runningValue.SetText("🟢 Running") + } else { + runningValue.SetText("🔴 Stopped") + } + + // WebUI URL + webuiLabel := widget.NewLabel("WebUI URL:") + webuiValue := widget.NewLabel(sm.launcher.GetWebUIURL()) + webuiValue.Wrapping = fyne.TextWrapWord + + // Recent logs (last 20 lines) + logsLabel := widget.NewLabel("Recent Logs:") + logsText := widget.NewMultiLineEntry() + logsText.SetText(sm.launcher.GetRecentLogs()) + logsText.Wrapping = fyne.TextWrapWord + logsText.Disable() // Make it read-only + + // Buttons + closeButton := widget.NewButton("Close", func() { + statusWindow.Close() + }) + + refreshButton := widget.NewButton("Refresh", func() { + // Refresh the status information + statusValue.SetText(sm.launcher.GetLastStatus()) + + // Note: Version refresh is not implemented for simplicity + // The version will be updated when the status details window is reopened + + if sm.launcher.IsRunning() { + runningValue.SetText("🟢 Running") + } else { + runningValue.SetText("🔴 Stopped") + } + logsText.SetText(sm.launcher.GetRecentLogs()) + }) + + openWebUIButton := widget.NewButton("Open WebUI", func() { + sm.openWebUI() + }) + + // Layout + buttons := container.NewHBox(closeButton, refreshButton, openWebUIButton) + + // Build info container dynamically + infoItems := []fyne.CanvasObject{ + statusLabel, statusValue, + widget.NewSeparator(), + } + + // Add version section if it exists + if versionContainer != nil { + infoItems = append(infoItems, versionContainer, widget.NewSeparator()) + } + + infoItems = append(infoItems, + runningLabel, runningValue, + widget.NewSeparator(), + webuiLabel, webuiValue, + ) + + infoContainer := container.NewVBox(infoItems...) + + content := container.NewVBox( + infoContainer, + widget.NewSeparator(), + logsLabel, + logsText, + widget.NewSeparator(), + buttons, + ) + + statusWindow.SetContent(content) + statusWindow.Show() + }) +} + +// showErrorDialog shows a simple error dialog +func (sm *SystrayManager) showErrorDialog(title, message string) { + fyne.DoAndWait(func() { + dialog.ShowError(fmt.Errorf(message), sm.window) + }) +} + +// showStartupErrorDialog shows a detailed error dialog with process logs +func (sm *SystrayManager) showStartupErrorDialog(err error) { + fyne.DoAndWait(func() { + // Get the recent process logs (more useful for debugging) + logs := sm.launcher.GetRecentLogs() + + // Create error window + errorWindow := sm.app.NewWindow("LocalAI Startup Failed") + errorWindow.Resize(fyne.NewSize(600, 500)) + errorWindow.CenterOnScreen() + + // Error message + errorLabel := widget.NewLabel(fmt.Sprintf("Failed to start LocalAI:\n%s", err.Error())) + errorLabel.Wrapping = fyne.TextWrapWord + + // Logs display + logsLabel := widget.NewLabel("Process Logs:") + logsText := widget.NewMultiLineEntry() + logsText.SetText(logs) + logsText.Wrapping = fyne.TextWrapWord + logsText.Disable() // Make it read-only + + // Buttons + closeButton := widget.NewButton("Close", func() { + errorWindow.Close() + }) + + retryButton := widget.NewButton("Retry", func() { + errorWindow.Close() + // Try to start again + go func() { + if retryErr := sm.launcher.StartLocalAI(); retryErr != nil { + sm.showStartupErrorDialog(retryErr) + } + }() + }) + + openLogsButton := widget.NewButton("Open Logs Folder", func() { + sm.openDataFolder() + }) + + // Layout + buttons := container.NewHBox(closeButton, retryButton, openLogsButton) + content := container.NewVBox( + errorLabel, + widget.NewSeparator(), + logsLabel, + logsText, + widget.NewSeparator(), + buttons, + ) + + errorWindow.SetContent(content) + errorWindow.Show() + }) +} + +// showDownloadProgress shows a progress window for downloading updates +func (sm *SystrayManager) showDownloadProgress(version string) { + // Create a new window for download progress + progressWindow := sm.app.NewWindow("Downloading LocalAI Update") + progressWindow.Resize(fyne.NewSize(400, 250)) + progressWindow.CenterOnScreen() + + // Progress bar + progressBar := widget.NewProgressBar() + progressBar.SetValue(0) + + // Status label + statusLabel := widget.NewLabel("Preparing download...") + + // Release notes button + releaseNotesButton := widget.NewButton("View Release Notes", func() { + releaseNotesURL, err := sm.launcher.githubReleaseNotesURL(version) + if err != nil { + log.Printf("Failed to parse URL: %v", err) + return + } + + sm.app.OpenURL(releaseNotesURL) + }) + + // Progress container + progressContainer := container.NewVBox( + widget.NewLabel(fmt.Sprintf("Downloading LocalAI version %s", version)), + progressBar, + statusLabel, + widget.NewSeparator(), + releaseNotesButton, + ) + + progressWindow.SetContent(progressContainer) + progressWindow.Show() + + // Start download in background + go func() { + err := sm.launcher.DownloadUpdate(version, func(progress float64) { + // Update progress bar + fyne.Do(func() { + progressBar.SetValue(progress) + percentage := int(progress * 100) + statusLabel.SetText(fmt.Sprintf("Downloading... %d%%", percentage)) + }) + }) + + // Handle completion + fyne.Do(func() { + if err != nil { + statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) + // Show error dialog + dialog.ShowError(err, progressWindow) + } else { + statusLabel.SetText("Download completed successfully!") + progressBar.SetValue(1.0) + + // Show restart dialog + dialog.ShowConfirm("Update Downloaded", + "LocalAI has been updated successfully. Please restart the launcher to use the new version.", + func(restart bool) { + if restart { + sm.app.Quit() + } + progressWindow.Close() + }, progressWindow) + } + }) + + // Update systray menu + if err == nil { + sm.hasUpdateAvailable = false + sm.latestVersion = "" + sm.recreateMenu() + } + }() +} diff --git a/cli/launcher/internal/ui.go b/cli/launcher/internal/ui.go new file mode 100644 index 000000000..7930abf41 --- /dev/null +++ b/cli/launcher/internal/ui.go @@ -0,0 +1,677 @@ +package launcher + +import ( + "fmt" + "log" + "net/url" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/widget" +) + +// EnvVar represents an environment variable +type EnvVar struct { + Key string + Value string +} + +// LauncherUI handles the user interface +type LauncherUI struct { + // Status display + statusLabel *widget.Label + versionLabel *widget.Label + + // Control buttons + startStopButton *widget.Button + webUIButton *widget.Button + updateButton *widget.Button + downloadButton *widget.Button + + // Configuration + modelsPathEntry *widget.Entry + backendsPathEntry *widget.Entry + addressEntry *widget.Entry + logLevelSelect *widget.Select + startOnBootCheck *widget.Check + + // Environment Variables + envVarsData []EnvVar + newEnvKeyEntry *widget.Entry + newEnvValueEntry *widget.Entry + updateEnvironmentDisplay func() + + // Logs + logText *widget.Entry + + // Progress + progressBar *widget.ProgressBar + + // Update management + latestVersion string + + // Reference to launcher + launcher *Launcher +} + +// NewLauncherUI creates a new UI instance +func NewLauncherUI() *LauncherUI { + return &LauncherUI{ + statusLabel: widget.NewLabel("Initializing..."), + versionLabel: widget.NewLabel("Version: Unknown"), + startStopButton: widget.NewButton("Start LocalAI", nil), + webUIButton: widget.NewButton("Open WebUI", nil), + updateButton: widget.NewButton("Check for Updates", nil), + modelsPathEntry: widget.NewEntry(), + backendsPathEntry: widget.NewEntry(), + addressEntry: widget.NewEntry(), + logLevelSelect: widget.NewSelect([]string{"error", "warn", "info", "debug", "trace"}, nil), + startOnBootCheck: widget.NewCheck("Start LocalAI on system boot", nil), + logText: widget.NewMultiLineEntry(), + progressBar: widget.NewProgressBar(), + envVarsData: []EnvVar{}, // Initialize the environment variables slice + } +} + +// CreateMainUI creates the main UI layout +func (ui *LauncherUI) CreateMainUI(launcher *Launcher) *fyne.Container { + ui.launcher = launcher + ui.setupBindings() + + // Main tab with status and controls + // Configuration is now the main content + configTab := ui.createConfigTab() + + // Create a simple container instead of tabs since we only have settings + tabs := container.NewVBox( + widget.NewCard("LocalAI Launcher Settings", "", configTab), + ) + + return tabs +} + +// createConfigTab creates the configuration tab +func (ui *LauncherUI) createConfigTab() *fyne.Container { + // Path configuration + pathsCard := widget.NewCard("Paths", "", container.NewGridWithColumns(2, + widget.NewLabel("Models Path:"), + ui.modelsPathEntry, + widget.NewLabel("Backends Path:"), + ui.backendsPathEntry, + )) + + // Server configuration + serverCard := widget.NewCard("Server", "", container.NewVBox( + container.NewGridWithColumns(2, + widget.NewLabel("Address:"), + ui.addressEntry, + widget.NewLabel("Log Level:"), + ui.logLevelSelect, + ), + ui.startOnBootCheck, + )) + + // Save button + saveButton := widget.NewButton("Save Configuration", func() { + ui.saveConfiguration() + }) + + // Environment Variables section + envCard := ui.createEnvironmentSection() + + return container.NewVBox( + pathsCard, + serverCard, + envCard, + saveButton, + ) +} + +// createEnvironmentSection creates the environment variables section for the config tab +func (ui *LauncherUI) createEnvironmentSection() *fyne.Container { + // Initialize environment variables widgets + ui.newEnvKeyEntry = widget.NewEntry() + ui.newEnvKeyEntry.SetPlaceHolder("Environment Variable Name") + + ui.newEnvValueEntry = widget.NewEntry() + ui.newEnvValueEntry.SetPlaceHolder("Environment Variable Value") + + // Add button + addButton := widget.NewButton("Add Environment Variable", func() { + ui.addEnvironmentVariable() + }) + + // Environment variables list with delete buttons + ui.envVarsData = []EnvVar{} + + // Create container for environment variables + envVarsContainer := container.NewVBox() + + // Update function to rebuild the environment variables display + ui.updateEnvironmentDisplay = func() { + envVarsContainer.Objects = nil + for i, envVar := range ui.envVarsData { + index := i // Capture index for closure + + // Create row with label and delete button + envLabel := widget.NewLabel(fmt.Sprintf("%s = %s", envVar.Key, envVar.Value)) + deleteBtn := widget.NewButton("Delete", func() { + ui.confirmDeleteEnvironmentVariable(index) + }) + deleteBtn.Importance = widget.DangerImportance + + row := container.NewBorder(nil, nil, nil, deleteBtn, envLabel) + envVarsContainer.Add(row) + } + envVarsContainer.Refresh() + } + + // Create a scrollable container for the environment variables + envScroll := container.NewScroll(envVarsContainer) + envScroll.SetMinSize(fyne.NewSize(400, 150)) + + // Input section for adding new environment variables + inputSection := container.NewVBox( + container.NewGridWithColumns(2, + ui.newEnvKeyEntry, + ui.newEnvValueEntry, + ), + addButton, + ) + + // Environment variables card + envCard := widget.NewCard("Environment Variables", "", container.NewVBox( + inputSection, + widget.NewSeparator(), + envScroll, + )) + + return container.NewVBox(envCard) +} + +// addEnvironmentVariable adds a new environment variable +func (ui *LauncherUI) addEnvironmentVariable() { + key := ui.newEnvKeyEntry.Text + value := ui.newEnvValueEntry.Text + + log.Printf("addEnvironmentVariable: attempting to add %s=%s", key, value) + log.Printf("addEnvironmentVariable: current ui.envVarsData has %d items: %v", len(ui.envVarsData), ui.envVarsData) + + if key == "" { + log.Printf("addEnvironmentVariable: key is empty, showing error") + dialog.ShowError(fmt.Errorf("environment variable name cannot be empty"), ui.launcher.window) + return + } + + // Check if key already exists + for _, envVar := range ui.envVarsData { + if envVar.Key == key { + log.Printf("addEnvironmentVariable: key %s already exists, showing error", key) + dialog.ShowError(fmt.Errorf("environment variable '%s' already exists", key), ui.launcher.window) + return + } + } + + log.Printf("addEnvironmentVariable: adding new env var %s=%s", key, value) + ui.envVarsData = append(ui.envVarsData, EnvVar{Key: key, Value: value}) + log.Printf("addEnvironmentVariable: after adding, ui.envVarsData has %d items: %v", len(ui.envVarsData), ui.envVarsData) + + fyne.Do(func() { + if ui.updateEnvironmentDisplay != nil { + ui.updateEnvironmentDisplay() + } + // Clear input fields + ui.newEnvKeyEntry.SetText("") + ui.newEnvValueEntry.SetText("") + }) + + log.Printf("addEnvironmentVariable: calling saveEnvironmentVariables") + // Save to configuration + ui.saveEnvironmentVariables() +} + +// removeEnvironmentVariable removes an environment variable by index +func (ui *LauncherUI) removeEnvironmentVariable(index int) { + if index >= 0 && index < len(ui.envVarsData) { + ui.envVarsData = append(ui.envVarsData[:index], ui.envVarsData[index+1:]...) + fyne.Do(func() { + if ui.updateEnvironmentDisplay != nil { + ui.updateEnvironmentDisplay() + } + }) + ui.saveEnvironmentVariables() + } +} + +// saveEnvironmentVariables saves environment variables to the configuration +func (ui *LauncherUI) saveEnvironmentVariables() { + if ui.launcher == nil { + log.Printf("saveEnvironmentVariables: launcher is nil") + return + } + + config := ui.launcher.GetConfig() + log.Printf("saveEnvironmentVariables: before - Environment vars: %v", config.EnvironmentVars) + + config.EnvironmentVars = make(map[string]string) + for _, envVar := range ui.envVarsData { + config.EnvironmentVars[envVar.Key] = envVar.Value + log.Printf("saveEnvironmentVariables: adding %s=%s", envVar.Key, envVar.Value) + } + + log.Printf("saveEnvironmentVariables: after - Environment vars: %v", config.EnvironmentVars) + log.Printf("saveEnvironmentVariables: calling SetConfig with %d environment variables", len(config.EnvironmentVars)) + + err := ui.launcher.SetConfig(config) + if err != nil { + log.Printf("saveEnvironmentVariables: failed to save config: %v", err) + } else { + log.Printf("saveEnvironmentVariables: config saved successfully") + } +} + +// confirmDeleteEnvironmentVariable shows confirmation dialog for deleting an environment variable +func (ui *LauncherUI) confirmDeleteEnvironmentVariable(index int) { + if index >= 0 && index < len(ui.envVarsData) { + envVar := ui.envVarsData[index] + dialog.ShowConfirm("Remove Environment Variable", + fmt.Sprintf("Remove environment variable '%s'?", envVar.Key), + func(remove bool) { + if remove { + ui.removeEnvironmentVariable(index) + } + }, ui.launcher.window) + } +} + +// setupBindings sets up event handlers for UI elements +func (ui *LauncherUI) setupBindings() { + // Start/Stop button + ui.startStopButton.OnTapped = func() { + if ui.launcher.IsRunning() { + ui.stopLocalAI() + } else { + ui.startLocalAI() + } + } + + // WebUI button + ui.webUIButton.OnTapped = func() { + ui.openWebUI() + } + ui.webUIButton.Disable() // Disabled until LocalAI is running + + // Update button + ui.updateButton.OnTapped = func() { + ui.checkForUpdates() + } + + // Log level selection + ui.logLevelSelect.OnChanged = func(selected string) { + if ui.launcher != nil { + config := ui.launcher.GetConfig() + config.LogLevel = selected + ui.launcher.SetConfig(config) + } + } +} + +// startLocalAI starts the LocalAI service +func (ui *LauncherUI) startLocalAI() { + fyne.Do(func() { + ui.startStopButton.Disable() + }) + ui.UpdateStatus("Starting LocalAI...") + + go func() { + err := ui.launcher.StartLocalAI() + if err != nil { + ui.UpdateStatus("Failed to start: " + err.Error()) + fyne.DoAndWait(func() { + dialog.ShowError(err, ui.launcher.window) + }) + } else { + fyne.Do(func() { + ui.startStopButton.SetText("Stop LocalAI") + ui.webUIButton.Enable() + }) + } + fyne.Do(func() { + ui.startStopButton.Enable() + }) + }() +} + +// stopLocalAI stops the LocalAI service +func (ui *LauncherUI) stopLocalAI() { + fyne.Do(func() { + ui.startStopButton.Disable() + }) + ui.UpdateStatus("Stopping LocalAI...") + + go func() { + err := ui.launcher.StopLocalAI() + if err != nil { + fyne.DoAndWait(func() { + dialog.ShowError(err, ui.launcher.window) + }) + } else { + fyne.Do(func() { + ui.startStopButton.SetText("Start LocalAI") + ui.webUIButton.Disable() + }) + } + fyne.Do(func() { + ui.startStopButton.Enable() + }) + }() +} + +// openWebUI opens the LocalAI WebUI in the default browser +func (ui *LauncherUI) openWebUI() { + webURL := ui.launcher.GetWebUIURL() + parsedURL, err := url.Parse(webURL) + if err != nil { + dialog.ShowError(err, ui.launcher.window) + return + } + + // Open URL in default browser + fyne.CurrentApp().OpenURL(parsedURL) +} + +// saveConfiguration saves the current configuration +func (ui *LauncherUI) saveConfiguration() { + log.Printf("saveConfiguration: starting to save configuration") + + config := ui.launcher.GetConfig() + log.Printf("saveConfiguration: current config Environment vars: %v", config.EnvironmentVars) + log.Printf("saveConfiguration: ui.envVarsData has %d items: %v", len(ui.envVarsData), ui.envVarsData) + + config.ModelsPath = ui.modelsPathEntry.Text + config.BackendsPath = ui.backendsPathEntry.Text + config.Address = ui.addressEntry.Text + config.LogLevel = ui.logLevelSelect.Selected + config.StartOnBoot = ui.startOnBootCheck.Checked + + // Ensure environment variables are included in the configuration + config.EnvironmentVars = make(map[string]string) + for _, envVar := range ui.envVarsData { + config.EnvironmentVars[envVar.Key] = envVar.Value + log.Printf("saveConfiguration: adding env var %s=%s", envVar.Key, envVar.Value) + } + + log.Printf("saveConfiguration: final config Environment vars: %v", config.EnvironmentVars) + + err := ui.launcher.SetConfig(config) + if err != nil { + log.Printf("saveConfiguration: failed to save config: %v", err) + dialog.ShowError(err, ui.launcher.window) + } else { + log.Printf("saveConfiguration: config saved successfully") + dialog.ShowInformation("Configuration", "Configuration saved successfully", ui.launcher.window) + } +} + +// checkForUpdates checks for available updates +func (ui *LauncherUI) checkForUpdates() { + fyne.Do(func() { + ui.updateButton.Disable() + }) + ui.UpdateStatus("Checking for updates...") + + go func() { + available, version, err := ui.launcher.CheckForUpdates() + if err != nil { + ui.UpdateStatus("Failed to check updates: " + err.Error()) + fyne.DoAndWait(func() { + dialog.ShowError(err, ui.launcher.window) + }) + } else if available { + ui.latestVersion = version // Store the latest version + ui.UpdateStatus("Update available: " + version) + fyne.Do(func() { + if ui.downloadButton != nil { + ui.downloadButton.Enable() + } + }) + ui.NotifyUpdateAvailable(version) + } else { + ui.UpdateStatus("No updates available") + fyne.DoAndWait(func() { + dialog.ShowInformation("Updates", "You are running the latest version", ui.launcher.window) + }) + } + fyne.Do(func() { + ui.updateButton.Enable() + }) + }() +} + +// downloadUpdate downloads the latest update +func (ui *LauncherUI) downloadUpdate() { + // Use stored version or check for updates + version := ui.latestVersion + if version == "" { + _, v, err := ui.launcher.CheckForUpdates() + if err != nil { + dialog.ShowError(err, ui.launcher.window) + return + } + version = v + ui.latestVersion = version + } + + if version == "" { + dialog.ShowError(fmt.Errorf("no version information available"), ui.launcher.window) + return + } + + // Disable buttons during download + if ui.downloadButton != nil { + fyne.Do(func() { + ui.downloadButton.Disable() + }) + } + + fyne.Do(func() { + ui.progressBar.Show() + ui.progressBar.SetValue(0) + }) + ui.UpdateStatus("Downloading update " + version + "...") + + go func() { + err := ui.launcher.DownloadUpdate(version, func(progress float64) { + // Update progress bar + fyne.Do(func() { + ui.progressBar.SetValue(progress) + }) + // Update status with percentage + percentage := int(progress * 100) + ui.UpdateStatus(fmt.Sprintf("Downloading update %s... %d%%", version, percentage)) + }) + + fyne.Do(func() { + ui.progressBar.Hide() + }) + + // Re-enable buttons after download + if ui.downloadButton != nil { + fyne.Do(func() { + ui.downloadButton.Enable() + }) + } + + if err != nil { + fyne.DoAndWait(func() { + ui.UpdateStatus("Failed to download update: " + err.Error()) + dialog.ShowError(err, ui.launcher.window) + }) + } else { + fyne.DoAndWait(func() { + ui.UpdateStatus("Update downloaded successfully") + dialog.ShowInformation("Update", "Update downloaded successfully. Please restart the launcher to use the new version.", ui.launcher.window) + }) + } + }() +} + +// UpdateStatus updates the status label +func (ui *LauncherUI) UpdateStatus(status string) { + if ui.statusLabel != nil { + fyne.Do(func() { + ui.statusLabel.SetText(status) + }) + } +} + +// OnLogUpdate handles new log content +func (ui *LauncherUI) OnLogUpdate(logLine string) { + if ui.logText != nil { + fyne.Do(func() { + currentText := ui.logText.Text + ui.logText.SetText(currentText + logLine) + + // Auto-scroll to bottom (simplified) + ui.logText.CursorRow = len(ui.logText.Text) + }) + } +} + +// NotifyUpdateAvailable shows an update notification +func (ui *LauncherUI) NotifyUpdateAvailable(version string) { + if ui.launcher != nil && ui.launcher.window != nil { + fyne.DoAndWait(func() { + dialog.ShowConfirm("Update Available", + "A new version ("+version+") is available. Would you like to download it?", + func(confirmed bool) { + if confirmed { + ui.downloadUpdate() + } + }, ui.launcher.window) + }) + } +} + +// LoadConfiguration loads the current configuration into UI elements +func (ui *LauncherUI) LoadConfiguration() { + if ui.launcher == nil { + log.Printf("UI LoadConfiguration: launcher is nil") + return + } + + config := ui.launcher.GetConfig() + log.Printf("UI LoadConfiguration: loading config - ModelsPath=%s, BackendsPath=%s, Address=%s, LogLevel=%s", + config.ModelsPath, config.BackendsPath, config.Address, config.LogLevel) + log.Printf("UI LoadConfiguration: Environment vars: %v", config.EnvironmentVars) + + ui.modelsPathEntry.SetText(config.ModelsPath) + ui.backendsPathEntry.SetText(config.BackendsPath) + ui.addressEntry.SetText(config.Address) + ui.logLevelSelect.SetSelected(config.LogLevel) + ui.startOnBootCheck.SetChecked(config.StartOnBoot) + + // Load environment variables + ui.envVarsData = []EnvVar{} + for key, value := range config.EnvironmentVars { + ui.envVarsData = append(ui.envVarsData, EnvVar{Key: key, Value: value}) + } + if ui.updateEnvironmentDisplay != nil { + fyne.Do(func() { + ui.updateEnvironmentDisplay() + }) + } + + // Update version display + version := ui.launcher.GetCurrentVersion() + ui.versionLabel.SetText("Version: " + version) + + log.Printf("UI LoadConfiguration: configuration loaded successfully") +} + +// showDownloadProgress shows a progress window for downloading LocalAI +func (ui *LauncherUI) showDownloadProgress(version, title string) { + fyne.DoAndWait(func() { + // Create progress window using the launcher's app + progressWindow := ui.launcher.app.NewWindow("Downloading LocalAI") + progressWindow.Resize(fyne.NewSize(400, 250)) + progressWindow.CenterOnScreen() + + // Progress bar + progressBar := widget.NewProgressBar() + progressBar.SetValue(0) + + // Status label + statusLabel := widget.NewLabel("Preparing download...") + + // Release notes button + releaseNotesButton := widget.NewButton("View Release Notes", func() { + releaseNotesURL, err := ui.launcher.githubReleaseNotesURL(version) + if err != nil { + log.Printf("Failed to parse URL: %v", err) + return + } + + ui.launcher.app.OpenURL(releaseNotesURL) + }) + + // Progress container + progressContainer := container.NewVBox( + widget.NewLabel(title), + progressBar, + statusLabel, + widget.NewSeparator(), + releaseNotesButton, + ) + + progressWindow.SetContent(progressContainer) + progressWindow.Show() + + // Start download in background + go func() { + err := ui.launcher.DownloadUpdate(version, func(progress float64) { + // Update progress bar + fyne.Do(func() { + progressBar.SetValue(progress) + percentage := int(progress * 100) + statusLabel.SetText(fmt.Sprintf("Downloading... %d%%", percentage)) + }) + }) + + // Handle completion + fyne.Do(func() { + if err != nil { + statusLabel.SetText(fmt.Sprintf("Download failed: %v", err)) + // Show error dialog + dialog.ShowError(err, progressWindow) + } else { + statusLabel.SetText("Download completed successfully!") + progressBar.SetValue(1.0) + + // Show success dialog + dialog.ShowConfirm("Installation Complete", + "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.", + func(close bool) { + progressWindow.Close() + // Update status + ui.UpdateStatus("LocalAI installed successfully") + }, progressWindow) + } + }) + }() + }) +} + +// UpdateRunningState updates UI based on LocalAI running state +func (ui *LauncherUI) UpdateRunningState(isRunning bool) { + fyne.Do(func() { + if isRunning { + ui.startStopButton.SetText("Stop LocalAI") + ui.webUIButton.Enable() + } else { + ui.startStopButton.SetText("Start LocalAI") + ui.webUIButton.Disable() + } + }) +} diff --git a/cli/launcher/logo.png b/cli/launcher/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..94035377eae8c2f90843d261f5e285940167f693 GIT binary patch literal 6107 zcmV<17bNJ3P)y{D4^000SaNLh0L01FcU01FcV0GgZ_00007 zbV*G`2k8hI5G5D~M_B&=02d!gL_t(&-pzYya9r1Q=6CMfdc)d{l>|T%0!R=G36KOg zQxYYUk|;-#wOO(?mdCL&W!Z76rfOolCO_g#Do!O;GnuhHu_j4P99wqDl4wzs6qB|n zkrKrf-~xaIMUfzhg^e9(p!fIgnIG?UqZ?>YBqh1#78~9D`rY^LchB~na~=sfMxc3f zWkS*(CRzs&5p!4&{}+l!{%!K&=`jAlBha|1K1t$-65?S13V|bn<@fv|^5aWgXE^g* zW&Y&ym20#pX}<+2?Enz~fJkTHekke+U+I#)(a}NqhXPZ$YQs8H(cWN)%>aablr$#V z5o2{QHac)>Q9w(Z*HZ-gH(9990U#+Ex~jmmED#C7B$}B~Sy*1VFFZ16eddyB-dt}9 z^>u)j5dhP)oc$#LBtX=XS6=<|$Z&r&Cm^k~#w$tr9}HST0BTp);r?$v4|8@BP&%1E4}7&2V&d@K9== zn0K8P3EDsan1RN7J`cs6hqi|v$MV(nU|U-i3Gom9=1prpOF&VR|0{@E0YE4M+n#t7 zm8;jHXlX4rKeP){+FG9^<@uWSyFEDpNzgY400XF~DTlvi1&A4{;l{?tzJ$W!Jo{G? zE|bOImbGj$J_|5qYqo5bq8T~@yu1%>%32*opz*!Bg0?s@J{ zg~_-d09Z0w?%fG#*&-nTn%j%E`|fk}7myG;EQ$6{-ENvTyJ8mofSEN0;4dk{nq3cr zL5@FQ7`}W2mQt#*Q_fpm|^0+eLs4uEw4D5)%i z;qlG84?v_~<0Frzt_vnn*VjM#z^94FWcpixzyO2*s#{v^yW9Wl&&((&D|Z|N0HDoc zWCLO$ipV+WDJX!d8yQgMhNrj`6;&0fqq?OUJ4%~3iBBhhwzoj6MYiLGCJ2tqT! zoK`sdoq)VR0Li3jubr`?C{U_pI|XJhJKr_KfKZg2H#Iz7RMyvn9hb+_`l4iZeJaus z+FfAi48Y~mQBl9z@vIq}>e#UE_Ci{=%2^D|3=b4%0a6sjmEHg$0Jg>M=o$c9tC<;< z_4P=kc1Nhys-mv?6r`hp@jz-#RrR&d+}^p{+naSkID>l$;=d2>?r( zp2q;Df`jPX`x=;SsaPgs^@i4r_=Sh&18KbLei3Ni{NRWvdpQR6kSY13Wa>&toUurp zHbGsIsUr#T5CFIgJ-|RQ09clVB@GP@GXX3!hPQtFBP64<=?zcD6Ch1>P%*rI|LnZL z%3*f)!%QS>P>uvb!695de*x3i2OW@v!pbG--Lr&RR^8N=2LQ`9+~q53+WgGmSmpEtrvQ=w5DkT3#$#y-g{P+xizjgI-M4LLKtP^9kfD|^;?RqWn>Mvi zM9o7Ml%GiwD^d$20J^Ti>vcm=hSo55tB`Nm`$qS&bHBViH=w9s05hXOTswaOimD(H z4yWf%kBvcd)LtQskBv{xWZ4kOKE;JjFZWs_k0TE&7To0h$6U^`>L4T5RNQO&CL;E_cy6YaO zE-#1_urzV}m0uy5v;ZOy5#lU<6bF)hW6`)!^*l%TP!s_O1&PQEj=s4c5CWEDXl{?A zk9X|r0+PmZ}E=jA-wccJtL(?EdfuXBe zWI{@b+Qxe9`sVX!+4Tr?cfPaJz}S_GINx!WOew)qro5Aj76JrTm}=yiCDOOa_Ks-mFv{(G_NbK60RhLm}w5;7T&;DP_}Bml;AXcm)$LrBEp zh|Yv?{n`-7F)kw8DJ7$`=h37)9^<2XU^W3!188W+sg8xA$S_%C)&Lu(=(UDIo;V`NrP# zW~tKLGthZ-Drd$T9UV-1N-K+)#0~%yo*2jS)>e3n{J7rRjhU$s03b3OfeI7B{vilK z*!JXOsA_3L27O$>0aMaU2X4o>6-|dLzYtX|tyr>bDMG;^%!Fn!(mw!}?3~Y7xp5N; zE0$p7(gmD)=a9o(I4l(Lcyzcwv{=RpudeUHHDdeMpF>^Csx0tsj~o8#YDhDQa|ho+ zDzy;cmtBeoX5MB2pn=Z!!>Yh@M6iSq=sJ8H6IU;yvY`hfaiQ_rBV zFc0idp`qR`bo~48psETOV1?v&$9qnkzkwoT4}W9(a+9=oB&f#mfQZGDHnSzdJvuZjBct8NBJ;1YXks?r zbG%O_`E7s`0FW@PG&3#REz?4XAf$C7&%i5J zJC4NCDi>JYMwZaOYC(B{LB?WEif^jMkp*#)IrCML9XCAB{uZ^0?D+!nyaxH@vF@X% zZ9vUiRwS6Z8RVXe5-CT#*x;;)!}D)X8CS|WCv!1rwtfSPo_~I}^C!ob5cs^?jNGLH zfF-TJVA9+G5RF97-uLWRDs`7@D8u)Bynra{HgnbwFsF2lZ%Nqmg`Pc+S)CXfjCZ{K zP6Lr}GiXIzbvM|6AR3*OBp&j z1(*PWrMukG)W%#p)6r)o&B_!dOaGlm)iSkXX;ZVVx!h^*X(kff-*r0SSUTm@o}4QCW`L^{9!?9O? zjcCj^o&0)#{grbHm(|mn=WZZPf@>IT|4yaD`egcp|Kvedg zJkxmRvy8I3RezmKB*L1mduxi_L^wiFn)v0Ghz*OPzsod=2qL{Px?G zwJXMnsA6Vpw2>Kk06~%FFVo~S*mYEb<$C~3m&?fO`TcKS0f3!f|C$HPIh(`G65F1B z8oog3TnZnF;MD&8X@D#(R5q=dqYEhlsT-)MEV(^(v#faC);moA)~~<&S9<^`0&>jl z()VYv?R*3K2s};zI^Q|eW|`)RiuzS~E|=?SE`4v`c@F~1md|w|EDOhe{R@l?&Lxmr z|LDG)mZIu9n(q1hP1(}?M839h<=dL!y3>rur}QQMXB}elt5_lt$pUn(^XP=i*8g-1Mtoy=uzLOt|MrSh`XsBLZiIv;7gT!1(;_B&>xP1OnIzuh=7vhf1cOh5& z0f4He7A&o)wELzv2n?Jpp6eT*cq#xQbiTPKXt)e7fT%9;(n!}axgZ~6#!m?R5s35; zUVHtkiD+cpTkH=!_5JT!sv-vMa#9{PJ^d_{R6~pi)7J-a1(y?HyED6qWqPcHjaIG_`Tn(d*Hs6r7P;R)SURt6yEvQ z|BG-q0iQ5=T}Ro9TBt5h+OBS?S4@PN;UP>858LG{W>eMmuCm&?A1AvQhfsIt zy#O=%yH4Z%S9U|Pgre!Vc=kMsmeyc(S@~klgUV{_vJbxWYO2>*$SggJ7D-002USw_ z66r*B!|hHlbTRikUp;DCH=wkx0r80u^nP#{eHX9b=v(`dOxjhWbB7N@vP8v_QZ(QD zdAN&9;qrQK$<_)A^D#9U0zjIftIZpq`Bwk2U;JcY_W_Egl0;F;`4^VldeUn6@@&>! zUV-w)wW#YkjU&7F;Kcsl10cBF2JU|HaV%Tcep}9i0l2(wU@`;%97*!uUpRZ_DuC_< zamX|+g;^G+E2w$-pD0CUW>hw`p#2LEq|0EfJ9nU_ZT)S5Npr~J;?#U%P{NX!$ysRB z3|$dI_)?3}QY!a}p4}l-x3=~$OS@TKqAb=TF^>vvWQUY+buX!siP&p^B6aa%6 z5Q=Jd+n5)9vg9V$06~DFDlqa2<~r=RIb3=9bCoa44Cz9CB%P-n=c$}2nPoJU9w);g zoIkK1gXg+#D1q@Smoe7&(G3@&D#CUwH_)`u$S{H*o<}YZnh4M0+L=zsWCDuI19Bu} z=<21hp5xaidOLUlAXQZ@keDO@su0-wlNb2p+XrFB6E|eTgXg;N-it3H8H+AL`IAVR z7A(txxkw&lC6hSw*6Zkg=Ri({3{Q^Z@Tuj3e`WiD#?HLV*kGCGCBFa8@6(ddGOp8x%C!63vUF?7EEDx{U2VkM$sbnSl= zy=Qx|w7x#)eZtezNG2_u>+GWAZ@=&KWC7Cj-z^%4{3Ru3aBOlAq|Rvu0~gO-#^>U3 z=$_mZ#TN)*?bh`;cj65CfAE)R+}MUd)l!}rAE%E_ox$XU?Sv#VdX9JET2C+P*RMrE zX#f*LL0mf9gHR}p<*V*M{hjwL@PN#W@Z=;|%8VW^gMdgPX%1yYp3E;}S3g32d6j8` zzT&hGBtS%hP*CXMK+TGr?nx*L%Ij7_M-n50*D*3Mgv;F*Xy{5ml1US)s-j_CGip}W zVQeUfL^6q?zJ6Tp`4FRn*I`)_t2eB{<}ZB(il!~p0?Wb&FZ~L!SR&Q2VB34~84dFP zL`DY_IVA)DhC&D9CJ!>8lQe%Uh4y_#5uOi^chK_no1q%Ha~xGS(6Z}ctZ3hi>ER&a z(I`L!!|g`N@>=L_o1xU-c`qjVuOc3az_KKCmw|$^a^#nmFA|#w69d-}ygm+>ONYYp zJV2e2=xM>cXRPP=?8g?y0!^DLB$#~~O-&`}B$Lue1l;$HKgI%H^%)Ol#({tODF!ZI z1puiKe_aE=WJ<2p1o}q0jzn&$(idoK-x-It4@A&afnDGJ77EIj{J{WSK7JVQ@80W_ z26;qBV*Pl}iT=gT|JY|mrQ`zw@@2a%Dbca#H;B)M{t&Xk*rg9~=+!-G2c?qy?>7RI zGap)z-;52(yz=UklJN|PJkyiYh>Z{1*{R|B%-~E7Ud6k=coB(&>Fhio&G#68URYC? znC`zUZyOK*L`M3j^2#bZB$P)%q)(1cVDw@yN|r5y`x6paB0?e>NqaYD#&B;p_WjGx z5Q`-;x)fx@O&Llg%u}(EYYT#6N&OlT86W1GCTMzb#Z`s*8Y{{}0L1KU1pQ}D17-~V z>SgwWx3@^}lJOY2_P+&}*8@*s5fb4UocZ^?=-Br*lBShew4DU-0)QWdy5G;)M2L(J z^KI43i&m}wvZcCy!a$*29c5?=Vawx>B0o@q;;Nb~B0IlrWhLVn@9#%wd>_FK}-%@#j(9_A(k}fx?=W2edoY<0lD9z+Z&P;4o$;CXtB65S|WUYJ3uw(^_#EwyMSA$$3pM?o%PZ zmSpY*DWRF}!{*I4WN&K(R&OXv3jMnbJip*k0Jhn>l=e$eoD`lY>xOd={LS^;nP4eW zzTi^u--2g92;QVEItPxN2m{vU1bB)5k&a<2dY002ovPDHLkV1oEdcgX+% literal 0 HcmV?d00001 diff --git a/cli/launcher/main.go b/cli/launcher/main.go new file mode 100644 index 000000000..0369981e3 --- /dev/null +++ b/cli/launcher/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/driver/desktop" + coreLauncher "github.com/mudler/LocalAI/cli/launcher/internal" +) + +func main() { + // Create the application with unique ID + myApp := app.NewWithID("com.localai.launcher") + myApp.SetIcon(resourceIconPng) + myWindow := myApp.NewWindow("LocalAI Launcher") + myWindow.Resize(fyne.NewSize(800, 600)) + + // Create the launcher UI + ui := coreLauncher.NewLauncherUI() + + // Initialize the launcher with UI context + launcher := coreLauncher.NewLauncher(ui, myWindow, myApp) + + // Setup the UI + content := ui.CreateMainUI(launcher) + myWindow.SetContent(content) + + // Setup window close behavior - minimize to tray instead of closing + myWindow.SetCloseIntercept(func() { + myWindow.Hide() + }) + + // Setup system tray using Fyne's built-in approach`` + if desk, ok := myApp.(desktop.App); ok { + // Create a dynamic systray manager + systray := coreLauncher.NewSystrayManager(launcher, myWindow, desk, myApp, resourceIconPng) + launcher.SetSystray(systray) + } + + // Setup signal handling for graceful shutdown + setupSignalHandling(launcher) + + // Initialize the launcher state + go func() { + if err := launcher.Initialize(); err != nil { + log.Printf("Failed to initialize launcher: %v", err) + if launcher.GetUI() != nil { + launcher.GetUI().UpdateStatus("Failed to initialize: " + err.Error()) + } + } else { + // Load configuration into UI + launcher.GetUI().LoadConfiguration() + launcher.GetUI().UpdateStatus("Ready") + } + }() + + // Run the application in background (window only shown when "Settings" is clicked) + myApp.Run() +} + +// setupSignalHandling sets up signal handlers for graceful shutdown +func setupSignalHandling(launcher *coreLauncher.Launcher) { + // Create a channel to receive OS signals + sigChan := make(chan os.Signal, 1) + + // Register for interrupt and terminate signals + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Handle signals in a separate goroutine + go func() { + sig := <-sigChan + log.Printf("Received signal %v, shutting down gracefully...", sig) + + // Perform cleanup + if err := launcher.Shutdown(); err != nil { + log.Printf("Error during shutdown: %v", err) + } + + // Exit the application + os.Exit(0) + }() +} diff --git a/main.go b/cli/local-ai/main.go similarity index 91% rename from main.go rename to cli/local-ai/main.go index 3c8615952..96ec75dd3 100644 --- a/main.go +++ b/cli/local-ai/main.go @@ -42,7 +42,7 @@ func main() { for _, envFile := range envFiles { if _, err := os.Stat(envFile); err == nil { - log.Info().Str("envFile", envFile).Msg("env file found, loading environment variables from file") + log.Debug().Str("envFile", envFile).Msg("env file found, loading environment variables from file") err = godotenv.Load(envFile) if err != nil { log.Error().Err(err).Str("envFile", envFile).Msg("failed to load environment variables from file") @@ -97,19 +97,19 @@ Version: ${version} switch *cli.CLI.LogLevel { case "error": zerolog.SetGlobalLevel(zerolog.ErrorLevel) - log.Info().Msg("Setting logging to error") + log.Debug().Msg("Setting logging to error") case "warn": zerolog.SetGlobalLevel(zerolog.WarnLevel) - log.Info().Msg("Setting logging to warn") + log.Debug().Msg("Setting logging to warn") case "info": zerolog.SetGlobalLevel(zerolog.InfoLevel) - log.Info().Msg("Setting logging to info") + log.Debug().Msg("Setting logging to info") case "debug": zerolog.SetGlobalLevel(zerolog.DebugLevel) log.Debug().Msg("Setting logging to debug") case "trace": zerolog.SetGlobalLevel(zerolog.TraceLevel) - log.Trace().Msg("Setting logging to trace") + log.Debug().Msg("Setting logging to trace") } // Run the thing! diff --git a/core/cli/run.go b/core/cli/run.go index 96bb203a9..09e98307d 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -13,6 +13,7 @@ import ( "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/http" "github.com/mudler/LocalAI/core/p2p" + "github.com/mudler/LocalAI/internal" "github.com/mudler/LocalAI/pkg/system" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -73,9 +74,16 @@ type RunCMD struct { DisableGalleryEndpoint bool `env:"LOCALAI_DISABLE_GALLERY_ENDPOINT,DISABLE_GALLERY_ENDPOINT" help:"Disable the gallery endpoints" group:"api"` MachineTag string `env:"LOCALAI_MACHINE_TAG,MACHINE_TAG" help:"Add Machine-Tag header to each response which is useful to track the machine in the P2P network" group:"api"` LoadToMemory []string `env:"LOCALAI_LOAD_TO_MEMORY,LOAD_TO_MEMORY" help:"A list of models to load into memory at startup" group:"models"` + + Version bool } func (r *RunCMD) Run(ctx *cliContext.Context) error { + if r.Version { + fmt.Println(internal.Version) + return nil + } + os.MkdirAll(r.BackendsPath, 0750) os.MkdirAll(r.ModelsPath, 0750) diff --git a/go.mod b/go.mod index 55d5ffadd..17b737edb 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.5 require ( dario.cat/mergo v1.0.1 + fyne.io/fyne/v2 v2.6.3 github.com/Masterminds/sprig/v3 v3.3.0 github.com/alecthomas/kong v0.9.0 github.com/charmbracelet/glamour v0.7.0 @@ -13,7 +14,7 @@ require ( github.com/containerd/containerd v1.7.19 github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2 github.com/ebitengine/purego v0.8.4 - github.com/fsnotify/fsnotify v1.7.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240626202019-c118733a29ad github.com/go-audio/wav v1.1.0 github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46 @@ -65,14 +66,30 @@ require ( ) require ( + fyne.io/systray v1.11.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fasthttp/websocket v1.5.8 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fredbi/uri v1.1.0 // indirect + github.com/fyne-io/gl-js v0.2.0 // indirect + github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-text/render v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/libp2p/go-yamux/v5 v5.0.1 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -82,6 +99,8 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect @@ -103,12 +122,16 @@ require ( github.com/pion/turn/v4 v4.0.2 // indirect github.com/pion/webrtc/v4 v4.1.2 // indirect github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect + github.com/rymdport/portal v0.4.1 // indirect github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect github.com/shirou/gopsutil/v4 v4.24.7 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/wlynxg/anet v0.0.5 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect go.uber.org/mock v0.5.2 // indirect + golang.org/x/image v0.25.0 // indirect golang.org/x/time v0.12.0 // indirect ) @@ -272,7 +295,7 @@ require ( github.com/vishvananda/netns v0.0.5 // indirect github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - github.com/yuin/goldmark v1.5.4 // indirect + github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/go.sum b/go.sum index bba156725..43668f44b 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,10 @@ dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA= +fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs= +fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= +fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= @@ -15,6 +19,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -128,15 +134,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= -github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo= -github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= @@ -146,9 +151,19 @@ github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJn github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= +github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= +github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240626202019-c118733a29ad h1:dQ93Vd6i25o+zH9vvnZ8mu7jtJQ6jT3D+zE3V8Q49n0= github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240626202019-c118733a29ad/go.mod h1:QIjZ9OktHFG7p+/m3sMvrAJKKdWrr1fZIK0rM6HZlyo= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -161,6 +176,10 @@ github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38r github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g= github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -182,8 +201,16 @@ github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46 h1:lALhXzDk github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46/go.mod h1:iub0ugfTnflE3rcIuqV2pQSo15nEw3GLW/utm5gyERo= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= +github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofiber/contrib/fiberzerolog v1.0.2 h1:LMa/luarQVeINoRwZLHtLQYepLPDIwUNB5OmdZKk+s8= github.com/gofiber/contrib/fiberzerolog v1.0.2/go.mod h1:aTPsgArSgxRWcUeJ/K6PiICz3mbQENR1QOR426QwOoQ= github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= @@ -267,6 +294,10 @@ github.com/grpc-ecosystem/grpc-gateway v1.5.0 h1:WcmKMm43DR7RdtlkEXQJyo5ws8iTp98 github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -310,6 +341,8 @@ github.com/jaypipes/pcidb v1.0.0 h1:vtZIfkiCUE42oYbJS0TAq9XSfSmcsgo9IdxSm9qzYU8= github.com/jaypipes/pcidb v1.0.0/go.mod h1:TnYUvqhPBzCKnH34KrIX22kAeEbDCSRJ9cqLRCuNDfk= github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk= github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -320,6 +353,8 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= @@ -490,6 +525,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/nikolalohinski/gonja/v2 v2.3.2 h1:UgLFfqi7L9XfX0PEcE4eUpvGojVQL5KhBfJJaBp7ZxY= github.com/nikolalohinski/gonja/v2 v2.3.2/go.mod h1:1Wcc/5huTu6y36e0sOFR1XQoFlylw3c3H3L5WOz0RDg= github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ= @@ -572,6 +611,8 @@ github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= +github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -616,6 +657,8 @@ github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3V github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/sashabaranov/go-openai v1.26.2 h1:cVlQa3gn3eYqNXRW03pPlpy6zLG52EU4g0FrWXc0EFI= github.com/sashabaranov/go-openai v1.26.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= @@ -674,6 +717,10 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/streamer45/silero-vad-go v0.2.1 h1:Li1/tTC4H/3cyw6q4weX+U8GWwEL3lTekK/nYa1Cvuk= github.com/streamer45/silero-vad-go v0.2.1/go.mod h1:B+2FXs/5fZ6pzl6unUZYhZqkYdOB+3saBVzjOzdZnUs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -740,8 +787,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= -github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -809,6 +856,8 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= diff --git a/tests/e2e-aio/e2e_test.go b/tests/e2e-aio/e2e_test.go index 3682d7e9a..f503f4952 100644 --- a/tests/e2e-aio/e2e_test.go +++ b/tests/e2e-aio/e2e_test.go @@ -188,7 +188,7 @@ var _ = Describe("E2E test", func() { { Type: openai.ChatMessagePartTypeImageURL, ImageURL: &openai.ChatMessageImageURL{ - URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg", + URL: "https://picsum.photos/id/22/4434/3729", Detail: openai.ImageURLDetailLow, }, }, @@ -197,7 +197,7 @@ var _ = Describe("E2E test", func() { }}) Expect(err).ToNot(HaveOccurred()) Expect(len(resp.Choices)).To(Equal(1), fmt.Sprint(resp)) - Expect(resp.Choices[0].Message.Content).To(Or(ContainSubstring("wooden"), ContainSubstring("grass")), fmt.Sprint(resp.Choices[0].Message.Content)) + Expect(resp.Choices[0].Message.Content).To(Or(ContainSubstring("man"), ContainSubstring("road")), fmt.Sprint(resp.Choices[0].Message.Content)) }) }) Context("text to audio", func() {