diff --git a/StabilityMatrix.Avalonia/DesignData/DesignData.cs b/StabilityMatrix.Avalonia/DesignData/DesignData.cs index 9bbbeceb..41569dcf 100644 --- a/StabilityMatrix.Avalonia/DesignData/DesignData.cs +++ b/StabilityMatrix.Avalonia/DesignData/DesignData.cs @@ -481,6 +481,9 @@ public static void Initialize() public static HuggingFacePageViewModel HuggingFacePageViewModel => Services.GetRequiredService(); + public static DirectUrlImportViewModel DirectUrlImportViewModel => + Services.GetRequiredService(); + public static NewOneClickInstallViewModel NewOneClickInstallViewModel => Services.GetRequiredService(); diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index af726ddd..da6a6f3c 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -4146,5 +4146,437 @@ public static string Watermark_EnterPackageName { return ResourceManager.GetString("Watermark_EnterPackageName", resourceCulture); } } + + /// + /// Looks up a localized string. + /// + public static string Action_SkipThisPage { + get { + return ResourceManager.GetString("Action_SkipThisPage", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_CustomEllipsis { + get { + return ResourceManager.GetString("Label_CustomEllipsis", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_DirectUrl { + get { + return ResourceManager.GetString("Label_DirectUrl", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_DirectUrlImport { + get { + return ResourceManager.GetString("Label_DirectUrlImport", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_ImportFailed { + get { + return ResourceManager.GetString("Label_ImportFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_ImportsStarted { + get { + return ResourceManager.GetString("Label_ImportsStarted", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_InvalidUrl { + get { + return ResourceManager.GetString("Label_InvalidUrl", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_ModelFileNameForDirectUrls { + get { + return ResourceManager.GetString("Label_ModelFileNameForDirectUrls", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_ModelHasNoFiles { + get { + return ResourceManager.GetString("Label_ModelHasNoFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_NoFileNameProvided { + get { + return ResourceManager.GetString("Label_NoFileNameProvided", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_NoModelsSelected { + get { + return ResourceManager.GetString("Label_NoModelsSelected", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_NoUrlsProvided { + get { + return ResourceManager.GetString("Label_NoUrlsProvided", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_SelectCivitAiModelsFromCurrentPage { + get { + return ResourceManager.GetString("Label_SelectCivitAiModelsFromCurrentPage", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_SelectDownloadFolder { + get { + return ResourceManager.GetString("Label_SelectDownloadFolder", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_SomeImportsFailed { + get { + return ResourceManager.GetString("Label_SomeImportsFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_Tips { + get { + return ResourceManager.GetString("Label_Tips", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Label_UnknownBase { + get { + return ResourceManager.GetString("Label_UnknownBase", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_CivitAiModelsAvailableFrom { + get { + return ResourceManager.GetString("Text_CivitAiModelsAvailableFrom", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_CivitAiPage { + get { + return ResourceManager.GetString("Text_CivitAiPage", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_CustomFolderSet { + get { + return ResourceManager.GetString("Text_CustomFolderSet", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_DirectUrlImportTipCivitAiModelPage { + get { + return ResourceManager.GetString("Text_DirectUrlImportTipCivitAiModelPage", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_DirectUrlImportTipCivitAiPage { + get { + return ResourceManager.GetString("Text_DirectUrlImportTipCivitAiPage", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_DirectUrlImportTipDirectUrls { + get { + return ResourceManager.GetString("Text_DirectUrlImportTipDirectUrls", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_DirectUrlImportTipSaveLocation { + get { + return ResourceManager.GetString("Text_DirectUrlImportTipSaveLocation", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_DirectUrlInputUrlsOnePerLine { + get { + return ResourceManager.GetString("Text_DirectUrlInputUrlsOnePerLine", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_DownloadingFromDirectUrls { + get { + return ResourceManager.GetString("Text_DownloadingFromDirectUrls", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_InvalidUrlFormat { + get { + return ResourceManager.GetString("Text_InvalidUrlFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_LoadingCivitAiModels { + get { + return ResourceManager.GetString("Text_LoadingCivitAiModels", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_ModelHasNoDownloadableFiles { + get { + return ResourceManager.GetString("Text_ModelHasNoDownloadableFiles", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_ModelsFormat { + get { + return ResourceManager.GetString("Text_ModelsFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_NoDownloadableModelVersionsFoundOnThisPageMovingToNext { + get { + return ResourceManager.GetString("Text_NoDownloadableModelVersionsFoundOnThisPageMovingToNext", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_NoModelsFoundOnThisPageMovingToNext { + get { + return ResourceManager.GetString("Text_NoModelsFoundOnThisPageMovingToNext", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PageFormat { + get { + return ResourceManager.GetString("Text_PageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_ParsingUrls { + get { + return ResourceManager.GetString("Text_ParsingUrls", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PleaseEnterAFileNameForFallbackDirectDownloads { + get { + return ResourceManager.GetString("Text_PleaseEnterAFileNameForFallbackDirectDownloads", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PleaseEnterAtLeastOneModelFromThisPage { + get { + return ResourceManager.GetString("Text_PleaseEnterAtLeastOneModelFromThisPage", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PleaseEnterAtLeastOneUrl { + get { + return ResourceManager.GetString("Text_PleaseEnterAtLeastOneUrl", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PleaseEnterAtLeastOneValidUrl { + get { + return ResourceManager.GetString("Text_PleaseEnterAtLeastOneValidUrl", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PleasePickAFolderForTheCustomLocation { + get { + return ResourceManager.GetString("Text_PleasePickAFolderForTheCustomLocation", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PleaseSelectADownloadLocation { + get { + return ResourceManager.GetString("Text_PleaseSelectADownloadLocation", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_PlatformDoesNotSupportFolderPicking { + get { + return ResourceManager.GetString("Text_PlatformDoesNotSupportFolderPicking", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_SearchFormat { + get { + return ResourceManager.GetString("Text_SearchFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_StartedModels { + get { + return ResourceManager.GetString("Text_StartedModels", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_StartedModelsSkippedModels { + get { + return ResourceManager.GetString("Text_StartedModelsSkippedModels", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_TagFormat { + get { + return ResourceManager.GetString("Text_TagFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_UrlImportModelFileNameWatermark { + get { + return ResourceManager.GetString("Text_UrlImportModelFileNameWatermark", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_UrlImportUrlsWatermark { + get { + return ResourceManager.GetString("Text_UrlImportUrlsWatermark", resourceCulture); + } + } + + /// + /// Looks up a localized string. + /// + public static string Text_UserFormat { + get { + return ResourceManager.GetString("Text_UserFormat", resourceCulture); + } + } } } diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index 42d057cf..47fdaa11 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1509,4 +1509,149 @@ We prioritize your privacy ([Gen AI Terms](<https://lykos.ai/gen-ai-terms> Filter + + Skip this page + + + Custom... + + + Direct URL + + + Direct URL Import + + + Import failed + + + Imports started + + + Invalid URL + + + Model File Name (for direct URLs): + + + Model has no files + + + No file name provided + + + No models selected + + + No URLs provided + + + Select CivitAI models from current page + + + Select Download Folder + + + Some imports failed + + + Tips: + + + Unknown Base + + + {0} model(s) available from {1} + + + CivitAI page + + + Custom folder set: {0} + + + • If your CivitAI URL is a model page (https://civitai.com/models/12345), the page's available models are shown for selection. + + + • If you add CivitAI page URLs (for example https://civitai.com/models?query=... ), you'll be asked to pick models page-by-page. + + + • Use direct URLs only when you want a single fallback file name for download. + + + • The download will be saved to the selected model folder. + + + Input URLs (one per line): + + + Downloading from {0} direct URL(s)... + + + Invalid URL: {0} + + + Loading CivitAI models... + + + {0} ({1}) has no downloadable files. + + + Models: {0} + + + No downloadable model versions found on this page. Moving to the next page... + + + No models found on this page. Moving to the next page... + + + {0} (Page {1}) + + + Parsing URLs... + + + Please enter a file name for fallback direct downloads. + + + Please select at least one model from this page. + + + Please enter at least one URL. + + + Please enter at least one valid URL. + + + Please pick a folder for the custom location. + + + Please select a download location. + + + The platform does not support folder picking. + + + Search: {0} + + + Started {0} model(s). + + + Started {0} model(s), skipped {1} model(s). + + + Tag: #{0} + + + model.safetensors + + + https://example.com/model.safetensors +https://civitai.com/models?query=anime + + + User: @{0} + diff --git a/StabilityMatrix.Avalonia/Services/ModelImportService.cs b/StabilityMatrix.Avalonia/Services/ModelImportService.cs index 7d36c2b9..149d0ac9 100644 --- a/StabilityMatrix.Avalonia/Services/ModelImportService.cs +++ b/StabilityMatrix.Avalonia/Services/ModelImportService.cs @@ -319,10 +319,19 @@ await notificationService.TryAsync( cleanupFilePaths.Add(previewImageDownloadPath); } - // Create tracked download - // todo: support multiple uris - var modelUri = modelUris.First(); - var download = trackedDownloadService.NewDownload(modelUri, downloadPath); + // Create tracked download with first URL, storing others as fallbacks + // If download fails, the TrackedDownloadService will attempt to use fallback URLs + var uriList = modelUris.ToList(); + var primaryUri = uriList.First(); + var fallbackUris = uriList.Skip(1).ToList(); + + var download = trackedDownloadService.NewDownload(primaryUri, downloadPath); + + // Store fallback URLs for retry attempts + if (fallbackUris.Count > 0) + { + download.FallbackUris = fallbackUris; + } // Add hash info // download.ExpectedHashSha256 = modelFile.Hashes.SHA256; diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/DirectUrlImportViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/DirectUrlImportViewModel.cs new file mode 100644 index 00000000..5b10df59 --- /dev/null +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowser/DirectUrlImportViewModel.cs @@ -0,0 +1,895 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using System.Web; +using Avalonia.Controls.Notifications; +using Avalonia.Platform.Storage; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Injectio.Attributes; +using StabilityMatrix.Avalonia; +using StabilityMatrix.Avalonia.Languages; +using StabilityMatrix.Avalonia.Services; +using StabilityMatrix.Avalonia.ViewModels.Base; +using StabilityMatrix.Core.Api; +using StabilityMatrix.Core.Attributes; +using StabilityMatrix.Core.Extensions; +using StabilityMatrix.Core.Models; +using StabilityMatrix.Core.Models.Api; +using StabilityMatrix.Core.Models.FileInterfaces; +using StabilityMatrix.Core.Services; + +namespace StabilityMatrix.Avalonia.ViewModels.CheckpointBrowser; + +[View(typeof(Views.DirectUrlImportPage))] +[RegisterSingleton] +public partial class DirectUrlImportViewModel : TabViewModelBase +{ + private static string CustomLocationLabel => Resources.Label_CustomEllipsis; + private const string CivitAiHost = "civitai.com"; + private const string WwwCivitAiHost = "www.civitai.com"; + private const int DefaultCivitPageLimit = 30; + private static readonly string[] UrlLineSeparators = ["\r\n", "\r", "\n"]; + + private readonly IModelImportService modelImportService; + private readonly CivitCompatApiManager civitApi; + private readonly ISettingsManager settingsManager; + private readonly INotificationService notificationService; + + private readonly Queue civitAiImportQueue = new(); + private CivitAiImportContext? activeCivitAiContext; + + [ObservableProperty] + private string urlsInput = string.Empty; + + [ObservableProperty] + private string modelFileName = string.Empty; + + [ObservableProperty] + private string selectedDownloadLocation = string.Empty; + + [ObservableProperty] + private List availableDownloadLocations = new(); + + [ObservableProperty] + private string customDownloadLocation = string.Empty; + + [ObservableProperty] + private bool isImporting; + + [ObservableProperty] + private string importStatus = string.Empty; + + [ObservableProperty] + private bool isCivitAiSelectionMode; + + [ObservableProperty] + private string civitAiCurrentPageLabel = string.Empty; + + [ObservableProperty] + private string civitAiCurrentPageUrl = string.Empty; + + [ObservableProperty] + private ObservableCollection civitAiCurrentPageModels = new(); + + [ObservableProperty] + private bool hasMoreCivitAiPages; + + [ObservableProperty] + private string civitAiSelectionStatus = string.Empty; + + private string lastValidDownloadLocation = string.Empty; + + public override string Header => Resources.Label_DirectUrl; + + public DirectUrlImportViewModel( + IModelImportService modelImportService, + CivitCompatApiManager civitApi, + ISettingsManager settingsManager, + INotificationService notificationService + ) + { + this.modelImportService = modelImportService; + this.civitApi = civitApi; + this.settingsManager = settingsManager; + this.notificationService = notificationService; + } + + public override void OnLoaded() + { + LoadAvailableDownloadLocations(); + if (AvailableDownloadLocations.Count > 0 && string.IsNullOrWhiteSpace(SelectedDownloadLocation)) + { + SelectedDownloadLocation = AvailableDownloadLocations[0]; + if (!IsCustomLocation(SelectedDownloadLocation)) + { + lastValidDownloadLocation = SelectedDownloadLocation; + } + } + } + + private static bool IsCustomLocation(string value) + { + return string.Equals(value, CustomLocationLabel, StringComparison.OrdinalIgnoreCase); + } + + partial void OnSelectedDownloadLocationChanged(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (IsCustomLocation(value)) + { + if (string.IsNullOrWhiteSpace(CustomDownloadLocation)) + { + SelectCustomFolderCommand.Execute(null); + } + + return; + } + + lastValidDownloadLocation = value; + } + + private void LoadAvailableDownloadLocations() + { + var locations = Enum + .GetValues() + .Where(folderType => folderType != SharedFolderType.Unknown) + .Select(folderType => $"Models/{folderType.GetStringValue()}") + .ToList(); + locations.Add(CustomLocationLabel); + AvailableDownloadLocations = locations; + } + + [RelayCommand] + private async Task Import() + { + if (string.IsNullOrWhiteSpace(UrlsInput)) + { + ShowNotification( + Resources.Label_NoUrlsProvided, + Resources.Text_PleaseEnterAtLeastOneUrl, + NotificationType.Warning + ); + return; + } + + var downloadFolder = GetValidatedDownloadFolder(); + if (downloadFolder is null) + { + return; + } + + try + { + IsImporting = true; + ImportStatus = Resources.Text_ParsingUrls; + + var parsedUrls = ParseUrls(UrlsInput); + if (parsedUrls.Count == 0) + { + ShowNotification( + Resources.Label_InvalidUrl, + Resources.Text_PleaseEnterAtLeastOneValidUrl, + NotificationType.Warning + ); + return; + } + + var directUrls = new List(); + var civitAiContexts = new List(); + + foreach (var parsedUrl in parsedUrls) + { + if (TryCreateCivitAiImportContext(parsedUrl, out var civitAiContext)) + { + civitAiContexts.Add(civitAiContext); + } + else + { + directUrls.Add(parsedUrl); + } + } + + if (directUrls.Count > 0 && string.IsNullOrWhiteSpace(ModelFileName)) + { + ShowNotification( + Resources.Label_NoFileNameProvided, + Resources.Text_PleaseEnterAFileNameForFallbackDirectDownloads, + NotificationType.Warning + ); + return; + } + + if (directUrls.Count > 0) + { + ImportStatus = string.Format( + Resources.Text_DownloadingFromDirectUrls, + directUrls.Count + ); + await modelImportService.DoCustomImport(directUrls, ModelFileName, downloadFolder); + + var downloadMessage = string.Format( + Resources.Label_DownloadWillBeSavedToLocation, + ModelFileName, + downloadFolder + ); + ImportStatus = downloadMessage; + ShowNotification( + Resources.Label_DownloadStarted, + downloadMessage, + NotificationType.Success + ); + } + + if (civitAiContexts.Count > 0) + { + StartCivitAiSelectionFlow(civitAiContexts); + ImportStatus = Resources.Text_LoadingCivitAiModels; + await LoadCurrentCivitAiPageAsync(); + return; + } + + UrlsInput = string.Empty; + ModelFileName = string.Empty; + ImportStatus = string.Empty; + } + catch (UriFormatException ex) + { + ShowNotification(Resources.Label_InvalidUrl, ex.Message, NotificationType.Error); + } + catch (Exception ex) + { + ShowNotification(Resources.Label_ImportFailed, ex.Message, NotificationType.Error); + } + finally + { + if (!IsCivitAiSelectionMode) + { + IsImporting = false; + } + } + } + + private static List ParseUrls(string rawInput) + { + var urls = new List(); + + foreach ( + var line in rawInput.Split( + UrlLineSeparators, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + ) + { + if (!Uri.TryCreate(line, UriKind.Absolute, out var parsed)) + { + throw new UriFormatException(string.Format(Resources.Text_InvalidUrlFormat, line)); + } + + urls.Add(parsed); + } + + return urls; + } + + private static string[] ParseListValues(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return []; + } + + return rawValue.Split([ + ',', + ';' + ], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static int? ParseQueryInt(string? rawValue) + { + return int.TryParse(rawValue, out var value) ? value : null; + } + + private static TEnum? TryParseEnum(string? value) + where TEnum : struct, Enum + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return Enum.TryParse(value, true, out TEnum parsed) + ? parsed + : null; + } + + private DirectoryPath ResolveDownloadFolder() + { + if (IsCustomLocation(SelectedDownloadLocation)) + { + return new DirectoryPath(CustomDownloadLocation); + } + + var folderName = SelectedDownloadLocation.Split('/').Last(); + return new DirectoryPath(settingsManager.ModelsDirectory, folderName); + } + + private static bool IsCivitAiHost(Uri uri) + { + return uri.Host.Equals(CivitAiHost, StringComparison.OrdinalIgnoreCase) + || uri.Host.Equals(WwwCivitAiHost, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsCivitAiModelsUrl(Uri uri) + { + return uri.AbsolutePath.StartsWith("/models", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryCreateCivitAiImportContext(Uri uri, out CivitAiImportContext? context) + { + context = null; + + if (!IsCivitAiHost(uri) || !IsCivitAiModelsUrl(uri)) + { + return false; + } + + var request = new CivitModelsRequest { Limit = DefaultCivitPageLimit, Nsfw = "true" }; + var query = HttpUtility.ParseQueryString(uri.Query); + + var pathParts = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (pathParts.Length > 1 && int.TryParse(pathParts[1], out var modelId)) + { + request.CommaSeparatedModelIds = modelId.ToString(); + context = new CivitAiImportContext + { + Request = request, + SourceLabel = $"https://{CivitAiHost}/models/{modelId}", + }; + + return true; + } + + var queryValue = query["query"]; + var tagValue = query["tag"]; + var usernameValue = query["username"]; + var idsValue = query["ids"]; + var modelIdValue = query["modelId"]; + var sortValue = query["sort"]; + var periodValue = query["period"]; + var cursorValue = query["cursor"]; + var pageValue = query["page"]; + var limitValue = query["limit"]; + var typesValue = query["types"]; + var baseModelsValue = query["baseModels"]; + + if (!string.IsNullOrWhiteSpace(queryValue)) + { + request.Query = queryValue; + } + + if (!string.IsNullOrWhiteSpace(tagValue)) + { + request.Tag = tagValue; + } + + if (!string.IsNullOrWhiteSpace(usernameValue)) + { + request.Username = usernameValue; + } + + if (!string.IsNullOrWhiteSpace(idsValue)) + { + request.CommaSeparatedModelIds = idsValue; + } + + if (!string.IsNullOrWhiteSpace(modelIdValue)) + { + request.CommaSeparatedModelIds = modelIdValue; + } + + var sort = TryParseEnum(sortValue); + if (sort is not null) + { + request.Sort = sort; + } + + var period = TryParseEnum(periodValue); + if (period is not null) + { + request.Period = period; + } + + if (!string.IsNullOrWhiteSpace(cursorValue)) + { + request.Cursor = cursorValue; + } + + var page = ParseQueryInt(pageValue); + if (page is not null) + { + request.Page = page; + } + + var limit = ParseQueryInt(limitValue); + if (limit is > 0) + { + request.Limit = Math.Min(limit.Value, 200); + } + + var typeStrings = ParseListValues(typesValue); + if (typeStrings.Length > 0) + { + var types = new List(); + foreach (var type in typeStrings) + { + var parsedType = TryParseEnum(type); + if (parsedType is not null) + { + types.Add(parsedType.Value); + } + } + + request.Types = types.Count > 0 ? [.. types] : null; + } + + var baseModels = ParseListValues(baseModelsValue); + request.BaseModels = baseModels.Length > 0 ? baseModels : null; + + context = new CivitAiImportContext + { + Request = request, + SourceLabel = uri.ToString(), + }; + + return true; + } + + private void StartCivitAiSelectionFlow(IReadOnlyList civitAiContexts) + { + civitAiImportQueue.Clear(); + + foreach (var context in civitAiContexts) + { + civitAiImportQueue.Enqueue(context); + } + + if (!civitAiImportQueue.TryDequeue(out var firstContext)) + { + return; + } + + activeCivitAiContext = firstContext; + IsCivitAiSelectionMode = true; + CivitAiSelectionStatus = string.Empty; + CivitAiCurrentPageUrl = firstContext.SourceLabel; + } + + [RelayCommand] + private async Task ImportSelectedCivitAiModels() + { + var selected = CivitAiCurrentPageModels.Where(x => x.IsSelected).ToList(); + if (selected.Count == 0) + { + ShowNotification( + Resources.Label_NoModelsSelected, + Resources.Text_PleaseEnterAtLeastOneModelFromThisPage, + NotificationType.Warning + ); + return; + } + + var downloadFolder = GetValidatedDownloadFolder(); + if (downloadFolder is null) + { + return; + } + + IsImporting = true; + + try + { + var started = 0; + var failed = 0; + + foreach (var item in selected) + { + if (await TryImportCivitModel(item.Model, item.ModelVersion, downloadFolder)) + { + started++; + } + else + { + failed++; + } + } + + if (failed > 0) + { + ShowNotification( + Resources.Label_SomeImportsFailed, + string.Format(Resources.Text_StartedModelsSkippedModels, started, failed), + NotificationType.Warning + ); + } + else + { + ShowNotification( + Resources.Label_ImportsStarted, + string.Format(Resources.Text_StartedModels, started), + NotificationType.Success + ); + } + + await MoveToNextCivitAiPageAsync(); + } + finally + { + IsImporting = false; + } + } + + [RelayCommand] + private async Task SkipCurrentCivitAiPage() + { + if (IsImporting) + { + return; + } + + await MoveToNextCivitAiPageAsync(); + } + + [RelayCommand] + private void CancelCivitAiSelection() + { + ResetCivitAiSelectionFlow(); + IsImporting = false; + ImportStatus = string.Empty; + } + + [RelayCommand] + private async Task ContinueNextCivitAiPage() + { + await MoveToNextCivitAiPageAsync(); + } + + private async Task MoveToNextCivitAiPageAsync() + { + if (!TryAdvanceToNextCivitAiPage()) + { + CompleteCivitAiSelectionFlow(); + return; + } + + await LoadCurrentCivitAiPageAsync(); + } + + private async Task LoadCurrentCivitAiPageAsync() + { + if (activeCivitAiContext is null) + { + ResetCivitAiSelectionFlow(); + return; + } + + IsImporting = true; + + try + { + while (activeCivitAiContext is not null) + { + var request = activeCivitAiContext.Request.Clone(); + if (!string.IsNullOrWhiteSpace(activeCivitAiContext.CurrentCursor)) + { + request.Cursor = activeCivitAiContext.CurrentCursor; + } + + var response = await civitApi.GetModels(request); + var pageModels = CreateSelectionItems(response.Items); + + if (pageModels.Count == 0) + { + CivitAiSelectionStatus = response.Items is { Count: > 0 } + ? Resources.Text_NoDownloadableModelVersionsFoundOnThisPageMovingToNext + : Resources.Text_NoModelsFoundOnThisPageMovingToNext; + + if (!TryAdvanceToNextCivitAiPage()) + { + CompleteCivitAiSelectionFlow(); + return; + } + + continue; + } + + SetCurrentCivitAiPage(response, pageModels); + return; + } + + CompleteCivitAiSelectionFlow(); + } + finally + { + IsImporting = false; + } + } + + private async Task TryImportCivitModel( + CivitModel model, + CivitModelVersion modelVersion, + DirectoryPath downloadFolder + ) + { + var modelFile = + modelVersion.Files?.FirstOrDefault(file => file.Type == CivitFileType.Model) + ?? modelVersion.Files?.FirstOrDefault(); + + if (modelFile is null) + { + ShowNotification( + Resources.Label_ModelHasNoFiles, + string.Format( + Resources.Text_ModelHasNoDownloadableFiles, + model.Name, + modelVersion.Name + ), + NotificationType.Warning + ); + return false; + } + + await modelImportService.DoImport( + model, + downloadFolder, + selectedVersion: modelVersion, + selectedFile: modelFile + ); + + return true; + } + + private void ResetCivitAiSelectionFlow() + { + IsCivitAiSelectionMode = false; + CivitAiCurrentPageModels.Clear(); + CivitAiCurrentPageLabel = string.Empty; + CivitAiCurrentPageUrl = string.Empty; + CivitAiSelectionStatus = string.Empty; + HasMoreCivitAiPages = false; + activeCivitAiContext = null; + civitAiImportQueue.Clear(); + ImportStatus = string.Empty; + } + + private sealed class CivitAiImportContext + { + public required CivitModelsRequest Request { get; init; } + public required string SourceLabel { get; init; } + public string? CurrentCursor { get; set; } + public string? NextCursor { get; set; } + public int? NextPage { get; set; } + } + + public sealed partial class CivitModelSelectionItem : ObservableObject + { + [ObservableProperty] + private bool isSelected; + + public required CivitModel Model { get; init; } + public required CivitModelVersion ModelVersion { get; init; } + + public string DisplayName => $"{Model.Name} - {ModelVersion.Name}"; + + public string Details => + $"{Model.Type} • {ModelVersion.BaseModel ?? Resources.Label_UnknownBase} • {Model.Creator?.Username}"; + } + + [RelayCommand] + private async Task SelectCustomFolder() + { + if (!App.StorageProvider.CanPickFolder) + { + ShowNotification( + Resources.Label_SelectDownloadFolder, + Resources.Text_PlatformDoesNotSupportFolderPicking, + NotificationType.Warning + ); + return; + } + + var files = await App.StorageProvider.OpenFolderPickerAsync( + new FolderPickerOpenOptions + { + Title = Resources.Label_SelectDownloadFolder, + AllowMultiple = false, + } + ); + + if (files.FirstOrDefault()?.TryGetLocalPath() is { } path) + { + CustomDownloadLocation = path; + SelectedDownloadLocation = CustomLocationLabel; + ImportStatus = string.Format(Resources.Text_CustomFolderSet, path); + return; + } + + SelectedDownloadLocation = GetLastValidDownloadLocation(); + } + + private string GetLastValidDownloadLocation() + { + if (!string.IsNullOrWhiteSpace(lastValidDownloadLocation)) + { + return lastValidDownloadLocation; + } + + return AvailableDownloadLocations.FirstOrDefault(loc => !IsCustomLocation(loc)) ?? string.Empty; + } + + private DirectoryPath? GetValidatedDownloadFolder() + { + if (string.IsNullOrWhiteSpace(SelectedDownloadLocation)) + { + ShowNotification( + Resources.Label_SelectDownloadLocation, + Resources.Text_PleaseSelectADownloadLocation, + NotificationType.Warning + ); + return null; + } + + if (IsCustomLocation(SelectedDownloadLocation) && string.IsNullOrWhiteSpace(CustomDownloadLocation)) + { + ShowNotification( + Resources.Label_SelectDownloadLocation, + Resources.Text_PleasePickAFolderForTheCustomLocation, + NotificationType.Warning + ); + return null; + } + + return ResolveDownloadFolder(); + } + + private bool TryAdvanceToNextCivitAiPage() + { + if (activeCivitAiContext is null) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(activeCivitAiContext.NextCursor)) + { + activeCivitAiContext.CurrentCursor = activeCivitAiContext.NextCursor; + activeCivitAiContext.NextCursor = null; + return true; + } + + if (activeCivitAiContext.NextPage is > 0) + { + activeCivitAiContext.CurrentCursor = null; + activeCivitAiContext.Request.Page = activeCivitAiContext.NextPage; + activeCivitAiContext.Request.Cursor = null; + activeCivitAiContext.NextPage = null; + return true; + } + + if (!civitAiImportQueue.TryDequeue(out var nextContext)) + { + return false; + } + + activeCivitAiContext = nextContext; + CivitAiCurrentPageUrl = nextContext.SourceLabel; + return true; + } + + private static List CreateSelectionItems(IReadOnlyList? models) + { + var items = new List(); + if (models is not { Count: > 0 }) + { + return items; + } + + foreach (var model in models.DistinctBy(x => x.Id)) + { + if (model.ModelVersions is not { Count: > 0 }) + { + continue; + } + + foreach (var version in model.ModelVersions.DistinctBy(x => x.Id)) + { + if (version.Files is not { Count: > 0 }) + { + continue; + } + + items.Add( + new CivitModelSelectionItem + { + Model = model, + ModelVersion = version, + } + ); + } + } + + return items; + } + + private void SetCurrentCivitAiPage( + CivitModelsResponse response, + IReadOnlyCollection pageModels + ) + { + CivitAiCurrentPageModels.Clear(); + foreach (var item in pageModels) + { + CivitAiCurrentPageModels.Add(item); + } + + activeCivitAiContext!.NextCursor = response.Metadata?.NextCursor; + activeCivitAiContext.NextPage = ParseQueryInt(response.Metadata?.NextPage); + HasMoreCivitAiPages = + !string.IsNullOrWhiteSpace(activeCivitAiContext.NextCursor) + || activeCivitAiContext.NextPage is > 0; + CivitAiCurrentPageLabel = FormatCivitAiPageLabel(activeCivitAiContext); + CivitAiSelectionStatus = string.Format( + Resources.Text_CivitAiModelsAvailableFrom, + CivitAiCurrentPageModels.Count, + CivitAiCurrentPageUrl + ); + } + + private static string FormatCivitAiPageLabel(CivitAiImportContext context) + { + var pageLabelSource = Resources.Text_CivitAiPage; + if (!string.IsNullOrWhiteSpace(context.Request.Query)) + { + pageLabelSource = string.Format(Resources.Text_SearchFormat, context.Request.Query); + } + else if (!string.IsNullOrWhiteSpace(context.Request.Tag)) + { + pageLabelSource = string.Format(Resources.Text_TagFormat, context.Request.Tag); + } + else if (!string.IsNullOrWhiteSpace(context.Request.Username)) + { + pageLabelSource = string.Format(Resources.Text_UserFormat, context.Request.Username); + } + else if (!string.IsNullOrWhiteSpace(context.Request.CommaSeparatedModelIds)) + { + pageLabelSource = string.Format( + Resources.Text_ModelsFormat, + context.Request.CommaSeparatedModelIds + ); + } + + if (context.Request.Page is > 0) + { + pageLabelSource = string.Format(Resources.Text_PageFormat, pageLabelSource, context.Request.Page); + } + + return pageLabelSource; + } + + private void CompleteCivitAiSelectionFlow() + { + ResetCivitAiSelectionFlow(); + UrlsInput = string.Empty; + ModelFileName = string.Empty; + } + + private void ShowNotification(string title, string message, NotificationType notificationType) + { + notificationService.Show(new Notification(title, message, notificationType)); + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs index 589f4d25..04680433 100644 --- a/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/CheckpointBrowserViewModel.cs @@ -33,12 +33,18 @@ public partial class CheckpointBrowserViewModel : PageViewModelBase public CheckpointBrowserViewModel( CivitAiBrowserViewModel civitAiBrowserViewModel, HuggingFacePageViewModel huggingFaceViewModel, - OpenModelDbBrowserViewModel openModelDbBrowserViewModel + OpenModelDbBrowserViewModel openModelDbBrowserViewModel, + DirectUrlImportViewModel directUrlImportViewModel ) { Pages = new List( new List( - [civitAiBrowserViewModel, huggingFaceViewModel, openModelDbBrowserViewModel] + [ + civitAiBrowserViewModel, + huggingFaceViewModel, + openModelDbBrowserViewModel, + directUrlImportViewModel + ] ).Select(vm => new TabItem { Header = vm.Header, Content = vm }) ); SelectedPage = Pages.FirstOrDefault(); diff --git a/StabilityMatrix.Avalonia/Views/DirectUrlImportPage.axaml b/StabilityMatrix.Avalonia/Views/DirectUrlImportPage.axaml new file mode 100644 index 00000000..74df8bc9 --- /dev/null +++ b/StabilityMatrix.Avalonia/Views/DirectUrlImportPage.axaml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + +