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 -->
<AIImages.Generation.Generate>Generate Image</AIImages.Generation.Generate>
<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.Success>Image generated successfully!</AIImages.Generation.Success>
<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.NoImage>No image generated yet.\nClick "Generate Image" to start.</AIImages.Generation.NoImage>
<!-- Settings -->

View File

@@ -34,9 +34,13 @@
<!-- Generation -->
<AIImages.Generation.Generate>Сгенерировать изображение</AIImages.Generation.Generate>
<AIImages.Generation.Generating>Генерация...</AIImages.Generation.Generating>
<AIImages.Generation.Cancel>Отменить генерацию</AIImages.Generation.Cancel>
<AIImages.Generation.InProgress>Генерируется изображение, пожалуйста подождите...</AIImages.Generation.InProgress>
<AIImages.Generation.Success>Изображение успешно сгенерировано!</AIImages.Generation.Success>
<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.NoImage>Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала.</AIImages.Generation.NoImage>
<!-- Settings -->

View File

@@ -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
{
/// <summary>
/// Main mod class with settings support
/// Main mod class with settings support and dependency injection
/// </summary>
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; }
/// <summary>
/// Глобальный экземпляр мода (для доступа из других классов)
/// </summary>
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)
: base(content)
{
Settings = GetSettings<AIImagesModSettings>();
Instance = this;
// Инициализируем сервисы
PawnDescriptionService = new PawnDescriptionService();
PromptGeneratorService = new AdvancedPromptGenerator();
ApiService = new StableDiffusionApiService(Settings.savePath);
var settings = GetSettings<AIImagesModSettings>();
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";
}
/// <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>

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.Threading;
using System.Threading.Tasks;
using AIImages.Models;
@@ -12,26 +13,41 @@ namespace AIImages.Services
/// <summary>
/// Генерирует изображение на основе запроса
/// </summary>
Task<GenerationResult> GenerateImageAsync(GenerationRequest request);
Task<GenerationResult> GenerateImageAsync(
GenerationRequest request,
CancellationToken cancellationToken = default
);
/// <summary>
/// Проверяет доступность API
/// </summary>
Task<bool> CheckApiAvailability(string apiEndpoint);
Task<bool> CheckApiAvailability(
string apiEndpoint,
CancellationToken cancellationToken = default
);
/// <summary>
/// Получает список доступных моделей с API
/// </summary>
Task<List<string>> GetAvailableModels(string apiEndpoint);
Task<List<string>> GetAvailableModels(
string apiEndpoint,
CancellationToken cancellationToken = default
);
/// <summary>
/// Получает список доступных сэмплеров
/// </summary>
Task<List<string>> GetAvailableSamplers(string apiEndpoint);
Task<List<string>> GetAvailableSamplers(
string apiEndpoint,
CancellationToken cancellationToken = default
);
/// <summary>
/// Получает список доступных schedulers
/// </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.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
{
/// <summary>
/// Сервис для работы с Stable Diffusion API (AUTOMATIC1111 WebUI)
/// Адаптер для Stable Diffusion API (AUTOMATIC1111 WebUI)
/// TODO: В будущем можно мигрировать на библиотеку StableDiffusionNet когда API будет полностью совместимо
/// </summary>
public class StableDiffusionApiService : IStableDiffusionApiService
public class StableDiffusionNetAdapter : IStableDiffusionApiService, IDisposable
{
private readonly HttpClient httpClient;
private readonly string saveFolderPath;
// Shared HttpClient для предотвращения socket exhaustion
// См: 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
{
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<bool> CheckApiAvailability(string apiEndpoint)
public async Task<bool> 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<List<string>> GetAvailableModels(string apiEndpoint)
public async Task<List<string>> 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<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
{
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<List<string>> GetAvailableSchedulers(string apiEndpoint)
public async Task<List<string>> 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
}
}

View File

@@ -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,12 +368,16 @@ 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
_ = AsyncHelper.RunAsync(
async () =>
{
Log.Message($"[AI Images] Testing connection to {endpoint}...");
bool available = await AIImagesMod.ApiService.CheckApiAvailability(endpoint);
bool available = await apiService.CheckApiAvailability(endpoint);
if (available)
{
@@ -364,84 +393,38 @@ namespace AIImages
MessageTypeDefOf.RejectInput
);
}
}
catch (Exception ex)
{
Messages.Message($"Error: {ex.Message}", MessageTypeDefOf.RejectInput);
}
},
"API Connection Test"
);
}
private static async System.Threading.Tasks.Task LoadAllFromApi(
private static void LoadAllFromApi(
IStableDiffusionApiService apiService,
AIImagesModSettings settings
)
{
try
_ = AsyncHelper.RunAsync(
async () =>
{
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;
// Загружаем семплеры
var samplers = await AIImagesMod.ApiService.GetAvailableSamplers(
settings.apiEndpoint
);
var samplers = await apiService.GetAvailableSamplers(settings.apiEndpoint);
settings.availableSamplers = samplers;
// Загружаем schedulers
var schedulers = await AIImagesMod.ApiService.GetAvailableSchedulers(
settings.apiEndpoint
);
var schedulers = await apiService.GetAvailableSchedulers(settings.apiEndpoint);
settings.availableSchedulers = schedulers;
int totalCount = models.Count + samplers.Count + schedulers.Count;
if (totalCount > 0)
{
Messages.Message(
"AIImages.Settings.AllLoaded".Translate(
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];
}
ShowSuccessMessage(models.Count, samplers.Count, schedulers.Count);
AutoSelectDefaults(settings, models, samplers, schedulers);
}
else
{
@@ -450,14 +433,48 @@ namespace AIImages
MessageTypeDefOf.RejectInput
);
}
}
catch (Exception ex)
{
Messages.Message(
$"Error loading from API: {ex.Message}",
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<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.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
/// </summary>
private void RefreshPawnData()
{
appearanceData = AIImagesMod.PawnDescriptionService.ExtractAppearanceData(pawn);
appearanceData = pawnDescriptionService.ExtractAppearanceData(pawn);
generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings();
}
/// <summary>
/// Освобождает ресурсы при закрытии окна
/// </summary>
public override void PreClose()
{
base.PreClose();
// Отменяем генерацию, если она идет
if (isGenerating && cancellationTokenSource != null)
{
cancellationTokenSource.Cancel();
}
// Освобождаем CancellationTokenSource
cancellationTokenSource?.Dispose();
}
/// <summary>
/// Обновляет текущую пешку в окне
/// </summary>
@@ -101,24 +133,28 @@ namespace AIImages
}
/// <summary>
/// Асинхронная генерация изображения
/// Асинхронная генерация изображения с поддержкой отмены
/// </summary>
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
}
}
/// <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)
{
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 (isGenerating)
{
// Показываем кнопку отмены во время генерации
if (
Widgets.ButtonText(
new Rect(rect.x, rect.y + curY, rect.width, 35f),
isGenerating
? "AIImages.Generation.Generating".Translate()
: "AIImages.Generation.Generate".Translate()
) && !isGenerating
"AIImages.Generation.Cancel".Translate()
)
)
{
_ = GenerateImage();
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;
// Дополнительный отступ