diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a6e7ff4..0f0ecf704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,51 @@ 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.3 +### Added +#### New Feature: πŸ”€ CivitAI Workflow Browsing +- The **Model Browser** now browses and imports **ComfyUI workflows** from CivitAI β€” pick the new **Workflows** model type (or spot them in All-type searches), open one, and import it straight into your workflow library; archives are unpacked automatically, and images with an embedded workflow (the drag-into-ComfyUI kind) are converted into regular workflows with the image kept as the preview +- Your workflow library is now **linked into ComfyUI itself** β€” imported workflows appear under a "Stability Matrix" folder in ComfyUI's own workflow browser, for every installed ComfyUI-based package +- **Custom node install assist**: after importing a workflow that needs custom nodes, Stability Matrix reads the node packs right out of the workflow file, shows which are already installed, and offers one-click installs for the rest +#### πŸ“– In-App Documentation (shipped in v2.16.2) +- Left out of the 2.16.2 release notes by mistake, so it is recorded here. Read the Stability Matrix guides without leaving the app: + - Press **F1** from anywhere, or open it from **Settings β†’ About β†’ Documentation** + - New **?** buttons on the Package Manager, install details, the running package console, Inference, and the Environment Variables and App Folders settings open the page for what you're looking at + - Browse every section from a nav tree, follow links between pages, and zoom the text in or out β€” your zoom level is remembered + - Pages are read live from [docs.lykos.ai](https://docs.lykos.ai/stability-matrix/), so new and updated writing shows up without an app update, and a copy ships inside the app so it still works offline +### Changed +- The **Workflows** page is now your installed workflow library, front and center β€” no more tabs, since browsing moved to the Model Browser. Cards for CivitAI-imported workflows link back to their CivitAI page +- Removed the **OpenArt workflow browser**: OpenArt has discontinued its ComfyUI workflow community (the site no longer has workflow pages, nothing new has been uploaded since March, and the unmoderated feed had filled with spam). Workflows you already imported from OpenArt stay fully usable in your library +- Updated **AI-Toolkit** installs to PyTorch 2.13.0 on CUDA 13.0 with Python 3.12, matching current upstream requirements ([#1714](https://github.com/LykosAI/StabilityMatrix/issues/1714)) β€” with a console warning at launch if your NVIDIA driver is older than the 580 series CUDA 13.0 needs +- Updated the bundled **uv** package manager to 0.12.5, adding newer Python builds to the Python picker β€” including the 3.10 security releases requested in [#1709](https://github.com/LykosAI/StabilityMatrix/issues/1709) +### Fixed +- Fixed custom node installs failing on legacy source builds that import `pkg_resources` (e.g. `cupy-wheel`) β€” pip runs started by extension install scripts now get the same `setuptools<82` constraint that package installs already applied for uv +- Fixed [#1703](https://github.com/LykosAI/StabilityMatrix/issues/1703) - model cover/preview images not displaying when your **File Name Pattern** contains a `#` +- Fixed [#1705](https://github.com/LykosAI/StabilityMatrix/issues/1705) - the **Model Browser** showing results in a scrambled order after refreshing with the **Search** button +- Fixed [#1708](https://github.com/LykosAI/StabilityMatrix/issues/1708) - OneTrainer on **Windows ROCm** crashing at the start of training with bitsandbytes 8-bit optimizers β€” a bundled wheel now pins bitsandbytes to the same 0.49.1 build OneTrainer itself expects, so 8-bit optimizers work again - thanks to @NeuralFault for the diagnosis and @0xDELUXA for the wheel! +- Fixed [#1710](https://github.com/LykosAI/StabilityMatrix/issues/1710) - CivitAI downloads sometimes fetching a different file from the same model version (then failing hash verification) when several files share identical metadata β€” downloads now pin the exact file +- Fixed [#1715](https://github.com/LykosAI/StabilityMatrix/issues/1715) - the app crashing on startup on Linux when the models folder contains folders or files differing only in letter case (e.g. `unet` and `Unet`, also [#1149](https://github.com/LykosAI/StabilityMatrix/issues/1149)/[#1357](https://github.com/LykosAI/StabilityMatrix/issues/1357)) β€” models in differently-cased folders now show up in the Checkpoint Manager instead of being silently skipped, and a model index error can no longer take down the app +- Fixed [#1716](https://github.com/LykosAI/StabilityMatrix/issues/1716) - phantom **Update Available** badges on embeddings, VAEs, upscalers, and ControlNets β€” the update check didn't count those file types as evidence of being up to date, so it flagged them all forever; wrong badges clear on the next check +- Fixed [#1707](https://github.com/LykosAI/StabilityMatrix/issues/1707) - Inference's **Layer Diffuse** addon failing at the sampler with `'NoneType' object is not subscriptable` on current ComfyUI β€” the Inference Core Nodes extension carries the fix, and Inference now offers the update when your copy predates it +- Fixed [#1717](https://github.com/LykosAI/StabilityMatrix/issues/1717) - Inference and Image Lab asking you to install a ComfyUI extension you already have (most often **ComfyUI-GGUF**), with the same prompt returning after every install β€” extensions added from the ComfyUI registry, which ComfyUI-Manager unpacks instead of cloning, weren't being seen at all, and clones were only recognized when their remote URL matched ours character for character (a `.git` suffix or an SSH remote was enough to miss) +- Fixed early access versions no longer being detected β€” CivitAI now marks them via a deadline field while reporting them as public, which broke the early-access update badge and the Model Browser's **Hide Early Access Models** filter +- Fixed multi-architecture models (e.g. Illustrious | Pony | NoobAI in one listing) counting a release for a different architecture as an update β€” update checks now compare within your installed version's base model, so the update and early-access badges reflect the actual successor on your track +- Fixed installing a package that needs a newer Python breaking an already-installed package's environment β€” adding a package on Python 3.13 next to an existing 3.12 one left the older package unable to launch with `error no: 2`, and repairing it by hand didn't hold because it was rewritten on every launch - thanks to @NeuralFault! +- Fixed **Clear Finished** in the **Activity** popout also clearing failed downloads, taking their **Retry** button with them β€” failed items now stay until you retry or dismiss them yourself - thanks to @NeuralFault! +- Fixed **SwarmUI**'s **Launch Mode** option having no effect, as it used a hyphen where SwarmUI expects `--launch_mode` β€” the dropdown also gains a **none** option to stop SwarmUI opening a browser window on launch - thanks to @NeuralFault! +- Fixed **Forge Neo** and other uv-dependent packages failing to launch on **Linux** and **macOS** with `Error: 2 uv not found` ([#1713](https://github.com/LykosAI/StabilityMatrix/issues/1713)) β€” the bundled uv is now on the launch PATH there too, not just on Windows - thanks to @NeuralFault! +- Fixed the **CivArchive browser** returning far fewer results than the CivArchive website β€” the site's redesign quietly pinned the app's search route to the past quarter, and the renamed result-count field capped infinite scroll at a single page +- Fixed clicking some CivArchive results showing an error instead of the model β€” the redesign added whole-model search results whose detail pages load through a redirect the app didn't follow +- Fixed CivArchive file results opening in the web browser instead of the in-app details page, and the details page's version dropdown doing nothing β€” both casualties of a renamed field in the redesigned API +- CivArchive cards without a preview (whole-model and file results) now backfill an image from the model's gallery in the background instead of staying gray placeholders +- Fixed trigger words being cut off on the CivArchive details page β€” entries that are whole prompt lines now wrap +- CivArchive article results, which have no in-app page, now open in the web browser instead of erroring +### Supporters +#### 🌟 Visionaries +A point release that quietly grew a feature: workflows you can now browse and import straight from CivitAI, sitting alongside a long run of fixes that each began with somebody taking the time to report a problem. Our Visionaries are why both halves get proper attention instead of one crowding out the other. Thank you **Waterclouds**, **MrMxyzptlk12836**, **bluepopsicle**, **Ibixat**, **Droolguy**, **snotty**, **dispenser**, **cusalapapen1481**, **moon_milky2843**, **sn3232323233350**, **CC**, and **SnooSnooEternal** for making that possible. A warm welcome as well to **TwistedDragon**, **Eaglebane**, **hasezou11013179**, and **Akros**, the newest names on this list; it's genuinely lovely to have you with us. πŸ’› +#### πŸš€ Pioneers +Our Pioneers carry the other half, and there's a quiet kind of trust in a list that barely changes from one release to the next: **SinthCore**, **Jisuren**, **jweg79**, **Hurbie53**, **Tuskaruho**, **Cjloha**, **Alligator1907**, **Bitti**, **CommissarGiygas16050**, **bastardofbethlehem**, **Zombop**, **Silerae**, and **joshsciascia72**, joined this release by **cy_hart691842**, who we're very glad to have aboard. Point releases rarely make headlines, and you back them just as readily as the big ones. Thank you for that, every one of you. πŸ’› + ## v2.16.2 > Full technical notes for this release: [docs.lykos.ai/stability-matrix/release-notes/2.16.2](https://docs.lykos.ai/stability-matrix/release-notes/2.16.2) ### Added diff --git a/Directory.Packages.props b/Directory.Packages.props index e8e30d9a6..ad680fd17 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -117,6 +117,7 @@ + diff --git a/Jenkinsfile b/Jenkinsfile index 8cd19deae..95bf7d7c5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,4 +1,4 @@ -node("Diligence") { +node("linux-builder") { def repoName = "StabilityMatrix" def author = "ionite34" def version = "" diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 3dde9483c..07a196cf2 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -469,7 +469,7 @@ internal static void ConfigurePageViewModels(IServiceCollection services) provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), - provider.GetRequiredService(), + provider.GetRequiredService(), }, FooterPages = { provider.GetRequiredService() }, }); @@ -836,15 +836,6 @@ internal static IServiceCollection ConfigureServices(bool disableMessagePipeInte .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }) .AddPolicyHandler(retryPolicy); - services - .AddRefitClient(defaultRefitSettings) - .ConfigureHttpClient(c => - { - c.BaseAddress = new Uri("https://openart.ai/api/public/workflows"); - c.Timeout = TimeSpan.FromHours(1); - }) - .AddPolicyHandler(retryPolicy); - services .AddRefitClient(defaultRefitSettings) // Assuming defaultRefitSettings is suitable .ConfigureHttpClient(c => diff --git a/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs b/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs index c95d547f4..5d4d2473c 100644 --- a/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs +++ b/StabilityMatrix.Avalonia/Converters/FileUriConverter.cs @@ -1,6 +1,6 @@ using System.Globalization; +using System.IO; using Avalonia.Data.Converters; -using StabilityMatrix.Core.Extensions; namespace StabilityMatrix.Avalonia.Converters; @@ -14,12 +14,22 @@ public class FileUriConverter : IValueConverter return null; } - return value switch + var str = value switch { - string str when str.StartsWith("avares://") => new Uri(str), - string str when (str.StartsWith("https://") || str.StartsWith("http://")) => new Uri(str), - string str => new Uri("file://" + str), - IFormattable formattable => new Uri("file://" + formattable.ToString(null, culture)), + string s => s, + IFormattable formattable => formattable.ToString(null, culture), + _ => null, + }; + + return str switch + { + null or "" => null, + _ when str.StartsWith("avares://") => new Uri(str), + _ when str.StartsWith("https://") || str.StartsWith("http://") => new Uri(str), + // Raw absolute file path: Uri's file-path parsing escapes reserved characters + // ("#", "%", spaces). Prepending "file://" instead would parse "#" as a fragment + // delimiter and truncate the path. + _ when Path.IsPathRooted(str) => new Uri(str), _ => null, }; } @@ -29,7 +39,7 @@ public class FileUriConverter : IValueConverter { if (targetType == typeof(string) && value is Uri uri) { - return uri.ToString().StripStart("file://"); + return uri.IsFile ? uri.LocalPath : uri.ToString(); } return null; diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 2c4322057..8d8972ec3 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -1295,7 +1295,7 @@ public static InstalledWorkflowsViewModel InstalledWorkflowsViewModel get { var vm = Services.GetRequiredService(); - vm.DisplayedWorkflows = new ObservableCollectionExtended + vm.DisplayedWorkflows = new ObservableCollectionExtended { new() { @@ -1602,60 +1602,6 @@ public static CompletionList SampleCompletionList public static SponsorshipPromptViewModel SponsorshipPromptViewModel => DialogFactory.Get(vm => { }); - public static OpenArtWorkflowViewModel OpenArtWorkflowViewModel => - new(Services.GetRequiredService(), Services.GetRequiredService()) - { - Workflow = new OpenArtSearchResult - { - Name = "Test Workflow", - Creator = new OpenArtCreator - { - Name = "Test Creator Name", - Username = "Test Creator Username", - }, - Thumbnails = - [ - new OpenArtThumbnail - { - Url = new Uri( - "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/a318ac1f-3ad0-48ac-98cc-79126febcc17/width=1500" - ), - }, - ], - NodesIndex = - [ - "Anything Everywhere", - "Reroute", - "Note", - ".", - "ComfyUI's ControlNet Auxiliary Preprocessors", - "DWPreprocessor", - "PixelPerfectResolution", - "AIO_Preprocessor", - ",", - "ComfyUI", - "PreviewImage", - "CLIPTextEncode", - "EmptyLatentImage", - "SplitImageWithAlpha", - "ControlNetApplyAdvanced", - "JoinImageWithAlpha", - "LatentUpscaleBy", - "VAEEncode", - "LoadImage", - "ControlNetLoader", - "CLIPVisionLoader", - "SaveImage", - ",", - "ComfyUI Impact Pack", - "SAMLoader", - "UltralyticsDetectorProvider", - "FaceDetailer", - ",", - ], - }, - }; - public static SafetensorMetadataViewModel SafetensorMetadataViewModel => DialogFactory.Get(vm => { diff --git a/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs b/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs index 9d037ed45..2d53d6ae3 100644 --- a/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs +++ b/StabilityMatrix.Avalonia/Helpers/EnumHelpers.cs @@ -18,7 +18,7 @@ public static class EnumHelpers public static IEnumerable AllCivitModelTypes { get; } = Enum.GetValues(typeof(CivitModelType)) .Cast() - .Where(t => t == CivitModelType.All || t.ConvertTo() > 0) + .Where(t => t == CivitModelType.All || t.IsBrowsable()) .OrderBy(t => t.ToString()); public static IEnumerable MetadataEditorCivitModelTypes { get; } = diff --git a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs index 356a9c141..5d1250493 100644 --- a/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/UnixPrerequisiteHelper.cs @@ -35,9 +35,9 @@ IPyInstallationManager pyInstallationManager private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private const string UvMacDownloadUrl = - "https://github.com/astral-sh/uv/releases/download/0.9.30/uv-aarch64-apple-darwin.tar.gz"; + "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-aarch64-apple-darwin.tar.gz"; private const string UvLinuxDownloadUrl = - "https://github.com/astral-sh/uv/releases/download/0.9.30/uv-x86_64-unknown-linux-gnu.tar.gz"; + "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-unknown-linux-gnu.tar.gz"; private DirectoryPath HomeDir => settingsManager.LibraryDir; private DirectoryPath AssetsDir => HomeDir.JoinDir("Assets"); @@ -75,7 +75,7 @@ private bool IsPythonVersionInstalled(PyVersion version) => // Cached store of whether or not git is installed private bool? isGitInstalled; - private string ExpectedUvVersion => "0.9.30"; + private string ExpectedUvVersion => "0.12.5"; public bool IsVcBuildToolsInstalled => false; public bool IsHipSdkInstalled => false; diff --git a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs index 459aa1618..99fb53929 100644 --- a/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs +++ b/StabilityMatrix.Avalonia/Helpers/WindowsPrerequisiteHelper.cs @@ -56,7 +56,7 @@ IPyInstallationManager pyInstallationManager private const string PythonLibsDownloadUrl = "https://cdn.lykos.ai/python_libs_for_sage.zip"; private const string UvWindowsDownloadUrl = - "https://github.com/astral-sh/uv/releases/download/0.9.30/uv-x86_64-pc-windows-msvc.zip"; + "https://github.com/astral-sh/uv/releases/download/0.12.5/uv-x86_64-pc-windows-msvc.zip"; private string HomeDir => settingsManager.LibraryDir; @@ -122,7 +122,7 @@ private string GetPythonLibraryZipPath(PyVersion version) => private string UvExtractPath => Path.Combine(AssetsDir, "uv"); public string UvExePath => Path.Combine(UvExtractPath, "uv.exe"); public bool IsUvInstalled => File.Exists(UvExePath); - private string ExpectedUvVersion => "0.9.30"; + private string ExpectedUvVersion => "0.12.5"; // FFmpeg paths private string FfmpegDownloadPath => Path.Combine(AssetsDir, "ffmpeg.zip"); diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 867b9d276..b11dbd975 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -2858,6 +2858,60 @@ public static string Label_NodeDetails { return ResourceManager.GetString("Label_NodeDetails", resourceCulture); } } + + /// + /// Looks up a localized string similar to Required Custom Nodes. + /// + public static string Label_RequiredCustomNodes { + get { + return ResourceManager.GetString("Label_RequiredCustomNodes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Not found in extension index. + /// + public static string Label_NotInExtensionIndex { + get { + return ResourceManager.GetString("Label_NotInExtensionIndex", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Install Selected. + /// + public static string Action_InstallSelected { + get { + return ResourceManager.GetString("Action_InstallSelected", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This workflow only uses built-in nodes. + /// + public static string Label_NoCustomNodesRequired { + get { + return ResourceManager.GetString("Label_NoCustomNodesRequired", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This workflow uses the node packs below.. + /// + public static string Text_WorkflowNodesExplanation { + get { + return ResourceManager.GetString("Text_WorkflowNodesExplanation", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to ComfyUI installation to check against and install into. + /// + public static string Label_WorkflowNodesTargetPackage { + get { + return ResourceManager.GetString("Label_WorkflowNodesTargetPackage", resourceCulture); + } + } /// /// Looks up a localized string similar to No Extension Packs Found. diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index a9257a69a..8b3915911 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1038,6 +1038,24 @@ Node Details + + Required Custom Nodes + + + Not found in extension index + + + Install Selected + + + This workflow only uses built-in nodes + + + This workflow uses the node packs below. Missing packs can be installed from the extension index, like extensions from the package's Extensions tab. + + + ComfyUI installation to check against and install into + Workflow Description diff --git a/StabilityMatrix.Avalonia/Models/AppArgs.cs b/StabilityMatrix.Avalonia/Models/AppArgs.cs index 66441ad34..ab11f68fd 100644 --- a/StabilityMatrix.Avalonia/Models/AppArgs.cs +++ b/StabilityMatrix.Avalonia/Models/AppArgs.cs @@ -74,6 +74,14 @@ public class AppArgs [Option("vulkan", HelpText = "Prefer Vulkan rendering")] public bool UseVulkanRendering { get; set; } + /// + /// Flag to render popups (flyouts, dropdowns, menus) inside the window instead of as + /// separate OS windows, so window captures and UI automation can see them. + /// Popups are clipped to the window bounds in this mode. + /// + [Option("overlay-popups", HelpText = "Render popups inside the window (for UI automation / capture)")] + public bool UseOverlayPopups { get; set; } + /// /// Override global app home directory /// Defaults to (%APPDATA%|~/.config)/StabilityMatrix diff --git a/StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs b/StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs new file mode 100644 index 000000000..a60ef5dd0 --- /dev/null +++ b/StabilityMatrix.Avalonia/Models/InstalledWorkflow.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Avalonia.Platform.Storage; +using StabilityMatrix.Core.Models; + +namespace StabilityMatrix.Avalonia.Models; + +/// +/// A workflow file in the installed workflows library, deserialized from the +/// embedded in the file. +/// +public class InstalledWorkflow : WorkflowMetadata +{ + [JsonIgnore] + public List? FilePath { get; set; } + + [JsonIgnore] + internal int Index { get; set; } +} diff --git a/StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs b/StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs deleted file mode 100644 index b075758dc..000000000 --- a/StabilityMatrix.Avalonia/Models/OpenArtCustomNode.cs +++ /dev/null @@ -1,10 +0,0 @@ -ο»Ώusing System.Collections.Generic; - -namespace StabilityMatrix.Avalonia.Models; - -public class OpenArtCustomNode -{ - public required string Title { get; set; } - public List Children { get; set; } = []; - public bool IsInstalled { get; set; } -} diff --git a/StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs b/StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs deleted file mode 100644 index ef7506a18..000000000 --- a/StabilityMatrix.Avalonia/Models/OpenArtMetadata.cs +++ /dev/null @@ -1,27 +0,0 @@ -ο»Ώusing System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.Json.Serialization; -using Avalonia.Platform.Storage; -using StabilityMatrix.Core.Models.Api.OpenArt; - -namespace StabilityMatrix.Avalonia.Models; - -public class OpenArtMetadata -{ - [JsonPropertyName("sm_workflow_data")] - public OpenArtSearchResult? Workflow { get; set; } - - [JsonIgnore] - public string? FirstThumbnail => Workflow?.Thumbnails?.Select(x => x.Url).FirstOrDefault()?.ToString(); - - [JsonIgnore] - public List? FilePath { get; set; } - - [JsonIgnore] - [MemberNotNullWhen(true, nameof(Workflow))] - public bool HasMetadata => Workflow?.Creator != null; - - [JsonIgnore] - internal int Index { get; set; } -} diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 25b17cf31..838b018d0 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -236,56 +236,31 @@ public static AppBuilder BuildAvaloniaApp() var app = AppBuilder.Configure().UsePlatformDetect().WithInterFont().LogToTrace(); - if (Compat.IsLinux) - { - app = app.With(new X11PlatformOptions { OverlayPopups = true, WmClass = "stabilitymatrix" }); - } - else if (Compat.IsMacOS) - { - app = app.With(new AvaloniaNativePlatformOptions { OverlayPopups = true }); - } + // .With() replaces the registered options instance wholesale, so each platform's + // options must be composed into a single object before applying. + var win32Options = new Win32PlatformOptions { OverlayPopups = Args.UseOverlayPopups }; + var x11Options = new X11PlatformOptions { OverlayPopups = true, WmClass = "stabilitymatrix" }; + var macOptions = new AvaloniaNativePlatformOptions { OverlayPopups = true }; if (Args.UseOpenGlRendering) { - app = app.With( - new Win32PlatformOptions - { - RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software], - } - ); + win32Options.RenderingMode = [Win32RenderingMode.Wgl, Win32RenderingMode.Software]; } if (Args.UseVulkanRendering) { - app = app.With( - new X11PlatformOptions - { - RenderingMode = [X11RenderingMode.Vulkan], - WmClass = "stabilitymatrix", - } - ) - .With(new Win32PlatformOptions { RenderingMode = [Win32RenderingMode.Vulkan] }); + win32Options.RenderingMode = [Win32RenderingMode.Vulkan]; + x11Options.RenderingMode = [X11RenderingMode.Vulkan]; } if (Args.DisableGpuRendering) { - app = app.With(new Win32PlatformOptions { RenderingMode = new[] { Win32RenderingMode.Software } }) - .With( - new X11PlatformOptions - { - RenderingMode = new[] { X11RenderingMode.Software }, - WmClass = "stabilitymatrix", - } - ) - .With( - new AvaloniaNativePlatformOptions - { - RenderingMode = new[] { AvaloniaNativeRenderingMode.Software }, - } - ); + win32Options.RenderingMode = [Win32RenderingMode.Software]; + x11Options.RenderingMode = [X11RenderingMode.Software]; + macOptions.RenderingMode = [AvaloniaNativeRenderingMode.Software]; } - return app; + return app.With(win32Options).With(x11Options).With(macOptions); } private static void HandleUpdateReplacement() diff --git a/StabilityMatrix.Avalonia/Services/ComfyImageGenerationProviderBase.cs b/StabilityMatrix.Avalonia/Services/ComfyImageGenerationProviderBase.cs index 3ea9971be..e18b482e3 100644 --- a/StabilityMatrix.Avalonia/Services/ComfyImageGenerationProviderBase.cs +++ b/StabilityMatrix.Avalonia/Services/ComfyImageGenerationProviderBase.cs @@ -215,17 +215,12 @@ is not GitPackageExtensionManager extensionManager return []; } - var installedUrls = ( - await extensionManager.GetInstalledExtensionsLiteAsync( - localPackagePair.InstalledPackage, - cancellationToken - ) - ) - .Select(ext => ext.GitRepositoryUrl) - .OfType() - .ToHashSet(StringComparer.OrdinalIgnoreCase); + var installedExtensions = await extensionManager.GetInstalledExtensionsLiteAsync( + localPackagePair.InstalledPackage, + cancellationToken + ); - return requiredExtensions.Where(specifier => !installedUrls.Contains(specifier.Name)).ToList(); + return ExtensionMatcher.GetMissing(requiredExtensions, installedExtensions); } catch (Exception e) { diff --git a/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs b/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs index c230ba22e..21b459c1a 100644 --- a/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs +++ b/StabilityMatrix.Avalonia/Services/InferenceClientManager.cs @@ -349,14 +349,23 @@ ICompletionProvider completionProvider // when the selection model tries to enumerate selected items. Dispatcher.UIThread.Post(() => { - ResetSharedProperties(); - - if (IsConnected) + // Guarded: an exception here is otherwise unhandled on the Dispatcher and + // crashes the app. + try + { + ResetSharedProperties(); + + if (IsConnected) + { + LoadSharedPropertiesAsync() + .SafeFireAndForget(onException: ex => + logger.LogError(ex, "Error loading shared properties") + ); + } + } + catch (Exception e) { - LoadSharedPropertiesAsync() - .SafeFireAndForget(onException: ex => - logger.LogError(ex, "Error loading shared properties") - ); + logger.LogError(e, "Error resetting shared properties for Inference"); } }); }; diff --git a/StabilityMatrix.Avalonia/Services/ModelImportService.cs b/StabilityMatrix.Avalonia/Services/ModelImportService.cs index ddf6fc511..60bfa34c6 100644 --- a/StabilityMatrix.Avalonia/Services/ModelImportService.cs +++ b/StabilityMatrix.Avalonia/Services/ModelImportService.cs @@ -188,7 +188,10 @@ public async Task DoImport( var previewImagePath = await SavePreviewImage(modelVersion, downloadPath); // Create tracked download - var download = trackedDownloadService.NewDownload(modelFile.DownloadUrl, downloadPath); + var download = trackedDownloadService.NewDownload( + modelFile.GetFileSpecificDownloadUrl(), + downloadPath + ); // Add hash info download.ExpectedHashSha256 = modelFile.Hashes.SHA256; diff --git a/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml b/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml index 5d5aeaee6..80d6b8f6e 100644 --- a/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml +++ b/StabilityMatrix.Avalonia/Styles/ControlThemes/DocsHelpButtonStyles.axaml @@ -25,6 +25,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs index a8450ab8f..f9af75674 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Base/InferenceGenerationViewModelBase.cs @@ -768,15 +768,6 @@ private async Task CheckPromptExtensionsInstalled(NodeDictionary nodeDicti ) ).ToList(); - var localExtensionsByGitUrl = localExtensions - .Where(ext => ext.GitRepositoryUrl is not null) - .DistinctBy(ext => ext.GitRepositoryUrl!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(ext => ext.GitRepositoryUrl!, ext => ext, StringComparer.OrdinalIgnoreCase); - - var requiredExtensionReferences = requiredExtensionSpecifiers - .Select(specifier => specifier.Name) - .ToHashSet(); - var missingExtensions = new List(); var outOfDateExtensions = new List<(ExtensionSpecifier Specifier, InstalledPackageExtension Installed)>(); @@ -784,7 +775,7 @@ private async Task CheckPromptExtensionsInstalled(NodeDictionary nodeDicti // Check missing extensions and out of date extensions foreach (var specifier in requiredExtensionSpecifiers) { - if (!localExtensionsByGitUrl.TryGetValue(specifier.Name, out var localExtension)) + if (ExtensionMatcher.FindInstalled(specifier, localExtensions) is not { } localExtension) { missingExtensions.Add(specifier); continue; diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs index f39219a87..ffc6bbc18 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivArchiveBrowserViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Reactive.Disposables; +using AsyncAwaitBestPractices; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -37,6 +38,12 @@ IModelIndexService modelIndexService private bool searchQueued; private int currentPage = 1; private CancellationTokenSource? searchDebounceCts; + private CancellationTokenSource? imageBackfillCts; + + // VM-wide, not per-backfill-call β€” overlapping infinite-scroll pages must share one + // 3-request budget against CivArchive (a community-run archive we'd rather not + // burst-load), instead of each page opening 3 more slots. + private readonly SemaphoreSlim imageBackfillThrottle = new(3, 3); /// /// How long to wait after the most recent filter change before firing the search. @@ -429,6 +436,10 @@ private async Task SearchModels(bool isInfiniteScroll = false) TotalHits = 0; rawResults.Clear(); Results.Clear(); + + imageBackfillCts?.Cancel(); + imageBackfillCts?.Dispose(); + imageBackfillCts = null; } var filters = BuildFilters(isInfiniteScroll ? currentPage + 1 : currentPage); @@ -449,6 +460,7 @@ private async Task SearchModels(bool isInfiniteScroll = false) var existingIds = isInfiniteScroll ? rawResults.Select(x => x.Id).ToHashSet() : []; var installedHashes = modelIndexService.ModelIndexSha256Hashes; var installedUrls = modelIndexService.ModelIndexCivArchiveUrls; + var addedItems = new List(); foreach (var item in response.Results) { if (isInfiniteScroll && !existingIds.Add(item.Id)) @@ -470,12 +482,16 @@ private async Task SearchModels(bool isInfiniteScroll = false) } rawResults.Add(item); + addedItems.Add(item); if (!HideInstalledModels || !item.IsInstalled) { Results.Add(item); } } + imageBackfillCts ??= new CancellationTokenSource(); + BackfillMissingCardImagesAsync(addedItems, imageBackfillCts.Token).SafeFireAndForget(); + HasSearched = true; NoResultsFound = Results.Count == 0; OnPropertyChanged(nameof(IsEndOfResults)); @@ -520,6 +536,10 @@ private async Task OpenResult(CivArchiveSearchResult? result) case CivArchiveKindOption.File: await OpenFileResult(result); break; + case CivArchiveKindOption.Article: + // Articles have no in-app view β€” hand them to the browser. + ProcessRunner.OpenUrl(civArchiveApiClient.GetAbsoluteUri(result.Url).ToString()); + break; default: NavigateToDetails(result.Url); break; @@ -534,10 +554,10 @@ private async Task OpenResult(CivArchiveSearchResult? result) /// private async Task OpenFileResult(CivArchiveSearchResult result) { - var resolvedUrl = await civArchiveApiClient.ResolveFileUrlAsync(result.Url); - if (!string.IsNullOrWhiteSpace(resolvedUrl)) + var resolution = await civArchiveApiClient.ResolveFileAsync(result.Url); + if (!string.IsNullOrWhiteSpace(resolution?.ModelUrl)) { - NavigateToDetails(resolvedUrl); + NavigateToDetails(resolution.ModelUrl); } else { @@ -545,6 +565,74 @@ private async Task OpenFileResult(CivArchiveSearchResult result) } } + /// + /// Model- and file-kind search results come back from the API without an + /// image_url even when their model page has a full gallery. Backfill card + /// thumbnails from the (cached) detail endpoints, a few at a time, so the grid + /// isn't a wall of gray placeholders. + /// + private async Task BackfillMissingCardImagesAsync( + IReadOnlyList items, + CancellationToken cancellationToken + ) + { + var targets = items + .Where(x => + string.IsNullOrWhiteSpace(x.ImageUrl) + && x.Kind + is CivArchiveKindOption.Model + or CivArchiveKindOption.Version + or CivArchiveKindOption.File + ) + .ToList(); + + if (targets.Count == 0) + { + return; + } + + await Task.WhenAll( + targets.Select(async item => + { + try + { + await imageBackfillThrottle.WaitAsync(cancellationToken); + try + { + var imageUrl = await ResolveCardImageAsync(item, cancellationToken); + if (!string.IsNullOrWhiteSpace(imageUrl)) + { + Dispatcher.UIThread.Post(() => item.ImageUrl = imageUrl); + } + } + finally + { + imageBackfillThrottle.Release(); + } + } + catch + { + // Best-effort backfill β€” a failed card just keeps its placeholder. + } + }) + ); + } + + private async Task ResolveCardImageAsync( + CivArchiveSearchResult item, + CancellationToken cancellationToken + ) + { + if (item.Kind == CivArchiveKindOption.File) + { + var resolution = await civArchiveApiClient.ResolveFileAsync(item.Url, cancellationToken); + return resolution?.ImageUrl; + } + + var details = await civArchiveApiClient.GetModelDetailsAsync(item.Url, cancellationToken); + return details.Model.Version?.PrimaryImageUrl; + } + private void NavigateToDetails(string relativeUrl) { var detailsVm = viewModelFactory.Get(vm => diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs index bf5460efa..a9218efca 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitAiBrowserViewModel.cs @@ -131,7 +131,7 @@ public sealed partial class CivitAiBrowserViewModel : TabViewModelBase, IInfinit public IEnumerable AllModelTypes => Enum.GetValues(typeof(CivitModelType)) .Cast() - .Where(t => t == CivitModelType.All || t.ConvertTo() > 0) + .Where(t => t == CivitModelType.All || t.IsBrowsable()) .OrderBy(t => t.ToString()); public string ClearButtonText => @@ -519,9 +519,7 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc } // Check how many items survive local filtering - var filteredCount = models - .Where(m => m.Type.ConvertTo() > 0) - .Count(m => m.Mode == null); + var filteredCount = models.Where(m => m.Type.IsBrowsable()).Count(m => m.Mode == null); var next = resp.Metadata?.NextCursor; if (filteredCount >= targetCount || string.IsNullOrEmpty(next)) @@ -564,10 +562,7 @@ private async Task CivitModelQuery(CivitModelsRequest request, bool isInfiniteSc } // Filter out unknown model types and archived/taken-down models - models = models - .Where(m => m.Type.ConvertTo() > 0) - .Where(m => m.Mode == null) - .ToList(); + models = models.Where(m => m.Type.IsBrowsable()).Where(m => m.Mode == null).ToList(); var cacheNew = true; if (UseLocalCache) @@ -697,7 +692,13 @@ private void UpdateModelCards(List? models, bool addCards = false) return; } - var startIndex = modelCache.Count; + // A replacement page restarts ordering at zero; an appended page continues after the + // highest existing order. Count is not a valid order base β€” it lags the max order + // whenever a fresh search replaces an uncleared cache (Search button re-click) or an + // append is de-duplicated, handing out colliding orders that scramble the sorted view. + var startIndex = addCards + ? modelCache.Items.Select(static ov => ov.Order).DefaultIfEmpty(-1).Max() + 1 + : 0; var modelsToAdd = models.Select((m, i) => new OrderedValue(startIndex + i, m)); diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs index 0d25ef06e..1637791a3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/CivitDetailsPageViewModel.cs @@ -29,11 +29,13 @@ using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Factory; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Api; using StabilityMatrix.Core.Models.Api.CivitTRPC; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Services; @@ -51,13 +53,25 @@ public partial class CivitDetailsPageViewModel( INavigationService navigationService, IModelIndexService modelIndexService, IServiceManager vmFactory, - IModelImportService modelImportService + IModelImportService modelImportService, + IDownloadService downloadService, + IPackageFactory packageFactory ) : DisposableViewModelBase { [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowInferenceDefaultsSection), nameof(CivitUrl))] + [NotifyPropertyChangedFor( + nameof(ShowInferenceDefaultsSection), + nameof(CivitUrl), + nameof(IsWorkflowModel) + )] public required partial CivitModel CivitModel { get; set; } + /// + /// Workflow models import into the workflow library rather than the models directory, + /// and hide model-only surfaces such as bulk download. + /// + public bool IsWorkflowModel => CivitModel.Type is CivitModelType.Workflows; + [ObservableProperty] public required partial List ModelIdList { get; set; } @@ -397,6 +411,12 @@ protected override async Task OnInitialLoadedAsync() public async Task DownloadModelAsync(CivitFileViewModel viewModel, string? locationKey = null) { + if (IsWorkflowModel) + { + await DownloadWorkflowAsync(viewModel); + return; + } + DirectoryPath? finalDestinationDir = null; var effectiveLocationKeyForPreference = string.Empty; @@ -559,6 +579,65 @@ await modelImportService.DoImport( } } + /// + /// Imports a workflow file into the workflow library: downloads the archive, extracts the + /// contained workflow jsons, and embeds library metadata in each. + /// + private async Task DownloadWorkflowAsync(CivitFileViewModel viewModel) + { + if (SelectedVersion?.ModelVersion is not { } modelVersion) + { + notificationService.Show( + new Notification( + "No version selected", + "Select a version to import this workflow from", + NotificationType.Warning + ) + ); + return; + } + + var runner = new PackageModificationRunner + { + ShowDialogOnStart = true, + ModificationCompleteTitle = Resources.Label_WorkflowImported, + ModificationCompleteMessage = Resources.Label_FinishedImportingWorkflow, + }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + var downloadStep = new DownloadCivitWorkflowStep( + CivitModel, + modelVersion, + viewModel.CivitFile, + downloadService, + settingsManager + ); + + await runner.ExecuteSteps([downloadStep]); + + if (runner.Failed) + return; + + notificationService.Show( + Resources.Label_WorkflowImported, + Resources.Label_WorkflowImportComplete, + NotificationType.Success + ); + + EventManager.Instance.OnWorkflowInstalled(); + + // Offer custom node installs when the imported workflows need any the user can act on + if (await WorkflowNodesDialogViewModel.HasRequiredPacksAsync(downloadStep.ImportedFiles)) + { + await WorkflowNodesDialogViewModel.ShowDialogAsync( + settingsManager, + packageFactory, + downloadStep.ImportedFiles, + onlyWhenActionable: true + ); + } + } + [RelayCommand] private async Task ShowBulkDownloadDialogAsync() { @@ -857,6 +936,10 @@ private void VmOnNavigateToModelRequested(object? sender, int modelId) private bool ShouldIncludeCivitFile(CivitFile file) { + // Workflow models publish their payload as archive/workflow files, not model weights + if (IsWorkflowModel) + return true; + if (ShowTrainingData) return true; @@ -923,6 +1006,11 @@ private ObservableCollection LoadInstallLocations(CivitFile selectedFile if (Design.IsDesignMode) return ["Models/StableDiffusion", "Custom..."]; + if (IsWorkflowModel) + { + return [Path.GetFileName(settingsManager.WorkflowDirectory)]; + } + var installLocations = new List(); var rootModelsDirectory = new DirectoryPath(settingsManager.ModelsDirectory); diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs index 244b3313f..ecaddeb87 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointsPageViewModel.cs @@ -401,7 +401,7 @@ or nameof(SortConnectedModelsFirst) ) ); - Refresh().SafeFireAndForget(); + Refresh().SafeFireAndForget(ex => logger.LogError(ex, "Error refreshing model index")); EventManager.Instance.ModelIndexChanged += (_, _) => { @@ -409,11 +409,20 @@ or nameof(SortConnectedModelsFirst) // The ModelIndexChanged event may be raised from a background thread. Dispatcher.UIThread.Post(() => { - RefreshCategories(); - ModelsCache.EditDiff( - modelIndexService.ModelIndex.Values.SelectMany(x => x), - LocalModelFile.RelativePathConnectedModelInfoComparer - ); + // Guarded: an exception here is otherwise unhandled on the Dispatcher and + // crashes the app. + try + { + RefreshCategories(); + ModelsCache.EditDiff( + modelIndexService.ModelIndex.Values.SelectMany(x => x), + LocalModelFile.RelativePathConnectedModelInfoComparer + ); + } + catch (Exception ex) + { + logger.LogError(ex, "Error refreshing checkpoint categories"); + } }); }; diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs deleted file mode 100644 index a1848afe8..000000000 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/OpenArtWorkflowViewModel.cs +++ /dev/null @@ -1,189 +0,0 @@ -ο»Ώusing System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Threading.Tasks; -using AsyncAwaitBestPractices; -using Avalonia.Controls; -using CommunityToolkit.Mvvm.ComponentModel; -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.Views.Dialogs; -using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.Helper.Factory; -using StabilityMatrix.Core.Models; -using StabilityMatrix.Core.Models.Api.OpenArt; -using StabilityMatrix.Core.Models.Packages.Extensions; -using StabilityMatrix.Core.Services; - -namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; - -[View(typeof(OpenArtWorkflowDialog))] -[ManagedService] -[RegisterTransient] -public partial class OpenArtWorkflowViewModel( - ISettingsManager settingsManager, - IPackageFactory packageFactory -) : ContentDialogViewModelBase -{ - public required OpenArtSearchResult Workflow { get; init; } - - [ObservableProperty] - private ObservableCollection customNodes = []; - - [ObservableProperty] - private string prunedDescription = string.Empty; - - [ObservableProperty] - private bool installRequiredNodes = true; - - [ObservableProperty] - private InstalledPackage? selectedPackage; - - public PackagePair? SelectedPackagePair => - SelectedPackage is { } package ? packageFactory.GetPackagePair(package) : null; - - public List AvailablePackages => - settingsManager - .Settings.InstalledPackages.Where(package => package.PackageName is "ComfyUI" or "ComfyUI-Zluda") - .ToList(); - - public List MissingNodes { get; } = []; - - public override async Task OnLoadedAsync() - { - if (Design.IsDesignMode) - return; - - if (settingsManager.Settings.PreferredWorkflowPackage is { } preferredPackage) - { - SelectedPackage = preferredPackage; - } - else - { - SelectedPackage = AvailablePackages.FirstOrDefault(); - } - - if (SelectedPackage == null) - { - InstallRequiredNodes = false; - } - - CustomNodes = new ObservableCollection( - await ParseNodes(Workflow.NodesIndex.ToList()) - ); - PrunedDescription = Utilities.RemoveHtml(Workflow.Description); - } - - partial void OnSelectedPackageChanged(InstalledPackage? oldValue, InstalledPackage? newValue) - { - if (oldValue is null) - return; - - settingsManager.Transaction(settings => - { - settings.PreferredWorkflowPackage = newValue; - }); - - OnLoadedAsync().SafeFireAndForget(); - } - - [Localizable(false)] - private async Task> ParseNodes(List nodes) - { - var indexOfFirstDot = nodes.IndexOf("."); - if (indexOfFirstDot != -1) - { - nodes = nodes[(indexOfFirstDot + 1)..]; - } - - var installedNodesNames = new HashSet(); - var nameToManifestNodes = new Dictionary(); - var addedMissingNodes = new HashSet(); - - var packagePair = SelectedPackagePair; - - if (packagePair?.BasePackage.ExtensionManager is { } extensionManager) - { - var installedNodes = ( - await extensionManager.GetInstalledExtensionsLiteAsync(packagePair.InstalledPackage) - ).ToList(); - - var manifestExtensionsMap = await extensionManager.GetManifestExtensionsMapAsync( - extensionManager.GetManifests(packagePair.InstalledPackage) - ); - - // Add manifestExtensions definition to installedNodes if matching git repository url - installedNodes = installedNodes - .Select(installedNode => - { - if ( - installedNode.GitRepositoryUrl is not null - && manifestExtensionsMap.TryGetValue( - installedNode.GitRepositoryUrl, - out var manifestExtension - ) - ) - { - installedNode = installedNode with { Definition = manifestExtension }; - } - - return installedNode; - }) - .ToList(); - - // There may be duplicate titles, deduplicate by using the first one - nameToManifestNodes = manifestExtensionsMap - .GroupBy(x => x.Value.Title) - .ToDictionary(x => x.Key, x => x.First().Value); - - installedNodesNames = installedNodes.Select(x => x.Title).ToHashSet(); - } - - var sections = new List(); - OpenArtCustomNode? currentSection = null; - - foreach (var node in nodes) - { - if (node is "." or ",") - { - currentSection = null; // End of the current section - continue; - } - - if (currentSection == null) - { - currentSection = new OpenArtCustomNode - { - Title = node, - IsInstalled = installedNodesNames.Contains(node), - }; - - // Add missing nodes to the list (deduplicate by title) - if ( - !currentSection.IsInstalled - && addedMissingNodes.Add(node) - && nameToManifestNodes.TryGetValue(node, out var manifestNode) - ) - { - MissingNodes.Add(manifestNode); - } - - sections.Add(currentSection); - } - else - { - currentSection.Children.Add(node); - } - } - - if (sections.FirstOrDefault(x => x.Title == "ComfyUI") != null) - { - sections = sections.Where(x => x.Title != "ComfyUI").ToList(); - } - - return sections; - } -} diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs new file mode 100644 index 000000000..6e1401cdd --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/WorkflowNodesDialogViewModel.cs @@ -0,0 +1,325 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using AsyncAwaitBestPractices; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using FluentAvalonia.UI.Controls; +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Avalonia.Views.Dialogs; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Helper; +using StabilityMatrix.Core.Helper.Factory; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Models.PackageModification; +using StabilityMatrix.Core.Models.Packages.Extensions; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.Dialogs; + +/// +/// Shows the custom node packs a set of workflow files requires, with install +/// options for packs that are missing but resolvable via the extension index. +/// +[View(typeof(WorkflowNodesDialog))] +[ManagedService] +[RegisterTransient] +public partial class WorkflowNodesDialogViewModel( + ISettingsManager settingsManager, + IPackageFactory packageFactory +) : ContentDialogViewModelBase +{ + public required IReadOnlyList WorkflowFiles { get; init; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNoPacks))] + public partial ObservableCollection Packs { get; set; } = []; + + [ObservableProperty] + public partial InstalledPackage? SelectedPackage { get; set; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasNoPacks))] + public partial bool IsLoading { get; set; } + + public List AvailablePackages => + settingsManager + .Settings.InstalledPackages.Where(package => package.PackageName is "ComfyUI" or "ComfyUI-Zluda") + .ToList(); + + public PackagePair? SelectedPackagePair => + SelectedPackage is { } package ? packageFactory.GetPackagePair(package) : null; + + /// + /// True when analysis finished and the workflows need no custom nodes at all. + /// + public bool HasNoPacks => !IsLoading && Packs.Count == 0; + + /// + /// Parses the given workflow files and returns whether any of them reference + /// custom node packs, without any UI. Used to skip the dialog after imports + /// of workflows that only use built-in nodes. + /// + public static async Task HasRequiredPacksAsync(IEnumerable workflowFiles) + { + foreach (var file in workflowFiles.Where(f => f.Exists)) + { + try + { + if ( + JsonNode.Parse(await File.ReadAllTextAsync(file)) is JsonObject workflow + && WorkflowNodeAnalyzer.GetRequiredPacks(workflow).Count > 0 + ) + { + return true; + } + } + catch (Exception) + { + // Unparseable files can't tell us anything + } + } + + return false; + } + + private bool isInitialized; + + public override async Task OnLoadedAsync() + { + if (Design.IsDesignMode) + return; + + await EnsureInitializedAsync(); + } + + /// + /// Runs the pack analysis if it hasn't run yet. Callers may use this before showing + /// the dialog to decide whether showing it is worthwhile. + /// + public async Task EnsureInitializedAsync() + { + if (isInitialized) + return; + + isInitialized = true; + + SelectedPackage = + settingsManager.Settings.PreferredWorkflowPackage ?? AvailablePackages.FirstOrDefault(); + + await AnalyzeAsync(); + } + + partial void OnSelectedPackageChanged(InstalledPackage? oldValue, InstalledPackage? newValue) + { + if (oldValue is null) + return; + + settingsManager.Transaction(settings => + { + settings.PreferredWorkflowPackage = newValue; + }); + + AnalyzeAsync().SafeFireAndForget(); + } + + /// + /// The install steps for the packs the user selected, for the caller to run. + /// + public List GetInstallSteps() + { + if (SelectedPackagePair is not { BasePackage.ExtensionManager: { } extensionManager } pair) + return []; + + return Packs + .Where(pack => pack is { IsSelected: true, Extension: not null }) + .Select(pack => + (IPackageStep) + new InstallExtensionStep(extensionManager, pair.InstalledPackage, pack.Extension!) + ) + .ToList(); + } + + private async Task AnalyzeAsync() + { + IsLoading = true; + + try + { + var requiredPacks = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in WorkflowFiles.Where(f => f.Exists)) + { + JsonObject? workflow; + try + { + workflow = JsonNode.Parse(await File.ReadAllTextAsync(file)) as JsonObject; + } + catch (Exception) + { + continue; + } + + if (workflow is null) + continue; + + foreach (var pack in WorkflowNodeAnalyzer.GetRequiredPacks(workflow)) + { + var key = pack.CnrId ?? pack.AuxId ?? "unidentified"; + if (requiredPacks.TryGetValue(key, out var existing)) + { + existing.NodeTypes.UnionWith(pack.NodeTypes); + } + else + { + requiredPacks[key] = pack; + } + } + } + + var manifestExtensions = new List(); + var installedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (SelectedPackagePair is { BasePackage.ExtensionManager: { } extensionManager } pair) + { + var manifestMap = await extensionManager.GetManifestExtensionsMapAsync( + extensionManager.GetManifests(pair.InstalledPackage) + ); + manifestExtensions = manifestMap.Values.ToList(); + + var installed = await extensionManager.GetInstalledExtensionsLiteAsync(pair.InstalledPackage); + + foreach (var extension in installed) + { + if (extension.PrimaryPath?.Name is { } dirName) + { + installedNames.Add(dirName); + } + + if (GetRepoName(extension.GitRepositoryUrl) is { } repoName) + { + installedNames.Add(repoName); + } + } + } + + Packs = new ObservableCollection( + requiredPacks + .Values.OrderBy(pack => pack.DisplayName) + .Select(pack => + { + var extension = WorkflowNodeAnalyzer.MatchExtension(pack, manifestExtensions); + var isInstalled = + (pack.CnrId is { } cnrId && installedNames.Contains(cnrId)) + || ( + pack.AuxId?.Split('/').Last() is { } auxName + && installedNames.Contains(auxName) + ) + || ( + extension is { Reference: { } reference } + && GetRepoName(reference.ToString()) is { } extensionRepo + && installedNames.Contains(extensionRepo) + ); + + return new WorkflowNodePackViewModel + { + Pack = pack, + Extension = extension, + IsInstalled = isInstalled, + IsSelected = !isInstalled && extension is not null, + }; + }) + ); + } + finally + { + IsLoading = false; + } + } + + private static string? GetRepoName(string? url) => + string.IsNullOrEmpty(url) + ? null + : url.TrimEnd('/').Split('/').LastOrDefault()?.Replace(".git", string.Empty); + + /// + /// Shows the dialog for the given workflow files and runs the selected installs. + /// + public static async Task ShowDialogAsync( + ISettingsManager settingsManager, + IPackageFactory packageFactory, + IReadOnlyList workflowFiles, + bool onlyWhenActionable = false + ) + { + var vm = new WorkflowNodesDialogViewModel(settingsManager, packageFactory) + { + WorkflowFiles = workflowFiles, + }; + + if (onlyWhenActionable) + { + // Skip the dialog when there's nothing the user could do: everything is + // already installed or can't be installed from the index anyway + await vm.EnsureInitializedAsync(); + if (!vm.Packs.Any(pack => pack.CanInstall)) + return; + } + + var dialog = new BetterContentDialog + { + IsPrimaryButtonEnabled = true, + IsSecondaryButtonEnabled = true, + PrimaryButtonText = Languages.Resources.Action_InstallSelected, + SecondaryButtonText = Languages.Resources.Action_Close, + DefaultButton = ContentDialogButton.Primary, + IsFooterVisible = true, + MaxDialogWidth = 600, + MaxDialogHeight = 700, + CloseOnClickOutside = true, + Content = vm, + }; + + if (await dialog.ShowAsync() != ContentDialogResult.Primary) + return; + + var steps = vm.GetInstallSteps(); + if (steps.Count == 0) + return; + + var runner = new PackageModificationRunner + { + ShowDialogOnStart = true, + ModificationCompleteTitle = Languages.Resources.Progress_InstallationComplete, + ModificationCompleteMessage = Languages.Resources.Progress_InstallationComplete, + }; + EventManager.Instance.OnPackageInstallProgressAdded(runner); + + await runner.ExecuteSteps(steps); + } +} + +public partial class WorkflowNodePackViewModel : ObservableObject +{ + public required WorkflowNodePack Pack { get; init; } + + /// Matched extension index entry, null when the pack isn't in the index. + public required PackageExtension? Extension { get; init; } + + public required bool IsInstalled { get; init; } + + [ObservableProperty] + public partial bool IsSelected { get; set; } + + public bool CanInstall => !IsInstalled && Extension is not null; + + public bool IsNotInIndex => !IsInstalled && Extension is null && !Pack.IsUnidentified; + + public string NodeTypesText => string.Join(", ", Pack.NodeTypes); +} diff --git a/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs index 606b75923..97edd6be9 100644 --- a/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/InstalledWorkflowsViewModel.cs @@ -1,4 +1,4 @@ -ο»Ώusing System.Reactive.Linq; +using System.Reactive.Linq; using System.Text.Json; using AsyncAwaitBestPractices; using Avalonia.Controls; @@ -28,16 +28,18 @@ namespace StabilityMatrix.Avalonia.ViewModels; public partial class InstalledWorkflowsViewModel( ISettingsManager settingsManager, INotificationService notificationService -) : TabViewModelBase +) : PageViewModelBase { - public override string Header => Resources.TabLabel_InstalledWorkflows; + public override string Title => Resources.Label_Workflows; + public override IconSource IconSource => new FASymbolIconSource { Symbol = "fa-solid fa-circle-nodes" }; - private readonly SourceCache workflowsCache = - new(x => x.Workflow?.Id ?? Guid.NewGuid().ToString()); + private readonly SourceCache workflowsCache = new(x => + x.Workflow?.Id ?? Guid.NewGuid().ToString() + ); [ObservableProperty] - private IObservableCollection displayedWorkflows = - new ObservableCollectionExtended(); + private IObservableCollection displayedWorkflows = + new ObservableCollectionExtended(); [ObservableProperty] private string searchQuery = string.Empty; @@ -49,7 +51,7 @@ protected override async Task OnInitialLoadedAsync() var searchPredicate = this.WhenPropertyChanged(vm => vm.SearchQuery) .Throttle(TimeSpan.FromMilliseconds(100)) .DistinctUntilChanged() - .Select(_ => (Func)FilterWorkflows); + .Select(_ => (Func)FilterWorkflows); AddDisposable( workflowsCache @@ -92,21 +94,21 @@ var workflowPath in Directory.EnumerateFiles( try { var json = await File.ReadAllTextAsync(workflowPath); - var metadata = JsonSerializer.Deserialize(json); + var metadata = JsonSerializer.Deserialize(json); if (metadata?.Workflow == null) { - metadata = new OpenArtMetadata + metadata = new InstalledWorkflow { Workflow = new OpenArtSearchResult { Id = Guid.NewGuid().ToString(), Name = Path.GetFileNameWithoutExtension(workflowPath), }, - Index = count++, }; } + metadata.Index = count++; metadata.FilePath = [await App.StorageProvider.TryGetFileFromPathAsync(workflowPath)]; workflowsCache.AddOrUpdate(metadata); } @@ -118,12 +120,12 @@ var workflowPath in Directory.EnumerateFiles( } [RelayCommand] - private async Task OpenInExplorer(OpenArtMetadata metadata) + private async Task OpenInExplorer(InstalledWorkflow workflow) { - if (metadata.FilePath == null) + if (workflow.FilePath == null) return; - var path = metadata.FilePath.FirstOrDefault()?.Path.ToString(); + var path = workflow.FilePath.FirstOrDefault()?.Path.ToString(); if (string.IsNullOrWhiteSpace(path)) return; @@ -131,16 +133,16 @@ private async Task OpenInExplorer(OpenArtMetadata metadata) } [RelayCommand] - private void OpenOnOpenArt(OpenArtMetadata metadata) + private void OpenSourcePage(InstalledWorkflow workflow) { - if (metadata.Workflow == null) - return; - - ProcessRunner.OpenUrl($"https://openart.ai/workflows/{metadata.Workflow.Id}"); + if (workflow.SourceUrl is { } url && !string.IsNullOrWhiteSpace(url)) + { + ProcessRunner.OpenUrl(url); + } } [RelayCommand] - private async Task DeleteAsync(OpenArtMetadata metadata) + private async Task DeleteAsync(InstalledWorkflow workflow) { var confirmationDialog = new BetterContentDialog { @@ -157,7 +159,7 @@ private async Task DeleteAsync(OpenArtMetadata metadata) await using var delay = new MinimumDelay(200, 500); - var path = metadata?.FilePath?.FirstOrDefault()?.Path.ToString().Replace("file:///", ""); + var path = workflow?.FilePath?.FirstOrDefault()?.Path.ToString().Replace("file:///", ""); if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { await notificationService.TryAsync( @@ -165,7 +167,7 @@ await notificationService.TryAsync( message: "Error deleting workflow" ); - var id = metadata?.Workflow?.Id; + var id = workflow?.Workflow?.Id; if (!string.IsNullOrWhiteSpace(id)) { workflowsCache.Remove(id); @@ -174,22 +176,22 @@ await notificationService.TryAsync( notificationService.Show( Resources.Label_WorkflowDeleted, - string.Format(Resources.Label_WorkflowDeletedSuccessfully, metadata?.Workflow?.Name) + string.Format(Resources.Label_WorkflowDeletedSuccessfully, workflow?.Workflow?.Name) ); } - private bool FilterWorkflows(OpenArtMetadata metadata) + private bool FilterWorkflows(InstalledWorkflow workflow) { if (string.IsNullOrWhiteSpace(SearchQuery)) return true; - if (metadata.HasMetadata) + if (workflow.HasMetadata) { - return metadata.Workflow.Creator.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) - || metadata.Workflow.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase); + return workflow.Workflow.Creator.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) + || workflow.Workflow.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase); } - return metadata.Workflow?.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false; + return workflow.Workflow?.Name.Contains(SearchQuery, StringComparison.OrdinalIgnoreCase) ?? false; } private void OnWorkflowInstalled(object? sender, EventArgs e) diff --git a/StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs deleted file mode 100644 index 7c52302e9..000000000 --- a/StabilityMatrix.Avalonia/ViewModels/OpenArtBrowserViewModel.cs +++ /dev/null @@ -1,355 +0,0 @@ -ο»Ώusing System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Reactive.Linq; -using System.Threading; -using System.Threading.Tasks; -using AsyncAwaitBestPractices; -using Avalonia.Controls.Notifications; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using DynamicData; -using DynamicData.Binding; -using FluentAvalonia.UI.Controls; -using Injectio.Attributes; -using Refit; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Avalonia.Services; -using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.ViewModels.Dialogs; -using StabilityMatrix.Avalonia.Views; -using StabilityMatrix.Core.Api; -using StabilityMatrix.Core.Attributes; -using StabilityMatrix.Core.Helper; -using StabilityMatrix.Core.Helper.Factory; -using StabilityMatrix.Core.Models.Api.OpenArt; -using StabilityMatrix.Core.Models.PackageModification; -using StabilityMatrix.Core.Processes; -using StabilityMatrix.Core.Services; -using Resources = StabilityMatrix.Avalonia.Languages.Resources; - -namespace StabilityMatrix.Avalonia.ViewModels; - -[View(typeof(OpenArtBrowserPage))] -[RegisterSingleton] -public partial class OpenArtBrowserViewModel( - IOpenArtApi openArtApi, - INotificationService notificationService, - ISettingsManager settingsManager, - IPackageFactory packageFactory -) : TabViewModelBase, IInfinitelyScroll -{ - private const int PageSize = 20; - - public override string Header => Resources.Label_OpenArtBrowser; - - private readonly SourceCache searchResultsCache = new(x => x.Id); - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(PageCount), nameof(CanGoBack), nameof(CanGoForward), nameof(CanGoToEnd))] - private OpenArtSearchResponse? latestSearchResponse; - - [ObservableProperty] - private IObservableCollection searchResults = - new ObservableCollectionExtended(); - - [ObservableProperty] - private string searchQuery = string.Empty; - - [ObservableProperty] - private bool isLoading; - - [ObservableProperty] - [NotifyPropertyChangedFor(nameof(InternalPageNumber), nameof(CanGoBack))] - private int displayedPageNumber = 1; - - public int InternalPageNumber => DisplayedPageNumber - 1; - - public int PageCount => - Math.Max( - 1, - Convert.ToInt32(Math.Ceiling((LatestSearchResponse?.Total ?? 0) / Convert.ToDouble(PageSize))) - ); - - public bool CanGoBack => - string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor) && InternalPageNumber > 0; - - public bool CanGoForward => - !string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor) || PageCount > InternalPageNumber + 1; - - public bool CanGoToEnd => - string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor) && PageCount > InternalPageNumber + 1; - - public IEnumerable AllSortModes => ["Trending", "Latest", "Most Downloaded", "Most Liked"]; - - [ObservableProperty] - private string? selectedSortMode; - - protected override void OnInitialLoaded() - { - searchResultsCache - .Connect() - .DeferUntilLoaded() - .Bind(SearchResults) - .ObserveOn(SynchronizationContext.Current) - .Subscribe(); - SelectedSortMode = AllSortModes.First(); - DoSearch().SafeFireAndForget(); - } - - [RelayCommand] - private async Task FirstPage() - { - DisplayedPageNumber = 1; - searchResultsCache.Clear(); - - await DoSearch(); - } - - [RelayCommand] - private async Task PreviousPage() - { - DisplayedPageNumber--; - searchResultsCache.Clear(); - - await DoSearch(InternalPageNumber); - } - - [RelayCommand] - private async Task NextPage() - { - if (string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - DisplayedPageNumber++; - } - - searchResultsCache.Clear(); - await DoSearch(InternalPageNumber); - } - - [RelayCommand] - private async Task LastPage() - { - if (string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - DisplayedPageNumber = PageCount; - } - - searchResultsCache.Clear(); - await DoSearch(PageCount - 1); - } - - [Localizable(false)] - [RelayCommand] - private void OpenModel(OpenArtSearchResult workflow) - { - ProcessRunner.OpenUrl($"https://openart.ai/workflows/{workflow.Id}"); - } - - [RelayCommand] - private async Task SearchButton() - { - DisplayedPageNumber = 1; - LatestSearchResponse = null; - searchResultsCache.Clear(); - - await DoSearch(); - } - - [RelayCommand] - private async Task OpenWorkflow(OpenArtSearchResult workflow) - { - var vm = new OpenArtWorkflowViewModel(settingsManager, packageFactory) { Workflow = workflow }; - - var dialog = new BetterContentDialog - { - IsPrimaryButtonEnabled = true, - IsSecondaryButtonEnabled = true, - PrimaryButtonText = Resources.Action_Import, - SecondaryButtonText = Resources.Action_Cancel, - DefaultButton = ContentDialogButton.Primary, - IsFooterVisible = true, - MaxDialogWidth = 750, - MaxDialogHeight = 850, - CloseOnClickOutside = true, - Content = vm - }; - - var result = await dialog.ShowAsync(); - - if (result != ContentDialogResult.Primary) - return; - - List steps = - [ - new DownloadOpenArtWorkflowStep(openArtApi, vm.Workflow, settingsManager) - ]; - - // Add install steps if missing nodes and preferred - if ( - vm is - { - InstallRequiredNodes: true, - MissingNodes: { Count: > 0 } missingNodes, - SelectedPackage: not null, - SelectedPackagePair: not null - } - ) - { - var extensionManager = vm.SelectedPackagePair.BasePackage.ExtensionManager!; - - steps.AddRange( - missingNodes.Select( - extension => - new InstallExtensionStep( - extensionManager, - vm.SelectedPackagePair.InstalledPackage, - extension - ) - ) - ); - } - - var runner = new PackageModificationRunner - { - ShowDialogOnStart = true, - ModificationCompleteTitle = Resources.Label_WorkflowImported, - ModificationCompleteMessage = Resources.Label_FinishedImportingWorkflow - }; - EventManager.Instance.OnPackageInstallProgressAdded(runner); - - await runner.ExecuteSteps(steps); - - notificationService.Show( - Resources.Label_WorkflowImported, - Resources.Label_WorkflowImportComplete, - NotificationType.Success - ); - - EventManager.Instance.OnWorkflowInstalled(); - } - - [RelayCommand] - private void OpenOnOpenArt(OpenArtSearchResult? workflow) - { - if (workflow?.Id == null) - return; - - ProcessRunner.OpenUrl($"https://openart.ai/workflows/{workflow.Id}"); - } - - private async Task DoSearch(int page = 0) - { - IsLoading = true; - - try - { - OpenArtSearchResponse? response = null; - if (string.IsNullOrWhiteSpace(SearchQuery)) - { - var request = new OpenArtFeedRequest { Sort = GetSortMode(SelectedSortMode) }; - if (!string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - request.Cursor = LatestSearchResponse.NextCursor; - } - - response = await openArtApi.GetFeedAsync(request); - } - else - { - response = await openArtApi.SearchAsync( - new OpenArtSearchRequest - { - Keyword = SearchQuery, - PageSize = PageSize, - CurrentPage = page - } - ); - } - - foreach (var item in response.Items) - { - searchResultsCache.AddOrUpdate(item); - } - - LatestSearchResponse = response; - } - catch (ApiException e) - { - notificationService.Show(Resources.Label_ErrorRetrievingWorkflows, e.Message); - } - finally - { - IsLoading = false; - } - } - - partial void OnSelectedSortModeChanged(string? value) - { - if (value is null || SearchResults.Count == 0) - return; - - searchResultsCache.Clear(); - LatestSearchResponse = null; - - DoSearch().SafeFireAndForget(); - } - - public async Task LoadNextPageAsync() - { - if (!CanGoForward) - return; - - try - { - OpenArtSearchResponse? response = null; - if (string.IsNullOrWhiteSpace(SearchQuery)) - { - var request = new OpenArtFeedRequest { Sort = GetSortMode(SelectedSortMode) }; - if (!string.IsNullOrWhiteSpace(LatestSearchResponse?.NextCursor)) - { - request.Cursor = LatestSearchResponse.NextCursor; - } - - response = await openArtApi.GetFeedAsync(request); - } - else - { - DisplayedPageNumber++; - response = await openArtApi.SearchAsync( - new OpenArtSearchRequest - { - Keyword = SearchQuery, - PageSize = PageSize, - CurrentPage = InternalPageNumber - } - ); - } - - foreach (var item in response.Items) - { - searchResultsCache.AddOrUpdate(item); - } - - LatestSearchResponse = response; - } - catch (ApiException e) - { - notificationService.Show("Unable to load the next page", e.Message); - } - } - - private static string GetSortMode(string? sortMode) - { - return sortMode switch - { - "Trending" => "trending", - "Latest" => "latest", - "Most Downloaded" => "most_downloaded", - "Most Liked" => "most_liked", - _ => "trending" - }; - } -} diff --git a/StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs deleted file mode 100644 index fe6c8b642..000000000 --- a/StabilityMatrix.Avalonia/ViewModels/WorkflowsPageViewModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -ο»Ώusing System.Collections.Generic; -using System.Linq; -using Avalonia.Controls; -using CommunityToolkit.Mvvm.ComponentModel; -using FluentAvalonia.UI.Controls; -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Languages; -using StabilityMatrix.Avalonia.ViewModels.Base; -using StabilityMatrix.Avalonia.Views; -using StabilityMatrix.Core.Attributes; - -namespace StabilityMatrix.Avalonia.ViewModels; - -[View(typeof(WorkflowsPage))] -[RegisterSingleton] -public partial class WorkflowsPageViewModel : PageViewModelBase -{ - public override string Title => Resources.Label_Workflows; - public override IconSource IconSource => new FASymbolIconSource { Symbol = "fa-solid fa-circle-nodes" }; - - public IReadOnlyList Pages { get; } - - [ObservableProperty] - private TabItem? selectedPage; - - /// - public WorkflowsPageViewModel( - OpenArtBrowserViewModel openArtBrowserViewModel, - InstalledWorkflowsViewModel installedWorkflowsViewModel - ) - { - Pages = new List( - new List([openArtBrowserViewModel, installedWorkflowsViewModel]).Select( - vm => new TabItem { Header = vm.Header, Content = vm } - ) - ); - SelectedPage = Pages.FirstOrDefault(); - } -} diff --git a/StabilityMatrix.Avalonia/Views/CivArchiveDetailsPage.axaml b/StabilityMatrix.Avalonia/Views/CivArchiveDetailsPage.axaml index 2a04df1e9..1135ba4c6 100644 --- a/StabilityMatrix.Avalonia/Views/CivArchiveDetailsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CivArchiveDetailsPage.axaml @@ -587,14 +587,17 @@ + + Text="{Binding}" + TextWrapping="Wrap" /> diff --git a/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml b/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml index 239e5d2db..bb25fb707 100644 --- a/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/CivitDetailsPage.axaml @@ -215,6 +215,7 @@ diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml deleted file mode 100644 index a5950c331..000000000 --- a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml +++ /dev/null @@ -1,137 +0,0 @@ -ο»Ώ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs b/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs deleted file mode 100644 index d9fe00a75..000000000 --- a/StabilityMatrix.Avalonia/Views/Dialogs/OpenArtWorkflowDialog.axaml.cs +++ /dev/null @@ -1,13 +0,0 @@ -ο»Ώusing Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; - -namespace StabilityMatrix.Avalonia.Views.Dialogs; - -[RegisterTransient] -public partial class OpenArtWorkflowDialog : UserControlBase -{ - public OpenArtWorkflowDialog() - { - InitializeComponent(); - } -} diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml new file mode 100644 index 000000000..0878d1a7b --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml @@ -0,0 +1,129 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs new file mode 100644 index 000000000..c4d1216b6 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/Dialogs/WorkflowNodesDialog.axaml.cs @@ -0,0 +1,13 @@ +using Injectio.Attributes; +using StabilityMatrix.Avalonia.Controls; + +namespace StabilityMatrix.Avalonia.Views.Dialogs; + +[RegisterTransient] +public partial class WorkflowNodesDialog : UserControlBase +{ + public WorkflowNodesDialog() + { + InitializeComponent(); + } +} diff --git a/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml b/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml index e7ef3fa28..81757225e 100644 --- a/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/InstalledWorkflowsPage.axaml @@ -15,7 +15,6 @@ xmlns:lang="clr-namespace:StabilityMatrix.Avalonia.Languages" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:models="clr-namespace:StabilityMatrix.Avalonia.Models" - xmlns:openArt="clr-namespace:StabilityMatrix.Core.Models.Api.OpenArt" xmlns:vendorLabs="clr-namespace:StabilityMatrix.Avalonia.Controls.VendorLabs" xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" d:DataContext="{x:Static designData:DesignData.InstalledWorkflowsViewModel}" @@ -71,7 +70,7 @@ - + @@ -128,7 +127,7 @@ - + + Header="{x:Static lang:Resources.Action_OpenOnCivitAi}" + IsVisible="{Binding SourceUrl, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> diff --git a/StabilityMatrix.Avalonia/Views/MainWindow.axaml b/StabilityMatrix.Avalonia/Views/MainWindow.axaml index 30ecc7b21..c6b9900df 100644 --- a/StabilityMatrix.Avalonia/Views/MainWindow.axaml +++ b/StabilityMatrix.Avalonia/Views/MainWindow.axaml @@ -38,6 +38,13 @@ + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs b/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs deleted file mode 100644 index 002ece52c..000000000 --- a/StabilityMatrix.Avalonia/Views/OpenArtBrowserPage.axaml.cs +++ /dev/null @@ -1,41 +0,0 @@ -ο»Ώusing System; -using AsyncAwaitBestPractices; -using Avalonia.Controls; -using Injectio.Attributes; -using StabilityMatrix.Avalonia.Controls; -using StabilityMatrix.Avalonia.Models; -using StabilityMatrix.Core.Services; - -namespace StabilityMatrix.Avalonia.Views; - -[RegisterSingleton] -public partial class OpenArtBrowserPage : UserControlBase -{ - private readonly ISettingsManager settingsManager; - - public OpenArtBrowserPage(ISettingsManager settingsManager) - { - this.settingsManager = settingsManager; - InitializeComponent(); - } - - private void ScrollViewer_OnScrollChanged(object? sender, ScrollChangedEventArgs e) - { - if (sender is not ScrollViewer scrollViewer) - return; - - if (scrollViewer.Offset.Y == 0) - return; - - var isAtEnd = Math.Abs(scrollViewer.Offset.Y - scrollViewer.ScrollBarMaximum.Y) < 1f; - - if ( - isAtEnd - && settingsManager.Settings.IsWorkflowInfiniteScrollEnabled - && DataContext is IInfinitelyScroll scroll - ) - { - scroll.LoadNextPageAsync().SafeFireAndForget(); - } - } -} diff --git a/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml b/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml index e05e94c0c..f99825e7c 100644 --- a/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml +++ b/StabilityMatrix.Avalonia/Views/PackageManager/MainPackageManagerView.axaml @@ -113,8 +113,10 @@ VerticalAlignment="Top" HorizontalContentAlignment="Right" VerticalContentAlignment="Top" + AutomationProperties.Name="More Options" BorderThickness="0" - Classes="transparent"> + Classes="transparent" + ToolTip.Tip="More Options"> @@ -347,6 +349,7 @@