using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Collections.ObjectModel; using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reactive.Linq; using System.Reflection; using System.Text; using System.Text.Json; using System.Threading.Tasks; using AsyncAwaitBestPractices; using Avalonia; using Avalonia.Controls.Notifications; using Avalonia.Controls.Primitives; using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DynamicData.Binding; using ExifLibrary; using FluentAvalonia.UI.Controls; using MetadataExtractor.Formats.Exif; using NLog; using SkiaSharp; using StabilityMatrix.Avalonia.Animations; using StabilityMatrix.Avalonia.Controls; using StabilityMatrix.Avalonia.Extensions; using StabilityMatrix.Avalonia.Helpers; using StabilityMatrix.Avalonia.Languages; using StabilityMatrix.Avalonia.Models; using StabilityMatrix.Avalonia.Models.Inference; using StabilityMatrix.Avalonia.Models.TagCompletion; using StabilityMatrix.Avalonia.Services; using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Avalonia.ViewModels.Dialogs; using StabilityMatrix.Avalonia.ViewModels.Inference; using StabilityMatrix.Avalonia.Views.Dialogs; using StabilityMatrix.Avalonia.Views.Settings; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Helper; using StabilityMatrix.Core.Helper.HardwareInfo; using StabilityMatrix.Core.Models; using StabilityMatrix.Core.Models.Database; using StabilityMatrix.Core.Models.FileInterfaces; using StabilityMatrix.Core.Models.Settings; using StabilityMatrix.Core.Python; using StabilityMatrix.Core.Services; using Symbol = FluentIcons.Common.Symbol; using SymbolIconSource = FluentIcons.FluentAvalonia.SymbolIconSource; namespace StabilityMatrix.Avalonia.ViewModels.Settings; [View(typeof(MainSettingsPage))] [Singleton, ManagedService] public partial class MainSettingsViewModel : PageViewModelBase { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private readonly INotificationService notificationService; private readonly ISettingsManager settingsManager; private readonly IPrerequisiteHelper prerequisiteHelper; private readonly IPyRunner pyRunner; private readonly ServiceManager dialogFactory; private readonly ICompletionProvider completionProvider; private readonly ITrackedDownloadService trackedDownloadService; private readonly IModelIndexService modelIndexService; private readonly INavigationService settingsNavigationService; private readonly IAccountsService accountsService; public SharedState SharedState { get; } public override string Title => "Settings"; public override IconSource IconSource => new SymbolIconSource { Symbol = Symbol.Settings, IsFilled = true }; // ReSharper disable once MemberCanBeMadeStatic.Global public string AppVersion => $"Version {Compat.AppVersion.ToDisplayString()}" + (Program.IsDebugBuild ? " (Debug)" : ""); // Theme section [ObservableProperty] private string? selectedTheme; public IReadOnlyList AvailableThemes { get; } = new[] { "Light", "Dark", "System", }; [ObservableProperty] private CultureInfo selectedLanguage; // ReSharper disable once MemberCanBeMadeStatic.Global public IReadOnlyList AvailableLanguages => Cultures.SupportedCultures; public IReadOnlyList AnimationScaleOptions { get; } = new[] { 0f, 0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, }; public IReadOnlyList HolidayModes { get; } = Enum.GetValues().ToList(); [ObservableProperty] private float selectedAnimationScale; // Shared folder options [ObservableProperty] private bool removeSymlinksOnShutdown; // Integrations section [ObservableProperty] private bool isDiscordRichPresenceEnabled; // Debug section [ObservableProperty] private string? debugPaths; [ObservableProperty] private string? debugCompatInfo; [ObservableProperty] private string? debugGpuInfo; [ObservableProperty] private HolidayMode holidayModeSetting; #region System Info private static Lazy> GpuInfosLazy { get; } = new(() => HardwareHelper.IterGpuInfo().ToImmutableArray()); public static IReadOnlyList GpuInfos => GpuInfosLazy.Value; [ObservableProperty] private MemoryInfo memoryInfo; private readonly DispatcherTimer hardwareInfoUpdateTimer = new() { Interval = TimeSpan.FromSeconds(2.627) }; public Task CpuInfoAsync => HardwareHelper.GetCpuInfoAsync(); #endregion // Info section private const int VersionTapCountThreshold = 7; [ObservableProperty, NotifyPropertyChangedFor(nameof(VersionFlyoutText))] private int versionTapCount; [ObservableProperty] private bool isVersionTapTeachingTipOpen; public string VersionFlyoutText => $"You are {VersionTapCountThreshold - VersionTapCount} clicks away from enabling Debug options."; public string DataDirectory => settingsManager.IsLibraryDirSet ? settingsManager.LibraryDir : "Not set"; public MainSettingsViewModel( INotificationService notificationService, ISettingsManager settingsManager, IPrerequisiteHelper prerequisiteHelper, IPyRunner pyRunner, ServiceManager dialogFactory, ITrackedDownloadService trackedDownloadService, SharedState sharedState, ICompletionProvider completionProvider, IModelIndexService modelIndexService, INavigationService settingsNavigationService, IAccountsService accountsService ) { this.notificationService = notificationService; this.settingsManager = settingsManager; this.prerequisiteHelper = prerequisiteHelper; this.pyRunner = pyRunner; this.dialogFactory = dialogFactory; this.trackedDownloadService = trackedDownloadService; this.completionProvider = completionProvider; this.modelIndexService = modelIndexService; this.settingsNavigationService = settingsNavigationService; this.accountsService = accountsService; SharedState = sharedState; if (Program.Args.DebugMode) { SharedState.IsDebugMode = true; } SelectedTheme = settingsManager.Settings.Theme ?? AvailableThemes[1]; SelectedLanguage = Cultures.GetSupportedCultureOrDefault(settingsManager.Settings.Language); RemoveSymlinksOnShutdown = settingsManager.Settings.RemoveFolderLinksOnShutdown; SelectedAnimationScale = settingsManager.Settings.AnimationScale; HolidayModeSetting = settingsManager.Settings.HolidayModeSetting; settingsManager.RelayPropertyFor(this, vm => vm.SelectedTheme, settings => settings.Theme); settingsManager.RelayPropertyFor( this, vm => vm.IsDiscordRichPresenceEnabled, settings => settings.IsDiscordRichPresenceEnabled, true ); settingsManager.RelayPropertyFor( this, vm => vm.SelectedAnimationScale, settings => settings.AnimationScale ); settingsManager.RelayPropertyFor( this, vm => vm.HolidayModeSetting, settings => settings.HolidayModeSetting ); DebugThrowAsyncExceptionCommand.WithNotificationErrorHandler(notificationService, LogLevel.Warn); hardwareInfoUpdateTimer.Tick += OnHardwareInfoUpdateTimerTick; } /// public override void OnLoaded() { base.OnLoaded(); hardwareInfoUpdateTimer.Start(); } /// public override void OnUnloaded() { base.OnUnloaded(); hardwareInfoUpdateTimer.Stop(); } /// public override async Task OnLoadedAsync() { await base.OnLoadedAsync(); await notificationService.TryAsync(completionProvider.Setup()); // Start accounts update accountsService.RefreshAsync().SafeFireAndForget(); } private void OnHardwareInfoUpdateTimerTick(object? sender, EventArgs e) { if (HardwareHelper.IsMemoryInfoAvailable && HardwareHelper.TryGetMemoryInfo(out var newMemoryInfo)) { MemoryInfo = newMemoryInfo; } // Stop timer if live memory info is not available if (!HardwareHelper.IsLiveMemoryUsageInfoAvailable) { (sender as DispatcherTimer)?.Stop(); } } partial void OnSelectedThemeChanged(string? value) { // In case design / tests if (Application.Current is null) return; // Change theme Application.Current.RequestedThemeVariant = value switch { "Dark" => ThemeVariant.Dark, "Light" => ThemeVariant.Light, _ => ThemeVariant.Default }; } partial void OnSelectedLanguageChanged(CultureInfo? oldValue, CultureInfo newValue) { if (oldValue is null || newValue.Name == Cultures.Current?.Name) return; // Set locale if (AvailableLanguages.Contains(newValue)) { Logger.Info("Changing language from {Old} to {New}", oldValue, newValue); Cultures.TrySetSupportedCulture(newValue); settingsManager.Transaction(s => s.Language = newValue.Name); var dialog = new BetterContentDialog { Title = Resources.Label_RelaunchRequired, Content = Resources.Text_RelaunchRequiredToApplyLanguage, DefaultButton = ContentDialogButton.Primary, PrimaryButtonText = Resources.Action_Relaunch, CloseButtonText = Resources.Action_RelaunchLater }; Dispatcher.UIThread.InvokeAsync(async () => { if (await dialog.ShowAsync() == ContentDialogResult.Primary) { Process.Start(Compat.AppCurrentPath); App.Shutdown(); } }); } else { Logger.Info("Requested invalid language change from {Old} to {New}", oldValue, newValue); } } partial void OnRemoveSymlinksOnShutdownChanged(bool value) { settingsManager.Transaction(s => s.RemoveFolderLinksOnShutdown = value); } public async Task ResetCheckpointCache() { settingsManager.Transaction(s => s.InstalledModelHashes = new HashSet()); await Task.Run(() => settingsManager.IndexCheckpoints()); notificationService.Show( "Checkpoint cache reset", "The checkpoint cache has been reset.", NotificationType.Success ); } [RelayCommand] private void NavigateToSubPage(Type viewModelType) { Dispatcher.UIThread.Post( () => settingsNavigationService.NavigateTo( viewModelType, BetterSlideNavigationTransition.PageSlideFromRight ), DispatcherPriority.Send ); } #region Package Environment [RelayCommand] private async Task OpenEnvVarsDialog() { var viewModel = dialogFactory.Get(); // Load current settings var current = settingsManager.Settings.EnvironmentVariables ?? new Dictionary(); viewModel.EnvVars = new ObservableCollection( current.Select(kvp => new EnvVarKeyPair(kvp.Key, kvp.Value)) ); var dialog = new BetterContentDialog { Content = new EnvVarsDialog { DataContext = viewModel }, PrimaryButtonText = Resources.Action_Save, IsPrimaryButtonEnabled = true, CloseButtonText = Resources.Action_Cancel, }; if (await dialog.ShowAsync() == ContentDialogResult.Primary) { // Save settings var newEnvVars = viewModel .EnvVars.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) .GroupBy(kvp => kvp.Key, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.Ordinal); settingsManager.Transaction(s => s.EnvironmentVariables = newEnvVars); } } [RelayCommand] private async Task CheckPythonVersion() { var isInstalled = prerequisiteHelper.IsPythonInstalled; Logger.Debug($"Check python installed: {isInstalled}"); // Ensure python installed if (!prerequisiteHelper.IsPythonInstalled) { // Need 7z as well for site packages repack Logger.Debug("Python not installed, unpacking resources..."); await prerequisiteHelper.UnpackResourcesIfNecessary(); Logger.Debug("Unpacked resources, installing python..."); await prerequisiteHelper.InstallPythonIfNecessary(); } // Get python version await pyRunner.Initialize(); var result = await pyRunner.GetVersionInfo(); // Show dialog box var dialog = new ContentDialog { Title = Resources.Label_PythonVersionInfo, Content = result, PrimaryButtonText = Resources.Action_OK, IsPrimaryButtonEnabled = true }; await dialog.ShowAsync(); } #endregion #region System /// /// Adds Stability Matrix to Start Menu for the current user. /// [RelayCommand] private async Task AddToStartMenu() { if (!Compat.IsWindows) { notificationService.Show("Not supported", "This feature is only supported on Windows."); return; } await using var _ = new MinimumDelay(200, 300); var shortcutDir = new DirectoryPath( Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), "Programs" ); var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); var appPath = Compat.AppCurrentPath; var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); await Assets.AppIcon.ExtractTo(iconPath); WindowsShortcuts.CreateShortcut(shortcutLink, appPath, iconPath, "Stability Matrix"); notificationService.Show( "Added to Start Menu", "Stability Matrix has been added to the Start Menu.", NotificationType.Success ); } /// /// Add Stability Matrix to Start Menu for all users. /// Requires Admin elevation. /// [RelayCommand] private async Task AddToGlobalStartMenu() { if (!Compat.IsWindows) { notificationService.Show("Not supported", "This feature is only supported on Windows."); return; } // Confirmation dialog var dialog = new BetterContentDialog { Title = "This will create a shortcut for Stability Matrix in the Start Menu for all users", Content = "You will be prompted for administrator privileges. Continue?", PrimaryButtonText = Resources.Action_Yes, CloseButtonText = Resources.Action_Cancel, DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return; } await using var _ = new MinimumDelay(200, 300); var shortcutDir = new DirectoryPath( Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu), "Programs" ); var shortcutLink = shortcutDir.JoinFile("Stability Matrix.lnk"); var appPath = Compat.AppCurrentPath; var iconPath = shortcutDir.JoinFile("Stability Matrix.ico"); // We can't directly write to the targets, so extract to temporary directory first using var tempDir = new TempDirectoryPath(); await Assets.AppIcon.ExtractTo(tempDir.JoinFile("Stability Matrix.ico")); WindowsShortcuts.CreateShortcut( tempDir.JoinFile("Stability Matrix.lnk"), appPath, iconPath, "Stability Matrix" ); // Move to target try { var moveLinkResult = await WindowsElevated.MoveFiles( (tempDir.JoinFile("Stability Matrix.lnk"), shortcutLink), (tempDir.JoinFile("Stability Matrix.ico"), iconPath) ); if (moveLinkResult != 0) { notificationService.ShowPersistent( "Failed to create shortcut", $"Could not copy shortcut", NotificationType.Error ); } } catch (Win32Exception e) { // We'll get this exception if user cancels UAC Logger.Warn(e, "Could not create shortcut"); notificationService.Show("Could not create shortcut", "", NotificationType.Warning); return; } notificationService.Show( "Added to Start Menu", "Stability Matrix has been added to the Start Menu for all users.", NotificationType.Success ); } public async Task PickNewDataDirectory() { var viewModel = dialogFactory.Get(); var dialog = new BetterContentDialog { IsPrimaryButtonEnabled = false, IsSecondaryButtonEnabled = false, IsFooterVisible = false, Content = new SelectDataDirectoryDialog { DataContext = viewModel } }; var result = await dialog.ShowAsync(); if (result == ContentDialogResult.Primary) { // 1. For portable mode, call settings.SetPortableMode() if (viewModel.IsPortableMode) { settingsManager.SetPortableMode(); } // 2. For custom path, call settings.SetLibraryPath(path) else { settingsManager.SetLibraryPath(viewModel.DataDirectory); } // Restart var restartDialog = new BetterContentDialog { Title = "Restart required", Content = "Stability Matrix must be restarted for the changes to take effect.", PrimaryButtonText = Resources.Action_Restart, DefaultButton = ContentDialogButton.Primary, IsSecondaryButtonEnabled = false, }; await restartDialog.ShowAsync(); Process.Start(Compat.AppCurrentPath); App.Shutdown(); } } #endregion #region Debug Section public void LoadDebugInfo() { var assembly = Assembly.GetExecutingAssembly(); var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); DebugPaths = $""" Current Working Directory [Environment.CurrentDirectory] "{Environment.CurrentDirectory}" App Directory [Assembly.GetExecutingAssembly().Location] "{assembly.Location}" App Directory [AppContext.BaseDirectory] "{AppContext.BaseDirectory}" AppData Directory [SpecialFolder.ApplicationData] "{appData}" """; // 1. Check portable mode var appDir = Compat.AppCurrentDir; var expectedPortableFile = Path.Combine(appDir, "Data", ".sm-portable"); var isPortableMode = File.Exists(expectedPortableFile); DebugCompatInfo = $""" Platform: {Compat.Platform} AppData: {Compat.AppData} AppDataHome: {Compat.AppDataHome} AppCurrentDir: {Compat.AppCurrentDir} ExecutableName: {Compat.GetExecutableName()} -- Settings -- Expected Portable Marker file: {expectedPortableFile} Portable Marker file exists: {isPortableMode} IsLibraryDirSet = {settingsManager.IsLibraryDirSet} IsPortableMode = {settingsManager.IsPortableMode} """; // Get Gpu info var gpuInfo = ""; foreach (var (i, gpu) in HardwareHelper.IterGpuInfo().Enumerate()) { gpuInfo += $"[{i + 1}] {gpu}\n"; } DebugGpuInfo = gpuInfo; } // Debug buttons [RelayCommand] private void DebugNotification() { notificationService.Show( new Notification( title: "Test Notification", message: "Here is some message", type: NotificationType.Information ) ); } [RelayCommand] private async Task DebugContentDialog() { var dialog = new ContentDialog { DefaultButton = ContentDialogButton.Primary, Title = "Test title", PrimaryButtonText = Resources.Action_OK, CloseButtonText = Resources.Action_Close }; var result = await dialog.ShowAsync(); notificationService.Show(new Notification("Content dialog closed", $"Result: {result}")); } [RelayCommand] private void DebugThrowException() { throw new OperationCanceledException("Example Message"); } [RelayCommand(FlowExceptionsToTaskScheduler = true)] private async Task DebugThrowAsyncException() { await Task.Yield(); throw new ApplicationException("Example Message"); } [RelayCommand] private async Task DebugMakeImageGrid() { var provider = App.StorageProvider; var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions() { AllowMultiple = true }); if (files.Count == 0) return; var images = await files.SelectAsync(async f => SKImage.FromEncodedData(await f.OpenReadAsync())); var grid = ImageProcessor.CreateImageGrid(images.ToImmutableArray()); // Show preview using var peekPixels = grid.PeekPixels(); using var data = peekPixels.Encode(SKEncodedImageFormat.Jpeg, 100); await using var stream = data.AsStream(); var bitmap = WriteableBitmap.Decode(stream); var galleryImages = new List { new(bitmap), }; galleryImages.AddRange(files.Select(f => new ImageSource(f.Path.ToString()))); var imageBox = new ImageGalleryCard { Width = 1000, Height = 900, DataContext = dialogFactory.Get(vm => { vm.ImageSources.AddRange(galleryImages); }) }; var dialog = new BetterContentDialog { MaxDialogWidth = 1000, MaxDialogHeight = 1000, FullSizeDesired = true, Content = imageBox, CloseButtonText = "Close", ContentVerticalScrollBarVisibility = ScrollBarVisibility.Disabled, }; await dialog.ShowAsync(); } [RelayCommand] private async Task DebugLoadCompletionCsv() { var provider = App.StorageProvider; var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); if (files.Count == 0) return; await completionProvider.LoadFromFile(files[0].TryGetLocalPath()!, true); notificationService.Show("Loaded completion file", ""); } [RelayCommand] private async Task DebugImageMetadata() { var provider = App.StorageProvider; var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions()); if (files.Count == 0) return; var metadata = ImageMetadata.ParseFile(files[0].TryGetLocalPath()!); var textualTags = metadata.GetTextualData()?.ToArray(); if (textualTags is null) { notificationService.Show("No textual data found", ""); return; } if (metadata.GetGenerationParameters() is { } parameters) { var parametersJson = JsonSerializer.Serialize(parameters); var dialog = DialogHelper.CreateJsonDialog(parametersJson, "Generation Parameters"); await dialog.ShowAsync(); } } [RelayCommand] private async Task DebugRefreshModelsIndex() { await modelIndexService.RefreshIndex(); } [RelayCommand] private async Task DebugTrackedDownload() { var textFields = new TextBoxField[] { new() { Label = "Url", }, new() { Label = "File path" } }; var dialog = DialogHelper.CreateTextEntryDialog("Add download", "", textFields); if (await dialog.ShowAsync() == ContentDialogResult.Primary) { var url = textFields[0].Text; var filePath = textFields[1].Text; var download = trackedDownloadService.NewDownload(new Uri(url), new FilePath(filePath)); download.Start(); } } #endregion #region Debug Commands public CommandItem[] DebugCommands => [new CommandItem(DebugFindLocalModelFromIndexCommand)]; [RelayCommand] private async Task DebugFindLocalModelFromIndex() { var textFields = new TextBoxField[] { new() { Label = "Blake3 Hash" }, new() { Label = "SharedFolderType" } }; var dialog = DialogHelper.CreateTextEntryDialog("Find Local Model", "", textFields); if (await dialog.ShowAsync() == ContentDialogResult.Primary) { Func>> modelGetter; if (textFields.ElementAtOrDefault(0)?.Text is { } hash && !string.IsNullOrWhiteSpace(hash)) { modelGetter = () => modelIndexService.FindByHashAsync(hash); } else if (textFields.ElementAtOrDefault(1)?.Text is { } type && !string.IsNullOrWhiteSpace(type)) { modelGetter = () => modelIndexService.FindAsync(Enum.Parse(type)); } else { return; } var timer = Stopwatch.StartNew(); var result = (await modelGetter()).ToImmutableArray(); timer.Stop(); if (result.Length != 0) { await DialogHelper .CreateMarkdownDialog( string.Join( "\n\n", result.Select( (model, i) => $"[{i + 1}] {model.RelativePath.ToRepr()} " + $"({model.DisplayModelName}, {model.DisplayModelVersion})" ) ), $"Found Models ({CodeTimer.FormatTime(timer.Elapsed)})" ) .ShowAsync(); } else { await DialogHelper .CreateMarkdownDialog(":(", $"No models found ({CodeTimer.FormatTime(timer.Elapsed)})") .ShowAsync(); } } } #endregion #region Info Section public void OnVersionClick() { // Ignore if already enabled if (SharedState.IsDebugMode) return; VersionTapCount++; switch (VersionTapCount) { // Reached required threshold case >= VersionTapCountThreshold: { IsVersionTapTeachingTipOpen = false; // Enable debug options SharedState.IsDebugMode = true; notificationService.Show( "Debug options enabled", "Warning: Improper use may corrupt application state or cause loss of data." ); VersionTapCount = 0; break; } // Open teaching tip above 3rd click case >= 3: IsVersionTapTeachingTipOpen = true; break; } } [RelayCommand] private async Task ShowLicensesDialog() { try { var markdown = GetLicensesMarkdown(); var dialog = DialogHelper.CreateMarkdownDialog(markdown, "Licenses"); dialog.MaxDialogHeight = 600; await dialog.ShowAsync(); } catch (Exception e) { notificationService.Show("Failed to read licenses information", $"{e}", NotificationType.Error); } } private static string GetLicensesMarkdown() { // Read licenses.json using var reader = new StreamReader(Assets.LicensesJson.Open()); var licenses = JsonSerializer.Deserialize>(reader.ReadToEnd()) ?? throw new InvalidOperationException("Failed to read licenses.json"); // Generate markdown var builder = new StringBuilder(); foreach (var license in licenses) { builder.AppendLine( $"## [{license.PackageName}]({license.PackageUrl}) by {string.Join(", ", license.Authors)}" ); builder.AppendLine(); builder.AppendLine(license.Description); builder.AppendLine(); builder.AppendLine($"[{license.LicenseUrl}]({license.LicenseUrl})"); builder.AppendLine(); } return builder.ToString(); } #endregion }