Merge branch 'main' into chunk-data

This commit is contained in:
PaulKoudelka committed 2026-05-26 17:19:32 +02:00
commit 00b6c98f3b
167 files changed
+8153 -3577

No files matched your search

@@ -0,0 +1,7 @@
namespace AIStudio.Tools.AIJobs;
public enum AIJobKind
{
NONE,
CHAT_GENERATION,
}
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.AIJobs;
public enum AIJobSchedulingClass
{
NONE,
TOP_LEVEL_USER_JOB,
INTERNAL_DEPENDENCY,
}
@@ -0,0 +1,458 @@
using System.Collections.Concurrent;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG.RAGProcesses;
namespace AIStudio.Tools.AIJobs;
public sealed class AIJobService(
SettingsManager settingsManager,
MessageBus messageBus,
ILogger<AIJobService> logger)
{
private sealed class AIJobState
{
public required CancellationTokenSource CancellationTokenSource { get; init; }
public required CancellationToken CancellationToken { get; init; }
public required ChatGenerationRequest ChatGenerationRequest { get; init; }
public required AIJobSnapshot Snapshot { get; set; }
public DateTimeOffset LastCheckpoint { get; set; }
public bool IsCompletionStarted { get; set; }
public readonly Lock SyncRoot = new();
}
private static readonly TimeSpan STREAMING_EVENT_MIN_TIME = TimeSpan.FromSeconds(3);
private static readonly TimeSpan CHECKPOINT_MIN_TIME = TimeSpan.FromSeconds(3);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AIJobService).Namespace, nameof(AIJobService));
private readonly ConcurrentDictionary<Guid, AIJobState> jobs = new();
private readonly ConcurrentDictionary<Guid, Guid> activeChatJobsByChatId = new();
public IReadOnlyCollection<AIJobSnapshot> GetSnapshots()
{
return this.jobs.Values
.Select(job => job.Snapshot)
.OrderByDescending(snapshot => snapshot.UpdatedAt)
.ToList();
}
public bool HasActiveJobs => this.jobs.Values.Any(job => job.Snapshot.IsActive);
public bool IsChatGenerationActive(Guid chatId)
{
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
return false;
return this.jobs.TryGetValue(jobId, out var job) && job.Snapshot.IsActive;
}
public AIJobSnapshot? TryGetChatSnapshot(Guid chatId)
{
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
return this.jobs.Values
.Select(job => job.Snapshot)
.Where(snapshot => snapshot.Kind is AIJobKind.CHAT_GENERATION && snapshot.SubjectId == chatId)
.MaxBy(snapshot => snapshot.UpdatedAt);
return this.jobs.TryGetValue(jobId, out var activeJob) ? activeJob.Snapshot : null;
}
public ChatThread? TryGetLiveChatThread(Guid chatId)
{
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
return null;
return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest.ChatThread : null;
}
public async Task<AIJobSnapshot?> TryStartChatGenerationAsync(ChatGenerationRequest request)
{
if (this.activeChatJobsByChatId.TryGetValue(request.ChatThread.ChatId, out var existingJobId))
return this.jobs.TryGetValue(existingJobId, out var existingJob) ? existingJob.Snapshot : null;
var jobId = Guid.NewGuid();
var rootJobId = request.ParentJobId ?? jobId;
var snapshot = new AIJobSnapshot
{
JobId = jobId,
Kind = AIJobKind.CHAT_GENERATION,
SubjectId = request.ChatThread.ChatId,
ParentJobId = request.ParentJobId,
RootJobId = rootJobId,
Priority = request.Priority,
IsForeground = request.IsForeground,
SchedulingClass = AIJobSchedulingClass.TOP_LEVEL_USER_JOB,
Status = AIJobStatus.WAITING_FOR_REMOTE,
Title = request.ChatThread.Name,
ProviderId = request.ProviderSettings.Id,
ModelId = request.ProviderSettings.Model.Id,
UpdatedAt = DateTimeOffset.Now,
};
var cancellationTokenSource = new CancellationTokenSource();
var state = new AIJobState
{
CancellationTokenSource = cancellationTokenSource,
CancellationToken = cancellationTokenSource.Token,
ChatGenerationRequest = request,
Snapshot = snapshot,
LastCheckpoint = DateTimeOffset.MinValue,
};
if (!this.activeChatJobsByChatId.TryAdd(request.ChatThread.ChatId, jobId))
{
state.CancellationTokenSource.Dispose();
return this.TryGetChatSnapshot(request.ChatThread.ChatId);
}
if (!this.jobs.TryAdd(jobId, state))
{
this.activeChatJobsByChatId.TryRemove(request.ChatThread.ChatId, out _);
state.CancellationTokenSource.Dispose();
return null;
}
request.AIText.InitialRemoteWait = true;
request.AIText.IsStreaming = false;
await CheckpointChatAsync(state, force: true);
await this.NotifyChangedAsync(state);
_ = Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning);
return state.Snapshot;
}
public async Task CancelAsync(Guid jobId)
{
if (!this.jobs.TryGetValue(jobId, out var job))
return;
lock (job.SyncRoot)
{
if (job.IsCompletionStarted)
return;
}
try
{
if (!job.CancellationTokenSource.IsCancellationRequested)
await job.CancellationTokenSource.CancelAsync();
}
catch (ObjectDisposedException)
{
return;
}
await this.CompleteChatGenerationAsync(job, AIJobStatus.CANCELED);
}
public async Task CancelChatGenerationAsync(Guid chatId)
{
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
return;
await this.CancelAsync(jobId);
}
public async Task SetForegroundAsync(AIJobKind kind, Guid subjectId, bool isForeground)
{
var matchingJobs = this.jobs.Values
.Where(job => job.Snapshot.Kind == kind && job.Snapshot.SubjectId == subjectId && job.Snapshot.IsActive)
.ToList();
foreach (var job in matchingJobs)
{
lock (job.SyncRoot)
{
job.Snapshot = job.Snapshot with
{
IsForeground = isForeground,
UpdatedAt = DateTimeOffset.Now,
};
}
await this.NotifyChangedAsync(job);
}
}
private async Task RunChatGenerationAsync(AIJobState state)
{
var request = state.ChatGenerationRequest;
var token = state.CancellationToken;
try
{
token.ThrowIfCancellationRequested();
var provider = request.ProviderSettings.CreateProvider();
var chatThread = request.ChatThread;
if (!chatThread.IsLLMProviderAllowed(provider))
{
logger.LogError("The provider is not allowed for chat '{ChatId}' due to data security reasons. Skipping the AI process.", chatThread.ChatId);
await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, TB("The selected provider is not allowed for this chat."));
return;
}
if (!await this.CheckSelectedModelAvailability(provider, request.ProviderSettings.Model, token))
{
await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, TB("The selected model is not available."));
return;
}
token.ThrowIfCancellationRequested();
try
{
var rag = new AISrcSelWithRetCtxVal();
if (request.LastUserPrompt is not null)
{
chatThread = await rag.ProcessAsync(provider, request.LastUserPrompt, chatThread, token);
request.ChatThread = chatThread;
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED);
return;
}
catch (Exception e)
{
logger.LogError(e, "Skipping the RAG process due to an error.");
}
token.ThrowIfCancellationRequested();
var lastStreamingEvent = DateTimeOffset.MinValue;
if (!TrySetWaitingForRemote(state, token))
return;
await this.NotifyChangedAsync(state);
await foreach (var contentStreamChunk in provider.StreamChatCompletion(request.ProviderSettings.Model, chatThread, settingsManager, token))
{
if (!TryApplyStreamChunk(state, contentStreamChunk, token))
break;
var now = DateTimeOffset.Now;
if (!settingsManager.ConfigurationData.App.IsSavingEnergy || now - lastStreamingEvent > STREAMING_EVENT_MIN_TIME)
{
lastStreamingEvent = now;
await this.NotifyChangedAsync(state);
}
await CheckpointChatAsync(state);
}
await this.CompleteChatGenerationAsync(state, token.IsCancellationRequested ? AIJobStatus.CANCELED : AIJobStatus.COMPLETED);
}
catch (OperationCanceledException)
{
await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED);
}
catch (ProviderRequestException e)
{
logger.LogError(e, "The provider request failed for chat generation job '{JobId}'. Status={StatusCode}, Reason='{ReasonPhrase}', Body='{ResponseBody}'", state.Snapshot.JobId, e.StatusCode, e.ReasonPhrase, e.ResponseBody);
RemoveEmptyAIResponse(state);
await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.UserMessage);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, e.UserMessage));
}
catch (Exception e)
{
logger.LogError(e, "The chat generation job '{JobId}' failed.", state.Snapshot.JobId);
await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.Message);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("The AI job failed. The message is: '{0}'"), e.Message)));
}
}
private async Task CompleteChatGenerationAsync(AIJobState state, AIJobStatus status, string errorMessage = "")
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted)
return;
state.IsCompletionStarted = true;
}
var aiText = state.ChatGenerationRequest.AIText;
aiText.InitialRemoteWait = false;
aiText.IsStreaming = false;
aiText.Text = aiText.Text.RemoveThinkTags().Trim();
RemoveEmptyAIResponse(state);
lock (state.SyncRoot)
{
state.Snapshot = state.Snapshot with
{
Status = status,
ErrorMessage = errorMessage,
UpdatedAt = DateTimeOffset.Now,
};
}
this.activeChatJobsByChatId.TryRemove(state.ChatGenerationRequest.ChatThread.ChatId, out _);
await CheckpointChatAsync(state, force: true);
await this.NotifyChangedAsync(state);
await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot);
state.CancellationTokenSource.Dispose();
}
private static void RemoveEmptyAIResponse(AIJobState state)
{
var aiText = state.ChatGenerationRequest.AIText;
if (!string.IsNullOrWhiteSpace(aiText.Text))
return;
var aiBlock = state.ChatGenerationRequest.ChatThread.Blocks
.LastOrDefault(block => ReferenceEquals(block.Content, aiText));
if (aiBlock is not null)
state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock);
}
private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token)
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted || token.IsCancellationRequested)
return false;
state.ChatGenerationRequest.AIText.InitialRemoteWait = true;
return true;
}
}
private static bool TryApplyStreamChunk(AIJobState state, ContentStreamChunk contentStreamChunk, CancellationToken token)
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted || token.IsCancellationRequested)
return false;
var aiText = state.ChatGenerationRequest.AIText;
aiText.InitialRemoteWait = false;
aiText.IsStreaming = true;
aiText.Text += contentStreamChunk;
aiText.Sources.MergeSources(contentStreamChunk.Sources);
if (state.Snapshot.Status is not AIJobStatus.RUNNING)
{
state.Snapshot = state.Snapshot with
{
Status = AIJobStatus.RUNNING,
UpdatedAt = DateTimeOffset.Now,
};
}
return true;
}
}
private async Task NotifyChangedAsync(AIJobState state)
{
lock (state.SyncRoot)
{
state.Snapshot = state.Snapshot with
{
Title = state.ChatGenerationRequest.ChatThread.Name,
UpdatedAt = DateTimeOffset.Now,
};
}
await messageBus.SendMessage(null, Event.AI_JOB_CHANGED, state.Snapshot);
}
private static async Task CheckpointChatAsync(AIJobState state, bool force = false)
{
var now = DateTimeOffset.Now;
if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME)
return;
state.LastCheckpoint = now;
await WorkspaceBehaviour.StoreChatAsync(state.ChatGenerationRequest.ChatThread);
}
private static bool ModelsMatch(Model modelA, Model modelB)
{
var idA = modelA.Id.Trim();
var idB = modelB.Id.Trim();
return string.Equals(idA, idB, StringComparison.OrdinalIgnoreCase);
}
private async Task<bool> CheckSelectedModelAvailability(IProvider provider, Model chatModel, CancellationToken token = default)
{
if (chatModel.IsSystemModel)
return true;
if (string.IsNullOrWhiteSpace(chatModel.Id))
{
logger.LogWarning("Skipping AI request because model ID is null or white space.");
return false;
}
if (!provider.HasModelLoadingCapability)
return true;
IReadOnlyList<Model> loadedModels;
try
{
var modelLoadResult = await provider.GetTextModels(token: token);
if (!modelLoadResult.Success)
{
var userMessage = modelLoadResult.FailureReason.ToUserMessage(provider.InstanceName);
if (!string.IsNullOrWhiteSpace(userMessage))
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage));
logger.LogWarning("Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because loading the model list failed with reason {FailureReason}.", provider.InstanceName, provider.Provider, modelLoadResult.FailureReason);
return false;
}
loadedModels = modelLoadResult.Models;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception e)
{
logger.LogWarning(e, "Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because the model list could not be loaded.", provider.InstanceName, provider.Provider);
return true;
}
var availableModels = loadedModels.Where(model => !string.IsNullOrWhiteSpace(model.Id)).ToList();
if (availableModels.Count == 0)
{
var emptyModelsMessage = string.Format(
TB("We could load models from '{0}', but the provider did not return any usable text models."),
provider.InstanceName);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, emptyModelsMessage));
logger.LogWarning("Skipping AI request because there are no models available from '{ProviderInstanceName}' (provider={ProviderType}).", provider.InstanceName, provider.Provider);
return false;
}
if (availableModels.Any(model => ModelsMatch(model, chatModel)))
return true;
var message = string.Format(
TB("The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."),
chatModel.Id,
provider.InstanceName,
provider.Provider);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, message));
logger.LogWarning("Skipping AI request because model '{ModelId}' is not available from '{ProviderInstanceName}' (provider={ProviderType}).", chatModel.Id, provider.InstanceName, provider.Provider);
return false;
}
}
@@ -0,0 +1,34 @@
namespace AIStudio.Tools.AIJobs;
public sealed record AIJobSnapshot
{
public Guid JobId { get; init; }
public AIJobKind Kind { get; init; }
public Guid SubjectId { get; init; }
public Guid? ParentJobId { get; init; }
public Guid RootJobId { get; init; }
public int Priority { get; init; }
public bool IsForeground { get; init; }
public AIJobSchedulingClass SchedulingClass { get; init; }
public AIJobStatus Status { get; init; }
public string Title { get; init; } = string.Empty;
public string ProviderId { get; init; } = string.Empty;
public string ModelId { get; init; } = string.Empty;
public DateTimeOffset UpdatedAt { get; init; }
public string ErrorMessage { get; init; } = string.Empty;
public bool IsActive => this.Status is AIJobStatus.QUEUED or AIJobStatus.WAITING_FOR_REMOTE or AIJobStatus.RUNNING;
}
@@ -0,0 +1,12 @@
namespace AIStudio.Tools.AIJobs;
public enum AIJobStatus
{
NONE,
QUEUED,
WAITING_FOR_REMOTE,
RUNNING,
COMPLETED,
CANCELED,
FAILED,
}
@@ -0,0 +1,20 @@
using AIStudio.Chat;
namespace AIStudio.Tools.AIJobs;
public sealed record ChatGenerationRequest
{
public required ChatThread ChatThread { get; set; }
public required ContentText AIText { get; init; }
public IContent? LastUserPrompt { get; init; }
public required AIStudio.Settings.Provider ProviderSettings { get; init; }
public Guid? ParentJobId { get; init; }
public int Priority { get; init; }
public bool IsForeground { get; init; } = true;
}
@@ -4,7 +4,11 @@ public abstract class EmbeddingStore(string name, string path)
{
public string Name => name;
public virtual bool IsAvailable => true;
public virtual string CacheKey => name;
public virtual DatabaseClientStatus Status => DatabaseClientStatus.AVAILABLE;
public bool IsAvailable => this.Status is DatabaseClientStatus.AVAILABLE;
private string Path => path;
@@ -50,15 +54,6 @@ public abstract class EmbeddingStore(string name, string path)
{
this.logger = logService;
}
public abstract Task EnsureEmbeddingStoreExists(string collectionName, int vectorSize, CancellationToken token);
public abstract Task InsertEmbedding(string collectionName, IReadOnlyList<EmbeddingStoragePoint> points, CancellationToken token);
public abstract Task DeleteEmbeddingByFile(string collectionName, string filePath, CancellationToken token);
public abstract Task DeleteEmbeddingStore(string collectionName, CancellationToken token);
public abstract void Dispose();
}
@@ -0,0 +1,79 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Databases;
public sealed partial class DatabaseClientProvider
{
private async Task<DatabaseClient> CreateQdrantClientAsync(CancellationToken cancellationToken)
{
var qdrantInfo = await this.rustService.GetQdrantInfo(cancellationToken);
if (qdrantInfo.Status is QdrantStatus.STARTING)
{
return this.CreateNoDatabaseClient(
"Qdrant",
"Qdrant is starting. Details will appear shortly.",
DatabaseClientStatus.STARTING);
}
if (!qdrantInfo.IsAvailable || qdrantInfo.Status is QdrantStatus.UNAVAILABLE)
{
var reason = qdrantInfo.UnavailableReason ?? "unknown";
this.logger.LogWarning("Qdrant is not available. Starting without vector database. Reason: '{Reason}'.", reason);
return this.CreateNoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason, DatabaseClientStatus.UNAVAILABLE);
}
if (!HasValidQdrantConnectionInfo(qdrantInfo, out var invalidReason))
return this.CreateNoDatabaseClient("Qdrant", invalidReason, DatabaseClientStatus.UNAVAILABLE);
var client = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken);
client.SetLogger(this.databaseClientLogger);
try
{
await client.CheckAvailabilityAsync();
return client;
}
catch (Exception e)
{
client.Dispose();
this.logger.LogWarning(e, "Qdrant reported as available by Rust, but the health check failed.");
return this.CreateNoDatabaseClient("Qdrant", e.Message, DatabaseClientStatus.STARTING);
}
}
private static bool HasValidQdrantConnectionInfo(QdrantInfo qdrantInfo, out string invalidReason)
{
if (qdrantInfo.Path == string.Empty)
{
invalidReason = "Failed to get the Qdrant path from Rust.";
return false;
}
if (qdrantInfo.PortHttp == 0)
{
invalidReason = "Failed to get the Qdrant HTTP port from Rust.";
return false;
}
if (qdrantInfo.PortGrpc == 0)
{
invalidReason = "Failed to get the Qdrant gRPC port from Rust.";
return false;
}
if (qdrantInfo.Fingerprint == string.Empty)
{
invalidReason = "Failed to get the Qdrant fingerprint from Rust.";
return false;
}
if (qdrantInfo.ApiToken == string.Empty)
{
invalidReason = "Failed to get the Qdrant API token from Rust.";
return false;
}
invalidReason = string.Empty;
return true;
}
}
@@ -0,0 +1,180 @@
using AIStudio.Tools.Databases.Qdrant;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases;
public sealed class EmbeddingStoreProvider(RustService rustService, ILoggerFactory loggerFactory) : IDisposable
{
private readonly Dictionary<DatabaseRole, EmbeddingStore> clients = new();
private readonly Dictionary<DatabaseRole, SemaphoreSlim> locks = new();
private readonly Lock locksLock = new();
private readonly ILogger<EmbeddingStoreProvider> logger = loggerFactory.CreateLogger<EmbeddingStoreProvider>();
private readonly ILogger<DatabaseClient> databaseClientLogger = loggerFactory.CreateLogger<DatabaseClient>();
public async Task<DatabaseClient> GetClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken = default)
{
var databaseLock = this.GetLock(databaseRole);
await databaseLock.WaitAsync(cancellationToken);
try
{
if (this.clients.TryGetValue(databaseRole, out var cachedClient) && cachedClient.IsAvailable)
return cachedClient;
var client = await this.CreateClientAsync(databaseRole, cancellationToken);
return this.CacheIfAvailable(databaseRole, client);
}
finally
{
databaseLock.Release();
}
}
public async Task<DatabaseClient> RefreshClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken = default)
{
var databaseLock = this.GetLock(databaseRole);
await databaseLock.WaitAsync(cancellationToken);
try
{
var client = await this.CreateClientAsync(databaseRole, cancellationToken);
return this.CacheIfAvailable(databaseRole, client);
}
finally
{
databaseLock.Release();
}
}
private DatabaseClient CacheIfAvailable(DatabaseRole databaseRole, DatabaseClient client)
{
if (!client.IsAvailable)
return client;
if (this.clients.TryGetValue(databaseRole, out var cachedClient))
{
if (IsSameClient(cachedClient, client))
{
client.Dispose();
return cachedClient;
}
cachedClient.Dispose();
}
this.clients[databaseRole] = client;
return client;
}
private SemaphoreSlim GetLock(DatabaseRole databaseRole)
{
lock (this.locksLock)
{
if (this.locks.TryGetValue(databaseRole, out var databaseLock))
return databaseLock;
databaseLock = new SemaphoreSlim(1, 1);
this.locks[databaseRole] = databaseLock;
return databaseLock;
}
}
private async Task<DatabaseClient> CreateClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken) => databaseRole switch
{
DatabaseRole.VECTOR_STORE => await this.CreateQdrantClientAsync(cancellationToken),
_ => new NoDatabaseClient(databaseRole.ToString(), "The requested database role is not supported.")
};
private async Task<DatabaseClient> CreateQdrantClientAsync(CancellationToken cancellationToken)
{
var qdrantInfo = await rustService.GetQdrantInfo(cancellationToken);
if (qdrantInfo.Status is QdrantStatus.STARTING)
{
return this.CreateNoDatabaseClient(
"Qdrant",
"Qdrant is starting. Details will appear shortly.",
DatabaseClientStatus.STARTING);
}
if (!qdrantInfo.IsAvailable || qdrantInfo.Status is QdrantStatus.UNAVAILABLE)
{
var reason = qdrantInfo.UnavailableReason ?? "unknown";
this.logger.LogWarning("Qdrant is not available. Starting without vector database. Reason: '{Reason}'.", reason);
return this.CreateNoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason, DatabaseClientStatus.UNAVAILABLE);
}
if (!HasValidQdrantConnectionInfo(qdrantInfo, out var invalidReason))
return this.CreateNoDatabaseClient("Qdrant", invalidReason, DatabaseClientStatus.UNAVAILABLE);
var client = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken);
client.SetLogger(this.databaseClientLogger);
try
{
await client.CheckAvailabilityAsync();
return client;
}
catch (Exception e)
{
client.Dispose();
this.logger.LogWarning(e, "Qdrant reported as available by Rust, but the health check failed.");
return this.CreateNoDatabaseClient("Qdrant", e.Message, DatabaseClientStatus.STARTING);
}
}
private static bool HasValidQdrantConnectionInfo(QdrantInfo qdrantInfo, out string invalidReason)
{
if (qdrantInfo.Path == string.Empty)
{
invalidReason = "Failed to get the Qdrant path from Rust.";
return false;
}
if (qdrantInfo.PortHttp == 0)
{
invalidReason = "Failed to get the Qdrant HTTP port from Rust.";
return false;
}
if (qdrantInfo.PortGrpc == 0)
{
invalidReason = "Failed to get the Qdrant gRPC port from Rust.";
return false;
}
if (qdrantInfo.Fingerprint == string.Empty)
{
invalidReason = "Failed to get the Qdrant fingerprint from Rust.";
return false;
}
if (qdrantInfo.ApiToken == string.Empty)
{
invalidReason = "Failed to get the Qdrant API token from Rust.";
return false;
}
invalidReason = string.Empty;
return true;
}
private NoDatabaseClient CreateNoDatabaseClient(string name, string? unavailableReason, DatabaseClientStatus status)
{
var client = new NoDatabaseClient(name, unavailableReason, status);
client.SetLogger(this.databaseClientLogger);
return client;
}
private static bool IsSameClient(DatabaseClient left, DatabaseClient right) =>
left.IsAvailable
&& right.IsAvailable
&& left.CacheKey == right.CacheKey;
public void Dispose()
{
foreach (var client in this.clients.Values)
client.Dispose();
foreach (var databaseLock in this.locks.Values)
databaseLock.Dispose();
}
}
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.Databases;
public enum DatabaseClientStatus
{
STARTING,
AVAILABLE,
UNAVAILABLE,
}
@@ -0,0 +1,6 @@
namespace AIStudio.Tools.Databases;
public enum DatabaseRole
{
VECTOR_STORE,
}
@@ -0,0 +1,23 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Databases;
public sealed class NoEmbeddingStore(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : EmbeddingStore(name, string.Empty)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoEmbeddingStore).Namespace, nameof(NoEmbeddingStore));
public override DatabaseClientStatus Status => status;
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
yield return (TB("Status"), TB("Unavailable"));
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);
await Task.CompletedTask;
}
public override void Dispose()
{
}
}
@@ -1,39 +0,0 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Databases;
public sealed class NoEmbeddingStore(string name, string? unavailableReason) : EmbeddingStore(name, string.Empty)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoEmbeddingStore).Namespace, nameof(NoEmbeddingStore));
public override bool IsAvailable => false;
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
yield return (TB("Status"), TB("Unavailable"));
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);
await Task.CompletedTask;
}
public override Task EnsureEmbeddingStoreExists(string collectionName, int vectorSize, CancellationToken token) => throw this.BuildUnavailableException();
public override Task InsertEmbedding(string collectionName, IReadOnlyList<EmbeddingStoragePoint> points, CancellationToken token) => throw this.BuildUnavailableException();
public override Task DeleteEmbeddingByFile(string collectionName, string filePath, CancellationToken token) => Task.CompletedTask;
public override Task DeleteEmbeddingStore(string collectionName, CancellationToken token) => Task.CompletedTask;
public override void Dispose()
{
}
private InvalidOperationException BuildUnavailableException()
{
return new InvalidOperationException(string.IsNullOrWhiteSpace(unavailableReason)
? "The vector database is not available."
: unavailableReason);
}
}
@@ -28,6 +28,8 @@ public class QdrantClientImplementation : EmbeddingStore
this.ApiToken = apiToken ?? string.Empty;
this.GrpcClient = this.CreateQdrantClient();
}
public override string CacheKey => $"{this.Name}:{this.HttpPort}:{this.GrpcPort}:{this.Fingerprint}";
private const string IP_ADDRESS = "localhost";
@@ -49,6 +51,11 @@ public class QdrantClientImplementation : EmbeddingStore
return $"v{operation.Version}";
}
public async Task CheckAvailabilityAsync()
{
await this.GrpcClient.HealthAsync();
}
private async Task<string> GetCollectionsAmount()
{
var operation = await this.GrpcClient.ListCollectionsAsync();
@@ -23,10 +23,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable
}
};
protected readonly HttpClient HttpClient = new()
{
BaseAddress = new Uri($"{dataSource.Hostname}:{dataSource.Port}"),
};
protected readonly HttpClient HttpClient = ExternalHttpClientTimeout.CreateHttpClient(new Uri($"{dataSource.Hostname}:{dataSource.Port}"));
protected string SecurityToken = string.Empty;
@@ -2,6 +2,7 @@ using System.Text;
using System.Text.Json;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ERIClient.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
@@ -102,10 +103,23 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource),
}
case AuthMethod.USERNAME_PASSWORD:
if (this.DataSource.UsernamePasswordMode is DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD)
{
username = await rustService.ReadUserName();
if (string.IsNullOrWhiteSpace(username))
{
return new()
{
Successful = false,
Message = TB("Failed to read the user's username from the operating system.")
};
}
}
string password;
if (string.IsNullOrWhiteSpace(temporarySecret))
{
var passwordResponse = await rustService.GetSecret(this.DataSource);
var passwordResponse = await rustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE);
if (!passwordResponse.Success)
{
return new()
@@ -159,7 +173,7 @@ public class ERIClientV1(IERIDataSource dataSource) : ERIClientBase(dataSource),
string token;
if (string.IsNullOrWhiteSpace(temporarySecret))
{
var tokenResponse = await rustService.GetSecret(this.DataSource);
var tokenResponse = await rustService.GetSecret(this.DataSource, SecretStoreType.DATA_SOURCE);
if (!tokenResponse.Success)
{
return new()
+219 -1
View File
@@ -1,64 +1,282 @@
namespace AIStudio.Tools;
/// <summary>
/// Defines message bus events used for communication between UI components and services.
/// </summary>
public enum Event
{
/// <summary>
/// Represents the absence of a message bus event.
/// </summary>
NONE,
//
// Common events:
//
/// <summary>
/// Requests registered receivers to refresh state that depends on the current UI state.
/// </summary>
STATE_HAS_CHANGED,
/// <summary>
/// Notifies receivers that the application configuration was changed and should be reloaded or re-applied.
/// </summary>
CONFIGURATION_CHANGED,
/// <summary>
/// Notifies receivers that the active color theme changed.
/// </summary>
COLOR_THEME_CHANGED,
/// <summary>
/// Requests startup initialization of the plugin system.
/// </summary>
STARTUP_PLUGIN_SYSTEM,
/// <summary>
/// Notifies receivers that the startup initialization completed.
/// </summary>
STARTUP_COMPLETED,
/// <summary>
/// Carries an enterprise environment that should be processed during startup.
/// </summary>
STARTUP_ENTERPRISE_ENVIRONMENT,
/// <summary>
/// Notifies receivers that the known enterprise environments changed.
/// </summary>
ENTERPRISE_ENVIRONMENTS_CHANGED,
/// <summary>
/// Notifies receivers that plugins were reloaded.
/// </summary>
PLUGINS_RELOADED,
/// <summary>
/// Requests display of an error notification.
/// </summary>
SHOW_ERROR,
/// <summary>
/// Requests display of a warning notification.
/// </summary>
SHOW_WARNING,
/// <summary>
/// Requests display of a success notification.
/// </summary>
SHOW_SUCCESS,
/// <summary>
/// Carries an event received from the Tauri runtime.
/// </summary>
TAURI_EVENT_RECEIVED,
/// <summary>
/// Notifies receivers that the Rust service is unavailable or failed a health check.
/// </summary>
RUST_SERVICE_UNAVAILABLE,
/// <summary>
/// Notifies receivers that voice recording availability changed.
/// </summary>
VOICE_RECORDING_AVAILABILITY_CHANGED,
// Update events:
/// <summary>
/// Requests a user-triggered search for application updates.
/// </summary>
USER_SEARCH_FOR_UPDATE,
/// <summary>
/// Notifies receivers that an application update is available.
/// </summary>
UPDATE_AVAILABLE,
/// <summary>
/// Requests installation of the available application update.
/// </summary>
INSTALL_UPDATE,
//
// Chat events:
//
/// <summary>
/// Queries whether the current chat has unsaved changes.
/// </summary>
HAS_CHAT_UNSAVED_CHANGES,
/// <summary>
/// Requests the current chat state to be reset.
/// </summary>
RESET_CHAT_STATE,
/// <summary>
/// Carries a chat that should be loaded by the chat component.
/// </summary>
LOAD_CHAT,
/// <summary>
/// Notifies receivers that chat response streaming has completed.
/// </summary>
CHAT_STREAMING_DONE,
/// <summary>
/// Notifies receivers that an AI job changed.
/// </summary>
AI_JOB_CHANGED,
/// <summary>
/// Notifies receivers that an AI job finished.
/// </summary>
AI_JOB_FINISHED,
/// <summary>
/// Notifies receivers that chat generation state changed.
/// </summary>
CHAT_GENERATION_CHANGED,
// Workspace events:
/// <summary>
/// Notifies receivers that the chat loaded in the workspace changed.
/// </summary>
WORKSPACE_LOADED_CHAT_CHANGED,
/// <summary>
/// Requests the chat workspace overlay to be toggled.
/// </summary>
WORKSPACE_TOGGLE_OVERLAY,
//
// RAG events:
//
/// <summary>
/// Carries data sources that were automatically selected for retrieval-augmented generation.
/// </summary>
RAG_AUTO_DATA_SOURCES_SELECTED,
RAG_EMBEDDING_STATUS_CHANGED,
//
// File attachment events:
//
/// <summary>
/// Registers a file drop area for file attachment handling.
/// </summary>
REGISTER_FILE_DROP_AREA,
/// <summary>
/// Unregisters a file drop area from file attachment handling.
/// </summary>
UNREGISTER_FILE_DROP_AREA,
//
// Send events:
//
/// <summary>
/// Sends content to the grammar and spelling assistant.
/// </summary>
SEND_TO_GRAMMAR_SPELLING_ASSISTANT,
/// <summary>
/// Sends content to the icon finder assistant.
/// </summary>
SEND_TO_ICON_FINDER_ASSISTANT,
/// <summary>
/// Sends content to the rewrite assistant.
/// </summary>
SEND_TO_REWRITE_ASSISTANT,
/// <summary>
/// Sends content to the prompt optimizer assistant.
/// </summary>
SEND_TO_PROMPT_OPTIMIZER_ASSISTANT,
/// <summary>
/// Sends content to the translation assistant.
/// </summary>
SEND_TO_TRANSLATION_ASSISTANT,
/// <summary>
/// Sends content to the agenda assistant.
/// </summary>
SEND_TO_AGENDA_ASSISTANT,
/// <summary>
/// Sends content to the coding assistant.
/// </summary>
SEND_TO_CODING_ASSISTANT,
/// <summary>
/// Sends content to the text summarizer assistant.
/// </summary>
SEND_TO_TEXT_SUMMARIZER_ASSISTANT,
/// <summary>
/// Sends the result of the current assistant to the chat component.
/// </summary>
SEND_TO_CHAT,
/// <summary>
/// Sends text to the chat input field, aka the user prompt.
/// </summary>
SEND_TO_CHAT_INPUT,
/// <summary>
/// Sends content to the email assistant.
/// </summary>
SEND_TO_EMAIL_ASSISTANT,
/// <summary>
/// Sends content to the legal check assistant.
/// </summary>
SEND_TO_LEGAL_CHECK_ASSISTANT,
/// <summary>
/// Sends content to the synonym assistant.
/// </summary>
SEND_TO_SYNONYMS_ASSISTANT,
/// <summary>
/// Sends content to the "my tasks assistant".
/// </summary>
SEND_TO_MY_TASKS_ASSISTANT,
/// <summary>
/// Sends content to the job posting assistant.
/// </summary>
SEND_TO_JOB_POSTING_ASSISTANT,
/// <summary>
/// Sends content to the document analysis assistant.
/// </summary>
SEND_TO_DOCUMENT_ANALYSIS_ASSISTANT,
/// <summary>
/// Sends content to the slide builder assistant.
/// </summary>
SEND_TO_SLIDE_BUILDER_ASSISTANT
}
}
@@ -0,0 +1,81 @@
using AIStudio.Settings;
namespace AIStudio.Tools;
/// <summary>
/// Provides utility methods to standardize the management of HTTP client timeouts
/// across various components in the application.
/// </summary>
public static class ExternalHttpClientTimeout
{
public const int MIN_HTTP_CLIENT_TIMEOUT_SECONDS = 120;
public const int MAX_HTTP_CLIENT_TIMEOUT_SECONDS = 3600;
public const int DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS = 3600;
private static readonly Lazy<SettingsManager> SETTINGS_MANAGER = new(() => Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>());
public static HttpClient CreateHttpClient(Uri? baseAddress = null)
{
var httpClient = new HttpClient();
Configure(httpClient, baseAddress);
return httpClient;
}
public static string GetTimeoutDescription()
{
var timeout = GetTimeout();
if (timeout.TotalHours >= 1 && timeout.TotalMinutes % 60 == 0)
{
var hours = (int)timeout.TotalHours;
return hours == 1 ? "1 hour" : $"{hours} hours";
}
if (timeout.TotalMinutes >= 1 && timeout.TotalSeconds % 60 == 0)
{
var minutes = (int)timeout.TotalMinutes;
return minutes == 1 ? "1 minute" : $"{minutes} minutes";
}
var seconds = (int)timeout.TotalSeconds;
return seconds == 1 ? "1 second" : $"{seconds} seconds";
}
public static CancellationTokenSource CreateTimeoutTokenSource(CancellationToken cancellationToken)
{
var timeoutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutTokenSource.CancelAfter(GetTimeout());
return timeoutTokenSource;
}
public static bool IsTimeoutException(Exception exception, CancellationToken userCancellationToken = default)
{
if (userCancellationToken.IsCancellationRequested)
return false;
if (exception is TimeoutException)
return true;
if (exception is OperationCanceledException)
return true;
return exception.InnerException is not null && IsTimeoutException(exception.InnerException, userCancellationToken);
}
private static TimeSpan GetTimeout()
{
var seconds = SETTINGS_MANAGER.Value.ConfigurationData.App.HttpClientTimeoutSeconds;
if (seconds <= 0)
seconds = DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS;
seconds = Math.Clamp(seconds, MIN_HTTP_CLIENT_TIMEOUT_SECONDS, MAX_HTTP_CLIENT_TIMEOUT_SECONDS);
return TimeSpan.FromSeconds(seconds);
}
private static void Configure(HttpClient httpClient, Uri? baseAddress = null)
{
httpClient.Timeout = GetTimeout();
if (baseAddress is not null)
httpClient.BaseAddress = baseAddress;
}
}
-16
View File
@@ -1,16 +0,0 @@
namespace AIStudio.Tools;
public static class LuaTools
{
public static string EscapeLuaString(string? value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", "\\r")
.Replace("\n", "\\n");
}
}
+2 -2
View File
@@ -64,11 +64,11 @@ public sealed class MessageBus
{
foreach (var (receiver, componentFilter) in this.componentFilters)
{
if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent))
if (componentFilter.Length > 0 && message.SendingComponent is not null && !componentFilter.Contains(message.SendingComponent))
continue;
var eventFilter = this.componentEvents[receiver];
if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent))
if (eventFilter.Length == 0 || eventFilter.Contains(message.TriggeredEvent))
// We don't await the task here because we don't want to block the message bus:
_ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data);
+189 -29
View File
@@ -35,12 +35,13 @@ public static partial class Pandoc
private static bool HAS_LOGGED_AVAILABILITY_CHECK_ONCE;
private static readonly HttpClient WEB_CLIENT = new();
private static readonly SemaphoreSlim INSTALLATION_LOCK = new(1, 1);
/// <summary>
/// Prepares a Pandoc process by using the Pandoc process builder.
/// </summary>
/// <returns>The Pandoc process builder with default settings.</returns>
public static PandocProcessBuilder PreparePandocProcess() => PandocProcessBuilder.Create();
private static PandocProcessBuilder PreparePandocProcess() => PandocProcessBuilder.Create();
/// <summary>
/// Checks if pandoc is available on the system and can be started as a process or is present in AI Studio's data dir.
@@ -145,12 +146,12 @@ public static partial class Pandoc
catch (Exception e)
{
if (showMessages)
await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("It seems that Pandoc is not installed.")));
await MessageBus.INSTANCE.SendError(new(@Icons.Material.Filled.AppsOutage, TB("Pandoc doesn't seem to be installed.")));
if(shouldLog)
LOG.LogError(e, "Pandoc availability check failed. This usually means Pandoc is not installed or not in the system PATH.");
return new(false, TB("It seems that Pandoc is not installed."), false, string.Empty, false);
return new(false, TB("Pandoc doesn't seem to be installed."), false, string.Empty, false);
}
finally
{
@@ -165,76 +166,230 @@ public static partial class Pandoc
/// <returns>None</returns>
public static async Task InstallAsync(RustService rustService)
{
await INSTALLATION_LOCK.WaitAsync();
var latestVersion = await FetchLatestVersionAsync();
var installDir = await GetPandocDataFolder(rustService);
ClearFolder(installDir);
var installParentDir = Path.GetDirectoryName(installDir) ?? Path.GetTempPath();
var stagingDir = Path.Combine(installParentDir, $"pandoc-install-{Guid.NewGuid():N}");
var pandocTempDownloadFile = Path.GetTempFileName();
LOG.LogInformation("Trying to install Pandoc v{0} to '{1}'...", latestVersion, installDir);
try
{
if (!Directory.Exists(installDir))
Directory.CreateDirectory(installDir);
// Create a temporary file to download the archive to:
var pandocTempDownloadFile = Path.GetTempFileName();
if (!Directory.Exists(installParentDir))
Directory.CreateDirectory(installParentDir);
//
// Download the latest Pandoc archive from GitHub:
//
var uri = await GenerateArchiveUriAsync();
var response = await WEB_CLIENT.GetAsync(uri);
var uri = GenerateArchiveUri(latestVersion);
if (string.IsNullOrWhiteSpace(uri))
{
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive type is unknown.")));
LOG.LogError("Pandoc was not installed, no archive is available for architecture '{Architecture}'.", CPU_ARCHITECTURE.ToUserFriendlyName());
return;
}
using var response = await WEB_CLIENT.GetAsync(uri);
if (!response.IsSuccessStatusCode)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("Pandoc was not installed successfully, because the archive was not found.")));
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive was not found.")));
LOG.LogError("Pandoc was not installed successfully, because the archive was not found (status code {0}): url='{1}', message='{2}'", response.StatusCode, uri, response.RequestMessage);
return;
}
// Download the archive to the temporary file:
await using var tempFileStream = File.Create(pandocTempDownloadFile);
await response.Content.CopyToAsync(tempFileStream);
await using (var tempFileStream = File.Create(pandocTempDownloadFile))
{
await response.Content.CopyToAsync(tempFileStream);
await tempFileStream.FlushAsync();
}
Directory.CreateDirectory(stagingDir);
if (uri.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
ZipFile.ExtractToDirectory(pandocTempDownloadFile, installDir);
await RunWithRetriesAsync(
() =>
{
ZipFile.ExtractToDirectory(pandocTempDownloadFile, stagingDir, true);
return Task.CompletedTask;
},
"extracting the Pandoc ZIP archive");
}
else if (uri.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
{
await using var tgzStream = File.Open(pandocTempDownloadFile, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var uncompressedStream = new GZipStream(tgzStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(uncompressedStream, installDir, true);
await RunWithRetriesAsync(
async () =>
{
await using var tgzStream = File.Open(pandocTempDownloadFile, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var uncompressedStream = new GZipStream(tgzStream, CompressionMode.Decompress);
await TarFile.ExtractToDirectoryAsync(uncompressedStream, stagingDir, true);
},
"extracting the Pandoc TAR archive");
}
else
{
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("Pandoc was not installed successfully, because the archive type is unknown.")));
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the archive type is unknown.")));
LOG.LogError("Pandoc was not installed, the archive is unknown: url='{0}'", uri);
return;
}
File.Delete(pandocTempDownloadFile);
var stagedPandocExecutable = FindExecutableInDirectory(stagingDir, PandocProcessBuilder.PandocExecutableName);
if (string.IsNullOrWhiteSpace(stagedPandocExecutable))
{
await MessageBus.INSTANCE.SendError(new (Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc because the executable was not found in the archive.")));
LOG.LogError("Pandoc was not installed, the executable was not found in the extracted archive: '{StagingDir}'.", stagingDir);
return;
}
LOG.LogInformation("Found Pandoc executable in downloaded archive: '{Executable}'.", stagedPandocExecutable);
await ReplaceInstallationDirectoryAsync(stagingDir, installDir);
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, string.Format(TB("Pandoc v{0} was installed successfully."), latestVersion)));
LOG.LogInformation("Pandoc v{0} was installed successfully.", latestVersion);
}
catch (Exception ex)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Error, TB("AI Studio couldn't install Pandoc.")));
LOG.LogError(ex, "An error occurred while installing Pandoc.");
}
finally
{
TryDeleteFile(pandocTempDownloadFile);
if (Directory.Exists(stagingDir))
await TryDeleteFolderAsync(stagingDir);
INSTALLATION_LOCK.Release();
}
}
private static void ClearFolder(string path)
private static async Task ReplaceInstallationDirectoryAsync(string stagingDir, string installDir)
{
if (!Directory.Exists(path))
return;
var backupDir = $"{installDir}.backup-{Guid.NewGuid():N}";
var hasBackup = false;
var stagingWasMoved = false;
try
{
Directory.Delete(path, true);
if (Directory.Exists(installDir))
{
await MoveDirectoryWithRetriesAsync(installDir, backupDir, "moving the previous Pandoc installation to backup");
hasBackup = true;
}
await MoveDirectoryWithRetriesAsync(stagingDir, installDir, "moving the new Pandoc installation into place");
stagingWasMoved = true;
}
catch (Exception ex)
{
LOG.LogError(ex, "Error clearing pandoc installation directory.");
if (hasBackup && !stagingWasMoved && !Directory.Exists(installDir) && Directory.Exists(backupDir))
{
try
{
await MoveDirectoryWithRetriesAsync(backupDir, installDir, "restoring the previous Pandoc installation");
hasBackup = false;
}
catch (Exception rollbackEx)
{
LOG.LogError(rollbackEx, "Error restoring previous Pandoc installation directory. Keeping backup directory at: '{BackupDir}'.", backupDir);
}
}
LOG.LogError(ex, "Error replacing pandoc installation directory.");
throw;
}
finally
{
if (hasBackup && stagingWasMoved && Directory.Exists(backupDir))
await TryDeleteFolderAsync(backupDir);
}
}
private static string FindExecutableInDirectory(string rootDirectory, string executableName)
{
if (!Directory.Exists(rootDirectory))
return string.Empty;
var rootExecutablePath = Path.Combine(rootDirectory, executableName);
if (File.Exists(rootExecutablePath))
return rootExecutablePath;
foreach (var subdirectory in Directory.GetDirectories(rootDirectory, "*", SearchOption.AllDirectories))
{
var pandocPath = Path.Combine(subdirectory, executableName);
if (File.Exists(pandocPath))
return pandocPath;
}
return string.Empty;
}
private static async Task MoveDirectoryWithRetriesAsync(string sourceDir, string destinationDir, string operationName)
{
await RunWithRetriesAsync(
() =>
{
Directory.Move(sourceDir, destinationDir);
return Task.CompletedTask;
},
operationName,
maxAttempts: 8);
}
private static async Task RunWithRetriesAsync(Func<Task> operation, string operationName, int maxAttempts = 4)
{
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
await operation();
return;
}
catch (Exception ex) when (attempt < maxAttempts && ex is IOException or UnauthorizedAccessException)
{
LOG.LogWarning(ex, "Error while {OperationName}; retrying attempt {Attempt}/{MaxAttempts}.", operationName, attempt + 1, maxAttempts);
await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt));
}
}
}
private static void TryDeleteFile(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return;
try
{
File.Delete(path);
}
catch (Exception ex)
{
LOG.LogWarning(ex, "Was not able to delete temporary Pandoc archive: '{Path}'.", path);
}
}
private static async Task TryDeleteFolderAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
return;
try
{
await RunWithRetriesAsync(
() =>
{
Directory.Delete(path, true);
return Task.CompletedTask;
},
$"deleting temporary Pandoc directory '{path}'",
maxAttempts: 3);
}
catch (Exception ex)
{
LOG.LogWarning(ex, "Was not able to delete temporary Pandoc directory: '{Path}'.", path);
}
}
@@ -248,7 +403,7 @@ public static partial class Pandoc
if (!response.IsSuccessStatusCode)
{
LOG.LogError("Code {StatusCode}: Could not fetch Pandoc's latest page: {Response}", response.StatusCode, response.RequestMessage);
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("The latest Pandoc version was not found, installing version {0} instead."), FALLBACK_VERSION.ToString())));
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("AI Studio couldn't find the latest Pandoc version and will install version {0} instead."), FALLBACK_VERSION.ToString())));
return FALLBACK_VERSION.ToString();
}
@@ -257,7 +412,7 @@ public static partial class Pandoc
if (!versionMatch.Success)
{
LOG.LogError("The latest version regex returned nothing: {0}", versionMatch.Groups.ToString());
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("The latest Pandoc version was not found, installing version {0} instead."), FALLBACK_VERSION.ToString())));
await MessageBus.INSTANCE.SendWarning(new (Icons.Material.Filled.Warning, string.Format(TB("AI Studio couldn't find the latest Pandoc version and will install version {0} instead."), FALLBACK_VERSION.ToString())));
return FALLBACK_VERSION.ToString();
}
@@ -272,6 +427,11 @@ public static partial class Pandoc
public static async Task<string> GenerateArchiveUriAsync()
{
var version = await FetchLatestVersionAsync();
return GenerateArchiveUri(version);
}
private static string GenerateArchiveUri(string version)
{
var baseUri = $"{DOWNLOAD_URL}/{version}/pandoc-{version}-";
return CPU_ARCHITECTURE switch
{
@@ -220,6 +220,17 @@ public sealed class PandocProcessBuilder
}
}
foreach (var candidate in SystemPandocExecutableCandidates(PandocExecutableName))
{
if (!File.Exists(candidate))
continue;
if (shouldLog)
LOGGER.LogInformation("Found system Pandoc installation at: '{Path}'.", candidate);
return new(candidate, false);
}
//
// When no local installation was found, we assume that the pandoc executable is in the system PATH:
//
@@ -238,4 +249,59 @@ public sealed class PandocProcessBuilder
/// Reads the os platform to determine the used executable name.
/// </summary>
public static string PandocExecutableName => CPU_ARCHITECTURE is RID.WIN_ARM64 or RID.WIN_X64 ? "pandoc.exe" : "pandoc";
private static IEnumerable<string> SystemPandocExecutableCandidates(string executableName)
{
var candidates = new List<string>();
switch (CPU_ARCHITECTURE)
{
case RID.WIN_X64 or RID.WIN_ARM64:
AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Pandoc", executableName);
AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Pandoc", executableName);
AddCandidate(candidates, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Pandoc", executableName);
break;
case RID.OSX_X64 or RID.OSX_ARM64:
AddCandidate(candidates, "/opt/homebrew/bin", executableName);
AddCandidate(candidates, "/usr/local/bin", executableName);
AddCandidate(candidates, "/usr/bin", executableName);
break;
case RID.LINUX_X64 or RID.LINUX_ARM64:
AddCandidate(candidates, "/usr/local/bin", executableName);
AddCandidate(candidates, "/usr/bin", executableName);
AddCandidate(candidates, "/snap/bin", executableName);
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
AddCandidate(candidates, homeDirectory, ".local", "bin", executableName);
break;
}
foreach (var pathDirectory in GetPathDirectories())
AddCandidate(candidates, pathDirectory, executableName);
var comparer = CPU_ARCHITECTURE is RID.WIN_X64 or RID.WIN_ARM64
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
return candidates.Distinct(comparer);
}
private static IEnumerable<string> GetPathDirectories()
{
var pathValue = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(pathValue))
yield break;
foreach (var pathDirectory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
yield return pathDirectory;
}
private static void AddCandidate(List<string> candidates, params string[] pathParts)
{
if (pathParts.Any(string.IsNullOrWhiteSpace))
return;
candidates.Add(Path.Combine(pathParts));
}
}
@@ -13,37 +13,4 @@ public sealed record PendingEnterpriseApiKey(
string SecretId,
string SecretName,
string ApiKey,
SecretStoreType StoreType);
/// <summary>
/// Static container for pending API keys during plugin loading.
/// </summary>
public static class PendingEnterpriseApiKeys
{
private static readonly List<PendingEnterpriseApiKey> PENDING_KEYS = [];
private static readonly Lock LOCK = new();
/// <summary>
/// Adds a pending API key to the list.
/// </summary>
/// <param name="key">The pending API key to add.</param>
public static void Add(PendingEnterpriseApiKey key)
{
lock (LOCK)
PENDING_KEYS.Add(key);
}
/// <summary>
/// Gets and clears all pending API keys.
/// </summary>
/// <returns>A list of all pending API keys.</returns>
public static IReadOnlyList<PendingEnterpriseApiKey> GetAndClear()
{
lock (LOCK)
{
var keys = PENDING_KEYS.ToList();
PENDING_KEYS.Clear();
return keys;
}
}
}
SecretStoreType StoreType);
@@ -0,0 +1,34 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Static container for pending API keys during plugin loading.
/// </summary>
public static class PendingEnterpriseApiKeys
{
private static readonly List<PendingEnterpriseApiKey> PENDING_KEYS = [];
private static readonly Lock LOCK = new();
/// <summary>
/// Adds a pending API key to the list.
/// </summary>
/// <param name="key">The pending API key to add.</param>
public static void Add(PendingEnterpriseApiKey key)
{
lock (LOCK)
PENDING_KEYS.Add(key);
}
/// <summary>
/// Gets and clears all pending API keys.
/// </summary>
/// <returns>A list of all pending API keys.</returns>
public static IReadOnlyList<PendingEnterpriseApiKey> GetAndClear()
{
lock (LOCK)
{
var keys = PENDING_KEYS.ToList();
PENDING_KEYS.Clear();
return keys;
}
}
}
@@ -0,0 +1,14 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Represents a pending enterprise secret that needs to be stored in the OS keyring.
/// </summary>
/// <param name="SecretId">The secret ID.</param>
/// <param name="SecretName">The secret name.</param>
/// <param name="SecretData">The decrypted secret data.</param>
/// <param name="StoreType">The type of secret store to use.</param>
public sealed record PendingEnterpriseSecret(
string SecretId,
string SecretName,
string SecretData,
SecretStoreType StoreType);
@@ -0,0 +1,34 @@
namespace AIStudio.Tools.PluginSystem;
/// <summary>
/// Static container for pending enterprise secrets during plugin loading.
/// </summary>
public static class PendingEnterpriseSecrets
{
private static readonly List<PendingEnterpriseSecret> PENDING_SECRETS = [];
private static readonly Lock LOCK = new();
/// <summary>
/// Adds a pending enterprise secret to the list.
/// </summary>
/// <param name="secret">The pending enterprise secret to add.</param>
public static void Add(PendingEnterpriseSecret secret)
{
lock (LOCK)
PENDING_SECRETS.Add(secret);
}
/// <summary>
/// Gets and clears all pending enterprise secrets.
/// </summary>
/// <returns>A list of all pending enterprise secrets.</returns>
public static IReadOnlyList<PendingEnterpriseSecret> GetAndClear()
{
lock (LOCK)
{
var secrets = PENDING_SECRETS.ToList();
PENDING_SECRETS.Clear();
return secrets;
}
}
}
@@ -41,12 +41,43 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Store any decrypted API keys from enterprise configuration in the OS keyring:
await StoreEnterpriseApiKeysAsync();
await StoreEnterpriseSecretsAsync();
await SETTINGS_MANAGER.StoreSettings();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
}
}
/// <summary>
/// Stores any pending enterprise secrets in the OS keyring.
/// </summary>
private static async Task StoreEnterpriseSecretsAsync()
{
var pendingSecrets = PendingEnterpriseSecrets.GetAndClear();
if (pendingSecrets.Count == 0)
return;
LOG.LogInformation($"Storing {pendingSecrets.Count} enterprise secret(s) in the OS keyring.");
var rustService = Program.SERVICE_PROVIDER.GetRequiredService<RustService>();
foreach (var pendingSecret in pendingSecrets)
{
try
{
var secretId = new TemporarySecretId(pendingSecret.SecretId, pendingSecret.SecretName);
var result = await rustService.SetSecret(secretId, pendingSecret.SecretData, pendingSecret.StoreType);
if (result.Success)
LOG.LogDebug($"Successfully stored enterprise secret for '{pendingSecret.SecretName}' in the OS keyring.");
else
LOG.LogWarning($"Failed to store enterprise secret for '{pendingSecret.SecretName}': {result.Issue}");
}
catch (Exception ex)
{
LOG.LogError(ex, $"Exception while storing enterprise secret for '{pendingSecret.SecretName}'.");
}
}
}
/// <summary>
/// Stores any pending enterprise API keys in the OS keyring.
/// </summary>
@@ -142,6 +173,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
// Config: global voice recording shortcut
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.ShortcutVoiceRecording, this.Id, settingsTable, dryRun);
// Config: timeout for external HTTP requests
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.HttpClientTimeoutSeconds, this.Id, settingsTable, dryRun);
// Handle configured LLM providers:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.LLM_PROVIDER, x => x.Providers, x => x.NextProviderNum, mainTable, this.Id, ref this.configObjects, dryRun);
@@ -153,7 +187,10 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, x => x.NextEmbeddingNum, mainTable, this.Id, ref this.configObjects, dryRun);
// Handle configured chat templates:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun);
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, x => x.NextChatTemplateNum, mainTable, this.Id, ref this.configObjects, dryRun, this.PluginPath);
// Handle configured data sources:
PluginConfigurationObject.TryParseDataSources(mainTable, this.Id, ref this.configObjects, dryRun);
// Handle configured profiles:
PluginConfigurationObject.TryParse(PluginConfigurationObjectType.PROFILE, x => x.Profiles, x => x.NextProfileNum, mainTable, this.Id, ref this.configObjects, dryRun);
@@ -53,6 +53,7 @@ public sealed record PluginConfigurationObject
/// This parameter is passed by reference.</param>
/// <param name="dryRun">Specifies whether to perform the operation as a dry run, where changes
/// are not persisted.</param>
/// <param name="pluginPath">An optional parameter specifying the file path of the plugin, used for relative paths in the Lua table.</param>
/// <returns>Returns true if parsing succeeds and configuration objects are added
/// to the list; otherwise, false.</returns>
public static bool TryParse<TClass>(
@@ -62,7 +63,8 @@ public sealed record PluginConfigurationObject
LuaTable mainTable,
Guid configPluginId,
ref List<PluginConfigurationObject> configObjects,
bool dryRun
bool dryRun,
string pluginPath = ""
) where TClass : ConfigurationBaseObject
{
var luaTableName = configObjectType switch
@@ -105,7 +107,7 @@ public sealed record PluginConfigurationObject
var (wasParsingSuccessful, configObject) = configObjectType switch
{
PluginConfigurationObjectType.LLM_PROVIDER => (Settings.Provider.TryParseProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Settings.Provider.NONE, configurationObject),
PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject),
PluginConfigurationObjectType.CHAT_TEMPLATE => (ChatTemplate.TryParseChatTemplateTable(i, luaObjectTable, configPluginId, pluginPath, out var configurationObject) && configurationObject != ChatTemplate.NO_CHAT_TEMPLATE, configurationObject),
PluginConfigurationObjectType.PROFILE => (Profile.TryParseProfileTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != Profile.NO_PROFILE, configurationObject),
PluginConfigurationObjectType.TRANSCRIPTION_PROVIDER => (TranscriptionProvider.TryParseTranscriptionProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != TranscriptionProvider.NONE, configurationObject),
PluginConfigurationObjectType.EMBEDDING_PROVIDER => (EmbeddingProvider.TryParseEmbeddingProviderTable(i, luaObjectTable, configPluginId, out var configurationObject) && configurationObject != EmbeddingProvider.NONE, configurationObject),
@@ -199,6 +201,87 @@ public sealed record PluginConfigurationObject
return wasConfigurationChanged;
}
/// <summary>
/// Parses configured data sources from a configuration plugin.
/// </summary>
/// <param name="mainTable">The Lua table containing entries to parse into data sources.</param>
/// <param name="configPluginId">The unique identifier of the plugin associated with the data sources.</param>
/// <param name="configObjects">The list to populate with the parsed configuration objects.</param>
/// <param name="dryRun">Specifies whether to perform the operation as a dry run.</param>
/// <returns>True if the table was present and processed; otherwise false.</returns>
public static bool TryParseDataSources(
LuaTable mainTable,
Guid configPluginId,
ref List<PluginConfigurationObject> configObjects,
bool dryRun)
{
const string LUA_TABLE_NAME = "DATA_SOURCES";
if (!mainTable.TryGetValue(LUA_TABLE_NAME, out var luaValue) || !luaValue.TryRead<LuaTable>(out var luaTable))
{
LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, configPluginId);
return false;
}
var storedObjects = SETTINGS_MANAGER.ConfigurationData.DataSources;
var numberObjects = luaTable.ArrayLength;
ThreadSafeRandom? random = null;
for (var i = 1; i <= numberObjects; i++)
{
var luaObjectTableValue = luaTable[i];
if (!luaObjectTableValue.TryRead<LuaTable>(out var luaObjectTable))
{
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId);
continue;
}
if (!DataSourceERI_V1.TryParseConfiguration(i, luaObjectTable, configPluginId, out var configObject))
{
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid data source (config plugin id: {ConfigPluginId}).", LUA_TABLE_NAME, i, configPluginId);
continue;
}
configObjects.Add(new()
{
ConfigPluginId = configPluginId,
Id = Guid.Parse(configObject.Id),
Type = PluginConfigurationObjectType.DATA_SOURCE,
});
if (dryRun)
continue;
var objectIndex = storedObjects.FindIndex(t => t.Id == configObject.Id);
if (objectIndex > -1)
{
var existingObject = storedObjects[objectIndex];
configObject = configObject with { Num = existingObject.Num };
storedObjects[objectIndex] = configObject;
}
else
{
if (IncrementDataSourceNum() is { Success: true, UpdatedValue: var nextNum })
{
configObject = configObject with { Num = nextNum };
storedObjects.Add(configObject);
}
else
{
random ??= new ThreadSafeRandom();
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
storedObjects.Add(configObject);
LOG.LogWarning("The next number for the data source '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId);
}
}
}
return true;
static IncrementResult<uint> IncrementDataSourceNum()
{
return ((Expression<Func<Data, uint>>)(x => x.NextDataSourceNum)).TryIncrement(SETTINGS_MANAGER.ConfigurationData, IncrementType.POST);
}
}
/// <summary>
/// Cleans up configuration objects of a specified type that are no longer associated with any available plugin.
/// </summary>
@@ -208,13 +291,15 @@ public sealed record PluginConfigurationObject
/// <param name="availablePlugins">A list of currently available plugins.</param>
/// <param name="configObjectList">A list of all existing configuration objects.</param>
/// <param name="secretStoreType">An optional parameter specifying the type of secret store to use for deleting associated API keys from the OS keyring, if applicable.</param>
/// <param name="deleteSecret">When true, delete the associated non-API-key secret from the OS keyring.</param>
/// <returns>Returns true if the configuration was altered during cleanup; otherwise, false.</returns>
public static async Task<bool> CleanLeftOverConfigurationObjects<TClass>(
PluginConfigurationObjectType configObjectType,
Expression<Func<Data, List<TClass>>> configObjectSelection,
IList<IAvailablePlugin> availablePlugins,
IList<PluginConfigurationObject> configObjectList,
SecretStoreType? secretStoreType = null) where TClass : IConfigurationObject
SecretStoreType? secretStoreType = null,
bool deleteSecret = false) where TClass : IConfigurationObject
{
var configuredObjects = configObjectSelection.Compile()(SETTINGS_MANAGER.ConfigurationData);
var leftOverObjects = new List<TClass>();
@@ -270,7 +355,15 @@ public sealed record PluginConfigurationObject
configuredObjects.Remove(item);
// Delete the API key from the OS keyring if the removed object has one:
if(secretStoreType is not null && item is ISecretId secretId)
if(deleteSecret && item is ISecretId regularSecretId)
{
var deleteResult = await RUST_SERVICE.DeleteSecret(regularSecretId, secretStoreType ?? SecretStoreType.DATA_SOURCE);
if (deleteResult.Success)
LOG.LogInformation($"Successfully deleted secret for removed enterprise object '{item.Name}' from the OS keyring.");
else
LOG.LogWarning($"Failed to delete secret for removed enterprise object '{item.Name}' from the OS keyring: {deleteResult.Issue}");
}
else if(secretStoreType is not null && item is ISecretId secretId)
{
var deleteResult = await RUST_SERVICE.DeleteAPIKey(secretId, secretStoreType.Value);
if (deleteResult.Success)
@@ -15,7 +15,7 @@ public static partial class PluginFactory
var serverUrl = configServerUrl.EndsWith('/') ? configServerUrl[..^1] : configServerUrl;
var downloadUrl = $"{serverUrl}/{configPlugId}.zip";
using var http = new HttpClient();
using var http = ExternalHttpClientTimeout.CreateHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -52,7 +52,7 @@ public static partial class PluginFactory
try
{
await LockHotReloadAsync();
using var httpClient = new HttpClient();
using var httpClient = ExternalHttpClientTimeout.CreateHttpClient();
var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -174,6 +174,10 @@ public static partial class PluginFactory
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.EMBEDDING_PROVIDER, x => x.EmbeddingProviders, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.EMBEDDING_PROVIDER))
wasConfigurationChanged = true;
// Check data sources:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.DATA_SOURCE, x => x.DataSources, AVAILABLE_PLUGINS, configObjectList, SecretStoreType.DATA_SOURCE, deleteSecret: true))
wasConfigurationChanged = true;
// Check chat templates:
if(await PluginConfigurationObject.CleanLeftOverConfigurationObjects(PluginConfigurationObjectType.CHAT_TEMPLATE, x => x.ChatTemplates, AVAILABLE_PLUGINS, configObjectList))
wasConfigurationChanged = true;
@@ -241,6 +245,10 @@ public static partial class PluginFactory
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.ShortcutVoiceRecording, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check for the external HTTP client timeout:
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.App, x => x.HttpClientTimeoutSeconds, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
// Check if audit is required before it can be activated
if(ManagedConfiguration.IsConfigurationLeftOver(x => x.AssistantPluginAudit, x => x.RequireAuditBeforeActivation, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
@@ -318,7 +326,11 @@ public static partial class PluginFactory
return new PluginLanguage(isInternal, state, type);
case PluginType.CONFIGURATION:
var configPlug = new PluginConfiguration(isInternal, state, type);
var configPlug = new PluginConfiguration(isInternal, state, type)
{
PluginPath = pluginPath ?? string.Empty
};
await configPlug.InitializeAsync(true);
return configPlug;
@@ -9,7 +9,7 @@ namespace AIStudio.Tools.Rust;
/// </summary>
public static class FileTypes
{
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypeFilter).Namespace, nameof(FileTypeFilter));
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypes).Namespace, nameof(FileTypes));
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.Leaf(TB("Source like"),
"Dockerfile", "Containerfile", "Jenkinsfile", "Makefile", "GNUmakefile", "Procfile", "Vagrantfile",
@@ -45,7 +45,7 @@ public static class FileTypes
// Document hierarchy
public static readonly FileTypeFilter PDF = FileTypeFilter.Leaf("PDF", "pdf");
public static readonly FileTypeFilter TEXT = FileTypeFilter.Leaf(TB("Text"), "txt", "md", "rtf");
public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx", "doc");
public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx");
public static readonly FileTypeFilter WORD = FileTypeFilter.Composite("Word", ["odt"], MS_WORD);
public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx");
public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "ppt", "pptx");
@@ -5,6 +5,8 @@
/// </summary>
public readonly record struct QdrantInfo
{
public QdrantStatus Status { get; init; }
public bool IsAvailable { get; init; }
public string? UnavailableReason { get; init; }
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.Rust;
public enum QdrantStatus
{
STARTING,
AVAILABLE,
UNAVAILABLE,
}
@@ -1,10 +1,10 @@
namespace AIStudio.Tools;
/// <summary>
/// Represents the type of secret store used for API keys.
/// Represents the type of secret store used for API keys and other secrets.
/// </summary>
/// <remarks>
/// Different provider types use different prefixes for storing API keys.
/// Different provider and secret types use different prefixes for storing secrets.
/// This prevents collisions when the same instance name is used across
/// different provider types (e.g., LLM, Embedding, Transcription).
/// </remarks>
@@ -29,4 +29,9 @@ public enum SecretStoreType
/// Image provider secrets. Uses the "image::" prefix.
/// </summary>
IMAGE_PROVIDER,
}
/// <summary>
/// Data source secrets. Uses the "data-source::" prefix.
/// </summary>
DATA_SOURCE,
}
@@ -9,12 +9,14 @@ public static class SecretStoreTypeExtensions
/// LLM_PROVIDER uses the legacy "provider" prefix for backward compatibility.
/// </remarks>
/// <param name="type">The SecretStoreType enum value.</param>
/// <returns>>The corresponding prefix string.</returns>
/// <returns>The corresponding prefix string.</returns>
public static string Prefix(this SecretStoreType type) => type switch
{
SecretStoreType.LLM_PROVIDER => "provider",
SecretStoreType.EMBEDDING_PROVIDER => "embedding",
SecretStoreType.TRANSCRIPTION_PROVIDER => "transcription",
SecretStoreType.IMAGE_PROVIDER => "image",
SecretStoreType.DATA_SOURCE => "data-source",
_ => "provider",
};
@@ -200,7 +200,7 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
{
logger.LogInformation("The enterprise encryption secret changed. Refreshing the enterprise encryption service and reloading plugins.");
PluginFactory.InitializeEnterpriseEncryption(enterpriseEncryptionSecret);
await this.RemoveEnterpriseManagedApiKeysAsync();
await this.RemoveEnterpriseManagedSecretsAsync();
await PluginFactory.LoadAll();
}
@@ -249,34 +249,36 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
return serverUrl.Trim().TrimEnd('/');
}
private async Task RemoveEnterpriseManagedApiKeysAsync()
private async Task RemoveEnterpriseManagedSecretsAsync()
{
var secretTargets = GetEnterpriseManagedSecretTargets();
if (secretTargets.Count == 0)
{
logger.LogInformation("No enterprise-managed API keys are currently known in the settings. No keyring cleanup is required.");
logger.LogInformation("No enterprise-managed secrets are currently known in the settings. No keyring cleanup is required.");
return;
}
logger.LogInformation("Removing {SecretCount} enterprise-managed API key(s) from the OS keyring after an enterprise encryption secret change.", secretTargets.Count);
logger.LogInformation("Removing {SecretCount} enterprise-managed secret(s) from the OS keyring after an enterprise encryption secret change.", secretTargets.Count);
foreach (var target in secretTargets)
{
try
{
var deleteResult = await rustService.DeleteAPIKey(target, target.StoreType);
var deleteResult = target.StoreType is SecretStoreType.DATA_SOURCE
? await rustService.DeleteSecret(target, target.StoreType)
: await rustService.DeleteAPIKey(target, target.StoreType);
if (deleteResult.Success)
{
if (deleteResult.WasEntryFound)
logger.LogInformation("Successfully deleted enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName);
logger.LogInformation("Successfully deleted enterprise-managed secret '{SecretName}' from the OS keyring.", target.SecretName);
else
logger.LogInformation("Enterprise-managed API key '{SecretName}' was already absent from the OS keyring.", target.SecretName);
logger.LogInformation("Enterprise-managed secret '{SecretName}' was already absent from the OS keyring.", target.SecretName);
}
else
logger.LogWarning("Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring: {Issue}", target.SecretName, deleteResult.Issue);
logger.LogWarning("Failed to delete enterprise-managed secret '{SecretName}' from the OS keyring: {Issue}", target.SecretName, deleteResult.Issue);
}
catch (Exception e)
{
logger.LogWarning(e, "Failed to delete enterprise-managed API key '{SecretName}' from the OS keyring.", target.SecretName);
logger.LogWarning(e, "Failed to delete enterprise-managed secret '{SecretName}' from the OS keyring.", target.SecretName);
}
}
}
@@ -289,6 +291,7 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
AddEnterpriseManagedSecretTargets(configurationData.Providers, SecretStoreType.LLM_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.EmbeddingProviders, SecretStoreType.EMBEDDING_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.TranscriptionProviders, SecretStoreType.TRANSCRIPTION_PROVIDER, secretTargets);
AddEnterpriseManagedSecretTargets(configurationData.DataSources.OfType<IExternalDataSource>(), SecretStoreType.DATA_SOURCE, secretTargets);
return secretTargets.ToList();
}
@@ -185,9 +185,7 @@ public sealed class GlobalShortcutService : BackgroundService, IMessageBusReceiv
return new(shortcut, isEnabled, false);
var fallbackShortcut = settingsSnapshot.App.ShortcutVoiceRecording;
var fallbackEnabled =
settingsSnapshot.App.EnabledPreviewFeatures.Contains(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026) &&
!string.IsNullOrWhiteSpace(settingsSnapshot.App.UseTranscriptionProvider);
var fallbackEnabled = !string.IsNullOrWhiteSpace(settingsSnapshot.App.UseTranscriptionProvider);
if (!fallbackEnabled || string.IsNullOrWhiteSpace(fallbackShortcut))
return new(shortcut, isEnabled, false);
@@ -5,49 +5,27 @@ namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
public async Task<EmbeddingStoreConfiguration> GetEmbeddingStoreConfiguration(EmbeddingStoreKind kind)
{
switch (kind)
{
case EmbeddingStoreKind.QDRANT_REMOTE:
{
var qdrantInfo = await this.GetQdrantInfo();
var invalidFields = new List<string>();
if (!qdrantInfo.IsAvailable)
invalidFields.Add(qdrantInfo.UnavailableReason ?? "unknown");
if (string.IsNullOrWhiteSpace(qdrantInfo.Path))
invalidFields.Add("Path");
if (qdrantInfo.PortHttp == 0)
invalidFields.Add("HttpPort");
if (qdrantInfo.PortGrpc == 0)
invalidFields.Add("GrpcPort");
if (string.IsNullOrWhiteSpace(qdrantInfo.Fingerprint))
invalidFields.Add("Fingerprint");
if (string.IsNullOrWhiteSpace(qdrantInfo.ApiToken))
invalidFields.Add("ApiToken");
if (invalidFields.Count <= 0) return new EmbeddingStoreConfiguration(kind, "Qdrant", new RemoteLocation(qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken), null);
var reason = string.Join(", ", invalidFields);
Console.WriteLine($"Warning: Qdrant is not available. Starting without vector database. Reason: '{reason}'.");
return new EmbeddingStoreConfiguration(
EmbeddingStoreKind.NONE,
"Qdrant",
null,
reason);
}
default:
return new EmbeddingStoreConfiguration(kind, kind.ToString(), null, $"No configuration available for {kind}");
}
}
public async Task<QdrantInfo> GetQdrantInfo()
public async Task<QdrantInfo> GetQdrantInfo(CancellationToken cancellationToken = default)
{
try
{
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var response = await this.http.GetFromJsonAsync<QdrantInfo>("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token);
return response;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(45));
return await this.http.GetFromJsonAsync<QdrantInfo>("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if(this.logger is not null)
this.logger.LogWarning("Fetching Qdrant info from Rust service was cancelled by caller.");
else
Console.WriteLine("Fetching Qdrant info from Rust service was cancelled by caller.");
return new QdrantInfo
{
Status = QdrantStatus.UNAVAILABLE,
UnavailableReason = "Operation cancelled by caller."
};
}
catch (Exception e)
{
@@ -56,7 +34,11 @@ public sealed partial class RustService
else
Console.WriteLine($"Error while fetching Qdrant info from Rust service: '{e}'.");
return default;
return new QdrantInfo
{
Status = QdrantStatus.UNAVAILABLE,
UnavailableReason = e.Message
};
}
}
}
@@ -6,8 +6,11 @@ public sealed partial class RustService
{
public async Task<DirectorySelectionResponse> SelectDirectory(string title, string? initialDirectory = null)
{
PreviousDirectory? previousDirectory = initialDirectory is null ? null : new (initialDirectory);
var result = await this.http.PostAsJsonAsync($"/select/directory?title={title}", previousDirectory, this.jsonRustSerializerOptions);
var encodedTitle = Uri.EscapeDataString(title);
var result = initialDirectory is null
? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null)
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to select a directory: '{result.StatusCode}'");
@@ -32,4 +32,35 @@ public sealed partial class RustService
this.userLanguageLock.Release();
}
}
public async Task<string> ReadUserName(bool forceRequest = false)
{
if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName))
return this.cachedUserName;
await this.userNameLock.WaitAsync();
try
{
if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserName))
return this.cachedUserName;
var response = await this.http.GetAsync("/system/username");
if (!response.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to read the user name from Rust: '{response.StatusCode}'");
return string.Empty;
}
var userName = (await response.Content.ReadAsStringAsync()).Trim();
if (string.IsNullOrWhiteSpace(userName))
return string.Empty;
this.cachedUserName = userName;
return userName;
}
finally
{
this.userNameLock.Release();
}
}
}
@@ -14,7 +14,16 @@ public sealed partial class RustService
var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
this.logger?.LogError(
"Failed to read arbitrary file data from Rust runtime. Status: {StatusCode}, reason: '{ReasonPhrase}', path: '{Path}', body: '{Body}'",
response.StatusCode,
response.ReasonPhrase,
path,
responseBody);
return string.Empty;
}
var resultBuilder = new StringBuilder();
@@ -4,26 +4,34 @@ namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
private static string SecretKey(ISecretId secretId, SecretStoreType storeType) => $"{storeType.Prefix()}::{secretId.SecretId}::{secretId.SecretName}";
private static string LegacySecretKey(ISecretId secretId) => $"secret::{secretId.SecretId}::{secretId.SecretName}";
/// <summary>
/// Try to get the secret data for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to get the data for.</param>
/// <param name="storeType">The secret store type.</param>
/// <param name="isTrying">Indicates if we are trying to get the data. In that case, we don't log errors.</param>
/// <returns>The requested secret.</returns>
public async Task<RequestedSecret> GetSecret(ISecretId secretId, bool isTrying = false)
public async Task<RequestedSecret> GetSecret(ISecretId secretId, SecretStoreType storeType, bool isTrying = false)
{
var secretRequest = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
var secretKey = SecretKey(secretId, storeType);
var secret = await this.GetSecretByKey(secretKey, isTrying || storeType is SecretStoreType.DATA_SOURCE);
if (secret.Success || storeType is not SecretStoreType.DATA_SOURCE)
return secret;
var legacySecretKey = LegacySecretKey(secretId);
var legacySecret = await this.GetSecretByKey(legacySecretKey, isTrying: true);
if (legacySecret.Success)
{
if(!isTrying)
this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue."));
this.logger!.LogDebug($"Successfully retrieved the legacy data source secret for '{legacySecretKey}'.");
return legacySecret;
}
var secret = await result.Content.ReadFromJsonAsync<RequestedSecret>(this.jsonRustSerializerOptions);
if (!secret.Success && !isTrying)
this.logger!.LogError($"Failed to get the secret data for secret ID '{secretId.SecretId}': '{secret.Issue}'");
this.logger!.LogError($"Failed to get the secret data for '{secretKey}': '{secret.Issue}'");
return secret;
}
@@ -33,21 +41,26 @@ public sealed partial class RustService
/// </summary>
/// <param name="secretId">The secret ID to store the data for.</param>
/// <param name="secretData">The data to store.</param>
/// <param name="storeType">The secret store type.</param>
/// <returns>The store secret response.</returns>
public async Task<StoreSecretResponse> SetSecret(ISecretId secretId, string secretData)
public async Task<StoreSecretResponse> SetSecret(ISecretId secretId, string secretData, SecretStoreType storeType)
{
var secretKey = SecretKey(secretId, storeType);
var encryptedSecret = await this.encryptor!.Encrypt(secretData);
var request = new StoreSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, encryptedSecret);
var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedSecret);
var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
return new StoreSecretResponse(false, TB("Failed to get the secret data due to an API issue."));
this.logger!.LogError($"Failed to store the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'");
return new StoreSecretResponse(false, TB("Failed to store the secret data due to an API issue."));
}
var state = await result.Content.ReadFromJsonAsync<StoreSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to store the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'");
this.logger!.LogError($"Failed to store the secret data for '{secretKey}': '{state.Issue}'");
if (state.Success && storeType is SecretStoreType.DATA_SOURCE)
await this.DeleteSecretByKey(LegacySecretKey(secretId));
return state;
}
@@ -56,20 +69,48 @@ public sealed partial class RustService
/// Tries to delete the secret data for the given secret ID.
/// </summary>
/// <param name="secretId">The secret ID to delete the data for.</param>
/// <param name="storeType">The secret store type.</param>
/// <returns>The delete secret response.</returns>
public async Task<DeleteSecretResponse> DeleteSecret(ISecretId secretId)
public async Task<DeleteSecretResponse> DeleteSecret(ISecretId secretId, SecretStoreType storeType)
{
var request = new SelectSecretRequest($"secret::{secretId.SecretId}::{secretId.SecretName}", Environment.UserName, false);
var deleteResult = await this.DeleteSecretByKey(SecretKey(secretId, storeType));
if (storeType is not SecretStoreType.DATA_SOURCE || !deleteResult.Success)
return deleteResult;
var legacyDeleteResult = await this.DeleteSecretByKey(LegacySecretKey(secretId));
if (!legacyDeleteResult.Success)
return legacyDeleteResult;
return deleteResult with { WasEntryFound = deleteResult.WasEntryFound || legacyDeleteResult.WasEntryFound };
}
private async Task<RequestedSecret> GetSecretByKey(string secretKey, bool isTrying)
{
var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying);
var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
if(!isTrying)
this.logger!.LogError($"Failed to get the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'");
return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue."));
}
return await result.Content.ReadFromJsonAsync<RequestedSecret>(this.jsonRustSerializerOptions);
}
private async Task<DeleteSecretResponse> DeleteSecretByKey(string secretKey)
{
var request = new SelectSecretRequest(secretKey, Environment.UserName, false);
var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions);
if (!result.IsSuccessStatusCode)
{
this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}' due to an API issue: '{result.StatusCode}'");
this.logger!.LogError($"Failed to delete the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'");
return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the secret data due to an API issue.")};
}
var state = await result.Content.ReadFromJsonAsync<DeleteSecretResponse>(this.jsonRustSerializerOptions);
if (!state.Success)
this.logger!.LogError($"Failed to delete the secret data for secret ID '{secretId.SecretId}': '{state.Issue}'");
this.logger!.LogError($"Failed to delete the secret data for '{secretKey}': '{state.Issue}'");
return state;
}
@@ -18,6 +18,7 @@ public sealed partial class RustService : BackgroundService
private readonly HttpClient http;
private readonly SemaphoreSlim userLanguageLock = new(1, 1);
private readonly SemaphoreSlim userNameLock = new(1, 1);
private readonly JsonSerializerOptions jsonRustSerializerOptions = new()
{
@@ -31,6 +32,7 @@ public sealed partial class RustService : BackgroundService
private ILogger<RustService>? logger;
private Encryption? encryptor;
private string? cachedUserLanguage;
private string? cachedUserName;
private readonly string apiPort;
private readonly string certificateFingerprint;
@@ -92,6 +94,7 @@ public sealed partial class RustService : BackgroundService
this.http.Dispose();
this.userLanguageLock.Dispose();
this.tokenizerLock.Dispose();
this.userNameLock.Dispose();
base.Dispose();
}
@@ -43,6 +43,14 @@ public static class FileExtensionValidation
/// <returns>True if valid, false if invalid (error/warning already sent via MessageBus).</returns>
public static async Task<bool> IsExtensionValidWithNotifyAsync(UseCase useCae, string filePath, bool validateMediaFileTypes = true, Settings.Provider? provider = null)
{
if (string.Equals(Path.GetExtension(filePath), ".doc", StringComparison.OrdinalIgnoreCase))
{
await MessageBus.INSTANCE.SendWarning(new(
Icons.Material.Filled.Description,
TB("This file format is not supported. Please convert the .doc file to .docx (e.g. with Microsoft Word).")));
return false;
}
if (FileTypes.IsAllowedPath(filePath, FileTypes.EXECUTABLES))
{
await MessageBus.INSTANCE.SendError(new(
@@ -138,4 +146,4 @@ public static class FileExtensionValidation
return true;
}
}
}