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:
Binary file not shown.
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
Source/AIImages/Helpers/AsyncHelper.cs
Normal file
145
Source/AIImages/Helpers/AsyncHelper.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
83
Source/AIImages/Services/ServiceContainer.cs
Normal file
83
Source/AIImages/Services/ServiceContainer.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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<GenerationResult> GenerateImageAsync(GenerationRequest request)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<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)
|
||||
{
|
||||
Messages.Message(
|
||||
$"Error loading from API: {ex.Message}",
|
||||
MessageTypeDefOf.RejectInput
|
||||
);
|
||||
selectedValue = availableValues[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
287
Source/AIImages/Validation/SettingsValidator.cs
Normal file
287
Source/AIImages/Validation/SettingsValidator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
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;
|
||||
|
||||
// Дополнительный отступ
|
||||
|
||||
Reference in New Issue
Block a user