Improved Qdrant server startup & client initialization (#770)
Some checks are pending
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-05-19 08:24:22 +02:00 committed by GitHub
parent 97e6003686
commit cef1c99765
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 443 additions and 79 deletions

View File

@ -6157,6 +6157,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840227993"] = "Used .NET runtim
-- Explanation
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation"
-- checking availability
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2855535668"] = "checking availability"
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software."
@ -6265,6 +6268,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library
-- Used .NET SDK
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK"
-- starting
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T594602073"] = "starting"
-- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated."
@ -6901,6 +6907,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "
-- Reason
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Reason"
-- Starting
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1233211769"] = "Starting"
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T3662391977"] = "Unavailable"

View File

@ -29,7 +29,7 @@ public partial class Information : MSGComponentBase
private ISnackbar Snackbar { get; init; } = null!;
[Inject]
private DatabaseClient DatabaseClient { get; init; } = null!;
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
private static readonly Assembly ASSEMBLY = Assembly.GetExecutingAssembly();
private static readonly MetaDataAttribute META_DATA = ASSEMBLY.GetCustomAttribute<MetaDataAttribute>()!;
@ -62,9 +62,21 @@ public partial class Information : MSGComponentBase
private string VersionPdfium => $"{T("Used PDFium version")}: v{META_DATA_LIBRARIES.PdfiumVersion}";
private string VersionDatabase => this.DatabaseClient.IsAvailable
? $"{T("Database version")}: {this.DatabaseClient.Name} v{META_DATA_DATABASES.DatabaseVersion}"
: $"{T("Database")}: {this.DatabaseClient.Name} - {T("not available")}";
private string VersionDatabase
{
get
{
if (this.databaseClient is null)
return $"{T("Database")}: {T("checking availability")}";
return this.databaseClient.Status switch
{
DatabaseClientStatus.AVAILABLE => $"{T("Database version")}: {this.databaseClient.Name} v{META_DATA_DATABASES.DatabaseVersion}",
DatabaseClientStatus.STARTING => $"{T("Database")}: {this.databaseClient.Name} - {T("starting")}",
_ => $"{T("Database")}: {this.databaseClient.Name} - {T("not available")}"
};
}
}
private string versionPandoc = TB("Determine Pandoc version, please wait...");
private PandocInstallation pandocInstallation;
@ -89,6 +101,8 @@ public partial class Information : MSGComponentBase
private sealed record MandatoryInfoPanelData(string HeaderText, string PluginName, DataMandatoryInfo Info, DataMandatoryInfoAcceptance? Acceptance);
private readonly List<DatabaseDisplayInfo> databaseDisplayInfo = new();
private DatabaseClient? databaseClient;
private CancellationTokenSource? databaseRefreshCancellationTokenSource;
private bool HasAnyActiveEnvironment => this.enterpriseEnvironments.Any(e => e.IsActive);
@ -134,10 +148,9 @@ public partial class Information : MSGComponentBase
this.osUserName = await this.RustService.ReadUserName();
this.logPaths = await this.RustService.GetLogPaths();
await foreach (var (label, value) in this.DatabaseClient.GetDisplayInfo())
{
this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value));
}
await this.RefreshDatabaseInfo(CancellationToken.None);
if (this.databaseClient?.Status is DatabaseClientStatus.STARTING)
this.StartShortDatabaseRefreshLoop();
// Determine the Pandoc version may take some time, so we start it here
// without waiting for the result:
@ -241,6 +254,69 @@ public partial class Information : MSGComponentBase
this.showDatabaseDetails = !this.showDatabaseDetails;
}
private async Task RefreshDatabaseInfo(CancellationToken cancellationToken)
{
var refreshedClient = await this.DatabaseClientProvider.RefreshClientAsync(DatabaseRole.VECTOR_STORE, cancellationToken);
this.databaseClient = refreshedClient;
this.databaseDisplayInfo.Clear();
try
{
await foreach (var (label, value) in refreshedClient.GetDisplayInfo().WithCancellation(cancellationToken))
{
this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value));
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception e)
{
this.databaseClient = new NoDatabaseClient(refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING);
await foreach (var (label, value) in this.databaseClient.GetDisplayInfo().WithCancellation(cancellationToken))
{
this.databaseDisplayInfo.Add(new DatabaseDisplayInfo(label, value));
}
}
}
private void StartShortDatabaseRefreshLoop()
{
this.databaseRefreshCancellationTokenSource?.Cancel();
this.databaseRefreshCancellationTokenSource?.Dispose();
this.databaseRefreshCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = this.databaseRefreshCancellationTokenSource.Token;
_ = Task.Run(async () =>
{
const int MAX_TRIES = 12;
for (var attempt = 0; attempt < MAX_TRIES; attempt++)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
await this.InvokeAsync(async () =>
{
await this.RefreshDatabaseInfo(cancellationToken);
this.StateHasChanged();
});
if (this.databaseClient?.Status is not DatabaseClientStatus.STARTING)
return;
}
catch (OperationCanceledException)
{
return;
}
catch
{
return;
}
}
}, cancellationToken);
}
private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId)
{
return this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId == configurationId)
@ -253,6 +329,13 @@ public partial class Information : MSGComponentBase
return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId;
}
protected override void DisposeResources()
{
this.databaseRefreshCancellationTokenSource?.Cancel();
this.databaseRefreshCancellationTokenSource?.Dispose();
base.DisposeResources();
}
private async Task CopyStartupLogPath()
{
await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath);

View File

@ -6159,6 +6159,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840227993"] = "Verwendete .NET-
-- Explanation
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Erklärung"
-- checking availability
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2855535668"] = "Verfügbarkeit wird geprüft"
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "Das .NET-Backend kann nicht als Desktop-App gestartet werden. Deshalb verwende ich ein zweites Backend in Rust, das ich „Runtime“ nenne. Mit Rust als Runtime kann Tauri genutzt werden, um eine typische Desktop-App zu realisieren. Dank Rust kann diese App für Windows-, macOS- und Linux-Desktops angeboten werden. Rust ist eine großartige Sprache für die Entwicklung sicherer und leistungsstarker Software."
@ -6267,6 +6270,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "Dies ist eine Bib
-- Used .NET SDK
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Verwendetes .NET SDK"
-- starting
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T594602073"] = "wird gestartet"
-- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "Diese Bibliothek wird verwendet, um Sidecar-Prozesse zu verwalten und sicherzustellen, dass veraltete oder Zombie-Sidecars erkannt und beendet werden."
@ -6903,6 +6909,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "
-- Reason
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Grund"
-- Starting
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1233211769"] = "Wird gestartet"
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T3662391977"] = "Nicht verfügbar"

View File

@ -6159,6 +6159,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840227993"] = "Used .NET runtim
-- Explanation
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2840582448"] = "Explanation"
-- checking availability
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2855535668"] = "checking availability"
-- The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2868174483"] = "The .NET backend cannot be started as a desktop app. Therefore, I use a second backend in Rust, which I call runtime. With Rust as the runtime, Tauri can be used to realize a typical desktop app. Thanks to Rust, this app can be offered for Windows, macOS, and Linux desktops. Rust is a great language for developing safe and high-performance software."
@ -6267,6 +6270,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T566998575"] = "This is a library
-- Used .NET SDK
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T585329785"] = "Used .NET SDK"
-- starting
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T594602073"] = "starting"
-- This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T633932150"] = "This library is used to manage sidecar processes and to ensure that stale or zombie sidecars are detected and terminated."
@ -6903,6 +6909,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::CONFIDENCESCHEMESEXTENSIONS::T4107860491"] = "
-- Reason
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Reason"
-- Starting
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1233211769"] = "Starting"
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T3662391977"] = "Unavailable"

View File

@ -2,7 +2,6 @@ using AIStudio.Agents;
using AIStudio.Agents.AssistantAudit;
using AIStudio.Settings;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.Qdrant;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.Services;
@ -28,7 +27,7 @@ internal sealed class Program
public static string API_TOKEN = null!;
public static IServiceProvider SERVICE_PROVIDER = null!;
public static ILoggerFactory LOGGER_FACTORY = null!;
public static DatabaseClient DATABASE_CLIENT = null!;
public static DatabaseClientProvider DATABASE_CLIENT_PROVIDER = null!;
public static async Task Main()
{
@ -87,48 +86,6 @@ internal sealed class Program
return;
}
var qdrantInfo = await rust.GetQdrantInfo();
DatabaseClient databaseClient;
if (!qdrantInfo.IsAvailable)
{
Console.WriteLine($"Warning: Qdrant is not available. Starting without vector database. Reason: '{qdrantInfo.UnavailableReason ?? "unknown"}'.");
databaseClient = new NoDatabaseClient("Qdrant", qdrantInfo.UnavailableReason);
}
else
{
if (qdrantInfo.Path == string.Empty)
{
Console.WriteLine("Error: Failed to get the Qdrant path from Rust.");
return;
}
if (qdrantInfo.PortHttp == 0)
{
Console.WriteLine("Error: Failed to get the Qdrant HTTP port from Rust.");
return;
}
if (qdrantInfo.PortGrpc == 0)
{
Console.WriteLine("Error: Failed to get the Qdrant gRPC port from Rust.");
return;
}
if (qdrantInfo.Fingerprint == string.Empty)
{
Console.WriteLine("Error: Failed to get the Qdrant fingerprint from Rust.");
return;
}
if (qdrantInfo.ApiToken == string.Empty)
{
Console.WriteLine("Error: Failed to get the Qdrant API token from Rust.");
return;
}
databaseClient = new QdrantClientImplementation("Qdrant", qdrantInfo.Path, qdrantInfo.PortHttp, qdrantInfo.PortGrpc, qdrantInfo.Fingerprint, qdrantInfo.ApiToken);
}
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(kestrelServerOptions =>
{
@ -183,7 +140,7 @@ internal sealed class Program
builder.Services.AddHostedService<UpdateService>();
builder.Services.AddHostedService<TemporaryChatService>();
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
builder.Services.AddSingleton(databaseClient);
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>();
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
@ -242,10 +199,7 @@ internal sealed class Program
RUST_SERVICE = rust;
ENCRYPTION = encryption;
var databaseLogger = app.Services.GetRequiredService<ILogger<DatabaseClient>>();
databaseClient.SetLogger(databaseLogger);
DATABASE_CLIENT = databaseClient;
DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService<DatabaseClientProvider>();
programLogger.LogInformation("Initialize internal file system.");
app.Use(Redirect.HandlerContentAsync);
@ -283,7 +237,7 @@ internal sealed class Program
await serverTask;
RUST_SERVICE.Dispose();
DATABASE_CLIENT.Dispose();
DATABASE_CLIENT_PROVIDER.Dispose();
PluginFactory.Dispose();
programLogger.LogInformation("The AI Studio server was stopped.");
}

View File

@ -4,7 +4,11 @@ public abstract class DatabaseClient(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;

View File

@ -0,0 +1,180 @@
using AIStudio.Tools.Databases.Qdrant;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases;
public sealed class DatabaseClientProvider(RustService rustService, ILoggerFactory loggerFactory) : IDisposable
{
private readonly Dictionary<DatabaseRole, DatabaseClient> clients = new();
private readonly Dictionary<DatabaseRole, SemaphoreSlim> locks = new();
private readonly Lock locksLock = new();
private readonly ILogger<DatabaseClientProvider> logger = loggerFactory.CreateLogger<DatabaseClientProvider>();
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();
}
}

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Databases;
public enum DatabaseClientStatus
{
STARTING,
AVAILABLE,
UNAVAILABLE,
}

View File

@ -0,0 +1,6 @@
namespace AIStudio.Tools.Databases;
public enum DatabaseRole
{
VECTOR_STORE,
}

View File

@ -2,15 +2,19 @@ using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Databases;
public sealed class NoDatabaseClient(string name, string? unavailableReason) : DatabaseClient(name, string.Empty)
public sealed class NoDatabaseClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : DatabaseClient(name, string.Empty)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoDatabaseClient).Namespace, nameof(NoDatabaseClient));
public override bool IsAvailable => false;
public override DatabaseClientStatus Status => status;
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
yield return (TB("Status"), TB("Unavailable"));
yield return (TB("Status"), status switch
{
DatabaseClientStatus.STARTING => TB("Starting"),
_ => TB("Unavailable")
});
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);

View File

@ -26,6 +26,8 @@ public class QdrantClientImplementation : DatabaseClient
this.ApiToken = apiToken;
this.GrpcClient = this.CreateQdrantClient();
}
public override string CacheKey => $"{this.Name}:{this.HttpPort}:{this.GrpcPort}:{this.Fingerprint}";
private const string IP_ADDRESS = "localhost";
@ -47,6 +49,11 @@ public class QdrantClientImplementation : DatabaseClient
return $"v{operation.Version}";
}
public async Task CheckAvailabilityAsync()
{
await this.GrpcClient.HealthAsync();
}
private async Task<string> GetCollectionsAmount()
{
var operation = await this.GrpcClient.ListCollectionsAsync();

View File

@ -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; }

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Rust;
public enum QdrantStatus
{
STARTING,
AVAILABLE,
UNAVAILABLE,
}

View File

@ -4,13 +4,27 @@ namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
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)
{
@ -19,7 +33,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
};
}
}
}

View File

@ -5,6 +5,7 @@
- Added the username to the information page to make organization support easier when users share their screen.
- Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base.
- Improved the Pandoc management and detection process to make it more reliable.
- Improved the Qdrant startup and vector database initialization, so AI Studio can start more reliably while the local vector database is still starting.
- Fixed the Pandoc installation, which could fail and prevent AI Studio from installing its local Pandoc dependency.
- Fixed an issue where the spellchecking setting was not applied to all text fields in the slide builder assistant.
- Fixed missing translations for file type names in file selection dialogs.

View File

@ -25,7 +25,7 @@ use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_serv
use crate::environment::{is_prod, is_dev, CONFIG_DIRECTORY, DATA_DIRECTORY};
use crate::log::switch_to_file_logging;
use crate::pdfium::PDFIUM_LIB_PATH;
use crate::qdrant::{cleanup_qdrant, start_qdrant_server, stop_qdrant_server};
use crate::qdrant::{start_qdrant_server, stop_qdrant_server};
#[cfg(debug_assertions)]
use crate::dotnet::create_startup_env_file;
@ -148,7 +148,6 @@ pub fn start_tauri() {
start_dotnet_server(app.handle().clone());
}
cleanup_qdrant();
start_qdrant_server(app.handle().clone());
info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}");

View File

@ -5,6 +5,7 @@ use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use log::{debug, error, info, warn};
use once_cell::sync::Lazy;
use axum::Json;
@ -18,6 +19,7 @@ use tauri::path::BaseDirectory;
use tempfile::{TempDir, Builder};
use crate::stale_process_cleanup::{kill_stale_process, log_potential_stale_process};
use crate::sidecar_types::SidecarType;
use tokio::time;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
@ -40,14 +42,24 @@ static API_TOKEN: Lazy<APIToken> = Lazy::new(|| {
});
static TMPDIR: Lazy<Mutex<Option<TempDir>>> = Lazy::new(|| Mutex::new(None));
static QDRANT_STATUS: Lazy<Mutex<QdrantStatus>> = Lazy::new(|| Mutex::new(QdrantStatus::default()));
static QDRANT_STATUS: Lazy<Mutex<QdrantStatusInfo>> = Lazy::new(|| Mutex::new(QdrantStatusInfo::default()));
const PID_FILE_NAME: &str = "qdrant.pid";
const SIDECAR_TYPE:SidecarType = SidecarType::Qdrant;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(60);
const STARTUP_CHECK_INTERVAL: Duration = Duration::from_millis(250);
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
enum QdrantStatus {
#[default]
Starting,
Available,
Unavailable,
}
#[derive(Default)]
struct QdrantStatus {
is_available: bool,
struct QdrantStatusInfo {
status: QdrantStatus,
unavailable_reason: Option<String>,
}
@ -60,6 +72,7 @@ fn qdrant_base_path() -> PathBuf {
#[derive(Serialize)]
pub struct ProvideQdrantInfo {
status: QdrantStatus,
path: String,
port_http: u16,
port_grpc: u16,
@ -71,10 +84,12 @@ pub struct ProvideQdrantInfo {
pub async fn qdrant_port(_token: APIToken) -> Json<ProvideQdrantInfo> {
let status = QDRANT_STATUS.lock().unwrap();
let is_available = status.is_available;
let current_status = status.status;
let is_available = current_status == QdrantStatus::Available;
let unavailable_reason = status.unavailable_reason.clone();
Json(ProvideQdrantInfo {
status: current_status,
path: if is_available {
qdrant_base_path().to_string_lossy().to_string()
} else {
@ -99,6 +114,14 @@ pub async fn qdrant_port(_token: APIToken) -> Json<ProvideQdrantInfo> {
/// Starts the Qdrant server in a separate process.
pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
set_qdrant_starting();
tauri::async_runtime::spawn(async move {
cleanup_qdrant();
start_qdrant_server_internal(app_handle);
});
}
fn start_qdrant_server_internal<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
let path = qdrant_base_path();
if !path.exists() && let Err(e) = fs::create_dir_all(&path){
error!(Source="Qdrant"; "The required directory to host the Qdrant database could not be created: {}", e);
@ -117,12 +140,13 @@ pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
let storage_path = path.join("storage").to_string_lossy().to_string();
let snapshot_path = path.join("snapshots").to_string_lossy().to_string();
let init_path = path.join(".qdrant-initialized").to_string_lossy().to_string();
let init_path = path.join(".qdrant-initialized");
let init_path_environment = init_path.to_string_lossy().to_string();
let qdrant_server_environment: HashMap<String, String> = HashMap::from_iter([
(String::from("QDRANT__SERVICE__HTTP_PORT"), QDRANT_SERVER_PORT_HTTP.to_string()),
(String::from("QDRANT__SERVICE__GRPC_PORT"), QDRANT_SERVER_PORT_GRPC.to_string()),
(String::from("QDRANT_INIT_FILE_PATH"), init_path),
(String::from("QDRANT_INIT_FILE_PATH"), init_path_environment),
(String::from("QDRANT__STORAGE__STORAGE_PATH"), storage_path),
(String::from("QDRANT__STORAGE__SNAPSHOTS_PATH"), snapshot_path),
(String::from("QDRANT__TLS__CERT"), cert_path.to_string_lossy().to_string()),
@ -172,13 +196,24 @@ pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
};
let server_pid = child.pid();
set_qdrant_available();
info!(Source = "Bootloader Qdrant"; "Qdrant server process started with PID={server_pid}.");
log_potential_stale_process(path.join(PID_FILE_NAME), server_pid, SIDECAR_TYPE);
// Save the server process to stop it later:
*server_spawn_clone.lock().unwrap() = Some(child);
let init_path_clone = init_path.clone();
tauri::async_runtime::spawn(async move {
if wait_for_qdrant_startup(init_path_clone).await {
set_qdrant_available();
info!(Source = "Qdrant"; "Qdrant is available.");
} else {
let reason = "Qdrant did not become available within the startup timeout.".to_string();
error!(Source = "Qdrant"; "{reason}");
set_qdrant_unavailable(reason);
}
});
// Log the output of the Qdrant server:
while let Some(event) = rx.recv().await {
match event {
@ -200,10 +235,18 @@ pub fn start_qdrant_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>){
let line_utf8 = String::from_utf8_lossy(&line).to_string();
error!(Source = "Qdrant Server (stderr)"; "{line_utf8}");
},
_ => {}
}
}
let is_available = QDRANT_STATUS.lock().unwrap().status == QdrantStatus::Available;
let unavailable_reason = if is_available {
"Qdrant server process stopped.".to_string()
} else {
"Qdrant server process stopped before it became available.".to_string()
};
set_qdrant_unavailable(unavailable_reason);
});
}
@ -226,6 +269,20 @@ pub fn stop_qdrant_server() {
cleanup_qdrant();
}
async fn wait_for_qdrant_startup(init_path: PathBuf) -> bool {
let mut elapsed = Duration::ZERO;
while elapsed < STARTUP_TIMEOUT {
if init_path.exists() {
return true;
}
time::sleep(STARTUP_CHECK_INTERVAL).await;
elapsed += STARTUP_CHECK_INTERVAL;
}
false
}
/// Create a temporary directory with TLS relevant files
pub fn create_temp_tls_files(path: &PathBuf) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
let cert = generate_certificate();
@ -278,13 +335,19 @@ pub fn cleanup_qdrant() {
fn set_qdrant_available() {
let mut status = QDRANT_STATUS.lock().unwrap();
status.is_available = true;
status.status = QdrantStatus::Available;
status.unavailable_reason = None;
}
fn set_qdrant_starting() {
let mut status = QDRANT_STATUS.lock().unwrap();
status.status = QdrantStatus::Starting;
status.unavailable_reason = None;
}
fn set_qdrant_unavailable(reason: String) {
let mut status = QDRANT_STATUS.lock().unwrap();
status.is_available = false;
status.status = QdrantStatus::Unavailable;
status.unavailable_reason = Some(reason);
}