diff --git a/Assemblies/AIImages.dll b/Assemblies/AIImages.dll index 8dbc707..0bda9d2 100644 Binary files a/Assemblies/AIImages.dll and b/Assemblies/AIImages.dll differ diff --git a/Languages/English/Keyed/AIImages.xml b/Languages/English/Keyed/AIImages.xml index bc0560e..f19385a 100644 --- a/Languages/English/Keyed/AIImages.xml +++ b/Languages/English/Keyed/AIImages.xml @@ -34,9 +34,13 @@ Generate Image Generating... + Cancel Generation Generating image, please wait... Image generated successfully! Generation failed + Generation cancelled by user + Cancelling generation... + Generation error Image saved to: {0} No image generated yet.\nClick "Generate Image" to start. diff --git a/Languages/Russian/Keyed/AIImages.xml b/Languages/Russian/Keyed/AIImages.xml index cd277ab..3a753e5 100644 --- a/Languages/Russian/Keyed/AIImages.xml +++ b/Languages/Russian/Keyed/AIImages.xml @@ -34,9 +34,13 @@ Сгенерировать изображение Генерация... + Отменить генерацию Генерируется изображение, пожалуйста подождите... Изображение успешно сгенерировано! Ошибка генерации + Генерация отменена пользователем + Отмена генерации... + Ошибка генерации Изображение сохранено в: {0} Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала. diff --git a/Source/AIImages/AIImagesMod.cs b/Source/AIImages/AIImagesMod.cs index 8700b50..dfffe35 100644 --- a/Source/AIImages/AIImagesMod.cs +++ b/Source/AIImages/AIImagesMod.cs @@ -1,39 +1,91 @@ +using System; using AIImages.Services; using AIImages.Settings; +using AIImages.Validation; using HarmonyLib; +using RimWorld; using UnityEngine; using Verse; namespace AIImages { /// - /// Main mod class with settings support + /// Main mod class with settings support and dependency injection /// public class AIImagesMod : Mod { - public static AIImagesModSettings Settings { get; private set; } + private static AIImagesMod _instance = null!; + private readonly ServiceContainer _serviceContainer; - // Singleton сервисы - public static IPawnDescriptionService PawnDescriptionService { get; private set; } - public static IPromptGeneratorService PromptGeneratorService { get; private set; } - public static IStableDiffusionApiService ApiService { get; private set; } + /// + /// Глобальный экземпляр мода (для доступа из других классов) + /// + public static AIImagesMod Instance + { + get + { + if (_instance == null) + { + throw new InvalidOperationException( + "[AI Images] Mod instance not initialized. This should not happen." + ); + } + return _instance; + } + private set => _instance = value; + } + + /// + /// Настройки мода + /// + public static AIImagesModSettings Settings => Instance._serviceContainer.Settings; + + /// + /// Контейнер сервисов с dependency injection + /// + public static ServiceContainer Services => Instance._serviceContainer; public AIImagesMod(ModContentPack content) : base(content) { - Settings = GetSettings(); + Instance = this; - // Инициализируем сервисы - PawnDescriptionService = new PawnDescriptionService(); - PromptGeneratorService = new AdvancedPromptGenerator(); - ApiService = new StableDiffusionApiService(Settings.savePath); + var settings = GetSettings(); - Log.Message("[AI Images] Mod initialized successfully with settings"); + // Валидируем настройки при загрузке + var validationResult = SettingsValidator.Validate(settings); + if (!validationResult.IsValid) + { + Log.Warning( + $"[AI Images] Settings validation failed:\n{validationResult.GetErrorsAsString()}" + ); + } + + if (validationResult.HasWarnings) + { + Log.Warning( + $"[AI Images] Settings validation warnings:\n{validationResult.GetWarningsAsString()}" + ); + } + + // Создаем контейнер сервисов + try + { + _serviceContainer = new ServiceContainer(settings); + Log.Message("[AI Images] Mod initialized successfully with dependency injection"); + } + catch (Exception ex) + { + Log.Error( + $"[AI Images] Failed to initialize ServiceContainer: {ex.Message}\n{ex.StackTrace}" + ); + throw; + } } public override void DoSettingsWindowContents(Rect inRect) { - AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings); + AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings, _serviceContainer); base.DoSettingsWindowContents(inRect); } @@ -41,6 +93,27 @@ namespace AIImages { return "AI Images"; } + + /// + /// Вызывается при выгрузке мода + /// + public override void WriteSettings() + { + base.WriteSettings(); + + // Валидируем настройки перед сохранением + var validationResult = SettingsValidator.Validate(Settings); + if (!validationResult.IsValid) + { + Log.Warning( + $"[AI Images] Saving settings with validation errors:\n{validationResult.GetErrorsAsString()}" + ); + Messages.Message( + "AI Images: Some settings have validation errors. Check the log.", + MessageTypeDefOf.CautionInput + ); + } + } } /// diff --git a/Source/AIImages/Helpers/AsyncHelper.cs b/Source/AIImages/Helpers/AsyncHelper.cs new file mode 100644 index 0000000..2e787ed --- /dev/null +++ b/Source/AIImages/Helpers/AsyncHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Threading.Tasks; +using RimWorld; +using Verse; + +namespace AIImages.Helpers +{ + /// + /// Вспомогательный класс для правильной обработки асинхронных операций в RimWorld + /// Предотвращает fire-and-forget паттерн и обеспечивает централизованную обработку ошибок + /// + public static class AsyncHelper + { + /// + /// Выполняет асинхронную задачу с обработкой ошибок + /// + public static async Task RunAsync(Func taskFunc, string operationName = "Operation") + { + try + { + await taskFunc(); + } + catch (OperationCanceledException) + { + Log.Message($"[AI Images] {operationName} was cancelled"); + Messages.Message($"{operationName} was cancelled", MessageTypeDefOf.RejectInput); + } + catch (Exception ex) + { + Log.Error($"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}"); + Messages.Message( + $"Error in {operationName}: {ex.Message}", + MessageTypeDefOf.RejectInput + ); + } + } + + /// + /// Выполняет асинхронную задачу с обработкой ошибок и callback при успехе + /// + public static async Task RunAsync( + Func> taskFunc, + Action onSuccess, + string operationName = "Operation" + ) + { + try + { + T result = await taskFunc(); + onSuccess?.Invoke(result); + } + catch (OperationCanceledException) + { + Log.Message($"[AI Images] {operationName} was cancelled"); + Messages.Message($"{operationName} was cancelled", MessageTypeDefOf.RejectInput); + } + catch (Exception ex) + { + Log.Error($"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}"); + Messages.Message( + $"Error in {operationName}: {ex.Message}", + MessageTypeDefOf.RejectInput + ); + } + } + + /// + /// Выполняет асинхронную задачу с полным контролем: onSuccess, onError, onCancel + /// + public static async Task RunAsync( + Func> taskFunc, + Action onSuccess, + Action onError = null, + Action onCancel = null, + string operationName = "Operation" + ) + { + try + { + T result = await taskFunc(); + onSuccess?.Invoke(result); + } + catch (OperationCanceledException) + { + Log.Message($"[AI Images] {operationName} was cancelled"); + + if (onCancel != null) + { + onCancel(); + } + else + { + Messages.Message( + $"{operationName} was cancelled", + MessageTypeDefOf.RejectInput + ); + } + } + catch (Exception ex) + { + Log.Error($"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}"); + + if (onError != null) + { + onError(ex); + } + else + { + Messages.Message( + $"Error in {operationName}: {ex.Message}", + MessageTypeDefOf.RejectInput + ); + } + } + } + + /// + /// Безопасно выполняет Task без ожидания результата, с логированием ошибок + /// Используется когда нужен fire-and-forget, но с обработкой ошибок + /// + public static void FireAndForget(Task task, string operationName = "Background operation") + { + if (task == null) + return; + + task.ContinueWith( + t => + { + if (t.IsFaulted && t.Exception != null) + { + var ex = t.Exception.GetBaseException(); + Log.Error( + $"[AI Images] Error in {operationName}: {ex.Message}\n{ex.StackTrace}" + ); + } + else if (t.IsCanceled) + { + Log.Message($"[AI Images] {operationName} was cancelled"); + } + }, + TaskScheduler.Default + ); + } + } +} diff --git a/Source/AIImages/Services/IStableDiffusionApiService.cs b/Source/AIImages/Services/IStableDiffusionApiService.cs index 3e6617a..e47ef7f 100644 --- a/Source/AIImages/Services/IStableDiffusionApiService.cs +++ b/Source/AIImages/Services/IStableDiffusionApiService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using AIImages.Models; @@ -12,26 +13,41 @@ namespace AIImages.Services /// /// Генерирует изображение на основе запроса /// - Task GenerateImageAsync(GenerationRequest request); + Task GenerateImageAsync( + GenerationRequest request, + CancellationToken cancellationToken = default + ); /// /// Проверяет доступность API /// - Task CheckApiAvailability(string apiEndpoint); + Task CheckApiAvailability( + string apiEndpoint, + CancellationToken cancellationToken = default + ); /// /// Получает список доступных моделей с API /// - Task> GetAvailableModels(string apiEndpoint); + Task> GetAvailableModels( + string apiEndpoint, + CancellationToken cancellationToken = default + ); /// /// Получает список доступных сэмплеров /// - Task> GetAvailableSamplers(string apiEndpoint); + Task> GetAvailableSamplers( + string apiEndpoint, + CancellationToken cancellationToken = default + ); /// /// Получает список доступных schedulers /// - Task> GetAvailableSchedulers(string apiEndpoint); + Task> GetAvailableSchedulers( + string apiEndpoint, + CancellationToken cancellationToken = default + ); } } diff --git a/Source/AIImages/Services/ServiceContainer.cs b/Source/AIImages/Services/ServiceContainer.cs new file mode 100644 index 0000000..d2dde2b --- /dev/null +++ b/Source/AIImages/Services/ServiceContainer.cs @@ -0,0 +1,83 @@ +using System; +using AIImages.Settings; +using Verse; + +namespace AIImages.Services +{ + /// + /// Простой DI контейнер для управления зависимостями мода + /// Используется вместо статического Service Locator паттерна + /// + public class ServiceContainer : IDisposable + { + private bool _disposed; + + // Сервисы + public IPawnDescriptionService PawnDescriptionService { get; } + public IPromptGeneratorService PromptGeneratorService { get; } + public IStableDiffusionApiService ApiService { get; private set; } + + // Настройки + public AIImagesModSettings Settings { get; } + + public ServiceContainer(AIImagesModSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + Settings = settings; + + // Инициализируем сервисы с внедрением зависимостей + PawnDescriptionService = new PawnDescriptionService(); + PromptGeneratorService = new AdvancedPromptGenerator(); + + // Создаем API сервис с текущими настройками + RecreateApiService(); + + Log.Message("[AI Images] ServiceContainer initialized successfully"); + } + + /// + /// Пересоздает API сервис с новыми настройками (например, когда изменился endpoint) + /// + public void RecreateApiService() + { + // Освобождаем старый сервис, если он был + if (ApiService is IDisposable disposable) + { + disposable.Dispose(); + } + + // Создаем новый с актуальными настройками + ApiService = new StableDiffusionNetAdapter(Settings.apiEndpoint, Settings.savePath); + + Log.Message($"[AI Images] API service recreated with endpoint: {Settings.apiEndpoint}"); + } + + /// + /// Проверяет, нужно ли пересоздать API сервис (например, если изменился endpoint) + /// + public bool ShouldRecreateApiService(string newEndpoint) + { + // Можно добавить более сложную логику проверки + return Settings.apiEndpoint != newEndpoint; + } + + public void Dispose() + { + if (_disposed) + return; + + // Освобождаем ресурсы API сервиса + if (ApiService is IDisposable disposable) + { + disposable.Dispose(); + } + + _disposed = true; + Log.Message("[AI Images] ServiceContainer disposed"); + } + } +} diff --git a/Source/AIImages/Services/StableDiffusionApiService.cs b/Source/AIImages/Services/StableDiffusionNetAdapter.cs similarity index 64% rename from Source/AIImages/Services/StableDiffusionApiService.cs rename to Source/AIImages/Services/StableDiffusionNetAdapter.cs index 3bb9373..413c424 100644 --- a/Source/AIImages/Services/StableDiffusionApiService.cs +++ b/Source/AIImages/Services/StableDiffusionNetAdapter.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net.Http; using System.Text; +using System.Threading; using System.Threading.Tasks; using AIImages.Models; using Newtonsoft.Json; @@ -11,29 +13,60 @@ using Verse; namespace AIImages.Services { /// - /// Сервис для работы с Stable Diffusion API (AUTOMATIC1111 WebUI) + /// Адаптер для Stable Diffusion API (AUTOMATIC1111 WebUI) + /// TODO: В будущем можно мигрировать на библиотеку StableDiffusionNet когда API будет полностью совместимо /// - public class StableDiffusionApiService : IStableDiffusionApiService + public class StableDiffusionNetAdapter : IStableDiffusionApiService, IDisposable { - private readonly HttpClient httpClient; - private readonly string saveFolderPath; - - public StableDiffusionApiService(string savePath = "AIImages/Generated") + // Shared HttpClient для предотвращения socket exhaustion + // См: https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines + private static readonly HttpClient _sharedHttpClient = new HttpClient { - httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + Timeout = TimeSpan.FromMinutes(5), + }; + + private readonly string _apiEndpoint; + private readonly string _saveFolderPath; + private bool _disposed; + + public StableDiffusionNetAdapter(string apiEndpoint, string savePath = "AIImages/Generated") + { + if (string.IsNullOrEmpty(apiEndpoint)) + { + throw new ArgumentException( + "API endpoint cannot be null or empty", + nameof(apiEndpoint) + ); + } + + _apiEndpoint = apiEndpoint; // Определяем путь для сохранения - saveFolderPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath); + _saveFolderPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath); // Создаем папку, если не существует - if (!Directory.Exists(saveFolderPath)) + if (!Directory.Exists(_saveFolderPath)) { - Directory.CreateDirectory(saveFolderPath); + Directory.CreateDirectory(_saveFolderPath); } + + Log.Message( + $"[AI Images] StableDiffusion adapter initialized with endpoint: {apiEndpoint}" + ); } - public async Task GenerateImageAsync(GenerationRequest request) + public async Task GenerateImageAsync( + GenerationRequest request, + CancellationToken cancellationToken = default + ) { + ThrowIfDisposed(); + + if (request == null) + { + return GenerationResult.Failure("Request cannot be null"); + } + try { Log.Message( @@ -59,9 +92,13 @@ namespace AIImages.Services string jsonRequest = JsonConvert.SerializeObject(apiRequest); var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); - // Отправляем запрос - string endpoint = $"{request.Model}/sdapi/v1/txt2img"; - HttpResponseMessage response = await httpClient.PostAsync(endpoint, content); + // Отправляем запрос с поддержкой cancellation + string endpoint = $"{_apiEndpoint}/sdapi/v1/txt2img"; + HttpResponseMessage response = await _sharedHttpClient.PostAsync( + endpoint, + content, + cancellationToken + ); if (!response.IsSuccessStatusCode) { @@ -85,7 +122,7 @@ namespace AIImages.Services // Сохраняем изображение string fileName = $"pawn_{DateTime.Now:yyyyMMdd_HHmmss}.png"; - string fullPath = Path.Combine(saveFolderPath, fileName); + string fullPath = Path.Combine(_saveFolderPath, fileName); await File.WriteAllBytesAsync(fullPath, imageData); Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}"); @@ -94,8 +131,14 @@ namespace AIImages.Services } catch (TaskCanceledException) { + Log.Warning("[AI Images] Request timeout. Generation took too long."); return GenerationResult.Failure("Request timeout. Generation took too long."); } + catch (OperationCanceledException) + { + Log.Message("[AI Images] Image generation was cancelled"); + return GenerationResult.Failure("Generation cancelled"); + } catch (HttpRequestException ex) { Log.Error($"[AI Images] HTTP error: {ex.Message}"); @@ -108,12 +151,20 @@ namespace AIImages.Services } } - public async Task CheckApiAvailability(string apiEndpoint) + public async Task CheckApiAvailability( + string apiEndpoint, + CancellationToken cancellationToken = default + ) { + ThrowIfDisposed(); + try { string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; - HttpResponseMessage response = await httpClient.GetAsync(endpoint); + HttpResponseMessage response = await _sharedHttpClient.GetAsync( + endpoint, + cancellationToken + ); return response.IsSuccessStatusCode; } catch (Exception ex) @@ -123,12 +174,20 @@ namespace AIImages.Services } } - public async Task> GetAvailableModels(string apiEndpoint) + public async Task> GetAvailableModels( + string apiEndpoint, + CancellationToken cancellationToken = default + ) { + ThrowIfDisposed(); + try { string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; - HttpResponseMessage response = await httpClient.GetAsync(endpoint); + HttpResponseMessage response = await _sharedHttpClient.GetAsync( + endpoint, + cancellationToken + ); if (!response.IsSuccessStatusCode) return new List(); @@ -155,12 +214,20 @@ namespace AIImages.Services } } - public async Task> GetAvailableSamplers(string apiEndpoint) + public async Task> GetAvailableSamplers( + string apiEndpoint, + CancellationToken cancellationToken = default + ) { + ThrowIfDisposed(); + try { string endpoint = $"{apiEndpoint}/sdapi/v1/samplers"; - HttpResponseMessage response = await httpClient.GetAsync(endpoint); + HttpResponseMessage response = await _sharedHttpClient.GetAsync( + endpoint, + cancellationToken + ); if (!response.IsSuccessStatusCode) return GetDefaultSamplers(); @@ -187,12 +254,20 @@ namespace AIImages.Services } } - public async Task> GetAvailableSchedulers(string apiEndpoint) + public async Task> GetAvailableSchedulers( + string apiEndpoint, + CancellationToken cancellationToken = default + ) { + ThrowIfDisposed(); + try { string endpoint = $"{apiEndpoint}/sdapi/v1/schedulers"; - HttpResponseMessage response = await httpClient.GetAsync(endpoint); + HttpResponseMessage response = await _sharedHttpClient.GetAsync( + endpoint, + cancellationToken + ); if (!response.IsSuccessStatusCode) return GetDefaultSchedulers(); @@ -258,8 +333,25 @@ namespace AIImages.Services }; } + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(StableDiffusionNetAdapter)); + } + } + + public void Dispose() + { + if (_disposed) + return; + + // Не dispose shared HttpClient - он используется глобально + _disposed = true; + } + // Вспомогательные классы для десериализации JSON ответов -#pragma warning disable S3459, S1144 // Properties set by JSON deserializer +#pragma warning disable S3459, S1144, IDE1006 // Properties set by JSON deserializer private sealed class Txt2ImgResponse { public string[] images { get; set; } @@ -280,6 +372,6 @@ namespace AIImages.Services { public string name { get; set; } } -#pragma warning restore S3459, S1144 +#pragma warning restore S3459, S1144, IDE1006 } } diff --git a/Source/AIImages/UI/AIImagesSettingsUI.cs b/Source/AIImages/UI/AIImagesSettingsUI.cs index d09ffc1..2d10b37 100644 --- a/Source/AIImages/UI/AIImagesSettingsUI.cs +++ b/Source/AIImages/UI/AIImagesSettingsUI.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using AIImages.Helpers; using AIImages.Models; +using AIImages.Services; using AIImages.Settings; using RimWorld; using UnityEngine; @@ -19,7 +21,11 @@ namespace AIImages private static string widthBuffer; private static string heightBuffer; - public static void DoSettingsWindowContents(Rect inRect, AIImagesModSettings settings) + public static void DoSettingsWindowContents( + Rect inRect, + AIImagesModSettings settings, + ServiceContainer serviceContainer + ) { InitializeBuffers(settings); @@ -29,7 +35,7 @@ namespace AIImages Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect); listingStandard.Begin(viewRect); - DrawApiSettings(listingStandard, settings); + DrawApiSettings(listingStandard, settings, serviceContainer); DrawGenerationSettings(listingStandard, settings); DrawSamplerSchedulerSettings(listingStandard, settings); DrawPromptsSettings(listingStandard, settings); @@ -51,7 +57,8 @@ namespace AIImages private static void DrawApiSettings( Listing_Standard listingStandard, - AIImagesModSettings settings + AIImagesModSettings settings, + ServiceContainer serviceContainer ) { listingStandard.Label( @@ -61,18 +68,36 @@ namespace AIImages ); listingStandard.GapLine(); + string oldEndpoint = settings.apiEndpoint; listingStandard.Label("AIImages.Settings.ApiEndpoint".Translate() + ":"); settings.apiEndpoint = listingStandard.TextEntry(settings.apiEndpoint); listingStandard.Gap(8f); + // Если endpoint изменился, пересоздаем API сервис + if (oldEndpoint != settings.apiEndpoint) + { + try + { + serviceContainer.RecreateApiService(); + } + catch (Exception ex) + { + Log.Error($"[AI Images] Failed to recreate API service: {ex.Message}"); + Messages.Message( + "Failed to update API endpoint. Check the log.", + MessageTypeDefOf.RejectInput + ); + } + } + if (listingStandard.ButtonText("AIImages.Settings.TestConnection".Translate())) { - _ = TestApiConnection(settings.apiEndpoint); + TestApiConnection(serviceContainer.ApiService, settings.apiEndpoint); } if (listingStandard.ButtonText("AIImages.Settings.LoadFromApi".Translate())) { - _ = LoadAllFromApi(settings); + LoadAllFromApi(serviceContainer.ApiService, settings); } DrawModelDropdown(listingStandard, settings); @@ -343,120 +368,112 @@ namespace AIImages settings.savePath = listingStandard.TextEntry(settings.savePath); } - private static async System.Threading.Tasks.Task TestApiConnection(string endpoint) + private static void TestApiConnection( + IStableDiffusionApiService apiService, + string endpoint + ) { - try - { - Log.Message($"[AI Images] Testing connection to {endpoint}..."); - bool available = await AIImagesMod.ApiService.CheckApiAvailability(endpoint); + _ = AsyncHelper.RunAsync( + async () => + { + Log.Message($"[AI Images] Testing connection to {endpoint}..."); + bool available = await apiService.CheckApiAvailability(endpoint); - if (available) - { - Messages.Message( - "AIImages.Settings.ConnectionSuccess".Translate(), - MessageTypeDefOf.PositiveEvent - ); - } - else - { - Messages.Message( - "AIImages.Settings.ConnectionFailed".Translate(), - MessageTypeDefOf.RejectInput - ); - } - } - catch (Exception ex) - { - Messages.Message($"Error: {ex.Message}", MessageTypeDefOf.RejectInput); - } + if (available) + { + Messages.Message( + "AIImages.Settings.ConnectionSuccess".Translate(), + MessageTypeDefOf.PositiveEvent + ); + } + else + { + Messages.Message( + "AIImages.Settings.ConnectionFailed".Translate(), + MessageTypeDefOf.RejectInput + ); + } + }, + "API Connection Test" + ); } - private static async System.Threading.Tasks.Task LoadAllFromApi( + private static void LoadAllFromApi( + IStableDiffusionApiService apiService, AIImagesModSettings settings ) { - try - { - Log.Message("[AI Images] Loading models, samplers and schedulers from API..."); - - // Загружаем модели - var models = await AIImagesMod.ApiService.GetAvailableModels(settings.apiEndpoint); - settings.availableModels = models; - - // Загружаем семплеры - var samplers = await AIImagesMod.ApiService.GetAvailableSamplers( - settings.apiEndpoint - ); - settings.availableSamplers = samplers; - - // Загружаем schedulers - var schedulers = await AIImagesMod.ApiService.GetAvailableSchedulers( - settings.apiEndpoint - ); - settings.availableSchedulers = schedulers; - - int totalCount = models.Count + samplers.Count + schedulers.Count; - if (totalCount > 0) + _ = AsyncHelper.RunAsync( + async () => { - Messages.Message( - "AIImages.Settings.AllLoaded".Translate( - models.Count, - samplers.Count, - schedulers.Count - ), - MessageTypeDefOf.PositiveEvent - ); + Log.Message("[AI Images] Loading models, samplers and schedulers from API..."); - // Автовыбор модели - if ( - ( - string.IsNullOrEmpty(settings.selectedModel) - || !models.Contains(settings.selectedModel) - ) - && models.Count > 0 - ) - { - settings.selectedModel = models[0]; - } + // Загружаем модели + var models = await apiService.GetAvailableModels(settings.apiEndpoint); + settings.availableModels = models; - // Автовыбор семплера - if ( - ( - string.IsNullOrEmpty(settings.selectedSampler) - || !samplers.Contains(settings.selectedSampler) - ) - && samplers.Count > 0 - ) - { - settings.selectedSampler = samplers[0]; - } + // Загружаем семплеры + var samplers = await apiService.GetAvailableSamplers(settings.apiEndpoint); + settings.availableSamplers = samplers; - // Автовыбор scheduler - if ( - ( - string.IsNullOrEmpty(settings.selectedScheduler) - || !schedulers.Contains(settings.selectedScheduler) - ) - && schedulers.Count > 0 - ) + // Загружаем schedulers + var schedulers = await apiService.GetAvailableSchedulers(settings.apiEndpoint); + settings.availableSchedulers = schedulers; + + int totalCount = models.Count + samplers.Count + schedulers.Count; + if (totalCount > 0) { - settings.selectedScheduler = schedulers[0]; + ShowSuccessMessage(models.Count, samplers.Count, schedulers.Count); + AutoSelectDefaults(settings, models, samplers, schedulers); } - } - else - { - Messages.Message( - "AIImages.Settings.NothingLoaded".Translate(), - MessageTypeDefOf.RejectInput - ); - } - } - catch (Exception ex) + else + { + Messages.Message( + "AIImages.Settings.NothingLoaded".Translate(), + MessageTypeDefOf.RejectInput + ); + } + }, + "Load API Data" + ); + } + + private static void ShowSuccessMessage(int modelCount, int samplerCount, int schedulerCount) + { + Messages.Message( + "AIImages.Settings.AllLoaded".Translate(modelCount, samplerCount, schedulerCount), + MessageTypeDefOf.PositiveEvent + ); + } + + private static void AutoSelectDefaults( + AIImagesModSettings settings, + List models, + List samplers, + List schedulers + ) + { + AutoSelectIfNeeded(ref settings.selectedModel, models, settings.selectedModel); + AutoSelectIfNeeded(ref settings.selectedSampler, samplers, settings.selectedSampler); + AutoSelectIfNeeded( + ref settings.selectedScheduler, + schedulers, + settings.selectedScheduler + ); + } + + private static void AutoSelectIfNeeded( + ref string selectedValue, + List availableValues, + string currentValue + ) + { + bool needsSelection = + string.IsNullOrEmpty(currentValue) || !availableValues.Contains(currentValue); + + if (needsSelection && availableValues.Count > 0) { - Messages.Message( - $"Error loading from API: {ex.Message}", - MessageTypeDefOf.RejectInput - ); + selectedValue = availableValues[0]; } } } diff --git a/Source/AIImages/Validation/SettingsValidator.cs b/Source/AIImages/Validation/SettingsValidator.cs new file mode 100644 index 0000000..0f85e8e --- /dev/null +++ b/Source/AIImages/Validation/SettingsValidator.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using AIImages.Settings; + +namespace AIImages.Validation +{ + /// + /// Валидатор для настроек мода AI Images + /// + public static class SettingsValidator + { + /// + /// Валидирует все настройки и возвращает список ошибок + /// + public static ValidationResult Validate(AIImagesModSettings settings) + { + var result = new ValidationResult(); + + if (settings == null) + { + result.AddError("Settings object is null"); + return result; + } + + // Валидация API endpoint + ValidateApiEndpoint(settings.apiEndpoint, result); + + // Валидация размеров изображения + ValidateImageDimensions(settings.width, settings.height, result); + + // Валидация steps + ValidateSteps(settings.steps, result); + + // Валидация CFG scale + ValidateCfgScale(settings.cfgScale, result); + + // Валидация sampler и scheduler + ValidateSamplerAndScheduler( + settings.selectedSampler, + settings.selectedScheduler, + result + ); + + // Валидация пути сохранения + ValidateSavePath(settings.savePath, result); + + return result; + } + + private static void ValidateApiEndpoint(string endpoint, ValidationResult result) + { + if (string.IsNullOrWhiteSpace(endpoint)) + { + result.AddError("API endpoint cannot be empty"); + return; + } + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri uri)) + { + result.AddError($"Invalid API endpoint format: {endpoint}"); + return; + } + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + result.AddWarning($"API endpoint should use HTTP or HTTPS protocol: {endpoint}"); + } + + // Проверка на localhost/127.0.0.1 + if ( + uri.Host != "localhost" + && uri.Host != "127.0.0.1" + && !uri.Host.StartsWith("192.168.") + ) + { + result.AddWarning( + "API endpoint is not pointing to a local address. Make sure the API is accessible." + ); + } + } + + private static void ValidateImageDimensions(int width, int height, ValidationResult result) + { + const int minDimension = 64; + const int maxDimension = 2048; + const int recommendedMin = 512; + const int recommendedMax = 1024; + + if (width < minDimension || width > maxDimension) + { + result.AddError( + $"Width must be between {minDimension} and {maxDimension}. Current: {width}" + ); + } + + if (height < minDimension || height > maxDimension) + { + result.AddError( + $"Height must be between {minDimension} and {maxDimension}. Current: {height}" + ); + } + + // Проверка кратности 8 (рекомендация для Stable Diffusion) + if (width % 8 != 0) + { + result.AddWarning( + $"Width should be divisible by 8 for optimal results. Current: {width}" + ); + } + + if (height % 8 != 0) + { + result.AddWarning( + $"Height should be divisible by 8 for optimal results. Current: {height}" + ); + } + + // Предупреждения о производительности + if (width > recommendedMax || height > recommendedMax) + { + result.AddWarning( + $"Large image dimensions ({width}x{height}) may result in slow generation and high memory usage" + ); + } + + if (width < recommendedMin || height < recommendedMin) + { + result.AddWarning( + $"Small image dimensions ({width}x{height}) may result in lower quality" + ); + } + } + + private static void ValidateSteps(int steps, ValidationResult result) + { + const int minSteps = 1; + const int maxSteps = 150; + const int recommendedMin = 20; + const int recommendedMax = 50; + + if (steps < minSteps || steps > maxSteps) + { + result.AddError( + $"Steps must be between {minSteps} and {maxSteps}. Current: {steps}" + ); + } + + if (steps < recommendedMin) + { + result.AddWarning( + $"Low steps value ({steps}) may result in lower quality. Recommended: {recommendedMin}-{recommendedMax}" + ); + } + + if (steps > recommendedMax) + { + result.AddWarning( + $"High steps value ({steps}) may result in slow generation with minimal quality improvement" + ); + } + } + + private static void ValidateCfgScale(float cfgScale, ValidationResult result) + { + const float minCfg = 1.0f; + const float maxCfg = 30.0f; + const float recommendedMin = 5.0f; + const float recommendedMax = 15.0f; + + if (cfgScale < minCfg || cfgScale > maxCfg) + { + result.AddError( + $"CFG Scale must be between {minCfg} and {maxCfg}. Current: {cfgScale}" + ); + } + + if (cfgScale < recommendedMin) + { + result.AddWarning( + $"Low CFG scale ({cfgScale}) may ignore prompt. Recommended: {recommendedMin}-{recommendedMax}" + ); + } + + if (cfgScale > recommendedMax) + { + result.AddWarning( + $"High CFG scale ({cfgScale}) may result in over-saturated or distorted images" + ); + } + } + + private static void ValidateSamplerAndScheduler( + string sampler, + string scheduler, + ValidationResult result + ) + { + if (string.IsNullOrWhiteSpace(sampler)) + { + result.AddWarning("Sampler is not selected. A default sampler will be used."); + } + + if (string.IsNullOrWhiteSpace(scheduler)) + { + result.AddWarning("Scheduler is not selected. A default scheduler will be used."); + } + } + + private static void ValidateSavePath(string savePath, ValidationResult result) + { + if (string.IsNullOrWhiteSpace(savePath)) + { + result.AddError("Save path cannot be empty"); + return; + } + + // Проверка на недопустимые символы в пути + char[] invalidChars = System.IO.Path.GetInvalidPathChars(); + if (savePath.IndexOfAny(invalidChars) >= 0) + { + result.AddError($"Save path contains invalid characters: {savePath}"); + } + } + } + + /// + /// Результат валидации настроек + /// + public class ValidationResult + { + private readonly List _errors = new List(); + private readonly List _warnings = new List(); + + public bool IsValid => _errors.Count == 0; + public bool HasWarnings => _warnings.Count > 0; + public IReadOnlyList Errors => _errors; + public IReadOnlyList Warnings => _warnings; + + public void AddError(string error) + { + if (!string.IsNullOrWhiteSpace(error)) + { + _errors.Add(error); + } + } + + public void AddWarning(string warning) + { + if (!string.IsNullOrWhiteSpace(warning)) + { + _warnings.Add(warning); + } + } + + public string GetErrorsAsString() + { + return string.Join("\n", _errors); + } + + public string GetWarningsAsString() + { + return string.Join("\n", _warnings); + } + + public string GetAllMessagesAsString() + { + var messages = new List(); + + if (_errors.Count > 0) + { + messages.Add("Errors:"); + messages.AddRange(_errors); + } + + if (_warnings.Count > 0) + { + if (messages.Count > 0) + messages.Add(""); + + messages.Add("Warnings:"); + messages.AddRange(_warnings); + } + + return string.Join("\n", messages); + } + } +} diff --git a/Source/AIImages/Window_AIImage.cs b/Source/AIImages/Window_AIImage.cs index 6dfca8a..68093a3 100644 --- a/Source/AIImages/Window_AIImage.cs +++ b/Source/AIImages/Window_AIImage.cs @@ -1,6 +1,9 @@ using System; using System.Linq; +using System.Threading; +using AIImages.Helpers; using AIImages.Models; +using AIImages.Services; using RimWorld; using UnityEngine; using Verse; @@ -31,6 +34,12 @@ namespace AIImages private Texture2D generatedImage; private bool isGenerating = false; private string generationStatus = ""; + private CancellationTokenSource cancellationTokenSource; + + // Сервисы (получаем через DI) + private readonly IPawnDescriptionService pawnDescriptionService; + private readonly IPromptGeneratorService promptGeneratorService; + private readonly IStableDiffusionApiService apiService; public Window_AIImage(Pawn pawn) { @@ -42,6 +51,12 @@ namespace AIImages this.draggable = true; this.preventCameraMotion = false; + // Получаем сервисы через DI контейнер + var services = AIImagesMod.Services; + pawnDescriptionService = services.PawnDescriptionService; + promptGeneratorService = services.PromptGeneratorService; + apiService = services.ApiService; + // Извлекаем данные персонажа RefreshPawnData(); } @@ -57,10 +72,27 @@ namespace AIImages /// private void RefreshPawnData() { - appearanceData = AIImagesMod.PawnDescriptionService.ExtractAppearanceData(pawn); + appearanceData = pawnDescriptionService.ExtractAppearanceData(pawn); generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings(); } + /// + /// Освобождает ресурсы при закрытии окна + /// + public override void PreClose() + { + base.PreClose(); + + // Отменяем генерацию, если она идет + if (isGenerating && cancellationTokenSource != null) + { + cancellationTokenSource.Cancel(); + } + + // Освобождаем CancellationTokenSource + cancellationTokenSource?.Dispose(); + } + /// /// Обновляет текущую пешку в окне /// @@ -101,24 +133,28 @@ namespace AIImages } /// - /// Асинхронная генерация изображения + /// Асинхронная генерация изображения с поддержкой отмены /// - private async System.Threading.Tasks.Task GenerateImage() + private async System.Threading.Tasks.Task GenerateImageAsync() { if (isGenerating) return; + // Создаем новый CancellationTokenSource + cancellationTokenSource?.Dispose(); + cancellationTokenSource = new CancellationTokenSource(); + isGenerating = true; generationStatus = "AIImages.Generation.InProgress".Translate(); try { // Генерируем промпты - string positivePrompt = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( + string positivePrompt = promptGeneratorService.GeneratePositivePrompt( appearanceData, generationSettings ); - string negativePrompt = AIImagesMod.PromptGeneratorService.GenerateNegativePrompt( + string negativePrompt = promptGeneratorService.GenerateNegativePrompt( generationSettings ); @@ -137,8 +173,11 @@ namespace AIImages Model = AIImagesMod.Settings.apiEndpoint, }; - // Генерируем изображение - var result = await AIImagesMod.ApiService.GenerateImageAsync(request); + // Генерируем изображение с поддержкой отмены + var result = await apiService.GenerateImageAsync( + request, + cancellationTokenSource.Token + ); if (result.Success) { @@ -159,10 +198,19 @@ namespace AIImages Messages.Message(generationStatus, MessageTypeDefOf.RejectInput); } } + catch (OperationCanceledException) + { + generationStatus = "AIImages.Generation.Cancelled".Translate(); + Log.Message("[AI Images] Generation cancelled by user"); + } catch (Exception ex) { generationStatus = $"Error: {ex.Message}"; Log.Error($"[AI Images] Generation error: {ex}"); + Messages.Message( + $"AIImages.Generation.Error".Translate() + ": {ex.Message}", + MessageTypeDefOf.RejectInput + ); } finally { @@ -170,6 +218,26 @@ namespace AIImages } } + /// + /// Запускает генерацию изображения (обертка для безопасного fire-and-forget) + /// + private void StartGeneration() + { + AsyncHelper.FireAndForget(GenerateImageAsync(), "Image Generation"); + } + + /// + /// Отменяет генерацию изображения + /// + private void CancelGeneration() + { + if (isGenerating && cancellationTokenSource != null) + { + cancellationTokenSource.Cancel(); + generationStatus = "AIImages.Generation.Cancelling".Translate(); + } + } + public override void DoWindowContents(Rect inRect) { float curY = 0f; @@ -229,9 +297,7 @@ namespace AIImages contentY += 35f; Text.Font = GameFont.Small; - string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( - pawn - ); + string appearanceText = pawnDescriptionService.GetAppearanceDescription(pawn); float appearanceHeight = Text.CalcHeight(appearanceText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, appearanceHeight), @@ -252,7 +318,7 @@ namespace AIImages contentY += 35f; Text.Font = GameFont.Small; - string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); + string apparelText = pawnDescriptionService.GetApparelDescription(pawn); float apparelHeight = Text.CalcHeight(apparelText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, apparelHeight), @@ -308,18 +374,33 @@ namespace AIImages curY += statusHeight + 10f; } - // Кнопка генерации + // Кнопка генерации/отмены Text.Font = GameFont.Small; - if ( - Widgets.ButtonText( - new Rect(rect.x, rect.y + curY, rect.width, 35f), - isGenerating - ? "AIImages.Generation.Generating".Translate() - : "AIImages.Generation.Generate".Translate() - ) && !isGenerating - ) + if (isGenerating) { - _ = GenerateImage(); + // Показываем кнопку отмены во время генерации + if ( + Widgets.ButtonText( + new Rect(rect.x, rect.y + curY, rect.width, 35f), + "AIImages.Generation.Cancel".Translate() + ) + ) + { + CancelGeneration(); + } + } + else + { + // Показываем кнопку генерации + if ( + Widgets.ButtonText( + new Rect(rect.x, rect.y + curY, rect.width, 35f), + "AIImages.Generation.Generate".Translate() + ) + ) + { + StartGeneration(); + } } curY += 40f; @@ -333,7 +414,7 @@ namespace AIImages // Получаем промпт Text.Font = GameFont.Tiny; - string promptText = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( + string promptText = promptGeneratorService.GeneratePositivePrompt( appearanceData, generationSettings ); @@ -411,9 +492,7 @@ namespace AIImages height += 35f; // Текст внешности - string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( - pawn - ); + string appearanceText = pawnDescriptionService.GetAppearanceDescription(pawn); height += Text.CalcHeight(appearanceText, 400f) + 20f; // Разделитель @@ -423,7 +502,7 @@ namespace AIImages height += 35f; // Текст одежды - string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); + string apparelText = pawnDescriptionService.GetApparelDescription(pawn); height += Text.CalcHeight(apparelText, 400f) + 20f; // Дополнительный отступ