From d9a0d70f3e266d5d62f763fa5957a2594128cfbc Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 15 Sep 2026 19:20:29 -0700 Subject: [PATCH 01/32] docs: add v2.17.0-pre.2 technical release notes Extended write-up of the env-var-override fix (PR #1400): the mechanism behind SETUPTOOLS_USE_DISTUTILS forcing failures on Python 3.12+, the SetPackageDefault/IsEnvironmentVariableUserOverridden precedence design, the version-gate applied everywhere the workaround is set, and the RunWithTemporaryEnvironmentVariableAsync helper Cogstudio now uses. Co-Authored-By: Lykos (Sonnet 5) --- docs/release-notes/2.17.0-pre.2.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docs/release-notes/2.17.0-pre.2.md 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..8c39b4083 --- /dev/null +++ b/docs/release-notes/2.17.0-pre.2.md @@ -0,0 +1,24 @@ +# 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 + +- [Package Environment Variables No Longer Silently Override Your Settings](#package-environment-variables-no-longer-silently-override-your-settings) + +## Package Environment Variables No Longer Silently Override Your Settings + +Several packages set their own workaround environment variables — most commonly `SETUPTOOLS_USE_DISTUTILS`, used to keep `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**. That ordering meant a value you set yourself to fix something on your own machine was silently clobbered the moment the package launched, with no indication anything had overridden it. + +This surfaced concretely 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 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 — which would have wiped out a value you'd set yourself for the rest of the venv's life. That's now a small reusable helper, `venvRunner.RunWithTemporaryEnvironmentVariableAsync(key, value, action)`, which snapshots whatever was there before the override, restores exactly that when the action completes or throws, and is generic enough to reach for the next time a package needs a scoped, one-shot env var override. + +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. From 3e5766c0ea2e8b5ba1495fb2d406e85d72d4f349 Mon Sep 17 00:00:00 2001 From: Ionite Date: Tue, 15 Sep 2026 19:25:11 -0700 Subject: [PATCH 02/32] docs: add env var override section to v2.16.4 technical notes The distutils/environment-variable fix (#1400) landed as an additional Fixed entry under the already-shipped v2.16.4 CHANGELOG section rather than a new version bump, via the dev->main backport (#1402). This adds the matching section to the v2.16.4 technical notes page, right after AI-Toolkit's own distutils fix it extends. Co-Authored-By: Lykos (Sonnet 5) --- docs/release-notes/2.16.4.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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. From a3c137d2689a17887d1e79dc02a9f60670aae4b7 Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 30 Sep 2026 09:51:46 -0700 Subject: [PATCH 03/32] Merge pull request #1407 from ionite34/fix/comfy-python313-numpy Fix ComfyUI NumPy constraint on Python 3.13 (cherry picked from commit fb5128edc36dcffc6ada11a7e00ec161f4a2566e) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 21 ++++++ .../Models/Packages/ComfyUI.cs | 28 +++++--- .../Packages/ComfyUIInstallConfigTests.cs | 68 +++++++++++++++++++ 3 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 StabilityMatrix.Tests/Models/Packages/ComfyUIInstallConfigTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ae12ceda2..2fbed4e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.4 ### Added +<<<<<<< HEAD +======= +- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker +- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> fb5128ed (Merge pull request #1407 from ionite34/fix/comfy-python313-numpy) - Added **Comfy Kitchen Attention** (`--use-ck-attention`) as a **Cross Attention Method** launch option for ComfyUI and ComfyUI-Zluda - thanks to @e-nord! ### Changed - **Windows ROCm** PyTorch installs now come from AMD's new permanent ROCm 10 package repositories, avoiding the `CUDA error: invalid argument` / `hipErrorInvalidValue` failures reported with the ROCm 7.14.1 builds — packages already on 7.14.1 need a reinstall, or an update that also upgrades PyTorch (like ComfyUI's) - thanks to @NeuralFault! diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index dc6c2cdc0..2ae310629 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -521,17 +521,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, @@ -622,6 +612,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, 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()); + } +} From c89c2bbbb07e173614cfc52cc96dc921d9ca039c Mon Sep 17 00:00:00 2001 From: jt Date: Wed, 30 Sep 2026 09:55:18 -0700 Subject: [PATCH 04/32] Resolve backport changelog conflict for Python 3.13 NumPy fix --- CHANGELOG.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbed4e65..a9a223f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,31 +7,11 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.4 ### Added -<<<<<<< HEAD -======= -- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker -- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> fb5128ed (Merge pull request #1407 from ionite34/fix/comfy-python313-numpy) - Added **Comfy Kitchen Attention** (`--use-ck-attention`) as a **Cross Attention Method** launch option for ComfyUI and ComfyUI-Zluda - thanks to @e-nord! ### Changed - **Windows ROCm** PyTorch installs now come from AMD's new permanent ROCm 10 package repositories, avoiding the `CUDA error: invalid argument` / `hipErrorInvalidValue` failures reported with the ROCm 7.14.1 builds — packages already on 7.14.1 need a reinstall, or an update that also upgrades PyTorch (like ComfyUI's) - thanks to @NeuralFault! ### Fixed +- 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)) - Installing a package into a folder that already contains files — like an existing ComfyUI installation placed in `Data\Packages` to be imported — now shows the folder's total size and file count and requires explicit confirmation, instead of silently deleting everything in it; this includes the first-run one-click installer ([#1733](https://github.com/LykosAI/StabilityMatrix/issues/1733)) - Turning off shared model folders no longer tries to delete real directories at the link locations — only links created by Stability Matrix are removed, so the model folders of an imported package are never touched ([#1733](https://github.com/LykosAI/StabilityMatrix/issues/1733)) - The package uninstall confirmation now correctly lists models/checkpoints among the items that will be deleted for packages using config-based model sharing (ComfyUI's default) — previously it implied they were safe — and shows the package folder's total size From 1c3260f2fbe19d45e4da1f712b9362332b3c8f61 Mon Sep 17 00:00:00 2001 From: jt Date: Wed, 30 Sep 2026 10:22:48 -0700 Subject: [PATCH 05/32] Document NumPy backport under forthcoming v2.16.5 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9a223f18..746f22361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,16 @@ 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 +### Fixed +- 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)) + ## 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! ### Changed - **Windows ROCm** PyTorch installs now come from AMD's new permanent ROCm 10 package repositories, avoiding the `CUDA error: invalid argument` / `hipErrorInvalidValue` failures reported with the ROCm 7.14.1 builds — packages already on 7.14.1 need a reinstall, or an update that also upgrades PyTorch (like ComfyUI's) - thanks to @NeuralFault! ### Fixed -- 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)) - Installing a package into a folder that already contains files — like an existing ComfyUI installation placed in `Data\Packages` to be imported — now shows the folder's total size and file count and requires explicit confirmation, instead of silently deleting everything in it; this includes the first-run one-click installer ([#1733](https://github.com/LykosAI/StabilityMatrix/issues/1733)) - Turning off shared model folders no longer tries to delete real directories at the link locations — only links created by Stability Matrix are removed, so the model folders of an imported package are never touched ([#1733](https://github.com/LykosAI/StabilityMatrix/issues/1733)) - The package uninstall confirmation now correctly lists models/checkpoints among the items that will be deleted for packages using config-based model sharing (ComfyUI's default) — previously it implied they were safe — and shows the package folder's total size From 7d7ea36804871be90b21302531608572f48dc69f Mon Sep 17 00:00:00 2001 From: JT Date: Wed, 30 Sep 2026 10:25:33 -0700 Subject: [PATCH 06/32] Merge pull request #1409 from ionite34/fix/release-notes-overflow Fix release notes clipping beside the update dialog scrollbar (#1757) (cherry picked from commit 537065930654e6a15682b46c15c09a5fc638d52b) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 23 +++ .../Controls/ReleaseNotesMarkdownViewer.cs | 24 +++ .../Views/Dialogs/UpdateDialog.axaml | 2 +- .../ReleaseNotesLayoutTests.cs | 162 ++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 StabilityMatrix.Avalonia/Controls/ReleaseNotesMarkdownViewer.cs create mode 100644 StabilityMatrix.UITests/ReleaseNotesLayoutTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ae12ceda2..ea0d7afea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.4 ### Added +<<<<<<< HEAD +======= +- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. +- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker +- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> 53706593 (Merge pull request #1409 from ionite34/fix/release-notes-overflow) - Added **Comfy Kitchen Attention** (`--use-ck-attention`) as a **Cross Attention Method** launch option for ComfyUI and ComfyUI-Zluda - thanks to @e-nord! ### Changed - **Windows ROCm** PyTorch installs now come from AMD's new permanent ROCm 10 package repositories, avoiding the `CUDA error: invalid argument` / `hipErrorInvalidValue` failures reported with the ROCm 7.14.1 builds — packages already on 7.14.1 need a reinstall, or an update that also upgrades PyTorch (like ComfyUI's) - thanks to @NeuralFault! 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/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.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")); + } +} From 6f2b13b43bff3d647b5e6d1fcbba0ec385fa88cc Mon Sep 17 00:00:00 2001 From: jt Date: Wed, 30 Sep 2026 10:27:17 -0700 Subject: [PATCH 07/32] Resolve release notes backport changelog under v2.16.5 --- CHANGELOG.md | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0d7afea..cc335f511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,31 +5,12 @@ 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.4 -### Added -<<<<<<< HEAD -======= -- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. -- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker -- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> 53706593 (Merge pull request #1409 from ionite34/fix/release-notes-overflow) + +## 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! ### Changed - **Windows ROCm** PyTorch installs now come from AMD's new permanent ROCm 10 package repositories, avoiding the `CUDA error: invalid argument` / `hipErrorInvalidValue` failures reported with the ROCm 7.14.1 builds — packages already on 7.14.1 need a reinstall, or an update that also upgrades PyTorch (like ComfyUI's) - thanks to @NeuralFault! From 78e3083551f09bc4413b7722ba066162d952a987 Mon Sep 17 00:00:00 2001 From: jt Date: Wed, 30 Sep 2026 16:41:35 -0700 Subject: [PATCH 08/32] Backport base-model navigation filter fix for 2.16.5 Backport only the base-model navigation portion of private PR #1390. Capture the originating request filter for cached and remote results, then reuse the existing details filter pipeline after refresh and Next/Previous navigation. Preserve direct-ID and unfiltered searches. Co-Authored-By: GPT-6 --- CHANGELOG.md | 1 + .../CivitAiBrowserViewModel.cs | 14 +- .../CivitDetailsPageViewModel.cs | 13 +- .../Avalonia/CivitBrowserFilterTests.cs | 275 ++++++++++++++++++ 4 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 StabilityMatrix.Tests/Avalonia/CivitBrowserFilterTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ef015aa..31701936d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.5 ### Fixed +- 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 release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) - 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)) 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.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); + } +} From 01bfb33e09f56ebf9ce455a34d41173f252662f3 Mon Sep 17 00:00:00 2001 From: jt Date: Wed, 30 Sep 2026 23:30:04 -0700 Subject: [PATCH 09/32] Remove contributor shoutouts section from 2.16.5 notes --- CHANGELOG.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa716bdd..4e31c2288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,6 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location - thanks to @NeuralFault and @ionite34 ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743), [#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763))! - Fixed release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) - 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)) -### Shoutouts -- Thanks to @NeuralFault for diagnosing and fixing shared model folder alias shadowing. -- Thanks to @ionite34 for simplifying link traversal and fixing aliases of linked model folders. -- Thanks to @mohnjiles for the Python 3.13 ComfyUI installation fix and update-dialog release-note layout fix. ## v2.16.4 ### Added From 8cd1881266c8b9cd9a8b21f3d15f9fc78fb17608 Mon Sep 17 00:00:00 2001 From: jt Date: Wed, 30 Sep 2026 23:30:56 -0700 Subject: [PATCH 10/32] Match inline contributor credit convention --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e31c2288..115e354d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.5 ### Fixed -- Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location - thanks to @NeuralFault and @ionite34 ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743), [#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763))! +- Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location ([#1743](https://github.com/LykosAI/StabilityMatrix/pull/1743), [#1763](https://github.com/LykosAI/StabilityMatrix/pull/1763)) - Thanks to @NeuralFault and @ionite34! - Fixed release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) - 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)) From 116f9f6a76c0360d52bfcfef543fafd5ff9a2a5b Mon Sep 17 00:00:00 2001 From: jt Date: Thu, 1 Oct 2026 15:27:21 -0700 Subject: [PATCH 11/32] Require published ControlNet compatibility fixes in Inference Raise only the AIO preprocessor minimum to 0.5.2, the first published Inference Core Nodes tag containing the namespaced ControlNet imports and Python 3.12/3.13 installation support. Earlier tags could satisfy the old 0.2.0 floor without either compatibility fix. Exercise a real preprocessor workflow's extension specifier against older and current installed tags. Record the change under 2.16.5. Co-Authored-By: Codex --- CHANGELOG.md | 1 + .../Api/Comfy/Nodes/ComfyNodeBuilder.cs | 2 +- .../ControlNetExtensionRequirementTests.cs | 47 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 StabilityMatrix.Tests/Core/ControlNetExtensionRequirementTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 284b9ea43..6cf4949a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.5 ### Fixed +- ControlNet preprocessing in Inference now offers to update older tagged Inference Core Nodes installations to a version with the ControlNet import fixes and Python 3.12/3.13 installation support. - Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location ([#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 release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) 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.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!)); + } +} From 4d30b92983ca593322aba18d154a60d7f81ce4b6 Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 2 Oct 2026 08:48:21 -0700 Subject: [PATCH 12/32] Backport ComfyUI SageAttention update fix for 2.16.5 --- CHANGELOG.md | 1 + .../Models/Packages/ComfyUI.cs | 92 ++++---- .../Services/PipWheelService.cs | 191 +++++++++++----- .../ComfyUISageAttentionUpdateTests.cs | 115 ++++++++++ .../Services/PipWheelServiceTests.cs | 208 ++++++++++++++++++ 5 files changed, 505 insertions(+), 102 deletions(-) create mode 100644 StabilityMatrix.Tests/Models/Packages/ComfyUISageAttentionUpdateTests.cs create mode 100644 StabilityMatrix.Tests/Services/PipWheelServiceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf4949a4..cec558ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.16.5 ### Fixed +- Fixed ComfyUI updates downgrading SageAttention post6 to post4 and reinstalling compatible wheels ([#1764](https://github.com/LykosAI/StabilityMatrix/issues/1764)). - ControlNet preprocessing in Inference now offers to update older tagged Inference Core Nodes installations to a version with the ControlNet import fixes and Python 3.12/3.13 installation support. - Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location ([#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)) diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index 2ae310629..86f360227 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; @@ -535,56 +536,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 @@ -1014,6 +966,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/Services/PipWheelService.cs b/StabilityMatrix.Core/Services/PipWheelService.cs index a0e23a494..f4c24dc7a 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) { - // Extract torch version from asset name (e.g., "torch2.9.0andhigher" -> "2.9.0") - var match = Regex.Match(assetName, @"torch(\d+\.\d+\.\d+)"); + 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) + { + // 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( 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 }; +} From ddef6370c34f8e698ab8a0adbdcecc8b3a227281 Mon Sep 17 00:00:00 2001 From: JT Date: Fri, 2 Oct 2026 11:01:52 -0700 Subject: [PATCH 13/32] Merge pull request #1422 from ionite34/fix/civarchive-web-links-1765 Fix CivArchive model links resolving to file URLs on Linux (cherry picked from commit bbc161a3b2421e6c9268803055e5f05955753530) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 214 ++++++++++++++++++ .../Api/CivArchiveApiClient.cs | 4 +- .../Core/CivArchiveApiClientTests.cs | 40 ++++ 3 files changed, 255 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf4949a4..5a2c7e903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,220 @@ 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). +<<<<<<< HEAD +======= +## v2.17.0-pre.2 +### Added +- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. +- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. +- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker +- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> bbc161a3 (Merge pull request #1422 from ionite34/fix/civarchive-web-links-1765) ## v2.16.5 ### Fixed - ControlNet preprocessing in Inference now offers to update older tagged Inference Core Nodes installations to a version with the ControlNet import fixes and Python 3.12/3.13 installation support. 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.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() { From f54b1cb6954cf864dfbbee4c364cb6e355dfd7e1 Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 2 Oct 2026 11:03:42 -0700 Subject: [PATCH 14/32] Resolve CivArchive backport changelog for v2.16.5 Keep the stable changelog and place only the approved issue-1765 fix under v2.16.5, removing the automation's committed conflict markers. Co-Authored-By: GPT-6 --- CHANGELOG.md | 215 +-------------------------------------------------- 1 file changed, 1 insertion(+), 214 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2c7e903..069320b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,222 +5,9 @@ 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). -<<<<<<< HEAD -======= -## v2.17.0-pre.2 -### Added -- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. -- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. -- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker -- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> bbc161a3 (Merge pull request #1422 from ionite34/fix/civarchive-web-links-1765) ## v2.16.5 ### Fixed +- Fixed **Open on CivArchive** opening a file URL instead of the model page on Linux ([#1765](https://github.com/LykosAI/StabilityMatrix/issues/1765)). - ControlNet preprocessing in Inference now offers to update older tagged Inference Core Nodes installations to a version with the ControlNet import fixes and Python 3.12/3.13 installation support. - Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location ([#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)) From 78d28cd17f32420f754e642ca9c78edf1c5c9f02 Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 9 Oct 2026 19:43:19 -0700 Subject: [PATCH 15/32] docs(changelog): add Fizgig and ROCm torch/torchvision pairing entries Changelog entries for public PRs LykosAI/StabilityMatrix#1761 (Fizgig, @e-nord) and #1769 (matched ROCm torch/torchvision pair, @NeuralFault). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e22549c17..419fa7b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ 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 +### 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! ### Fixed - Fixed **Open on CivArchive** opening a file URL instead of the model page on Linux ([#1765](https://github.com/LykosAI/StabilityMatrix/issues/1765)). - Fixed ComfyUI updates downgrading SageAttention post6 to post4 and reinstalling compatible wheels ([#1764](https://github.com/LykosAI/StabilityMatrix/issues/1764)). @@ -14,6 +16,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - 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 release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) - 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 **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision`; a matched pair is now always installed together ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! ## v2.16.4 ### Added From 57a32da028d57ec6b8363c35764461ff5ca41d4f Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 11 Oct 2026 15:00:35 -0700 Subject: [PATCH 16/32] docs(changelog): Forge Neo ROCm + --home-dir log path for v2.16.5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 419fa7b5f..06c289886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 ## v2.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! ### Fixed - Fixed **Open on CivArchive** opening a file URL instead of the model page on Linux ([#1765](https://github.com/LykosAI/StabilityMatrix/issues/1765)). - Fixed ComfyUI updates downgrading SageAttention post6 to post4 and reinstalling compatible wheels ([#1764](https://github.com/LykosAI/StabilityMatrix/issues/1764)). @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) - 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 **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision`; a matched pair is now always installed together ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! +- Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice; the app's own folders are now recreated when the library loads ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! ## v2.16.4 ### Added From 208b6a81427f1e4a096f23dee46a977db6fcb1a6 Mon Sep 17 00:00:00 2001 From: jt Date: Sun, 11 Oct 2026 17:00:01 -0700 Subject: [PATCH 17/32] docs(changelog): supporters shoutout for v2.16.5 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c289886..b6c4a6491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - 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 **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision`; a matched pair is now always installed together ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! - Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice; the app's own folders are now recreated when the library loads ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! +### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 +#### 🚀 Pioneers +To our Pioneers **SinthCore**, **Jisuren**, **jweg79**, **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. Welcome back, **Tuskaruho**, it's lovely to see your name here again. And hi **acleveranagram**, our newest Pioneer; we're really glad to have you! 💛 ## v2.16.4 ### Added From 415865251b579f737cfba035ea990e7bb0a3f986 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 11 Oct 2026 19:06:50 -0700 Subject: [PATCH 18/32] Merge pull request #1424 from ionite34/fix-node-tar-extraction-dev Fix Unix Node extraction with safe relative symlinks (cherry picked from commit a226ac820d3702167f9f4d7ae28c230b3713d96a) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 224 ++++++++++++++ .../Helpers/UnixPrerequisiteHelper.cs | 2 +- .../Helper/TarGZipExtractor.cs | 113 +++++++ .../Core/TarGZipExtractorTests.cs | 285 ++++++++++++++++++ 4 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 StabilityMatrix.Core/Helper/TarGZipExtractor.cs create mode 100644 StabilityMatrix.Tests/Core/TarGZipExtractorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c4a6491..e64b79895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,229 @@ 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). +<<<<<<< HEAD +======= +## v2.17.0-pre.2 +### Added +- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. +- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. +- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker +- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> a226ac82 (Merge pull request #1424 from ionite34/fix-node-tar-extraction-dev) ## v2.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! @@ -19,6 +242,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - 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 **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision`; a matched pair is now always installed together ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! - Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice; the app's own folders are now recreated when the library loads ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! +- Fixed [#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767) - **AI-Toolkit** installation failing on Linux while unpacking Node.js with a "Dangerous link path" error. ### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 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.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.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 }; +} From ea12d66af8dfee63ed7f9c48b66c27d6286b0393 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 11 Oct 2026 19:33:17 -0700 Subject: [PATCH 19/32] fix chagenlog --- CHANGELOG.md | 223 --------------------------------------------------- 1 file changed, 223 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64b79895..ffe4a4985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,229 +5,6 @@ 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). -<<<<<<< HEAD -======= -## v2.17.0-pre.2 -### Added -- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. -- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. -- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker -- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> a226ac82 (Merge pull request #1424 from ionite34/fix-node-tar-extraction-dev) ## v2.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! From e62acc44d08d5f87a09613d7a7ada7d32a8caf78 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 11 Oct 2026 21:08:48 -0700 Subject: [PATCH 20/32] Merge pull request #1435 from ionite34/fix/package-delete-size fix: uninstall confirmation counted shared models behind symlinks (cherry picked from commit 4426a791ee917416355d00f2cf033354aefabde2) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 226 ++++++++++++++++++ StabilityMatrix.Avalonia/DialogHelper.cs | 17 +- .../Helper/EnumerationOptionConstants.cs | 25 +- .../Models/FileInterfaces/DirectoryPath.cs | 27 +-- .../Core/DirectoryPathSizeTests.cs | 72 ++++++ 5 files changed, 331 insertions(+), 36 deletions(-) create mode 100644 StabilityMatrix.Tests/Core/DirectoryPathSizeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe4a4985..6c27baacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,231 @@ 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). +<<<<<<< HEAD +======= +## v2.17.0-pre.2 +### Added +- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. +- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. +- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker +- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> 4426a791 (Merge pull request #1435 from ionite34/fix/package-delete-size) ## v2.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! @@ -20,6 +245,7 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision`; a matched pair is now always installed together ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! - Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice; the app's own folders are now recreated when the library loads ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! - Fixed [#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767) - **AI-Toolkit** installation failing on Linux while unpacking Node.js with a "Dangerous link path" error. +- Fixed the package uninstall confirmation showing the size of your whole shared model library for packages using symlink sharing. Only the package's own files are counted; the shared models were never deleted ### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 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.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/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.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)); + } +} From f16da95c5e00edd8d3c40ce97ac60f99bc29e461 Mon Sep 17 00:00:00 2001 From: JT Date: Sun, 11 Oct 2026 21:25:05 -0700 Subject: [PATCH 21/32] fix chagenlog --- CHANGELOG.md | 225 --------------------------------------------------- 1 file changed, 225 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c27baacf..503b93be2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,231 +5,6 @@ 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). -<<<<<<< HEAD -======= -## v2.17.0-pre.2 -### Added -- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. -- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. -- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker -- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> 4426a791 (Merge pull request #1435 from ionite34/fix/package-delete-size) ## v2.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! From 8db1640340c2335399db7dee4359fd080579b68c Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 12 Oct 2026 17:13:40 -0700 Subject: [PATCH 22/32] Merge pull request #1437 from ionite34/fix/wan2gp-python-311 Fix Wan2GP installs on Python 3.11 (torch 2.10 / CUDA 13 stack) (cherry picked from commit 0dc7ba8feddd0729eb86290bc57e908bb5a865b7) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 228 ++++++++++++++++++ .../Models/Packages/Wan2GP.cs | 131 +++++++++- 2 files changed, 348 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 503b93be2..5dbdcab82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,234 @@ 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). +<<<<<<< HEAD +======= +## v2.17.0-pre.2 +### Added +- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. +- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. +- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker +- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> 0dc7ba8f (Merge pull request #1437 from ionite34/fix/wan2gp-python-311) ## v2.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! 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()) From 3e2086e090cdfaf774c85e095e57590322318bcd Mon Sep 17 00:00:00 2001 From: JT Date: Mon, 12 Oct 2026 17:37:50 -0700 Subject: [PATCH 23/32] fix chagenlog --- CHANGELOG.md | 229 +-------------------------------------------------- 1 file changed, 1 insertion(+), 228 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbdcab82..45a33f966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,234 +5,6 @@ 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). -<<<<<<< HEAD -======= -## v2.17.0-pre.2 -### Added -- ComfyUI configuration sharing now includes `Models/FrameInterpolation` for core FILM/RIFE models and `Models/GeometryEstimation` for core MoGe/Depth Anything 3 models. Existing package-local models stay in place; custom paths in other top-level YAML sections are retained. -- Inference output filename patterns now support `{lora_name}` for the LoRAs used in a generation ([#1759](https://github.com/LykosAI/StabilityMatrix/issues/1759)). Multiple LoRA names are sorted and joined with underscores; generations without LoRAs use `no_lora`. Long output names are shortened automatically with a hash while keeping their extension and batch or collision suffixes. -- **Model categories** in the Checkpoint Manager — tag any model (LoRAs, checkpoints, anything) with as many custom categories as you like, e.g. *Character*, *Style*, *Concept*. Categories show as coloured chips on the cards, filter from the new **Categories** section in the sidebar (or with `@name` in the search box), and can be assigned in bulk: select models and use **Categories** in the selection bar, right-click → **Set Categories**, or drop cards onto a category. Stored in the model's existing `.cm-info.json`, so they survive moves, renames, folder copies and metadata refreshes — the most requested item on the feature tracker -- Categories reach **Inference** too: the model picker dialog gets a **Categories** filter row and shows each model's category chips, and typing a category name filters any model dropdown as well as the `>>>>>> 0dc7ba8f (Merge pull request #1437 from ionite34/fix/wan2gp-python-311) ## v2.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! @@ -249,6 +21,7 @@ This build leans hard into what's next: a live HuggingFace browser and Wan 2.2 v - Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice; the app's own folders are now recreated when the library loads ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! - Fixed [#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767) - **AI-Toolkit** installation failing on Linux while unpacking Node.js with a "Dangerous link path" error. - Fixed the package uninstall confirmation showing the size of your whole shared model library for packages using symlink sharing. Only the package's own files are counted; the shared models were never deleted +- 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, the same setup Wan2GP's own installer uses, including matching Triton, SageAttention and Flash Attention. GTX 10XX cards stay on Python 3.10, and existing Python 3.10 installs keep updating as before. CUDA 13 needs NVIDIA driver 580 or newer ### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 From 9aab6b0fa3e67c6e8fd4b9ed3412d2b2803e1ad9 Mon Sep 17 00:00:00 2001 From: jt Date: Mon, 12 Oct 2026 19:13:25 -0700 Subject: [PATCH 24/32] Backport: Fix Image Lab text clipping, emoji, and generation flows Text in Image Lab bubbles and thinking blocks lost its right edge because HtmlPanel reports the width of its first layout pass (18px held back for its scrollbar) and then lays the text out at full width. MarkdownViewer now uses HtmlLabel. HtmlRenderer also splits every emoji into two U+FFFD halves (its CJK word-break range covers the surrogates), so astral characters are passed in as character references. Neither is fixed upstream through 12.0. - Message bubbles fit their text (MarkdownHtmlLabel.FitToContent). - Send, Retry and Regenerate share one runner. A finished generation reloads its conversation from the database only if it is still on screen, refreshing the conversation list no longer switches conversations, and the Retry banner belongs to the failed conversation. - Regenerate keeps the old reply until the new one is saved; retry history is now the messages before the last user message. - Save & Regenerate reuses Regenerate: later images stay on disk, attached images are kept, and it can be cancelled. - Cancelling a Gemini generation reports "Cancelled", not a failure. - Removing one image from a message only deletes files Image Lab owns. - Auto-scroll follows new content when the view was at the bottom. - A ComfyUI disconnect cancels a local run even after switching to a conversation that uses Gemini. - UITests clear HtmlRenderer's static font cache per test, since each headless test disposes its FontManager. Cherry-picked from #1440 (a262d60e); CHANGELOG entries placed under v2.16.5. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 + .../Controls/MarkdownHtmlLabel.cs | 54 ++ .../Controls/MarkdownViewer.axaml | 8 +- .../Controls/MarkdownViewer.axaml.cs | 75 +- .../Models/BananaVision/Messages.cs | 4 +- .../ViewModels/BananaVisionPageViewModel.cs | 694 +++++------------- .../Views/BananaVisionPage.axaml | 17 +- .../Views/BananaVisionPage.axaml.cs | 9 +- .../GeminiBaseImageGenerationProvider.cs | 5 + .../ImageGenerationChatService.cs | 35 +- .../Avalonia/MarkdownViewerTests.cs | 37 + .../Avalonia/ThinkingMessageTests.cs | 15 + .../GeminiImageGenerationProviderTests.cs | 45 ++ .../Core/ImageGenerationChatServiceTests.cs | 73 ++ .../BananaVisionPageTests.cs | 290 ++++++++ .../MarkdownViewerLayoutTests.cs | 92 +++ StabilityMatrix.UITests/TestAppBuilder.cs | 28 +- 17 files changed, 960 insertions(+), 532 deletions(-) create mode 100644 StabilityMatrix.Avalonia/Controls/MarkdownHtmlLabel.cs create mode 100644 StabilityMatrix.Tests/Avalonia/MarkdownViewerTests.cs create mode 100644 StabilityMatrix.Tests/Avalonia/ThinkingMessageTests.cs create mode 100644 StabilityMatrix.Tests/Core/GeminiImageGenerationProviderTests.cs create mode 100644 StabilityMatrix.Tests/Core/ImageGenerationChatServiceTests.cs create mode 100644 StabilityMatrix.UITests/BananaVisionPageTests.cs create mode 100644 StabilityMatrix.UITests/MarkdownViewerLayoutTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a33f966..305dce856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,17 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed [#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767) - **AI-Toolkit** installation failing on Linux while unpacking Node.js with a "Dangerous link path" error. - Fixed the package uninstall confirmation showing the size of your whole shared model library for packages using symlink sharing. Only the package's own files are counted; the shared models were never deleted - 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, the same setup Wan2GP's own installer uses, including matching Triton, SageAttention and Flash Attention. GTX 10XX cards stay on Python 3.10, and existing Python 3.10 installs keep updating as before. CUDA 13 needs NVIDIA driver 580 or newer +- Fixed text in **Image Lab** messages and thinking blocks being cut off at the right edge +- **Image Lab** message bubbles now fit their text, so short messages no longer stretch across the chat, and inline code in messages is easier to read +- Fixed emoji showing as `��` in Image Lab messages and in CivitAI and CivArchive model descriptions +- Fixed **Save & Regenerate** in Image Lab deleting the images generated later in the conversation and dropping the edited message's attached images. It now shows progress and can be cancelled like any other generation +- Fixed cancelling a Gemini generation in Image Lab showing a "Generation failed" error instead of "Cancelled" +- Fixed deleting one image from a multi-image message in Image Lab deleting your original file when Image Lab hadn't been able to keep its own copy of it +- Fixed **Image Lab** pulling you back to a conversation when its generation finished while you were in another one, and that generation's error banner showing in the conversation you had open +- Fixed **Regenerate** in Image Lab removing the old response before the new one was ready. A failed or cancelled regenerate now keeps it +- Fixed your message in Image Lab not being editable or deletable after a failed or cancelled generation until you reopened the conversation +- Fixed Image Lab not scrolling to a new message or image 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 conversation using Gemini in the meantime ### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 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/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/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.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.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/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.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/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() From a14ff98a7da346f4521cf851dd4dc1e8514430bd Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 13 Oct 2026 16:47:56 -0700 Subject: [PATCH 25/32] docs: expand v2.17.0-pre.2 technical notes Add sections on the Wan2GP Python 3.11 / CUDA 13 stack (including how existing Python 3.10 installs move to it and the driver check at launch), Node.js extraction on Linux and macOS, shared model folder links hiding DiffusionModels, uninstall size with symlink sharing, model category storage, and the local thumbnail cache. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/2.17.0-pre.2.md | 83 ++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/2.17.0-pre.2.md b/docs/release-notes/2.17.0-pre.2.md index 8c39b4083..627676f7c 100644 --- a/docs/release-notes/2.17.0-pre.2.md +++ b/docs/release-notes/2.17.0-pre.2.md @@ -6,19 +6,96 @@ Extended technical notes for the v2.17.0-pre.2 release — the deeper "how and w ## 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`, used to keep `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**. That ordering meant a value you set yourself to fix something on your own machine was silently clobbered the moment the package launched, with no indication anything had overridden it. +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 concretely 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 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. +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 — which would have wiped out a value you'd set yourself for the rest of the venv's life. That's now a small reusable helper, `venvRunner.RunWithTemporaryEnvironmentVariableAsync(key, value, action)`, which snapshots whatever was there before the override, restores exactly that when the action completes or throws, and is generic enough to reach for the next time a package needs a scoped, one-shot env var override. +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. From 67da06ef3a2fc91b49e0b0234ae5d2a547582afa Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 13 Oct 2026 16:49:03 -0700 Subject: [PATCH 26/32] docs(changelog): tidy v2.16.5 to match the pre.2 wording Same grouping and wording as the shared entries in v2.17.0-pre.2, issue links at the end of each line, and a note for existing Python 3.10 Wan2GP installs hitting Triton errors (#1756). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 305dce856..e6643f239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,29 +10,29 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - 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! ### Fixed -- Fixed **Open on CivArchive** opening a file URL instead of the model page on Linux ([#1765](https://github.com/LykosAI/StabilityMatrix/issues/1765)). +- 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)). -- ControlNet preprocessing in Inference now offers to update older tagged Inference Core Nodes installations to a version with the ControlNet import fixes and Python 3.12/3.13 installation support. -- Fixed shared model folder aliases hiding DiffusionModels in Checkpoint Manager, Image Lab, and Inference, including when DiffusionModels is itself linked to another location ([#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 release-note text being cut off beside the scrollbar in the update dialog ([#1757](https://github.com/LykosAI/StabilityMatrix/issues/1757)) - 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 **Windows ROCm** PyTorch installs pairing mismatched `torch` and `torchvision` versions, which broke packages at `import torchvision`; a matched pair is now always installed together ([#1769](https://github.com/LykosAI/StabilityMatrix/pull/1769)) - thanks to @NeuralFault! -- Fixed logs ignoring `--home-dir`, and opening a missing folder or file showing an error dialog instead of a short notice; the app's own folders are now recreated when the library loads ([#1766](https://github.com/LykosAI/StabilityMatrix/pull/1766)) - thanks to @e-nord! -- Fixed [#1767](https://github.com/LykosAI/StabilityMatrix/issues/1767) - **AI-Toolkit** installation failing on Linux while unpacking Node.js with a "Dangerous link path" error. -- Fixed the package uninstall confirmation showing the size of your whole shared model library for packages using symlink sharing. Only the package's own files are counted; the shared models were never deleted -- 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, the same setup Wan2GP's own installer uses, including matching Triton, SageAttention and Flash Attention. GTX 10XX cards stay on Python 3.10, and existing Python 3.10 installs keep updating as before. CUDA 13 needs NVIDIA driver 580 or newer -- Fixed text in **Image Lab** messages and thinking blocks being cut off at the right edge -- **Image Lab** message bubbles now fit their text, so short messages no longer stretch across the chat, and inline code in messages is easier to read +- 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 **Save & Regenerate** in Image Lab deleting the images generated later in the conversation and dropping the edited message's attached images. It now shows progress and can be cancelled like any other generation -- Fixed cancelling a Gemini generation in Image Lab showing a "Generation failed" error instead of "Cancelled" -- Fixed deleting one image from a multi-image message in Image Lab deleting your original file when Image Lab hadn't been able to keep its own copy of it -- Fixed **Image Lab** pulling you back to a conversation when its generation finished while you were in another one, and that generation's error banner showing in the conversation you had open +- 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 your message in Image Lab not being editable or deletable after a failed or cancelled generation until you reopened the conversation -- Fixed Image Lab not scrolling to a new message or image 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 conversation using Gemini in the meantime +- 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 ### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 From 2408dd0f08447c47840f550b12a5bbfb5edfbbbf Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 13 Oct 2026 18:13:31 -0700 Subject: [PATCH 27/32] docs: add v2.16.5 technical notes The Wan2GP, Node.js extraction, DiffusionModels link and uninstall size sections from the pre.2 page, which apply unchanged to 2.16.5 (the code behind them is identical on main and dev). Links the page from the v2.16.5 changelog header. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/release-notes/2.16.5.md | 66 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 docs/release-notes/2.16.5.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e6643f239..d6c1f4d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ 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! 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. From 789341c88d605a14bf2f920b728fb2918b57350a Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 16 Oct 2026 19:06:44 -0700 Subject: [PATCH 28/32] docs(changelog): refresh supporter shoutouts against the current roster Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c1f4d57..66d53257b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,9 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - Fixed a ComfyUI generation in Image Lab not stopping when ComfyUI disconnected, if you had opened a Gemini conversation in the meantime ### 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**, **bluepopsicle**, **dispenser**, **Droolguy**, **TwistedDragon**, **snotty**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, **hasezou11013179**, and **Akros**. It's great to see **SnooSnooEternal** back with us, and hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 +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 hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 #### 🚀 Pioneers -To our Pioneers **SinthCore**, **Jisuren**, **jweg79**, **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. Welcome back, **Tuskaruho**, it's lovely to see your name here again. And hi **acleveranagram**, our newest Pioneer; we're really glad to have you! 💛 +To our Pioneers **SinthCore**, **Jisuren**, **jweg79**, **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 From 860bb1a06645fffbdc85d1b4c5b1d2afea288a4c Mon Sep 17 00:00:00 2001 From: jt Date: Fri, 16 Oct 2026 19:12:45 -0700 Subject: [PATCH 29/32] fix(packages): point Fizgig's preview image at its new upstream path Upstream moved icon.png to src/fizgig/web/static/ on 2026-10-13, so the old URL 404s and PackageLinkTests fails. Co-Authored-By: Claude Opus 5.5 --- StabilityMatrix.Core/Models/Packages/Fizgig.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StabilityMatrix.Core/Models/Packages/Fizgig.cs b/StabilityMatrix.Core/Models/Packages/Fizgig.cs index 7e6483e14..526b5794b 100644 --- a/StabilityMatrix.Core/Models/Packages/Fizgig.cs +++ b/StabilityMatrix.Core/Models/Packages/Fizgig.cs @@ -52,7 +52,7 @@ IPipWheelService pipWheelService public override string LaunchCommand => "lora_trainer_gui.py"; public override Uri PreviewImageUri => - new("https://github.com/shootthesound/Fizgig/blob/master/icon.png?raw=true"); + new("https://github.com/shootthesound/Fizgig/blob/master/src/fizgig/web/static/icon.png?raw=true"); public override string MainBranch => "master"; public override PackageType PackageType => PackageType.SdTraining; From d84643819880e52d1b6cda2e137692a876ef580c Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 20 Oct 2026 21:00:31 -0700 Subject: [PATCH 30/32] Merge pull request #1451 from ionite34/fix/github-releases-single-page Cut startup GitHub requests: newest release page only, ComfyUI on Comfy-Org (cherry picked from commit 2e5580daa34efd628149ee475ef6ba28c0d08e33) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 250 ++++++++++++++++++ .../Dialogs/PackageImportViewModel.cs | 7 +- .../Helper/Cache/GithubApiCache.cs | 70 +++-- .../Models/Packages/BasePackage.cs | 7 + .../Models/Packages/ComfyUI.cs | 5 +- .../Models/Packages/ComfyZluda.cs | 1 + .../Services/IPipWheelService.cs | 10 - .../Services/PipWheelService.cs | 116 -------- .../Helper/GithubApiCacheTests.cs | 65 +++++ 9 files changed, 385 insertions(+), 146 deletions(-) create mode 100644 StabilityMatrix.Tests/Helper/GithubApiCacheTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d53257b..350b274e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,256 @@ 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). +<<<<<<< HEAD +======= +## v2.17.0-pre.3 +### Added +- **Manage Categories** now lets you reorder categories by dragging them, and delete them from the list +- The category colour picker now has a palette of preset colours, and each new category picks its own colour from it +### Changed +- ComfyUI now installs and updates from its current GitHub home, [Comfy-Org/ComfyUI](https://github.com/Comfy-Org/ComfyUI) +### Fixed +- Fixed **Delete** and **Rename** in a category's right-click menu doing nothing +- Fixed the **Categories** sidebar hiding model counts behind its scrollbar +- Fixed package version lists and update checks showing an old release as the newest, because startup update checks ran into GitHub's rate limit + +## v2.17.0-pre.2 +> Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.17.0-pre.2](https://docs.lykos.ai/stability-matrix/release-notes/2.17.0-pre.2) +### Added +- **Model categories** in the Checkpoint Manager, the most requested item on our feature tracker. Tag any model with as many custom categories as you like, e.g. *Character*, *Style* or *Concept*, shown as coloured chips on its card. Filter from the new **Categories** sidebar section or with `@name` in the search box, and assign in bulk from the selection bar, with right-click → **Set Categories**, or by dropping cards onto a category. Categories are stored in the model's `.cm-info.json`, so they survive moves, renames and metadata refreshes +- Categories reach **Inference** too: the model picker gets a **Categories** filter row and shows each model's chips, and typing a category name filters model dropdowns and the `>>>>>> 2e5580da (Merge pull request #1451 from ionite34/fix/github-releases-single-page) ## 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 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.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/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 86f360227..65142f1d0 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -45,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"; 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/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/PipWheelService.cs b/StabilityMatrix.Core/Services/PipWheelService.cs index f4c24dc7a..2c9fd8bbc 100644 --- a/StabilityMatrix.Core/Services/PipWheelService.cs +++ b/StabilityMatrix.Core/Services/PipWheelService.cs @@ -566,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/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"); + } +} From 180d6686876b8a2cb3a16b929feb135365a2087e Mon Sep 17 00:00:00 2001 From: JT Date: Tue, 20 Oct 2026 21:03:47 -0700 Subject: [PATCH 31/32] fix chagenlog --- CHANGELOG.md | 253 +-------------------------------------------------- 1 file changed, 3 insertions(+), 250 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350b274e4..c0b386120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,261 +5,13 @@ 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). -<<<<<<< HEAD -======= -## v2.17.0-pre.3 -### Added -- **Manage Categories** now lets you reorder categories by dragging them, and delete them from the list -- The category colour picker now has a palette of preset colours, and each new category picks its own colour from it -### Changed -- ComfyUI now installs and updates from its current GitHub home, [Comfy-Org/ComfyUI](https://github.com/Comfy-Org/ComfyUI) -### Fixed -- Fixed **Delete** and **Rename** in a category's right-click menu doing nothing -- Fixed the **Categories** sidebar hiding model counts behind its scrollbar -- Fixed package version lists and update checks showing an old release as the newest, because startup update checks ran into GitHub's rate limit - -## v2.17.0-pre.2 -> Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.17.0-pre.2](https://docs.lykos.ai/stability-matrix/release-notes/2.17.0-pre.2) -### Added -- **Model categories** in the Checkpoint Manager, the most requested item on our feature tracker. Tag any model with as many custom categories as you like, e.g. *Character*, *Style* or *Concept*, shown as coloured chips on its card. Filter from the new **Categories** sidebar section or with `@name` in the search box, and assign in bulk from the selection bar, with right-click → **Set Categories**, or by dropping cards onto a category. Categories are stored in the model's `.cm-info.json`, so they survive moves, renames and metadata refreshes -- Categories reach **Inference** too: the model picker gets a **Categories** filter row and shows each model's chips, and typing a category name filters model dropdowns and the `>>>>>> 2e5580da (Merge pull request #1451 from ionite34/fix/github-releases-single-page) ## 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)) @@ -284,6 +36,7 @@ This build leans hard into what's next: a live HuggingFace browser and Wan 2.2 v - 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 hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 From 1c9b9b5940e5f3482cd6f67add6675718a4fa811 Mon Sep 17 00:00:00 2001 From: jt Date: Tue, 20 Oct 2026 21:15:52 -0700 Subject: [PATCH 32/32] Credit jweg79 as a Visionary in the 2.16.5 shoutout jweg79 moved up from Pioneer to Visionary, so the shoutout now thanks them there and drops them from the Pioneer list. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b386120..a0f61a9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,9 +39,9 @@ and this project adheres to [Semantic Versioning 2.0](https://semver.org/spec/v2 - 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 hello to **japublicspot78364519** and **jonny90**, our newest Visionaries; we're so happy you're here. 💛 +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**, **jweg79**, **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! 💛 +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