Browse Source

Merge pull request #90 from ionite34/fix-model-manager-ui

pull/5/head
Ionite 1 year ago committed by GitHub
parent
commit
b6af7cf301
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      .editorconfig
  2. 285
      StabilityMatrix/CheckpointManagerPage.xaml
  3. 1
      StabilityMatrix/Database/ILiteDbContext.cs
  4. 22
      StabilityMatrix/Database/LiteDbContext.cs
  5. 10
      StabilityMatrix/DesignData/MockCheckpointFolder.cs
  6. 20
      StabilityMatrix/DesignData/MockCheckpointManagerViewModel.cs
  7. 85
      StabilityMatrix/Helper/DialogFactory.cs
  8. 3
      StabilityMatrix/Helper/IDialogFactory.cs
  9. 2
      StabilityMatrix/Helper/ISettingsManager.cs
  10. 41
      StabilityMatrix/Helper/SettingsManager.cs
  11. 5
      StabilityMatrix/MainWindow.xaml
  12. 112
      StabilityMatrix/Models/CheckpointFile.cs
  13. 58
      StabilityMatrix/Models/CheckpointFolder.cs
  14. 5
      StabilityMatrix/Models/Settings.cs
  15. 46
      StabilityMatrix/Models/SharedFolderType.cs
  16. 4
      StabilityMatrix/StabilityMatrix.csproj
  17. 10
      StabilityMatrix/ViewModels/CheckpointManagerViewModel.cs
  18. 48
      StabilityMatrix/ViewModels/SettingsViewModel.cs
  19. 2
      StabilityMatrix/ViewModels/TextToImageViewModel.cs

9
.editorconfig

@ -0,0 +1,9 @@
root = true
[*]
indent_size = 4
max_line_length = 100
csharp_style_var_for_built_in_types = true
dotnet_sort_system_directives_first = true

285
StabilityMatrix/CheckpointManagerPage.xaml

@ -5,14 +5,18 @@
d:DesignHeight="1000"
d:DesignWidth="650"
mc:Ignorable="d"
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
ui:Design.Foreground="{DynamicResource TextFillColorPrimaryBrush}"
x:Class="StabilityMatrix.CheckpointManagerPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:controls="clr-namespace:StabilityMatrix.Controls"
xmlns:converters="clr-namespace:StabilityMatrix.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:designData="clr-namespace:StabilityMatrix.DesignData"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:StabilityMatrix.Models"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:viewModels="clr-namespace:StabilityMatrix.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
@ -26,6 +30,10 @@
<converters:BoolNegationConverter />
<converters:BooleanToHiddenVisibleConverter />
</converters:ValueConverterGroup>
<converters:ValueConverterGroup x:Key="InvertBoolToVisibilityConverter">
<converters:BoolNegationConverter />
<BooleanToVisibilityConverter />
</converters:ValueConverterGroup>
<DropShadowEffect
BlurRadius="8"
@ -35,61 +43,120 @@
ShadowDepth="0"
x:Key="TextDropShadowEffect" />
<!-- Template for a single badge -->
<DataTemplate DataType="{x:Type system:String}" x:Key="BadgeTemplate">
<ui:Card
Height="18"
Margin="4,0,0,0"
Padding="3"
Visibility="{Binding Converter={StaticResource StringNullOrEmptyToVisibilityConverter}, FallbackValue=Collapsed}"
Width="40">
<TextBlock
FontSize="10"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
Text="{Binding}"
VerticalAlignment="Center" />
</ui:Card>
</DataTemplate>
<DataTemplate DataType="{x:Type models:CheckpointFile}" x:Key="CheckpointFileDataTemplate">
<Border Background="Transparent" BorderThickness="0">
<Border
Background="Transparent"
BorderThickness="0"
Margin="8">
<i:Interaction.Behaviors>
<i:MouseDragElementBehavior />
</i:Interaction.Behaviors>
<ui:Card
Margin="8,8,8,8"
MaxHeight="512"
MaxWidth="300"
MinWidth="240"
Padding="12,16">
<ui:Card Width="260">
<ui:Card.ContextMenu>
<ContextMenu>
<MenuItem Header="Rename" />
<!-- ReSharper disable once Xaml.RedundantResource -->
<MenuItem Command="{Binding RenameCommand}" Header="Rename" />
<!-- ReSharper disable once Xaml.RedundantResource -->
<MenuItem Command="{Binding DeleteCommand}" Header="Delete" />
</ContextMenu>
</ui:Card.ContextMenu>
<Grid>
<!-- Main contents, hidden when IsLoading is true -->
<VirtualizingStackPanel Orientation="Vertical">
<StackPanel MinHeight="70">
<ui:Image
CornerRadius="4"
Margin="0,0,0,8"
Source="{Binding PreviewImagePath}"
Source="{Binding PreviewImage}"
Stretch="Uniform"
Visibility="Collapsed" />
<Grid
HorizontalAlignment="Stretch"
Name="ModelInfoGrid"
VerticalAlignment="Stretch"
Visibility="{Binding IsLoading, Converter={StaticResource InvertAndVisibleOrHidden}}">
<Grid Name="ModelInfoGrid" Visibility="{Binding IsLoading, Converter={StaticResource InvertAndVisibleOrHidden}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0.6*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Orientation="Vertical">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="0.1*" />
</Grid.RowDefinitions>
<StackPanel
Grid.ColumnSpan="4"
HorizontalAlignment="Left"
IsEnabled="True"
Margin="4"
Orientation="Vertical"
ToolTipService.BetweenShowDelay="100"
ToolTipService.InitialShowDelay="300">
<!-- Tooltip for title and version -->
<StackPanel.ToolTip>
<ToolTip Padding="8">
<VirtualizingStackPanel>
<!-- Title -->
<TextBlock
FontSize="14"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Left"
Margin="0,0,0,4"
Text="{Binding Title}"
TextWrapping="WrapWithOverflow" />
<!-- Version -->
<TextBlock
FontSize="12"
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
HorizontalAlignment="Left"
Margin="0,0,0,4"
Text="{Binding ConnectedModel.VersionName, FallbackValue=''}"
TextWrapping="WrapWithOverflow"
Visibility="{Binding IsConnectedModel, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- Image -->
<ui:Image
CornerRadius="4"
Margin="0,0,0,4"
Source="{Binding PreviewImage}"
Stretch="Uniform"
Visibility="{Binding IsConnectedModel, Converter={StaticResource BoolToVisibilityConverter}}" />
</VirtualizingStackPanel>
</ToolTip>
</StackPanel.ToolTip>
<TextBlock
FontSize="11"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Left"
IsEnabled="True"
MaxWidth="150"
Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
FontSize="13"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,4"
Text="{Binding FileName}"
HorizontalAlignment="Left"
IsEnabled="True"
Text="{Binding ConnectedModel.VersionName, FallbackValue=''}"
TextWrapping="WrapWithOverflow"
VerticalAlignment="Bottom" />
Visibility="{Binding IsConnectedModel, Converter={StaticResource BoolToVisibilityConverter}}" />
</StackPanel>
<Grid Grid.Column="1" Visibility="{Binding IsConnectedModel, Mode=OneWay, Converter={StaticResource BoolToHiddenVisibleConverter}}">
<Grid Grid.Column="4" Grid.Row="0">
<Button
Background="Transparent"
BorderBrush="Transparent"
@ -108,6 +175,24 @@
Symbol="CloudCheckmark24"
ToolTip="Connected Model" />
</Button>
<Button
Background="Transparent"
BorderBrush="Transparent"
FontSize="20"
HorizontalAlignment="Right"
IsEnabled="True"
MaxHeight="48"
MaxWidth="64"
Padding="0"
ToolTip="Local Model"
VerticalAlignment="Top"
Visibility="{Binding IsConnectedModel, Mode=OneWay, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<ui:SymbolIcon
FontSize="12"
Foreground="DimGray"
Symbol="CloudOff24"
ToolTip="Local Model" />
</Button>
<ui:Button
Background="Transparent"
BorderBrush="Transparent"
@ -127,43 +212,37 @@
ToolTip="Update Available" />
</ui:Button.Icon>
</ui:Button>
<VirtualizingStackPanel
</Grid>
<TextBlock
FontSize="11"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Grid.Column="0"
Grid.ColumnSpan="3"
Grid.Row="1"
IsEnabled="True"
Margin="4,4"
Text="{Binding FileName}"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding FileName}"
VerticalAlignment="Bottom" />
<ItemsControl
Grid.Column="3"
Grid.ColumnSpan="2"
Grid.Row="1"
ItemTemplate="{StaticResource BadgeTemplate}"
ItemsSource="{Binding Badges}"
VerticalAlignment="Bottom">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
VerticalAlignment="Bottom"
Visibility="{Binding IsConnectedModel, Mode=OneWay, Converter={StaticResource BoolToHiddenVisibleConverter}}">
<ui:Card
Height="18"
Margin="4,0,0,0"
Padding="3"
Visibility="{Binding FpType, Converter={StaticResource StringNullOrEmptyToVisibilityConverter}, FallbackValue=Collapsed}"
Width="40">
<TextBlock
FontSize="10"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
Text="{Binding FpType}"
VerticalAlignment="Center" />
</ui:Card>
<ui:Card
Height="18"
Margin="4,0,0,0"
Padding="3"
Visibility="{Binding BaseModel, Converter={StaticResource StringNullOrEmptyToVisibilityConverter}, FallbackValue=Collapsed}"
Width="40">
<TextBlock
FontSize="10"
FontWeight="Medium"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center"
Text="{Binding BaseModel}"
VerticalAlignment="Center" />
</ui:Card>
</VirtualizingStackPanel>
</Grid>
VerticalAlignment="Bottom" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</VirtualizingStackPanel>
</StackPanel>
<!-- Progress ring -->
<ui:ProgressRing
@ -186,7 +265,9 @@
AllowDrop="True"
Header="{Binding Title}"
IsExpanded="True"
Margin="8">
Margin="8"
Padding="8,8,8,16"
Visibility="{Binding IsCategoryEnabled, Converter={StaticResource BoolToVisibilityConverter}, FallbackValue=Visible}">
<i:Interaction.Triggers>
<i:EventTrigger EventName="PreviewDragEnter">
<i:InvokeCommandAction Command="{Binding OnPreviewDragEnterCommand}" />
@ -264,9 +345,9 @@
IsIndeterminate="{Binding Progress.IsIndeterminate, FallbackValue=False}"
Margin="64,8"
Name="ImportProgressBar"
Value="{Binding Progress.Value, FallbackValue=20}"
VerticalAlignment="Center"
Visibility="{Binding Progress.ProgressVisibility, FallbackValue=Visible}" />
Visibility="{Binding Progress.ProgressVisibility, FallbackValue=Visible}"
controls:ProgressBarSmoother.SmoothValue="{Binding Progress.Value, FallbackValue=20}" />
</VirtualizingStackPanel>
</Grid>
</Expander>
@ -281,17 +362,91 @@
</ContentControl.Style>
</ContentControl>
</DataTemplate>
<!-- Template for dropdown category checkbox item -->
<DataTemplate DataType="{x:Type models:CheckpointFolder}" x:Key="CategoryItemTemplate">
<CheckBox Content="{Binding Title}" IsChecked="{Binding IsCategoryEnabled, Mode=TwoWay}" />
</DataTemplate>
<!-- Template for dropdown box -->
<ControlTemplate TargetType="{x:Type ComboBoxItem}" x:Key="CategoryDropDownStyle">
<Grid>
<Border
CornerRadius="{TemplateBinding Border.CornerRadius}"
Margin="{TemplateBinding Margin}"
Name="ContentBorder"
Padding="0"
SnapsToDevicePixels="True"
VerticalAlignment="Stretch">
<Grid>
<Rectangle
Height="16"
HorizontalAlignment="Left"
Margin="0"
RadiusX="2"
RadiusY="2"
VerticalAlignment="Center"
Visibility="Collapsed"
Width="3"
x:Name="ActiveRectangle">
<Rectangle.Fill>
<SolidColorBrush Color="{DynamicResource SystemAccentColorSecondary}" />
</Rectangle.Fill>
</Rectangle>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Setup grid rows -->
<StackPanel
Grid.Column="0"
Margin="10,0,0,0"
VerticalAlignment="Top"
d:DataContext="{d:DesignInstance Type=models:CheckpointFolder,
IsDesignTimeCreatable=True}">
<CheckBox Content="{Binding TitleWithFilesCount}" IsChecked="{Binding IsCategoryEnabled, Mode=TwoWay}" />
</StackPanel>
</Grid>
</Grid>
</Border>
</Grid>
</ControlTemplate>
</Page.Resources>
<!-- Folder Cards -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="0.1*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<VirtualizingStackPanel Margin="16,8" Orientation="Horizontal">
<ui:ToggleSwitch Content="Preview Images" />
</VirtualizingStackPanel>
<!-- Top settings bar -->
<StackPanel
HorizontalAlignment="Right"
Margin="16,0"
Orientation="Horizontal">
<ComboBox
HorizontalAlignment="Right"
ItemsSource="{Binding CheckpointFolders}"
MinWidth="180"
SelectedIndex="0">
<ComboBox.ItemContainerStyle>
<Style TargetType="ComboBoxItem">
<Setter Property="Template" Value="{StaticResource CategoryDropDownStyle}" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:CheckpointFolder}">
<StackPanel Margin="10,0,0,0" VerticalAlignment="Top">
<TextBlock Margin="0,5,0,5" Text="Categories" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Main view with model cards -->
<ui:DynamicScrollViewer
CanContentScroll="True"
Grid.Row="1"
@ -303,7 +458,7 @@
HorizontalAlignment="Stretch"
ItemTemplate="{StaticResource CheckpointFolderGridDataTemplate}"
ItemsSource="{Binding CheckpointFolders, Mode=OneWay}"
Margin="8" />
Margin="8,0,8,8" />
</StackPanel>
</Grid>
</ui:DynamicScrollViewer>

1
StabilityMatrix/Database/ILiteDbContext.cs

@ -14,6 +14,7 @@ public interface ILiteDbContext : IDisposable
ILiteCollectionAsync<CivitModelVersion> CivitModelVersions { get; }
ILiteCollectionAsync<CivitModelQueryCacheEntry> CivitModelQueryCache { get; }
Task<(CivitModel?, CivitModelVersion?)> FindCivitModelFromFileHashAsync(string hashBlake3);
Task<bool> UpsertCivitModelAsync(CivitModel civitModel);
Task<bool> UpsertCivitModelAsync(IEnumerable<CivitModel> civitModels);
Task<bool> UpsertCivitModelQueryCacheEntryAsync(CivitModelQueryCacheEntry entry);

22
StabilityMatrix/Database/LiteDbContext.cs

@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using AsyncAwaitBestPractices;
using LiteDB;
using LiteDB.Async;
using StabilityMatrix.Extensions;
using StabilityMatrix.Models.Api;
@ -24,10 +27,29 @@ public class LiteDbContext : ILiteDbContext
{
Database = new LiteDatabaseAsync(connectionString);
// Register reference fields
LiteDBExtensions.Register<CivitModel, CivitModelVersion>(m => m.ModelVersions, "CivitModelVersions");
LiteDBExtensions.Register<CivitModelQueryCacheEntry, CivitModel>(e => e.Items, "CivitModels");
}
public async Task<(CivitModel?, CivitModelVersion?)> FindCivitModelFromFileHashAsync(string hashBlake3)
{
var version = await CivitModelVersions.Query()
.Where(mv => mv.Files!
.Select(f => f.Hashes)
.Select(hashes => hashes.BLAKE3)
.Any(hash => hash == hashBlake3))
.FirstOrDefaultAsync();
if (version is null) return (null, null);
var model = await CivitModels.Query()
.Include(m => m.ModelVersions)
.Where(m => m.ModelVersions!
.Select(v => v.Id)
.Any(id => id == version.Id))
.FirstOrDefaultAsync();
return (model, version);
}
public async Task<bool> UpsertCivitModelAsync(CivitModel civitModel)
{
// Insert model versions first then model

10
StabilityMatrix/DesignData/MockCheckpointFolder.cs

@ -0,0 +1,10 @@
using StabilityMatrix.Models;
namespace StabilityMatrix.DesignData;
public class MockCheckpointFolder : CheckpointFolder
{
public MockCheckpointFolder() : base(null!, null!, useCategoryVisibility: false)
{
}
}

20
StabilityMatrix/DesignData/MockCheckpointManagerViewModel.cs

@ -7,26 +7,26 @@ namespace StabilityMatrix.DesignData;
[DesignOnly(true)]
public class MockCheckpointManagerViewModel : CheckpointManagerViewModel
{
public MockCheckpointManagerViewModel() : base(null!, null!)
public MockCheckpointManagerViewModel() : base(null!, null!, null!)
{
CheckpointFolders = new()
{
new()
new MockCheckpointFolder
{
Title = "Stable Diffusion",
CheckpointFiles = new()
{
new()
new(null!)
{
Title = "Stable Diffusion v1.5",
FilePath = "v1-5-pruned-emaonly.safetensors",
},
new()
new(null!)
{
Title = "Scenery Mix",
FilePath = "scenery-mix.pt",
},
new()
new(null!)
{
Title = "Some Model",
FilePath = "exr-v3.safetensors",
@ -42,13 +42,13 @@ public class MockCheckpointManagerViewModel : CheckpointManagerViewModel
}
}
},
new()
new(null!)
{
Title = "Painting e12",
FilePath = "painting-e12.pt",
ConnectedModel = new()
{
ModelName = "Long Name Model (Stuff)",
ModelName = "Long Name Model (Stuff / More Content)",
VersionName = "v42-Advanced-Hybrid",
ModelDescription = "Example Description",
BaseModel = "SD 2.0",
@ -60,18 +60,18 @@ public class MockCheckpointManagerViewModel : CheckpointManagerViewModel
},
}
},
new()
new MockCheckpointFolder
{
Title = "Lora",
IsCurrentDragTarget = true,
CheckpointFiles = new()
{
new()
new(null!)
{
Title = "Detail Tweaker LoRA",
FilePath = "add_detail.safetensors",
},
new()
new(null!)
{
Title = "Armor Suit LoRa",
FilePath = "ArmorSuit_v1.safetensors",

85
StabilityMatrix/Helper/DialogFactory.cs

@ -1,8 +1,15 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using StabilityMatrix.Models;
using StabilityMatrix.Services;
using StabilityMatrix.ViewModels;
using Wpf.Ui.Contracts;
using Wpf.Ui.Controls;
using Wpf.Ui.Controls.ContentDialogControl;
using TextBox = Wpf.Ui.Controls.TextBox;
namespace StabilityMatrix.Helper;
@ -37,6 +44,84 @@ public class DialogFactory : IDialogFactory
return new LaunchOptionsDialog(contentDialogService, launchOptionsDialogViewModel);
}
/// <summary>
/// Creates a dialog that allows the user to enter text for each field name.
/// Return a list of strings that correspond to the field names.
/// If cancel is pressed, return null.
/// <param name="fields">List of (fieldName, placeholder)</param>
/// </summary>
public async Task<List<string>?> ShowTextEntryDialog(string title,
IEnumerable<(string, string)> fields,
string closeButtonText = "Cancel",
string saveButtonText = "Save")
{
var dialog = contentDialogService.CreateDialog();
dialog.Title = title;
dialog.PrimaryButtonAppearance = ControlAppearance.Primary;
dialog.CloseButtonText = closeButtonText;
dialog.PrimaryButtonText = saveButtonText;
dialog.IsPrimaryButtonEnabled = true;
var textBoxes = new List<TextBox>();
var stackPanel = new StackPanel();
dialog.Content = stackPanel;
foreach (var (fieldName, fieldPlaceholder) in fields)
{
var textBox = new TextBox
{
PlaceholderText = fieldPlaceholder,
PlaceholderEnabled = true,
MinWidth = 200,
};
textBoxes.Add(textBox);
stackPanel.Children.Add(new Card
{
Content = new StackPanel
{
Children =
{
new TextBlock
{
Text = fieldName,
Margin = new Thickness(0, 0, 0, 4)
},
textBox
}
},
Margin = new Thickness(16)
});
}
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
return textBoxes.Select(x => x.Text).ToList();
}
return null;
}
/// <summary>
/// Creates and shows a confirmation dialog.
/// Return true if the user clicks the primary button.
/// </summary>
public async Task<bool> ShowConfirmationDialog(string title, string message, string closeButtonText = "Cancel", string primaryButtonText = "Confirm")
{
var dialog = contentDialogService.CreateDialog();
dialog.Title = title;
dialog.PrimaryButtonAppearance = ControlAppearance.Primary;
dialog.CloseButtonText = closeButtonText;
dialog.PrimaryButtonText = primaryButtonText;
dialog.IsPrimaryButtonEnabled = true;
dialog.Content = new TextBlock
{
Text = message,
Margin = new Thickness(16)
};
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
public OneClickInstallDialog CreateOneClickInstallDialog()
{
return new OneClickInstallDialog(contentDialogService, oneClickInstallViewModel);

3
StabilityMatrix/Helper/IDialogFactory.cs

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using StabilityMatrix.Models;
namespace StabilityMatrix.Helper;
@ -8,4 +9,6 @@ public interface IDialogFactory
LaunchOptionsDialog CreateLaunchOptionsDialog(IEnumerable<LaunchOptionDefinition> definitions, InstalledPackage installedPackage);
InstallerWindow CreateInstallerWindow();
OneClickInstallDialog CreateOneClickInstallDialog();
Task<List<string>?> ShowTextEntryDialog(string title, IEnumerable<(string, string)> fieldNames,
string closeButtonText = "Cancel", string saveButtonText = "Save");
}

2
StabilityMatrix/Helper/ISettingsManager.cs

@ -40,4 +40,6 @@ public interface ISettingsManager
void SetModelsDirectory(string? directory);
void SetFirstLaunchSetupComplete(bool firstLaunchSetupCompleted);
void SetModelBrowserNsfwEnabled(bool value);
void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible);
bool IsSharedFolderCategoryVisible(SharedFolderType type);
}

41
StabilityMatrix/Helper/SettingsManager.cs

@ -4,7 +4,7 @@ using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using StabilityMatrix.Api;
using System.Threading;
using StabilityMatrix.Models;
using Wpf.Ui.Controls.Window;
@ -12,6 +12,8 @@ namespace StabilityMatrix.Helper;
public class SettingsManager : ISettingsManager
{
private static readonly ReaderWriterLockSlim FileLock = new();
/// <summary>
/// Directory of %AppData%
/// </summary>
@ -222,7 +224,31 @@ public class SettingsManager : ISettingsManager
SaveSettings();
}
public void SetSharedFolderCategoryVisible(SharedFolderType type, bool visible)
{
Settings.SharedFolderVisibleCategories ??= new SharedFolderType();
if (visible)
{
Settings.SharedFolderVisibleCategories |= type;
}
else
{
Settings.SharedFolderVisibleCategories &= ~type;
}
SaveSettings();
}
public bool IsSharedFolderCategoryVisible(SharedFolderType type)
{
// False for default
if (type == 0) return false;
return Settings.SharedFolderVisibleCategories?.HasFlag(type) ?? false;
}
private void LoadSettings()
{
FileLock.EnterReadLock();
try
{
var settingsContent = File.ReadAllText(SettingsPath);
Settings = JsonSerializer.Deserialize<Settings>(settingsContent, new JsonSerializerOptions
@ -230,8 +256,16 @@ public class SettingsManager : ISettingsManager
Converters = { new JsonStringEnumConverter() }
})!;
}
finally
{
FileLock.ExitReadLock();
}
}
private void SaveSettings()
{
FileLock.TryEnterWriteLock(1000);
try
{
var json = JsonSerializer.Serialize(Settings, new JsonSerializerOptions
{
@ -240,4 +274,9 @@ public class SettingsManager : ISettingsManager
});
File.WriteAllText(SettingsPath, json);
}
finally
{
FileLock.ExitWriteLock();
}
}
}

5
StabilityMatrix/MainWindow.xaml

@ -2,12 +2,12 @@
Closed="MainWindow_OnClosed"
ExtendsContentIntoTitleBar="True"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Height="750"
Height="900"
Icon="pack://application:,,,/Assets/Icon.ico"
Loaded="MainWindow_OnLoaded"
Title="Stability Matrix"
UseLayoutRounding="True"
Width="1100"
Width="1300"
WindowBackdropType="Mica"
WindowStartupLocation="CenterScreen"
d:DataContext="{d:DesignInstance Type=viewModels:MainWindowViewModel,
@ -62,6 +62,7 @@
<Grid Grid.Row="1">
<ui:NavigationView
IsBackButtonVisible="Collapsed"
OpenPaneLength="260"
PaneClosed="RootNavigation_OnPaneClosed"
PaneOpened="RootNavigation_OnPaneOpened"
UseLayoutRounding="True"

112
StabilityMatrix/Models/CheckpointFile.cs

@ -1,60 +1,83 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using AsyncAwaitBestPractices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NLog;
using StabilityMatrix.Extensions;
using StabilityMatrix.Helper;
namespace StabilityMatrix.Models;
public partial class CheckpointFile : ObservableObject
{
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly IDialogFactory dialogFactory;
// Event for when this file is deleted
public event EventHandler<CheckpointFile>? Deleted;
/// <summary>
/// Absolute path to the checkpoint file.
/// </summary>
public string FilePath { get; init; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FileName))]
private string filePath = string.Empty;
/// <summary>
/// Custom title for UI.
/// </summary>
[ObservableProperty] private string title = string.Empty;
[ObservableProperty]
private string title = string.Empty;
public string? PreviewImagePath { get; set; }
public BitmapImage? PreviewImage { get; set; }
public bool IsPreviewImageLoaded => PreviewImage != null;
[ObservableProperty] private ConnectedModelInfo? connectedModel;
[ObservableProperty]
private ConnectedModelInfo? connectedModel;
public bool IsConnectedModel => ConnectedModel != null;
[ObservableProperty] private string fpType = string.Empty;
[ObservableProperty] private string baseModel = string.Empty;
[ObservableProperty] private bool isLoading;
public string FileName => Path.GetFileName(FilePath);
public ObservableCollection<string> Badges { get; set; } = new();
private static readonly string[] SupportedCheckpointExtensions = { ".safetensors", ".pt", ".ckpt", ".pth", "bin" };
private static readonly string[] SupportedImageExtensions = { ".png", ".jpg", ".jpeg" };
private static readonly string[] SupportedMetadataExtensions = { ".json" };
public CheckpointFile(IDialogFactory dialogFactory)
{
this.dialogFactory = dialogFactory;
}
partial void OnConnectedModelChanged(ConnectedModelInfo? value)
{
if (value == null) return;
// Update title, first check user defined, then connected model name
Title = value.UserTitle ?? value.ModelName;
// Update fp type and base model
FpType = value.FileMetadata.Fp?.GetStringValue().ToUpperInvariant() ?? "";
BaseModel = value.BaseModel ?? "";
Title = value?.UserTitle ?? value?.ModelName ?? string.Empty;
// Update badges
Badges.Clear();
var fpType = value.FileMetadata.Fp?.GetStringValue().ToUpperInvariant();
if (fpType != null)
{
Badges.Add(fpType);
}
if (!string.IsNullOrWhiteSpace(value.BaseModel))
{
Badges.Add(value.BaseModel);
}
}
[RelayCommand]
@ -62,34 +85,70 @@ public partial class CheckpointFile : ObservableObject
{
if (File.Exists(FilePath))
{
// Start progress ring
IsLoading = true;
var timer = Stopwatch.StartNew();
try
{
await using var delay = new MinimumDelay(200, 500);
await Task.Run(() => File.Delete(FilePath));
if (PreviewImagePath != null && File.Exists(PreviewImagePath))
{
await Task.Run(() => File.Delete(PreviewImagePath));
}
// If it was too fast, wait a bit to show progress ring
var targetDelay = new Random().Next(200, 500);
var elapsed = timer.ElapsedMilliseconds;
if (elapsed < targetDelay)
{
await Task.Delay(targetDelay - (int) elapsed);
}
catch (IOException ex)
{
Logger.Warn($"Failed to delete checkpoint file {FilePath}: {ex.Message}");
return; // Don't delete from collection
}
catch (IOException e)
finally
{
Logger.Error(e, $"Failed to delete checkpoint file: {FilePath}");
IsLoading = false;
return; // Don't delete from collection
}
}
Deleted?.Invoke(this, this);
}
[RelayCommand]
private async Task RenameAsync()
{
var responses = await dialogFactory.ShowTextEntryDialog("Rename Model", new []
{
("File Name", FileName)
});
var name = responses?.FirstOrDefault();
if (name == null) return;
// Rename file in OS
try
{
var newFilePath = Path.Combine(Path.GetDirectoryName(FilePath) ?? "", name);
File.Move(FilePath, newFilePath);
FilePath = newFilePath;
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
// Loads image from path
private async Task LoadPreviewImage()
{
if (PreviewImagePath == null) return;
var bytes = await File.ReadAllBytesAsync(PreviewImagePath);
await Application.Current.Dispatcher.InvokeAsync(() =>
{
var bitmap = new BitmapImage();
using var ms = new MemoryStream(bytes);
bitmap.BeginInit();
bitmap.StreamSource = ms;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
PreviewImage = bitmap;
});
}
/// <summary>
/// Indexes directory and yields all checkpoint files.
/// First we match all files with supported extensions.
@ -97,7 +156,7 @@ public partial class CheckpointFile : ObservableObject
/// - {filename}.preview.{image-extensions} (preview image)
/// - {filename}.cm-info.json (connected model info)
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly)
public static IEnumerable<CheckpointFile> FromDirectoryIndex(IDialogFactory dialogFactory, string directory, SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
// Get all files with supported extensions
var allExtensions = SupportedCheckpointExtensions
@ -110,7 +169,7 @@ public partial class CheckpointFile : ObservableObject
foreach (var file in files.Keys.Where(k => SupportedCheckpointExtensions.Contains(Path.GetExtension(k))))
{
var checkpointFile = new CheckpointFile
var checkpointFile = new CheckpointFile(dialogFactory)
{
Title = Path.GetFileNameWithoutExtension(file),
FilePath = Path.Combine(directory, file),
@ -137,6 +196,7 @@ public partial class CheckpointFile : ObservableObject
if (previewImage != null)
{
checkpointFile.PreviewImagePath = Path.Combine(directory, previewImage);
checkpointFile.LoadPreviewImage().SafeFireAndForget();
}
yield return checkpointFile;
@ -146,11 +206,11 @@ public partial class CheckpointFile : ObservableObject
/// <summary>
/// Index with progress reporting.
/// </summary>
public static IEnumerable<CheckpointFile> FromDirectoryIndex(string directory, IProgress<ProgressReport> progress,
public static IEnumerable<CheckpointFile> FromDirectoryIndex(IDialogFactory dialogFactory, string directory, IProgress<ProgressReport> progress,
SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var current = 0ul;
foreach (var checkpointFile in FromDirectoryIndex(directory, searchOption))
foreach (var checkpointFile in FromDirectoryIndex(dialogFactory, directory, searchOption))
{
current++;
progress.Report(new ProgressReport(current, "Indexing", checkpointFile.FileName));

58
StabilityMatrix/Models/CheckpointFolder.cs

@ -15,6 +15,11 @@ namespace StabilityMatrix.Models;
public partial class CheckpointFolder : ObservableObject
{
private readonly IDialogFactory dialogFactory;
private readonly ISettingsManager settingsManager;
// ReSharper disable once FieldCanBeMadeReadOnly.Local
private bool useCategoryVisibility;
/// <summary>
/// Absolute path to the folder.
/// </summary>
@ -23,7 +28,20 @@ public partial class CheckpointFolder : ObservableObject
/// <summary>
/// Custom title for UI.
/// </summary>
public string Title { get; init; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FolderType))]
[NotifyPropertyChangedFor(nameof(TitleWithFilesCount))]
private string title = string.Empty;
private SharedFolderType FolderType => Enum.TryParse(Title, out SharedFolderType type)
? type
: new SharedFolderType();
/// <summary>
/// True if the category is enabled for the manager page.
/// </summary>
[ObservableProperty]
private bool isCategoryEnabled = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDragBlurEnabled))]
@ -34,22 +52,49 @@ public partial class CheckpointFolder : ObservableObject
private bool isImportInProgress;
public bool IsDragBlurEnabled => IsCurrentDragTarget || IsImportInProgress;
public string TitleWithFilesCount => CheckpointFiles.Any() ? $"{Title} ({CheckpointFiles.Count})" : Title;
public ProgressViewModel Progress { get; } = new();
public ObservableCollection<CheckpointFile> CheckpointFiles { get; set; } = new();
public ObservableCollection<CheckpointFile> CheckpointFiles { get; init; } = new();
public RelayCommand OnPreviewDragEnterCommand => new(() => IsCurrentDragTarget = true);
public RelayCommand OnPreviewDragLeaveCommand => new(() => IsCurrentDragTarget = false);
public CheckpointFolder()
public CheckpointFolder(IDialogFactory dialogFactory, ISettingsManager settingsManager, bool useCategoryVisibility = true)
{
this.dialogFactory = dialogFactory;
this.settingsManager = settingsManager;
this.useCategoryVisibility = useCategoryVisibility;
CheckpointFiles.CollectionChanged += OnCheckpointFilesChanged;
}
/// <summary>
/// When title is set, set the category enabled state from settings.
/// </summary>
// ReSharper disable once UnusedParameterInPartialMethod
partial void OnTitleChanged(string value)
{
if (!useCategoryVisibility) return;
IsCategoryEnabled = settingsManager.IsSharedFolderCategoryVisible(FolderType);
}
/// <summary>
/// When toggling the category enabled state, save it to settings.
/// </summary>
partial void OnIsCategoryEnabledChanged(bool value)
{
if (!useCategoryVisibility) return;
if (value != settingsManager.IsSharedFolderCategoryVisible(FolderType))
{
settingsManager.SetSharedFolderCategoryVisible(FolderType, value);
}
}
// On collection changes
private void OnCheckpointFilesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
OnPropertyChanged(nameof(TitleWithFilesCount));
if (e.NewItems == null) return;
// On new added items, add event handler for deletion
foreach (CheckpointFile item in e.NewItems)
@ -74,8 +119,7 @@ public partial class CheckpointFolder : ObservableObject
IsImportInProgress = true;
IsCurrentDragTarget = false;
var files = e.Data.GetData(DataFormats.FileDrop) as string[];
if (files == null || files.Length < 1)
if (e.Data.GetData(DataFormats.FileDrop) is not string[] files || files.Length < 1)
{
IsImportInProgress = false;
return;
@ -129,8 +173,8 @@ public partial class CheckpointFolder : ObservableObject
{
var checkpointFiles = await (progress switch
{
null => Task.Run(() => CheckpointFile.FromDirectoryIndex(DirectoryPath)),
_ => Task.Run(() => CheckpointFile.FromDirectoryIndex(DirectoryPath, progress))
null => Task.Run(() => CheckpointFile.FromDirectoryIndex(dialogFactory, DirectoryPath)),
_ => Task.Run(() => CheckpointFile.FromDirectoryIndex(dialogFactory, DirectoryPath, progress))
});
CheckpointFiles.Clear();

5
StabilityMatrix/Models/Settings.cs

@ -23,6 +23,11 @@ public class Settings
public string ModelsDirectory { get; set; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StabilityMatrix", "Models");
public bool ModelBrowserNsfwEnabled { get; set; }
public SharedFolderType? SharedFolderVisibleCategories { get; set; } =
SharedFolderType.StableDiffusion |
SharedFolderType.Lora |
SharedFolderType.LyCORIS;
public InstalledPackage? GetActiveInstalledPackage()
{
return InstalledPackages.FirstOrDefault(x => x.Id == ActiveInstalledPackage);

46
StabilityMatrix/Models/SharedFolderType.cs

@ -1,30 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using System;
using System.Diagnostics.CodeAnalysis;
namespace StabilityMatrix.Models;
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "IdentifierTypo")]
[Flags]
public enum SharedFolderType
{
StableDiffusion,
Lora,
LyCORIS,
ESRGAN,
GFPGAN,
BSRGAN,
Codeformer,
Diffusers,
RealESRGAN,
SwinIR,
VAE,
ApproxVAE,
Karlo,
DeepDanbooru,
TextualInversion,
Hypernetwork,
ControlNet,
LDSR,
CLIP,
ScuNET,
GLIGEN,
StableDiffusion = 1 << 0,
Lora = 1 << 1,
LyCORIS = 1 << 2,
ESRGAN = 1 << 3,
GFPGAN = 1 << 4,
BSRGAN = 1 << 5,
Codeformer = 1 << 6,
Diffusers = 1 << 7,
RealESRGAN = 1 << 8,
SwinIR = 1 << 9,
VAE = 1 << 10,
ApproxVAE = 1 << 11,
Karlo = 1 << 12,
DeepDanbooru = 1 << 13,
TextualInversion = 1 << 14,
Hypernetwork = 1 << 15,
ControlNet = 1 << 16,
LDSR = 1 << 17,
CLIP = 1 << 18,
ScuNET = 1 << 19,
GLIGEN = 1 << 20,
}

4
StabilityMatrix/StabilityMatrix.csproj

@ -44,6 +44,10 @@
<PackageReference Include="pythonnet" Version="3.0.1"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StabilityMatrix.Tests"/>
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\Icon.ico"/>
<None Update="appsettings.json">

10
StabilityMatrix/ViewModels/CheckpointManagerViewModel.cs

@ -15,12 +15,14 @@ public partial class CheckpointManagerViewModel : ObservableObject
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
private readonly ISharedFolders sharedFolders;
private readonly ISettingsManager settingsManager;
private readonly IDialogFactory dialogFactory;
public ObservableCollection<CheckpointFolder> CheckpointFolders { get; set; } = new();
public CheckpointManagerViewModel(ISharedFolders sharedFolders, ISettingsManager settingsManager)
public CheckpointManagerViewModel(ISharedFolders sharedFolders, ISettingsManager settingsManager, IDialogFactory dialogFactory)
{
this.sharedFolders = sharedFolders;
this.settingsManager = settingsManager;
this.dialogFactory = dialogFactory;
}
public async Task OnLoaded()
@ -46,7 +48,11 @@ public partial class CheckpointManagerViewModel : ObservableObject
// Index all folders
var tasks = folders.Select(f => Task.Run(async () =>
{
var checkpointFolder = new CheckpointFolder {Title = Path.GetFileName(f), DirectoryPath = f};
var checkpointFolder = new CheckpointFolder(dialogFactory, settingsManager)
{
Title = Path.GetFileName(f),
DirectoryPath = f
};
await checkpointFolder.IndexAsync();
indexedFolders.Add(checkpointFolder);
})).ToList();

48
StabilityMatrix/ViewModels/SettingsViewModel.cs

@ -227,45 +227,35 @@ public partial class SettingsViewModel : ObservableObject
var fileHash = await FileHash.GetBlake3Async(path, progress);
var timeTakenHash = timer.Elapsed.TotalSeconds;
IsFileSearchFlyoutOpen = false;
// Search for file
timer.Restart();
var modelVersion = await liteDbContext.CivitModelVersions.Query()
.Where(mv => mv.Files!
.Select(f => f.Hashes)
.Select(hashes => hashes.BLAKE3)
.Any(hash => hash == fileHash))
.FirstOrDefaultAsync();
var model = modelVersion != null
? await liteDbContext.CivitModels.Query()
.Include(m => m.ModelVersions)
.Where(m => m.ModelVersions!
.Select(v => v.Id)
.Any(id => id == modelVersion.Id))
.FirstOrDefaultAsync() : null;
var (model, version) =
await liteDbContext.FindCivitModelFromFileHashAsync(fileHash);
timer.Stop();
var timeTakenSearch = timer.Elapsed.TotalSeconds;
var timeTakenSearch = timer.Elapsed.TotalMilliseconds;
var generalText =
$"Time taken to hash: {timeTakenHash:F2} s\n" +
$"Time taken to search: {timeTakenSearch:F1} ms\n";
// Not found
if (model == null)
{
var dialog = contentDialogService.CreateDialog();
dialog.Title = "Model file search";
dialog.Content = $"File not found in database. Hash: {fileHash}\n" +
$"Time taken to hash: {timeTakenHash:F1} s\n" +
$"Time taken to search: {timeTakenSearch:F1} s";
dialog.Title = "Model not found :(";
dialog.Content = $"File not found in database. Hash: {fileHash}\n" + generalText;
await dialog.ShowAsync();
}
else
{
// Found
var dialog = contentDialogService.CreateDialog();
dialog.Title = "Model file search";
dialog.Title = "Model found!";
dialog.Content = $"File found in database. Hash: {fileHash}\n" +
$"Model: {model.Name}\n" +
$"Version: {modelVersion.Name}\n" +
$"Time taken to hash: {timeTakenHash:F1} s\n" +
$"Time taken to search: {timeTakenSearch:F1} s";
$"Version: {version!.Name}\n" + generalText;
await dialog.ShowAsync();
}
}
@ -336,19 +326,25 @@ public partial class SettingsViewModel : ObservableObject
return;
}
var flowViewer = new FlowDocumentScrollViewer();
var flowViewer = new FlowDocumentScrollViewer
{
MaxHeight = 400,
MaxWidth = 600,
};
var markdownText = "";
foreach (var license in licenses)
{
markdownText += $"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}\n\n";
markdownText += $"## [**{license.PackageName}**]({license.PackageUrl}) by {string.Join(", ", license.Authors)}\n\n";
markdownText += $"{license.Copyright}\n\n";
markdownText += $"[{license.LicenseUrl}]({license.LicenseUrl})\n\n";
}
flowViewer.Document = TextToFlowDocumentConverter!.Convert(markdownText, typeof(FlowDocument), null, CultureInfo.CurrentCulture) as FlowDocument;
flowViewer.Document = TextToFlowDocumentConverter!.Convert(markdownText, typeof(FlowDocument), null!, CultureInfo.CurrentCulture) as FlowDocument;
var dialog = contentDialogService.CreateDialog();
dialog.Title = "License and Open Source Notices";
dialog.Content = flowViewer;
dialog.DialogMaxHeight = 1000;
dialog.DialogMaxWidth = 900;
dialog.IsPrimaryButtonEnabled = false;
await dialog.ShowAsync();
}

2
StabilityMatrix/ViewModels/TextToImageViewModel.cs

@ -103,7 +103,7 @@ public partial class TextToImageViewModel : ObservableObject
logger.LogWarning("Skipped model folder index - {SdModelsDir} does not exist", sdModelsDir);
return;
}
DiffusionCheckpointFolder = new CheckpointFolder
DiffusionCheckpointFolder = new CheckpointFolder(null!, null!) // TODO: refactor to not use view models
{
Title = Path.GetFileName(sdModelsDir),
DirectoryPath = sdModelsDir

Loading…
Cancel
Save