diff --git a/App.axaml.cs b/App.axaml.cs index a436ff3..8b4c7c2 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -52,6 +52,11 @@ public override void OnFrameworkInitializationCompleted() } public void OnOpenDashboardClick(object? sender, EventArgs e) + { + ShowMainWindow(); + } + + public void ShowMainWindow() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { @@ -59,6 +64,11 @@ public void OnOpenDashboardClick(object? sender, EventArgs e) { desktop.MainWindow = new MainWindow(); } + if (desktop.MainWindow.DataContext is LocalLLMServerManager.Shared.ViewModels.MainViewModel vm) + { + vm.IsWindowVisible = true; + _ = vm.RefreshStatusCommand.ExecuteAsync(null); + } try { desktop.MainWindow.Show(); diff --git a/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs index f18f78d..1d45b8b 100644 --- a/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/CivitaiSearchViewModel.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Net.Http; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; @@ -88,8 +90,17 @@ private void InitStarterModels() public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) { - if (totalVramMb > 0) TotalVramMb = totalVramMb; - if (totalRamMb > 0) TotalRamMb = totalRamMb; + double nextVram = totalVramMb > 0 ? totalVramMb : TotalVramMb; + double nextRam = totalRamMb > 0 ? totalRamMb : TotalRamMb; + if (nextVram == TotalVramMb && nextRam == TotalRamMb && + CivitaiResults.All(r => r.FitBadge != null) && + StarterModels.All(s => s.FitBadge != null)) + { + return; + } + + TotalVramMb = nextVram; + TotalRamMb = nextRam; RecomputeBadges(); } @@ -238,7 +249,8 @@ public async Task LoadDefaultModelsAsync(string apiBase, HttpClient http) [RelayCommand] public async Task SearchCivitaiAsync() { - await SearchCivitaiAsync(ApiBase, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + await SearchCivitaiAsync(ApiBase, client); } public async Task SearchCivitaiAsync(string apiBase, HttpClient http) @@ -269,7 +281,8 @@ public async Task SearchCivitaiAsync(string apiBase, HttpClient http) [RelayCommand] public async Task DownloadCivitaiModelAsync(CivitaiModelItem item) { - await DownloadCivitaiModelAsync(item, ApiBase, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + await DownloadCivitaiModelAsync(item, ApiBase, client); } public async Task DownloadCivitaiModelAsync(CivitaiModelItem item, string apiBase, HttpClient http) diff --git a/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs index 2c533dd..1a80c69 100644 --- a/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/HuggingFaceSearchViewModel.cs @@ -96,8 +96,15 @@ public HuggingFaceSearchViewModel( public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) { - if (totalVramMb > 0) TotalVramMb = totalVramMb; - if (totalRamMb > 0) TotalRamMb = totalRamMb; + double nextVram = totalVramMb > 0 ? totalVramMb : TotalVramMb; + double nextRam = totalRamMb > 0 ? totalRamMb : TotalRamMb; + if (nextVram == TotalVramMb && nextRam == TotalRamMb && HuggingFaceResults.All(r => r.FitBadge != null)) + { + return; + } + + TotalVramMb = nextVram; + TotalRamMb = nextRam; RecomputeBadges(); } @@ -497,7 +504,8 @@ public void InspectQuantFile(HfFileQuantItem? file) [RelayCommand] public async Task SearchHuggingFaceAsync() { - await SearchHuggingFaceAsync(ApiBase, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + await SearchHuggingFaceAsync(ApiBase, client); } [RelayCommand] @@ -602,14 +610,16 @@ public void OpenInBrowser(string? repoId) public async Task OpenHfModalAsync(HuggingFaceRepoItem? item) { if (item == null || string.IsNullOrWhiteSpace(item.Id)) return; - await OpenHfModalAsync(item.Id, ApiBase, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + await OpenHfModalAsync(item.Id, ApiBase, client); } [RelayCommand] public async Task DownloadHfFileAsync(HfFileQuantItem? file) { if (file == null || string.IsNullOrWhiteSpace(file.Filename) || string.IsNullOrWhiteSpace(ModalRepoId)) return; - await DownloadHfFileAsync(file, ApiBase, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + await DownloadHfFileAsync(file, ApiBase, client); } public async Task DownloadHfFileAsync(HfFileQuantItem file, string apiBase, HttpClient http) diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index 297e226..b06089f 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -1078,6 +1078,18 @@ public async Task LaunchTestFlightAsync(object? modalityParam = null) } } + [ObservableProperty] private bool _isWindowVisible = true; + + public void ReleaseTransientBuffers() + { + StudioGeneratedImageBytes = null; + AttachedImageBytes = null; + StickerStudio.GeneratedPngBytes = null; + StickerStudio.InputImageBytes = null; + } + + private int _pollTickCount = 0; + private async Task StartBackgroundPollingAsync() { while (EnableAutomaticPolling) @@ -1086,17 +1098,27 @@ private async Task StartBackgroundPollingAsync() { await Task.Delay(5000); if (!EnableAutomaticPolling) break; - await RefreshStatusAsync(); + if (!IsWindowVisible) continue; + _pollTickCount++; + await RefreshStatusCoreAsync(isBackgroundPoll: true); } catch { } } } [RelayCommand] - public async Task RefreshStatusAsync() + public Task RefreshStatusAsync() => RefreshStatusCoreAsync(isBackgroundPoll: false); + + private async Task RefreshStatusCoreAsync(bool isBackgroundPoll) { - await Telemetry.RefreshStatusAsync(ApiBase, ComfyUiUrl, Http); - await Ollama.LoadInstalledModelsAsync(ApiBase, Http); + var apiBase = ApiBase; + var comfyUiUrl = ComfyUiUrl; + var http = Http; + await Telemetry.RefreshStatusAsync(apiBase, comfyUiUrl, http); + if (!isBackgroundPoll || Ollama.InstalledModels.Count == 0 || _pollTickCount % 12 == 0) + { + await Ollama.LoadInstalledModelsAsync(apiBase, http); + } if (Telemetry != null) { IsForgeOnline = Telemetry.IsForgeOnline; diff --git a/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs index 987d3f5..8c0964a 100644 --- a/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Text.Json.Nodes; @@ -63,6 +64,8 @@ public partial class OllamaLibraryViewModel : ObservableObject public Action? OnInspectModelRequested { get; set; } public Action? OnApplyModelContextRequested { get; set; } + private bool _suppressFilter; + public OllamaLibraryViewModel(IOllamaModelService ollamaModelService) : this(ollamaModelService, new CanIRunItService(), null) { @@ -76,14 +79,24 @@ public OllamaLibraryViewModel( _ollamaModelService = ollamaModelService; _canIRunItService = canIRunItService ?? new CanIRunItService(); _telemetryService = telemetryService; - InstalledModels.CollectionChanged += (s, e) => ApplyFilter(); + InstalledModels.CollectionChanged += (s, e) => + { + if (!_suppressFilter) ApplyFilter(); + }; RecalculateKvCache(); } public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) { - if (totalVramMb > 0) TotalVramMb = totalVramMb; - if (totalRamMb > 0) TotalRamMb = totalRamMb; + double nextVram = totalVramMb > 0 ? totalVramMb : TotalVramMb; + double nextRam = totalRamMb > 0 ? totalRamMb : TotalRamMb; + if (nextVram == TotalVramMb && nextRam == TotalRamMb && InstalledModels.All(m => m.FitBadge != null)) + { + return; + } + + TotalVramMb = nextVram; + TotalRamMb = nextRam; RecomputeBadges(); RecalculateKvCache(); @@ -91,11 +104,19 @@ public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) public void RecomputeBadges() { - for (int i = 0; i < InstalledModels.Count; i++) + _suppressFilter = true; + try + { + for (int i = 0; i < InstalledModels.Count; i++) + { + var m = InstalledModels[i]; + var badge = _canIRunItService.EvaluateQuickFit(m.Name, m.SizeBytes > 0 ? m.SizeBytes : null, "LLM", (long)TotalVramMb, (long)TotalRamMb); + InstalledModels[i] = m with { FitBadge = badge }; + } + } + finally { - var m = InstalledModels[i]; - var badge = _canIRunItService.EvaluateQuickFit(m.Name, m.SizeBytes > 0 ? m.SizeBytes : null, "LLM", (long)TotalVramMb, (long)TotalRamMb); - InstalledModels[i] = m with { FitBadge = badge }; + _suppressFilter = false; } ApplyFilter(); } @@ -246,10 +267,18 @@ public void SelectModel(OllamaModelItem? model) { if (model == null) return; SelectedInstalledModel = model; - for (int i = 0; i < InstalledModels.Count; i++) + _suppressFilter = true; + try + { + for (int i = 0; i < InstalledModels.Count; i++) + { + var m = InstalledModels[i]; + InstalledModels[i] = m with { IsSelected = (m.Name == model.Name) }; + } + } + finally { - var m = InstalledModels[i]; - InstalledModels[i] = m with { IsSelected = (m.Name == model.Name) }; + _suppressFilter = false; } ApplyFilter(); RecalculateKvCache(); @@ -311,12 +340,44 @@ public async Task LoadInstalledModelsAsync(string apiBase, HttpClient http) try { var models = await _ollamaModelService.LoadInstalledModelsAsync(apiBase, http); - InstalledModels.Clear(); - foreach (var m in models) + bool isSame = models.Count == InstalledModels.Count; + if (isSame) { - var badge = _canIRunItService.EvaluateQuickFit(m.Name, m.SizeBytes > 0 ? m.SizeBytes : null, "LLM", (long)TotalVramMb, (long)TotalRamMb); - InstalledModels.Add(m with { FitBadge = badge }); + for (int i = 0; i < models.Count; i++) + { + var existing = InstalledModels[i]; + var incoming = models[i]; + if (existing.Name != incoming.Name || + existing.SizeBytes != incoming.SizeBytes || + existing.CapabilityTag != incoming.CapabilityTag || + existing.FormatSize != incoming.FormatSize) + { + isSame = false; + break; + } + } + } + + if (isSame) + { + return; } + + _suppressFilter = true; + try + { + InstalledModels.Clear(); + foreach (var m in models) + { + var badge = _canIRunItService.EvaluateQuickFit(m.Name, m.SizeBytes > 0 ? m.SizeBytes : null, "LLM", (long)TotalVramMb, (long)TotalRamMb); + InstalledModels.Add(m with { FitBadge = badge }); + } + } + finally + { + _suppressFilter = false; + } + if (SelectedInstalledModel == null && InstalledModels.Count > 0) { SelectModel(InstalledModels[0]); @@ -381,7 +442,8 @@ public async Task DeleteModelAsync(OllamaModelItem? item) ToastService.Instance.Show($"Deleting model '{item.Name}'...", ToastType.Info); try { - var success = await _ollamaModelService.DeleteModelAsync(ApiBase, item.Name, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + var success = await _ollamaModelService.DeleteModelAsync(ApiBase, item.Name, client); if (success) { InstalledModels.Remove(item); @@ -402,7 +464,8 @@ public async Task DeleteModelAsync(OllamaModelItem? item) [RelayCommand] public async Task UnloadAllVramAsync() { - await UnloadAllVramAsync(ApiBase, HttpHelper.CreateClient(ApiBase)); + using var client = HttpHelper.CreateClient(ApiBase); + await UnloadAllVramAsync(ApiBase, client); } public async Task UnloadAllVramAsync(string apiBase, HttpClient http) diff --git a/LocalLLMServerManager.Tests/MainViewModelTests.cs b/LocalLLMServerManager.Tests/MainViewModelTests.cs index 2d6bc6b..bd076d5 100644 --- a/LocalLLMServerManager.Tests/MainViewModelTests.cs +++ b/LocalLLMServerManager.Tests/MainViewModelTests.cs @@ -669,5 +669,22 @@ public void MainViewModel_OrchestratesNavigationRailAndStickerStudio() Assert.True(vm.IsCopilotSidebarOpen); Assert.Equal("Assistant", vm.SelectedCopilotTab); } + + [Fact] + public void ReleaseTransientBuffers_ClearsImageByteArrays() + { + var vm = new MainViewModel(); + vm.StudioGeneratedImageBytes = new byte[] { 1, 2, 3 }; + vm.AttachedImageBytes = new byte[] { 4, 5, 6 }; + vm.StickerStudio.GeneratedPngBytes = new byte[] { 7, 8, 9 }; + vm.StickerStudio.InputImageBytes = new byte[] { 10, 11, 12 }; + + vm.ReleaseTransientBuffers(); + + Assert.Null(vm.StudioGeneratedImageBytes); + Assert.Null(vm.AttachedImageBytes); + Assert.Null(vm.StickerStudio.GeneratedPngBytes); + Assert.Null(vm.StickerStudio.InputImageBytes); + } } diff --git a/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs b/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs index fefcfe4..20354a0 100644 --- a/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs +++ b/LocalLLMServerManager.Tests/OllamaLibraryViewModelTests.cs @@ -149,4 +149,51 @@ public async Task PullModelAsync_StreamsProgressAndClosesDrawer() vm.ClosePullDrawer(); Assert.False(vm.IsPullDrawerOpen); } + + [Fact] + public void UpdateHardwareTelemetry_WhenVramAndRamUnchanged_DoesNotFireCollectionChanged() + { + var mockOllama = new Mock(); + var vm = new OllamaLibraryViewModel(mockOllama.Object); + + vm.InstalledModels.Add(new OllamaModelItem("llama3.1:8b", "4.7 GB", "General", "#38BDF8", false)); + vm.UpdateHardwareTelemetry(8192.0, 16384.0); + + int installedChanges = 0; + int filteredChanges = 0; + vm.InstalledModels.CollectionChanged += (s, e) => installedChanges++; + vm.FilteredInstalledModels.CollectionChanged += (s, e) => filteredChanges++; + + vm.UpdateHardwareTelemetry(8192.0, 16384.0); + + Assert.Equal(0, installedChanges); + Assert.Equal(0, filteredChanges); + } + + [Fact] + public async Task LoadInstalledModelsAsync_WhenFetchedModelsIdentical_DoesNotClearOrReAddItems() + { + var mockOllama = new Mock(); + mockOllama.Setup(m => m.LoadInstalledModelsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new OllamaModelItem("llama3.1:8b", "4.7 GB", "General", "#38BDF8", false, null, 4700000000L), + new OllamaModelItem("qwen2.5:7b", "4.4 GB", "Coding", "#38BDF8", false, null, 4400000000L) + }); + + var vm = new OllamaLibraryViewModel(mockOllama.Object); + using var client = new HttpClient(); + await vm.LoadInstalledModelsAsync("http://localhost:5246", client); + + int installedChanges = 0; + int filteredChanges = 0; + vm.InstalledModels.CollectionChanged += (s, e) => installedChanges++; + vm.FilteredInstalledModels.CollectionChanged += (s, e) => filteredChanges++; + + await vm.LoadInstalledModelsAsync("http://localhost:5246", client); + + Assert.Equal(0, installedChanges); + Assert.Equal(0, filteredChanges); + Assert.Equal(2, vm.InstalledModels.Count); + } } diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs index 8a4cb5c..d88b68f 100644 --- a/Views/MainWindow.axaml.cs +++ b/Views/MainWindow.axaml.cs @@ -144,5 +144,10 @@ protected override void OnClosing(WindowClosingEventArgs e) // Hide window to system tray when user clicks X close button e.Cancel = true; Hide(); + if (DataContext is MainViewModel vm) + { + vm.IsWindowVisible = false; + vm.ReleaseTransientBuffers(); + } } }