diff --git a/CHANGELOG.md b/CHANGELOG.md index ae12ceda2..a0f61a9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ All notable changes to Stability Matrix will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2.0.0.html). +## v2.16.5 +> Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.16.5](https://docs.lykos.ai/stability-matrix/release-notes/2.16.5) +### Added +- Added **Fizgig**, a LoRA training studio for Flux 2 Klein 9B, Krea 2, MiniMax H3 and Qwen Image 2.1 with dataset prep, training, LoRA repair and extraction in one app ([#1761](https://github.com/LykosAI/StabilityMatrix/pull/1761)) - thanks to @e-nord! +- Added AMD ROCm support for **Stable Diffusion WebUI Forge - Neo** on Windows and Linux. Forge Neo's developer supports NVIDIA only, so report AMD issues with it to Stability Matrix ([#1770](https://github.com/LykosAI/StabilityMatrix/pull/1770)) - thanks to @NeuralFault! +### Changed +- ComfyUI now installs and updates from its current GitHub home, [Comfy-Org/ComfyUI](https://github.com/Comfy-Org/ComfyUI) +### Fixed +- Fixed **Wan2GP** installs on Python 3.11 failing with `sageattention ... has no wheels with a matching Python version tag`. New Wan2GP installs now use Python 3.11 with PyTorch 2.10 and CUDA 13 with matching Triton, SageAttention and Flash Attention, the same setup Wan2GP's own installer uses. CUDA 13 needs NVIDIA driver 580 or newer. GTX 10XX cards stay on Python 3.10 +- Existing **Wan2GP** installs on Python 3.10 keep their PyTorch 2.7 setup when updated. If a newer model such as LTX 2.5 fails with a Triton error, add Wan2GP again as a new install, which now uses Python 3.11, and move your outputs across ([#1756](https://github.com/LykosAI/StabilityMatrix/issues/1756)) +- Fixed ComfyUI updates downgrading SageAttention post6 to post4 and reinstalling compatible wheels ([#1764](https://github.com/LykosAI/StabilityMatrix/issues/1764)). +- Fixed ComfyUI installation and updates failing on Python 3.13+ because the NumPy 1.x constraint has no compatible wheel ([#1754](https://github.com/LykosAI/StabilityMatrix/issues/1754)) +- Fixed **AI-Toolkit** installation failing on Linux while unpacking Node.js with a "Dangerous link path" error ([#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767)) +- Fixed **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision` ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! +- ControlNet preprocessing in Inference now offers to update older tagged Inference Core Nodes installs to a version with the ControlNet import fixes and Python 3.12/3.13 support +- Fixed the package uninstall confirmation counting your whole shared model library for packages using symlink sharing. Only the package's own files are counted; shared models were never deleted +- Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! +- Fixed shared model folder links hiding **DiffusionModels** in the Checkpoint Manager, Image Lab and Inference, including when DiffusionModels is itself a link ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743), [#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763)) - thanks to @NeuralFault and @ionite34! +- Fixed the Model Browser's **Base Model** filter being lost when using **Next Model** / **Previous Model** or refreshing a model's details ([#1436](https://github.com/LykosAI/StabilityMatrix/issues/1436)) +- Fixed **Open on CivArchive** opening a file URL instead of the model page on Linux ([#1765](https://github.com/LykosAI/StabilityMatrix/issues/1765)) +- Fixed emoji showing as `��` in Image Lab messages and in CivitAI and CivArchive model descriptions +- Fixed release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) +- Fixed text in **Image Lab** messages and thinking blocks being cut off at the right edge. Message bubbles now fit their text, and inline code is easier to read +- Fixed **Save & Regenerate** in Image Lab deleting later images and dropping the edited message's attachments. It now shows progress and can be cancelled +- Fixed **Regenerate** in Image Lab removing the old response before the new one was ready. A failed or cancelled regenerate now keeps it +- Fixed Image Lab pulling you back to a conversation when its generation finished while you were in another, and showing that generation's error in the one you had open +- Fixed deleting one image from a multi-image message in Image Lab deleting your original file when Image Lab hadn't kept its own copy +- Fixed your message in Image Lab not being editable or deletable after a failed or cancelled generation +- Fixed cancelling a Gemini generation in Image Lab showing "Generation failed" instead of "Cancelled" +- Fixed Image Lab not scrolling to a new message when you were already at the bottom of the conversation +- Fixed a ComfyUI generation in Image Lab not stopping when ComfyUI disconnected, if you had opened a Gemini conversation in the meantime +- Fixed package version lists and update checks showing an old release as the newest, because startup update checks ran into GitHub's rate limit +### Supporters +#### 🌟 Visionaries +This patch is a good one for AMD owners: Forge Neo now runs on ROCm on both Windows and Linux, and Windows ROCm installs always get a `torch` and `torchvision` that actually match. It also adds Fizgig, a whole LoRA training studio, to the package list. Most of it arrived as community contributions, and our Visionaries are the reason we have time to review that work properly and say yes to it. Thank you **Waterclouds**, **MrMxyzptlk12836**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us and **jweg79** moving up from the Pioneers, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 +#### 🚀 Pioneers +To our Pioneers **SinthCore**, **Jisuren**, **Hurbie53**, **Cjloha**, **Alligator1907**, **CommissarGiygas16050**, **Zombop**, **Silerae**, **joshsciascia72**, **cy_hart691842**, and **okachako**: thank you for backing a patch full of fixes that make things work on more machines. And hi **acleveranagram**, our newest Pioneer; we're really glad to have you! 💛 + ## v2.16.4 ### Added - Added **Comfy Kitchen Attention** (`--use-ck-attention`) as a **Cross Attention Method** launch option for ComfyUI and ComfyUI-Zluda - thanks to @e-nord! diff --git a/StabilityMatrix.Avalonia/Controls/MarkdownHtmlLabel.cs b/StabilityMatrix.Avalonia/Controls/MarkdownHtmlLabel.cs new file mode 100644 index 000000000..7d7944cf1 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/MarkdownHtmlLabel.cs @@ -0,0 +1,54 @@ +using System; +using Avalonia; +using TheArtOfDev.HtmlRenderer.Avalonia; + +namespace StabilityMatrix.Avalonia.Controls; + +/// +/// The HTML label behind . It fills the width it is offered, or with +/// takes only the width its text needs, wrapping at the width offered. +/// +public class MarkdownHtmlLabel : HtmlLabel +{ + public static readonly StyledProperty FitToContentProperty = AvaloniaProperty.Register< + MarkdownHtmlLabel, + bool + >(nameof(FitToContent)); + + static MarkdownHtmlLabel() + { + AffectsMeasure(FitToContentProperty); + } + + /// + /// Size the width to the text, up to the width offered, instead of filling it. + /// + public bool FitToContent + { + get => GetValue(FitToContentProperty); + set => SetValue(FitToContentProperty, value); + } + + protected override Size MeasureOverride(Size constraint) + { + if (!FitToContent || double.IsPositiveInfinity(constraint.Width)) + return base.MeasureOverride(constraint); + + var horizontal = Padding.Left + Padding.Right + BorderThickness.Left + BorderThickness.Right; + var vertical = Padding.Top + Padding.Bottom + BorderThickness.Top + BorderThickness.Bottom; + var available = Math.Max(constraint.Width - horizontal, 0); + + // Lay out at the text's natural width, and wrap at the offered width only if it is wider + _htmlContainer.MaxSize = default; + _htmlContainer.PerformLayout(); + if (_htmlContainer.ActualSize.Width > available) + { + _htmlContainer.MaxSize = new Size(available, 0); + _htmlContainer.PerformLayout(); + } + + // Rounded up so layout rounding can't hand back less than the text was laid out in + var size = _htmlContainer.ActualSize; + return new Size(Math.Ceiling(size.Width) + horizontal, Math.Ceiling(size.Height) + vertical); + } +} diff --git a/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml b/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml index d8ad2b545..6a10c20ff 100644 --- a/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml +++ b/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml @@ -1,7 +1,6 @@  - + FitToContent="{TemplateBinding FitToContent}" /> diff --git a/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs b/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs index 9f8797816..c3efc061d 100644 --- a/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs +++ b/StabilityMatrix.Avalonia/Controls/MarkdownViewer.axaml.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Text; using Avalonia; using Avalonia.Controls.Primitives; using Markdig; @@ -6,21 +7,30 @@ namespace StabilityMatrix.Avalonia.Controls; +/// +/// Renders Markdown, or HTML set through the Html property, with the app's markdown stylesheet. +/// public class MarkdownViewer : TemplatedControlBase { + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .UseEmojiAndSmiley() + .Build(); + + private HtmlLabel? htmlLabel; + public static readonly StyledProperty TextProperty = AvaloniaProperty.Register< MarkdownViewer, string >(nameof(Text)); + /// + /// Markdown to render. + /// public string Text { get => GetValue(TextProperty); - set - { - SetValue(TextProperty, value); - ParseText(value); - } + set => SetValue(TextProperty, value); } public static readonly StyledProperty HtmlProperty = AvaloniaProperty.Register< @@ -34,25 +44,63 @@ private string Html set => SetValue(HtmlProperty, value); } + public static readonly StyledProperty FitToContentProperty = + MarkdownHtmlLabel.FitToContentProperty.AddOwner(); + + /// + /// Size the width to the text, up to the width offered, instead of filling it. For chat + /// bubbles; leave it off where the content should span its container. + /// + public bool FitToContent + { + get => GetValue(FitToContentProperty); + set => SetValue(FitToContentProperty, value); + } + public static readonly StyledProperty CustomCssProperty = AvaloniaProperty.Register< MarkdownViewer, string >(nameof(CustomCss)); + /// + /// CSS appended to the markdown stylesheet when the template is applied. + /// public string CustomCss { get => GetValue(CustomCssProperty); set => SetValue(CustomCssProperty, value); } + /// + /// Encodes every character outside the Basic Multilingual Plane (emoji, mostly) as a numeric + /// character reference. HtmlRenderer splits words at each UTF-16 code unit in U+4E00..U+FA2D, + /// a range that covers the surrogates, so a raw emoji is drawn as two U+FFFD halves; a + /// reference is decoded after that split and keeps the pair together. + /// + internal static string? EncodeAstralCharacters(string? html) + { + if (string.IsNullOrEmpty(html) || html.AsSpan().IndexOfAnyInRange('\uD800', '\uDFFF') < 0) + return html; + + var builder = new StringBuilder(html.Length + 16); + foreach (var rune in html.EnumerateRunes()) + { + if (rune.IsBmp) + builder.Append((char)rune.Value); + else + builder.Append("&#x").Append(rune.Value.ToString("X")).Append(';'); + } + + return builder.ToString(); + } + private void ParseText(string value) { if (string.IsNullOrWhiteSpace(value)) return; - var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().UseEmojiAndSmiley().Build(); var html = - $"""{Markdig.Markdown.ToHtml(value, pipeline)}"""; + $"""{Markdig.Markdown.ToHtml(value, Pipeline)}"""; Html = html; } @@ -60,19 +108,24 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - if (e.NameScope.Find("PART_HtmlPanel") is not HtmlPanel htmlPanel) + htmlLabel = e.NameScope.Find("PART_HtmlLabel") as HtmlLabel; + if (htmlLabel is null) return; using var cssFile = Assets.MarkdownCss.Open(); using var reader = new StreamReader(cssFile); var css = reader.ReadToEnd(); - htmlPanel.BaseStylesheet = $"{css}\n{CustomCss}"; + htmlLabel.BaseStylesheet = $"{css}\n{CustomCss}"; if (string.IsNullOrWhiteSpace(Html) && !string.IsNullOrWhiteSpace(Text)) { ParseText(Text); } + else + { + htmlLabel.Text = EncodeAstralCharacters(Html); + } } protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) @@ -81,6 +134,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { ParseText(change.NewValue.ToString()); } + else if (change.Property == HtmlProperty && htmlLabel is not null) + { + htmlLabel.Text = EncodeAstralCharacters(change.NewValue as string); + } base.OnPropertyChanged(change); } diff --git a/StabilityMatrix.Avalonia/Controls/ReleaseNotesMarkdownViewer.cs b/StabilityMatrix.Avalonia/Controls/ReleaseNotesMarkdownViewer.cs new file mode 100644 index 000000000..71d36e2c3 --- /dev/null +++ b/StabilityMatrix.Avalonia/Controls/ReleaseNotesMarkdownViewer.cs @@ -0,0 +1,24 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.VisualTree; + +namespace StabilityMatrix.Avalonia.Controls; + +/// +/// Displays release notes with space between the document and the vertical scrollbar. +/// +public class ReleaseNotesMarkdownViewer : BetterMarkdownScrollViewer +{ + public ReleaseNotesMarkdownViewer() + { + if ( + this.GetVisualChildren().OfType().FirstOrDefault() is + { Content: Control content } viewer + ) + { + viewer.Content = null; + viewer.Content = new Border { Child = content, Padding = new Thickness(0, 0, 18, 0) }; + } + } +} diff --git a/StabilityMatrix.Avalonia/DialogHelper.cs b/StabilityMatrix.Avalonia/DialogHelper.cs index 22c581a30..2119a4c5b 100644 --- a/StabilityMatrix.Avalonia/DialogHelper.cs +++ b/StabilityMatrix.Avalonia/DialogHelper.cs @@ -696,25 +696,14 @@ private static (int FileCount, long TotalBytes) GetFileStats(DirectoryInfo direc var fileCount = 0; var totalBytes = 0L; - foreach (var file in directory.EnumerateFiles()) + foreach ( + var file in directory.EnumerateFiles("*", EnumerationOptionConstants.AllDirectoriesExceptLinks) + ) { - if (file.Attributes.HasFlag(FileAttributes.ReparsePoint)) - continue; - fileCount++; totalBytes += file.Length; } - foreach (var subDir in directory.EnumerateDirectories()) - { - if (subDir.Attributes.HasFlag(FileAttributes.ReparsePoint)) - continue; - - var (subCount, subBytes) = GetFileStats(subDir); - fileCount += subCount; - totalBytes += subBytes; - } - return (fileCount, totalBytes); } } diff --git a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs index 5d1250493..701753292 100644 --- a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs @@ -560,7 +560,7 @@ public async Task InstallNodeIfNecessary(IProgress? progress = n ); // unzip - await ArchiveHelper.Extract7ZAuto(nodeDownloadPath, AssetsDir); + await TarGZipExtractor.ExtractAsync(nodeDownloadPath, AssetsDir); var nodeDir = Compat.IsMacOS ? AssetsDir.JoinDir("node-v20.19.3-darwin-arm64") diff --git a/StabilityMatrix.Avalonia/Models/BananaVision/Messages.cs b/StabilityMatrix.Avalonia/Models/BananaVision/Messages.cs index 00770aac2..7c713ff5b 100644 --- a/StabilityMatrix.Avalonia/Models/BananaVision/Messages.cs +++ b/StabilityMatrix.Avalonia/Models/BananaVision/Messages.cs @@ -69,7 +69,9 @@ public ThinkingMessage(string thinkingContent) /// Preview of the thinking content (first ~100 chars) /// public string ThinkingPreview => - ThinkingContent.Length > 100 ? ThinkingContent[..100] + "..." : ThinkingContent; + ThinkingContent.Length > 100 + ? ThinkingContent[..(char.IsHighSurrogate(ThinkingContent[99]) ? 99 : 100)] + "..." + : ThinkingContent; /// /// Whether the thinking section is expanded diff --git a/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs index a3f610c19..1140461ea 100644 --- a/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/BananaVisionPageViewModel.cs @@ -131,6 +131,7 @@ public string GenerationProgressText [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsCurrentConversationGenerating))] + [NotifyPropertyChangedFor(nameof(CanRetryLastMessage))] public partial ImageGenerationConversation? CurrentConversation { get; set; } partial void OnCurrentConversationChanged( @@ -138,10 +139,10 @@ partial void OnCurrentConversationChanged( ImageGenerationConversation? newValue ) { - // Cancel any pending message load from the previous conversation - loadMessagesCts?.Cancel(); - loadMessagesCts?.Dispose(); - loadMessagesCts = null; + // Rebuilding the conversation list passes through here; the conversation on screen + // stays the same (see LoadConversationsAsync). + if (isRefreshingConversations) + return; if (newValue != null) { @@ -160,12 +161,7 @@ partial void OnCurrentConversationChanged( SelectedProviderId = newValue.ProviderId; } - // Create new cancellation token for this load operation - loadMessagesCts = new(); - var token = loadMessagesCts.Token; - - // Load messages for the new conversation (fire and forget with error handling) - LoadMessagesForConversationAsync(newValue, token) + LoadMessagesAsync(newValue) .SafeFireAndForget(ex => { logger.LogError( @@ -177,11 +173,29 @@ partial void OnCurrentConversationChanged( } else { + CancelMessageLoad(); logger.LogWarning("Current conversation set to null"); ClearMessages(); } } + private void CancelMessageLoad() + { + loadMessagesCts?.Cancel(); + loadMessagesCts?.Dispose(); + loadMessagesCts = null; + } + + /// + /// Loads a conversation's messages into the page, cancelling any load still in flight. + /// + private Task LoadMessagesAsync(ImageGenerationConversation conversation) + { + CancelMessageLoad(); + loadMessagesCts = new CancellationTokenSource(); + return LoadMessagesForConversationAsync(conversation, loadMessagesCts.Token); + } + /// /// Loads messages for a conversation without changing CurrentConversation /// @@ -261,8 +275,18 @@ await Dispatcher.UIThread.InvokeAsync(() => [ObservableProperty] public partial string? ProviderStatusMessage { get; set; } + /// + /// The conversation whose last generation failed or was cancelled. Its Retry banner shows + /// only while that conversation is on screen. + /// [ObservableProperty] - public partial bool CanRetryLastMessage { get; set; } + [NotifyPropertyChangedFor(nameof(CanRetryLastMessage))] + public partial Guid? FailedConversationId { get; set; } + + /// + /// Whether the conversation on screen is the one whose generation failed. + /// + public bool CanRetryLastMessage => FailedConversationId is { } id && id == CurrentConversation?.Id; /// /// Whether we can regenerate the last assistant response (true when there's at least one assistant message) @@ -469,8 +493,15 @@ partial void OnIsWaitingForConnectionChanged(bool value) public bool IsComfyRunning => RunningPackage?.BasePackage is ComfyUI; - private string? lastMessageText; - private List? lastMessageImagePaths; + private bool isRefreshingConversations; + + /// + /// Provider of the generation in progress. The selected provider can change mid-run when the + /// user opens a conversation that last used another one. + /// + private string? generatingProviderId; + + private bool IsGeneratingLocally => BananaVisionProviderIds.IsLocalProvider(generatingProviderId); private IDisposable? startupCompleteSubscription; private bool hasShownMissingModelsDialog; private CancellationTokenSource? loadMessagesCts; @@ -567,7 +598,7 @@ INavigationService settingsNavigationService } // When disconnected during generation, cancel the pending operation - if (!ClientManager.IsConnected && IsGenerating && RequiresLocalBackend) + if (!ClientManager.IsConnected && IsGenerating && IsGeneratingLocally) { logger.LogWarning("ComfyUI disconnected during generation, cancelling..."); CancelGeneration(); @@ -707,13 +738,29 @@ private async Task LoadConversationsAsync() { var conversations = await chatService.GetConversationsAsync(); - // Update UI on the UI thread await Dispatcher.UIThread.InvokeAsync(() => { - Conversations.Clear(); - foreach (var conversation in conversations) + // Clearing the list makes the conversation ListBox drop its selection and then + // pick the first item, which would switch conversations. Keep the one on screen + // and only swap in its refreshed record. + var current = conversations.FirstOrDefault(c => c.Id == CurrentConversation?.Id); + isRefreshingConversations = current != null; + try + { + Conversations.Clear(); + foreach (var conversation in conversations) + { + Conversations.Add(conversation); + } + + if (current != null) + { + CurrentConversation = current; + } + } + finally { - Conversations.Add(conversation); + isRefreshingConversations = false; } }); } @@ -839,12 +886,8 @@ private async Task RenameConversationAsync(ImageGenerationConversation conversat conversation.Title = textBox.Text.Trim(); await chatService.UpdateConversationAsync(conversation); - // Refresh the list to update UI - var index = Conversations.IndexOf(conversation); - if (index >= 0) - { - Conversations[index] = conversation; - } + // The record isn't observable, so refresh the list to show the new title + await LoadConversationsAsync(); } } catch (Exception ex) @@ -975,46 +1018,66 @@ private async Task SendMessageAsync(CancellationToken cancellationToken) return; } + var conversationId = CurrentConversation.Id; + var providerId = SelectedProviderId; var messageText = NewMessageText; var imagePaths = PendingImages.Select(p => p.FilePath).ToList(); - // Store for retry - lastMessageText = messageText; - lastMessageImagePaths = imagePaths.Count > 0 ? imagePaths : null; - NewMessageText = string.Empty; - ErrorMessage = null; - CanRetryLastMessage = false; - // Add user message to UI immediately - var provisionalUiItems = new List(); + // Show the message right away; the reload when generation ends replaces it with the saved copy if (!string.IsNullOrWhiteSpace(messageText)) { - var uiText = new TextMessage(messageText, true); - provisionalUiItems.Add(uiText); - Messages.Add(uiText); + Messages.Add(new TextMessage(messageText, true)); } - // Show pending images in chat (provisional; will be replaced by persisted copies after DB save) foreach (var pendingImage in PendingImages) { - var uiImage = new ImageMessage(pendingImage.Bitmap, true); - provisionalUiItems.Add(uiImage); - Messages.Add(uiImage); + Messages.Add(new ImageMessage(pendingImage.Bitmap, true)); } - // Clear pending images PendingImages.Clear(); + await RunGenerationAsync( + conversationId, + providerId, + progress => + chatService.SendMessageAsync( + conversationId, + providerId, + messageText, + imagePaths.Count > 0 ? imagePaths : null, + BuildProviderOptions(), + progress, + cancellationToken + ) + ); + } + + /// + /// Runs one generation for a conversation: a placeholder and progress while it runs, then the + /// conversation's messages reloaded from the database if it is still on screen, so the page + /// shows what was saved. A failure or cancel puts the Retry banner on that conversation, + /// wherever the user is when it ends. + /// + private async Task RunGenerationAsync( + Guid conversationId, + string providerId, + Func?, Task> generate + ) + { + ErrorMessage = null; + FailedConversationId = null; + generatingProviderId = providerId; IsGenerating = true; ResetGenerationProgress(); - if (RequiresLocalBackend && !string.IsNullOrEmpty(SelectedProviderId)) + if (IsGeneratingLocally) { GenerationProgressStage = "Starting..."; } // Track which conversation is generating (for restoring placeholder on switch back) - GeneratingConversationId = CurrentConversation.Id; + GeneratingConversationId = conversationId; // Add loading placeholder (scaled to 1/3 of target size for compact display) currentLoadingMessage = new LoadingImageMessage @@ -1026,67 +1089,16 @@ private async Task SendMessageAsync(CancellationToken cancellationToken) try { - // Build provider options - var providerOptions = BuildProviderOptions(); - var progress = - RequiresLocalBackend && SelectedProviderId != null - ? CreateProgressReporter(SelectedProviderId) - : null; - - var (userMessage, assistantMessage) = await chatService.SendMessageAsync( - CurrentConversation.Id, - SelectedProviderId!, - messageText, - imagePaths.Count > 0 ? imagePaths : null, - providerOptions, - progress, - cancellationToken - ); - - // Remove loading placeholder - if (currentLoadingMessage != null) - { - Messages.Remove(currentLoadingMessage); - currentLoadingMessage = null; - } - - // Replace provisional user UI items with canonical DB-backed messages (with IDs and persisted image paths). - foreach (var item in provisionalUiItems) - { - Messages.Remove(item); - if (item is ImageMessage imageMessage) - { - imageMessage.Image?.Dispose(); - } - } - - AddUserMessageToUI(userMessage); - - // Add assistant response to UI - if (assistantMessage != null) - { - AddAssistantMessageToUI(assistantMessage); - } + var progress = IsGeneratingLocally ? CreateProgressReporter(providerId) : null; - // Reload conversations to update timestamps and titles - await LoadConversationsAsync(); - - // Update current conversation reference to reflect title changes - if (CurrentConversation != null) - { - var updatedConversation = Conversations.FirstOrDefault(c => c.Id == CurrentConversation.Id); - if (updatedConversation != null) - { - CurrentConversation = updatedConversation; - } - } + await generate(progress); } catch (OperationCanceledException) { // Check if cancellation was due to connection loss - if (RequiresLocalBackend && !ClientManager.IsConnected) + if (IsGeneratingLocally && !ClientManager.IsConnected) { - logger.LogWarning("Message generation cancelled due to connection loss"); + logger.LogWarning("Generation cancelled due to connection loss"); ErrorMessage = "Connection to ComfyUI was lost during generation."; notificationService.Show( "Connection Lost", @@ -1096,40 +1108,38 @@ private async Task SendMessageAsync(CancellationToken cancellationToken) } else { - logger.LogInformation("Message generation cancelled"); + logger.LogInformation("Generation cancelled"); ErrorMessage = "Cancelled"; } - CanRetryLastMessage = true; + FailedConversationId = conversationId; } catch (ImageGenerationException ex) { // Expected error from generation (provider error, API error, etc.) logger.LogWarning("Image generation failed: {Message}", ex.Message); + FailedConversationId = conversationId; - // Check if this is an API key error if (IsGeminiSetupError(ex)) { await ShowApiKeyRequiredDialogAsync(ex); - CanRetryLastMessage = true; } else { await ShowGenerationFailedAsync(ex); - CanRetryLastMessage = true; } } catch (Exception ex) { - // Unexpected error - logger.LogError(ex, "Unexpected error sending message"); + logger.LogError(ex, "Unexpected error during generation"); ErrorMessage = $"Unexpected error: {ex.Message}"; notificationService.Show("Error", ex.Message, NotificationType.Error); - CanRetryLastMessage = true; + FailedConversationId = conversationId; } finally { IsGenerating = false; GeneratingConversationId = null; + generatingProviderId = null; ResetGenerationProgress(); // Ensure loading placeholder is removed on cancel/error if (currentLoadingMessage != null) @@ -1138,6 +1148,14 @@ private async Task SendMessageAsync(CancellationToken cancellationToken) currentLoadingMessage = null; } } + + // Timestamps and titles change with every generation + await LoadConversationsAsync(); + + if (CurrentConversation?.Id == conversationId) + { + await LoadMessagesAsync(CurrentConversation); + } } /// @@ -1254,124 +1272,35 @@ private async Task RetryLastMessageAsync(CancellationToken cancellationToken) return; } - // Clear error state - ErrorMessage = null; - CanRetryLastMessage = false; - IsGenerating = true; - ResetGenerationProgress(); - if (RequiresLocalBackend && !string.IsNullOrEmpty(SelectedProviderId)) - { - GenerationProgressStage = "Starting..."; - } - - // Track which conversation is generating (for restoring placeholder on switch back) - GeneratingConversationId = CurrentConversation.Id; - - try - { - // Build provider options - var providerOptions = BuildProviderOptions(); - var progress = - RequiresLocalBackend && SelectedProviderId != null - ? CreateProgressReporter(SelectedProviderId) - : null; - - // Add loading placeholder (scaled to 1/3 of target size for compact display) - currentLoadingMessage = new LoadingImageMessage - { - TargetWidth = (SelectedAspectRatio?.Width ?? 300) / 3, - TargetHeight = (SelectedAspectRatio?.Height ?? 300) / 3, - }; - Messages.Add(currentLoadingMessage); - - // Retry generation - this doesn't create a new user message - var assistantMessage = await chatService.RetryGenerationAsync( - CurrentConversation.Id, - SelectedProviderId, - providerOptions, - progress, - cancellationToken - ); - - // Remove loading placeholder - if (currentLoadingMessage != null) - { - Messages.Remove(currentLoadingMessage); - currentLoadingMessage = null; - } - - // Add only the assistant response to UI - AddAssistantMessageToUI(assistantMessage, includeDbId: false); - - // Reload conversations to update timestamps - await LoadConversationsAsync(); - } - catch (OperationCanceledException) - { - // Check if cancellation was due to connection loss - if (RequiresLocalBackend && !ClientManager.IsConnected) - { - logger.LogWarning("Retry generation cancelled due to connection loss"); - ErrorMessage = "Connection to ComfyUI was lost during generation."; - notificationService.Show( - "Connection Lost", - "ComfyUI disconnected during generation", - NotificationType.Warning - ); - } - else - { - logger.LogInformation("Retry generation cancelled"); - ErrorMessage = "Cancelled"; - } - CanRetryLastMessage = true; - } - catch (ImageGenerationException ex) - { - logger.LogWarning("Retry generation failed: {Message}", ex.Message); - - // Check if this is an API key error - if (IsGeminiSetupError(ex)) - { - await ShowApiKeyRequiredDialogAsync(ex); - CanRetryLastMessage = true; - } - else - { - await ShowGenerationFailedAsync(ex); - CanRetryLastMessage = true; - } - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error during retry"); - ErrorMessage = $"Unexpected error: {ex.Message}"; - notificationService.Show("Error", ex.Message, NotificationType.Error); - CanRetryLastMessage = true; - } - finally - { - IsGenerating = false; - GeneratingConversationId = null; - ResetGenerationProgress(); - // Ensure loading placeholder is removed on cancel/error - if (currentLoadingMessage != null) - { - Messages.Remove(currentLoadingMessage); - currentLoadingMessage = null; - } - } + var conversationId = CurrentConversation.Id; + var providerId = SelectedProviderId; + + // Retry generation - this doesn't create a new user message + await RunGenerationAsync( + conversationId, + providerId, + progress => + chatService.RetryGenerationAsync( + conversationId, + providerId, + BuildProviderOptions(), + progress, + cancellationToken + ) + ); } [RelayCommand] private void DismissError() { ErrorMessage = null; - CanRetryLastMessage = false; + FailedConversationId = null; } /// - /// Regenerates the last assistant response (without an error context) + /// Regenerates the response to the last user message. The old response stays until the new + /// one is saved, so a failed or cancelled regenerate loses nothing; its image files stay on + /// disk for the output browser either way. /// [RelayCommand(IncludeCancelCommand = true)] private async Task RegenerateLastResponseAsync(CancellationToken cancellationToken) @@ -1385,173 +1314,35 @@ private async Task RegenerateLastResponseAsync(CancellationToken cancellationTok return; } - // Clear error state - ErrorMessage = null; - CanRetryLastMessage = false; - IsGenerating = true; - ResetGenerationProgress(); - if (RequiresLocalBackend && !string.IsNullOrEmpty(SelectedProviderId)) - { - GenerationProgressStage = "Starting..."; - } - - // Track which conversation is generating (for restoring placeholder on switch back) - GeneratingConversationId = CurrentConversation.Id; - - try - { - // Remove the last assistant message(s) from UI before regenerating - var messagesToRemove = new List(); - for (var i = Messages.Count - 1; i >= 0; i--) - { - var message = Messages[i]; - // Stop when we hit a user message - if (message is TextMessage tm && tm.IsMyMessage) - break; - if (message is ImageMessage im && im.IsMyMessage) - break; - - messagesToRemove.Add(message); - } - - // Remove in reverse order to avoid index issues - foreach (var message in messagesToRemove) - { - Messages.Remove(message); - // Dispose image if needed - if (message is ImageMessage imageMessage) - { - imageMessage.Image?.Dispose(); - } - } + var conversationId = CurrentConversation.Id; + var providerId = SelectedProviderId; - // Delete old assistant messages from database (but preserve their image files) - var dbMessages = await chatService.GetMessagesAsync(CurrentConversation.Id); - var lastUserMessage = dbMessages.LastOrDefault(m => m.Role == MessageRole.User); - if (lastUserMessage != null) - { - // Find all assistant messages after the last user message - var oldAssistantMessages = dbMessages - .Where(m => m.Role == MessageRole.Assistant && m.Timestamp > lastUserMessage.Timestamp) - .ToList(); + var dbMessages = await chatService.GetMessagesAsync(conversationId); + var lastUserIndex = dbMessages.FindLastIndex(m => m.Role == MessageRole.User); + var oldResponses = dbMessages + .Skip(lastUserIndex + 1) + .Where(m => m.Role == MessageRole.Assistant) + .ToList(); - // Delete them from database but preserve image files for the output browser - foreach (var oldMsg in oldAssistantMessages) - { - await chatService.DeleteMessageAsync(oldMsg.Id, preserveImageFile: true); - } + await RunGenerationAsync( + conversationId, + providerId, + async progress => + { + await chatService.RetryGenerationAsync( + conversationId, + providerId, + BuildProviderOptions(), + progress, + cancellationToken + ); - if (oldAssistantMessages.Count > 0) + foreach (var oldResponse in oldResponses) { - logger.LogInformation( - "Removed {Count} old assistant message(s) from database, preserved image files", - oldAssistantMessages.Count - ); + await chatService.DeleteMessageAsync(oldResponse.Id, preserveImageFile: true); } } - - // Build provider options - var providerOptions = BuildProviderOptions(); - var progress = - RequiresLocalBackend && SelectedProviderId != null - ? CreateProgressReporter(SelectedProviderId) - : null; - - // Add loading placeholder (scaled to 1/3 of target size for compact display) - currentLoadingMessage = new LoadingImageMessage - { - TargetWidth = (SelectedAspectRatio?.Width ?? 300) / 3, - TargetHeight = (SelectedAspectRatio?.Height ?? 300) / 3, - }; - Messages.Add(currentLoadingMessage); - - // Retry generation - this doesn't create a new user message - var assistantMessage = await chatService.RetryGenerationAsync( - CurrentConversation.Id, - SelectedProviderId, - providerOptions, - progress, - cancellationToken - ); - - // Remove loading placeholder - if (currentLoadingMessage != null) - { - Messages.Remove(currentLoadingMessage); - currentLoadingMessage = null; - } - - // Add the new assistant response to UI - var addedAnyImages = AddAssistantMessageToUI(assistantMessage, includeDbId: false); - - if (addedAnyImages) - { - // Notify gallery - OnPropertyChanged(nameof(ConversationImages)); - OnPropertyChanged(nameof(HasConversationImages)); - } - - // Reload conversations to update timestamps - await LoadConversationsAsync(); - - // Notify property change - OnPropertyChanged(nameof(CanRegenerateLastResponse)); - } - catch (OperationCanceledException) - { - // Check if cancellation was due to connection loss - if (RequiresLocalBackend && !ClientManager.IsConnected) - { - logger.LogWarning("Regenerate cancelled due to connection loss"); - ErrorMessage = "Connection to ComfyUI was lost during generation."; - notificationService.Show( - "Connection Lost", - "ComfyUI disconnected during generation", - NotificationType.Warning - ); - } - else - { - logger.LogInformation("Regenerate cancelled"); - ErrorMessage = "Cancelled"; - } - CanRetryLastMessage = true; - } - catch (ImageGenerationException ex) - { - logger.LogWarning("Regenerate failed: {Message}", ex.Message); - - // Check if this is an API key error - if (IsGeminiSetupError(ex)) - { - await ShowApiKeyRequiredDialogAsync(ex); - CanRetryLastMessage = true; - } - else - { - await ShowGenerationFailedAsync(ex); - CanRetryLastMessage = true; - } - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error during regenerate"); - ErrorMessage = $"Unexpected error: {ex.Message}"; - notificationService.Show("Error", ex.Message, NotificationType.Error); - CanRetryLastMessage = true; - } - finally - { - IsGenerating = false; - GeneratingConversationId = null; - ResetGenerationProgress(); - // Ensure loading placeholder is removed on cancel/error - if (currentLoadingMessage != null) - { - Messages.Remove(currentLoadingMessage); - currentLoadingMessage = null; - } - } + ); } /// @@ -1652,15 +1443,30 @@ private async Task EditMessageOnlyAsync( string newText ) { - // Update in database + if (!await UpdateMessageTextAsync(uiMessage, dbMessage, newText)) + return; + + logger.LogInformation("Updated message {MessageId} text without regeneration", dbMessage.Id); + notificationService.Show("Message Updated", "Your message has been saved.", NotificationType.Success); + } + + /// + /// Saves the edited text to the database and swaps the UI message for one showing it + /// (TextMessage.Text is read-only). + /// + private async Task UpdateMessageTextAsync( + TextMessage uiMessage, + ImageGenerationMessage dbMessage, + string newText + ) + { var updatedMessage = await chatService.UpdateMessageTextAsync(dbMessage.Id, newText); if (updatedMessage == null) { notificationService.Show("Error", "Failed to update message", NotificationType.Error); - return; + return false; } - // Replace the UI message (TextMessage.Text is read-only) var index = Messages.IndexOf(uiMessage); if (index >= 0) { @@ -1670,12 +1476,13 @@ string newText }; } - logger.LogInformation("Updated message {MessageId} text without regeneration", dbMessage.Id); - notificationService.Show("Message Updated", "Your message has been saved.", NotificationType.Success); + return true; } /// - /// Edits a message and regenerates the conversation from that point + /// Edits a message, drops every message after it, and regenerates the response to it. + /// The edited message keeps its attached images, and image files from the dropped + /// messages stay on disk for the output browser, as with Regenerate. /// private async Task EditAndRegenerateAsync( TextMessage uiMessage, @@ -1684,24 +1491,20 @@ private async Task EditAndRegenerateAsync( string editedText ) { - // Delete all UI messages from this point onward - var firstUiIndexToDelete = -1; + if (!await UpdateMessageTextAsync(uiMessage, dbMessage, editedText)) + return; + + // The edited message can span several UI items (its text and each attached image) + var lastEditedUiIndex = -1; for (var i = 0; i < Messages.Count; i++) { if (GetDatabaseMessageId(Messages[i]) == dbMessage.Id) { - firstUiIndexToDelete = i; - break; + lastEditedUiIndex = i; } } - if (firstUiIndexToDelete < 0) - { - firstUiIndexToDelete = Messages.IndexOf(uiMessage); - } - - var messagesToRemove = Messages.Skip(firstUiIndexToDelete).ToList(); - foreach (var msg in messagesToRemove) + foreach (var msg in Messages.Skip(lastEditedUiIndex + 1).ToList()) { Messages.Remove(msg); if (msg is ImageMessage im) @@ -1710,81 +1513,15 @@ string editedText } } - // Delete all database messages from this point onward - var messagesToDelete = allDbMessages - .Where(m => m.Timestamp >= dbMessage.Timestamp) - .OrderBy(m => m.Timestamp) - .ToList(); - + var messagesToDelete = allDbMessages.Where(m => m.Timestamp > dbMessage.Timestamp).ToList(); foreach (var msg in messagesToDelete) { - await chatService.DeleteMessageAsync(msg.Id); + await chatService.DeleteMessageAsync(msg.Id, preserveImageFile: true); } - // Now send the edited message - IsGenerating = true; - ErrorMessage = null; - - try - { - // Add edited user message to UI - Messages.Add(new TextMessage(editedText, true)); - - // Build provider options - var providerOptions = BuildProviderOptions(); - - // Send the edited message - var (userMessage, assistantMessage) = await chatService.SendMessageAsync( - CurrentConversation!.Id, - SelectedProviderId!, - editedText, - null, - providerOptions, - progress: null, - CancellationToken.None - ); - - // Add assistant response to UI - if (assistantMessage != null) - { - AddAssistantMessageToUI(assistantMessage); - } - - // Reload conversations to update timestamps - await LoadConversationsAsync(); - - notificationService.Show( - "Message Edited", - "Your message has been edited and the conversation regenerated.", - NotificationType.Success - ); - } - catch (ImageGenerationException ex) - { - logger.LogWarning("Failed to regenerate after edit: {Message}", ex.Message); - - if (IsGeminiSetupError(ex)) - { - await ShowApiKeyRequiredDialogAsync(ex); - CanRetryLastMessage = true; - } - else - { - await ShowGenerationFailedAsync(ex); - CanRetryLastMessage = true; - } - } - catch (Exception ex) - { - logger.LogError(ex, "Unexpected error regenerating after edit"); - ErrorMessage = $"Unexpected error: {ex.Message}"; - notificationService.Show("Error", ex.Message, NotificationType.Error); - CanRetryLastMessage = true; - } - finally - { - IsGenerating = false; - } + // The edited message is now the last user message, so Regenerate answers it, with the + // same progress, placeholder and Cancel button as any other generation. + await RegenerateLastResponseCommand.ExecuteAsync(null); } /// @@ -2412,13 +2149,10 @@ private static List GetMessageImagePaths(ImageGenerationMessage message) /// /// Adds a message (user or assistant) to the Messages collection /// - /// The message to add - /// Whether to include the database message ID for tracking - /// True if any images were added - private bool AddMessageToUI(ImageGenerationMessage message, bool includeDbId = true) + private void AddMessageToUI(ImageGenerationMessage message) { var isUserMessage = message.Role == MessageRole.User; - var dbId = includeDbId ? message.Id : (Guid?)null; + var dbId = message.Id; // Show thinking content first (only for assistant messages) if (!isUserMessage && ShowThinkingOutput && !string.IsNullOrEmpty(message.ThinkingContent)) @@ -2431,33 +2165,13 @@ private bool AddMessageToUI(ImageGenerationMessage message, bool includeDbId = t Messages.Add(new TextMessage(message.TextContent, isUserMessage) { DatabaseMessageId = dbId }); } - var addedAnyImages = false; foreach (var imagePath in GetMessageImagePaths(message).Where(File.Exists)) { var bitmap = new Bitmap(imagePath); Messages.Add( new ImageMessage(bitmap, isUserMessage) { DatabaseMessageId = dbId, FilePath = imagePath } ); - addedAnyImages = true; } - - return addedAnyImages; - } - - /// - /// Adds an assistant message (thinking, text, and images) to the Messages collection - /// - private bool AddAssistantMessageToUI(ImageGenerationMessage message, bool includeDbId = true) - { - return AddMessageToUI(message, includeDbId); - } - - /// - /// Adds a user message (text and images) to the Messages collection - /// - private void AddUserMessageToUI(ImageGenerationMessage message) - { - AddMessageToUI(message, includeDbId: true); } /// @@ -2628,9 +2342,7 @@ protected override void Dispose(bool disposing) if (disposing) { // Cancel and dispose any pending message load - loadMessagesCts?.Cancel(); - loadMessagesCts?.Dispose(); - loadMessagesCts = null; + CancelMessageLoad(); // Dispose startup subscription startupCompleteSubscription?.Dispose(); diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs index a9218efca..557dcfc1c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; using System.Reactive.Linq; @@ -616,7 +616,7 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc if (doesBaseModelTypeMatch && doesModelTypeMatch) { - UpdateModelCards(models, isInfiniteScroll); + UpdateModelCards(models, request.BaseModels, isInfiniteScroll); } } @@ -681,11 +681,16 @@ await DialogHelper.CreateApiExceptionDialog(e).ShowAsync() } } + private string[] resultBaseModels = []; + /// /// Updates model cards using api response object. /// - private void UpdateModelCards(List? models, bool addCards = false) + private void UpdateModelCards(List? models, string[]? baseModels, bool addCards = false) { + // Keep the filter that produced these results, including searches that bypass it. + resultBaseModels = baseModels?.ToArray() ?? []; + if (models is null) { modelCache.Clear(); @@ -863,7 +868,7 @@ private async Task SearchModels(bool isInfiniteScroll = false) elapsed.TotalSeconds ); NextPageCursor = cachedQuery.Metadata?.NextCursor; - UpdateModelCards(cachedQuery.Items, isInfiniteScroll); + UpdateModelCards(cachedQuery.Items, modelRequest.BaseModels, isInfiniteScroll); // Start remote query (background mode) // Skip when last query was less than 2 min ago @@ -973,6 +978,7 @@ private async Task ShowVersionDialog(CivitModel model) vm.ModelIdList = allModelIds; vm.CurrentIndex = index; + vm.BaseModelFilter = resultBaseModels.ToArray(); vm.CivitModel = model; return vm; }); diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs index 1637791a3..a9eb10331 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Globalization; @@ -75,6 +75,11 @@ IPackageFactory packageFactory [ObservableProperty] public required partial List ModelIdList { get; set; } + /// + /// Base models from the originating search. Empty for unfiltered or direct model navigation. + /// + public IReadOnlyList BaseModelFilter { get; set; } = []; + [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanGoNext), nameof(CanGoPrevious))] public required partial int CurrentIndex { get; set; } @@ -995,6 +1000,12 @@ private bool ShouldIncludeVersion(ModelVersionViewModel? versionVm) var version = versionVm.ModelVersion; + if ( + BaseModelFilter.Count > 0 + && !BaseModelFilter.Contains(version.BaseModel, StringComparer.OrdinalIgnoreCase) + ) + return false; + if (HideInstalledModels && versionVm.IsInstalled) return false; diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PackageImportViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PackageImportViewModel.cs index 61319dd19..fca22702a 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/PackageImportViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/PackageImportViewModel.cs @@ -361,8 +361,11 @@ await gitPackage.SetupVenv( var candidates = AvailablePackages .Where(p => - GetGitHubSlug(p.GithubUrl) is { } slug - && string.Equals(slug, remoteSlug, StringComparison.OrdinalIgnoreCase) + p.FormerGithubUrls.Prepend(p.GithubUrl) + .Any(url => + GetGitHubSlug(url) is { } slug + && string.Equals(slug, remoteSlug, StringComparison.OrdinalIgnoreCase) + ) ) .ToList(); diff --git a/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml b/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml index 7651684a0..5dd55d2e8 100644 --- a/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml +++ b/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml @@ -1339,7 +1339,10 @@ - + - + diff --git a/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml.cs b/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml.cs index 3fcb49ba8..0caa3b5b1 100644 --- a/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml.cs +++ b/StabilityMatrix.Avalonia/Views/BananaVisionPage.axaml.cs @@ -138,8 +138,9 @@ private void OnScrollToEndRequested(object? sender, EventArgs e) if (messageScrollViewer == null) return; - isNearBottom = IsScrollNearBottom(messageScrollViewer); - + // Use where the user was before the new message grew the content: a large image or the + // loading placeholder is taller than the threshold, so measuring now would always say + // they had scrolled up. if (isNearBottom) { messageScrollViewer.ScrollToEnd(); @@ -175,6 +176,10 @@ private void OnMessageScrollChanged(object? sender, ScrollChangedEventArgs e) if (messageScrollViewer == null) return; + // Content growing under a still viewport isn't the user scrolling away + if (e.OffsetDelta.Y == 0 && e.ExtentDelta.Y > 0) + return; + isNearBottom = IsScrollNearBottom(messageScrollViewer); if (isNearBottom) diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml index 88aa3a8be..bf5259976 100644 --- a/StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml +++ b/StabilityMatrix.Avalonia/Views/Dialogs/UpdateDialog.axaml @@ -84,7 +84,7 @@ Margin="32,16" Background="{DynamicResource ButtonBackgroundPressed}" CornerRadius="8"> - + diff --git a/StabilityMatrix.Core/Api/CivArchiveApiClient.cs b/StabilityMatrix.Core/Api/CivArchiveApiClient.cs index b09f4ddb1..63f2ea4e8 100644 --- a/StabilityMatrix.Core/Api/CivArchiveApiClient.cs +++ b/StabilityMatrix.Core/Api/CivArchiveApiClient.cs @@ -282,9 +282,7 @@ public Uri GetAbsoluteUri(string relativeUrl) return BaseUri; } - return Uri.TryCreate(relativeUrl, UriKind.Absolute, out var absolute) - ? absolute - : new Uri(BaseUri, relativeUrl); + return new Uri(BaseUri, relativeUrl); } public static string BuildSearchDataPath(string routePath, CivArchiveSearchFilters filters) diff --git a/StabilityMatrix.Core/Helper/Cache/GithubApiCache.cs b/StabilityMatrix.Core/Helper/Cache/GithubApiCache.cs index 452be470d..35a94ff7b 100644 --- a/StabilityMatrix.Core/Helper/Cache/GithubApiCache.cs +++ b/StabilityMatrix.Core/Helper/Cache/GithubApiCache.cs @@ -1,4 +1,5 @@ -using Injectio.Attributes; +using System.Collections.Concurrent; +using Injectio.Attributes; using Microsoft.Extensions.Logging; using Octokit; using StabilityMatrix.Core.Database; @@ -25,6 +26,12 @@ ILogger logger /// private readonly object rateLimitLock = new(); + /// + /// GitHub requests in flight, by cache key. Callers that miss the cache while a request for the + /// same key is running await that request instead of sending their own. + /// + private readonly ConcurrentDictionary> inFlightRequests = new(); + /// /// Checks if we're currently rate limited and should skip API calls /// @@ -98,8 +105,17 @@ public async Task> GetAllReleases(string username, string r try { - var allReleases = await githubApi - .Repository.Release.GetAll(username, repository) + // Newest page only: paging the whole history costs several unauthenticated + // requests per repo and exhausts the 60/hour rate limit during startup update checks + var allReleases = await RequestOnce( + cacheKey, + () => + githubApi.Repository.Release.GetAll( + username, + repository, + new ApiOptions { PageSize = 100, PageCount = 1 } + ) + ) .ConfigureAwait(false); if (allReleases == null) { @@ -151,8 +167,10 @@ public async Task> GetAllBranches(string username, string re try { - var branches = await githubApi - .Repository.Branch.GetAll(username, repository) + var branches = await RequestOnce( + cacheKey, + () => githubApi.Repository.Branch.GetAll(username, repository) + ) .ConfigureAwait(false); if (branches == null) { @@ -206,17 +224,20 @@ public async Task> GetAllBranches(string username, string re try { - var commits = await githubApi - .Repository.Commit.GetAll( - username, - repository, - new CommitRequest { Sha = branch }, - new ApiOptions - { - PageCount = page, - PageSize = perPage, - StartPage = page, - } + var commits = await RequestOnce( + cacheKey, + () => + githubApi.Repository.Commit.GetAll( + username, + repository, + new CommitRequest { Sha = branch }, + new ApiOptions + { + PageCount = page, + PageSize = perPage, + StartPage = page, + } + ) ) .ConfigureAwait(false); @@ -246,6 +267,23 @@ public async Task> GetAllBranches(string username, string re } } + /// + /// Sends unless one for is already in flight, + /// in which case this awaits that one. Its result or exception reaches every caller. + /// + private async Task RequestOnce(string cacheKey, Func> request) + { + var inFlight = inFlightRequests.GetOrAdd(cacheKey, _ => new Lazy(request)); + try + { + return await ((Task)inFlight.Value).ConfigureAwait(false); + } + finally + { + inFlightRequests.TryRemove(KeyValuePair.Create(cacheKey, inFlight)); + } + } + private bool IsCacheExpired(DateTimeOffset expiration) => expiration.Add(cacheDuration) < DateTimeOffset.UtcNow; } diff --git a/StabilityMatrix.Core/Helper/EnumerationOptionConstants.cs b/StabilityMatrix.Core/Helper/EnumerationOptionConstants.cs index 1c2c2c889..7308d8992 100644 --- a/StabilityMatrix.Core/Helper/EnumerationOptionConstants.cs +++ b/StabilityMatrix.Core/Helper/EnumerationOptionConstants.cs @@ -2,9 +2,26 @@ public static class EnumerationOptionConstants { - public static readonly EnumerationOptions TopLevelOnly = - new() { RecurseSubdirectories = false, IgnoreInaccessible = true }; + public static readonly EnumerationOptions TopLevelOnly = new() + { + RecurseSubdirectories = false, + IgnoreInaccessible = true, + }; - public static readonly EnumerationOptions AllDirectories = - new() { RecurseSubdirectories = true, IgnoreInaccessible = true }; + public static readonly EnumerationOptions AllDirectories = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + }; + + /// + /// Recurses into subdirectories but never through a symbolic link or junction, at any depth, + /// and includes hidden and system files. Use it to measure what a recursive delete removes. + /// + public static readonly EnumerationOptions AllDirectoriesExceptLinks = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; } diff --git a/StabilityMatrix.Core/Helper/TarGZipExtractor.cs b/StabilityMatrix.Core/Helper/TarGZipExtractor.cs new file mode 100644 index 000000000..e3f3ee497 --- /dev/null +++ b/StabilityMatrix.Core/Helper/TarGZipExtractor.cs @@ -0,0 +1,113 @@ +using System.Formats.Tar; +using System.IO.Compression; + +namespace StabilityMatrix.Core.Helper; + +/// +/// Extracts gzip-compressed tar archives containing directories, regular files, and relative file symlinks. +/// Links must target regular files inside the destination. Hard links and special files are rejected. +/// +public static class TarGZipExtractor +{ + /// + /// Extracts files with their Unix permissions and creates links after their targets have been extracted. + /// Existing symbolic links or junctions beneath the destination are never followed or overwritten. + /// + public static async Task ExtractAsync(string archivePath, string outputDirectory) + { + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(outputDirectory)); + Directory.CreateDirectory(root); + EnsureNoLinks(root, root); + + var links = new List<(string Path, string Target, string Name)>(); + var files = new HashSet( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal + ); + await using var archive = File.OpenRead(archivePath); + await using var gzip = new GZipStream(archive, CompressionMode.Decompress); + await using var reader = new TarReader(gzip); + while (await reader.GetNextEntryAsync().ConfigureAwait(false) is { } entry) + { + var path = GetContainedPath(root, root, entry.Name); + EnsureNoLinks(root, path); + switch (entry.EntryType) + { + case TarEntryType.Directory: + Directory.CreateDirectory(path); + break; + case TarEntryType.RegularFile: + case TarEntryType.V7RegularFile: + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await entry.ExtractToFileAsync(path, overwrite: true).ConfigureAwait(false); + files.Add(path); + break; + case TarEntryType.SymbolicLink: + var target = GetContainedPath(root, Path.GetDirectoryName(path)!, entry.LinkName); + links.Add((path, target, entry.LinkName)); + break; + default: + throw new IOException($"Unsupported tar entry type: {entry.EntryType}."); + } + } + + // Create links after files so no archive entry can write through an archive-created link. + // Requiring regular targets also prevents link chains and directory-link traversal. + foreach (var (path, target, name) in links) + { + EnsureNoLinks(root, path); + EnsureNoLinks(root, target); + if (!files.Contains(target) || !File.Exists(target)) + { + throw new IOException($"Tar link target is not a regular file: {name}."); + } + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + // Use the checked path: retaining segments such as "link/../file" could traverse + // an existing link even though the normalized target is inside the destination. + File.CreateSymbolicLink(path, Path.GetRelativePath(Path.GetDirectoryName(path)!, target)); + } + } + + private static string GetContainedPath(string root, string parent, string name) + { + // TAR uses forward slashes. Reject drive/UNC spellings as well, even on Unix. + if (string.IsNullOrEmpty(name) || name.StartsWith('/') || name.Contains('\\') || name.Contains(':')) + { + throw new IOException($"Invalid tar path: {name}."); + } + + var path = Path.GetFullPath(Path.Combine(parent, name)); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if ( + !path.Equals(root, comparison) && !path.StartsWith(root + Path.DirectorySeparatorChar, comparison) + ) + { + throw new IOException($"Tar path is outside the destination: {name}."); + } + return path; + } + + private static void EnsureNoLinks(string root, string path) + { + var current = path; + while (true) + { + try + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new IOException($"Tar extraction cannot traverse a link: {current}."); + } + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + + if (current == root) + { + return; + } + current = Path.GetDirectoryName(current)!; + } + } +} diff --git a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs index 8049340a2..071617ac2 100644 --- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs +++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs @@ -834,7 +834,7 @@ public record PromptExpansion : ComfyTypedNodeBase [TypedNodeOptions( Name = "Inference_Core_AIO_Preprocessor", - RequiredExtensions = ["https://github.com/LykosAI/ComfyUI-Inference-Core-Nodes >= 0.2.0"] + RequiredExtensions = ["https://github.com/LykosAI/ComfyUI-Inference-Core-Nodes >= 0.5.2"] )] public record AIOPreprocessor : ComfyTypedNodeBase { diff --git a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs index 019e0c2b1..545fb655c 100644 --- a/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs +++ b/StabilityMatrix.Core/Models/FileInterfaces/DirectoryPath.cs @@ -79,6 +79,7 @@ public long GetSize() /// /// /// Whether to include files and subdirectories that are symbolic links / reparse points. + /// When false, links at any depth are skipped, matching what a recursive delete removes. /// public long GetSize(bool includeSymbolicLinks) { @@ -86,17 +87,8 @@ public long GetSize(bool includeSymbolicLinks) return GetSize(); Info.Refresh(); - var files = Info.GetFiles() - .Where(file => !file.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return Info.EnumerateFiles("*", EnumerationOptionConstants.AllDirectoriesExceptLinks) .Sum(file => file.Length); - var subDirs = Info.GetDirectories() - .Where(dir => !dir.Attributes.HasFlag(FileAttributes.ReparsePoint)) - .Sum( - dir => - dir.EnumerateFiles("*", EnumerationOptionConstants.AllDirectories) - .Sum(file => file.Length) - ); - return files + subDirs; } /// @@ -298,14 +290,13 @@ public DirectoryPath WithName(string directoryName) public IEnumerator GetEnumerator() { return Info.EnumerateFileSystemInfos("*", EnumerationOptionConstants.TopLevelOnly) - .Select( - fsInfo => - fsInfo switch - { - FileInfo file => new FilePath(file), - DirectoryInfo directory => new DirectoryPath(directory), - _ => throw new InvalidOperationException("Unknown file system info type") - } + .Select(fsInfo => + fsInfo switch + { + FileInfo file => new FilePath(file), + DirectoryInfo directory => new DirectoryPath(directory), + _ => throw new InvalidOperationException("Unknown file system info type"), + } ) .GetEnumerator(); } diff --git a/StabilityMatrix.Core/Models/Packages/BasePackage.cs b/StabilityMatrix.Core/Models/Packages/BasePackage.cs index b698ea4f4..d18eb6352 100644 --- a/StabilityMatrix.Core/Models/Packages/BasePackage.cs +++ b/StabilityMatrix.Core/Models/Packages/BasePackage.cs @@ -25,6 +25,13 @@ public abstract class BasePackage(ISettingsManager settingsManager) public abstract string Author { get; } public abstract string Blurb { get; } public abstract string GithubUrl { get; } + + /// + /// URLs the repository was published under before it moved. GitHub redirects them, so clones made + /// before the move can still have one as their remote. + /// + public virtual IReadOnlyList FormerGithubUrls => []; + public abstract string LicenseType { get; } public abstract string LicenseUrl { get; } public virtual string Disclaimer => string.Empty; diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index dc6c2cdc0..65142f1d0 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using System.Text.Json; using System.Text.RegularExpressions; using Injectio.Attributes; @@ -18,6 +18,7 @@ using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using StabilityMatrix.Core.Services.Rocm; +using SageWheelService = StabilityMatrix.Core.Services.PipWheelService; namespace StabilityMatrix.Core.Models.Packages; @@ -44,9 +45,10 @@ IRocmPackageHelper rocmPackageHelper public override string Name => "ComfyUI"; public override string DisplayName { get; set; } = "ComfyUI"; - public override string Author => "comfyanonymous"; + public override string Author => "Comfy-Org"; + public override IReadOnlyList FormerGithubUrls => ["https://github.com/comfyanonymous/ComfyUI"]; public override string LicenseType => "GPL-3.0"; - public override string LicenseUrl => "https://github.com/comfyanonymous/ComfyUI/blob/master/LICENSE"; + public override string LicenseUrl => "https://github.com/Comfy-Org/ComfyUI/blob/master/LICENSE"; public override string Blurb => "A powerful and modular stable diffusion GUI and backend"; public override string LaunchCommand => "main.py"; @@ -521,17 +523,7 @@ await rocmPackageHelper } else { - var config = new PipInstallConfig - { - RequirementsFilePaths = ["requirements.txt"], - ExtraPipArgs = ["numpy<2"], - TorchaudioVersion = " ", // Request torchaudio without a specific version - CudaIndex = isLegacyNvidia ? "cu126" : "cu130", - RocmIndex = "rocm7.2", - XpuIndex = "xpu", - UpgradePackages = true, - PostInstallPipArgs = ["typing-extensions>=4.15.0"], - }; + var config = GetStandardInstallConfig(venvRunner.Version, isLegacyNvidia); await StandardPipInstallProcessAsync( venvRunner, @@ -545,56 +537,7 @@ await StandardPipInstallProcessAsync( .ConfigureAwait(false); } - if (!(Compat.IsWindows && torchIndex == TorchIndex.Rocm && HasWindowsRocmSupport())) - { - try - { - var sageVersion = await venvRunner.PipShow("sageattention").ConfigureAwait(false); - var torchVersion = await venvRunner.PipShow("torch").ConfigureAwait(false); - - if (torchVersion is not null && sageVersion is not null) - { - var version = torchVersion.Version; - var plusPos = version.IndexOf('+'); - var index = plusPos >= 0 ? version[(plusPos + 1)..] : string.Empty; - var versionWithoutIndex = plusPos >= 0 ? version[..plusPos] : version; - - if ( - !sageVersion.Version.Contains(index) - || !sageVersion.Version.Contains(versionWithoutIndex) - ) - { - progress?.Report( - new ProgressReport(-1f, "Updating SageAttention...", isIndeterminate: true) - ); - - var step = new InstallSageAttentionStep( - downloadService, - prerequisiteHelper, - pyInstallationManager - ) - { - InstalledPackage = installedPackage, - IsBlackwellGpu = - SettingsManager.Settings.PreferredGpu?.IsBlackwellGpu() - ?? HardwareHelper.HasBlackwellGpu(), - WorkingDirectory = installLocation, - EnvironmentVariables = GetEnvVars( - venvRunner.EnvironmentVariables, - installLocation, - installedPackage - ), - }; - - await step.ExecuteAsync(progress).ConfigureAwait(false); - } - } - } - catch (Exception e) - { - Logger.Error(e, "Failed to verify/update SageAttention after installation"); - } - } + await UpdateSageAttentionAsync(venvRunner, torchIndex, progress).ConfigureAwait(false); // Install Comfy Manager (built-in to ComfyUI) try @@ -622,6 +565,22 @@ await StandardPipInstallProcessAsync( progress?.Report(new ProgressReport(1, "Install complete", isIndeterminate: false)); } + internal PipInstallConfig GetStandardInstallConfig(PyVersion pythonVersion, bool isLegacyNvidia) + { + return new PipInstallConfig + { + RequirementsFilePaths = ["requirements.txt"], + // NumPy 1.x supports Python through 3.12; older torch builds need its ABI. + ExtraPipArgs = pythonVersion < new PyVersion(3, 13, 0) ? ["numpy<2"] : [], + TorchaudioVersion = " ", // Request torchaudio without a specific version + CudaIndex = isLegacyNvidia ? "cu126" : "cu130", + RocmIndex = "rocm7.2", + XpuIndex = "xpu", + UpgradePackages = true, + PostInstallPipArgs = ["typing-extensions>=4.15.0"], + }; + } + public override async Task RunPackage( string installLocation, InstalledPackage installedPackage, @@ -1008,6 +967,44 @@ await venvRunner } } + internal async Task UpdateSageAttentionAsync( + IPyVenvRunner venvRunner, + TorchIndex torchIndex, + IProgress? progress = null + ) + { + if (Compat.IsWindows && torchIndex == TorchIndex.Rocm && HasWindowsRocmSupport()) + return; + + try + { + var sageVersion = await venvRunner.PipShow("sageattention").ConfigureAwait(false); + var torchVersion = await venvRunner.PipShow("torch").ConfigureAwait(false); + + if (torchVersion is not null && sageVersion is not null) + { + if (!SageWheelService.IsCompatibleSageAttention(sageVersion.Version, torchVersion.Version)) + { + progress?.Report( + new ProgressReport(-1f, "Updating SageAttention...", isIndeterminate: true) + ); + + var gpuInfo = + SettingsManager.Settings.PreferredGpu + ?? HardwareHelper.IterGpuInfo().FirstOrDefault(x => x.IsNvidia); + + await PipWheelService + .InstallSageAttentionAsync(venvRunner, gpuInfo, progress) + .ConfigureAwait(false); + } + } + } + catch (Exception e) + { + Logger.Error(e, "Failed to verify/update SageAttention after installation"); + } + } + private async Task InstallTritonAndSageAttention(InstalledPackage? installedPackage) { if (installedPackage?.FullPath is null) diff --git a/StabilityMatrix.Core/Models/Packages/ComfyZluda.cs b/StabilityMatrix.Core/Models/Packages/ComfyZluda.cs index 9d29a9d89..4058acc1d 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyZluda.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyZluda.cs @@ -47,6 +47,7 @@ IRocmPackageHelper rocmPackageHelper public override string Name => "ComfyUI-Zluda"; public override string DisplayName => "ComfyUI-Zluda"; public override string Author => "patientx"; + public override IReadOnlyList FormerGithubUrls => []; public override string LicenseUrl => "https://github.com/patientx/ComfyUI-Zluda/blob/master/LICENSE"; public override string Blurb => "Windows-only version of ComfyUI which uses ZLUDA to get better performance with AMD GPUs."; diff --git a/StabilityMatrix.Core/Models/Packages/Wan2GP.cs b/StabilityMatrix.Core/Models/Packages/Wan2GP.cs index 497599e9c..5ef817459 100644 --- a/StabilityMatrix.Core/Models/Packages/Wan2GP.cs +++ b/StabilityMatrix.Core/Models/Packages/Wan2GP.cs @@ -76,9 +76,11 @@ IRocmPackageHelper rocmPackageHelper public override Dictionary> SharedOutputFolders => new() { [SharedOutputType.Img2Vid] = ["outputs"] }; - // Wan2GP currently uses Python 3.11 for ROCm and 3.10 for CUDA. + // Wan2GP's installer keeps Python 3.10 (torch 2.7.1) only for GTX 10XX, which CUDA 13 dropped. public override PyVersion RecommendedPythonVersion => - IsAmdRocm ? Python.PyInstallationManager.Python_3_11_13 : Python.PyInstallationManager.Python_3_10_17; + IsLegacyNvidia + ? Python.PyInstallationManager.Python_3_10_17 + : Python.PyInstallationManager.Python_3_11_13; public override string Disclaimer => IsAmdRocm && Compat.IsWindows @@ -90,6 +92,9 @@ IRocmPackageHelper rocmPackageHelper /// private bool IsAmdRocm => GetRecommendedTorchVersion() == TorchIndex.Rocm; + private bool IsLegacyNvidia => + SettingsManager.Settings.PreferredGpu?.IsLegacyNvidiaGpu() ?? HardwareHelper.HasLegacyNvidiaGpu(); + private bool HasWindowsRocmSupport() { return HasWindowsRocmSupport(rocmPackageHelper); @@ -293,19 +298,11 @@ private async Task InstallNvidiaAsync( Action? onConsoleOutput ) { - var isLegacyNvidia = - SettingsManager.Settings.PreferredGpu?.IsLegacyNvidiaGpu() ?? HardwareHelper.HasLegacyNvidiaGpu(); + var isLegacyNvidia = IsLegacyNvidia; var isNewerNvidia = SettingsManager.Settings.PreferredGpu?.IsAmpereOrNewerGpu() ?? HardwareHelper.HasAmpereOrNewerGpu(); - // Platform-specific versions from pinokio torch.js - // Windows: torch 2.7.1, Linux: torch 2.7.0 (to match prebuilt attention wheel requirements) - var torchVersion = Compat.IsWindows ? "2.7.1" : "2.7.0"; - var torchvisionVersion = Compat.IsWindows ? "0.22.1" : "0.22.0"; - var torchaudioVersion = Compat.IsWindows ? "2.7.1" : "2.7.0"; - var cudaIndex = isLegacyNvidia ? "cu126" : "cu128"; - progress?.Report(new ProgressReport(-1f, "Upgrading pip...", isIndeterminate: true)); await venvRunner.PipInstall("--upgrade pip wheel", onConsoleOutput).ConfigureAwait(false); @@ -313,6 +310,113 @@ private async Task InstallNvidiaAsync( progress?.Report(new ProgressReport(-1f, "Installing requirements...", isIndeterminate: true)); await venvRunner.PipInstall("-r requirements.txt", onConsoleOutput).ConfigureAwait(false); + // Mirrors Wan2GP's setup_config.json: Python 3.11+ runs torch 2.10 on CUDA 13, Python 3.10 + // stays on torch 2.7 (GTX 10XX, and venvs created before Wan2GP moved to 3.11). Every + // attention wheel below is built for one of those two stacks. + if (!isLegacyNvidia && venvRunner.Version >= new PyVersion(3, 11, 0)) + { + await InstallCuda13StackAsync(venvRunner, isNewerNvidia, progress, onConsoleOutput) + .ConfigureAwait(false); + } + else + { + await InstallTorch27StackAsync( + venvRunner, + isLegacyNvidia, + isNewerNvidia, + progress, + onConsoleOutput + ) + .ConfigureAwait(false); + } + } + + private async Task InstallCuda13StackAsync( + IPyVenvRunner venvRunner, + bool isNewerNvidia, + IProgress? progress, + Action? onConsoleOutput + ) + { + // With deps: the CUDA 13 runtime libraries and Linux triton come in as torch dependencies + progress?.Report(new ProgressReport(-1f, "Installing PyTorch...", isIndeterminate: true)); + var torchArgs = new PipInstallArgs() + .WithTorch("==2.10.0") + .WithTorchVision("==0.25.0") + .WithTorchAudio("==2.10.0") + .WithTorchExtraIndex("cu130") + .AddArg("--force-reinstall"); + + await venvRunner.PipInstall(torchArgs, onConsoleOutput).ConfigureAwait(false); + + await venvRunner.PipInstall("hf-xet \"setuptools<70.0.0\"", onConsoleOutput).ConfigureAwait(false); + + if (!isNewerNvidia) + return; + + if (Compat.IsWindows) + { + progress?.Report(new ProgressReport(-1f, "Installing triton-windows...", isIndeterminate: true)); + await venvRunner + .PipInstall("\"triton-windows>=3.6,<3.7\"", onConsoleOutput) + .ConfigureAwait(false); + + // Resolves woct0rdho's post6 cu130torch2.10.0andhigher wheel, the one Wan2GP installs + await PipWheelService + .InstallSageAttentionAsync(venvRunner, SettingsManager.Settings.PreferredGpu, progress) + .ConfigureAwait(false); + + // Wan2GP only publishes this wheel for cp311 + if (venvRunner.Version.Minor == 11) + { + progress?.Report( + new ProgressReport(-1f, "Installing Flash Attention...", isIndeterminate: true) + ); + await venvRunner + .PipInstall( + "https://github.com/deepbeepmeep/kernels/releases/download/Flash2/flash_attn-2.8.3-cp311-cp311-win_amd64.whl", + onConsoleOutput + ) + .ConfigureAwait(false); + } + } + else if (Compat.IsLinux) + { + // Wan2GP's SageAttention fork (CUDA graph capable kernels); PyPI's sageattention is 1.x. + // Flash Attention is skipped: Wan2GP's Linux installer builds it from source. + if (venvRunner.Version.Minor is >= 10 and <= 12) + { + var py = $"cp3{venvRunner.Version.Minor}"; + progress?.Report( + new ProgressReport(-1f, "Installing SageAttention...", isIndeterminate: true) + ); + await venvRunner + .PipInstall( + $"https://github.com/deepbeepmeep/SageAttention/releases/download/v2.2.0-linux.cg1/sageattention-2.2.0+cu130torch2.10.0.cg1-{py}-{py}-linux_x86_64.whl", + onConsoleOutput + ) + .ConfigureAwait(false); + } + + await venvRunner.PipInstall("numpy==2.1.2", onConsoleOutput).ConfigureAwait(false); + } + } + + private async Task InstallTorch27StackAsync( + IPyVenvRunner venvRunner, + bool isLegacyNvidia, + bool isNewerNvidia, + IProgress? progress, + Action? onConsoleOutput + ) + { + // Platform-specific versions from pinokio torch.js + // Windows: torch 2.7.1, Linux: torch 2.7.0 (to match prebuilt attention wheel requirements) + var torchVersion = Compat.IsWindows ? "2.7.1" : "2.7.0"; + var torchvisionVersion = Compat.IsWindows ? "0.22.1" : "0.22.0"; + var torchaudioVersion = Compat.IsWindows ? "2.7.1" : "2.7.0"; + var cudaIndex = isLegacyNvidia ? "cu126" : "cu128"; + // Install torch with specific versions and CUDA index (force reinstall to ensure correct version) progress?.Report(new ProgressReport(-1f, "Installing PyTorch...", isIndeterminate: true)); var torchArgs = new PipInstallArgs() @@ -448,6 +552,11 @@ public override async Task RunPackage( await SetupVenv(installLocation, pythonVersion: PyVersion.Parse(installedPackage.PythonVersion)) .ConfigureAwait(false); + if (await WarnIfNvidiaDriverBelowCu130MinimumAsync(VenvRunner, onConsoleOutput).ConfigureAwait(false)) + { + return; + } + var selectedTorchIndex = installedPackage.PreferredTorchIndex ?? GetRecommendedTorchVersion(); if (Compat.IsWindows && selectedTorchIndex == TorchIndex.Rocm && HasWindowsRocmSupport()) diff --git a/StabilityMatrix.Core/Services/IPipWheelService.cs b/StabilityMatrix.Core/Services/IPipWheelService.cs index 25b90a865..a07ec5a0e 100644 --- a/StabilityMatrix.Core/Services/IPipWheelService.cs +++ b/StabilityMatrix.Core/Services/IPipWheelService.cs @@ -42,14 +42,4 @@ Task InstallNunchakuAsync( IProgress? progress = null, string? version = null ); - - /// - /// Installs FlashAttention from pre-built wheels. - /// Windows only. No-ops on Linux/macOS. - /// - Task InstallFlashAttentionAsync( - IPyVenvRunner venv, - IProgress? progress = null, - string? version = null - ); } diff --git a/StabilityMatrix.Core/Services/ImageGeneration/GeminiBaseImageGenerationProvider.cs b/StabilityMatrix.Core/Services/ImageGeneration/GeminiBaseImageGenerationProvider.cs index 2df706f4d..60d786416 100644 --- a/StabilityMatrix.Core/Services/ImageGeneration/GeminiBaseImageGenerationProvider.cs +++ b/StabilityMatrix.Core/Services/ImageGeneration/GeminiBaseImageGenerationProvider.cs @@ -56,6 +56,11 @@ public async Task GenerateAsync( return ParseGeminiResponse(response); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + logger.LogInformation("Image generation was cancelled"); + throw; + } catch (ApiException apiEx) when (apiEx.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { logger.LogError(apiEx, "Rate limit or quota exceeded for Gemini API"); diff --git a/StabilityMatrix.Core/Services/ImageGeneration/ImageGenerationChatService.cs b/StabilityMatrix.Core/Services/ImageGeneration/ImageGenerationChatService.cs index b73b1f77b..10153f5c1 100644 --- a/StabilityMatrix.Core/Services/ImageGeneration/ImageGenerationChatService.cs +++ b/StabilityMatrix.Core/Services/ImageGeneration/ImageGenerationChatService.cs @@ -352,8 +352,14 @@ public async Task RemoveImageFromMessageAsync(Guid messageId, string image .Where(p => !string.Equals(p, imagePath, StringComparison.OrdinalIgnoreCase)) .ToList(); - // Delete the image file - if (File.Exists(imagePath)) + // Delete the image file, but only if Image Lab owns it (never a user's original) + if ( + File.Exists(imagePath) + && ( + IsPathUnderDirectory(imagePath, GetOutputDirectory()) + || IsPathUnderDirectory(imagePath, GetInputDirectory(message.ConversationId)) + ) + ) { try { @@ -810,14 +816,22 @@ public async Task RetryGenerationAsync( throw new InvalidOperationException($"Provider {providerId} not found"); } - // Get all messages to find the last user message and build history + // The last user message is the prompt and everything before it is history. Responses + // after it are the ones being regenerated, so they stay out of the request. var allMessages = await GetMessagesAsync(conversationId).ConfigureAwait(false); + var lastUserMessage = allMessages.LastOrDefault(m => m.Role == MessageRole.User); + if (lastUserMessage == null) + { + throw new InvalidOperationException("No user message found to retry"); + } + + var priorMessages = allMessages.Take(allMessages.IndexOf(lastUserMessage)).ToList(); // Check for provider compatibility - thought signature requirements string? carryForwardImagePath = null; if (provider.RequiresThoughtSignatures) { - var incompatibleMessages = allMessages + var incompatibleMessages = priorMessages .Where(m => m.Role == MessageRole.Assistant && !string.IsNullOrEmpty(m.ImagePath) @@ -828,7 +842,7 @@ public async Task RetryGenerationAsync( if (incompatibleMessages.Count > 0) { // Find the last assistant message with an image to carry forward - var lastAssistantImage = allMessages + var lastAssistantImage = priorMessages .Where(m => m.Role == MessageRole.Assistant && !string.IsNullOrEmpty(m.ImagePath)) .OrderByDescending(m => m.Timestamp) .FirstOrDefault(); @@ -849,20 +863,13 @@ public async Task RetryGenerationAsync( } } - // Find the last user message - var lastUserMessage = allMessages.LastOrDefault(m => m.Role == MessageRole.User); - if (lastUserMessage == null) - { - throw new InvalidOperationException("No user message found to retry"); - } - - // Build conversation history (everything except the last user message) + // Build conversation history (everything before the last user message) // If we're carrying forward an image, skip the incompatible history var conversationHistory = new List(); if (string.IsNullOrEmpty(carryForwardImagePath)) { - foreach (var m in allMessages.Where(msg => msg.Id != lastUserMessage.Id)) + foreach (var m in priorMessages) { var messageImagePaths = GetMessageImagePaths(m).Where(File.Exists).ToList(); diff --git a/StabilityMatrix.Core/Services/PipWheelService.cs b/StabilityMatrix.Core/Services/PipWheelService.cs index a0e23a494..2c9fd8bbc 100644 --- a/StabilityMatrix.Core/Services/PipWheelService.cs +++ b/StabilityMatrix.Core/Services/PipWheelService.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Injectio.Attributes; using NLog; using Octokit; @@ -117,16 +117,21 @@ private async Task InstallSageAttentionWindowsAsync( .OrderByDescending(r => r.CreatedAt) .ToList(); - if (releaseList.Count == 0) - { - Logger.Warn("No SageAttention Windows releases found"); - await InstallSageAttentionFromSourceAsync(venv, progress).ConfigureAwait(false); - return; - } - // Find matching wheel from release assets var wheelUrl = FindMatchingWheelAsset(releaseList, torchInfo, venv.Version, version); + // GitHub can come back empty (rate limited with a cold cache, offline) - try the + // known-good wheel list before resorting to a source build + if (string.IsNullOrWhiteSpace(wheelUrl)) + { + wheelUrl = FindFallbackWheelUrl(torchInfo, venv.Version, version); + + if (!string.IsNullOrWhiteSpace(wheelUrl)) + { + Logger.Info("Using fallback SageAttention wheel: {Url}", wheelUrl); + } + } + if (!string.IsNullOrWhiteSpace(wheelUrl)) { progress?.Report( @@ -159,13 +164,6 @@ private async Task InstallSageAttentionWindowsAsync( var baseTorchVersion = plusIndex >= 0 ? torchVersionStr[..plusIndex] : torchVersionStr; var cudaIndex = plusIndex >= 0 ? torchVersionStr[(plusIndex + 1)..] : ""; - // Get major.minor of torch - var torchParts = baseTorchVersion.Split('.'); - var shortTorch = torchParts.Length >= 2 ? $"{torchParts[0]}.{torchParts[1]}" : baseTorchVersion; - - // Get python version string (e.g., "cp312") - var shortPy = $"cp3{pyVersion.Minor}"; - foreach (var release in releases) { // If a specific version is requested, filter releases @@ -174,38 +172,9 @@ private async Task InstallSageAttentionWindowsAsync( foreach (var asset in release.Assets) { - var name = asset.Name; - - // Must be a wheel file - if (!name.EndsWith(".whl")) - continue; - - // Must be for Windows - if (!name.Contains("win_amd64")) - continue; - - // Check Python version compatibility (cp39-abi3 works for cp39+, or specific version) - var matchesPython = - name.Contains($"{shortPy}-{shortPy}") - || name.Contains("cp39-abi3") - || (pyVersion.Minor >= 9 && name.Contains("abi3")); - - if (!matchesPython) - continue; - - // Check torch version match - // Assets use patterns like: cu128torch2.9.0 or cu130torch2.9.0andhigher - var matchesTorch = - name.Contains($"torch{shortTorch}") - || name.Contains($"torch{baseTorchVersion}") - || (name.Contains("andhigher") && CompareTorchVersions(baseTorchVersion, name)); - - // Check CUDA index match - var matchesCuda = !string.IsNullOrEmpty(cudaIndex) && name.Contains(cudaIndex); - - if (matchesTorch && matchesCuda) + if (IsMatchingSageWheel(asset.Name, baseTorchVersion, cudaIndex, pyVersion)) { - Logger.Info("Found matching SageAttention wheel: {Name}", name); + Logger.Info("Found matching SageAttention wheel: {Name}", asset.Name); return asset.BrowserDownloadUrl; } } @@ -214,10 +183,48 @@ private async Task InstallSageAttentionWindowsAsync( return null; } - private static bool CompareTorchVersions(string installedTorch, string assetName) + internal static bool IsMatchingSageWheel( + string assetName, + string baseTorchVersion, + string cudaIndex, + PyVersion pyVersion + ) + { + // Must be a Windows wheel file + if (!assetName.EndsWith(".whl") || !assetName.Contains("win_amd64")) + return false; + + // Check Python version compatibility: abi3 wheels declare their minimum + // interpreter (cp39-abi3 = 3.9+, cp310-abi3 = 3.10+), others need an exact match + var abi3Match = Regex.Match(assetName, @"cp3(\d+)-abi3"); + var shortPy = $"cp3{pyVersion.Minor}"; + var matchesPython = abi3Match.Success + ? pyVersion.Minor >= int.Parse(abi3Match.Groups[1].Value) + : assetName.Contains($"{shortPy}-{shortPy}"); + + if (!matchesPython) + return false; + + return IsCompatibleSageAttention(assetName, $"{baseTorchVersion}+{cudaIndex}"); + } + + internal static bool IsCompatibleSageAttention(string sageVersion, string torchVersion) + { + var plusIndex = torchVersion.IndexOf('+'); + var baseTorchVersion = plusIndex >= 0 ? torchVersion[..plusIndex] : torchVersion; + var cudaIndex = plusIndex >= 0 ? torchVersion[(plusIndex + 1)..] : ""; + var sageCuda = Regex.Match(sageVersion, @"\+cu(\d+)torch"); + + return sageCuda.Success + && cudaIndex == $"cu{sageCuda.Groups[1].Value}" + && MatchesTorchVersion(sageVersion, baseTorchVersion); + } + + private static bool MatchesTorchVersion(string assetName, string installedTorch) { - // Extract torch version from asset name (e.g., "torch2.9.0andhigher" -> "2.9.0") - var match = Regex.Match(assetName, @"torch(\d+\.\d+\.\d+)"); + // Assets embed the torch they were built for: "cu128torch2.9.1.post6" needs that + // major.minor line, "cu130torch2.10.0andhigher.post6" works for that version and up + var match = Regex.Match(assetName, @"torch(\d+\.\d+(?:\.\d+)?)"); if (!match.Success) return false; @@ -227,8 +234,90 @@ private static bool CompareTorchVersions(string installedTorch, string assetName if (!Version.TryParse(match.Groups[1].Value, out var required)) return false; - // "andhigher" means installed version must be >= required version - return installed >= required; + return assetName.Contains("andhigher") + ? installed >= required + : installed.Major == required.Major && installed.Minor == required.Minor; + } + + /// + /// Known-good wheels from https://github.com/woct0rdho/SageAttention/releases, used when the + /// GitHub API yields nothing. Refresh these when new Windows releases are published. + /// + internal static string? FindFallbackWheelUrl( + PipShowResult torchInfo, + PyVersion pyVersion, + string? targetVersion + ) + { + const string releaseBase = "https://github.com/woct0rdho/SageAttention/releases/download"; + + var versionString = torchInfo.Version; + var plusIndex = versionString.IndexOf('+'); + var baseVersionString = plusIndex >= 0 ? versionString[..plusIndex] : versionString; + var cudaIndex = plusIndex >= 0 ? versionString[(plusIndex + 1)..] : string.Empty; + + if (!Version.TryParse(baseVersionString, out var torchVersion)) + return null; + + string? url = null; + + // v2.2.0-windows.post6 wheels are cp310-abi3 (Python 3.10+) for torch 2.9.x / 2.10+ + if (torchVersion >= new Version(2, 10, 0) && pyVersion.Minor >= 10) + { + if (cudaIndex.Contains("cu128")) + { + url = + $"{releaseBase}/v2.2.0-windows.post6/sageattention-2.2.0+cu128torch2.10.0andhigher.post6-cp310-abi3-win_amd64.whl"; + } + else if (cudaIndex.Contains("cu130")) + { + url = + $"{releaseBase}/v2.2.0-windows.post6/sageattention-2.2.0+cu130torch2.10.0andhigher.post6-cp310-abi3-win_amd64.whl"; + } + } + else if (torchVersion >= new Version(2, 9, 0) && pyVersion.Minor >= 10) + { + if (cudaIndex.Contains("cu128")) + { + url = + $"{releaseBase}/v2.2.0-windows.post6/sageattention-2.2.0+cu128torch2.9.1.post6-cp310-abi3-win_amd64.whl"; + } + else if (cudaIndex.Contains("cu130")) + { + url = + $"{releaseBase}/v2.2.0-windows.post6/sageattention-2.2.0+cu130torch2.9.1.post6-cp310-abi3-win_amd64.whl"; + } + } + // Older torch lines only have cp39-abi3 (Python 3.9+) wheels in earlier releases + else if (pyVersion.Minor >= 9) + { + if (versionString.Contains("2.8.0") && versionString.Contains("cu128")) + { + url = + $"{releaseBase}/v2.2.0-windows.post3/sageattention-2.2.0+cu128torch2.8.0.post3-cp39-abi3-win_amd64.whl"; + } + else if (versionString.Contains("2.7.1") && versionString.Contains("cu128")) + { + url = + $"{releaseBase}/v2.2.0-windows.post3/sageattention-2.2.0+cu128torch2.7.1.post3-cp39-abi3-win_amd64.whl"; + } + else if (versionString.Contains("2.6.0") && versionString.Contains("cu126")) + { + url = + $"{releaseBase}/v2.2.0-windows.post3/sageattention-2.2.0+cu126torch2.6.0.post3-cp39-abi3-win_amd64.whl"; + } + else if (versionString.Contains("2.5.1") && versionString.Contains("cu124")) + { + url = + $"{releaseBase}/v2.2.0-windows.post3/sageattention-2.2.0+cu124torch2.5.1.post3-cp39-abi3-win_amd64.whl"; + } + } + + // Honor a pinned sageattention version request (e.g. "2.2.0") + if (url is not null && !string.IsNullOrWhiteSpace(targetVersion) && !url.Contains(targetVersion)) + return null; + + return url; } private async Task InstallSageAttentionFromSourceAsync( @@ -477,120 +566,4 @@ await venv.PipInstall( } #endregion - - #region FlashAttention - - /// - public async Task InstallFlashAttentionAsync( - IPyVenvRunner venv, - IProgress? progress = null, - string? version = null - ) - { - // Windows only - if (!Compat.IsWindows) - { - Logger.Info("Skipping FlashAttention installation - Windows only"); - return; - } - - var torchInfo = await venv.PipShow("torch").ConfigureAwait(false); - if (torchInfo is null) - { - Logger.Warn("Cannot install FlashAttention - torch not installed"); - return; - } - - progress?.Report(new ProgressReport(-1f, "Finding FlashAttention wheel", isIndeterminate: true)); - - // Get releases from GitHub - var releases = await githubApi - .GetAllReleases("mjun0812", "flash-attention-prebuild-wheels") - .ConfigureAwait(false); - var releaseList = releases.OrderByDescending(r => r.CreatedAt).ToList(); - - if (releaseList.Count == 0) - { - Logger.Warn("No FlashAttention releases found"); - return; - } - - var wheelUrl = FindMatchingFlashAttentionWheelAsset(releaseList, torchInfo, venv.Version, version); - - if (string.IsNullOrWhiteSpace(wheelUrl)) - { - Logger.Warn( - "No compatible FlashAttention wheel found for torch {TorchVersion}", - torchInfo.Version - ); - return; - } - - progress?.Report(new ProgressReport(-1f, "Installing FlashAttention", isIndeterminate: true)); - // Use --no-deps to prevent reinstalling torch without CUDA - await venv.PipInstall( - new PipInstallArgs("--no-deps").AddArg(wheelUrl), - progress.AsProcessOutputHandler() - ) - .ConfigureAwait(false); - progress?.Report(new ProgressReport(1f, "FlashAttention installed", isIndeterminate: false)); - } - - private static string? FindMatchingFlashAttentionWheelAsset( - IEnumerable releases, - PipShowResult torchInfo, - PyVersion pyVersion, - string? targetVersion - ) - { - // Parse torch version and CUDA index - var torchVersionStr = torchInfo.Version; - var plusIndex = torchVersionStr.IndexOf('+'); - var baseTorchVersion = plusIndex >= 0 ? torchVersionStr[..plusIndex] : torchVersionStr; - var cudaIndex = plusIndex >= 0 ? torchVersionStr[(plusIndex + 1)..] : ""; - var torchParts = baseTorchVersion.Split('.'); - var shortTorch = torchParts.Length >= 2 ? $"{torchParts[0]}.{torchParts[1]}" : baseTorchVersion; - - // Get python version string - var shortPy = $"cp3{pyVersion.Minor}"; - - foreach (var release in releases) - { - foreach (var asset in release.Assets) - { - var name = asset.Name; - - if (!name.EndsWith(".whl")) - continue; - - if (!name.Contains("win_amd64")) - continue; - - // Check for specific version if requested - if ( - !string.IsNullOrWhiteSpace(targetVersion) && !name.Contains($"flash_attn-{targetVersion}") - ) - continue; - - // Check Python version - if (!name.Contains($"{shortPy}-{shortPy}")) - continue; - - // Check torch version - if (!name.Contains($"torch{shortTorch}")) - continue; - - // Check CUDA index - if (!string.IsNullOrEmpty(cudaIndex) && !name.Contains(cudaIndex)) - continue; - - Logger.Info("Found matching FlashAttention wheel: {Name}", name); - return asset.BrowserDownloadUrl; - } - } - - return null; - } - - #endregion } diff --git a/StabilityMatrix.Tests/Avalonia/CivitBrowserFilterTests.cs b/StabilityMatrix.Tests/Avalonia/CivitBrowserFilterTests.cs new file mode 100644 index 000000000..f14cad6c2 --- /dev/null +++ b/StabilityMatrix.Tests/Avalonia/CivitBrowserFilterTests.cs @@ -0,0 +1,275 @@ +using System.Reflection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; +using StabilityMatrix.Avalonia.ViewModels.CheckpointManager; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Core.Api; +using StabilityMatrix.Core.Database; +using StabilityMatrix.Core.Helper.Factory; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.Api.CivitTRPC; +using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Tests.Avalonia; + +[TestClass] +public class CivitBrowserFilterTests +{ + private SynchronizationContext? originalContext; + private readonly ISettingsManager settings = Substitute.For(); + private readonly ICivitApi api = Substitute.For(); + private readonly IModelIndexService modelIndex = Substitute.For(); + private readonly IServiceManager factory = Substitute.For< + IServiceManager + >(); + + [TestInitialize] + public void Initialize() + { + originalContext = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(new ImmediateSynchronizationContext()); + settings.Settings.Returns(new Settings()); + } + + [TestCleanup] + public void Cleanup() => SynchronizationContext.SetSynchronizationContext(originalContext); + + [TestMethod] + public async Task NextAndPreviousModel_KeepBaseModelFilter() + { + var first = Model(1, Version(11, "Illustrious"), Version(12, "Pony")); + var second = Model(2, Version(21, "SD 1.5"), Version(22, "pony")); + api.GetModelById(1).Returns(first); + api.GetModelById(2).Returns(second); + using var vm = Details(first); + vm.BaseModelFilter = ["Pony"]; + await LoadDetails(vm); + + Assert.AreEqual(12, vm.SelectedVersion?.ModelVersion.Id); + await vm.NextModel(); + Assert.AreEqual(1, vm.CurrentIndex); + Assert.AreEqual(22, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(1, vm.ModelVersions.Count); + await vm.PreviousModel(); + Assert.AreEqual(0, vm.CurrentIndex); + Assert.AreEqual(12, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(1, vm.ModelVersions.Count); + await vm.NextModel(); + await vm.PreviousModel(); + Assert.AreEqual(12, vm.SelectedVersion?.ModelVersion.Id); + } + + [TestMethod] + public async Task Multiselect_KeepsBothSelectedArchitecturesAcrossNavigation() + { + var first = Model(1, Version(11, "SD 1.5"), Version(12, "Pony"), Version(13, "Illustrious")); + api.GetModelById(1).Returns(first); + api.GetModelById(2) + .Returns(Model(2, Version(21, "SD 1.5"), Version(22, "Illustrious"), Version(23, "Pony"))); + using var vm = Details(first); + vm.BaseModelFilter = ["Pony", "Illustrious"]; + await LoadDetails(vm); + Assert.AreEqual(12, vm.SelectedVersion?.ModelVersion.Id); + await vm.NextModel(); + Assert.AreEqual(22, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(2, vm.ModelVersions.Count); + await vm.PreviousModel(); + Assert.AreEqual(12, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(2, vm.ModelVersions.Count); + } + + [TestMethod] + public async Task InitialMetadataRefresh_AlsoKeepsBaseModelFilter() + { + api.GetModelById(1).Returns(Model(1, Version(11, "Illustrious"), Version(12, "Pony"))); + using var vm = Details(new CivitModel { Id = 1 }); + vm.BaseModelFilter = ["Pony"]; + await LoadDetails(vm); + + Assert.AreEqual(12, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(1, vm.ModelVersions.Count); + } + + [TestMethod] + public async Task UnfilteredDetails_KeepAllArchitectures() + { + api.GetModelById(1).Returns(Model(1, Version(11, "Illustrious"), Version(12, "Pony"))); + api.GetModelById(2).Returns(Model(2, Version(21, "SD 1.5"), Version(22, "Pony"))); + using var vm = Details(Model(1, Version(11, "Illustrious"), Version(12, "Pony"))); + await LoadDetails(vm); + + Assert.AreEqual(2, vm.ModelVersions.Count); + Assert.AreEqual(11, vm.SelectedVersion?.ModelVersion.Id); + await vm.NextModel(); + Assert.AreEqual(21, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(2, vm.ModelVersions.Count); + await vm.PreviousModel(); + Assert.AreEqual(11, vm.SelectedVersion?.ModelVersion.Id); + } + + [TestMethod] + public async Task BaseModelFilter_ComposesWithEarlyAccessAndInstalledFilters() + { + var early = Version(12, "Pony"); + early.EarlyAccessDeadline = DateTimeOffset.UtcNow.AddDays(7); + using var vm = Details(Model(1, Version(11, "Illustrious"), early, Version(13, "Pony"))); + vm.BaseModelFilter = ["Pony"]; + vm.HideEarlyAccess = true; + vm.HideInstalledModels = true; + await LoadDetails(vm); + + Assert.AreEqual(13, vm.SelectedVersion?.ModelVersion.Id); + Assert.AreEqual(1, vm.ModelVersions.Count); + using var installed = new ModelVersionViewModel(modelIndex, Version(14, "Pony")) + { + IsInstalled = true, + }; + Assert.IsFalse((bool)Invoke(vm, "ShouldIncludeVersion", installed)!); + } + + [TestMethod] + public async Task NextModel_WithoutMatchingVersions_DoesNotSelectAnotherArchitecture() + { + api.GetModelById(1).Returns(Model(1, Version(11, "Pony"))); + api.GetModelById(2).Returns(Model(2, Version(21, "Illustrious"))); + using var vm = Details(Model(1, Version(11, "Pony"))); + vm.BaseModelFilter = ["Pony"]; + await LoadDetails(vm); + await vm.NextModel(); + + Assert.IsNull(vm.SelectedVersion); + Assert.AreEqual(0, vm.ModelVersions.Count); + Assert.AreEqual(0, vm.CivitFiles.Count); + await vm.PreviousModel(); + Assert.AreEqual(11, vm.SelectedVersion?.ModelVersion.Id); + } + + [TestMethod] + [DataRow("filtered")] + [DataRow("multiselect")] + [DataRow("none")] + [DataRow("all")] + [DataRow("direct-id")] + public async Task OpenDetails_UsesResultFilterSnapshot(string searchMode) + { + using var browser = Browser(); + browser.AllBaseModels.Add(new BaseModelOptionViewModel { ModelType = "Pony" }); + browser.AllBaseModels.Add(new BaseModelOptionViewModel { ModelType = "Illustrious" }); + var model = Model(1, Version(11, "Pony"), Version(12, "Illustrious")); + using var details = Details(model); + factory + .Get(Arg.Any>()) + .Returns(call => call.Arg>()(details)); + + string[] expected = searchMode switch + { + "filtered" => ["Pony"], + "multiselect" => ["Pony", "Illustrious"], + _ => [], + }; + string[]? requestBaseModels = expected.Length > 0 ? expected.ToArray() : null; + Invoke(browser, "UpdateModelCards", new List(), requestBaseModels, false); + if (requestBaseModels is not null) + requestBaseModels[0] = "Illustrious"; + + // A direct-ID search has no base-model restriction even when the dropdown has one. + if (searchMode != "none") + browser.SelectedBaseModels.Add("Illustrious"); + if (searchMode == "all") + browser.SelectedBaseModels.Add("Pony"); + await browser.ShowVersionDialogCommand.ExecuteAsync(model); + + CollectionAssert.AreEqual(expected, details.BaseModelFilter.ToArray()); + await LoadDetails(details); + Assert.AreEqual(searchMode == "filtered" ? 1 : 2, details.ModelVersions.Count); + } + + private CivitCompatApiManager CompatApi() => + new( + NullLogger.Instance, + api, + Substitute.For(), + Substitute.For(), + settings + ); + + private CivitDetailsPageViewModel Details(CivitModel model) => + new( + settings, + CompatApi(), + Substitute.For(), + NullLogger.Instance, + Substitute.For(), + Substitute.For>(), + modelIndex, + factory, + Substitute.For(), + Substitute.For(), + Substitute.For() + ) + { + CivitModel = model, + ModelIdList = [1, 2], + CurrentIndex = 0, + }; + + private CivitAiBrowserViewModel Browser() => + new( + CompatApi(), + settings, + factory, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For>(), + Substitute.For>() + ); + + private static CivitModel Model(int id, params CivitModelVersion[] versions) => + new() + { + Id = id, + Name = $"Model {id}", + Type = CivitModelType.Checkpoint, + ModelVersions = versions.ToList(), + }; + + private static CivitModelVersion Version(int id, string baseModel) => + new() + { + Id = id, + Name = $"Version {id}", + BaseModel = baseModel, + PublishedAt = DateTimeOffset.UtcNow.AddDays(-id), + Images = [], + Files = + [ + new CivitFile + { + Id = id, + Type = CivitFileType.TrainingData, + Hashes = new CivitFileHashes(), + }, + ], + }; + + private static Task LoadDetails(CivitDetailsPageViewModel vm) => + (Task)Invoke(vm, "OnInitialLoadedAsync")!; + + private static object? Invoke(object target, string method, params object?[] arguments) => + target + .GetType() + .GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(target, arguments); + + private sealed class ImmediateSynchronizationContext : SynchronizationContext + { + public override void Post(SendOrPostCallback callback, object? state) => callback(state); + } +} diff --git a/StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs b/StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs new file mode 100644 index 000000000..67b8cf826 --- /dev/null +++ b/StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs @@ -0,0 +1,37 @@ +using StabilityMatrix.Avalonia.Controls; + +namespace StabilityMatrix.Tests.Avalonia; + +[TestClass] +public class MarkdownViewerTests +{ + [TestMethod] + public void EncodeAstralCharacters_Emoji_BecomesOneCharacterReference() + { + var html = MarkdownViewer.EncodeAstralCharacters("

so sad \U0001F97A its perfect

"); + + Assert.AreEqual("

so sad 🥺 its perfect

", html); + } + + [TestMethod] + public void EncodeAstralCharacters_ZwjSequence_KeepsJoinersBetweenReferences() + { + var html = MarkdownViewer.EncodeAstralCharacters("\U0001F43A\u200D\U0001F525"); + + Assert.AreEqual("🐺\u200D🔥", html); + } + + [TestMethod] + public void EncodeAstralCharacters_BmpOnly_ReturnsSameInstance() + { + const string html = "

héllo 狼 ;3

"; + + Assert.AreSame(html, MarkdownViewer.EncodeAstralCharacters(html)); + } + + [TestMethod] + public void EncodeAstralCharacters_LoneSurrogate_BecomesReplacementCharacter() + { + Assert.AreEqual("a\uFFFDb", MarkdownViewer.EncodeAstralCharacters("a\uD83Eb")); + } +} diff --git a/StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs b/StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs new file mode 100644 index 000000000..46ac72867 --- /dev/null +++ b/StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs @@ -0,0 +1,15 @@ +using StabilityMatrix.Avalonia.Models.BananaVision; + +namespace StabilityMatrix.Tests.Avalonia; + +[TestClass] +public class ThinkingMessageTests +{ + [TestMethod] + public void ThinkingPreview_CutInsideEmoji_DoesNotSplitTheSurrogatePair() + { + var thinking = new ThinkingMessage(new string('a', 99) + "\U0001F97A" + new string('b', 20)); + + Assert.AreEqual(new string('a', 99) + "...", thinking.ThinkingPreview); + } +} diff --git a/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs b/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs index bae974d00..bee52e36e 100644 --- a/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs +++ b/StabilityMatrix.Tests/Core/CivArchiveApiClientTests.cs @@ -9,6 +9,46 @@ namespace StabilityMatrix.Tests.Core; [TestClass] public class CivArchiveApiClientTests { + [TestMethod] + [DataRow( + "/models/2612109?modelVersionId=2932986", + "https://civarchive.com/models/2612109?modelVersionId=2932986" + )] + [DataRow( + "/seaart/models/07a9c14c35691c1a7de156a462355bd4/versions/7b9fc01641ec058d815d8e25aaa950ad", + "https://civarchive.com/seaart/models/07a9c14c35691c1a7de156a462355bd4/versions/7b9fc01641ec058d815d8e25aaa950ad" + )] + [DataRow("models/123?modelVersionId=456", "https://civarchive.com/models/123?modelVersionId=456")] + [DataRow( + "https://huggingface.co/example/model/resolve/main/model.safetensors?download=true", + "https://huggingface.co/example/model/resolve/main/model.safetensors?download=true" + )] + [DataRow("http://example.org/model?download=true", "http://example.org/model?download=true")] + [DataRow("//example.org/model?download=true", "https://example.org/model?download=true")] + [DataRow("/models/a%20b?name=a%2Fb#files", "https://civarchive.com/models/a%20b?name=a%2Fb#files")] + [DataRow("", "https://civarchive.com/")] + [DataRow(" ", "https://civarchive.com/")] + [DataRow("file:///tmp/model", "file:///tmp/model")] + [DataRow("mailto:test@example.org", "mailto:test@example.org")] + public void GetAbsoluteUri_ResolvesRoutesAndPreservesExplicitUris(string input, string expected) + { + var client = CreateClient( + new RecordingHandler((_, _) => throw new AssertFailedException("Unexpected HTTP request")) + ); + + Assert.AreEqual(expected, client.GetAbsoluteUri(input).AbsoluteUri); + } + + [TestMethod] + public void GetAbsoluteUri_RejectsMalformedAbsoluteUri() + { + var client = CreateClient( + new RecordingHandler((_, _) => throw new AssertFailedException("Unexpected HTTP request")) + ); + + Assert.ThrowsException(() => client.GetAbsoluteUri("http://[invalid")); + } + [TestMethod] public void BuildSearchDataPath_UsesDefaultFilterValues() { diff --git a/StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs b/StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs new file mode 100644 index 000000000..f30cdbfdc --- /dev/null +++ b/StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs @@ -0,0 +1,47 @@ +using Semver; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api.Comfy.Nodes; +using StabilityMatrix.Core.Models.Api.Comfy.NodeTypes; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.Packages.Extensions; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class ControlNetExtensionRequirementTests +{ + [TestMethod] + [DataRow("v0.2.0", false)] + [DataRow("v0.4.2", false)] + [DataRow("v0.5.2", true)] + [DataRow("v0.5.3", true)] + public void PreprocessorWorkflow_RequiresPublishedCompatibilityFixes(string installedTag, bool satisfies) + { + var nodes = new NodeDictionary(); + nodes.AddTypedNode( + new ComfyNodeBuilder.AIOPreprocessor + { + Name = "ControlNet_Preprocessor", + Image = new ImageNodeConnection { Data = ["LoadImage", 0] }, + Preprocessor = "CannyEdgePreprocessor", + } + ); + + var specifier = nodes.RequiredExtensions.Single(); + var installed = new InstalledPackageExtension + { + Paths = [new DirectoryPath(Path.GetTempPath(), "ComfyUI-Inference-Core-Nodes")], + GitRepositoryUrl = "https://github.com/LykosAI/ComfyUI-Inference-Core-Nodes.git", + Version = new PackageExtensionVersion { Tag = installedTag }, + }; + + var matched = ExtensionMatcher.FindInstalled(specifier, [installed]); + Assert.IsNotNull( + matched, + "An older version must be recognized as installed before checking its tag." + ); + Assert.IsTrue(specifier.TryGetSemVersionRange(out var range)); + Assert.IsTrue(SemVersion.TryParse(matched.Version!.Tag, SemVersionStyles.AllowV, out var version)); + Assert.AreEqual(satisfies, range!.Contains(version!)); + } +} diff --git a/StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs b/StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs new file mode 100644 index 000000000..f4a95c431 --- /dev/null +++ b/StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs @@ -0,0 +1,72 @@ +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.ReparsePoints; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class DirectoryPathSizeTests +{ + private string tempRoot = null!; + + [TestInitialize] + public void Setup() + { + tempRoot = Path.Combine(Path.GetTempPath(), $"sm-size-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempRoot); + } + + [TestCleanup] + public void Cleanup() + { + if (Directory.Exists(tempRoot)) + new DirectoryPath(tempRoot).DeleteVerbose(); + } + + /// + /// A package using symlink sharing: models/checkpoints links to the shared models folder, + /// one level below the package root, beside the package's own files and a hidden .git. + /// + private (DirectoryPath Package, string SharedModel) CreateLinkedPackage() + { + var shared = Directory.CreateDirectory(Path.Combine(tempRoot, "Models", "StableDiffusion")); + var sharedModel = Path.Combine(shared.FullName, "model.safetensors"); + File.WriteAllBytes(sharedModel, new byte[10_000]); + + var package = Directory.CreateDirectory(Path.Combine(tempRoot, "Packages", "ComfyUI")); + File.WriteAllBytes(Path.Combine(package.FullName, "main.py"), new byte[100]); + + var git = Directory.CreateDirectory(Path.Combine(package.FullName, ".git")); + git.Attributes |= FileAttributes.Hidden; + File.WriteAllBytes(Path.Combine(git.FullName, "pack"), new byte[20]); + + var models = Directory.CreateDirectory(Path.Combine(package.FullName, "models")); + var link = Path.Combine(models.FullName, "checkpoints"); + if (Compat.IsWindows) + Junction.Create(link, shared.FullName, false); + else + Directory.CreateSymbolicLink(link, shared.FullName); + + return (new DirectoryPath(package.FullName), sharedModel); + } + + [TestMethod] + public void GetSize_ExcludingLinks_SkipsNestedLinkTargets() + { + var (package, _) = CreateLinkedPackage(); + + Assert.AreEqual(120, package.GetSize(includeSymbolicLinks: false)); + } + + [TestMethod] + public void DeleteVerbose_RemovesNestedLinkButKeepsTarget() + { + var (package, sharedModel) = CreateLinkedPackage(); + + package.DeleteVerbose(); + + Assert.IsFalse(Directory.Exists(package.FullPath)); + Assert.IsTrue(File.Exists(sharedModel)); + } +} diff --git a/StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs b/StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs new file mode 100644 index 000000000..7e0229291 --- /dev/null +++ b/StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using StabilityMatrix.Core.Api; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api.Gemini; +using StabilityMatrix.Core.Services; +using StabilityMatrix.Core.Services.ImageGeneration; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class GeminiImageGenerationProviderTests +{ + [TestMethod] + public async Task GenerateAsync_CancelledByCaller_ThrowsInsteadOfReportingFailure() + { + using var cts = new CancellationTokenSource(); + var geminiApi = Substitute.For(); + geminiApi + .GenerateContentAsync( + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(_ => + { + cts.Cancel(); + return Task.FromException( + new TaskCanceledException("A task was canceled.") + ); + }); + var secretsManager = Substitute.For(); + secretsManager.SafeLoadAsync().Returns(new Secrets { GeminiApiKey = "test-key" }); + var provider = new Gemini3ProImageGenerationProvider( + Substitute.For>(), + geminiApi, + secretsManager + ); + + await Assert.ThrowsExceptionAsync(() => + provider.GenerateAsync(new ImageGenerationRequest { TextPrompt = "a wolf" }, cts.Token) + ); + } +} diff --git a/StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs b/StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs new file mode 100644 index 000000000..a136d8510 --- /dev/null +++ b/StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using StabilityMatrix.Core.Database; +using StabilityMatrix.Core.Models.Configs; +using StabilityMatrix.Core.Models.Database; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Services; +using StabilityMatrix.Core.Services.ImageGeneration; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class ImageGenerationChatServiceTests +{ + [TestMethod] + public async Task RetryGenerationAsync_WithOldResponse_SendsOnlyMessagesBeforeThePrompt() + { + var settingsManager = Substitute.For(); + settingsManager.LibraryDir.Returns(new DirectoryPath(Path.GetTempPath())); + using var database = new BananaVisionDbContext( + NullLogger.Instance, + settingsManager, + Options.Create(new DebugOptions { TempDatabase = true }) + ); + + ImageGenerationRequest? sentRequest = null; + var provider = Substitute.For(); + provider.ProviderId.Returns("test"); + provider + .GenerateAsync(Arg.Do(r => sentRequest = r), Arg.Any()) + .Returns(new ImageGenerationResponse { IsSuccess = true, TextResponse = "new reply" }); + + var service = new ImageGenerationChatService( + NullLogger.Instance, + database, + settingsManager, + [provider] + ); + + var conversation = await service.CreateConversationAsync("test"); + var t0 = DateTime.UtcNow.AddMinutes(-10); + foreach ( + var (role, text, minutes) in new[] + { + (MessageRole.User, "first prompt", 0), + (MessageRole.Assistant, "first reply", 1), + (MessageRole.User, "second prompt", 2), + (MessageRole.Assistant, "reply being regenerated", 3), + } + ) + { + await database.Messages.InsertAsync( + new ImageGenerationMessage + { + ConversationId = conversation.Id, + Role = role, + TextContent = text, + Timestamp = t0.AddMinutes(minutes), + } + ); + } + + await service.RetryGenerationAsync(conversation.Id, "test"); + + Assert.IsNotNull(sentRequest); + Assert.AreEqual("second prompt", sentRequest.TextPrompt); + CollectionAssert.AreEqual( + new[] { "first prompt", "first reply" }, + sentRequest.ConversationHistory!.Select(m => m.TextContent).ToArray() + ); + } +} diff --git a/StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs b/StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs new file mode 100644 index 000000000..5de9ea6d1 --- /dev/null +++ b/StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs @@ -0,0 +1,285 @@ +using System.Formats.Tar; +using System.IO.Compression; +using System.Text; +using StabilityMatrix.Core.Helper; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +public class TarGZipExtractorTests +{ + private string workspace = null!; + private string output = null!; + private string archive = null!; + + [TestInitialize] + public void Initialize() + { + workspace = Path.Combine(Path.GetTempPath(), $"tar-extraction-{Guid.NewGuid():N}"); + output = Path.Combine(workspace, "output with spaces"); + archive = Path.Combine(workspace, "archive.tar.gz"); + Directory.CreateDirectory(workspace); + } + + [TestCleanup] + public void Cleanup() => Directory.Delete(workspace, recursive: true); + + [TestMethod] + public async Task ExtractAsync_RegularFilesAndDirectories() + { + WriteArchive( + new PaxTarEntry(TarEntryType.Directory, "node/lib/"), + FileEntry("node/lib/file with spaces", "contents") + ); + + await TarGZipExtractor.ExtractAsync(archive, output); + + Assert.AreEqual("contents", File.ReadAllText(Path.Combine(output, "node/lib/file with spaces"))); + } + + [DataTestMethod] + [DataRow("node-v20.19.3-linux-x64")] + [DataRow("node-v20.19.3-darwin-arm64")] + public async Task ExtractAsync_NodeLinksBeforeTargetsAndExecutableMode(string folder) + { + RequireSymlinks(); + var names = new[] { "npm", "npx", "corepack" }; + var targets = new[] + { + "../lib/node_modules/npm/bin/npm-cli.js", + "../lib/node_modules/npm/bin/npx-cli.js", + "../lib/node_modules/corepack/dist/corepack.js", + }; + var entries = names.Select((name, i) => LinkEntry($"{folder}/bin/{name}", targets[i])).ToList(); + entries.Add(FileEntry($"{folder}/bin/node", "node")); + entries.AddRange(targets.Select(target => FileEntry($"{folder}/bin/{target}", "script"))); + WriteArchive(entries.ToArray()); + + await TarGZipExtractor.ExtractAsync(archive, output); + + // The installer moves the whole distribution after extraction; relative links must survive it. + var installed = Path.Combine(output, "nodejs"); + Directory.Move(Path.Combine(output, folder), installed); + for (var i = 0; i < names.Length; i++) + { + var path = Path.Combine(installed, "bin", names[i]); + Assert.AreEqual(targets[i], new FileInfo(path).LinkTarget); + Assert.AreEqual("script", File.ReadAllText(path)); + } + if (!OperatingSystem.IsWindows()) + { + Assert.IsTrue( + File.GetUnixFileMode(Path.Combine(installed, "bin/node")).HasFlag(UnixFileMode.UserExecute) + ); + } + } + + [DataTestMethod] + [DataRow("../outside")] + [DataRow("../output with spaces-sibling/outside")] + [DataRow("/absolute")] + [DataRow("C:/outside")] + [DataRow("C:outside")] + [DataRow("\\\\server\\share\\outside")] + [DataRow("..\\outside")] + public async Task ExtractAsync_RejectsUnsafeEntryPaths(string name) + { + WriteArchive(FileEntry(name, "unsafe")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + + Assert.IsFalse(File.Exists(Path.Combine(workspace, "outside"))); + } + + [DataTestMethod] + [DataRow("../../../outside")] + [DataRow("/absolute")] + [DataRow("C:/outside")] + [DataRow("..\\..\\outside")] + public async Task ExtractAsync_RejectsUnsafeLinkTargets(string target) + { + WriteArchive(LinkEntry("node/bin/npm", target)); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + } + + [DataTestMethod] + [DataRow(TarEntryType.HardLink)] + [DataRow(TarEntryType.Fifo)] + [DataRow(TarEntryType.CharacterDevice)] + [DataRow(TarEntryType.BlockDevice)] + public async Task ExtractAsync_RejectsHardLinksAndSpecialFiles(TarEntryType type) + { + var entry = new PaxTarEntry(type, "unsafe"); + if (type == TarEntryType.HardLink) + { + entry.LinkName = "../outside"; + } + WriteArchive(entry); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + } + + [TestMethod] + public async Task ExtractAsync_RejectsMissingLinkTarget() + { + WriteArchive(LinkEntry("node/bin/npm", "../missing")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + } + + [TestMethod] + public async Task ExtractAsync_RejectsTargetNotExtractedFromArchive() + { + Directory.CreateDirectory(output); + File.WriteAllText(Path.Combine(output, "existing"), "sentinel"); + WriteArchive(LinkEntry("link", "existing")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + + Assert.AreEqual("sentinel", File.ReadAllText(Path.Combine(output, "existing"))); + } + + [TestMethod] + public async Task ExtractAsync_RejectsAbsoluteEntryInsideDestination() + { + WriteArchive(FileEntry(Path.Combine(output, "file").Replace('\\', '/'), "unsafe")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + } + + [TestMethod] + public async Task ExtractAsync_RejectsLinkedDestinationRoot() + { + RequireSymlinks(); + var outside = Path.Combine(workspace, "outside"); + Directory.CreateDirectory(outside); + Directory.CreateSymbolicLink(output, outside); + WriteArchive(FileEntry("file", "unsafe")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + + Assert.IsFalse(File.Exists(Path.Combine(outside, "file"))); + } + + [TestMethod] + public async Task ExtractAsync_RejectsArchiveDirectoryLinks() + { + WriteArchive( + new PaxTarEntry(TarEntryType.Directory, "node/lib/"), + LinkEntry("node/bin", "lib"), + FileEntry("node/bin/file", "unsafe") + ); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + + Assert.IsFalse(File.Exists(Path.Combine(output, "node/lib/file"))); + } + + [TestMethod] + public async Task ExtractAsync_RejectsLinkChains() + { + RequireSymlinks(); + WriteArchive(FileEntry("file", "safe"), LinkEntry("link", "file"), LinkEntry("chain", "link")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task ExtractAsync_RejectsExistingDirectoryLink(bool broken) + { + RequireSymlinks(); + var outside = Path.Combine(workspace, "outside"); + if (!broken) + { + Directory.CreateDirectory(outside); + File.WriteAllText(Path.Combine(outside, "file"), "sentinel"); + } + Directory.CreateDirectory(output); + Directory.CreateSymbolicLink(Path.Combine(output, "node"), outside); + WriteArchive(FileEntry("node/file", "unsafe")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + + if (!broken) + { + Assert.AreEqual("sentinel", File.ReadAllText(Path.Combine(outside, "file"))); + } + else + { + Assert.IsFalse(Directory.Exists(outside)); + } + } + + [TestMethod] + public async Task ExtractAsync_RejectsExistingFileLink() + { + RequireSymlinks(); + var outside = Path.Combine(workspace, "outside"); + File.WriteAllText(outside, "sentinel"); + Directory.CreateDirectory(output); + File.CreateSymbolicLink(Path.Combine(output, "file"), outside); + WriteArchive(FileEntry("file", "unsafe")); + + await Assert.ThrowsExceptionAsync(() => TarGZipExtractor.ExtractAsync(archive, output)); + + Assert.AreEqual("sentinel", File.ReadAllText(outside)); + } + + [TestMethod] + public async Task ExtractAsync_NormalizesLinkTargetBeforeCreatingLink() + { + RequireSymlinks(); + var outside = Path.Combine(workspace, "outside"); + Directory.CreateDirectory(Path.Combine(outside, "directory")); + File.WriteAllText(Path.Combine(outside, "file"), "sentinel"); + Directory.CreateDirectory(output); + Directory.CreateSymbolicLink(Path.Combine(output, "existing"), Path.Combine(outside, "directory")); + WriteArchive(FileEntry("file", "safe"), LinkEntry("link", "existing/../file")); + + await TarGZipExtractor.ExtractAsync(archive, output); + + Assert.AreEqual("file", new FileInfo(Path.Combine(output, "link")).LinkTarget); + Assert.AreEqual("safe", File.ReadAllText(Path.Combine(output, "link"))); + Assert.AreEqual("sentinel", File.ReadAllText(Path.Combine(outside, "file"))); + } + + private void RequireSymlinks() + { + var target = Path.Combine(workspace, "symlink-probe-target"); + var link = Path.Combine(workspace, "symlink-probe"); + File.WriteAllText(target, "probe"); + try + { + File.CreateSymbolicLink(link, target); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + Assert.Inconclusive($"This host cannot create symlinks: {e.Message}"); + } + } + + private void WriteArchive(params TarEntry[] entries) + { + using var stream = File.Create(archive); + using var gzip = new GZipStream(stream, CompressionMode.Compress); + using var writer = new TarWriter(gzip, TarEntryFormat.Pax); + foreach (var entry in entries) + { + writer.WriteEntry(entry); + entry.DataStream?.Dispose(); + } + } + + private static PaxTarEntry FileEntry(string name, string contents) => + new(TarEntryType.RegularFile, name) + { + DataStream = new MemoryStream(Encoding.UTF8.GetBytes(contents)), + Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, + }; + + private static PaxTarEntry LinkEntry(string name, string target) => + new(TarEntryType.SymbolicLink, name) { LinkName = target }; +} diff --git a/StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs b/StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs new file mode 100644 index 000000000..7b2aae70b --- /dev/null +++ b/StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using Octokit; +using StabilityMatrix.Core.Database; +using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Models.Database; + +namespace StabilityMatrix.Tests.Helper; + +[TestClass] +public class GithubApiCacheTests +{ + private readonly IGitHubClient github = Substitute.For(); + private readonly ILiteDbContext dbContext = Substitute.For(); + private GithubApiCache cache = null!; + + [TestInitialize] + public void Setup() + { + dbContext.GetGithubCacheEntry(Arg.Any()).Returns((GithubCacheEntry?)null); + cache = new GithubApiCache(dbContext, github, Substitute.For>()); + } + + [TestMethod] + public async Task GetAllReleases_ConcurrentCallsForSameRepo_ShareOneRequest() + { + var response = new TaskCompletionSource>(); + github + .Repository.Release.GetAll("Comfy-Org", "ComfyUI", Arg.Any()) + .Returns(response.Task); + + var calls = Enumerable.Range(0, 5).Select(_ => cache.GetAllReleases("Comfy-Org", "ComfyUI")).ToList(); + response.SetResult([new Release()]); + var results = await Task.WhenAll(calls); + + await github.Repository.Release.Received(1).GetAll("Comfy-Org", "ComfyUI", Arg.Any()); + Assert.IsTrue(results.All(r => r.Count() == 1)); + } + + [TestMethod] + public async Task GetAllReleases_AfterRequestCompletes_FetchesAgainOnCacheMiss() + { + github + .Repository.Release.GetAll("Comfy-Org", "ComfyUI", Arg.Any()) + .Returns([new Release()]); + + await cache.GetAllReleases("Comfy-Org", "ComfyUI"); + await cache.GetAllReleases("Comfy-Org", "ComfyUI"); + + await github.Repository.Release.Received(2).GetAll("Comfy-Org", "ComfyUI", Arg.Any()); + } + + [TestMethod] + public async Task GetAllBranches_ConcurrentCallsForSameRepo_ShareOneRequest() + { + var response = new TaskCompletionSource>(); + github.Repository.Branch.GetAll("Comfy-Org", "ComfyUI").Returns(response.Task); + + var calls = Enumerable.Range(0, 5).Select(_ => cache.GetAllBranches("Comfy-Org", "ComfyUI")).ToList(); + response.SetResult([new Branch()]); + await Task.WhenAll(calls); + + await github.Repository.Branch.Received(1).GetAll("Comfy-Org", "ComfyUI"); + } +} diff --git a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs index f3c040d4b..a29152f30 100644 --- a/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs +++ b/StabilityMatrix.Tests/Helper/LinkSafeFileSystemTests.cs @@ -164,6 +164,8 @@ string outerName [DataTestMethod] [DataRow("diffusion_models")] + [DataRow("Diffusion_Models")] + [DataRow("z_alias")] [DataRow("a_alias")] [DataRow("sub", "alias")] public void EnumerateFiles_RealFolderShadowedByLink_KeepsRealFolderPaths(params string[] linkSegments) diff --git a/StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs b/StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs new file mode 100644 index 000000000..1b5e84fa6 --- /dev/null +++ b/StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs @@ -0,0 +1,68 @@ +using NSubstitute; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using StabilityMatrix.Core.Services.Rocm; + +namespace StabilityMatrix.Tests.Models.Packages; + +[TestClass] +public class ComfyUIInstallConfigTests +{ + private readonly ComfyUI comfy = new( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + + [TestMethod] + [DataRow("3.10.21", true)] + [DataRow("3.11.14", true)] + [DataRow("3.12.10", true)] + [DataRow("3.12.99", true)] + [DataRow("3.13.0", false)] + [DataRow("3.13.12", false)] + [DataRow("3.14.0", false)] + public void Requirements_OnlyConstrainNumpyOnInterpretersSupportedByNumpy1( + string pythonVersion, + bool expectLegacyConstraint + ) + { + var config = comfy.GetStandardInstallConfig(PyVersion.Parse(pythonVersion), false); + var args = new PipInstallArgs([.. config.ExtraPipArgs]) + .WithParsedFromRequirementsTxt( + "numpy>=1.25.0\nscipy\ntorch\ntorchvision", + config.RequirementsExcludePattern + ) + .ToProcessArgs(); + + Assert.AreEqual(expectLegacyConstraint, args.Contains("numpy<2")); + Assert.IsTrue(args.Contains("numpy>=1.25.0")); + Assert.IsTrue(args.Contains("scipy")); + Assert.IsFalse(args.Contains("torch")); + Assert.IsFalse(args.Contains("torchvision")); + } + + [TestMethod] + [DataRow(true, "cu126")] + [DataRow(false, "cu130")] + public void Python313_PreservesTorchAndRequirementsConfiguration(bool isLegacyNvidia, string cudaIndex) + { + var config = comfy.GetStandardInstallConfig(new PyVersion(3, 13, 0), isLegacyNvidia); + + CollectionAssert.AreEqual(new[] { "requirements.txt" }, config.RequirementsFilePaths.ToArray()); + Assert.AreEqual(cudaIndex, config.CudaIndex); + Assert.AreEqual("rocm7.2", config.RocmIndex); + Assert.AreEqual("xpu", config.XpuIndex); + Assert.AreEqual(" ", config.TorchaudioVersion); + Assert.IsTrue(config.UpgradePackages); + Assert.IsTrue(config.ForceReinstallTorch); + CollectionAssert.AreEqual(new[] { "typing-extensions>=4.15.0" }, config.PostInstallPipArgs.ToArray()); + } +} diff --git a/StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs b/StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs new file mode 100644 index 000000000..53089157b --- /dev/null +++ b/StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs @@ -0,0 +1,115 @@ +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Packages; +using StabilityMatrix.Core.Models.Rocm; +using StabilityMatrix.Core.Models.Settings; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using StabilityMatrix.Core.Services.Rocm; + +namespace StabilityMatrix.Tests.Models.Packages; + +[TestClass] +public class ComfyUISageAttentionUpdateTests +{ + private readonly IPyVenvRunner venv = Substitute.For(); + private readonly IPipWheelService wheels = Substitute.For(); + private readonly IRocmPackageHelper rocm = Substitute.For(); + private readonly ISettingsManager settings = Substitute.For(); + private ComfyUI comfy = null!; + + [TestInitialize] + public void Setup() + { + settings.Settings.Returns(new Settings { PreferredGpu = new GpuInfo { Name = "NVIDIA RTX 4090" } }); + rocm.GetCompatibility().Returns(new RocmCompatibilityResult { IsCompatible = true }); + comfy = new ComfyUI( + Substitute.For(), + settings, + Substitute.For(), + Substitute.For(), + Substitute.For(), + wheels, + rocm + ); + } + + [TestMethod] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.0+cu128")] + [DataRow("2.2.0+cu130torch2.9.1.post6", "2.9.2+cu130")] + [DataRow("2.2.0+cu128torch2.10.0andhigher.post6", "2.11.0+cu128")] + [DataRow("2.2.0+cu130torch2.9.0andhigher.post4", "2.10.0+cu130")] + [DataRow("2.2.0+cu128torch2.8.0.post3", "2.8.1+cu128")] + public async Task CompatibleInstalledWheel_IsPreserved(string sage, string torch) + { + SetVersions(sage, torch); + await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda); + Assert.AreEqual(0, wheels.ReceivedCalls().Count()); + await venv.Received(1).PipShow("sageattention"); + await venv.Received(1).PipShow("torch"); + } + + [TestMethod] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.10.0+cu128")] + [DataRow("2.2.0+cu128torch2.10.0andhigher.post6", "2.9.1+cu128")] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+cu130")] + [DataRow("2.2.0", "2.10.0+cu130")] + [DataRow("malformed", "malformed")] + public async Task IncompatibleInstalledWheel_UsesDynamicInstaller(string sage, string torch) + { + SetVersions(sage, torch); + await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda); + await wheels.Received(1).InstallSageAttentionAsync(venv, settings.Settings.PreferredGpu, null); + } + + [TestMethod] + [DataRow(null, "2.10.0+cu130")] + [DataRow("2.2.0+cu130torch2.10.0andhigher.post6", null)] + public async Task MissingPackage_DoesNotInstallSage(string? sage, string? torch) + { + SetVersions(sage, torch); + await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda); + Assert.AreEqual(0, wheels.ReceivedCalls().Count()); + } + + [TestMethod] + public async Task WindowsRocm_RemainsOnItsOwnInstallerPath() + { + if (!StabilityMatrix.Core.Helper.Compat.IsWindows) + Assert.Inconclusive("Windows native ROCm guard"); + await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Rocm); + Assert.AreEqual(0, venv.ReceivedCalls().Count()); + Assert.AreEqual(0, wheels.ReceivedCalls().Count()); + } + + [TestMethod] + public async Task InspectionFailure_DoesNotAbortComfyUpdate() + { + venv.PipShow("sageattention").ThrowsAsync(new InvalidOperationException("pip show failed")); + await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda); + Assert.AreEqual(0, wheels.ReceivedCalls().Count()); + } + + [TestMethod] + public async Task RepairFailure_DoesNotAbortComfyUpdate() + { + SetVersions("2.2.0+cu128torch2.9.1.post6", "2.10.0+cu130"); + wheels + .InstallSageAttentionAsync(venv, settings.Settings.PreferredGpu, null) + .ThrowsAsync(new InvalidOperationException("installation failed")); + await comfy.UpdateSageAttentionAsync(venv, TorchIndex.Cuda); + await wheels.Received(1).InstallSageAttentionAsync(venv, settings.Settings.PreferredGpu, null); + } + + private void SetVersions(string? sage, string? torch) + { + venv.PipShow("sageattention") + .Returns(sage is null ? null : new PipShowResult { Name = "sageattention", Version = sage }); + venv.PipShow("torch") + .Returns(torch is null ? null : new PipShowResult { Name = "torch", Version = torch }); + } +} diff --git a/StabilityMatrix.Tests/Services/PipWheelServiceTests.cs b/StabilityMatrix.Tests/Services/PipWheelServiceTests.cs new file mode 100644 index 000000000..97c67f3d4 --- /dev/null +++ b/StabilityMatrix.Tests/Services/PipWheelServiceTests.cs @@ -0,0 +1,208 @@ +using NSubstitute; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Cache; +using StabilityMatrix.Core.Helper.HardwareInfo; +using StabilityMatrix.Core.Processes; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Tests.Services; + +[TestClass] +public class PipWheelServiceTests +{ + private static readonly PyVersion Py310 = new(3, 10, 11); + private static readonly PyVersion Py312 = new(3, 12, 7); + + // Real asset names from woct0rdho/SageAttention releases + private const string Post6Cu128Torch210 = + "sageattention-2.2.0+cu128torch2.10.0andhigher.post6-cp310-abi3-win_amd64.whl"; + private const string Post6Cu130Torch291 = + "sageattention-2.2.0+cu130torch2.9.1.post6-cp310-abi3-win_amd64.whl"; + private const string Post3Cu126Torch260 = + "sageattention-2.2.0+cu126torch2.6.0.post3-cp39-abi3-win_amd64.whl"; + private const string V211Cu128Torch270Cp310 = + "sageattention-2.1.1+cu128torch2.7.0-cp310-cp310-win_amd64.whl"; + + [TestMethod] + public void AndHigherWheel_MatchesNewerTorch() + { + Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.11.0", "cu128", Py312)); + Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "cu128", Py310)); + } + + [TestMethod] + public void AndHigherWheel_RejectsOlderTorch() + { + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.9.1", "cu128", Py312)); + } + + [TestMethod] + public void PinnedWheel_MatchesSameTorchMinorLine() + { + Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu130Torch291, "2.9.0", "cu130", Py312)); + Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post6Cu130Torch291, "2.9.1", "cu130", Py310)); + } + + [TestMethod] + public void PinnedWheel_RejectsDifferentTorchMinor() + { + // The old prefix matching ("torch2.1" in "torch2.10...") must not resurface here + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu130Torch291, "2.10.0", "cu130", Py312)); + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post3Cu126Torch260, "2.60.0", "cu126", Py312)); + } + + [TestMethod] + public void Cp310Abi3Wheel_RequiresPython310OrNewer() + { + var py39 = new PyVersion(3, 9, 13); + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "cu128", py39)); + Assert.IsTrue(PipWheelService.IsMatchingSageWheel(Post3Cu126Torch260, "2.6.0", "cu126", py39)); + } + + [TestMethod] + public void InterpreterSpecificWheel_RequiresExactPython() + { + Assert.IsTrue(PipWheelService.IsMatchingSageWheel(V211Cu128Torch270Cp310, "2.7.0", "cu128", Py310)); + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(V211Cu128Torch270Cp310, "2.7.0", "cu128", Py312)); + } + + [TestMethod] + public void CudaIndexMismatch_Rejects() + { + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "cu130", Py312)); + Assert.IsFalse(PipWheelService.IsMatchingSageWheel(Post6Cu128Torch210, "2.10.0", "", Py312)); + } + + [TestMethod] + public void FallbackWheel_Torch210Cu130_UsesPost6AndHigher() + { + var url = PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu130"), Py312, null); + StringAssert.Contains(url, "cu130torch2.10.0andhigher.post6"); + } + + [TestMethod] + public void FallbackWheel_Torch29Cu128_UsesPost6Pinned() + { + var url = PipWheelService.FindFallbackWheelUrl(TorchInfo("2.9.0+cu128"), Py310, null); + StringAssert.Contains(url, "cu128torch2.9.1.post6"); + } + + [TestMethod] + public void FallbackWheel_LegacyTorch_UsesPost3() + { + var url = PipWheelService.FindFallbackWheelUrl(TorchInfo("2.6.0+cu126"), Py310, null); + StringAssert.Contains(url, "cu126torch2.6.0.post3"); + } + + [TestMethod] + public void FallbackWheel_UnknownCombination_ReturnsNull() + { + Assert.IsNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu126"), Py312, null)); + Assert.IsNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.4.0+cu121"), Py310, null)); + } + + [TestMethod] + public void FallbackWheel_HonorsPinnedSageVersion() + { + Assert.IsNotNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu130"), Py312, "2.2.0")); + Assert.IsNull(PipWheelService.FindFallbackWheelUrl(TorchInfo("2.10.0+cu130"), Py312, "9.9.9")); + } + + [TestMethod] + [DataRow("2.9.0", true)] + [DataRow("2.10.0", true)] + [DataRow("2.11.0", true)] + [DataRow("2.8.1", false)] + [DataRow("invalid", false)] + public void Torch29AndHigherWheel_UsesSharedCompatibilityRules(string torch, bool expected) + { + const string version = "2.2.0+cu130torch2.9.0andhigher.post4"; + Assert.AreEqual(expected, PipWheelService.IsCompatibleSageAttention(version, $"{torch}+cu130")); + Assert.AreEqual( + expected, + PipWheelService.IsMatchingSageWheel( + $"sageattention-{version}-cp39-abi3-win_amd64.whl", + torch, + "cu130", + Py312 + ) + ); + } + + [TestMethod] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+cu1280")] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1")] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+cpu")] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1+rocm7.2")] + [DataRow("2.2.0+cu128torch2.9.1.post6", "2.9.1.dev20260101+cu128")] + public void UnsupportedTorchMetadata_IsNotDeclaredCompatible(string sage, string torch) + { + Assert.IsFalse(PipWheelService.IsCompatibleSageAttention(sage, torch)); + } + + [TestMethod] + public async Task WindowsRepair_EmptyReleaseListUsesPost6WithoutTorchDependencies() + { + if (!Compat.IsWindows) + Assert.Inconclusive("Windows wheel installation"); + var github = Substitute.For(); + github.GetAllReleases("woct0rdho", "SageAttention").Returns([]); + var venv = Substitute.For(); + venv.Version.Returns(Py312); + venv.PipShow("torch").Returns(TorchInfo("2.10.0+cu130")); + var service = new PipWheelService( + github, + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + + await service.InstallSageAttentionAsync(venv, new GpuInfo { Name = "NVIDIA RTX 4090" }); + + await venv.Received(1) + .PipInstall( + Arg.Is(args => + args.ToString().Contains("--no-deps") + && args.ToString().Contains("cu130torch2.10.0andhigher.post6") + ), + Arg.Any?>() + ); + } + + [TestMethod] + public async Task NonNvidiaGpu_DoesNotInspectOrInstallCudaWheels() + { + var github = Substitute.For(); + var venv = Substitute.For(); + var service = new PipWheelService( + github, + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + await service.InstallSageAttentionAsync(venv, new GpuInfo { Name = "AMD Radeon RX 9070" }); + Assert.AreEqual(0, venv.ReceivedCalls().Count()); + Assert.AreEqual(0, github.ReceivedCalls().Count()); + } + + [TestMethod] + public async Task WindowsMissingTorch_DoesNotLookUpOrInstallWheels() + { + if (!Compat.IsWindows) + Assert.Inconclusive("Windows wheel installation"); + var github = Substitute.For(); + var venv = Substitute.For(); + var service = new PipWheelService( + github, + Substitute.For(), + Substitute.For(), + Substitute.For() + ); + await service.InstallSageAttentionAsync(venv, new GpuInfo { Name = "NVIDIA RTX 4090" }); + await venv.DidNotReceive().PipInstall(Arg.Any(), Arg.Any?>()); + Assert.AreEqual(0, github.ReceivedCalls().Count()); + } + + private static PipShowResult TorchInfo(string version) => new() { Name = "torch", Version = version }; +} diff --git a/StabilityMatrix.UITests/BananaVisionPageTests.cs b/StabilityMatrix.UITests/BananaVisionPageTests.cs new file mode 100644 index 000000000..76b17be4d --- /dev/null +++ b/StabilityMatrix.UITests/BananaVisionPageTests.cs @@ -0,0 +1,290 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using StabilityMatrix.Avalonia.Models.BananaVision; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views; +using StabilityMatrix.Core.Helper.Factory; +using StabilityMatrix.Core.Models.Database; +using StabilityMatrix.Core.Python; +using StabilityMatrix.Core.Services; +using StabilityMatrix.Core.Services.ImageGeneration; + +namespace StabilityMatrix.UITests; + +/// +/// Image Lab page hosted headlessly over an in-memory chat service, so generation outcomes can be +/// timed against conversation switches. +/// +[Collection("TempDir")] +public class BananaVisionPageTests +{ + [AvaloniaFact] + public async Task Generation_FinishingWhileAnotherConversationIsOpen_StaysOutOfIt() + { + foreach (var fails in new[] { false, true }) + { + var lab = new ImageLab(); + var a = lab.AddConversation("A", minutesAgo: 1, "A prompt", "A reply"); + var b = lab.AddConversation("B", minutesAgo: 30, "B prompt", "B reply"); + var generation = lab.HoldSend(fails ? new ImageGenerationException("boom") : null); + await lab.OpenAsync(a); + + lab.Vm.NewMessageText = "A second prompt"; + var send = lab.Vm.SendMessageCommand.ExecuteAsync(null); + await Settle(); + await lab.SelectAsync(b); + + generation.SetResult(); + await send; + await Settle(); + + Assert.Equal("B", lab.Vm.CurrentConversation?.Title); + Assert.Equal(new[] { "B prompt", "B reply" }, lab.Texts()); + Assert.False(lab.Vm.CanRetryLastMessage); + + await lab.SelectAsync(a); + Assert.Equal( + fails + ? new[] { "A prompt", "A reply", "A second prompt" } + : new[] { "A prompt", "A reply", "A second prompt", "sent reply" }, + lab.Texts() + ); + Assert.Equal(fails, lab.Vm.CanRetryLastMessage); + Assert.All(lab.Vm.Messages.OfType(), m => Assert.True(m.CanDelete)); + lab.Close(); + } + } + + [AvaloniaFact] + public async Task Regenerate_KeepsTheOldResponseUntilTheNewOneIsSaved() + { + var lab = new ImageLab(); + var a = lab.AddConversation("A", minutesAgo: 1, "prompt", "old reply"); + await lab.OpenAsync(a); + + lab.FailRetry = true; + await lab.Vm.RegenerateLastResponseCommand.ExecuteAsync(null); + await Settle(); + Assert.Equal(new[] { "prompt", "old reply" }, lab.Texts()); + Assert.True(lab.Vm.CanRetryLastMessage); + + lab.FailRetry = false; + await lab.Vm.RegenerateLastResponseCommand.ExecuteAsync(null); + await Settle(); + Assert.Equal(new[] { "prompt", "regenerated reply" }, lab.Texts()); + lab.Close(); + } + + [AvaloniaFact] + public async Task NewMessage_WhenAtTheBottom_ScrollsToIt() + { + var lab = new ImageLab(); + var a = lab.AddConversation("A", minutesAgo: 1); + for (var i = 0; i < 12; i++) + { + lab.AddMessages(a, $"prompt {i} " + new string('x', 400), $"reply {i}"); + } + lab.HoldSend(null); + await lab.OpenAsync(a); + + var scroll = lab.Page.FindControl("MessageScrollViewer")!; + Assert.Equal(scroll.Extent.Height - scroll.Viewport.Height, scroll.Offset.Y, 1); + + lab.Vm.NewMessageText = "one more"; + _ = lab.Vm.SendMessageCommand.ExecuteAsync(null); + await Settle(); + + // The loading placeholder alone is taller than the near-bottom threshold + Assert.Equal(scroll.Extent.Height - scroll.Viewport.Height, scroll.Offset.Y, 1); + lab.Close(); + } + + private static async Task Settle() + { + for (var i = 0; i < 10; i++) + { + await Task.Delay(30); + Dispatcher.UIThread.RunJobs(); + } + } + + private sealed class ImageLab + { + private readonly List conversations = []; + private readonly Dictionary> messages = []; + private readonly IImageGenerationChatService chatService = + Substitute.For(); + private Window? window; + + public ImageLab() + { + chatService.GetAvailableProviders().Returns([]); + chatService + .GetConversationsAsync() + .Returns(_ => + conversations.OrderByDescending(c => c.UpdatedAt).Select(c => c with { }).ToList() + ); + chatService.GetMessagesAsync(Arg.Any()).Returns(ci => messages[ci.Arg()].ToList()); + chatService + .DeleteMessageAsync(Arg.Any(), Arg.Any()) + .Returns(ci => + { + foreach (var list in messages.Values) + list.RemoveAll(m => m.Id == ci.ArgAt(0)); + return Task.CompletedTask; + }); + chatService + .RetryGenerationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any?>(), + Arg.Any() + ) + .Returns(ci => + { + if (FailRetry) + return Task.FromException( + new ImageGenerationException("failed") + ); + Touch(ci.ArgAt(0)); + return Task.FromResult( + Save(ci.ArgAt(0), MessageRole.Assistant, "regenerated reply") + ); + }); + + Vm = new BananaVisionPageViewModel( + NullLogger.Instance, + chatService, + Substitute.For(), + Substitute.For(), + Substitute.For(), + new RunningPackageService( + NullLogger.Instance, + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For() + ), + Substitute.For>(), + Substitute.For(), + Substitute.For>(), + Substitute.For>() + ) + { + SelectedProviderId = BananaVisionProviderIds.Gemini3Pro, + }; + Page = new BananaVisionPage { DataContext = Vm }; + } + + public BananaVisionPageViewModel Vm { get; } + + public BananaVisionPage Page { get; } + + public bool FailRetry { get; set; } + + public ImageGenerationConversation AddConversation( + string title, + int minutesAgo, + params string[] turns + ) + { + var conversation = new ImageGenerationConversation + { + Title = title, + ProviderId = BananaVisionProviderIds.Gemini3Pro, + UpdatedAt = DateTime.UtcNow.AddMinutes(-minutesAgo), + }; + conversations.Add(conversation); + messages[conversation.Id] = []; + AddMessages(conversation, turns); + return conversation; + } + + public void AddMessages(ImageGenerationConversation conversation, params string[] turns) + { + for (var i = 0; i < turns.Length; i++) + { + Save(conversation.Id, i % 2 == 0 ? MessageRole.User : MessageRole.Assistant, turns[i]); + } + } + + /// + /// Makes Send save the prompt and then wait; completing the returned source finishes it, + /// with a reply or by throwing . + /// + public TaskCompletionSource HoldSend(Exception? failure) + { + var gate = new TaskCompletionSource(); + chatService + .SendMessageAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any?>(), + Arg.Any?>(), + Arg.Any() + ) + .Returns(async ci => + { + var id = ci.ArgAt(0); + var user = Save(id, MessageRole.User, ci.ArgAt(2)); + await gate.Task; + Touch(id); + if (failure != null) + throw failure; + return (user, (ImageGenerationMessage?)Save(id, MessageRole.Assistant, "sent reply")); + }); + return gate; + } + + public async Task OpenAsync(ImageGenerationConversation conversation) + { + window = new Window + { + Width = 1400, + Height = 900, + Content = Page, + }; + window.Show(); + foreach (var c in await chatService.GetConversationsAsync()) + { + Vm.Conversations.Add(c); + } + await SelectAsync(conversation); + } + + /// Selects the conversation the way the sidebar does. + public async Task SelectAsync(ImageGenerationConversation conversation) + { + Vm.CurrentConversation = Vm.Conversations.First(c => c.Id == conversation.Id); + await Settle(); + } + + public string[] Texts() => Vm.Messages.OfType().Select(m => m.Text).ToArray(); + + public void Close() => window?.Close(); + + private ImageGenerationMessage Save(Guid conversationId, MessageRole role, string? text) + { + var message = new ImageGenerationMessage + { + ConversationId = conversationId, + Role = role, + TextContent = text, + Timestamp = DateTime.UtcNow.AddTicks(messages[conversationId].Count), + }; + messages[conversationId].Add(message); + return message; + } + + /// Bumps the conversation to the top of the list, as a generation does. + private void Touch(Guid conversationId) => + conversations.First(c => c.Id == conversationId).UpdatedAt = DateTime.UtcNow; + } +} diff --git a/StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs b/StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs new file mode 100644 index 000000000..07aa3fa64 --- /dev/null +++ b/StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs @@ -0,0 +1,92 @@ +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; +using StabilityMatrix.Avalonia.Controls; +using TheArtOfDev.HtmlRenderer.Avalonia; + +namespace StabilityMatrix.UITests; + +[Collection("TempDir")] +public class MarkdownViewerLayoutTests +{ + private static readonly FieldInfo HtmlContainerField = typeof(HtmlControl).GetField( + "_htmlContainer", + BindingFlags.Instance | BindingFlags.NonPublic + )!; + + private static readonly string LongText = string.Join( + " ", + Enumerable.Repeat("Keep his face shape, snout and ear shape identical.", 12) + ); + + [AvaloniaFact] + public void LongText_InHeightUnboundedHost_WrapsWithinTheControl() + { + foreach (var fitToContent in new[] { false, true }) + { + var (label, container) = LayOutInBubble(LongText, fitToContent); + + Assert.True(container.ActualSize.Height > 100, "text should wrap onto several lines"); + Assert.True( + container.ActualSize.Width <= label.Bounds.Width + 0.5, + $"text laid out {container.ActualSize.Width} wide in a {label.Bounds.Width} wide control" + ); + Assert.True(label.Bounds.Width <= 520, $"control is {label.Bounds.Width} wide, offered 520"); + } + } + + [AvaloniaFact] + public void ShortText_FitToContent_TakesOnlyTheWidthItNeeds() + { + var (fitted, fittedContainer) = LayOutInBubble("hi!", fitToContent: true); + var (filled, _) = LayOutInBubble("hi!", fitToContent: false); + + Assert.True(fitted.DesiredSize.Width < 100, $"fitted control wants {fitted.DesiredSize.Width}"); + Assert.True(fittedContainer.ActualSize.Width <= fitted.Bounds.Width + 0.5); + Assert.Equal(520, filled.DesiredSize.Width, 0.5); + } + + /// + /// Lays the text out in a host shaped like an Image Lab chat bubble: an Auto grid column offers + /// unbounded width, the bubble's MaxWidth bounds it, and the StackPanel offers unbounded height. + /// + private static (HtmlControl Label, HtmlContainer Container) LayOutInBubble(string text, bool fitToContent) + { + var viewer = new MarkdownViewer { Text = text, FitToContent = fitToContent }; + var window = new Window + { + Width = 800, + Height = 600, + Content = new ScrollViewer + { + Content = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto"), + Children = + { + new Border + { + MaxWidth = 550, + Padding = new Thickness(14, 10), + Child = new StackPanel { MaxWidth = 520, Children = { viewer } }, + }, + }, + }, + }, + }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + + var label = viewer.GetVisualDescendants().OfType().Single(); + return (label, (HtmlContainer)HtmlContainerField.GetValue(label)!); + } + finally + { + window.Close(); + } + } +} diff --git a/StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs b/StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs new file mode 100644 index 000000000..6ce6f97e6 --- /dev/null +++ b/StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs @@ -0,0 +1,162 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Avalonia.VisualTree; +using ColorTextBlock.Avalonia; +using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels.Dialogs; +using StabilityMatrix.Avalonia.Views.Dialogs; + +namespace StabilityMatrix.UITests; + +[Collection("TempDir")] +public class ReleaseNotesLayoutTests +{ + private static string StressNotes => + "## Changed\n\n" + + string.Join( + " ", + Enumerable.Repeat("Long release notes must remain readable beside the scrollbar.", 40) + ) + + "\n\n## Fixed\n\n- **Windows ROCm** packages now come from AMD's permanent repositories, " + + string.Join( + " ", + Enumerable.Repeat( + "including `inline code` and [linked release notes](https://example.org/releases).", + 25 + ) + ) + + "\n - Nested lists also need to wrap without covering the scrollbar.\n\n" + + "```python\nprint('" + + new string('x', 300) + + "')\n```\n\n" + + string.Join("\n\n", Enumerable.Repeat("A final paragraph after the wide code block.", 20)); + + [AvaloniaFact] + public void ReleaseNotes_FitBesideScrollbar_AfterResizeAndScale() + { + foreach (var scale in new[] { 1.0, 1.25, 1.5, 2.0 }) + { + var viewer = new ReleaseNotesMarkdownViewer { Markdown = StressNotes }; + var window = new Window + { + Width = 700, + Height = 400, + Content = new LayoutTransformControl + { + Child = viewer, + LayoutTransform = new ScaleTransform(scale, scale), + }, + }; + try + { + window.Show(); + Dispatcher.UIThread.RunJobs(); + foreach (var width in new[] { 700, 520, 400, 700 }) + { + window.Width = width; + Dispatcher.UIThread.RunJobs(); + AssertDocumentFits(viewer); + var scroll = InnerScrollViewer(viewer); + scroll.Offset = new Vector(0, scroll.Extent.Height); + Dispatcher.UIThread.RunJobs(); + Assert.True(scroll.Offset.Y > 0); + AssertDocumentFits(viewer); + } + } + finally + { + window.Close(); + } + } + } + + [AvaloniaFact] + public async Task UpdateDialog_PreloadedNotes_FitBesideScrollbar() + { + var model = TestBase.Services.GetRequiredService(); + model.ShowProgressBar = false; + model.ReleaseNotes = StressNotes; + var dialog = new UpdateDialog { DataContext = model }; + var window = new Window + { + Width = 700, + Height = 700, + Content = new Grid(), + }; + var host = new BetterContentDialog + { + Content = dialog, + ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + IsFooterVisible = false, + }; + try + { + window.Show(); + var showTask = host.ShowAsync(window); + Dispatcher.UIThread.RunJobs(); + var viewer = dialog.GetVisualDescendants().OfType().Single(); + foreach (var width in new[] { 700, 632, 520 }) + { + window.Width = width; + Dispatcher.UIThread.RunJobs(); + Assert.True(viewer.Bounds.Height > 50); + AssertDocumentFits(viewer); + } + + var scroll = InnerScrollViewer(viewer); + scroll.Offset = new Vector(0, 1400); + Dispatcher.UIThread.RunJobs(); + SaveQaScreenshot(window); + host.Hide(); + Dispatcher.UIThread.RunJobs(); + await showTask; + } + finally + { + window.Close(); + } + } + + private static ScrollViewer InnerScrollViewer(BetterMarkdownScrollViewer viewer) => + viewer.GetVisualChildren().OfType().Single(); + + private static void AssertDocumentFits(BetterMarkdownScrollViewer viewer) + { + var scroll = InnerScrollViewer(viewer); + var bar = scroll + .GetVisualDescendants() + .OfType() + .Last(b => b.Orientation == Orientation.Vertical); + Assert.True(bar.IsVisible); + var contentRight = bar.TranslatePoint(new Point(), scroll)!.Value.X; + var texts = viewer.GetVisualDescendants().OfType().ToArray(); + Assert.NotEmpty(texts); + foreach (var text in texts) + { + var position = text.TranslatePoint(new Point(), scroll)!.Value; + Assert.True(text.Bounds.Height > 0); + Assert.True( + position.X + text.Bounds.Width <= contentRight + 1, + $"Text right={position.X + text.Bounds.Width}, scrollbar left={contentRight}, viewer width={viewer.Bounds.Width}" + ); + } + } + + private static void SaveQaScreenshot(Window window) + { + if (Environment.GetEnvironmentVariable("SM_RELEASE_NOTES_QA_DIR") is not { Length: > 0 } directory) + return; + + Directory.CreateDirectory(directory); + using var bitmap = new RenderTargetBitmap( + new PixelSize((int)window.Bounds.Width, (int)window.Bounds.Height) + ); + bitmap.Render(window); + bitmap.Save(Path.Combine(directory, "release-notes-dialog.png")); + } +} diff --git a/StabilityMatrix.UITests/TestAppBuilder.cs b/StabilityMatrix.UITests/TestAppBuilder.cs index c3b752c24..0f9ad4f3a 100644 --- a/StabilityMatrix.UITests/TestAppBuilder.cs +++ b/StabilityMatrix.UITests/TestAppBuilder.cs @@ -1,4 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Collections; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -13,6 +15,8 @@ using StabilityMatrix.Core.Updater; using StabilityMatrix.UITests; using StabilityMatrix.UITests.Fakes; +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Avalonia; [assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] @@ -31,7 +35,27 @@ public static AppBuilder BuildAvaloniaApp() return AppBuilder .Configure() .UseSkia() - .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }); + .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }) + .AfterSetup(_ => ClearHtmlRendererFontCache()); + } + + /// + /// HtmlRenderer (behind MarkdownViewer) caches fonts in a process-wide static, while each + /// headless test gets its own FontManager that is disposed when the test ends. A later test + /// would then measure text with a freed HarfBuzz font and crash the test host, so the cache is + /// emptied as each test's app is set up. The app itself has one FontManager for its lifetime. + /// + private static void ClearHtmlRendererFontCache() + { + const BindingFlags instance = BindingFlags.Instance | BindingFlags.NonPublic; + var adapter = typeof(HtmlControl) + .Assembly.GetType("TheArtOfDev.HtmlRenderer.Avalonia.Adapters.AvaloniaAdapter")! + .GetProperty("Instance", BindingFlags.Static | BindingFlags.Public)! + .GetValue(null)!; + var fontsHandler = typeof(RAdapter).GetField("_fontsHandler", instance)!.GetValue(adapter)!; + var fontsCache = (IDictionary) + fontsHandler.GetType().GetField("_fontsCache", instance)!.GetValue(fontsHandler)!; + fontsCache.Clear(); } private static void ConfigureGlobals() diff --git a/docs/release-notes/2.16.4.md b/docs/release-notes/2.16.4.md index d28cc22f2..714ede723 100644 --- a/docs/release-notes/2.16.4.md +++ b/docs/release-notes/2.16.4.md @@ -11,6 +11,7 @@ Extended technical notes for the v2.16.4 release — the deeper "how and why" be - [ComfyUI Cross Attention Launch Options](#comfyui-cross-attention-launch-options) - [Windows ROCm Package Repositories](#windows-rocm-package-repositories) - [AI-Toolkit on Pre-2.16.3 Installs](#ai-toolkit-on-pre-2163-installs) +- [Package Environment Variables and User Overrides](#package-environment-variables-and-user-overrides) - [Wan2GP Deepy and the Gradio Logging Wrapper](#wan2gp-deepy-and-the-gradio-logging-wrapper) ## Guarding User Data During Install and Uninstall @@ -61,6 +62,21 @@ Contributed by [@NeuralFault](https://github.com/NeuralFault). v2.16.3 moved AI-Toolkit to Python 3.12 and set `SETUPTOOLS_USE_DISTUTILS=local` so setuptools uses its bundled distutils. Installs from before v2.16.3 still run Python 3.11, where the standard-library distutils is present and the *local* setting re-arms setuptools' distutils shim, crashing jobs with a `distutils` error ([#1725](https://github.com/LykosAI/StabilityMatrix/issues/1725)). The environment setup now reads the venv's real Python version and uses *stdlib* below 3.12 and *local* on 3.12 and up, so older installs keep working without a reinstall. +## Package Environment Variables and User Overrides + +The AI-Toolkit fix above turned out to be one instance of a wider pattern: several packages set their own workaround environment variables — most commonly `SETUPTOOLS_USE_DISTUTILS` — directly on the venv process after it had already been seeded with whatever you'd configured under **Settings → Environment Variables**. That ordering meant a value you set yourself to fix something on your own machine was silently overwritten the moment the package launched. + +This surfaced on Wan2GP: it forced `SETUPTOOLS_USE_DISTUTILS=stdlib` unconditionally on every launch, which crashes with `ModuleNotFoundError: No module named 'distutils'` on a Python 3.12+ venv, since `distutils` was removed from the standard library entirely at that version. No environment variable override could survive to work around it, because the code never checked for one before overwriting it. The same unconditional force existed in AI Toolkit's SageAttention source build; VladAutomatic's equivalent path already had a Python-version guard. + +Two changes: + +- **User env vars win.** A new `Settings.IsEnvironmentVariableUserOverridden(key)` checks your actual configured environment variables (list or legacy dict format, disabled entries excluded), independent of what a package's own code has already written into the in-progress environment dictionary. The `env.SetPackageDefault(settingsManager, key, value)` extension applies a package's computed default only when that check comes back false, and replaces every unconditional `SETUPTOOLS_USE_DISTUTILS` write (Wan2GP, AI Toolkit, VladAutomatic, and PipWheelService's SageAttention build) along with Cogstudio's `UV_SKIP_WHEEL_FILENAME_CHECK`. Checking against your real settings rather than "is this key currently present" also means a package can still legitimately override its own base class's default — Reforge's Stable Diffusion repository URL over A3WebUI's, for one — without that being mistaken for a user override. +- **`SETUPTOOLS_USE_DISTUTILS` is version-gated everywhere it's set.** Wan2GP now picks `local` on Python 3.12+ and `stdlib` below it, matching the guard AI Toolkit gained above (`local` uses setuptools' own vendored `distutils` copy, which works on any Python version; `stdlib` requires the real module to exist). PipWheelService's SageAttention build gained the same guard VladAutomatic already had, having previously set the workaround unconditionally on every Python version. + +Cogstudio's temporary `UV_SKIP_WHEEL_FILENAME_CHECK` override — needed for one specific pinned wheel with a mismatched internal version — also had a cleanup step that unconditionally removed the variable afterward, which would have erased a value you'd set yourself for the rest of the venv's life. That's now a small reusable helper, `RunWithTemporaryEnvironmentVariableAsync`, which snapshots whatever was there before the override and restores exactly that when the operation finishes or throws. + +Deliberately out of scope: install-path-derived variables like `INVOKEAI_ROOT`, `COMFYUI_PATH`, and `UV_BUILD_CONSTRAINT`. Those record facts about the install rather than tunable workarounds, so letting a user override them would just break the launch rather than fix anything. + ## Wan2GP Deepy and the Gradio Logging Wrapper Wan2GP v13's **Deepy** panel repeatedly showed **Connection to server lost** and logged `issubclass() arg 1 must be a class` ([Wan2GP #2298](https://github.com/deepbeepmeep/Wan2GP/issues/2298)). Deepy registers `gradio.Error` as a FastAPI exception handler. Stability Matrix's console logging wrapper, which mirrors Gradio error messages to stderr so they appear in the package console, had replaced that class with a plain function, so the mounted app failed to build its middleware stack. Diagnosis was confirmed from [NeuralFault's comment](https://github.com/deepbeepmeep/Wan2GP/issues/2298#issuecomment-5657302034) on the upstream issue. diff --git a/docs/release-notes/2.16.5.md b/docs/release-notes/2.16.5.md new file mode 100644 index 000000000..805b85f9e --- /dev/null +++ b/docs/release-notes/2.16.5.md @@ -0,0 +1,66 @@ +# v2.16.5 Technical Notes + +Extended technical notes for the v2.16.5 release — the deeper "how and why" behind each change. For the user-facing summary, see the [CHANGELOG](https://github.com/LykosAI/StabilityMatrix/blob/main/CHANGELOG.md). + +[`Home`](../README.md) + +## Table of Contents + +- [Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13](#wan2gp-on-python-3-11-pytorch-2-10-and-cuda-13) +- [Node.js Extraction on Linux and macOS](#node-js-extraction-on-linux-and-macos) +- [Shared Model Folder Links Hiding DiffusionModels](#shared-model-folder-links-hiding-diffusionmodels) +- [Uninstall Size with Symlink Sharing](#uninstall-size-with-symlink-sharing) + +## Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13 + +Wan2GP's own installer (the GPU profiles in its `setup_config.json`) moved RTX 20XX and newer to Python 3.11 with a CUDA 13 build of PyTorch, keeping Python 3.10 only for GTX 10XX. Stability Matrix still installed Python 3.10 attention wheels, so a Python 3.11 venv failed with `sageattention ... has no wheels with a matching Python version tag`. + +The NVIDIA install now chooses its stack from the venv's Python version: + +- **Python 3.11 and newer** (any NVIDIA card except GTX 10XX): torch 2.10.0, torchvision 0.25.0 and torchaudio 2.10.0 from the `cu130` index, with their dependencies, so the CUDA 13 runtime (and Triton, on Linux) comes along. Ampere (RTX 30XX) and newer cards also get: + - Windows: `triton-windows` 3.6, SageAttention through the same wheel resolver ComfyUI uses (woct0rdho's post6 build for CUDA 13 and torch 2.10 or newer), and Wan2GP's Flash Attention wheel, which is published for Python 3.11 only. + - Linux: Wan2GP's own SageAttention build (the `cg1` wheel for CUDA 13 and torch 2.10). Flash Attention is skipped, since Wan2GP's Linux installer builds it from source. +- **Python 3.10**: the torch 2.7 stack as before, with `triton-windows` 3.3.1 and the Python 3.10 attention wheels on Ampere and newer. + +RTX 20XX gets the CUDA 13 PyTorch build and, as before, no Triton or attention wheels. New installs default to Python 3.11, or 3.10 on GTX 10XX. + +### Existing installs keep their stack + +Updates reuse the install's recorded Python version, and Wan2GP declares no minimum version that would trigger the venv-recreating upgrade prompt, so an install created on Python 3.10 keeps torch 2.7.1 and `triton-windows` 3.3.1 (on Windows) on every update. Triton can't be bumped alone: `triton-windows` 3.4 and later pair with torch 2.8 and later. Newer Wan2GP models that compile Triton kernels, such as LTX 2.5, can fail on that stack ([#1756](https://github.com/LykosAI/StabilityMatrix/issues/1756)). + +**Change Version** offers no Python choice either, so moving to the new stack means a fresh install: + +1. In the Package Manager, add Wan2GP again under a different name. The installer won't accept a name that's already in use. +2. Open **Advanced Options** and check that **Python Version** is 3.11 (the default on RTX 20XX and newer). +3. Once it's installed and working, copy your outputs and anything else you want to keep from the old install folder, then uninstall the old copy. + +### Driver check at launch + +CUDA 13 needs NVIDIA driver 580 or newer. Wan2GP's launch now runs the same check as ComfyUI and AI Toolkit: on an NVIDIA card other than GTX 10XX, if the driver's major version is below 580 and the venv's torch is a `+cu130` build, it prints a console warning recommending a driver update (or an older torch index such as cu128) and stops the launch. Python 3.10 installs, and systems where the driver version can't be read, launch unchecked. + +## Node.js Extraction on Linux and macOS + +On Linux and macOS, AI Toolkit's Node.js prerequisite comes from the official v20.19.3 `.tar.gz`, which contains relative symlinks for `npm`, `npx` and `corepack`. The bundled 7-Zip rejects those as dangerous link paths, so AI Toolkit installs on Linux failed before Python setup started ([#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767)). + +Rather than turning 7-Zip's protection off, the Unix Node install now goes through a small dedicated extractor built on .NET's `System.Formats.Tar`. It extracts directories and regular files first, keeping their Unix permissions so `node` stays executable, and creates the symlinks only after every file is out, so no archive entry can write through a link the same archive created. Each link is written as a relative link to its checked, normalized target. Extraction fails on: + +- absolute paths, backslash or drive/UNC spellings, and any path that resolves outside the destination; +- a link whose target is not a regular file extracted from the same archive, which also rules out directory links, link chains and missing targets; +- hard links and special entries such as devices and FIFOs; +- an existing symlink or junction anywhere along a destination path. + +Windows Node installs and every other archive extraction keep their existing behavior. + +## Shared Model Folder Links Hiding DiffusionModels + +Every SwarmUI install gets `Models/diffusion_models`, a link to `Models/DiffusionModels`. Since 2.16.4 the model index follows links but scans each physical folder at most once (see [Workflow Library Link Loops](2.16.4.md#workflow-library-link-loops)), so the alias and the real folder competed for one folder, and the scan order let the last-listed entry win. On NTFS `_` sorts after uppercase letters, so `diffusion_models` won every time. Diffusion models were then indexed only under the alias, whose name maps to no model type, so the Checkpoint Manager showed bare filenames without previews or metadata, and Image Lab and Inference missed them ([#1749](https://github.com/LykosAI/StabilityMatrix/issues/1749), [#1753](https://github.com/LykosAI/StabilityMatrix/issues/1753), [#1755](https://github.com/LykosAI/StabilityMatrix/issues/1755)). + +The rule now: the real directory tree is scanned first, then links are followed in order of how many links each path passes through, and the first path to reach a physical folder owns it. A real folder is the zero-link case, so it always beats an alias. If `DiffusionModels` is itself a link to another drive and the alias points at that link, the direct link is one hop and the alias two, so name sort order (or Linux's unordered listing) never decides. Links are resolved one at a time through each stored target, which is what counts the hops and also stops resolution passing through `subst` drive letters, where every file had been listed twice. + +Not covered: a hand-made link whose stored target uses a different drive alias (`T:\...` while the library is reached through `C:\...`) can still list files twice. Stability Matrix's own links are spelled from the models folder, so they can't hit this. + +Diagnosed and first fixed by [@NeuralFault](https://github.com/NeuralFault) ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743)), with the link-count ordering by [@ionite34](https://github.com/ionite34) ([#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763)). + +## Uninstall Size with Symlink Sharing + +For a package using symlink sharing, the package size shown in the uninstall confirmation (added in 2.16.4) could be the size of the whole shared model library. The count skipped links only among the package folder's direct children, then recursed with options that follow nested links, and symlink sharing puts its links one level down (`models/checkpoints`). It's now one enumeration that skips links at every depth and still counts hidden files, shared with the size shown when installing over an existing folder. Only the number was wrong: the delete checks for links at every level and only unlinks them, so shared models were never removed. diff --git a/docs/release-notes/2.17.0-pre.2.md b/docs/release-notes/2.17.0-pre.2.md new file mode 100644 index 000000000..627676f7c --- /dev/null +++ b/docs/release-notes/2.17.0-pre.2.md @@ -0,0 +1,101 @@ +# v2.17.0-pre.2 Technical Notes + +Extended technical notes for the v2.17.0-pre.2 release — the deeper "how and why" behind each change. For the user-facing summary, see the [CHANGELOG](https://github.com/LykosAI/StabilityMatrix/blob/main/CHANGELOG.md). + +[`Home`](../README.md) + +## Table of Contents + +- [Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13](#wan2gp-on-python-3-11-pytorch-2-10-and-cuda-13) +- [Package Environment Variables No Longer Silently Override Your Settings](#package-environment-variables-no-longer-silently-override-your-settings) +- [Node.js Extraction on Linux and macOS](#node-js-extraction-on-linux-and-macos) +- [Shared Model Folder Links Hiding DiffusionModels](#shared-model-folder-links-hiding-diffusionmodels) +- [Uninstall Size with Symlink Sharing](#uninstall-size-with-symlink-sharing) +- [Where Model Categories Are Stored](#where-model-categories-are-stored) +- [Thumbnail Cache for Local Images](#thumbnail-cache-for-local-images) + +## Wan2GP on Python 3.11, PyTorch 2.10 and CUDA 13 + +Wan2GP's own installer (the GPU profiles in its `setup_config.json`) moved RTX 20XX and newer to Python 3.11 with a CUDA 13 build of PyTorch, keeping Python 3.10 only for GTX 10XX. Stability Matrix still installed Python 3.10 attention wheels, so a Python 3.11 venv failed with `sageattention ... has no wheels with a matching Python version tag`. + +The NVIDIA install now chooses its stack from the venv's Python version: + +- **Python 3.11 and newer** (any NVIDIA card except GTX 10XX): torch 2.10.0, torchvision 0.25.0 and torchaudio 2.10.0 from the `cu130` index, with their dependencies, so the CUDA 13 runtime (and Triton, on Linux) comes along. Ampere (RTX 30XX) and newer cards also get: + - Windows: `triton-windows` 3.6, SageAttention through the same wheel resolver ComfyUI uses (woct0rdho's post6 build for CUDA 13 and torch 2.10 or newer), and Wan2GP's Flash Attention wheel, which is published for Python 3.11 only. + - Linux: Wan2GP's own SageAttention build (the `cg1` wheel for CUDA 13 and torch 2.10). Flash Attention is skipped, since Wan2GP's Linux installer builds it from source. +- **Python 3.10**: the torch 2.7 stack as before, with `triton-windows` 3.3.1 and the Python 3.10 attention wheels on Ampere and newer. + +RTX 20XX gets the CUDA 13 PyTorch build and, as before, no Triton or attention wheels. New installs default to Python 3.11, or 3.10 on GTX 10XX. + +### Existing installs keep their stack + +Updates reuse the install's recorded Python version, and Wan2GP declares no minimum version that would trigger the venv-recreating upgrade prompt, so an install created on Python 3.10 keeps torch 2.7.1 and `triton-windows` 3.3.1 (on Windows) on every update. Triton can't be bumped alone: `triton-windows` 3.4 and later pair with torch 2.8 and later. Newer Wan2GP models that compile Triton kernels, such as LTX 2.5, can fail on that stack ([#1756](https://github.com/LykosAI/StabilityMatrix/issues/1756)). + +**Change Version** offers no Python choice either, so moving to the new stack means a fresh install: + +1. In the Package Manager, add Wan2GP again under a different name. The installer won't accept a name that's already in use. +2. Open **Advanced Options** and check that **Python Version** is 3.11 (the default on RTX 20XX and newer). +3. Once it's installed and working, copy your outputs and anything else you want to keep from the old install folder, then uninstall the old copy. + +### Driver check at launch + +CUDA 13 needs NVIDIA driver 580 or newer. Wan2GP's launch now runs the same check as ComfyUI and AI Toolkit: on an NVIDIA card other than GTX 10XX, if the driver's major version is below 580 and the venv's torch is a `+cu130` build, it prints a console warning recommending a driver update (or an older torch index such as cu128) and stops the launch. Python 3.10 installs, and systems where the driver version can't be read, launch unchecked. + +## Package Environment Variables No Longer Silently Override Your Settings + +Several packages set their own workaround environment variables (most commonly `SETUPTOOLS_USE_DISTUTILS`, which keeps `distutils` importable for setuptools-based builds) directly on the venv process, after it had already been seeded with whatever you'd configured under **Settings → Environment Variables**. A value you set yourself was silently clobbered the moment the package launched. + +This surfaced on Wan2GP: it forced `SETUPTOOLS_USE_DISTUTILS=stdlib` on every launch, which crashes with `ModuleNotFoundError: No module named 'distutils'` on Python 3.12+, where `distutils` is gone from the standard library, and no user-set override could survive to work around it. The same unconditional force existed in AI Toolkit's SageAttention source build and VladAutomatic's Zluda/Rocm paths, though those already had a Python-version guard Wan2GP was missing. + +Two changes: + +- **User env vars win.** `Settings.IsEnvironmentVariableUserOverridden(key)` checks the *actual* configured environment variables (list or legacy dict format, `IsEnabled` respected), independent of what a package's own code has already written into the in-progress environment dictionary. A new `env.SetPackageDefault(settingsManager, key, value)` extension applies a package's computed default only when that check comes back false, and replaces every unconditional `SETUPTOOLS_USE_DISTUTILS` write (Wan2GP, AI Toolkit, VladAutomatic, and PipWheelService's SageAttention build) along with `Cogstudio`'s `UV_SKIP_WHEEL_FILENAME_CHECK` and `Reforge`/`A3WebUI`'s `STABLE_DIFFUSION_REPO`. Because the check is against your real settings rather than "is this key currently present," a package can still legitimately override its own base class's default (Reforge's Stable Diffusion repo URL over A3WebUI's) without that being mistaken for a user override. +- **`SETUPTOOLS_USE_DISTUTILS` is version-gated everywhere.** Wan2GP now picks `local` on Python 3.12+ and `stdlib` below it, matching the guard AI Toolkit already carried (`local` uses setuptools' own vendored `distutils` copy, which works on any Python version; `stdlib` requires the real module to exist). PipWheelService's SageAttention build gained the same `Minor < 12` guard VladAutomatic already had, since it previously set the workaround unconditionally on every Python version. + +Cogstudio's temporary `UV_SKIP_WHEEL_FILENAME_CHECK` override (needed for one specific pinned wheel with a mismatched internal version) also had a `finally` block that unconditionally removed the variable, wiping out a value you'd set yourself. That's now a reusable helper, `venvRunner.RunWithTemporaryEnvironmentVariableAsync(key, value, action)`, which snapshots whatever was there before the override and restores exactly that when the action completes or throws. + +Deliberately out of scope: install-path-derived variables like `INVOKEAI_ROOT`, `COMFYUI_PATH`, `COMFYUI_MODEL_PATH`, `UV_BUILD_CONSTRAINT`, and `PIP_CONSTRAINT`. Those record facts about the install rather than tunable workarounds, so letting a user override them would just break the launch rather than fix anything. + +## Node.js Extraction on Linux and macOS + +On Linux and macOS, AI Toolkit's Node.js prerequisite comes from the official v20.19.3 `.tar.gz`, which contains relative symlinks for `npm`, `npx` and `corepack`. The bundled 7-Zip rejects those as dangerous link paths, so AI Toolkit installs on Linux failed before Python setup started ([#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767)). + +Rather than turning 7-Zip's protection off, the Unix Node install now goes through a small dedicated extractor built on .NET's `System.Formats.Tar`. It extracts directories and regular files first, keeping their Unix permissions so `node` stays executable, and creates the symlinks only after every file is out, so no archive entry can write through a link the same archive created. Each link is written as a relative link to its checked, normalized target. Extraction fails on: + +- absolute paths, backslash or drive/UNC spellings, and any path that resolves outside the destination; +- a link whose target is not a regular file extracted from the same archive, which also rules out directory links, link chains and missing targets; +- hard links and special entries such as devices and FIFOs; +- an existing symlink or junction anywhere along a destination path. + +Windows Node installs and every other archive extraction keep their existing behavior. + +## Shared Model Folder Links Hiding DiffusionModels + +Every SwarmUI install gets `Models/diffusion_models`, a link to `Models/DiffusionModels`. Since 2.16.4 the model index follows links but scans each physical folder at most once (see [Workflow Library Link Loops](2.16.4.md#workflow-library-link-loops)), so the alias and the real folder competed for one folder, and the scan order let the last-listed entry win. On NTFS `_` sorts after uppercase letters, so `diffusion_models` won every time. Diffusion models were then indexed only under the alias, whose name maps to no model type, so the Checkpoint Manager showed bare filenames without previews or metadata, and Image Lab and Inference missed them ([#1749](https://github.com/LykosAI/StabilityMatrix/issues/1749), [#1753](https://github.com/LykosAI/StabilityMatrix/issues/1753), [#1755](https://github.com/LykosAI/StabilityMatrix/issues/1755)). + +The rule now: the real directory tree is scanned first, then links are followed in order of how many links each path passes through, and the first path to reach a physical folder owns it. A real folder is the zero-link case, so it always beats an alias. If `DiffusionModels` is itself a link to another drive and the alias points at that link, the direct link is one hop and the alias two, so name sort order (or Linux's unordered listing) never decides. Links are resolved one at a time through each stored target, which is what counts the hops and also stops resolution passing through `subst` drive letters, where every file had been listed twice. + +Not covered: a hand-made link whose stored target uses a different drive alias (`T:\...` while the library is reached through `C:\...`) can still list files twice. Stability Matrix's own links are spelled from the models folder, so they can't hit this. + +Diagnosed and first fixed by [@NeuralFault](https://github.com/NeuralFault) ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743)), with the link-count ordering by [@ionite34](https://github.com/ionite34) ([#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763)). + +## Uninstall Size with Symlink Sharing + +For a package using symlink sharing, the package size shown in the uninstall confirmation (added in 2.16.4) could be the size of the whole shared model library. The count skipped links only among the package folder's direct children, then recursed with options that follow nested links, and symlink sharing puts its links one level down (`models/checkpoints`). It's now one enumeration that skips links at every depth and still counts hidden files, shared with the size shown when installing over an existing folder. Only the number was wrong: the delete checks for links at every level and only unlinks them, so shared models were never removed. + +## Where Model Categories Are Stored + +Model categories live in each model's existing `.cm-info.json`, in a `User` block holding the category names (plus a flag for models you disconnected from their source). Membership travels with the file through moves, renames and folder copies; settings hold only each category's name and colour. + +Every `.cm-info.json` write goes through one save path that carries the `User` block already on disk across when the data being written has none, so downloads and metadata refreshes can't drop your categories. Since a category alone creates a sidecar, "linked to a source" now means a CivitAI model id, an OpenModelDb or CivArchive source, or a HuggingFace source URL, not just having a `.cm-info.json`. Bulk **Find Connected Metadata** scans every unlinked model except ones you disconnected. + +## Thumbnail Cache for Local Images + +Checkpoint Manager cards had no image cache and the Outputs grid keeps only 64 decoded images in memory, so scrolling back decoded the full source file again. Local image loads now go through an on-disk cache of downscaled WebP thumbnails in `StabilityMatrix/Cache/Thumbnails` under the system temp folder. + +- A thumbnail's key covers the source's path, size, last-write time and the requested width, so an edited or replaced file gets a new one. Images no wider than the requested width aren't stored. +- A thumbnail that won't decode is deleted and rebuilt. +- The first image load of each session starts a background sweep that deletes thumbnails nobody has shown for 30 days, which keeps thumbnails of deleted outputs from piling up. +- Source files are never modified. Preview images and outputs stay full resolution, and anything that asks for the full image still decodes the original. + +The decode width is now in device-independent pixels times the window's display scaling, so thumbnails are sharp at 150% and 200%. At 200% each decoded thumbnail has four times the pixels, so the Outputs memory cache can use up to four times the memory; at 100% nothing changes. On 40 real outputs averaging 2.8 MB, a cached 600 px thumbnail loaded in 3.4 ms against 113 ms for a full decode, at about 46 KB on disk each.