mirror of
https://github.com/mudler/LocalAI.git
synced 2025-12-25 19:30:17 -06: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
44
.github/workflows/build-test.yaml
vendored
44
.github/workflows/build-test.yaml
vendored
@@ -21,3 +21,47 @@ jobs:
|
||||
- name: Run GoReleaser
|
||||
run: |
|
||||
make dev-dist
|
||||
launcher-build-darwin:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23
|
||||
- name: Build launcher for macOS ARM64
|
||||
run: |
|
||||
make build-launcher-darwin
|
||||
ls -liah dist
|
||||
- name: Upload macOS launcher artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-macos
|
||||
path: dist/
|
||||
retention-days: 30
|
||||
|
||||
launcher-build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23
|
||||
- name: Build launcher for Linux
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install golang gcc libgl1-mesa-dev xorg-dev libxkbcommon-dev
|
||||
make build-launcher-linux
|
||||
- name: Upload Linux launcher artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-linux
|
||||
path: local-ai-launcher-linux.tar.xz
|
||||
retention-days: 30
|
||||
50
.github/workflows/release.yaml
vendored
50
.github/workflows/release.yaml
vendored
@@ -23,4 +23,52 @@ jobs:
|
||||
version: v2.11.0
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
launcher-build-darwin:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23
|
||||
- name: Build launcher for macOS ARM64
|
||||
run: |
|
||||
make build-launcher-darwin
|
||||
- name: Upload DMG to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./dist/LocalAI-Launcher.dmg
|
||||
asset_name: LocalAI.dmg
|
||||
asset_content_type: application/octet-stream
|
||||
launcher-build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.23
|
||||
- name: Build launcher for Linux
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install golang gcc libgl1-mesa-dev xorg-dev libxkbcommon-dev
|
||||
make build-launcher-linux
|
||||
- name: Upload Linux launcher artifacts
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
url: ${{ github.event.release.upload_url }}
|
||||
asset_path: ./local-ai-launcher-linux.tar.xz
|
||||
asset_name: LocalAI-Launcher-linux.tar.xz
|
||||
asset_content_type: application/octet-stream
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,7 +24,7 @@ go-bert
|
||||
|
||||
# LocalAI build binary
|
||||
LocalAI
|
||||
local-ai
|
||||
/local-ai
|
||||
# prevent above rules from omitting the helm chart
|
||||
!charts/*
|
||||
# prevent above rules from omitting the api/localai folder
|
||||
|
||||
@@ -8,7 +8,7 @@ source:
|
||||
enabled: true
|
||||
name_template: '{{ .ProjectName }}-{{ .Tag }}-source'
|
||||
builds:
|
||||
-
|
||||
- main: ./cli/local-ai
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
ldflags:
|
||||
|
||||
29
Makefile
29
Makefile
@@ -2,6 +2,7 @@ GOCMD=go
|
||||
GOTEST=$(GOCMD) test
|
||||
GOVET=$(GOCMD) vet
|
||||
BINARY_NAME=local-ai
|
||||
LAUNCHER_BINARY_NAME=local-ai-launcher
|
||||
|
||||
GORELEASER?=
|
||||
|
||||
@@ -90,7 +91,17 @@ build: protogen-go install-go-tools ## Build the project
|
||||
$(info ${GREEN}I LD_FLAGS: ${YELLOW}$(LD_FLAGS)${RESET})
|
||||
$(info ${GREEN}I UPX: ${YELLOW}$(UPX)${RESET})
|
||||
rm -rf $(BINARY_NAME) || true
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(BINARY_NAME) ./
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(BINARY_NAME) ./cli/local-ai
|
||||
|
||||
build-launcher: ## Build the launcher application
|
||||
$(info ${GREEN}I local-ai launcher build info:${RESET})
|
||||
$(info ${GREEN}I BUILD_TYPE: ${YELLOW}$(BUILD_TYPE)${RESET})
|
||||
$(info ${GREEN}I GO_TAGS: ${YELLOW}$(GO_TAGS)${RESET})
|
||||
$(info ${GREEN}I LD_FLAGS: ${YELLOW}$(LD_FLAGS)${RESET})
|
||||
rm -rf $(LAUNCHER_BINARY_NAME) || true
|
||||
CGO_LDFLAGS="$(CGO_LDFLAGS)" $(GOCMD) build -ldflags "$(LD_FLAGS)" -tags "$(GO_TAGS)" -o $(LAUNCHER_BINARY_NAME) ./cli/launcher
|
||||
|
||||
build-all: build build-launcher ## Build both server and launcher
|
||||
|
||||
dev-dist:
|
||||
$(GORELEASER) build --snapshot --clean
|
||||
@@ -507,3 +518,19 @@ docs-clean:
|
||||
.PHONY: docs
|
||||
docs: docs/static/gallery.html
|
||||
cd docs && hugo serve
|
||||
|
||||
########################################################
|
||||
## Platform-specific builds
|
||||
########################################################
|
||||
|
||||
## fyne cross-platform build
|
||||
build-launcher-darwin: build-launcher
|
||||
go run github.com/tiagomelo/macos-dmg-creator/cmd/createdmg@latest \
|
||||
--appName "LocalAI" \
|
||||
--appBinaryPath "$(LAUNCHER_BINARY_NAME)" \
|
||||
--bundleIdentifier "com.localai.launcher" \
|
||||
--iconPath "core/http/static/logo.png" \
|
||||
--outputDir "dist/"
|
||||
|
||||
build-launcher-linux:
|
||||
cd cli/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv launcher.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
|
||||
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)
|
||||
}()
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func main() {
|
||||
|
||||
for _, envFile := range envFiles {
|
||||
if _, err := os.Stat(envFile); err == nil {
|
||||
log.Info().Str("envFile", envFile).Msg("env file found, loading environment variables from file")
|
||||
log.Debug().Str("envFile", envFile).Msg("env file found, loading environment variables from file")
|
||||
err = godotenv.Load(envFile)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("envFile", envFile).Msg("failed to load environment variables from file")
|
||||
@@ -97,19 +97,19 @@ Version: ${version}
|
||||
switch *cli.CLI.LogLevel {
|
||||
case "error":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
log.Info().Msg("Setting logging to error")
|
||||
log.Debug().Msg("Setting logging to error")
|
||||
case "warn":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
log.Info().Msg("Setting logging to warn")
|
||||
log.Debug().Msg("Setting logging to warn")
|
||||
case "info":
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
log.Info().Msg("Setting logging to info")
|
||||
log.Debug().Msg("Setting logging to info")
|
||||
case "debug":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
log.Debug().Msg("Setting logging to debug")
|
||||
case "trace":
|
||||
zerolog.SetGlobalLevel(zerolog.TraceLevel)
|
||||
log.Trace().Msg("Setting logging to trace")
|
||||
log.Debug().Msg("Setting logging to trace")
|
||||
}
|
||||
|
||||
// Run the thing!
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http"
|
||||
"github.com/mudler/LocalAI/core/p2p"
|
||||
"github.com/mudler/LocalAI/internal"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -73,9 +74,16 @@ type RunCMD struct {
|
||||
DisableGalleryEndpoint bool `env:"LOCALAI_DISABLE_GALLERY_ENDPOINT,DISABLE_GALLERY_ENDPOINT" help:"Disable the gallery endpoints" group:"api"`
|
||||
MachineTag string `env:"LOCALAI_MACHINE_TAG,MACHINE_TAG" help:"Add Machine-Tag header to each response which is useful to track the machine in the P2P network" group:"api"`
|
||||
LoadToMemory []string `env:"LOCALAI_LOAD_TO_MEMORY,LOAD_TO_MEMORY" help:"A list of models to load into memory at startup" group:"models"`
|
||||
|
||||
Version bool
|
||||
}
|
||||
|
||||
func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||
if r.Version {
|
||||
fmt.Println(internal.Version)
|
||||
return nil
|
||||
}
|
||||
|
||||
os.MkdirAll(r.BackendsPath, 0750)
|
||||
os.MkdirAll(r.ModelsPath, 0750)
|
||||
|
||||
|
||||
27
go.mod
27
go.mod
@@ -6,6 +6,7 @@ toolchain go1.24.5
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1
|
||||
fyne.io/fyne/v2 v2.6.3
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/alecthomas/kong v0.9.0
|
||||
github.com/charmbracelet/glamour v0.7.0
|
||||
@@ -13,7 +14,7 @@ require (
|
||||
github.com/containerd/containerd v1.7.19
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2
|
||||
github.com/ebitengine/purego v0.8.4
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240626202019-c118733a29ad
|
||||
github.com/go-audio/wav v1.1.0
|
||||
github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46
|
||||
@@ -65,14 +66,30 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.11.0 // indirect
|
||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fasthttp/websocket v1.5.8 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fredbi/uri v1.1.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||
github.com/fyne-io/image v0.1.1 // indirect
|
||||
github.com/fyne-io/oksvg v0.1.0 // indirect
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/libp2p/go-yamux/v5 v5.0.1 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
@@ -82,6 +99,8 @@ require (
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/otiai10/mint v1.6.3 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
@@ -103,12 +122,16 @@ require (
|
||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
||||
github.com/pion/webrtc/v4 v4.1.2 // indirect
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
|
||||
github.com/rymdport/portal v0.4.1 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.24.7 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect
|
||||
go.uber.org/mock v0.5.2 // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
)
|
||||
|
||||
@@ -272,7 +295,7 @@ require (
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.2 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
|
||||
63
go.sum
63
go.sum
@@ -8,6 +8,10 @@ dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl
|
||||
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
|
||||
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
|
||||
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
|
||||
fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA=
|
||||
fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs=
|
||||
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||
@@ -15,6 +19,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
@@ -128,15 +134,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elastic/gosigar v0.12.0/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
||||
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8=
|
||||
github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
@@ -146,9 +151,19 @@ github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJn
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
||||
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240626202019-c118733a29ad h1:dQ93Vd6i25o+zH9vvnZ8mu7jtJQ6jT3D+zE3V8Q49n0=
|
||||
github.com/ggerganov/whisper.cpp/bindings/go v0.0.0-20240626202019-c118733a29ad/go.mod h1:QIjZ9OktHFG7p+/m3sMvrAJKKdWrr1fZIK0rM6HZlyo=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
@@ -161,6 +176,10 @@ github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38r
|
||||
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
|
||||
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -182,8 +201,16 @@ github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46 h1:lALhXzDk
|
||||
github.com/go-skynet/go-llama.cpp v0.0.0-20240314183750-6a8041ef6b46/go.mod h1:iub0ugfTnflE3rcIuqV2pQSo15nEw3GLW/utm5gyERo=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/contrib/fiberzerolog v1.0.2 h1:LMa/luarQVeINoRwZLHtLQYepLPDIwUNB5OmdZKk+s8=
|
||||
github.com/gofiber/contrib/fiberzerolog v1.0.2/go.mod h1:aTPsgArSgxRWcUeJ/K6PiICz3mbQENR1QOR426QwOoQ=
|
||||
github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
|
||||
@@ -267,6 +294,10 @@ github.com/grpc-ecosystem/grpc-gateway v1.5.0 h1:WcmKMm43DR7RdtlkEXQJyo5ws8iTp98
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -310,6 +341,8 @@ github.com/jaypipes/pcidb v1.0.0 h1:vtZIfkiCUE42oYbJS0TAq9XSfSmcsgo9IdxSm9qzYU8=
|
||||
github.com/jaypipes/pcidb v1.0.0/go.mod h1:TnYUvqhPBzCKnH34KrIX22kAeEbDCSRJ9cqLRCuNDfk=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0 h1:zpb3ZH6wIE8Shj2sKS+khgRvf7T7RABoLk/+KKHggpk=
|
||||
github.com/jbenet/go-temp-err-catcher v0.1.0/go.mod h1:0kJRvmDZXNMIiJirNPEYfhpPwbGVtZVWC34vc5WLsDk=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
@@ -320,6 +353,8 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
||||
@@ -490,6 +525,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||
github.com/nikolalohinski/gonja/v2 v2.3.2 h1:UgLFfqi7L9XfX0PEcE4eUpvGojVQL5KhBfJJaBp7ZxY=
|
||||
github.com/nikolalohinski/gonja/v2 v2.3.2/go.mod h1:1Wcc/5huTu6y36e0sOFR1XQoFlylw3c3H3L5WOz0RDg=
|
||||
github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7MQ=
|
||||
@@ -572,6 +611,8 @@ github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZ
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=
|
||||
github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -616,6 +657,8 @@ github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3V
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
||||
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/sashabaranov/go-openai v1.26.2 h1:cVlQa3gn3eYqNXRW03pPlpy6zLG52EU4g0FrWXc0EFI=
|
||||
github.com/sashabaranov/go-openai v1.26.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8=
|
||||
@@ -674,6 +717,10 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/streamer45/silero-vad-go v0.2.1 h1:Li1/tTC4H/3cyw6q4weX+U8GWwEL3lTekK/nYa1Cvuk=
|
||||
github.com/streamer45/silero-vad-go v0.2.1/go.mod h1:B+2FXs/5fZ6pzl6unUZYhZqkYdOB+3saBVzjOzdZnUs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -740,8 +787,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
||||
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
|
||||
github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -809,6 +856,8 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
|
||||
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
|
||||
@@ -188,7 +188,7 @@ var _ = Describe("E2E test", func() {
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg",
|
||||
URL: "https://picsum.photos/id/22/4434/3729",
|
||||
Detail: openai.ImageURLDetailLow,
|
||||
},
|
||||
},
|
||||
@@ -197,7 +197,7 @@ var _ = Describe("E2E test", func() {
|
||||
}})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(resp.Choices)).To(Equal(1), fmt.Sprint(resp))
|
||||
Expect(resp.Choices[0].Message.Content).To(Or(ContainSubstring("wooden"), ContainSubstring("grass")), fmt.Sprint(resp.Choices[0].Message.Content))
|
||||
Expect(resp.Choices[0].Message.Content).To(Or(ContainSubstring("man"), ContainSubstring("road")), fmt.Sprint(resp.Choices[0].Message.Content))
|
||||
})
|
||||
})
|
||||
Context("text to audio", func() {
|
||||
|
||||
Reference in New Issue
Block a user