mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-28 10:27:30 -04:00
fix(launcher): truncate download status labels to stop dialog blowout The download progress windows place a ProgressBar and a status Label in the same VBox. On failure the status label is set to "Download failed: <error>", and the error commonly contains a long, unbreakable URL/path. A Fyne label with default settings reports its MinSize as the full single-line text width, so a long message stretches the window — and the progress bar sharing the VBox — arbitrarily wide (fixes #10355). Set Truncation = fyne.TextTruncateEllipsis on the four affected status labels (the main-window status label plus the status label in each of the three showDownloadProgress implementations). Truncation collapses the label's MinSize to roughly one character plus the ellipsis regardless of content, so the window keeps its intended size. TextWrapWord is not enough because it cannot break a spaceless URL. The full error text remains visible via the dialog.ShowError call already present in each path. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
870 lines
23 KiB
Go
870 lines
23 KiB
Go
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"`
|
|
ShowWelcome *bool `json:"show_welcome"`
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// Set default welcome window preference
|
|
if l.config.ShowWelcome == nil {
|
|
true := true
|
|
l.config.ShowWelcome = &true
|
|
log.Printf("Setting default ShowWelcome: true")
|
|
}
|
|
|
|
// 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. Truncate with an ellipsis so a long "Download failed:
|
|
// <url>" message can't stretch the window (and progress bar) to fit the
|
|
// whole error on one line; the full error is shown in the dialog below.
|
|
statusLabel := widget.NewLabel("Preparing download...")
|
|
statusLabel.Truncation = fyne.TextTruncateEllipsis
|
|
|
|
// 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
|
|
}
|