JT
1 year ago
18 changed files with 1026 additions and 33 deletions
After Width: | Height: | Size: 15 KiB |
@ -1,23 +1,14 @@ |
|||||||
using Avalonia.Controls; |
using System; |
||||||
using Avalonia.Controls.Primitives; |
using Avalonia.Controls; |
||||||
using Avalonia.Styling; |
|
||||||
|
|
||||||
namespace StabilityMatrix.Avalonia.Controls; |
namespace StabilityMatrix.Avalonia.Controls; |
||||||
|
|
||||||
public class Card : Expander |
public class Card : Expander |
||||||
{ |
{ |
||||||
|
protected override Type StyleKeyOverride => typeof(Expander); |
||||||
|
|
||||||
public Card() |
public Card() |
||||||
{ |
{ |
||||||
// Expander /template/ ToggleButton#PART_toggle |
IsExpanded = true; |
||||||
var customStyle = new Style(x => |
|
||||||
x.OfType<Expander>().Template().OfType<ToggleButton>().Name("PART_toggle")); |
|
||||||
|
|
||||||
customStyle.Setters.Add(new Setter |
|
||||||
{ |
|
||||||
Property = IsVisibleProperty, |
|
||||||
Value = false |
|
||||||
}); |
|
||||||
|
|
||||||
Styles.Add(customStyle); |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
@ -0,0 +1,22 @@ |
|||||||
|
<Styles xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:controls="clr-namespace:StabilityMatrix.Avalonia.Controls"> |
||||||
|
<Design.PreviewWith> |
||||||
|
<Border Padding="20"> |
||||||
|
<controls:Card |
||||||
|
Margin="8" |
||||||
|
MaxHeight="450" |
||||||
|
IsExpanded="True" |
||||||
|
Header="Header Text" |
||||||
|
Name="ModelCard" |
||||||
|
Width="330"> |
||||||
|
<TextBlock Text="Hello World"/> |
||||||
|
</controls:Card> |
||||||
|
</Border> |
||||||
|
</Design.PreviewWith> |
||||||
|
|
||||||
|
<Style Selector="Expander /template/ ToggleButton#PART_toggle"> |
||||||
|
<Setter Property="IsVisible" Value="False"></Setter> |
||||||
|
</Style> |
||||||
|
|
||||||
|
</Styles> |
@ -0,0 +1,269 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.IO; |
||||||
|
using System.Linq; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Media.Imaging; |
||||||
|
using Avalonia.Threading; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.FileInterfaces; |
||||||
|
using StabilityMatrix.Core.Models.Progress; |
||||||
|
using StabilityMatrix.Core.Processes; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
public partial class CheckpointBrowserCardViewModel : ProgressViewModel |
||||||
|
|
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
public CivitModel CivitModel { get; init; } |
||||||
|
public Bitmap? CardImage { get; set; } |
||||||
|
public override bool IsTextVisible => Value > 0; |
||||||
|
|
||||||
|
[ObservableProperty] private bool isImporting; |
||||||
|
|
||||||
|
public CheckpointBrowserCardViewModel( |
||||||
|
CivitModel civitModel, |
||||||
|
IDownloadService downloadService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
Bitmap? fixedImage = null) |
||||||
|
{ |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
CivitModel = civitModel; |
||||||
|
|
||||||
|
if (fixedImage != null) |
||||||
|
{ |
||||||
|
CardImage = fixedImage; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (Design.IsDesignMode) return; |
||||||
|
|
||||||
|
UpdateImage().SafeFireAndForget(); |
||||||
|
|
||||||
|
// Update image when nsfw setting changes |
||||||
|
settingsManager.RegisterPropertyChangedHandler( |
||||||
|
s => s.ModelBrowserNsfwEnabled, |
||||||
|
_ => UpdateImage().SafeFireAndForget()); |
||||||
|
} |
||||||
|
|
||||||
|
// Choose and load image based on nsfw setting |
||||||
|
private async Task UpdateImage() |
||||||
|
{ |
||||||
|
var nsfwEnabled = settingsManager.Settings.ModelBrowserNsfwEnabled; |
||||||
|
var version = CivitModel.ModelVersions?.FirstOrDefault(); |
||||||
|
var images = version?.Images; |
||||||
|
|
||||||
|
var image = images?.FirstOrDefault(image => nsfwEnabled || image.Nsfw == "None"); |
||||||
|
if (image != null) |
||||||
|
{ |
||||||
|
var imageStream = await downloadService.GetImageStreamFromUrl(image.Url); |
||||||
|
Dispatcher.UIThread.Invoke(() => |
||||||
|
{ |
||||||
|
CardImage = new Bitmap(imageStream); |
||||||
|
}); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise Default image |
||||||
|
Dispatcher.UIThread.Invoke(() => |
||||||
|
{ |
||||||
|
CardImage = new Bitmap("Assets/noimage.png"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// On any mode changes, update the image |
||||||
|
private void OnNsfwModeChanged(object? sender, bool value) |
||||||
|
{ |
||||||
|
UpdateImage().SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private void OpenModel() |
||||||
|
{ |
||||||
|
ProcessRunner.OpenUrl($"https://civitai.com/models/{CivitModel.Id}"); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task Import(CivitModel model) |
||||||
|
{ |
||||||
|
await DoImport(model); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task ShowVersionDialog(CivitModel model) |
||||||
|
{ |
||||||
|
// var dialog = dialogFactory.CreateSelectModelVersionDialog(model); |
||||||
|
// var result = await dialog.ShowAsync(); |
||||||
|
// |
||||||
|
// if (result != ContentDialogResult.Primary) |
||||||
|
// { |
||||||
|
// return; |
||||||
|
// } |
||||||
|
// |
||||||
|
// var viewModel = dialog.DataContext as SelectModelVersionDialogViewModel; |
||||||
|
// var selectedVersion = viewModel?.SelectedVersion; |
||||||
|
// var selectedFile = viewModel?.SelectedFile; |
||||||
|
// |
||||||
|
// await Task.Delay(100); |
||||||
|
// await DoImport(model, selectedVersion, selectedFile); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task DoImport(CivitModel model, CivitModelVersion? selectedVersion = null, CivitFile? selectedFile = null) |
||||||
|
{ |
||||||
|
IsImporting = true; |
||||||
|
Text = "Downloading..."; |
||||||
|
|
||||||
|
// Holds files to be deleted on errors |
||||||
|
var filesForCleanup = new HashSet<FilePath>(); |
||||||
|
|
||||||
|
// Set Text when exiting, finally block will set 100 and delay clear progress |
||||||
|
try |
||||||
|
{ |
||||||
|
// Get latest version |
||||||
|
var modelVersion = selectedVersion ?? model.ModelVersions?.FirstOrDefault(); |
||||||
|
if (modelVersion is null) |
||||||
|
{ |
||||||
|
// snackbarService.ShowSnackbarAsync( |
||||||
|
// "This model has no versions available for download", |
||||||
|
// "Model has no versions available", ControlAppearance.Caution).SafeFireAndForget(); |
||||||
|
Text = "Unable to Download"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Get latest version file |
||||||
|
var modelFile = selectedFile ?? modelVersion.Files?.FirstOrDefault(); |
||||||
|
if (modelFile is null) |
||||||
|
{ |
||||||
|
// snackbarService.ShowSnackbarAsync( |
||||||
|
// "This model has no files available for download", |
||||||
|
// "Model has no files available", ControlAppearance.Caution).SafeFireAndForget(); |
||||||
|
Text = "Unable to Download"; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
var downloadFolder = Path.Combine(settingsManager.ModelsDirectory, |
||||||
|
model.Type.ConvertTo<SharedFolderType>().GetStringValue()); |
||||||
|
// Folders might be missing if user didn't install any packages yet |
||||||
|
Directory.CreateDirectory(downloadFolder); |
||||||
|
var downloadPath = Path.GetFullPath(Path.Combine(downloadFolder, modelFile.Name)); |
||||||
|
filesForCleanup.Add(downloadPath); |
||||||
|
|
||||||
|
// Do the download |
||||||
|
var downloadTask = downloadService.DownloadToFileAsync(modelFile.DownloadUrl, downloadPath, |
||||||
|
new Progress<ProgressReport>(report => |
||||||
|
{ |
||||||
|
Dispatcher.UIThread.Invoke(() => |
||||||
|
{ |
||||||
|
Value = report.Percentage; |
||||||
|
Text = $"Downloading... {report.Percentage}%"; |
||||||
|
}); |
||||||
|
})); |
||||||
|
|
||||||
|
// var downloadResult = await snackbarService.TryAsync(downloadTask, "Could not download file"); |
||||||
|
|
||||||
|
// Failed download handling |
||||||
|
// if (downloadResult.Exception is not null) |
||||||
|
// { |
||||||
|
// // For exceptions other than ApiException or TaskCanceledException, log error |
||||||
|
// var logLevel = downloadResult.Exception switch |
||||||
|
// { |
||||||
|
// HttpRequestException or ApiException or TaskCanceledException => LogLevel.Warn, |
||||||
|
// _ => LogLevel.Error |
||||||
|
// }; |
||||||
|
// Logger.Log(logLevel, downloadResult.Exception, "Error during model download"); |
||||||
|
// |
||||||
|
// Text = "Download Failed"; |
||||||
|
// return; |
||||||
|
//} |
||||||
|
|
||||||
|
// When sha256 is available, validate the downloaded file |
||||||
|
var fileExpectedSha256 = modelFile.Hashes.SHA256; |
||||||
|
if (!string.IsNullOrEmpty(fileExpectedSha256)) |
||||||
|
{ |
||||||
|
var hashProgress = new Progress<ProgressReport>(progress => |
||||||
|
{ |
||||||
|
Value = progress.Percentage; |
||||||
|
Text = $"Validating... {progress.Percentage}%"; |
||||||
|
}); |
||||||
|
var sha256 = await FileHash.GetSha256Async(downloadPath, hashProgress); |
||||||
|
if (sha256 != fileExpectedSha256.ToLowerInvariant()) |
||||||
|
{ |
||||||
|
Text = "Import Failed!"; |
||||||
|
DelayedClearProgress(TimeSpan.FromMilliseconds(800)); |
||||||
|
// snackbarService.ShowSnackbarAsync( |
||||||
|
// "This may be caused by network or server issues from CivitAI, please try again in a few minutes.", |
||||||
|
// "Download failed hash validation").SafeFireAndForget(); |
||||||
|
Text = "Download Failed"; |
||||||
|
return; |
||||||
|
} |
||||||
|
// snackbarService.ShowSnackbarAsync($"{model.Type} {model.Name} imported successfully!", |
||||||
|
// "Import complete", ControlAppearance.Info).SafeFireAndForget(); |
||||||
|
} |
||||||
|
|
||||||
|
IsIndeterminate = true; |
||||||
|
|
||||||
|
// Save connected model info |
||||||
|
var modelFileName = Path.GetFileNameWithoutExtension(modelFile.Name); |
||||||
|
var modelInfo = new ConnectedModelInfo(CivitModel, modelVersion, modelFile, DateTime.UtcNow); |
||||||
|
var modelInfoPath = Path.GetFullPath(Path.Combine( |
||||||
|
downloadFolder, modelFileName + ConnectedModelInfo.FileExtension)); |
||||||
|
filesForCleanup.Add(modelInfoPath); |
||||||
|
await modelInfo.SaveJsonToDirectory(downloadFolder, modelFileName); |
||||||
|
|
||||||
|
// If available, save a model image |
||||||
|
if (modelVersion.Images != null && modelVersion.Images.Any()) |
||||||
|
{ |
||||||
|
var image = modelVersion.Images[0]; |
||||||
|
var imageExtension = Path.GetExtension(image.Url).TrimStart('.'); |
||||||
|
if (imageExtension is "jpg" or "jpeg" or "png") |
||||||
|
{ |
||||||
|
var imageDownloadPath = Path.GetFullPath(Path.Combine(downloadFolder, $"{modelFileName}.preview.{imageExtension}")); |
||||||
|
filesForCleanup.Add(imageDownloadPath); |
||||||
|
var imageTask = downloadService.DownloadToFileAsync(image.Url, imageDownloadPath); |
||||||
|
// await snackbarService.TryAsync(imageTask, "Could not download preview image"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Successful - clear cleanup list |
||||||
|
filesForCleanup.Clear(); |
||||||
|
|
||||||
|
Text = "Import complete!"; |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
foreach (var file in filesForCleanup.Where(file => file.Exists)) |
||||||
|
{ |
||||||
|
file.Delete(); |
||||||
|
Logger.Info($"Download cleanup: Deleted file {file}"); |
||||||
|
} |
||||||
|
IsIndeterminate = false; |
||||||
|
Value = 100; |
||||||
|
DelayedClearProgress(TimeSpan.FromMilliseconds(800)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void DelayedClearProgress(TimeSpan delay) |
||||||
|
{ |
||||||
|
Task.Delay(delay).ContinueWith(_ => |
||||||
|
{ |
||||||
|
Text = string.Empty; |
||||||
|
Value = 0; |
||||||
|
IsImporting = false; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,381 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Collections.ObjectModel; |
||||||
|
using System.Diagnostics; |
||||||
|
using System.Linq; |
||||||
|
using System.Net.Http; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using AsyncAwaitBestPractices; |
||||||
|
using Avalonia.Collections; |
||||||
|
using Avalonia.Controls; |
||||||
|
using CommunityToolkit.Mvvm.ComponentModel; |
||||||
|
using CommunityToolkit.Mvvm.Input; |
||||||
|
using FluentAvalonia.UI.Controls; |
||||||
|
using FluentAvalonia.UI.Data; |
||||||
|
using NLog; |
||||||
|
using StabilityMatrix.Avalonia.Views; |
||||||
|
using StabilityMatrix.Core.Api; |
||||||
|
using StabilityMatrix.Core.Attributes; |
||||||
|
using StabilityMatrix.Core.Database; |
||||||
|
using StabilityMatrix.Core.Extensions; |
||||||
|
using StabilityMatrix.Core.Helper; |
||||||
|
using StabilityMatrix.Core.Models; |
||||||
|
using StabilityMatrix.Core.Models.Api; |
||||||
|
using StabilityMatrix.Core.Models.Settings; |
||||||
|
using StabilityMatrix.Core.Services; |
||||||
|
using ApiException = Refit.ApiException; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.ViewModels; |
||||||
|
|
||||||
|
[View(typeof(CheckpointBrowserPage))] |
||||||
|
public partial class CheckpointBrowserViewModel : PageViewModelBase |
||||||
|
{ |
||||||
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); |
||||||
|
private readonly ICivitApi civitApi; |
||||||
|
private readonly IDownloadService downloadService; |
||||||
|
private readonly ISettingsManager settingsManager; |
||||||
|
private readonly ILiteDbContext liteDbContext; |
||||||
|
private const int MaxModelsPerPage = 14; |
||||||
|
|
||||||
|
[ObservableProperty] private ObservableCollection<CheckpointBrowserCardViewModel>? modelCards; |
||||||
|
[ObservableProperty] private DataGridCollectionView? modelCardsView; |
||||||
|
|
||||||
|
[ObservableProperty] private string searchQuery = string.Empty; |
||||||
|
[ObservableProperty] private bool showNsfw; |
||||||
|
[ObservableProperty] private bool showMainLoadingSpinner; |
||||||
|
[ObservableProperty] private CivitPeriod selectedPeriod = CivitPeriod.Month; |
||||||
|
[ObservableProperty] private CivitSortMode sortMode = CivitSortMode.HighestRated; |
||||||
|
[ObservableProperty] private CivitModelType selectedModelType = CivitModelType.Checkpoint; |
||||||
|
[ObservableProperty] private int currentPageNumber; |
||||||
|
[ObservableProperty] private int totalPages; |
||||||
|
[ObservableProperty] private bool hasSearched; |
||||||
|
[ObservableProperty] private bool canGoToNextPage; |
||||||
|
[ObservableProperty] private bool canGoToPreviousPage; |
||||||
|
[ObservableProperty] private bool isIndeterminate; |
||||||
|
[ObservableProperty] private bool noResultsFound; |
||||||
|
[ObservableProperty] private string noResultsText; |
||||||
|
|
||||||
|
public IEnumerable<CivitPeriod> AllCivitPeriods => Enum.GetValues(typeof(CivitPeriod)).Cast<CivitPeriod>(); |
||||||
|
public IEnumerable<CivitSortMode> AllSortModes => Enum.GetValues(typeof(CivitSortMode)).Cast<CivitSortMode>(); |
||||||
|
|
||||||
|
public IEnumerable<CivitModelType> AllModelTypes => Enum.GetValues(typeof(CivitModelType)) |
||||||
|
.Cast<CivitModelType>() |
||||||
|
.Where(t => t == CivitModelType.All || t.ConvertTo<SharedFolderType>() > 0) |
||||||
|
.OrderBy(t => t.ToString()); |
||||||
|
|
||||||
|
public CheckpointBrowserViewModel( |
||||||
|
ICivitApi civitApi, |
||||||
|
IDownloadService downloadService, |
||||||
|
ISettingsManager settingsManager, |
||||||
|
ILiteDbContext liteDbContext) |
||||||
|
{ |
||||||
|
this.civitApi = civitApi; |
||||||
|
this.downloadService = downloadService; |
||||||
|
this.settingsManager = settingsManager; |
||||||
|
this.liteDbContext = liteDbContext; |
||||||
|
|
||||||
|
CurrentPageNumber = 1; |
||||||
|
CanGoToNextPage = true; |
||||||
|
} |
||||||
|
|
||||||
|
public override void OnLoaded() |
||||||
|
{ |
||||||
|
if (Design.IsDesignMode) return; |
||||||
|
|
||||||
|
var searchOptions = settingsManager.Settings.ModelSearchOptions; |
||||||
|
|
||||||
|
SelectedPeriod = searchOptions?.SelectedPeriod ?? CivitPeriod.Month; |
||||||
|
SortMode = searchOptions?.SortMode ?? CivitSortMode.HighestRated; |
||||||
|
SelectedModelType = searchOptions?.SelectedModelType ?? CivitModelType.Checkpoint; |
||||||
|
|
||||||
|
ShowNsfw = settingsManager.Settings.ModelBrowserNsfwEnabled; |
||||||
|
|
||||||
|
settingsManager.RelayPropertyFor(this, model => model.ShowNsfw, |
||||||
|
settings => settings.ModelBrowserNsfwEnabled); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Filter predicate for model cards |
||||||
|
/// </summary> |
||||||
|
private bool FilterModelCardsPredicate(object? item) |
||||||
|
{ |
||||||
|
if (item is not CheckpointBrowserCardViewModel card) return false; |
||||||
|
return !card.CivitModel.Nsfw || ShowNsfw; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Background update task |
||||||
|
/// </summary> |
||||||
|
private async Task CivitModelQuery(CivitModelsRequest request) |
||||||
|
{ |
||||||
|
var timer = Stopwatch.StartNew(); |
||||||
|
var queryText = request.Query; |
||||||
|
try |
||||||
|
{ |
||||||
|
var modelsResponse = await civitApi.GetModels(request); |
||||||
|
var models = modelsResponse.Items; |
||||||
|
if (models is null) |
||||||
|
{ |
||||||
|
Logger.Debug("CivitAI Query {Text} returned no results (in {Elapsed:F1} s)", |
||||||
|
queryText, timer.Elapsed.TotalSeconds); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Logger.Debug("CivitAI Query {Text} returned {Results} results (in {Elapsed:F1} s)", |
||||||
|
queryText, models.Count, timer.Elapsed.TotalSeconds); |
||||||
|
|
||||||
|
var unknown = models.Where(m => m.Type == CivitModelType.Unknown).ToList(); |
||||||
|
if (unknown.Any()) |
||||||
|
{ |
||||||
|
var names = unknown.Select(m => m.Name).ToList(); |
||||||
|
Logger.Warn("Excluded {Unknown} unknown model types: {Models}", unknown.Count, |
||||||
|
names); |
||||||
|
} |
||||||
|
|
||||||
|
// Filter out unknown model types and archived/taken-down models |
||||||
|
models = models.Where(m => m.Type.ConvertTo<SharedFolderType>() > 0) |
||||||
|
.Where(m => m.Mode == null).ToList(); |
||||||
|
|
||||||
|
// Database update calls will invoke `OnModelsUpdated` |
||||||
|
// Add to database |
||||||
|
await liteDbContext.UpsertCivitModelAsync(models); |
||||||
|
// Add as cache entry |
||||||
|
var cacheNew = await liteDbContext.UpsertCivitModelQueryCacheEntryAsync(new() |
||||||
|
{ |
||||||
|
Id = ObjectHash.GetMd5Guid(request), |
||||||
|
InsertedAt = DateTimeOffset.UtcNow, |
||||||
|
Request = request, |
||||||
|
Items = models, |
||||||
|
Metadata = modelsResponse.Metadata |
||||||
|
}); |
||||||
|
|
||||||
|
if (cacheNew) |
||||||
|
{ |
||||||
|
Logger.Debug("New cache entry, updating model cards"); |
||||||
|
UpdateModelCards(models, modelsResponse.Metadata); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
Logger.Debug("Cache entry already exists, not updating model cards"); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (OperationCanceledException) |
||||||
|
{ |
||||||
|
// snackbarService.ShowSnackbarAsync("Request to CivitAI timed out", |
||||||
|
// "Please try again in a few minutes").SafeFireAndForget(); |
||||||
|
Logger.Warn($"CivitAI query timed out ({request})"); |
||||||
|
} |
||||||
|
catch (HttpRequestException e) |
||||||
|
{ |
||||||
|
// snackbarService.ShowSnackbarAsync("CivitAI can't be reached right now", |
||||||
|
// "Please try again in a few minutes").SafeFireAndForget(); |
||||||
|
Logger.Warn(e, $"CivitAI query HttpRequestException ({request})"); |
||||||
|
} |
||||||
|
catch (ApiException e) |
||||||
|
{ |
||||||
|
// snackbarService.ShowSnackbarAsync("CivitAI can't be reached right now", |
||||||
|
// "Please try again in a few minutes").SafeFireAndForget(); |
||||||
|
Logger.Warn(e, $"CivitAI query ApiException ({request})"); |
||||||
|
} |
||||||
|
catch (Exception e) |
||||||
|
{ |
||||||
|
// snackbarService.ShowSnackbarAsync($"Please try again in a few minutes", |
||||||
|
// $"Unknown exception during CivitAI query: {e.GetType().Name}").SafeFireAndForget(); |
||||||
|
Logger.Error(e, $"CivitAI query unknown exception ({request})"); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
ShowMainLoadingSpinner = false; |
||||||
|
UpdateResultsText(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary> |
||||||
|
/// Updates model cards using api response object. |
||||||
|
/// </summary> |
||||||
|
private void UpdateModelCards(IEnumerable<CivitModel>? models, CivitMetadata? metadata) |
||||||
|
{ |
||||||
|
if (models is null) |
||||||
|
{ |
||||||
|
ModelCards?.Clear(); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
var updateCards = models |
||||||
|
.Select(model => new CheckpointBrowserCardViewModel(model, |
||||||
|
downloadService, settingsManager)); |
||||||
|
ModelCards = new ObservableCollection<CheckpointBrowserCardViewModel>(updateCards); |
||||||
|
} |
||||||
|
TotalPages = metadata?.TotalPages ?? 1; |
||||||
|
CanGoToPreviousPage = CurrentPageNumber > 1; |
||||||
|
CanGoToNextPage = CurrentPageNumber < TotalPages; |
||||||
|
// Status update |
||||||
|
ShowMainLoadingSpinner = false; |
||||||
|
IsIndeterminate = false; |
||||||
|
HasSearched = true; |
||||||
|
} |
||||||
|
|
||||||
|
private string previousSearchQuery = string.Empty; |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task SearchModels() |
||||||
|
{ |
||||||
|
var timer = Stopwatch.StartNew(); |
||||||
|
|
||||||
|
if (SearchQuery != previousSearchQuery) |
||||||
|
{ |
||||||
|
// Reset page number |
||||||
|
CurrentPageNumber = 1; |
||||||
|
previousSearchQuery = SearchQuery; |
||||||
|
} |
||||||
|
|
||||||
|
// Build request |
||||||
|
var modelRequest = new CivitModelsRequest |
||||||
|
{ |
||||||
|
Limit = MaxModelsPerPage, |
||||||
|
Nsfw = "true", // Handled by local view filter |
||||||
|
Sort = SortMode, |
||||||
|
Period = SelectedPeriod, |
||||||
|
Page = CurrentPageNumber |
||||||
|
}; |
||||||
|
|
||||||
|
if (SearchQuery.StartsWith("#")) |
||||||
|
{ |
||||||
|
modelRequest.Tag = SearchQuery[1..]; |
||||||
|
} |
||||||
|
else if (SearchQuery.StartsWith("@")) |
||||||
|
{ |
||||||
|
modelRequest.Username = SearchQuery[1..]; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
modelRequest.Query = SearchQuery; |
||||||
|
} |
||||||
|
|
||||||
|
if (SelectedModelType != CivitModelType.All) |
||||||
|
{ |
||||||
|
modelRequest.Types = new[] {SelectedModelType}; |
||||||
|
} |
||||||
|
|
||||||
|
// See if query is cached |
||||||
|
var cachedQuery = await liteDbContext.CivitModelQueryCache |
||||||
|
.IncludeAll() |
||||||
|
.FindByIdAsync(ObjectHash.GetMd5Guid(modelRequest)); |
||||||
|
|
||||||
|
// If cached, update model cards |
||||||
|
if (cachedQuery is not null) |
||||||
|
{ |
||||||
|
var elapsed = timer.Elapsed; |
||||||
|
Logger.Debug("Using cached query for {Text} [{RequestHash}] (in {Elapsed:F1} s)", |
||||||
|
SearchQuery, modelRequest.GetHashCode(), elapsed.TotalSeconds); |
||||||
|
UpdateModelCards(cachedQuery.Items, cachedQuery.Metadata); |
||||||
|
|
||||||
|
// Start remote query (background mode) |
||||||
|
// Skip when last query was less than 2 min ago |
||||||
|
var timeSinceCache = DateTimeOffset.UtcNow - cachedQuery.InsertedAt; |
||||||
|
if (timeSinceCache?.TotalMinutes >= 2) |
||||||
|
{ |
||||||
|
CivitModelQuery(modelRequest).SafeFireAndForget(); |
||||||
|
Logger.Debug( |
||||||
|
"Cached query was more than 2 minutes ago ({Seconds:F0} s), updating cache with remote query", |
||||||
|
timeSinceCache.Value.TotalSeconds); |
||||||
|
} |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
// Not cached, wait for remote query |
||||||
|
ShowMainLoadingSpinner = true; |
||||||
|
await CivitModelQuery(modelRequest); |
||||||
|
} |
||||||
|
|
||||||
|
UpdateResultsText(); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task PreviousPage() |
||||||
|
{ |
||||||
|
if (CurrentPageNumber == 1) return; |
||||||
|
|
||||||
|
CurrentPageNumber--; |
||||||
|
await TrySearchAgain(false); |
||||||
|
} |
||||||
|
|
||||||
|
[RelayCommand] |
||||||
|
private async Task NextPage() |
||||||
|
{ |
||||||
|
CurrentPageNumber++; |
||||||
|
await TrySearchAgain(false); |
||||||
|
} |
||||||
|
|
||||||
|
// On changes to ModelCards, update the view source |
||||||
|
partial void OnModelCardsChanged(ObservableCollection<CheckpointBrowserCardViewModel>? value) |
||||||
|
{ |
||||||
|
if (value is null) |
||||||
|
{ |
||||||
|
ModelCardsView = null; |
||||||
|
} |
||||||
|
// Create new view |
||||||
|
var view = new DataGridCollectionView(value) |
||||||
|
{ |
||||||
|
Filter = FilterModelCardsPredicate, |
||||||
|
}; |
||||||
|
ModelCardsView = view; |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnShowNsfwChanged(bool value) |
||||||
|
{ |
||||||
|
settingsManager.Transaction(s => s.ModelBrowserNsfwEnabled = value); |
||||||
|
ModelCardsView?.Refresh(); |
||||||
|
|
||||||
|
if (!HasSearched) |
||||||
|
return; |
||||||
|
|
||||||
|
UpdateResultsText(); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedPeriodChanged(CivitPeriod oldValue, CivitPeriod newValue) |
||||||
|
{ |
||||||
|
TrySearchAgain().SafeFireAndForget(); |
||||||
|
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions( |
||||||
|
newValue, SortMode, SelectedModelType)); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSortModeChanged(CivitSortMode oldValue, CivitSortMode newValue) |
||||||
|
{ |
||||||
|
TrySearchAgain().SafeFireAndForget(); |
||||||
|
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions( |
||||||
|
SelectedPeriod, newValue, SelectedModelType)); |
||||||
|
} |
||||||
|
|
||||||
|
partial void OnSelectedModelTypeChanged(CivitModelType oldValue, CivitModelType newValue) |
||||||
|
{ |
||||||
|
TrySearchAgain().SafeFireAndForget(); |
||||||
|
settingsManager.Transaction(s => s.ModelSearchOptions = new ModelSearchOptions( |
||||||
|
SelectedPeriod, SortMode, newValue)); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task TrySearchAgain(bool shouldUpdatePageNumber = true) |
||||||
|
{ |
||||||
|
if (!HasSearched) return; |
||||||
|
ModelCards?.Clear(); |
||||||
|
|
||||||
|
if (shouldUpdatePageNumber) |
||||||
|
{ |
||||||
|
CurrentPageNumber = 1; |
||||||
|
} |
||||||
|
|
||||||
|
// execute command instead of calling method directly so that the IsRunning property gets updated |
||||||
|
await SearchModelsCommand.ExecuteAsync(null); |
||||||
|
} |
||||||
|
|
||||||
|
private void UpdateResultsText() |
||||||
|
{ |
||||||
|
NoResultsFound = (ModelCards?.Count ?? 0) <= 0; |
||||||
|
NoResultsText = ModelCards?.Count > 0 |
||||||
|
? $"{ModelCards.Count} results hidden by filters" |
||||||
|
: "No results found"; |
||||||
|
} |
||||||
|
|
||||||
|
public override string Title => "Model Browser"; |
||||||
|
public override Symbol Icon => Symbol.Find; |
||||||
|
} |
@ -0,0 +1,260 @@ |
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" |
||||||
|
xmlns:controls1="clr-namespace:StabilityMatrix.Avalonia.Controls" |
||||||
|
xmlns:viewModels="clr-namespace:StabilityMatrix.Avalonia.ViewModels" |
||||||
|
xmlns:designData="clr-namespace:StabilityMatrix.Avalonia.DesignData" |
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" |
||||||
|
x:DataType="viewModels:CheckpointBrowserViewModel" |
||||||
|
d:DataContext="{x:Static designData:DesignData.CheckpointBrowserViewModel}" |
||||||
|
x:CompileBindings="True" |
||||||
|
x:Class="StabilityMatrix.Avalonia.Views.CheckpointBrowserPage"> |
||||||
|
|
||||||
|
<UserControl.Resources> |
||||||
|
<DataTemplate DataType="{x:Type viewModels:CheckpointBrowserCardViewModel}" x:Key="CivitModelTemplate"> |
||||||
|
<controls1:Card |
||||||
|
Margin="8" |
||||||
|
MaxHeight="450" |
||||||
|
IsExpanded="True" |
||||||
|
Name="ModelCard" |
||||||
|
Width="330"> |
||||||
|
|
||||||
|
<StackPanel Orientation="Vertical"> |
||||||
|
<TextBlock |
||||||
|
Foreground="{DynamicResource TextControlForeground}" |
||||||
|
Margin="0,0,0,0" |
||||||
|
Text="{Binding CivitModel.Name}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<TextBlock |
||||||
|
FontSize="11" |
||||||
|
Foreground="{DynamicResource TextControlForeground}" |
||||||
|
Margin="0,2,0,0" |
||||||
|
Text="{Binding CivitModel.ModelVersions[0].Name, FallbackValue=''}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<Grid> |
||||||
|
<Image |
||||||
|
Margin="0,8,0,8" |
||||||
|
MaxHeight="300" |
||||||
|
Source="{Binding CardImage}" |
||||||
|
Stretch="UniformToFill" /> |
||||||
|
|
||||||
|
<!-- Appearance="Info" --> |
||||||
|
<Button |
||||||
|
Command="{Binding OpenModelCommand}" |
||||||
|
CommandParameter="{Binding CivitModel}" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
Margin="0,16,8,0" |
||||||
|
VerticalAlignment="Top"> |
||||||
|
<Button.Content> |
||||||
|
<controls:SymbolIcon Symbol="Open" /> |
||||||
|
</Button.Content> |
||||||
|
</Button> |
||||||
|
|
||||||
|
<controls1:Card |
||||||
|
Background="#AA1467B5" |
||||||
|
Height="20" |
||||||
|
HorizontalAlignment="Left" |
||||||
|
Margin="4,16,0,0" |
||||||
|
Padding="3" |
||||||
|
VerticalAlignment="Top"> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
FontSize="10" |
||||||
|
FontWeight="Medium" |
||||||
|
Foreground="{DynamicResource TextControlForeground}" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Text="{Binding CivitModel.Type}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</controls1:Card> |
||||||
|
|
||||||
|
<Rectangle |
||||||
|
Fill="#DD000000" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Margin="0,8,0,8" |
||||||
|
VerticalAlignment="Stretch" |
||||||
|
IsVisible="{Binding IsImporting, FallbackValue=True}" /> |
||||||
|
<StackPanel |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Orientation="Vertical" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding IsImporting}"> |
||||||
|
<controls1:ProgressRing |
||||||
|
HorizontalAlignment="Center" |
||||||
|
IsIndeterminate="False" |
||||||
|
Value="{Binding Value}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
<TextBlock |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Margin="0,8,0,0" |
||||||
|
Text="{Binding Text, FallbackValue=Importing...}" |
||||||
|
VerticalAlignment="Center" /> |
||||||
|
</StackPanel> |
||||||
|
</Grid> |
||||||
|
<Grid> |
||||||
|
<Grid.ColumnDefinitions> |
||||||
|
<ColumnDefinition Width="1*" /> |
||||||
|
<ColumnDefinition Width="Auto"/> |
||||||
|
</Grid.ColumnDefinitions> |
||||||
|
<Button |
||||||
|
Classes="accent" |
||||||
|
Command="{Binding ImportCommand}" |
||||||
|
CommandParameter="{Binding CivitModel}" |
||||||
|
IsEnabled="{Binding !IsImporting}" |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Margin="0,8,0,0"> |
||||||
|
<Button.Content> |
||||||
|
<StackPanel Orientation="Horizontal"> |
||||||
|
<TextBlock Text="Import Latest -"/> |
||||||
|
<TextBlock Margin="4,0,0,0" |
||||||
|
Text="{Binding CivitModel.FullFilesSize}"/> |
||||||
|
</StackPanel> |
||||||
|
</Button.Content> |
||||||
|
</Button> |
||||||
|
|
||||||
|
<Button Grid.Column="1" |
||||||
|
Margin="8,8,0,0" |
||||||
|
Classes="accent" |
||||||
|
IsEnabled="{Binding !IsImporting}" |
||||||
|
Command="{Binding ShowVersionDialogCommand}" |
||||||
|
CommandParameter="{Binding CivitModel}" |
||||||
|
Content="All Versions"/> |
||||||
|
</Grid> |
||||||
|
</StackPanel> |
||||||
|
</controls1:Card> |
||||||
|
</DataTemplate> |
||||||
|
</UserControl.Resources> |
||||||
|
|
||||||
|
<Grid> |
||||||
|
<Grid.RowDefinitions> |
||||||
|
<RowDefinition Height="Auto" /> |
||||||
|
<RowDefinition Height="*" /> |
||||||
|
<RowDefinition Height="Auto" /> |
||||||
|
</Grid.RowDefinitions> |
||||||
|
|
||||||
|
<StackPanel Margin="8" Orientation="Vertical"> |
||||||
|
<Grid> |
||||||
|
<Grid.ColumnDefinitions> |
||||||
|
<ColumnDefinition Width="*" /> |
||||||
|
<ColumnDefinition Width="Auto" /> |
||||||
|
</Grid.ColumnDefinitions> |
||||||
|
|
||||||
|
<TextBox |
||||||
|
HorizontalAlignment="Stretch" |
||||||
|
Margin="8,0,0,0" |
||||||
|
Watermark="Search models, #tags, or @users" |
||||||
|
Text="{Binding SearchQuery, Mode=TwoWay}" /> |
||||||
|
|
||||||
|
<Button |
||||||
|
Classes="accent" |
||||||
|
Command="{Binding SearchModelsCommand}" |
||||||
|
Grid.Column="1" |
||||||
|
IsDefault="True" |
||||||
|
Margin="8,0,8,0" |
||||||
|
VerticalAlignment="Stretch" |
||||||
|
Width="80"> |
||||||
|
<StackPanel Orientation="Horizontal"> |
||||||
|
<controls1:ProgressRing |
||||||
|
Height="20" |
||||||
|
IsIndeterminate="True" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding SearchModelsCommand.IsRunning}" |
||||||
|
Width="20" /> |
||||||
|
<TextBlock |
||||||
|
Text="Search" |
||||||
|
VerticalAlignment="Center" |
||||||
|
IsVisible="{Binding !SearchModelsCommand.IsRunning}" /> |
||||||
|
</StackPanel> |
||||||
|
</Button> |
||||||
|
</Grid> |
||||||
|
<DockPanel> |
||||||
|
<StackPanel Margin="8" Orientation="Vertical"> |
||||||
|
<Label Content="Sort" /> |
||||||
|
<ComboBox |
||||||
|
ItemsSource="{Binding AllSortModes}" |
||||||
|
MinWidth="100" |
||||||
|
SelectedItem="{Binding SortMode}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<StackPanel Margin="0,8" Orientation="Vertical"> |
||||||
|
<Label Content="Period" /> |
||||||
|
<ComboBox |
||||||
|
ItemsSource="{Binding AllCivitPeriods}" |
||||||
|
MinWidth="100" |
||||||
|
SelectedItem="{Binding SelectedPeriod}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<StackPanel Margin="8" Orientation="Vertical"> |
||||||
|
<Label Content="Model Type" /> |
||||||
|
<ComboBox |
||||||
|
ItemsSource="{Binding AllModelTypes}" |
||||||
|
MinWidth="100" |
||||||
|
SelectedItem="{Binding SelectedModelType}" /> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<CheckBox |
||||||
|
Content="Show NSFW Content" |
||||||
|
FontSize="12" |
||||||
|
HorizontalAlignment="Right" |
||||||
|
IsChecked="{Binding ShowNsfw, Mode=TwoWay}" |
||||||
|
Margin="8,8,8,0" /> |
||||||
|
</DockPanel> |
||||||
|
|
||||||
|
</StackPanel> |
||||||
|
<ScrollViewer Grid.Row="1"> |
||||||
|
<ItemsRepeater ItemTemplate="{StaticResource CivitModelTemplate}" |
||||||
|
ItemsSource="{Binding ModelCards}"> |
||||||
|
<ItemsRepeater.Layout> |
||||||
|
<UniformGridLayout Orientation="Horizontal" /> |
||||||
|
</ItemsRepeater.Layout> |
||||||
|
</ItemsRepeater> |
||||||
|
</ScrollViewer> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="2" |
||||||
|
Margin="16,8" |
||||||
|
Text="Data provided by CivitAI" |
||||||
|
VerticalAlignment="Bottom" /> |
||||||
|
|
||||||
|
<StackPanel |
||||||
|
Grid.Row="2" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
Margin="8" |
||||||
|
Orientation="Vertical" |
||||||
|
IsVisible="{Binding HasSearched}"> |
||||||
|
<TextBlock Margin="0,0,4,4" TextAlignment="Center"> |
||||||
|
<Run Text="Page" /> |
||||||
|
<Run Text="{Binding CurrentPageNumber, FallbackValue=1}" /> |
||||||
|
<Run Text="/" /> |
||||||
|
<Run Text="{Binding TotalPages, FallbackValue=5}" /> |
||||||
|
</TextBlock> |
||||||
|
<StackPanel HorizontalAlignment="Center" Orientation="Horizontal"> |
||||||
|
<Button |
||||||
|
Command="{Binding PreviousPageCommand}" |
||||||
|
IsEnabled="{Binding CanGoToPreviousPage}" |
||||||
|
Margin="0,0,8,0"> |
||||||
|
<controls:SymbolIcon Symbol="PreviousFilled" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding NextPageCommand}" IsEnabled="{Binding CanGoToNextPage}"> |
||||||
|
<controls:SymbolIcon Symbol="NextFilled" /> |
||||||
|
</Button> |
||||||
|
</StackPanel> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<TextBlock |
||||||
|
Grid.Row="0" |
||||||
|
Grid.RowSpan="3" |
||||||
|
FontSize="20" |
||||||
|
HorizontalAlignment="Center" |
||||||
|
VerticalAlignment="Center" |
||||||
|
Text="{Binding NoResultsText, FallbackValue=No results found}" |
||||||
|
IsVisible="{Binding NoResultsFound}" /> |
||||||
|
|
||||||
|
<controls1:ProgressRing |
||||||
|
Grid.Row="0" |
||||||
|
Grid.RowSpan="3" |
||||||
|
IsIndeterminate="True" |
||||||
|
IsVisible="{Binding ShowMainLoadingSpinner, FallbackValue=False}" /> |
||||||
|
</Grid> |
||||||
|
</UserControl> |
@ -0,0 +1,18 @@ |
|||||||
|
using Avalonia; |
||||||
|
using Avalonia.Controls; |
||||||
|
using Avalonia.Markup.Xaml; |
||||||
|
|
||||||
|
namespace StabilityMatrix.Avalonia.Views; |
||||||
|
|
||||||
|
public partial class CheckpointBrowserPage : UserControl |
||||||
|
{ |
||||||
|
public CheckpointBrowserPage() |
||||||
|
{ |
||||||
|
InitializeComponent(); |
||||||
|
} |
||||||
|
|
||||||
|
private void InitializeComponent() |
||||||
|
{ |
||||||
|
AvaloniaXamlLoader.Load(this); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue