mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 04:17:59 +00:00
Merge branch 'main' into chunk-data
This commit is contained in:
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;
|
||||
}
|
||||
+5
-10
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
File renamed without changes.
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user