mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-28 10:27:30 -04:00
Compare commits
2 Commits
dependabot
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1ae9338b | ||
|
|
923c47020d |
@@ -1,6 +1,6 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
accelerate
|
||||
torch==2.12.0+cpu
|
||||
torch==2.9.0
|
||||
torchvision
|
||||
torchaudio
|
||||
transformers
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# for cublas12 so uv consults this index alongside PyPI.
|
||||
--extra-index-url https://download.pytorch.org/whl/cu128
|
||||
accelerate
|
||||
torch==2.12.0+cpu
|
||||
torch==2.9.1
|
||||
torchvision
|
||||
torchaudio
|
||||
transformers
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
torch==2.12.0+cpu
|
||||
torch==2.10.0
|
||||
trl
|
||||
peft
|
||||
datasets>=3.0.0
|
||||
transformers>=5.12.1
|
||||
transformers>=4.56.2
|
||||
accelerate>=1.4.0
|
||||
huggingface-hub>=1.3.0
|
||||
sentencepiece
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
torch==2.12.0+cpu
|
||||
torch==2.10.0
|
||||
trl
|
||||
peft
|
||||
datasets>=3.0.0
|
||||
transformers>=5.12.1
|
||||
transformers>=4.56.2
|
||||
accelerate>=1.4.0
|
||||
huggingface-hub>=1.3.0
|
||||
sentencepiece
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
torch==2.12.0+cpu
|
||||
torch==2.10.0
|
||||
trl
|
||||
peft
|
||||
datasets>=3.0.0
|
||||
transformers>=5.12.1
|
||||
transformers>=4.56.2
|
||||
accelerate>=1.4.0
|
||||
huggingface-hub>=1.3.0
|
||||
sentencepiece
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
torch==2.12.0+cpu
|
||||
torch==2.10.0
|
||||
trl
|
||||
peft
|
||||
datasets>=3.0.0
|
||||
transformers>=5.12.1
|
||||
transformers>=4.56.2
|
||||
accelerate>=1.4.0
|
||||
huggingface-hub>=1.3.0
|
||||
sentencepiece
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
accelerate
|
||||
torch==2.12.0+cu130
|
||||
torch==2.7.0
|
||||
transformers
|
||||
bitsandbytes
|
||||
|
||||
@@ -429,7 +429,7 @@ func (l *Launcher) CheckForUpdates() (bool, string, error) {
|
||||
}
|
||||
|
||||
// DownloadUpdate downloads the latest version
|
||||
func (l *Launcher) DownloadUpdate(version string, progressCallback func(float64)) error {
|
||||
func (l *Launcher) DownloadUpdate(version string, progressCallback func(downloaded, total int64)) error {
|
||||
return l.releaseManager.DownloadRelease(version, progressCallback)
|
||||
}
|
||||
|
||||
@@ -486,7 +486,6 @@ func (l *Launcher) showDownloadLocalAIDialog() {
|
||||
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()
|
||||
@@ -548,6 +547,7 @@ func (l *Launcher) showDownloadLocalAIDialog() {
|
||||
)
|
||||
|
||||
dialogWindow.SetContent(content)
|
||||
resizeToContent(dialogWindow, content)
|
||||
dialogWindow.Show()
|
||||
})
|
||||
}
|
||||
@@ -621,88 +621,134 @@ func (l *Launcher) showDownloadError(title, message string) {
|
||||
}
|
||||
|
||||
// showDownloadProgress shows a standalone progress window for downloading LocalAI
|
||||
// after a fresh install (no LocalAI binary present yet).
|
||||
func (l *Launcher) showDownloadProgress(version, title string) {
|
||||
l.showDownloadProgressWindow(version, title, func(win fyne.Window) {
|
||||
dialog.ShowConfirm("Installation Complete",
|
||||
"LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.",
|
||||
func(bool) {
|
||||
win.Close()
|
||||
l.updateStatus("LocalAI installed successfully")
|
||||
if l.systray != nil {
|
||||
l.systray.recreateMenu()
|
||||
}
|
||||
}, win)
|
||||
})
|
||||
}
|
||||
|
||||
// showDownloadProgressWindow renders the download progress popup shared by every
|
||||
// "download/upgrade LocalAI" entry point. It owns the progress bar, the
|
||||
// human-readable byte readout, resume-aware retry, and content-fit window
|
||||
// sizing so the behaviour stays identical everywhere. onSuccess runs (on the UI
|
||||
// goroutine) once the download verifies, and is responsible for the success
|
||||
// dialog and any follow-up; the window is passed in so it can be parented/closed.
|
||||
func (l *Launcher) showDownloadProgressWindow(version, title string, onSuccess func(win fyne.Window)) {
|
||||
fyne.DoAndWait(func() {
|
||||
// Create progress window
|
||||
progressWindow := l.app.NewWindow("Downloading LocalAI")
|
||||
progressWindow.Resize(fyne.NewSize(400, 250))
|
||||
progressWindow.CenterOnScreen()
|
||||
progressWindow.SetCloseIntercept(func() {
|
||||
progressWindow.Close()
|
||||
})
|
||||
|
||||
// Progress bar
|
||||
progressBar := widget.NewProgressBar()
|
||||
progressBar.SetValue(0)
|
||||
|
||||
// Status label. Truncate with an ellipsis so a long "Download failed:
|
||||
// <url>" message can't stretch the window (and progress bar) to fit the
|
||||
// whole error on one line; the full error is shown in the dialog below.
|
||||
// whole error on one line.
|
||||
statusLabel := widget.NewLabel("Preparing download...")
|
||||
statusLabel.Truncation = fyne.TextTruncateEllipsis
|
||||
|
||||
// Release notes button
|
||||
releaseNotesButton := widget.NewButton("View Release Notes", func() {
|
||||
releaseNotesURL, err := l.githubReleaseNotesURL(version)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse URL: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
l.app.OpenURL(releaseNotesURL)
|
||||
})
|
||||
|
||||
// Progress container
|
||||
progressContainer := container.NewVBox(
|
||||
// Retry button: hidden until a download fails. GitHub downloads are
|
||||
// flaky, and the underlying download resumes from the partial file, so
|
||||
// a retry continues where it left off rather than starting over.
|
||||
retryButton := widget.NewButton("Retry", nil)
|
||||
retryButton.Importance = widget.HighImportance
|
||||
retryButton.Hide()
|
||||
|
||||
buttonRow := container.NewHBox(releaseNotesButton, retryButton)
|
||||
content := container.NewVBox(
|
||||
widget.NewLabel(title),
|
||||
progressBar,
|
||||
statusLabel,
|
||||
widget.NewSeparator(),
|
||||
releaseNotesButton,
|
||||
buttonRow,
|
||||
)
|
||||
progressWindow.SetContent(content)
|
||||
resizeToContent(progressWindow, content)
|
||||
|
||||
progressWindow.SetContent(progressContainer)
|
||||
progressWindow.Show()
|
||||
var startDownload func()
|
||||
startDownload = func() {
|
||||
retryButton.Hide()
|
||||
progressBar.SetValue(0)
|
||||
statusLabel.SetText("Preparing download...")
|
||||
resizeToContent(progressWindow, content)
|
||||
|
||||
// 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))
|
||||
go func() {
|
||||
err := l.DownloadUpdate(version, func(downloaded, total int64) {
|
||||
fyne.Do(func() {
|
||||
if total > 0 {
|
||||
progressBar.SetValue(float64(downloaded) / float64(total))
|
||||
statusLabel.SetText(fmt.Sprintf("Downloading… %s / %s", formatBytes(downloaded), formatBytes(total)))
|
||||
} else {
|
||||
statusLabel.SetText(fmt.Sprintf("Downloading… %s", formatBytes(downloaded)))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 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!")
|
||||
fyne.Do(func() {
|
||||
if err != nil {
|
||||
statusLabel.SetText(fmt.Sprintf("Download failed: %v", err))
|
||||
retryButton.Show()
|
||||
resizeToContent(progressWindow, content)
|
||||
return
|
||||
}
|
||||
progressBar.SetValue(1.0)
|
||||
statusLabel.SetText("Download complete")
|
||||
onSuccess(progressWindow)
|
||||
})
|
||||
}()
|
||||
}
|
||||
retryButton.OnTapped = startDownload
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}()
|
||||
progressWindow.Show()
|
||||
startDownload()
|
||||
})
|
||||
}
|
||||
|
||||
// resizeToContent sizes a window to fit its content (with a sane minimum width)
|
||||
// so the dialog doesn't show a large blank gap below the last widget.
|
||||
func resizeToContent(w fyne.Window, content fyne.CanvasObject) {
|
||||
size := content.MinSize()
|
||||
if size.Width < 400 {
|
||||
size.Width = 400
|
||||
}
|
||||
w.Resize(size)
|
||||
}
|
||||
|
||||
// formatBytes renders a byte count as a human-readable size (e.g. "12.3 MB").
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -50,6 +51,12 @@ type ReleaseManager struct {
|
||||
ChecksumsPath string
|
||||
// MetadataPath is where version metadata is stored
|
||||
MetadataPath string
|
||||
// BaseDownloadURL is the base URL release assets are downloaded from
|
||||
// (defaults to https://github.com; overridable for testing)
|
||||
BaseDownloadURL string
|
||||
// RetryBackoff is the base wait between download attempts; the Nth retry
|
||||
// waits N*RetryBackoff (defaults to 1s; lowered in tests)
|
||||
RetryBackoff time.Duration
|
||||
// HTTPClient is the HTTP client used for downloads
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
@@ -62,28 +69,94 @@ func NewReleaseManager() *ReleaseManager {
|
||||
metadataPath := filepath.Join(homeDir, ".localai", "metadata")
|
||||
|
||||
return &ReleaseManager{
|
||||
GitHubOwner: "mudler",
|
||||
GitHubRepo: "LocalAI",
|
||||
BinaryPath: binaryPath,
|
||||
CurrentVersion: internal.PrintableVersion(),
|
||||
ChecksumsPath: checksumsPath,
|
||||
MetadataPath: metadataPath,
|
||||
HTTPClient: httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects()),
|
||||
GitHubOwner: "mudler",
|
||||
GitHubRepo: "LocalAI",
|
||||
BinaryPath: binaryPath,
|
||||
CurrentVersion: internal.PrintableVersion(),
|
||||
ChecksumsPath: checksumsPath,
|
||||
MetadataPath: metadataPath,
|
||||
BaseDownloadURL: "https://github.com",
|
||||
RetryBackoff: 1 * time.Second,
|
||||
HTTPClient: httpclient.NewWithTimeout(30*time.Second, httpclient.WithFollowRedirects()),
|
||||
}
|
||||
}
|
||||
|
||||
// GetLatestRelease fetches the latest release information from GitHub
|
||||
// GetLatestRelease resolves the latest LocalAI release.
|
||||
//
|
||||
// It first follows the github.com "releases/latest" redirect, which reveals the
|
||||
// latest tag in the final URL and—crucially—is NOT subject to the
|
||||
// 60-requests/hour unauthenticated rate limit of api.github.com. That limit is
|
||||
// per-IP, so on shared/NAT/CGNAT/cloud addresses the API returns 403 almost
|
||||
// immediately (e.g. on a fresh install with no LocalAI present yet). The
|
||||
// redirect avoids that entirely. The richer JSON API is kept only as a fallback.
|
||||
//
|
||||
// Only the version is consumed by callers, so the redirect's tag is sufficient.
|
||||
func (rm *ReleaseManager) GetLatestRelease() (*Release, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", rm.GitHubOwner, rm.GitHubRepo)
|
||||
version, redirectErr := rm.latestVersionFromRedirect()
|
||||
if redirectErr == nil {
|
||||
return &Release{Version: version}, nil
|
||||
}
|
||||
log.Printf("Could not resolve latest version via release redirect (%v); falling back to GitHub API", redirectErr)
|
||||
|
||||
release, apiErr := rm.latestReleaseFromAPI()
|
||||
if apiErr != nil {
|
||||
// Surface both failures so a rate-limited API doesn't mask the (usually
|
||||
// more relevant) redirect error.
|
||||
return nil, fmt.Errorf("failed to fetch latest release: %v (redirect: %v)", apiErr, redirectErr)
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
// latestVersionFromRedirect returns the latest tag by following the github.com
|
||||
// "releases/latest" redirect to ".../releases/tag/<tag>".
|
||||
func (rm *ReleaseManager) latestVersionFromRedirect() (string, error) {
|
||||
url := fmt.Sprintf("%s/%s/%s/releases/latest", rm.BaseDownloadURL, rm.GitHubOwner, rm.GitHubRepo)
|
||||
|
||||
resp, err := rm.HTTPClient.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
// After the redirect is followed, the final request URL is the tag page.
|
||||
version := path.Base(resp.Request.URL.Path)
|
||||
if version == "" || version == "." || version == "latest" {
|
||||
return "", fmt.Errorf("could not determine version from %s", resp.Request.URL.String())
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// latestReleaseFromAPI fetches the latest release JSON from api.github.com. This
|
||||
// is the fallback path; it is rate-limited unless GITHUB_TOKEN is set.
|
||||
func (rm *ReleaseManager) latestReleaseFromAPI() (*Release, error) {
|
||||
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", rm.GitHubOwner, rm.GitHubRepo)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
// An optional token lifts the unauthenticated 60/hour limit to 5000/hour.
|
||||
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
resp, err := rm.HTTPClient.Do(req)
|
||||
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)
|
||||
if (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests) &&
|
||||
resp.Header.Get("X-RateLimit-Remaining") == "0" {
|
||||
return nil, fmt.Errorf("GitHub API rate limit exceeded (status %d); retry later or set GITHUB_TOKEN to raise the limit", resp.StatusCode)
|
||||
}
|
||||
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the JSON response properly
|
||||
@@ -106,7 +179,7 @@ func (rm *ReleaseManager) GetLatestRelease() (*Release, error) {
|
||||
}
|
||||
|
||||
// DownloadRelease downloads a specific version of LocalAI
|
||||
func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(float64)) error {
|
||||
func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(downloaded, total int64)) 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)
|
||||
@@ -117,16 +190,16 @@ func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(
|
||||
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)
|
||||
downloadURL := fmt.Sprintf("%s/%s/%s/releases/download/%s/%s",
|
||||
rm.BaseDownloadURL, 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)
|
||||
checksumURL := fmt.Sprintf("%s/%s/%s/releases/download/%s/LocalAI-%s-checksums.txt",
|
||||
rm.BaseDownloadURL, rm.GitHubOwner, rm.GitHubRepo, version, version)
|
||||
|
||||
checksumPath := filepath.Join(rm.BinaryPath, "checksums.txt")
|
||||
manualChecksumPath := filepath.Join(rm.ChecksumsPath, fmt.Sprintf("checksums-%s.txt", version))
|
||||
@@ -154,6 +227,10 @@ func (rm *ReleaseManager) DownloadRelease(version string, progressCallback func(
|
||||
// Verify the checksum if we have a checksum file
|
||||
if _, err := os.Stat(checksumPath); err == nil {
|
||||
if err := rm.VerifyChecksum(localPath, checksumPath, binaryName); err != nil {
|
||||
// Discard the corrupt binary (and any leftover partial) so the next
|
||||
// retry starts from a clean slate rather than resuming corruption.
|
||||
os.Remove(localPath)
|
||||
os.Remove(localPath + ".part")
|
||||
return fmt.Errorf("checksum verification failed: %w", err)
|
||||
}
|
||||
log.Printf("Checksum verification successful")
|
||||
@@ -196,44 +273,88 @@ func (rm *ReleaseManager) GetBinaryName(version string) string {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (rm *ReleaseManager) downloadFile(url, filepath string, progressCallback func(downloaded, total int64)) error {
|
||||
return rm.downloadFileWithRetry(url, filepath, progressCallback, 3)
|
||||
}
|
||||
|
||||
// downloadFileWithRetry downloads a file from a URL with retry logic
|
||||
func (rm *ReleaseManager) downloadFileWithRetry(url, filepath string, progressCallback func(float64), maxRetries int) error {
|
||||
// downloadFileWithRetry downloads a file with retry and HTTP Range resume.
|
||||
//
|
||||
// The body is streamed to "<dest>.part" and only renamed to dest on success, so
|
||||
// a dropped connection leaves a partial file that the next attempt continues via
|
||||
// a "Range: bytes=N-" request instead of restarting from zero. This matters for
|
||||
// GitHub release downloads, which are large and flaky.
|
||||
func (rm *ReleaseManager) downloadFileWithRetry(url, dest string, progressCallback func(downloaded, total int64), maxRetries int) error {
|
||||
partPath := dest + ".part"
|
||||
var lastErr error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
log.Printf("Retrying download (attempt %d/%d): %s", attempt, maxRetries, url)
|
||||
time.Sleep(time.Duration(attempt) * time.Second)
|
||||
time.Sleep(time.Duration(attempt) * rm.RetryBackoff)
|
||||
}
|
||||
|
||||
resp, err := rm.HTTPClient.Get(url)
|
||||
// Resume from however much we already have on disk.
|
||||
var offset int64
|
||||
if fi, err := os.Stat(partPath); err == nil {
|
||||
offset = fi.Size()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if offset > 0 {
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
|
||||
}
|
||||
|
||||
resp, err := rm.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
// Server ignored the Range (or we had nothing): start fresh.
|
||||
offset = 0
|
||||
case http.StatusPartialContent:
|
||||
// Resume: append to the existing partial file.
|
||||
case http.StatusRequestedRangeNotSatisfiable:
|
||||
// Stale or already-complete partial: discard and restart fresh.
|
||||
resp.Body.Close()
|
||||
os.Remove(partPath)
|
||||
lastErr = fmt.Errorf("partial download no longer valid (status %s), restarting", resp.Status)
|
||||
continue
|
||||
default:
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("bad status: %s", resp.Status)
|
||||
continue
|
||||
}
|
||||
|
||||
out, err := os.Create(filepath)
|
||||
var out *os.File
|
||||
if offset > 0 {
|
||||
out, err = os.OpenFile(partPath, os.O_WRONLY|os.O_APPEND, 0644)
|
||||
} else {
|
||||
out, err = os.Create(partPath)
|
||||
}
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a progress reader if callback is provided
|
||||
// On a 206 the Content-Length is the remaining bytes, so the full size
|
||||
// is what we already have plus what's still to come.
|
||||
total := resp.ContentLength
|
||||
if offset > 0 && total > 0 {
|
||||
total += offset
|
||||
}
|
||||
|
||||
var reader io.Reader = resp.Body
|
||||
if progressCallback != nil && resp.ContentLength > 0 {
|
||||
if progressCallback != nil && total > 0 {
|
||||
reader = &progressReader{
|
||||
Reader: resp.Body,
|
||||
Total: resp.ContentLength,
|
||||
Total: total,
|
||||
Current: offset,
|
||||
Callback: progressCallback,
|
||||
}
|
||||
}
|
||||
@@ -243,11 +364,14 @@ func (rm *ReleaseManager) downloadFileWithRetry(url, filepath string, progressCa
|
||||
out.Close()
|
||||
|
||||
if err != nil {
|
||||
// Keep the partial file so the next attempt can resume from it.
|
||||
lastErr = err
|
||||
os.Remove(filepath)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Rename(partPath, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -322,20 +446,21 @@ func (rm *ReleaseManager) saveVersionMetadata(version string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// progressReader wraps an io.Reader to provide download progress
|
||||
// progressReader wraps an io.Reader to provide download progress as a
|
||||
// (downloaded, total) byte count so callers can render both a progress bar and
|
||||
// a human-readable size.
|
||||
type progressReader struct {
|
||||
io.Reader
|
||||
Total int64
|
||||
Current int64
|
||||
Callback func(float64)
|
||||
Callback func(downloaded, total int64)
|
||||
}
|
||||
|
||||
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)
|
||||
pr.Callback(pr.Current, pr.Total)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package launcher_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -178,4 +186,221 @@ var _ = Describe("ReleaseManager", func() {
|
||||
Expect(err.Error()).To(ContainSubstring("checksum not found"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DownloadRelease resume and retry", func() {
|
||||
var (
|
||||
version string
|
||||
binaryName string
|
||||
content []byte
|
||||
checksums string
|
||||
finalPath string
|
||||
partPath string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
version = "v9.9.9"
|
||||
binaryName = rm.GetBinaryName(version)
|
||||
|
||||
// Deterministic, non-trivial content so resume/append bugs surface.
|
||||
content = make([]byte, 4096)
|
||||
for i := range content {
|
||||
content[i] = byte(i % 251)
|
||||
}
|
||||
sum := sha256.Sum256(content)
|
||||
checksums = fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), binaryName)
|
||||
|
||||
finalPath = filepath.Join(tempDir, "local-ai")
|
||||
partPath = finalPath + ".part"
|
||||
|
||||
// Isolate the persistent checksum/metadata dirs to the temp dir so
|
||||
// the test never touches the real ~/.localai and existing checksum
|
||||
// files don't short-circuit the download.
|
||||
rm.ChecksumsPath = filepath.Join(tempDir, "checksums")
|
||||
rm.MetadataPath = filepath.Join(tempDir, "metadata")
|
||||
rm.GitHubOwner = "owner"
|
||||
rm.GitHubRepo = "repo"
|
||||
rm.RetryBackoff = time.Millisecond
|
||||
|
||||
Expect(os.MkdirAll(tempDir, 0755)).To(Succeed())
|
||||
})
|
||||
|
||||
It("resumes from a partial .part file using a Range request", func() {
|
||||
Expect(os.WriteFile(partPath, content[:1024], 0644)).To(Succeed())
|
||||
|
||||
var mu sync.Mutex
|
||||
sawRange := false
|
||||
binBytesServed := 0
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "checksums.txt") {
|
||||
_, _ = w.Write([]byte(checksums))
|
||||
return
|
||||
}
|
||||
if rangeHdr := r.Header.Get("Range"); rangeHdr != "" {
|
||||
var start int
|
||||
_, _ = fmt.Sscanf(rangeHdr, "bytes=%d-", &start)
|
||||
mu.Lock()
|
||||
sawRange = true
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(content)-1, len(content)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
n, _ := w.Write(content[start:])
|
||||
mu.Lock()
|
||||
binBytesServed += n
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
n, _ := w.Write(content)
|
||||
mu.Lock()
|
||||
binBytesServed += n
|
||||
mu.Unlock()
|
||||
}))
|
||||
defer srv.Close()
|
||||
rm.BaseDownloadURL = srv.URL
|
||||
|
||||
err := rm.DownloadRelease(version, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
got, err := os.ReadFile(finalPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(content))
|
||||
Expect(sawRange).To(BeTrue(), "expected the download to resume with a Range request")
|
||||
Expect(binBytesServed).To(Equal(len(content)-1024), "expected only the remaining bytes to be served")
|
||||
Expect(partPath).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("starts fresh when the server ignores the Range header (200)", func() {
|
||||
// A stale/garbage partial that must NOT be appended to.
|
||||
Expect(os.WriteFile(partPath, []byte("garbage-garbage-garbage"), 0644)).To(Succeed())
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "checksums.txt") {
|
||||
_, _ = w.Write([]byte(checksums))
|
||||
return
|
||||
}
|
||||
// Ignore any Range and always serve the full body.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
defer srv.Close()
|
||||
rm.BaseDownloadURL = srv.URL
|
||||
|
||||
err := rm.DownloadRelease(version, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
got, err := os.ReadFile(finalPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(content))
|
||||
})
|
||||
|
||||
It("restarts the download when the partial is stale (416)", func() {
|
||||
// Oversized partial -> requested Range start is beyond the content.
|
||||
Expect(os.WriteFile(partPath, make([]byte, len(content)+10), 0644)).To(Succeed())
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "checksums.txt") {
|
||||
_, _ = w.Write([]byte(checksums))
|
||||
return
|
||||
}
|
||||
if rangeHdr := r.Header.Get("Range"); rangeHdr != "" {
|
||||
var start int
|
||||
_, _ = fmt.Sscanf(rangeHdr, "bytes=%d-", &start)
|
||||
if start >= len(content) {
|
||||
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, len(content)-1, len(content)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write(content[start:])
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
defer srv.Close()
|
||||
rm.BaseDownloadURL = srv.URL
|
||||
|
||||
err := rm.DownloadRelease(version, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
got, err := os.ReadFile(finalPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got).To(Equal(content))
|
||||
})
|
||||
|
||||
It("removes the downloaded file when checksum verification fails", func() {
|
||||
bad := []byte("this is definitely not the expected binary content")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "checksums.txt") {
|
||||
// Checksums are for `content`, but we serve `bad`.
|
||||
_, _ = w.Write([]byte(checksums))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(bad)
|
||||
}))
|
||||
defer srv.Close()
|
||||
rm.BaseDownloadURL = srv.URL
|
||||
|
||||
err := rm.DownloadRelease(version, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("checksum"))
|
||||
Expect(finalPath).ToNot(BeAnExistingFile())
|
||||
Expect(partPath).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("reports progress as downloaded and total byte counts", func() {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "checksums.txt") {
|
||||
_, _ = w.Write([]byte(checksums))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
defer srv.Close()
|
||||
rm.BaseDownloadURL = srv.URL
|
||||
|
||||
var mu sync.Mutex
|
||||
var lastDownloaded, lastTotal int64
|
||||
err := rm.DownloadRelease(version, func(downloaded, total int64) {
|
||||
mu.Lock()
|
||||
lastDownloaded = downloaded
|
||||
lastTotal = total
|
||||
mu.Unlock()
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lastTotal).To(Equal(int64(len(content))))
|
||||
Expect(lastDownloaded).To(Equal(int64(len(content))))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetLatestRelease", func() {
|
||||
It("resolves the latest version from the releases/latest redirect", func() {
|
||||
// The github.com redirect path must be preferred over the
|
||||
// rate-limited api.github.com, so a working redirect yields the tag
|
||||
// without ever needing the API.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
||||
http.Redirect(w, r, "/owner/repo/releases/tag/v9.9.9", http.StatusFound)
|
||||
case strings.HasSuffix(r.URL.Path, "/releases/tag/v9.9.9"):
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
rm.BaseDownloadURL = srv.URL
|
||||
rm.GitHubOwner = "owner"
|
||||
rm.GitHubRepo = "repo"
|
||||
|
||||
release, err := rm.GetLatestRelease()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(release.Version).To(Equal("v9.9.9"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -443,84 +443,23 @@ func (sm *SystrayManager) showStartupErrorDialog(err error) {
|
||||
})
|
||||
}
|
||||
|
||||
// showDownloadProgress shows a progress window for downloading updates
|
||||
// showDownloadProgress shows a progress window for downloading updates. The
|
||||
// progress UI (byte readout, resume-aware retry, sizing) is shared with the
|
||||
// other download entry points via the launcher; only the post-success behaviour
|
||||
// (restart prompt + systray refresh) is specific to the update flow.
|
||||
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()
|
||||
sm.launcher.showDownloadProgressWindow(version, fmt.Sprintf("Downloading LocalAI version %s", version), func(win fyne.Window) {
|
||||
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()
|
||||
}
|
||||
win.Close()
|
||||
}, win)
|
||||
|
||||
// Progress bar
|
||||
progressBar := widget.NewProgressBar()
|
||||
progressBar.SetValue(0)
|
||||
|
||||
// Status label. Truncate with an ellipsis so a long "Download failed:
|
||||
// <url>" message can't stretch the window (and progress bar) to fit the
|
||||
// whole error on one line; the full error is shown in the dialog below.
|
||||
statusLabel := widget.NewLabel("Preparing download...")
|
||||
statusLabel.Truncation = fyne.TextTruncateEllipsis
|
||||
|
||||
// Release notes button
|
||||
releaseNotesButton := widget.NewButton("View Release Notes", func() {
|
||||
releaseNotesURL, err := sm.launcher.githubReleaseNotesURL(version)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse URL: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
sm.app.OpenURL(releaseNotesURL)
|
||||
sm.hasUpdateAvailable = false
|
||||
sm.latestVersion = ""
|
||||
sm.recreateMenu()
|
||||
})
|
||||
|
||||
// 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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -490,14 +490,19 @@ func (ui *LauncherUI) downloadUpdate() {
|
||||
ui.UpdateStatus("Downloading update " + version + "...")
|
||||
|
||||
go func() {
|
||||
err := ui.launcher.DownloadUpdate(version, func(progress float64) {
|
||||
// Update progress bar
|
||||
err := ui.launcher.DownloadUpdate(version, func(downloaded, total int64) {
|
||||
fyne.Do(func() {
|
||||
ui.progressBar.SetValue(progress)
|
||||
if total > 0 {
|
||||
ui.progressBar.SetValue(float64(downloaded) / float64(total))
|
||||
}
|
||||
})
|
||||
// Update status with percentage
|
||||
percentage := int(progress * 100)
|
||||
ui.UpdateStatus(fmt.Sprintf("Downloading update %s... %d%%", version, percentage))
|
||||
// The progress bar already shows the percentage, so report the
|
||||
// human-readable size here instead of repeating the percent.
|
||||
if total > 0 {
|
||||
ui.UpdateStatus(fmt.Sprintf("Downloading update %s… %s / %s", version, formatBytes(downloaded), formatBytes(total)))
|
||||
} else {
|
||||
ui.UpdateStatus(fmt.Sprintf("Downloading update %s… %s", version, formatBytes(downloaded)))
|
||||
}
|
||||
})
|
||||
|
||||
fyne.Do(func() {
|
||||
@@ -598,82 +603,6 @@ func (ui *LauncherUI) LoadConfiguration() {
|
||||
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. Truncate with an ellipsis so a long "Download failed:
|
||||
// <url>" message can't stretch the window (and progress bar) to fit the
|
||||
// whole error on one line; the full error is shown in the dialog below.
|
||||
statusLabel := widget.NewLabel("Preparing download...")
|
||||
statusLabel.Truncation = fyne.TextTruncateEllipsis
|
||||
|
||||
// Release notes button
|
||||
releaseNotesButton := widget.NewButton("View Release Notes", func() {
|
||||
releaseNotesURL, err := 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() {
|
||||
|
||||
@@ -20,6 +20,8 @@ func WorkerPermissions(nodeID, nodeType string) (pubAllow, subAllow []string) {
|
||||
subAllow = []string{
|
||||
"agent.execute",
|
||||
"agent.*.cancel",
|
||||
"gallery.*.cancel",
|
||||
"gallery.*.progress",
|
||||
"jobs.*.cancel",
|
||||
"jobs.*.progress",
|
||||
"jobs.*.result",
|
||||
@@ -27,6 +29,7 @@ func WorkerPermissions(nodeID, nodeType string) (pubAllow, subAllow []string) {
|
||||
"mcp.tools.execute",
|
||||
"mcp.discovery",
|
||||
prefix + ".backend.stop", // stop events drive MCP session cleanup
|
||||
"staging.*.progress",
|
||||
"_INBOX.>",
|
||||
}
|
||||
pubAllow = []string{
|
||||
|
||||
Reference in New Issue
Block a user