mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-14 12:02:23 -04:00
feat(launcher): add LocalAI launcher app (#6127)
* Add launcher (WIP) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Update gomod Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Cleanup, focus on systray Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Separate launcher from main Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Add a way to identify the binary version Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Implement save config, and start on boot Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Small fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Save installed version as metadata Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Stop LocalAI on quit Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fix goreleaser Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Check first if binary is there Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * do not show version if we don't have it Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Try to build on CI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * use fyne package Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Add to release Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fyne.Do Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * show WEBUI button only if LocalAI is started Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Default to localhost Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * CI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Show rel notes Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Update logo Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Small improvements and fix tests Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Try to fix e2e tests Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
0fc88b3cdf
commit
f8a8cf3e95
16
cli/launcher/icon.go
Normal file
16
cli/launcher/icon.go
Normal file
@@ -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,
|
||||
}
|
||||
858
cli/launcher/internal/launcher.go
Normal file
858
cli/launcher/internal/launcher.go
Normal file
@@ -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
|
||||
}
|
||||
13
cli/launcher/internal/launcher_suite_test.go
Normal file
13
cli/launcher/internal/launcher_suite_test.go
Normal file
@@ -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")
|
||||
}
|
||||
205
cli/launcher/internal/launcher_test.go
Normal file
205
cli/launcher/internal/launcher_test.go
Normal file
@@ -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())
|
||||
})
|
||||
})
|
||||
502
cli/launcher/internal/release_manager.go
Normal file
502
cli/launcher/internal/release_manager.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
178
cli/launcher/internal/release_manager_test.go
Normal file
178
cli/launcher/internal/release_manager_test.go
Normal file
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
513
cli/launcher/internal/systray_manager.go
Normal file
513
cli/launcher/internal/systray_manager.go
Normal file
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
677
cli/launcher/internal/ui.go
Normal file
677
cli/launcher/internal/ui.go
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
BIN
cli/launcher/logo.png
Normal file
BIN
cli/launcher/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
86
cli/launcher/main.go
Normal file
86
cli/launcher/main.go
Normal file
@@ -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)
|
||||
}()
|
||||
}
|
||||
120
cli/local-ai/main.go
Normal file
120
cli/local-ai/main.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/mudler/LocalAI/core/cli"
|
||||
"github.com/mudler/LocalAI/internal"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
_ "github.com/mudler/LocalAI/swagger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
||||
// Initialize zerolog at a level of INFO, we will set the desired level after we parse the CLI options
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
|
||||
// Catch signals from the OS requesting us to exit
|
||||
go func() {
|
||||
c := make(chan os.Signal, 1) // we need to reserve to buffer size 1, so the notifier are not blocked
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
<-c
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
// handle loading environment variabled from .env files
|
||||
envFiles := []string{".env", "localai.env"}
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
envFiles = append(envFiles, filepath.Join(homeDir, "localai.env"), filepath.Join(homeDir, ".config/localai.env"))
|
||||
}
|
||||
envFiles = append(envFiles, "/etc/localai.env")
|
||||
|
||||
for _, envFile := range envFiles {
|
||||
if _, err := os.Stat(envFile); err == nil {
|
||||
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")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actually parse the CLI options
|
||||
ctx := kong.Parse(&cli.CLI,
|
||||
kong.Description(
|
||||
` LocalAI is a drop-in replacement OpenAI API for running LLM, GPT and genAI models locally on CPU, GPUs with consumer grade hardware.
|
||||
|
||||
Some of the models compatible are:
|
||||
- Vicuna
|
||||
- Koala
|
||||
- GPT4ALL
|
||||
- GPT4ALL-J
|
||||
- Cerebras
|
||||
- Alpaca
|
||||
- StableLM (ggml quantized)
|
||||
|
||||
For a list of all available models for one-click install, check out: https://models.localai.io
|
||||
|
||||
Copyright: Ettore Di Giacinto
|
||||
|
||||
Version: ${version}
|
||||
`,
|
||||
),
|
||||
kong.UsageOnError(),
|
||||
kong.Vars{
|
||||
"basepath": kong.ExpandPath("."),
|
||||
"galleries": `[{"name":"localai", "url":"github:mudler/LocalAI/gallery/index.yaml@master"}]`,
|
||||
"backends": `[{"name":"localai", "url":"github:mudler/LocalAI/backend/index.yaml@master"}]`,
|
||||
"version": internal.PrintableVersion(),
|
||||
},
|
||||
)
|
||||
|
||||
// Configure the logging level before we run the application
|
||||
// This is here to preserve the existing --debug flag functionality
|
||||
logLevel := "info"
|
||||
if cli.CLI.Debug && cli.CLI.LogLevel == nil {
|
||||
logLevel = "debug"
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
cli.CLI.LogLevel = &logLevel
|
||||
}
|
||||
|
||||
if cli.CLI.LogLevel == nil {
|
||||
cli.CLI.LogLevel = &logLevel
|
||||
}
|
||||
|
||||
switch *cli.CLI.LogLevel {
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
log.Debug().Msg("Setting logging to error")
|
||||
case "warn":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
log.Debug().Msg("Setting logging to warn")
|
||||
case "info":
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
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.Debug().Msg("Setting logging to trace")
|
||||
}
|
||||
|
||||
// Run the thing!
|
||||
err = ctx.Run(&cli.CLI.Context)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Error running the application")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user