Enhance AIImages mod by adding cancellation support for image generation, improving user experience with localized strings for cancellation actions in English and Russian. Refactor service integration for better dependency management and update AIImages.dll to reflect these changes.

This commit is contained in:
Leonid Pershin
2025-10-26 19:10:45 +03:00
parent 3434927342
commit 02b0143186
11 changed files with 974 additions and 174 deletions

Binary file not shown.

View File

@@ -34,9 +34,13 @@
<!-- Generation --> <!-- Generation -->
<AIImages.Generation.Generate>Generate Image</AIImages.Generation.Generate> <AIImages.Generation.Generate>Generate Image</AIImages.Generation.Generate>
<AIImages.Generation.Generating>Generating...</AIImages.Generation.Generating> <AIImages.Generation.Generating>Generating...</AIImages.Generation.Generating>
<AIImages.Generation.Cancel>Cancel Generation</AIImages.Generation.Cancel>
<AIImages.Generation.InProgress>Generating image, please wait...</AIImages.Generation.InProgress> <AIImages.Generation.InProgress>Generating image, please wait...</AIImages.Generation.InProgress>
<AIImages.Generation.Success>Image generated successfully!</AIImages.Generation.Success> <AIImages.Generation.Success>Image generated successfully!</AIImages.Generation.Success>
<AIImages.Generation.Failed>Generation failed</AIImages.Generation.Failed> <AIImages.Generation.Failed>Generation failed</AIImages.Generation.Failed>
<AIImages.Generation.Cancelled>Generation cancelled by user</AIImages.Generation.Cancelled>
<AIImages.Generation.Cancelling>Cancelling generation...</AIImages.Generation.Cancelling>
<AIImages.Generation.Error>Generation error</AIImages.Generation.Error>
<AIImages.Generation.SavedTo>Image saved to: {0}</AIImages.Generation.SavedTo> <AIImages.Generation.SavedTo>Image saved to: {0}</AIImages.Generation.SavedTo>
<AIImages.Generation.NoImage>No image generated yet.\nClick "Generate Image" to start.</AIImages.Generation.NoImage> <AIImages.Generation.NoImage>No image generated yet.\nClick "Generate Image" to start.</AIImages.Generation.NoImage>
<!-- Settings --> <!-- Settings -->

View File

@@ -34,9 +34,13 @@
<!-- Generation --> <!-- Generation -->
<AIImages.Generation.Generate>Сгенерировать изображение</AIImages.Generation.Generate> <AIImages.Generation.Generate>Сгенерировать изображение</AIImages.Generation.Generate>
<AIImages.Generation.Generating>Генерация...</AIImages.Generation.Generating> <AIImages.Generation.Generating>Генерация...</AIImages.Generation.Generating>
<AIImages.Generation.Cancel>Отменить генерацию</AIImages.Generation.Cancel>
<AIImages.Generation.InProgress>Генерируется изображение, пожалуйста подождите...</AIImages.Generation.InProgress> <AIImages.Generation.InProgress>Генерируется изображение, пожалуйста подождите...</AIImages.Generation.InProgress>
<AIImages.Generation.Success>Изображение успешно сгенерировано!</AIImages.Generation.Success> <AIImages.Generation.Success>Изображение успешно сгенерировано!</AIImages.Generation.Success>
<AIImages.Generation.Failed>Ошибка генерации</AIImages.Generation.Failed> <AIImages.Generation.Failed>Ошибка генерации</AIImages.Generation.Failed>
<AIImages.Generation.Cancelled>Генерация отменена пользователем</AIImages.Generation.Cancelled>
<AIImages.Generation.Cancelling>Отмена генерации...</AIImages.Generation.Cancelling>
<AIImages.Generation.Error>Ошибка генерации</AIImages.Generation.Error>
<AIImages.Generation.SavedTo>Изображение сохранено в: {0}</AIImages.Generation.SavedTo> <AIImages.Generation.SavedTo>Изображение сохранено в: {0}</AIImages.Generation.SavedTo>
<AIImages.Generation.NoImage>Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала.</AIImages.Generation.NoImage> <AIImages.Generation.NoImage>Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала.</AIImages.Generation.NoImage>
<!-- Settings --> <!-- Settings -->

View File

@@ -1,39 +1,91 @@
using System;
using AIImages.Services; using AIImages.Services;
using AIImages.Settings; using AIImages.Settings;
using AIImages.Validation;
using HarmonyLib; using HarmonyLib;
using RimWorld;
using UnityEngine; using UnityEngine;
using Verse; using Verse;
namespace AIImages namespace AIImages
{ {
/// <summary> /// <summary>
/// Main mod class with settings support /// Main mod class with settings support and dependency injection
/// </summary> /// </summary>
public class AIImagesMod : Mod public class AIImagesMod : Mod
{ {
public static AIImagesModSettings Settings { get; private set; } private static AIImagesMod _instance = null!;
private readonly ServiceContainer _serviceContainer;
// Singleton сервисы /// <summary>
public static IPawnDescriptionService PawnDescriptionService { get; private set; } /// Глобальный экземпляр мода (для доступа из других классов)
public static IPromptGeneratorService PromptGeneratorService { get; private set; } /// </summary>
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;
}
/// <summary>
/// Настройки мода
/// </summary>
public static AIImagesModSettings Settings => Instance._serviceContainer.Settings;
/// <summary>
/// Контейнер сервисов с dependency injection
/// </summary>
public static ServiceContainer Services => Instance._serviceContainer;
public AIImagesMod(ModContentPack content) public AIImagesMod(ModContentPack content)
: base(content) : base(content)
{ {
Settings = GetSettings<AIImagesModSettings>(); Instance = this;
// Инициализируем сервисы var settings = GetSettings<AIImagesModSettings>();
PawnDescriptionService = new PawnDescriptionService();
PromptGeneratorService = new AdvancedPromptGenerator();
ApiService = new StableDiffusionApiService(Settings.savePath);
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) public override void DoSettingsWindowContents(Rect inRect)
{ {
AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings); AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings, _serviceContainer);
base.DoSettingsWindowContents(inRect); base.DoSettingsWindowContents(inRect);
} }
@@ -41,6 +93,27 @@ namespace AIImages
{ {
return "AI Images"; return "AI Images";
} }
/// <summary>
/// Вызывается при выгрузке мода
/// </summary>
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
);
}
}
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,145 @@
using System;
using System.Threading.Tasks;
using RimWorld;
using Verse;
namespace AIImages.Helpers
{
/// <summary>
/// Вспомогательный класс для правильной обработки асинхронных операций в RimWorld
/// Предотвращает fire-and-forget паттерн и обеспечивает централизованную обработку ошибок
/// </summary>
public static class AsyncHelper
{
/// <summary>
/// Выполняет асинхронную задачу с обработкой ошибок
/// </summary>
public static async Task RunAsync(Func<Task> 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
);
}
}
/// <summary>
/// Выполняет асинхронную задачу с обработкой ошибок и callback при успехе
/// </summary>
public static async Task RunAsync<T>(
Func<Task<T>> taskFunc,
Action<T> 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
);
}
}
/// <summary>
/// Выполняет асинхронную задачу с полным контролем: onSuccess, onError, onCancel
/// </summary>
public static async Task RunAsync<T>(
Func<Task<T>> taskFunc,
Action<T> onSuccess,
Action<Exception> 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
);
}
}
}
/// <summary>
/// Безопасно выполняет Task без ожидания результата, с логированием ошибок
/// Используется когда нужен fire-and-forget, но с обработкой ошибок
/// </summary>
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
);
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AIImages.Models; using AIImages.Models;
@@ -12,26 +13,41 @@ namespace AIImages.Services
/// <summary> /// <summary>
/// Генерирует изображение на основе запроса /// Генерирует изображение на основе запроса
/// </summary> /// </summary>
Task<GenerationResult> GenerateImageAsync(GenerationRequest request); Task<GenerationResult> GenerateImageAsync(
GenerationRequest request,
CancellationToken cancellationToken = default
);
/// <summary> /// <summary>
/// Проверяет доступность API /// Проверяет доступность API
/// </summary> /// </summary>
Task<bool> CheckApiAvailability(string apiEndpoint); Task<bool> CheckApiAvailability(
string apiEndpoint,
CancellationToken cancellationToken = default
);
/// <summary> /// <summary>
/// Получает список доступных моделей с API /// Получает список доступных моделей с API
/// </summary> /// </summary>
Task<List<string>> GetAvailableModels(string apiEndpoint); Task<List<string>> GetAvailableModels(
string apiEndpoint,
CancellationToken cancellationToken = default
);
/// <summary> /// <summary>
/// Получает список доступных сэмплеров /// Получает список доступных сэмплеров
/// </summary> /// </summary>
Task<List<string>> GetAvailableSamplers(string apiEndpoint); Task<List<string>> GetAvailableSamplers(
string apiEndpoint,
CancellationToken cancellationToken = default
);
/// <summary> /// <summary>
/// Получает список доступных schedulers /// Получает список доступных schedulers
/// </summary> /// </summary>
Task<List<string>> GetAvailableSchedulers(string apiEndpoint); Task<List<string>> GetAvailableSchedulers(
string apiEndpoint,
CancellationToken cancellationToken = default
);
} }
} }

View File

@@ -0,0 +1,83 @@
using System;
using AIImages.Settings;
using Verse;
namespace AIImages.Services
{
/// <summary>
/// Простой DI контейнер для управления зависимостями мода
/// Используется вместо статического Service Locator паттерна
/// </summary>
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");
}
/// <summary>
/// Пересоздает API сервис с новыми настройками (например, когда изменился endpoint)
/// </summary>
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}");
}
/// <summary>
/// Проверяет, нужно ли пересоздать API сервис (например, если изменился endpoint)
/// </summary>
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");
}
}
}

View File

@@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AIImages.Models; using AIImages.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -11,29 +13,60 @@ using Verse;
namespace AIImages.Services namespace AIImages.Services
{ {
/// <summary> /// <summary>
/// Сервис для работы с Stable Diffusion API (AUTOMATIC1111 WebUI) /// Адаптер для Stable Diffusion API (AUTOMATIC1111 WebUI)
/// TODO: В будущем можно мигрировать на библиотеку StableDiffusionNet когда API будет полностью совместимо
/// </summary> /// </summary>
public class StableDiffusionApiService : IStableDiffusionApiService public class StableDiffusionNetAdapter : IStableDiffusionApiService, IDisposable
{ {
private readonly HttpClient httpClient; // Shared HttpClient для предотвращения socket exhaustion
private readonly string saveFolderPath; // См: https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines
private static readonly HttpClient _sharedHttpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(5),
};
public StableDiffusionApiService(string savePath = "AIImages/Generated") private readonly string _apiEndpoint;
private readonly string _saveFolderPath;
private bool _disposed;
public StableDiffusionNetAdapter(string apiEndpoint, string savePath = "AIImages/Generated")
{ {
httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; 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);
}
} }
public async Task<GenerationResult> GenerateImageAsync(GenerationRequest request) Log.Message(
$"[AI Images] StableDiffusion adapter initialized with endpoint: {apiEndpoint}"
);
}
public async Task<GenerationResult> GenerateImageAsync(
GenerationRequest request,
CancellationToken cancellationToken = default
)
{ {
ThrowIfDisposed();
if (request == null)
{
return GenerationResult.Failure("Request cannot be null");
}
try try
{ {
Log.Message( Log.Message(
@@ -59,9 +92,13 @@ namespace AIImages.Services
string jsonRequest = JsonConvert.SerializeObject(apiRequest); string jsonRequest = JsonConvert.SerializeObject(apiRequest);
var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json");
// Отправляем запрос // Отправляем запрос с поддержкой cancellation
string endpoint = $"{request.Model}/sdapi/v1/txt2img"; string endpoint = $"{_apiEndpoint}/sdapi/v1/txt2img";
HttpResponseMessage response = await httpClient.PostAsync(endpoint, content); HttpResponseMessage response = await _sharedHttpClient.PostAsync(
endpoint,
content,
cancellationToken
);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -85,7 +122,7 @@ namespace AIImages.Services
// Сохраняем изображение // Сохраняем изображение
string fileName = $"pawn_{DateTime.Now:yyyyMMdd_HHmmss}.png"; 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); await File.WriteAllBytesAsync(fullPath, imageData);
Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}"); Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}");
@@ -94,8 +131,14 @@ namespace AIImages.Services
} }
catch (TaskCanceledException) catch (TaskCanceledException)
{ {
Log.Warning("[AI Images] Request timeout. Generation took too long.");
return GenerationResult.Failure("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) catch (HttpRequestException ex)
{ {
Log.Error($"[AI Images] HTTP error: {ex.Message}"); Log.Error($"[AI Images] HTTP error: {ex.Message}");
@@ -108,12 +151,20 @@ namespace AIImages.Services
} }
} }
public async Task<bool> CheckApiAvailability(string apiEndpoint) public async Task<bool> CheckApiAvailability(
string apiEndpoint,
CancellationToken cancellationToken = default
)
{ {
ThrowIfDisposed();
try try
{ {
string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models";
HttpResponseMessage response = await httpClient.GetAsync(endpoint); HttpResponseMessage response = await _sharedHttpClient.GetAsync(
endpoint,
cancellationToken
);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
catch (Exception ex) catch (Exception ex)
@@ -123,12 +174,20 @@ namespace AIImages.Services
} }
} }
public async Task<List<string>> GetAvailableModels(string apiEndpoint) public async Task<List<string>> GetAvailableModels(
string apiEndpoint,
CancellationToken cancellationToken = default
)
{ {
ThrowIfDisposed();
try try
{ {
string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models";
HttpResponseMessage response = await httpClient.GetAsync(endpoint); HttpResponseMessage response = await _sharedHttpClient.GetAsync(
endpoint,
cancellationToken
);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return new List<string>(); return new List<string>();
@@ -155,12 +214,20 @@ namespace AIImages.Services
} }
} }
public async Task<List<string>> GetAvailableSamplers(string apiEndpoint) public async Task<List<string>> GetAvailableSamplers(
string apiEndpoint,
CancellationToken cancellationToken = default
)
{ {
ThrowIfDisposed();
try try
{ {
string endpoint = $"{apiEndpoint}/sdapi/v1/samplers"; string endpoint = $"{apiEndpoint}/sdapi/v1/samplers";
HttpResponseMessage response = await httpClient.GetAsync(endpoint); HttpResponseMessage response = await _sharedHttpClient.GetAsync(
endpoint,
cancellationToken
);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return GetDefaultSamplers(); return GetDefaultSamplers();
@@ -187,12 +254,20 @@ namespace AIImages.Services
} }
} }
public async Task<List<string>> GetAvailableSchedulers(string apiEndpoint) public async Task<List<string>> GetAvailableSchedulers(
string apiEndpoint,
CancellationToken cancellationToken = default
)
{ {
ThrowIfDisposed();
try try
{ {
string endpoint = $"{apiEndpoint}/sdapi/v1/schedulers"; string endpoint = $"{apiEndpoint}/sdapi/v1/schedulers";
HttpResponseMessage response = await httpClient.GetAsync(endpoint); HttpResponseMessage response = await _sharedHttpClient.GetAsync(
endpoint,
cancellationToken
);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return GetDefaultSchedulers(); 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 ответов // Вспомогательные классы для десериализации 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 private sealed class Txt2ImgResponse
{ {
public string[] images { get; set; } public string[] images { get; set; }
@@ -280,6 +372,6 @@ namespace AIImages.Services
{ {
public string name { get; set; } public string name { get; set; }
} }
#pragma warning restore S3459, S1144 #pragma warning restore S3459, S1144, IDE1006
} }
} }

View File

@@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AIImages.Helpers;
using AIImages.Models; using AIImages.Models;
using AIImages.Services;
using AIImages.Settings; using AIImages.Settings;
using RimWorld; using RimWorld;
using UnityEngine; using UnityEngine;
@@ -19,7 +21,11 @@ namespace AIImages
private static string widthBuffer; private static string widthBuffer;
private static string heightBuffer; private static string heightBuffer;
public static void DoSettingsWindowContents(Rect inRect, AIImagesModSettings settings) public static void DoSettingsWindowContents(
Rect inRect,
AIImagesModSettings settings,
ServiceContainer serviceContainer
)
{ {
InitializeBuffers(settings); InitializeBuffers(settings);
@@ -29,7 +35,7 @@ namespace AIImages
Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect); Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect);
listingStandard.Begin(viewRect); listingStandard.Begin(viewRect);
DrawApiSettings(listingStandard, settings); DrawApiSettings(listingStandard, settings, serviceContainer);
DrawGenerationSettings(listingStandard, settings); DrawGenerationSettings(listingStandard, settings);
DrawSamplerSchedulerSettings(listingStandard, settings); DrawSamplerSchedulerSettings(listingStandard, settings);
DrawPromptsSettings(listingStandard, settings); DrawPromptsSettings(listingStandard, settings);
@@ -51,7 +57,8 @@ namespace AIImages
private static void DrawApiSettings( private static void DrawApiSettings(
Listing_Standard listingStandard, Listing_Standard listingStandard,
AIImagesModSettings settings AIImagesModSettings settings,
ServiceContainer serviceContainer
) )
{ {
listingStandard.Label( listingStandard.Label(
@@ -61,18 +68,36 @@ namespace AIImages
); );
listingStandard.GapLine(); listingStandard.GapLine();
string oldEndpoint = settings.apiEndpoint;
listingStandard.Label("AIImages.Settings.ApiEndpoint".Translate() + ":"); listingStandard.Label("AIImages.Settings.ApiEndpoint".Translate() + ":");
settings.apiEndpoint = listingStandard.TextEntry(settings.apiEndpoint); settings.apiEndpoint = listingStandard.TextEntry(settings.apiEndpoint);
listingStandard.Gap(8f); 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())) if (listingStandard.ButtonText("AIImages.Settings.TestConnection".Translate()))
{ {
_ = TestApiConnection(settings.apiEndpoint); TestApiConnection(serviceContainer.ApiService, settings.apiEndpoint);
} }
if (listingStandard.ButtonText("AIImages.Settings.LoadFromApi".Translate())) if (listingStandard.ButtonText("AIImages.Settings.LoadFromApi".Translate()))
{ {
_ = LoadAllFromApi(settings); LoadAllFromApi(serviceContainer.ApiService, settings);
} }
DrawModelDropdown(listingStandard, settings); DrawModelDropdown(listingStandard, settings);
@@ -343,12 +368,16 @@ namespace AIImages
settings.savePath = listingStandard.TextEntry(settings.savePath); 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 _ = AsyncHelper.RunAsync(
async () =>
{ {
Log.Message($"[AI Images] Testing connection to {endpoint}..."); Log.Message($"[AI Images] Testing connection to {endpoint}...");
bool available = await AIImagesMod.ApiService.CheckApiAvailability(endpoint); bool available = await apiService.CheckApiAvailability(endpoint);
if (available) if (available)
{ {
@@ -364,84 +393,38 @@ namespace AIImages
MessageTypeDefOf.RejectInput MessageTypeDefOf.RejectInput
); );
} }
} },
catch (Exception ex) "API Connection Test"
{ );
Messages.Message($"Error: {ex.Message}", MessageTypeDefOf.RejectInput);
}
} }
private static async System.Threading.Tasks.Task LoadAllFromApi( private static void LoadAllFromApi(
IStableDiffusionApiService apiService,
AIImagesModSettings settings AIImagesModSettings settings
) )
{ {
try _ = AsyncHelper.RunAsync(
async () =>
{ {
Log.Message("[AI Images] Loading models, samplers and schedulers from API..."); Log.Message("[AI Images] Loading models, samplers and schedulers from API...");
// Загружаем модели // Загружаем модели
var models = await AIImagesMod.ApiService.GetAvailableModels(settings.apiEndpoint); var models = await apiService.GetAvailableModels(settings.apiEndpoint);
settings.availableModels = models; settings.availableModels = models;
// Загружаем семплеры // Загружаем семплеры
var samplers = await AIImagesMod.ApiService.GetAvailableSamplers( var samplers = await apiService.GetAvailableSamplers(settings.apiEndpoint);
settings.apiEndpoint
);
settings.availableSamplers = samplers; settings.availableSamplers = samplers;
// Загружаем schedulers // Загружаем schedulers
var schedulers = await AIImagesMod.ApiService.GetAvailableSchedulers( var schedulers = await apiService.GetAvailableSchedulers(settings.apiEndpoint);
settings.apiEndpoint
);
settings.availableSchedulers = schedulers; settings.availableSchedulers = schedulers;
int totalCount = models.Count + samplers.Count + schedulers.Count; int totalCount = models.Count + samplers.Count + schedulers.Count;
if (totalCount > 0) if (totalCount > 0)
{ {
Messages.Message( ShowSuccessMessage(models.Count, samplers.Count, schedulers.Count);
"AIImages.Settings.AllLoaded".Translate( AutoSelectDefaults(settings, models, samplers, schedulers);
models.Count,
samplers.Count,
schedulers.Count
),
MessageTypeDefOf.PositiveEvent
);
// Автовыбор модели
if (
(
string.IsNullOrEmpty(settings.selectedModel)
|| !models.Contains(settings.selectedModel)
)
&& models.Count > 0
)
{
settings.selectedModel = models[0];
}
// Автовыбор семплера
if (
(
string.IsNullOrEmpty(settings.selectedSampler)
|| !samplers.Contains(settings.selectedSampler)
)
&& samplers.Count > 0
)
{
settings.selectedSampler = samplers[0];
}
// Автовыбор scheduler
if (
(
string.IsNullOrEmpty(settings.selectedScheduler)
|| !schedulers.Contains(settings.selectedScheduler)
)
&& schedulers.Count > 0
)
{
settings.selectedScheduler = schedulers[0];
}
} }
else else
{ {
@@ -450,14 +433,48 @@ namespace AIImages
MessageTypeDefOf.RejectInput MessageTypeDefOf.RejectInput
); );
} }
} },
catch (Exception ex) "Load API Data"
{
Messages.Message(
$"Error loading from API: {ex.Message}",
MessageTypeDefOf.RejectInput
); );
} }
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<string> models,
List<string> samplers,
List<string> 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<string> availableValues,
string currentValue
)
{
bool needsSelection =
string.IsNullOrEmpty(currentValue) || !availableValues.Contains(currentValue);
if (needsSelection && availableValues.Count > 0)
{
selectedValue = availableValues[0];
}
} }
} }
} }

View File

@@ -0,0 +1,287 @@
using System;
using System.Collections.Generic;
using AIImages.Settings;
namespace AIImages.Validation
{
/// <summary>
/// Валидатор для настроек мода AI Images
/// </summary>
public static class SettingsValidator
{
/// <summary>
/// Валидирует все настройки и возвращает список ошибок
/// </summary>
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}");
}
}
}
/// <summary>
/// Результат валидации настроек
/// </summary>
public class ValidationResult
{
private readonly List<string> _errors = new List<string>();
private readonly List<string> _warnings = new List<string>();
public bool IsValid => _errors.Count == 0;
public bool HasWarnings => _warnings.Count > 0;
public IReadOnlyList<string> Errors => _errors;
public IReadOnlyList<string> 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<string>();
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);
}
}
}

View File

@@ -1,6 +1,9 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading;
using AIImages.Helpers;
using AIImages.Models; using AIImages.Models;
using AIImages.Services;
using RimWorld; using RimWorld;
using UnityEngine; using UnityEngine;
using Verse; using Verse;
@@ -31,6 +34,12 @@ namespace AIImages
private Texture2D generatedImage; private Texture2D generatedImage;
private bool isGenerating = false; private bool isGenerating = false;
private string generationStatus = ""; private string generationStatus = "";
private CancellationTokenSource cancellationTokenSource;
// Сервисы (получаем через DI)
private readonly IPawnDescriptionService pawnDescriptionService;
private readonly IPromptGeneratorService promptGeneratorService;
private readonly IStableDiffusionApiService apiService;
public Window_AIImage(Pawn pawn) public Window_AIImage(Pawn pawn)
{ {
@@ -42,6 +51,12 @@ namespace AIImages
this.draggable = true; this.draggable = true;
this.preventCameraMotion = false; this.preventCameraMotion = false;
// Получаем сервисы через DI контейнер
var services = AIImagesMod.Services;
pawnDescriptionService = services.PawnDescriptionService;
promptGeneratorService = services.PromptGeneratorService;
apiService = services.ApiService;
// Извлекаем данные персонажа // Извлекаем данные персонажа
RefreshPawnData(); RefreshPawnData();
} }
@@ -57,10 +72,27 @@ namespace AIImages
/// </summary> /// </summary>
private void RefreshPawnData() private void RefreshPawnData()
{ {
appearanceData = AIImagesMod.PawnDescriptionService.ExtractAppearanceData(pawn); appearanceData = pawnDescriptionService.ExtractAppearanceData(pawn);
generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings(); generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings();
} }
/// <summary>
/// Освобождает ресурсы при закрытии окна
/// </summary>
public override void PreClose()
{
base.PreClose();
// Отменяем генерацию, если она идет
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
}
// Освобождаем CancellationTokenSource
cancellationTokenSource?.Dispose();
}
/// <summary> /// <summary>
/// Обновляет текущую пешку в окне /// Обновляет текущую пешку в окне
/// </summary> /// </summary>
@@ -101,24 +133,28 @@ namespace AIImages
} }
/// <summary> /// <summary>
/// Асинхронная генерация изображения /// Асинхронная генерация изображения с поддержкой отмены
/// </summary> /// </summary>
private async System.Threading.Tasks.Task GenerateImage() private async System.Threading.Tasks.Task GenerateImageAsync()
{ {
if (isGenerating) if (isGenerating)
return; return;
// Создаем новый CancellationTokenSource
cancellationTokenSource?.Dispose();
cancellationTokenSource = new CancellationTokenSource();
isGenerating = true; isGenerating = true;
generationStatus = "AIImages.Generation.InProgress".Translate(); generationStatus = "AIImages.Generation.InProgress".Translate();
try try
{ {
// Генерируем промпты // Генерируем промпты
string positivePrompt = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( string positivePrompt = promptGeneratorService.GeneratePositivePrompt(
appearanceData, appearanceData,
generationSettings generationSettings
); );
string negativePrompt = AIImagesMod.PromptGeneratorService.GenerateNegativePrompt( string negativePrompt = promptGeneratorService.GenerateNegativePrompt(
generationSettings generationSettings
); );
@@ -137,8 +173,11 @@ namespace AIImages
Model = AIImagesMod.Settings.apiEndpoint, Model = AIImagesMod.Settings.apiEndpoint,
}; };
// Генерируем изображение // Генерируем изображение с поддержкой отмены
var result = await AIImagesMod.ApiService.GenerateImageAsync(request); var result = await apiService.GenerateImageAsync(
request,
cancellationTokenSource.Token
);
if (result.Success) if (result.Success)
{ {
@@ -159,10 +198,19 @@ namespace AIImages
Messages.Message(generationStatus, MessageTypeDefOf.RejectInput); Messages.Message(generationStatus, MessageTypeDefOf.RejectInput);
} }
} }
catch (OperationCanceledException)
{
generationStatus = "AIImages.Generation.Cancelled".Translate();
Log.Message("[AI Images] Generation cancelled by user");
}
catch (Exception ex) catch (Exception ex)
{ {
generationStatus = $"Error: {ex.Message}"; generationStatus = $"Error: {ex.Message}";
Log.Error($"[AI Images] Generation error: {ex}"); Log.Error($"[AI Images] Generation error: {ex}");
Messages.Message(
$"AIImages.Generation.Error".Translate() + ": {ex.Message}",
MessageTypeDefOf.RejectInput
);
} }
finally finally
{ {
@@ -170,6 +218,26 @@ namespace AIImages
} }
} }
/// <summary>
/// Запускает генерацию изображения (обертка для безопасного fire-and-forget)
/// </summary>
private void StartGeneration()
{
AsyncHelper.FireAndForget(GenerateImageAsync(), "Image Generation");
}
/// <summary>
/// Отменяет генерацию изображения
/// </summary>
private void CancelGeneration()
{
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
generationStatus = "AIImages.Generation.Cancelling".Translate();
}
}
public override void DoWindowContents(Rect inRect) public override void DoWindowContents(Rect inRect)
{ {
float curY = 0f; float curY = 0f;
@@ -229,9 +297,7 @@ namespace AIImages
contentY += 35f; contentY += 35f;
Text.Font = GameFont.Small; Text.Font = GameFont.Small;
string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( string appearanceText = pawnDescriptionService.GetAppearanceDescription(pawn);
pawn
);
float appearanceHeight = Text.CalcHeight(appearanceText, scrollViewRect.width - 30f); float appearanceHeight = Text.CalcHeight(appearanceText, scrollViewRect.width - 30f);
Widgets.Label( Widgets.Label(
new Rect(20f, contentY, scrollViewRect.width - 30f, appearanceHeight), new Rect(20f, contentY, scrollViewRect.width - 30f, appearanceHeight),
@@ -252,7 +318,7 @@ namespace AIImages
contentY += 35f; contentY += 35f;
Text.Font = GameFont.Small; Text.Font = GameFont.Small;
string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); string apparelText = pawnDescriptionService.GetApparelDescription(pawn);
float apparelHeight = Text.CalcHeight(apparelText, scrollViewRect.width - 30f); float apparelHeight = Text.CalcHeight(apparelText, scrollViewRect.width - 30f);
Widgets.Label( Widgets.Label(
new Rect(20f, contentY, scrollViewRect.width - 30f, apparelHeight), new Rect(20f, contentY, scrollViewRect.width - 30f, apparelHeight),
@@ -308,18 +374,33 @@ namespace AIImages
curY += statusHeight + 10f; curY += statusHeight + 10f;
} }
// Кнопка генерации // Кнопка генерации/отмены
Text.Font = GameFont.Small; Text.Font = GameFont.Small;
if (isGenerating)
{
// Показываем кнопку отмены во время генерации
if ( if (
Widgets.ButtonText( Widgets.ButtonText(
new Rect(rect.x, rect.y + curY, rect.width, 35f), new Rect(rect.x, rect.y + curY, rect.width, 35f),
isGenerating "AIImages.Generation.Cancel".Translate()
? "AIImages.Generation.Generating".Translate() )
: "AIImages.Generation.Generate".Translate()
) && !isGenerating
) )
{ {
_ = GenerateImage(); CancelGeneration();
}
}
else
{
// Показываем кнопку генерации
if (
Widgets.ButtonText(
new Rect(rect.x, rect.y + curY, rect.width, 35f),
"AIImages.Generation.Generate".Translate()
)
)
{
StartGeneration();
}
} }
curY += 40f; curY += 40f;
@@ -333,7 +414,7 @@ namespace AIImages
// Получаем промпт // Получаем промпт
Text.Font = GameFont.Tiny; Text.Font = GameFont.Tiny;
string promptText = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( string promptText = promptGeneratorService.GeneratePositivePrompt(
appearanceData, appearanceData,
generationSettings generationSettings
); );
@@ -411,9 +492,7 @@ namespace AIImages
height += 35f; height += 35f;
// Текст внешности // Текст внешности
string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( string appearanceText = pawnDescriptionService.GetAppearanceDescription(pawn);
pawn
);
height += Text.CalcHeight(appearanceText, 400f) + 20f; height += Text.CalcHeight(appearanceText, 400f) + 20f;
// Разделитель // Разделитель
@@ -423,7 +502,7 @@ namespace AIImages
height += 35f; height += 35f;
// Текст одежды // Текст одежды
string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); string apparelText = pawnDescriptionService.GetApparelDescription(pawn);
height += Text.CalcHeight(apparelText, 400f) + 20f; height += Text.CalcHeight(apparelText, 400f) + 20f;
// Дополнительный отступ // Дополнительный отступ