From 929624761aec031754b1dc38dbc39fe5082e9cae Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Thu, 1 Oct 2026 00:59:09 -0500 Subject: [PATCH 1/8] feat: multi-modality model scanner, interactive studio model selector, and sidebar styling --- .../Interfaces/ILocalModelScannerService.cs | 11 + .../Models/LocalModelModels.cs | 26 ++ .../Services/LocalModelScannerService.cs | 241 +++++++++++++ .../ViewModels/EngineStudioViewModel.cs | 256 +++++++++++++- .../ViewModels/MainViewModel.cs | 1 + .../Views/Controls/ActivityRailControl.axaml | 38 +- .../Controls/EngineStudioTabControl.axaml | 108 +++++- .../Controls/LocalModelsManagerControl.axaml | 333 ++++++++++++++++++ .../LocalModelsManagerControl.axaml.cs | 11 + .../Views/Controls/ModelsTabControl.axaml | 4 +- .../EngineStudioViewModelTests.cs | 59 ++++ .../LocalModelScannerServiceTests.cs | 106 ++++++ ...cal-model-scanning-and-studio-selection.md | 111 ++++++ ...el-scanning-and-studio-selection-design.md | 56 +++ 14 files changed, 1332 insertions(+), 29 deletions(-) create mode 100644 LocalLLMServerManager.Shared/Interfaces/ILocalModelScannerService.cs create mode 100644 LocalLLMServerManager.Shared/Models/LocalModelModels.cs create mode 100644 LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs create mode 100644 LocalLLMServerManager.Shared/Views/Controls/LocalModelsManagerControl.axaml create mode 100644 LocalLLMServerManager.Shared/Views/Controls/LocalModelsManagerControl.axaml.cs create mode 100644 LocalLLMServerManager.Tests/LocalModelScannerServiceTests.cs create mode 100644 docs/superpowers/plans/2026-10-01-local-model-scanning-and-studio-selection.md create mode 100644 docs/superpowers/specs/2026-10-01-local-model-scanning-and-studio-selection-design.md diff --git a/LocalLLMServerManager.Shared/Interfaces/ILocalModelScannerService.cs b/LocalLLMServerManager.Shared/Interfaces/ILocalModelScannerService.cs new file mode 100644 index 0000000..44b135f --- /dev/null +++ b/LocalLLMServerManager.Shared/Interfaces/ILocalModelScannerService.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Models; + +namespace LocalLLMServerManager.Shared.Interfaces; + +public interface ILocalModelScannerService +{ + Task> ScanAllModelsAsync(AppSettings? settings = null, string? baseDirectory = null); + IReadOnlyList GetCachedModels(); +} diff --git a/LocalLLMServerManager.Shared/Models/LocalModelModels.cs b/LocalLLMServerManager.Shared/Models/LocalModelModels.cs new file mode 100644 index 0000000..81b03ee --- /dev/null +++ b/LocalLLMServerManager.Shared/Models/LocalModelModels.cs @@ -0,0 +1,26 @@ +using System; + +namespace LocalLLMServerManager.Shared.Models; + +public enum LocalModelCategory +{ + Ollama, + ImageCheckpoint, + ImageLora, + Video, + Audio, + ThreeD +} + +public record LocalModelItem( + string Id, + string Name, + string FileName, + string FullPath, + LocalModelCategory Category, + string Architecture, + long SizeBytes, + string FormattedSize, + string SourceLocation, + DateTime CreatedAt +); diff --git a/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs b/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs new file mode 100644 index 0000000..581b2a6 --- /dev/null +++ b/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using LocalLLMServerManager.Shared.Interfaces; +using LocalLLMServerManager.Shared.Models; + +namespace LocalLLMServerManager.Shared.Services; + +public class LocalModelScannerService : ILocalModelScannerService +{ + private readonly object _lock = new(); + private List _cachedModels = new(); + + public IReadOnlyList GetCachedModels() + { + lock (_lock) + { + return _cachedModels.ToList(); + } + } + + public Task> ScanAllModelsAsync(AppSettings? settings = null, string? baseDirectory = null) + { + return Task.Run>(() => + { + var baseDir = !string.IsNullOrWhiteSpace(baseDirectory) ? baseDirectory : AppContext.BaseDirectory; + var results = new List(); + var scannedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // 1. Image Checkpoints + var checkpointDirs = new List<(string Path, string Label)> + { + (Path.Combine(baseDir, "models", "checkpoints"), "Local Checkpoints"), + (Path.Combine(baseDir, "ComfyUI", "models", "checkpoints"), "ComfyUI Checkpoints") + }; + if (!string.IsNullOrWhiteSpace(settings?.ForgeModelsPath)) + { + checkpointDirs.Add((settings.ForgeModelsPath, "Forge Checkpoints")); + } + if (!string.IsNullOrWhiteSpace(settings?.ComfyModelsPath)) + { + checkpointDirs.Add((settings.ComfyModelsPath, "ComfyUI Checkpoints")); + } + + foreach (var (dir, label) in checkpointDirs) + { + ScanDirectoryFiles(dir, label, LocalModelCategory.ImageCheckpoint, new[] { ".safetensors", ".ckpt" }, results, scannedPaths); + } + + // 2. Image LoRAs + var loraDirs = new List<(string Path, string Label)> + { + (Path.Combine(baseDir, "models", "Lora"), "Local LoRAs"), + (Path.Combine(baseDir, "ComfyUI", "models", "loras"), "ComfyUI LoRAs") + }; + foreach (var (dir, label) in loraDirs) + { + ScanDirectoryFiles(dir, label, LocalModelCategory.ImageLora, new[] { ".safetensors", ".ckpt" }, results, scannedPaths); + } + + // 3. Video Models & Workflows + var videoDirs = new List<(string Path, string Label)> + { + (Path.Combine(baseDir, "ComfyUI", "models", "diffusion_models"), "ComfyUI Diffusion Models"), + (Path.Combine(baseDir, "Workflows", "Video"), "Video Workflows") + }; + if (!string.IsNullOrWhiteSpace(settings?.VideoModelsPath)) + { + videoDirs.Add((settings.VideoModelsPath, "Configured Video Models")); + } + foreach (var (dir, label) in videoDirs) + { + ScanDirectoryFiles(dir, label, LocalModelCategory.Video, new[] { ".safetensors", ".json" }, results, scannedPaths); + } + + // 4. Audio Models & Workflows + var audioDirs = new List<(string Path, string Label)> + { + (Path.Combine(baseDir, "models", "tts"), "Local TTS Models"), + (Path.Combine(baseDir, "audio", "stt"), "STT Whisper Models"), + (Path.Combine(baseDir, "Workflows", "Audio"), "Audio Workflows") + }; + if (!string.IsNullOrWhiteSpace(settings?.AudioPath)) + { + audioDirs.Add((settings.AudioPath, "Configured Audio Models")); + } + foreach (var (dir, label) in audioDirs) + { + ScanDirectoryFiles(dir, label, LocalModelCategory.Audio, new[] { ".safetensors", ".onnx", ".bin", ".json" }, results, scannedPaths); + } + + // 5. 3D Mesh Pipelines & Models + var threeDDirs = new List<(string Path, string Label)> + { + (Path.Combine(baseDir, "models", "3d"), "3D Models"), + (Path.Combine(baseDir, "Workflows"), "3D Workflows") + }; + if (!string.IsNullOrWhiteSpace(settings?.ThreeDModelsPath)) + { + threeDDirs.Add((settings.ThreeDModelsPath, "Configured 3D Models")); + } + foreach (var (dir, label) in threeDDirs) + { + // In root Workflows, only match 3D files like trellis, hunyuan3d + ScanDirectoryFiles(dir, label, LocalModelCategory.ThreeD, new[] { ".safetensors", ".json" }, results, scannedPaths, fileFilter: f => + { + var lower = Path.GetFileName(f).ToLowerInvariant(); + return lower.Contains("3d") || lower.Contains("trellis") || lower.Contains("hunyuan3d"); + }); + } + + lock (_lock) + { + _cachedModels = results.OrderBy(m => m.Name).ToList(); + } + + return _cachedModels; + }); + } + + private static void ScanDirectoryFiles( + string directoryPath, + string sourceLabel, + LocalModelCategory category, + string[] extensions, + List results, + HashSet scannedPaths, + Func? fileFilter = null) + { + try + { + if (string.IsNullOrWhiteSpace(directoryPath) || !Directory.Exists(directoryPath)) + { + return; + } + + var dirInfo = new DirectoryInfo(directoryPath); + var files = dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly); + + foreach (var file in files) + { + var ext = file.Extension.ToLowerInvariant(); + if (!extensions.Contains(ext)) continue; + + if (fileFilter != null && !fileFilter(file.FullName)) continue; + + if (!scannedPaths.Add(file.FullName)) continue; + + var nameWithoutExt = Path.GetFileNameWithoutExtension(file.Name); + var architecture = DetectArchitecture(file.Name, category); + var formattedSize = FormatBytes(file.Length); + + results.Add(new LocalModelItem( + Id: file.FullName, + Name: CleanDisplayName(nameWithoutExt), + FileName: file.Name, + FullPath: file.FullName, + Category: category, + Architecture: architecture, + SizeBytes: file.Length, + FormattedSize: formattedSize, + SourceLocation: sourceLabel, + CreatedAt: file.CreationTime + )); + } + } + catch + { + // Safeguard against OS permission issues or inaccessible directories + } + } + + public static string DetectArchitecture(string fileName, LocalModelCategory category) + { + var lower = fileName.ToLowerInvariant(); + + if (category == LocalModelCategory.ImageCheckpoint || category == LocalModelCategory.ImageLora) + { + if (lower.Contains("flux")) return "Flux.1"; + if (lower.Contains("pony")) return "Pony XL"; + if (lower.Contains("illustrious")) return "Illustrious XL"; + if (lower.Contains("sdxl") || lower.Contains("xl_") || lower.Contains("-xl")) return "SDXL"; + if (lower.Contains("sd3") || lower.Contains("sd_3")) return "SD 3.5"; + if (lower.Contains("sd15") || lower.Contains("v1-5") || lower.Contains("1.5") || lower.Contains("realisticvision")) return "SD 1.5"; + return category == LocalModelCategory.ImageLora ? "LoRA" : "SDXL / General"; + } + + if (category == LocalModelCategory.Video) + { + if (lower.Contains("wan")) return "Wan 2.2"; + if (lower.Contains("ltx")) return "LTX-Video 2.5"; + if (lower.Contains("hunyuanvideo")) return "HunyuanVideo"; + if (lower.Contains("animatediff")) return "AnimateDiff"; + if (lower.Contains("svd")) return "Stable Video Diffusion"; + return "Video Pipeline"; + } + + if (category == LocalModelCategory.Audio) + { + if (lower.Contains("kokoro")) return "Kokoro TTS"; + if (lower.Contains("whisper")) return "Whisper STT"; + if (lower.Contains("musicgen")) return "MusicGen"; + if (lower.Contains("stable_audio")) return "Stable Audio Open"; + if (lower.Contains("yue")) return "YuE Full Song"; + return "Audio Engine"; + } + + if (category == LocalModelCategory.ThreeD) + { + if (lower.Contains("trellis")) return "TRELLIS V2"; + if (lower.Contains("hunyuan3d")) return "Hunyuan3D V2"; + return "3D Synthesis"; + } + + return "Local Model"; + } + + public static string CleanDisplayName(string rawName) + { + // Replace underscores/hyphens and clean common suffixes + var clean = rawName + .Replace("_api", "", StringComparison.OrdinalIgnoreCase) + .Replace(".api", "", StringComparison.OrdinalIgnoreCase) + .Replace('_', ' '); + + return clean.Trim(); + } + + public static string FormatBytes(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + var kb = bytes / 1024.0; + if (kb < 1024) return $"{kb:F1} KB"; + var mb = kb / 1024.0; + if (mb < 1024) return $"{mb:F1} MB"; + var gb = mb / 1024.0; + return $"{gb:F2} GB"; + } +} diff --git a/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs index 1aeba3b..87222ba 100644 --- a/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs @@ -1,15 +1,36 @@ using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using LocalLLMServerManager.Shared.Interfaces; +using LocalLLMServerManager.Shared.Models; +using LocalLLMServerManager.Shared.Services; namespace LocalLLMServerManager.Shared.ViewModels; /// /// ViewModel managing the fluid creative studio canvas, top modality switching, -/// creative prompt dock, and fine-tuning parameters flyout. +/// creative prompt dock, fine-tuning parameters flyout, and dynamic model discovery. /// public partial class EngineStudioViewModel : ObservableObject { + private readonly ILocalModelScannerService _scanner; + + public ObservableCollection ScannedModels { get; } = new(); + public ObservableCollection AvailableCurrentModels { get; } = new(); + + public EngineStudioViewModel() : this(new LocalModelScannerService()) + { + } + + public EngineStudioViewModel(ILocalModelScannerService scanner) + { + _scanner = scanner; + UpdateAvailableModelsForModality(); + } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsImageModalityActive))] [NotifyPropertyChangedFor(nameof(IsTextModalityActive))] @@ -107,11 +128,103 @@ public partial class EngineStudioViewModel : ObservableObject _ => "Describe what you want to create..." }; + public void UpdateAvailableModelsForModality() + { + AvailableCurrentModels.Clear(); + switch (SelectedModality) + { + case "Image" or "Images": + var scannedImages = ScannedModels.Where(m => m.Category == LocalModelCategory.ImageCheckpoint || m.Category == LocalModelCategory.ImageLora).Select(m => m.Name).ToList(); + foreach (var m in scannedImages) AvailableCurrentModels.Add(m); + var imageDefaults = new[] { "SDXL Base 1.0", "Flux.1 [dev]", "Flux.1 [schnell]", "SD 1.5", "Pony Diffusion V6", "Illustrious XL v0.1", "SDXL Turbo" }; + foreach (var d in imageDefaults) + { + if (!AvailableCurrentModels.Contains(d)) AvailableCurrentModels.Add(d); + } + break; + case "Text": + var textDefaults = new[] { "llama3.2:latest", "mistral:latest", "deepseek-r1:latest", "qwen2.5:latest", "phi4:latest" }; + foreach (var d in textDefaults) AvailableCurrentModels.Add(d); + break; + case "Video": + var scannedVideos = ScannedModels.Where(m => m.Category == LocalModelCategory.Video).Select(m => m.Name).ToList(); + foreach (var m in scannedVideos) AvailableCurrentModels.Add(m); + var videoDefaults = new[] { "Wan 2.2 / LTX-2.5", "Wan 2.2 T2V (720p)", "Wan 2.2 I2V (480p)", "LTX-Video 2.5", "HunyuanVideo 1.5", "AnimateDiff SDXL" }; + foreach (var d in videoDefaults) + { + if (!AvailableCurrentModels.Contains(d)) AvailableCurrentModels.Add(d); + } + break; + case "3D Mesh": + var scanned3D = ScannedModels.Where(m => m.Category == LocalModelCategory.ThreeD).Select(m => m.Name).ToList(); + foreach (var m in scanned3D) AvailableCurrentModels.Add(m); + var threeDDefaults = new[] { "TRELLIS V2 (Gaussian Splat)", "Hunyuan3D V2", "InstantMesh" }; + foreach (var d in threeDDefaults) + { + if (!AvailableCurrentModels.Contains(d)) AvailableCurrentModels.Add(d); + } + break; + case "Audio": + var scannedAudio = ScannedModels.Where(m => m.Category == LocalModelCategory.Audio).Select(m => m.Name).ToList(); + foreach (var m in scannedAudio) AvailableCurrentModels.Add(m); + var audioDefaults = new[] { "Kokoro TTS (af_heart)", "Kokoro TTS (am_adam)", "Kokoro TTS (bf_emma)", "Stable Audio Open", "MusicGen Melody" }; + foreach (var d in audioDefaults) + { + if (!AvailableCurrentModels.Contains(d)) AvailableCurrentModels.Add(d); + } + break; + case "Sticker" or "Sticker Studio": + AvailableCurrentModels.Add("Sticker Studio (BirefNet + SDXL)"); + break; + } + } + + [RelayCommand] + public void SelectModel(string modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) return; + + switch (SelectedModality) + { + case "Image" or "Images": + ImageModel = modelName; + break; + case "Text": + TextModel = modelName; + break; + case "Video": + VideoModel = modelName; + break; + case "3D Mesh": + MeshModel = modelName; + break; + case "Audio": + AudioModel = modelName; + break; + case "Sticker" or "Sticker Studio": + StickerModel = modelName; + break; + } + OnPropertyChanged(nameof(ActiveModelBadge)); + } + + public async Task LoadScannedModelsAsync(AppSettings? settings = null, string? baseDirectory = null) + { + var models = await _scanner.ScanAllModelsAsync(settings, baseDirectory); + ScannedModels.Clear(); + foreach (var m in models) + { + ScannedModels.Add(m); + } + UpdateAvailableModelsForModality(); + } + [RelayCommand] public void SelectModality(string modality) { if (string.IsNullOrWhiteSpace(modality)) return; SelectedModality = modality; + UpdateAvailableModelsForModality(); OnPropertyChanged(nameof(IsImageModalityActive)); OnPropertyChanged(nameof(IsTextModalityActive)); OnPropertyChanged(nameof(IsVideoModalityActive)); @@ -174,6 +287,147 @@ public partial class MainViewModel [ObservableProperty] private EngineStudioViewModel _studio = new(); + public ObservableCollection ScannedModels => Studio.ScannedModels; + public ObservableCollection AvailableCurrentModels => Studio.AvailableCurrentModels; + + public ObservableCollection ScannedImageModels { get; } = new(); + public ObservableCollection ScannedVideoModels { get; } = new(); + public ObservableCollection ScannedAudioModels { get; } = new(); + public ObservableCollection ScannedThreeDModels { get; } = new(); + + [ObservableProperty] + private LocalModelCategory _selectedManageModelCategory = LocalModelCategory.Ollama; + + [RelayCommand] + public void SelectManageCategory(string categoryStr) + { + if (Enum.TryParse(categoryStr, out var cat)) + { + SelectedManageModelCategory = cat; + OnPropertyChanged(nameof(IsManageOllamaSelected)); + OnPropertyChanged(nameof(IsManageImageSelected)); + OnPropertyChanged(nameof(IsManageVideoSelected)); + OnPropertyChanged(nameof(IsManageAudioSelected)); + OnPropertyChanged(nameof(IsManageThreeDSelected)); + } + } + + public bool IsManageOllamaSelected => SelectedManageModelCategory == LocalModelCategory.Ollama; + public bool IsManageImageSelected => SelectedManageModelCategory == LocalModelCategory.ImageCheckpoint || SelectedManageModelCategory == LocalModelCategory.ImageLora; + public bool IsManageVideoSelected => SelectedManageModelCategory == LocalModelCategory.Video; + public bool IsManageAudioSelected => SelectedManageModelCategory == LocalModelCategory.Audio; + public bool IsManageThreeDSelected => SelectedManageModelCategory == LocalModelCategory.ThreeD; + + public string SelectedStudioModelText + { + get => ActiveModelBadge; + set + { + if (!string.IsNullOrWhiteSpace(value) && value != ActiveModelBadge) + { + SelectStudioModel(value); + } + } + } + + [RelayCommand] + public void SelectStudioModel(string modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) return; + Studio.SelectModel(modelName); + if (IsImageModalityActive) SelectedImageWorkflow = modelName; + else if (IsVideoModalityActive) SelectedVideoWorkflow = modelName; + else if (IsAudioModalityActive && Audio != null) Audio.VoiceProfile = modelName; + OnPropertyChanged(nameof(ActiveModelBadge)); + OnPropertyChanged(nameof(SelectedStudioModelText)); + ToastService.Instance.Show($"Active model set to '{modelName}'", ToastType.Success); + } + + [RelayCommand] + public void UseModelInStudio(LocalModelItem item) + { + if (item == null) return; + var targetModality = item.Category switch + { + LocalModelCategory.ImageCheckpoint or LocalModelCategory.ImageLora => "Image", + LocalModelCategory.Video => "Video", + LocalModelCategory.Audio => "Audio", + LocalModelCategory.ThreeD => "3D Mesh", + _ => "Image" + }; + SelectModality(targetModality); + SelectStudioModel(item.Name); + SelectedTabIndex = 1; + } + + [RelayCommand] + public void RevealModelInExplorer(LocalModelItem item) + { + if (item == null || string.IsNullOrWhiteSpace(item.FullPath)) return; + try + { + if (System.IO.File.Exists(item.FullPath)) + { + var argument = $"/select,\"{item.FullPath}\""; + System.Diagnostics.Process.Start("explorer.exe", argument); + } + else if (System.IO.Directory.Exists(item.FullPath)) + { + System.Diagnostics.Process.Start("explorer.exe", $"\"{item.FullPath}\""); + } + } + catch { } + } + + [RelayCommand] + public async Task DeleteLocalModelAsync(LocalModelItem item) + { + if (item == null || string.IsNullOrWhiteSpace(item.FullPath)) return; + try + { + if (System.IO.File.Exists(item.FullPath)) + { + System.IO.File.Delete(item.FullPath); + ToastService.Instance.Show($"Deleted '{item.FileName}'", ToastType.Info); + await RefreshScannedModelsAsync(); + } + } + catch (Exception ex) + { + ToastService.Instance.Show($"Failed to delete: {ex.Message}", ToastType.Error); + } + } + + [RelayCommand] + public async Task RefreshScannedModelsAsync() + { + var appSettings = Settings != null ? new AppSettings( + ForgeModelsPath: Settings.ForgeModelsPath, + ComfyModelsPath: Settings.ComfyModelsPath, + ThreeDModelsPath: Settings.ThreeDModelsPath, + WorkflowsPath: Settings.WorkflowsPath + ) : null; + + await Studio.LoadScannedModelsAsync(appSettings, AppContext.BaseDirectory); + + ScannedImageModels.Clear(); + ScannedVideoModels.Clear(); + ScannedAudioModels.Clear(); + ScannedThreeDModels.Clear(); + + foreach (var m in Studio.ScannedModels) + { + if (m.Category == LocalModelCategory.ImageCheckpoint || m.Category == LocalModelCategory.ImageLora) + ScannedImageModels.Add(m); + else if (m.Category == LocalModelCategory.Video) + ScannedVideoModels.Add(m); + else if (m.Category == LocalModelCategory.Audio) + ScannedAudioModels.Add(m); + else if (m.Category == LocalModelCategory.ThreeD) + ScannedThreeDModels.Add(m); + } + } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsImageModalityActive))] [NotifyPropertyChangedFor(nameof(IsTextModalityActive))] diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index 3a3b7b8..a8eda1f 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -341,6 +341,7 @@ public MainViewModel( _ = Audio.LoadAudioFilesAsync(ApiBase, Http); _ = LoadSettingsAsync(); _ = Settings.RefreshComponentStatusesAsync(ApiBase, Http); + _ = RefreshScannedModelsAsync(); if (EnableAutomaticPolling) { _ = StartBackgroundPollingAsync(); diff --git a/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml index 857725f..3ef51a6 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml @@ -16,15 +16,17 @@ - + + + + @@ -54,12 +63,10 @@ CommandParameter="{x:Static models:NavDomain.Studio}" ToolTip.Tip="Studio / Workflows"> - @@ -73,12 +80,10 @@ CommandParameter="{x:Static models:NavDomain.Models}" ToolTip.Tip="Models & Hubs"> - @@ -92,12 +97,10 @@ CommandParameter="{x:Static models:NavDomain.HardwareFit}" ToolTip.Tip="Hardware Fit"> - @@ -111,12 +114,10 @@ CommandParameter="{x:Static models:NavDomain.Settings}" ToolTip.Tip="Settings & Daemon"> - @@ -140,6 +141,7 @@ @@ -155,6 +157,7 @@ @@ -176,6 +179,7 @@ IsVisible="{Binding !IsExpanded, RelativeSource={RelativeSource AncestorType=controls:ActivityRailControl}}"/> diff --git a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml index 7f3ab2d..d6a13ae 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml @@ -59,6 +59,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml.cs index 0b17aca..1bc9145 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml.cs +++ b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml.cs @@ -1,11 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia; using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Media.Imaging; +using Avalonia.Platform.Storage; +using LocalLLMServerManager.Shared.ViewModels; namespace LocalLLMServerManager.Shared.Views.Controls; public partial class EngineStudioTabControl : UserControl { + private MainViewModel? _boundVm; + public EngineStudioTabControl() { InitializeComponent(); } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnDataContextChanged(EventArgs e) + { + base.OnDataContextChanged(e); + + if (_boundVm != null) + { + _boundVm.CopyStudioImageRequested -= OnCopyStudioImageRequested; + _boundVm.SaveStudioImageRequested -= OnSaveStudioImageRequested; + _boundVm.PickImageRequested -= OnPickImageRequested; + _boundVm = null; + } + + if (DataContext is MainViewModel vm) + { + _boundVm = vm; + _boundVm.CopyStudioImageRequested += OnCopyStudioImageRequested; + _boundVm.SaveStudioImageRequested += OnSaveStudioImageRequested; + _boundVm.PickImageRequested += OnPickImageRequested; + } + } + + private async Task OnCopyStudioImageRequested(byte[] bytes) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.Clipboard == null || bytes == null || bytes.Length == 0) return; + + try + { + using var ms = new MemoryStream(bytes); + var bitmap = new Bitmap(ms); + await Avalonia.Input.Platform.ClipboardExtensions.SetBitmapAsync(topLevel.Clipboard, bitmap); + } + catch { } + } + + private async Task OnSaveStudioImageRequested(byte[] bytes) + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.StorageProvider == null || bytes == null || bytes.Length == 0) return null; + + try + { + var options = new FilePickerSaveOptions + { + Title = "Save Generated Image", + DefaultExtension = ".png", + SuggestedFileName = $"studio_render_{DateTime.Now:yyyyMMdd_HHmmss}.png", + FileTypeChoices = new List + { + new("PNG Image (*.png)") { Patterns = new[] { "*.png" } } + } + }; + + var file = await topLevel.StorageProvider.SaveFilePickerAsync(options); + if (file != null) + { + using var stream = await file.OpenWriteAsync(); + await stream.WriteAsync(bytes); + return file.TryGetLocalPath() ?? file.Name; + } + } + catch { } + return null; + } + + private async Task OnPickImageRequested() + { + var topLevel = TopLevel.GetTopLevel(this); + if (topLevel?.StorageProvider == null) return null; + + try + { + var options = new FilePickerOpenOptions + { + Title = "Select Reference Image", + AllowMultiple = false, + FileTypeFilter = new List + { + new("Images (*.png;*.jpg;*.jpeg;*.webp)") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp" } } + } + }; + + var files = await topLevel.StorageProvider.OpenFilePickerAsync(options); + if (files != null && files.Count > 0) + { + using var stream = await files[0].OpenReadAsync(); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + return ms.ToArray(); + } + } + catch { } + return null; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + if (_boundVm != null) + { + _boundVm.CopyStudioImageRequested -= OnCopyStudioImageRequested; + _boundVm.SaveStudioImageRequested -= OnSaveStudioImageRequested; + _boundVm.PickImageRequested -= OnPickImageRequested; + _boundVm = null; + } + } } diff --git a/LocalLLMServerManager.Tests/EngineStudioViewModelTests.cs b/LocalLLMServerManager.Tests/EngineStudioViewModelTests.cs index a41834d..7184df1 100644 --- a/LocalLLMServerManager.Tests/EngineStudioViewModelTests.cs +++ b/LocalLLMServerManager.Tests/EngineStudioViewModelTests.cs @@ -130,4 +130,140 @@ public void MainViewModel_UseModelInStudio_SwitchesModalityAndModel() Assert.Equal("DreamShaper XL", vm.Studio.ImageModel); Assert.Equal("DreamShaper XL", vm.ActiveModelBadge); } + + [Fact] + public async System.Threading.Tasks.Task MainViewModel_GenerateStudioImage_DispatchesToForgeAndDecodesImage() + { + byte[] fakePng = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4 }; + string base64Png = System.Convert.ToBase64String(fakePng); + + var handler = new TestHttpHandler(req => + { + if (req.RequestUri != null && req.RequestUri.ToString().Contains("/sdapi/v1/txt2img")) + { + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent($"{{\"images\":[\"{base64Png}\"]}}", System.Text.Encoding.UTF8, "application/json") + }; + } + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.NotFound); + }); + + var client = new System.Net.Http.HttpClient(handler); + var vm = new MainViewModel(client); + + Assert.Null(vm.StudioGeneratedImageBytes); + Assert.False(vm.HasStudioGeneratedImage); + Assert.True(vm.ShowImageCanvasZeroState); + + vm.PromptText = "A beautiful cybernetic forest"; + vm.ActiveAspectPreset = "16:9"; + + await vm.GenerateStudioImageAsync(); + + Assert.False(vm.IsGeneratingStudioImage); + Assert.NotNull(vm.StudioGeneratedImageBytes); + Assert.True(vm.HasStudioGeneratedImage); + Assert.False(vm.ShowImageCanvasZeroState); + Assert.Equal(fakePng, vm.StudioGeneratedImageBytes); + Assert.Equal("Generation complete!", vm.StudioImageGenerationStatus); + + vm.ClearStudioImageCommand.Execute(null); + + Assert.Null(vm.StudioGeneratedImageBytes); + Assert.False(vm.HasStudioGeneratedImage); + Assert.True(vm.ShowImageCanvasZeroState); + } + + [Fact] + public async System.Threading.Tasks.Task MainViewModel_GenerateStudioImage_HandlesOfflineForgeGracefully() + { + var handler = new TestHttpHandler(req => throw new System.Net.Http.HttpRequestException("Connection refused")); + var client = new System.Net.Http.HttpClient(handler); + var vm = new MainViewModel(client); + + vm.PromptText = "Offline test"; + await vm.GenerateStudioImageAsync(); + + Assert.False(vm.IsGeneratingStudioImage); + Assert.Null(vm.StudioGeneratedImageBytes); + Assert.False(vm.HasStudioGeneratedImage); + Assert.Contains("offline or unreachable", vm.StudioImageGenerationStatus); + } + + [Fact] + public async System.Threading.Tasks.Task MainViewModel_GenerateFromDock_DispatchesImageGeneration() + { + byte[] fakePng = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 5, 6, 7, 8 }; + string base64Png = System.Convert.ToBase64String(fakePng); + + var handler = new TestHttpHandler(req => new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent($"{{\"images\":[\"{base64Png}\"]}}", System.Text.Encoding.UTF8, "application/json") + }); + + var client = new System.Net.Http.HttpClient(handler); + var vm = new MainViewModel(client); + + vm.SelectModality("Image"); + vm.PromptText = "Floating islands in sky"; + + await vm.GenerateFromDockAsync(); + + Assert.NotNull(vm.StudioGeneratedImageBytes); + Assert.True(vm.HasStudioGeneratedImage); + } + + [Fact] + public async System.Threading.Tasks.Task MainViewModel_AttachmentAndClipboard_FiresCallbacks() + { + var vm = new MainViewModel(); + + byte[] fakeRef = new byte[] { 1, 2, 3, 4, 5 }; + vm.PickImageRequested += () => System.Threading.Tasks.Task.FromResult(fakeRef); + + Assert.False(vm.HasAttachedImage); + await vm.AttachImageAsync(); + + Assert.True(vm.HasAttachedImage); + Assert.Equal(fakeRef, vm.AttachedImageBytes); + + vm.RemoveAttachedImageCommand.Execute(null); + Assert.False(vm.HasAttachedImage); + Assert.Null(vm.AttachedImageBytes); + + // Clipboard test + byte[] copied = null!; + vm.CopyStudioImageRequested += bytes => + { + copied = bytes; + return System.Threading.Tasks.Task.CompletedTask; + }; + + vm.StudioGeneratedImageBytes = fakeRef; + await vm.CopyStudioImageAsync(); + Assert.Equal(fakeRef, copied); + + // Save test + string? savedPath = null; + vm.SaveStudioImageRequested += bytes => System.Threading.Tasks.Task.FromResult("C:/fake/render.png"); + await vm.SaveStudioImageAsync(); + } + + private class TestHttpHandler : System.Net.Http.HttpMessageHandler + { + private readonly System.Func _func; + + public TestHttpHandler(System.Func func) + { + _func = func; + } + + protected override System.Threading.Tasks.Task SendAsync( + System.Net.Http.HttpRequestMessage request, + System.Threading.CancellationToken cancellationToken) + { + return System.Threading.Tasks.Task.FromResult(_func(request)); + } + } } From 2894cf859de7cbf5110823b5057255426c6c7452 Mon Sep 17 00:00:00 2001 From: Steven Pelech Date: Sat, 3 Oct 2026 12:08:56 -0500 Subject: [PATCH 4/8] fix(ui,sizer): overhaul KV cache sizer, studio dock prompts, and layout audit clipping --- .../Services/CanIRunItService.cs | 4 +- .../Services/LocalModelScannerService.cs | 158 +++++++++++--- .../ViewModels/EngineStudioViewModel.cs | 90 +++++++- .../ViewModels/MainViewModel.cs | 24 ++- .../ViewModels/OllamaLibraryViewModel.cs | 156 +++++++++++++- .../Views/Controls/ActivityRailControl.axaml | 15 +- .../Controls/ActivityRailControl.axaml.cs | 4 +- .../Controls/AiAssistantTabControl.axaml | 11 +- .../Views/Controls/CanIRunItView.axaml | 30 +-- .../Controls/EngineStudioTabControl.axaml | 79 ++++++- .../Controls/EngineStudioTabControl.axaml.cs | 5 +- .../Controls/OllamaModelsTabControl.axaml | 194 ++++++++++++++++-- .../Controls/OllamaModelsTabControl.axaml.cs | 10 + .../AvaloniaHeadlessInteractionTests.cs | 86 ++++++++ .../EngineStudioViewModelTests.cs | 30 +++ .../MainWindowUiTests.cs | 2 +- .../OllamaIntegrationExhaustiveTests.cs | 12 +- .../OllamaLibraryViewModelTests.cs | 2 +- Views/AiAssistWindow.axaml | 6 + Views/AiAssistWindow.axaml.cs | 13 ++ Views/DocumentationWindow.axaml | 7 +- Views/DocumentationWindow.axaml.cs | 13 ++ Views/MainWindow.axaml | 2 +- Views/MainWindow.axaml.cs | 34 ++- docs/images/dashboard_docs_drawer.png | Bin 188255 -> 188162 bytes docs/images/dashboard_huggingface.png | Bin 186028 -> 184721 bytes 26 files changed, 863 insertions(+), 124 deletions(-) diff --git a/LocalLLMServerManager.Shared/Services/CanIRunItService.cs b/LocalLLMServerManager.Shared/Services/CanIRunItService.cs index 30751d2..d16bf89 100644 --- a/LocalLLMServerManager.Shared/Services/CanIRunItService.cs +++ b/LocalLLMServerManager.Shared/Services/CanIRunItService.cs @@ -699,7 +699,7 @@ public StudioHardwareFit EstimateStudioHardwareFit(StudioModality modality, int ); } - private static double ExtractParamBillions(string modelName) + public static double ExtractParamBillions(string modelName) { if (string.IsNullOrWhiteSpace(modelName)) return 8.0; @@ -735,7 +735,7 @@ private static double ExtractParamBillions(string modelName) return 8.0; } - private static string ExtractQuantization(string modelName) + public static string ExtractQuantization(string modelName) { if (string.IsNullOrWhiteSpace(modelName)) return "Q4_K_M"; diff --git a/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs b/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs index 581b2a6..680549a 100644 --- a/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs +++ b/LocalLLMServerManager.Shared/Services/LocalModelScannerService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; +using System.Text.Json; using System.Threading.Tasks; using LocalLLMServerManager.Shared.Interfaces; using LocalLLMServerManager.Shared.Models; @@ -21,29 +23,33 @@ public IReadOnlyList GetCachedModels() } } - public Task> ScanAllModelsAsync(AppSettings? settings = null, string? baseDirectory = null) + public async Task> ScanAllModelsAsync(AppSettings? settings = null, string? baseDirectory = null) { - return Task.Run>(() => - { - var baseDir = !string.IsNullOrWhiteSpace(baseDirectory) ? baseDirectory : AppContext.BaseDirectory; - var results = new List(); - var scannedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var baseDir = !string.IsNullOrWhiteSpace(baseDirectory) ? baseDirectory : AppContext.BaseDirectory; + var results = new List(); + var scannedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); - // 1. Image Checkpoints - var checkpointDirs = new List<(string Path, string Label)> - { - (Path.Combine(baseDir, "models", "checkpoints"), "Local Checkpoints"), - (Path.Combine(baseDir, "ComfyUI", "models", "checkpoints"), "ComfyUI Checkpoints") - }; - if (!string.IsNullOrWhiteSpace(settings?.ForgeModelsPath)) - { - checkpointDirs.Add((settings.ForgeModelsPath, "Forge Checkpoints")); - } - if (!string.IsNullOrWhiteSpace(settings?.ComfyModelsPath)) - { - checkpointDirs.Add((settings.ComfyModelsPath, "ComfyUI Checkpoints")); - } + // 1. Image Checkpoints (Directory scan) + var checkpointDirs = new List<(string Path, string Label)> + { + (Path.Combine(baseDir, "models", "checkpoints"), "Local Checkpoints"), + (Path.Combine(baseDir, "ComfyUI", "models", "checkpoints"), "ComfyUI Checkpoints"), + (@"D:\AI\models\checkpoints", "Local AI Checkpoints"), + (@"C:\AI\models\checkpoints", "Local AI Checkpoints"), + (@"D:\models\checkpoints", "Local Checkpoints"), + (@"C:\models\checkpoints", "Local Checkpoints") + }; + if (!string.IsNullOrWhiteSpace(settings?.ForgeModelsPath)) + { + checkpointDirs.Add((settings.ForgeModelsPath, "Forge Checkpoints")); + } + if (!string.IsNullOrWhiteSpace(settings?.ComfyModelsPath)) + { + checkpointDirs.Add((settings.ComfyModelsPath, "ComfyUI Checkpoints")); + } + await Task.Run(() => + { foreach (var (dir, label) in checkpointDirs) { ScanDirectoryFiles(dir, label, LocalModelCategory.ImageCheckpoint, new[] { ".safetensors", ".ckpt" }, results, scannedPaths); @@ -53,7 +59,9 @@ public Task> ScanAllModelsAsync(AppSettings? setti var loraDirs = new List<(string Path, string Label)> { (Path.Combine(baseDir, "models", "Lora"), "Local LoRAs"), - (Path.Combine(baseDir, "ComfyUI", "models", "loras"), "ComfyUI LoRAs") + (Path.Combine(baseDir, "ComfyUI", "models", "loras"), "ComfyUI LoRAs"), + (@"D:\AI\models\Lora", "Local LoRAs"), + (@"C:\AI\models\Lora", "Local LoRAs") }; foreach (var (dir, label) in loraDirs) { @@ -103,21 +111,119 @@ public Task> ScanAllModelsAsync(AppSettings? setti } foreach (var (dir, label) in threeDDirs) { - // In root Workflows, only match 3D files like trellis, hunyuan3d ScanDirectoryFiles(dir, label, LocalModelCategory.ThreeD, new[] { ".safetensors", ".json" }, results, scannedPaths, fileFilter: f => { var lower = Path.GetFileName(f).ToLowerInvariant(); return lower.Contains("3d") || lower.Contains("trellis") || lower.Contains("hunyuan3d"); }); } + }); - lock (_lock) + // 6. Live WebUI Forge Checkpoints & LoRAs API Discovery + try + { + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + var forgeModelsResp = await http.GetAsync("http://127.0.0.1:7860/sdapi/v1/sd-models"); + if (forgeModelsResp.IsSuccessStatusCode) { - _cachedModels = results.OrderBy(m => m.Name).ToList(); + var json = await forgeModelsResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var elem in doc.RootElement.EnumerateArray()) + { + var title = elem.TryGetProperty("title", out var tp) ? tp.GetString() : null; + var modelName = elem.TryGetProperty("model_name", out var mnp) ? mnp.GetString() : null; + var filename = elem.TryGetProperty("filename", out var fnp) ? fnp.GetString() : null; + + var name = !string.IsNullOrWhiteSpace(modelName) ? modelName : (!string.IsNullOrWhiteSpace(title) ? title : "Forge Checkpoint"); + var fullPath = !string.IsNullOrWhiteSpace(filename) ? filename : name; + + if (!scannedPaths.Add(fullPath)) continue; + + long sizeBytes = 0; + if (!string.IsNullOrWhiteSpace(filename) && File.Exists(filename)) + { + try { sizeBytes = new FileInfo(filename).Length; } catch { } + } + + var isVideo = name.Contains("svd", StringComparison.OrdinalIgnoreCase); + var cat = isVideo ? LocalModelCategory.Video : LocalModelCategory.ImageCheckpoint; + + results.Add(new LocalModelItem( + Id: fullPath, + Name: CleanDisplayName(name), + FileName: Path.GetFileName(fullPath), + FullPath: fullPath, + Category: cat, + Architecture: DetectArchitecture(name, cat), + SizeBytes: sizeBytes, + FormattedSize: sizeBytes > 0 ? FormatBytes(sizeBytes) : "Forge Live", + SourceLocation: "WebUI Forge (Active API)", + CreatedAt: DateTime.Now + )); + + if (!string.IsNullOrWhiteSpace(filename) && File.Exists(filename)) + { + var dir = Path.GetDirectoryName(filename); + if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir)) + { + ScanDirectoryFiles(dir, "Forge Discovered Directory", LocalModelCategory.ImageCheckpoint, new[] { ".safetensors", ".ckpt" }, results, scannedPaths); + } + } + } + } } - return _cachedModels; - }); + var forgeLorasResp = await http.GetAsync("http://127.0.0.1:7860/sdapi/v1/loras"); + if (forgeLorasResp.IsSuccessStatusCode) + { + var json = await forgeLorasResp.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var elem in doc.RootElement.EnumerateArray()) + { + var name = elem.TryGetProperty("name", out var np) ? np.GetString() : null; + var path = elem.TryGetProperty("path", out var pp) ? pp.GetString() : null; + if (string.IsNullOrWhiteSpace(name)) continue; + + var fullPath = !string.IsNullOrWhiteSpace(path) ? path : name; + if (!scannedPaths.Add(fullPath)) continue; + + long sizeBytes = 0; + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + { + try { sizeBytes = new FileInfo(path).Length; } catch { } + } + + results.Add(new LocalModelItem( + Id: fullPath, + Name: CleanDisplayName(name), + FileName: Path.GetFileName(fullPath), + FullPath: fullPath, + Category: LocalModelCategory.ImageLora, + Architecture: "LoRA", + SizeBytes: sizeBytes, + FormattedSize: sizeBytes > 0 ? FormatBytes(sizeBytes) : "Forge LoRA", + SourceLocation: "WebUI Forge (Active API)", + CreatedAt: DateTime.Now + )); + } + } + } + } + catch + { + // Forge is either offline or unreachable, ignore safely + } + + lock (_lock) + { + _cachedModels = results.OrderBy(m => m.Name).ToList(); + } + + return _cachedModels; } private static void ScanDirectoryFiles( diff --git a/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs index 8cbb78a..6f42708 100644 --- a/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/EngineStudioViewModel.cs @@ -82,8 +82,17 @@ public EngineStudioViewModel(ILocalModelScannerService scanner) private string _stickerModel = "Sticker Studio (BirefNet + SDXL)"; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAspectSquareActive))] + [NotifyPropertyChangedFor(nameof(IsAspectLandscapeActive))] + [NotifyPropertyChangedFor(nameof(IsAspectPortraitActive))] + [NotifyPropertyChangedFor(nameof(IsAspectStandardActive))] private string _activeAspectPreset = "16:9"; + public bool IsAspectSquareActive => ActiveAspectPreset == "1:1"; + public bool IsAspectLandscapeActive => ActiveAspectPreset == "16:9"; + public bool IsAspectPortraitActive => ActiveAspectPreset == "9:16"; + public bool IsAspectStandardActive => ActiveAspectPreset == "4:3"; + [ObservableProperty] private int _steps = 30; @@ -431,6 +440,16 @@ public async Task RefreshScannedModelsAsync() else if (m.Category == LocalModelCategory.ThreeD) ScannedThreeDModels.Add(m); } + + var totalFound = Studio.ScannedModels.Count; + if (totalFound > 0) + { + ToastService.Instance.Show($"Scan complete: discovered {totalFound} local models & workflows.", ToastType.Success); + } + else + { + ToastService.Instance.Show("Scan complete: no model checkpoints found in local paths or active engines.", ToastType.Info); + } } [ObservableProperty] @@ -453,8 +472,17 @@ public async Task RefreshScannedModelsAsync() private bool _isParametersFlyoutOpen; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsAspectSquareActive))] + [NotifyPropertyChangedFor(nameof(IsAspectLandscapeActive))] + [NotifyPropertyChangedFor(nameof(IsAspectPortraitActive))] + [NotifyPropertyChangedFor(nameof(IsAspectStandardActive))] private string _activeAspectPreset = "16:9"; + public bool IsAspectSquareActive => ActiveAspectPreset == "1:1"; + public bool IsAspectLandscapeActive => ActiveAspectPreset == "16:9"; + public bool IsAspectPortraitActive => ActiveAspectPreset == "9:16"; + public bool IsAspectStandardActive => ActiveAspectPreset == "4:3"; + [ObservableProperty] private int _studioSteps = 30; @@ -544,6 +572,9 @@ public void ToggleParametersFlyout() [ObservableProperty] private string _studioImageGenerationStatus = "Ready"; + [ObservableProperty] + private double _studioImageGenerationProgress = 0.0; + [ObservableProperty] private byte[]? _attachedImageBytes; @@ -634,6 +665,10 @@ public void SelectAspectPreset(string preset) { ActiveAspectPreset = preset; Studio.ActiveAspectPreset = preset; + OnPropertyChanged(nameof(IsAspectSquareActive)); + OnPropertyChanged(nameof(IsAspectLandscapeActive)); + OnPropertyChanged(nameof(IsAspectPortraitActive)); + OnPropertyChanged(nameof(IsAspectStandardActive)); } } @@ -664,6 +699,7 @@ public async Task GenerateStudioImageAsync() } IsGeneratingStudioImage = true; + StudioImageGenerationProgress = 0.0; StudioImageGenerationStatus = "Preparing inference parameters..."; var (width, height) = ActiveAspectPreset switch @@ -724,9 +760,55 @@ public async Task GenerateStudioImageAsync() StudioImageGenerationStatus = $"Dispatching {width}x{height} request to Forge ({forgeBase})..."; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(180)); + using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); + + var progressTask = Task.Run(async () => + { + using var progClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + while (!pollCts.Token.IsCancellationRequested) + { + try + { + await Task.Delay(350, pollCts.Token); + var progResp = await progClient.GetAsync($"{forgeBase}/sdapi/v1/progress", pollCts.Token); + if (progResp.IsSuccessStatusCode) + { + var progJson = await progResp.Content.ReadAsStringAsync(pollCts.Token); + using var progDoc = JsonDocument.Parse(progJson); + var root = progDoc.RootElement; + var prog = root.TryGetProperty("progress", out var pVal) ? pVal.GetDouble() : 0.0; + int step = 0; + int totalSteps = 0; + if (root.TryGetProperty("state", out var stateObj) && stateObj.ValueKind == JsonValueKind.Object) + { + if (stateObj.TryGetProperty("sampling_step", out var stepVal)) step = stepVal.GetInt32(); + if (stateObj.TryGetProperty("sampling_steps", out var stepsVal)) totalSteps = stepsVal.GetInt32(); + } + + var clampedProg = Math.Clamp(prog, 0.0, 1.0); + StudioImageGenerationProgress = clampedProg; + if (totalSteps > 0 && step > 0) + { + StudioImageGenerationStatus = $"Sampling step {step}/{totalSteps} ({Math.Round(clampedProg * 100)}%)..."; + } + else if (clampedProg > 0.01) + { + StudioImageGenerationStatus = $"Generating: {Math.Round(clampedProg * 100)}%..."; + } + else + { + StudioImageGenerationStatus = "Forge preparing model & allocating VRAM..."; + } + } + } + catch (OperationCanceledException) { break; } + catch { } + } + }, pollCts.Token); + try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120)); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); using var content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -745,6 +827,7 @@ public async Task GenerateStudioImageAsync() { var bytes = Convert.FromBase64String(base64); StudioGeneratedImageBytes = bytes; + StudioImageGenerationProgress = 1.0; StudioImageGenerationStatus = "Generation complete!"; ToastService.Instance.Show("Image generated successfully!", ToastType.Success); return; @@ -760,7 +843,7 @@ public async Task GenerateStudioImageAsync() } catch (TaskCanceledException) { - StudioImageGenerationStatus = "Generation timed out after 120s."; + StudioImageGenerationStatus = "Generation timed out after 180s."; ToastService.Instance.Show("Generation timed out. Forge engine may be overloaded or hung.", ToastType.Error); } catch (HttpRequestException) @@ -775,6 +858,8 @@ public async Task GenerateStudioImageAsync() } finally { + pollCts.Cancel(); + try { await progressTask; } catch { } IsGeneratingStudioImage = false; } } @@ -784,6 +869,7 @@ public async Task GenerateFromDockAsync() { if (IsImageModalityActive) { + if (!string.IsNullOrWhiteSpace(PromptText)) ImagePrompt = PromptText; await GenerateStudioImageAsync(); } else if (IsTextModalityActive) diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs index a8eda1f..5ab30eb 100644 --- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs @@ -18,7 +18,8 @@ public record OllamaModelItem( string CapabilityColor, bool IsLoaded, QuickFitBadge? FitBadge = null, - long SizeBytes = 0 + long SizeBytes = 0, + bool IsSelected = false ); public record HuggingFaceRepoItem( @@ -307,6 +308,15 @@ public MainViewModel( Assistant.InputText = prompt; }; + Ollama.OnApplyModelContextRequested = (modelName, tokens) => + { + ConfiguredContextTokens = tokens; + SelectModality("Text"); + SelectedStudioMode = "Text"; + SelectedTabIndex = 1; + SelectStudioModel(modelName); + }; + NavigationRail.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(NavigationRail.SelectedDomain)) @@ -500,6 +510,9 @@ public void CancelServiceConfirm() [ObservableProperty] private int _imageHeight = 1024; + [ObservableProperty] + private int _configuredContextTokens = 8192; + [ObservableProperty] private long _imageSeed = 42890; @@ -1298,11 +1311,16 @@ public async Task GenerateOllamaTextAsync() OllamaResponseText = "Generating response from local LLM..."; try { - var modelName = Ollama.InstalledModels.FirstOrDefault()?.Name ?? "llama3.2:latest"; + var modelName = Ollama.SelectedInstalledModel?.Name ?? Ollama.InstalledModels.FirstOrDefault()?.Name ?? "llama3.2:latest"; + int numCtx = ConfiguredContextTokens > 0 ? ConfiguredContextTokens : (int)Ollama.TargetContextTokens; var req = new { prompt = OllamaPrompt, - model = modelName + model = modelName, + options = new + { + num_ctx = numCtx + } }; var content = new StringContent( JsonSerializer.Serialize(req), diff --git a/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs index 286918a..987d3f5 100644 --- a/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs +++ b/LocalLLMServerManager.Shared/ViewModels/OllamaLibraryViewModel.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Avalonia.Input.Platform; using LocalLLMServerManager.Shared.Interfaces; using LocalLLMServerManager.Shared.Models; using LocalLLMServerManager.Shared.Services; @@ -23,6 +24,17 @@ public partial class OllamaLibraryViewModel : ObservableObject public ObservableCollection FilteredInstalledModels { get; } = new(); [ObservableProperty] private OllamaModelItem? _selectedInstalledModel; + [ObservableProperty] private string _calcModelName = "llama3.2:latest (3B, Q4_K_M)"; + [ObservableProperty] private string _calcModelWeightText = "~2.2 GB"; + [ObservableProperty] private string _calcTotalVramNeededText = "~3.3 GB / 16.0 GB"; + [ObservableProperty] private double _calcVramUsageRatio = 0.20; + [ObservableProperty] private string _calcFitVerdictText = "🟢 Full VRAM (28/28 layers on GPU)"; + [ObservableProperty] private string _calcFitVerdictColor = "#10B981"; + [ObservableProperty] private string _calcRecommendationMessage = ""; + [ObservableProperty] private string _selectedKvPrecision = "FP16"; + [ObservableProperty] private FitVerdict _calcFitVerdict = FitVerdict.FullVram; + [ObservableProperty] private LlmFitResult? _currentFitResult; + [ObservableProperty] private bool _isFullVramActive = true; [ObservableProperty] private bool _isPartialOffloadActive = true; [ObservableProperty] private bool _isCpuOnlyActive = true; @@ -49,6 +61,7 @@ public partial class OllamaLibraryViewModel : ObservableObject [ObservableProperty] private double _totalRamMb = 32768.0; public Action? OnInspectModelRequested { get; set; } + public Action? OnApplyModelContextRequested { get; set; } public OllamaLibraryViewModel(IOllamaModelService ollamaModelService) : this(ollamaModelService, new CanIRunItService(), null) @@ -64,6 +77,7 @@ public OllamaLibraryViewModel( _canIRunItService = canIRunItService ?? new CanIRunItService(); _telemetryService = telemetryService; InstalledModels.CollectionChanged += (s, e) => ApplyFilter(); + RecalculateKvCache(); } public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) @@ -72,6 +86,7 @@ public void UpdateHardwareTelemetry(double totalVramMb, double totalRamMb) if (totalRamMb > 0) TotalRamMb = totalRamMb; RecomputeBadges(); + RecalculateKvCache(); } public void RecomputeBadges() @@ -158,11 +173,134 @@ public void InspectModel(OllamaModelItem? item) } } - partial void OnTargetContextTokensChanged(double value) + public bool IsFp16Selected => SelectedKvPrecision == "FP16"; + public bool IsQ8Selected => SelectedKvPrecision == "Q8_0"; + public bool IsQ4Selected => SelectedKvPrecision == "Q4_0"; + + partial void OnTargetContextTokensChanged(double value) => RecalculateKvCache(); + partial void OnSelectedInstalledModelChanged(OllamaModelItem? value) => RecalculateKvCache(); + partial void OnSelectedKvPrecisionChanged(string value) + { + OnPropertyChanged(nameof(IsFp16Selected)); + OnPropertyChanged(nameof(IsQ8Selected)); + OnPropertyChanged(nameof(IsQ4Selected)); + RecalculateKvCache(); + } + + public void RecalculateKvCache() + { + var model = SelectedInstalledModel ?? InstalledModels.FirstOrDefault(); + string modelName = model?.Name ?? "llama3.2:latest"; + double paramsB = CanIRunItService.ExtractParamBillions(modelName); + string quant = CanIRunItService.ExtractQuantization(modelName); + + var req = new LlmFitRequest( + ParametersBillions: paramsB, + Quantization: quant, + ContextLength: (int)TargetContextTokens, + KvPrecision: SelectedKvPrecision, + AvailableVramMb: (long)TotalVramMb, + AvailableRamMb: (long)TotalRamMb + ); + + var fit = _canIRunItService.EvaluateLlmFit(req); + CurrentFitResult = fit; + CalcFitVerdict = fit.FitVerdict; + CalcModelName = $"{modelName} ({paramsB:G2}B, {quant})"; + + double weightGb = fit.ModelWeightMb / 1024.0; + CalcModelWeightText = weightGb >= 1.0 ? $"{weightGb:F1} GB" : $"{fit.ModelWeightMb:N0} MB"; + + double kvGb = fit.KvCacheMb / 1024.0; + EstimatedKvCacheText = kvGb >= 1.0 ? $"{kvGb:F1} GB" : $"{fit.KvCacheMb:N0} MB"; + + long totalNeededMb = fit.ModelWeightMb + fit.KvCacheMb + fit.OverheadMb; + double totalGb = totalNeededMb / 1024.0; + double vramGb = TotalVramMb / 1024.0; + CalcTotalVramNeededText = $"{totalGb:F1} GB / {vramGb:F1} GB"; + CalcVramUsageRatio = TotalVramMb > 0 ? Math.Min(1.0, (double)totalNeededMb / TotalVramMb) : 0.0; + + CalcFitVerdictText = fit.FitVerdict switch + { + FitVerdict.FullVram => $"🟢 Full VRAM ({fit.GpuLayers}/{fit.TotalLayers} layers on GPU • ~{fit.EstimatedTokPerSec:F0} tok/s)", + FitVerdict.PartialOffload => $"🟡 Partial Offload ({fit.GpuLayers} GPU / {fit.CpuLayers} CPU layers • ~{fit.EstimatedTokPerSec:F1} tok/s)", + FitVerdict.CpuOnly => "🟠 CPU Only (VRAM Insufficient)", + FitVerdict.OutOfMemory => "🔴 System Out of Memory", + _ => "Unknown" + }; + + CalcFitVerdictColor = fit.FitVerdict switch + { + FitVerdict.FullVram => "#10B981", + FitVerdict.PartialOffload => "#F59E0B", + FitVerdict.CpuOnly => "#F97316", + FitVerdict.OutOfMemory => "#EF4444", + _ => "#94A3B8" + }; + + CalcRecommendationMessage = fit.RecommendationMessage; + } + + [RelayCommand] + public void SelectModel(OllamaModelItem? model) + { + if (model == null) return; + SelectedInstalledModel = model; + for (int i = 0; i < InstalledModels.Count; i++) + { + var m = InstalledModels[i]; + InstalledModels[i] = m with { IsSelected = (m.Name == model.Name) }; + } + ApplyFilter(); + RecalculateKvCache(); + } + + [RelayCommand] + public void SetContextPreset(string? preset) { - double estimatedBytes = value * 65536.0; - double mb = estimatedBytes / (1024.0 * 1024.0); - EstimatedKvCacheText = mb >= 1024 ? $"~{(mb / 1024.0):F1} GB" : $"~{mb:F0} MB"; + if (string.IsNullOrWhiteSpace(preset)) return; + var p = preset.Trim().ToUpperInvariant(); + if (p.EndsWith("K") && double.TryParse(p[..^1], out double k)) + { + TargetContextTokens = k * 1024; + } + else if (double.TryParse(p, out double num)) + { + TargetContextTokens = num; + } + } + + [RelayCommand] + public void SetKvPrecision(string? precision) + { + if (string.IsNullOrWhiteSpace(precision)) return; + SelectedKvPrecision = precision.Trim().ToUpperInvariant(); + RecalculateKvCache(); + } + + [RelayCommand] + public void UseModelWithContextInStudio() + { + var modelName = SelectedInstalledModel?.Name ?? InstalledModels.FirstOrDefault()?.Name ?? "llama3.2:latest"; + int tokens = (int)TargetContextTokens; + OnApplyModelContextRequested?.Invoke(modelName, tokens); + ToastService.Instance.Show($"Loaded '{modelName}' with {tokens:N0} tokens into Studio.", ToastType.Success); + } + + [RelayCommand] + public async Task CopyModelfileParameterAsync() + { + string text = $"PARAMETER num_ctx {(int)TargetContextTokens}"; + try + { + if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop && + desktop.MainWindow?.Clipboard != null) + { + await desktop.MainWindow.Clipboard.SetTextAsync(text); + } + } + catch { } + ToastService.Instance.Show($"Copied to clipboard: '{text}'", ToastType.Success); } private readonly System.Threading.SemaphoreSlim _loadLock = new(1, 1); @@ -179,7 +317,15 @@ public async Task LoadInstalledModelsAsync(string apiBase, HttpClient http) var badge = _canIRunItService.EvaluateQuickFit(m.Name, m.SizeBytes > 0 ? m.SizeBytes : null, "LLM", (long)TotalVramMb, (long)TotalRamMb); InstalledModels.Add(m with { FitBadge = badge }); } - ApplyFilter(); + if (SelectedInstalledModel == null && InstalledModels.Count > 0) + { + SelectModel(InstalledModels[0]); + } + else + { + ApplyFilter(); + RecalculateKvCache(); + } } finally { diff --git a/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml index 3ef51a6..2d0697e 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml +++ b/LocalLLMServerManager.Shared/Views/Controls/ActivityRailControl.axaml @@ -27,6 +27,7 @@ + + + + + + + + @@ -200,7 +237,7 @@ - + + + + + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml.cs index 3622253..fd8febe 100644 --- a/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml.cs +++ b/LocalLLMServerManager.Shared/Views/Controls/OllamaModelsTabControl.axaml.cs @@ -1,4 +1,6 @@ using Avalonia.Controls; +using Avalonia.Input; +using LocalLLMServerManager.Shared.ViewModels; namespace LocalLLMServerManager.Shared.Views.Controls; @@ -8,4 +10,12 @@ public OllamaModelsTabControl() { InitializeComponent(); } + + private void OnModelCardPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (sender is Control c && c.DataContext is OllamaModelItem item && DataContext is OllamaLibraryViewModel vm) + { + vm.SelectModel(item); + } + } } diff --git a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs index 4287a1c..41dbce0 100644 --- a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs +++ b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs @@ -798,6 +798,92 @@ public void MainView_PullProgressDrawer_RendersAndBindsToOllamaPullState() window.Close(); } + + [AvaloniaFact] + public void OllamaLibrary_ModelSelection_And_KvCacheCalculation() + { + var vm = new OllamaLibraryViewModel(new OllamaModelService()); + vm.UpdateHardwareTelemetry(16384, 32768); + + var model1 = new OllamaModelItem("llama3.2:3b", "2.0 GB", "LLM", "#3B82F6", false, null, 2_000_000_000L); + var model2 = new OllamaModelItem("llama3.1:70b-instruct-q4_k_m", "40.0 GB", "LLM", "#3B82F6", false, null, 42_000_000_000L); + vm.InstalledModels.Add(model1); + vm.InstalledModels.Add(model2); + + // Select model1 + vm.SelectModel(model1); + Assert.NotNull(vm.SelectedInstalledModel); + Assert.Equal("llama3.2:3b", vm.SelectedInstalledModel.Name); + Assert.True(vm.InstalledModels[0].IsSelected); + Assert.False(vm.InstalledModels[1].IsSelected); + + // Set context preset to 16K + vm.SetContextPreset("16K"); + Assert.Equal(16384, vm.TargetContextTokens); + Assert.Equal(FitVerdict.FullVram, vm.CalcFitVerdict); + Assert.True(vm.CalcVramUsageRatio > 0 && vm.CalcVramUsageRatio < 1.0); + + // Set KV precision to Q4_0 + vm.SetKvPrecision("Q4_0"); + Assert.True(vm.IsQ4Selected); + Assert.False(vm.IsFp16Selected); + Assert.NotNull(vm.CurrentFitResult); + + // Select 70B model - should trigger offload or OOM on 16GB VRAM + vm.SelectModel(model2); + Assert.Equal("llama3.1:70b-instruct-q4_k_m", vm.SelectedInstalledModel.Name); + Assert.NotEqual(FitVerdict.FullVram, vm.CalcFitVerdict); + } + + [AvaloniaFact] + public void OllamaLibrary_UseModelWithContextInStudio_WiresToMainViewModel() + { + var mainVm = new MainViewModel(); + mainVm.Ollama.UpdateHardwareTelemetry(16384, 32768); + + var model = new OllamaModelItem("qwen2.5:14b", "8.5 GB", "LLM", "#3B82F6", false, null, 9_000_000_000L); + mainVm.Ollama.InstalledModels.Add(model); + mainVm.Ollama.SelectModel(model); + mainVm.Ollama.SetContextPreset("32K"); + + // Execute UseModelWithContextInStudio + mainVm.Ollama.UseModelWithContextInStudio(); + + // Verify MainViewModel transitioned to Studio Text mode with configured context + Assert.Equal(1, mainVm.SelectedTabIndex); + Assert.Equal("Text", mainVm.SelectedStudioMode); + Assert.Equal(32768, mainVm.ConfiguredContextTokens); + Assert.Equal("qwen2.5:14b", mainVm.ActiveModelBadge); + } + + [AvaloniaFact] + public void OllamaModelsTabControl_RendersVisualTree_AndBindsInteractiveKvCacheSizer() + { + var vm = new OllamaLibraryViewModel(new OllamaModelService()); + var model = new OllamaModelItem("mistral:7b", "4.1 GB", "LLM", "#3B82F6", false, null, 4_400_000_000L); + vm.InstalledModels.Add(model); + vm.SelectModel(model); + + var view = new OllamaModelsTabControl { DataContext = vm }; + var window = new Window { Content = view, Width = 1024, Height = 768 }; + window.Show(); + + // 1. Verify Sizer Card & Title + var textBlocks = view.GetVisualDescendants().OfType().ToList(); + Assert.Contains(textBlocks, t => t.Text != null && t.Text.Contains("Interactive KV Cache & VRAM Sizer")); + + // 2. Verify Preset Buttons exist + var buttons = view.GetVisualDescendants().OfType