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;
// Дополнительный отступ