mirror of
https://github.com/mudler/LocalAI.git
synced 2026-01-06 10:39:55 -06:00
* chore(refactor): cli -> cmd Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Update README Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
514 lines
14 KiB
Go
514 lines
14 KiB
Go
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()
|
|
}
|
|
}()
|
|
}
|