using AIStudio.Chat;
using AIStudio.Tools.Rust;
using Microsoft.Extensions.Caching.Memory;
// ReSharper disable NotAccessedPositionalProperty.Local
namespace AIStudio.Tools.Services;
///
/// Prepares the images which go to an AI model, and keeps the latest results for a while.
///
///
/// Every message builds the whole conversation anew, with every image in it. Without the cache,
/// each photo of a chat would be decoded and encoded again for each message, which a small device
/// such as a Raspberry Pi notices. The cache also lets the Batch Processing assistant check an
/// image ahead of its request without preparing it twice.
///
/// The cache is a MemoryCache of its own: the shared one, which the Markdown library uses, has no
/// size limit, and its entries state no size.
///
/// The runtime which prepares the images.
/// The logger.
public sealed class ModelImageService(RustService rustService, ILogger logger) : IDisposable
{
///
/// How much memory the cached images may take: a 1 MB photo takes about 2.7 MB as base64 in a string.
///
private const long CACHE_SIZE_LIMIT_BYTES = 32 * 1024 * 1024;
///
/// How long a cached image waits for its next message. A chat paused for longer prepares its images again.
///
private static readonly TimeSpan CACHE_SLIDING_EXPIRATION = TimeSpan.FromMinutes(10);
private readonly MemoryCache cache = new(new MemoryCacheOptions { SizeLimit = CACHE_SIZE_LIMIT_BYTES });
///
/// Prepares an attached image for the model of the given provider.
///
///
/// A changed file and another byte budget each make for a new preparation. An image which could
/// not be prepared is not cached, so the next message tries it again, e.g., once the network
/// drive holding it is back.
///
/// The attached image.
/// The configured provider the image goes to.
/// The cancellation token.
/// The prepared image, or why it could not be prepared.
public async Task PrepareAsync(FileAttachmentImage image, AIStudio.Settings.Provider provider, CancellationToken token = default)
{
var file = new FileInfo(image.FilePath);
if (!file.Exists)
{
logger.LogWarning("The image '{ImagePath}' does not exist anymore and cannot go to the model.", image.FilePath);
return PreparedModelImage.Failed(ModelImageErrorCode.FILE_NOT_FOUND);
}
var maxBytes = IImageSourceExtensions.MaxImageSizeBytesFor(provider);
var key = new CacheKey(file.FullName, file.LastWriteTimeUtc, file.Length, maxBytes);
if (this.cache.TryGetValue(key, out PreparedModelImage? cached) && cached is not null)
{
logger.LogInformation("Took the image '{ImagePath}' for a budget of {MaxBytes} bytes from the cache.", file.FullName, maxBytes);
return cached;
}
var prepared = await rustService.PrepareImageForModelAsync(file.FullName, maxBytes, token);
if (!prepared.IsPrepared)
{
logger.LogWarning("The image '{ImagePath}' could not be prepared for the model: {ErrorCode}.", file.FullName, prepared.ErrorCode);
return prepared;
}
//
// The runtime logs the sizes before and after, so this line only says where it came from:
//
logger.LogInformation("The runtime prepared the image '{ImagePath}' for a budget of {MaxBytes} bytes; it is cached now.", file.FullName, maxBytes);
this.cache.Set(key, prepared, new MemoryCacheEntryOptions
{
Size = prepared.Base64.Length * (long)sizeof(char),
SlidingExpiration = CACHE_SLIDING_EXPIRATION,
});
return prepared;
}
///
public void Dispose() => this.cache.Dispose();
///
/// What makes a prepared image valid: the file as it is now, and the budget it was made for.
///
private readonly record struct CacheKey(string FullPath, DateTime LastWriteTimeUtc, long Length, long MaxBytes);
}