From 2e3a774124234562293701d527ce93d83fe5510a Mon Sep 17 00:00:00 2001 From: PaulKoudelka Date: Mon, 1 Jun 2026 23:36:38 +0200 Subject: [PATCH] final changes to ensure proper replacement of qdrant --- .github/workflows/build-and-release.yml | 123 ---- app/Build/Commands/Qdrant.cs | 120 ---- app/Build/Commands/UpdateMetadataCommands.cs | 47 +- .../Assistants/I18N/allTexts.lua | 31 +- .../Components/ChatComponent.razor.cs | 1 - .../MindWork AI Studio.csproj | 1 - .../Pages/Information.razor | 4 +- .../Pages/Information.razor.cs | 3 +- .../DatabaseClientProvider.Qdrant.cs | 80 --- .../Tools/Databases/DatabaseClientProvider.cs | 11 +- .../VectorStore/IVectorStoreClient.cs | 8 - .../VectorStore/NoVectorStoreClient.cs | 20 +- .../VectorStore/QdrantClientImplementation.cs | 142 ----- .../QdrantEdgeClientImplementation.cs | 109 ++++ .../{ => VectorStore}/VectorStoragePoint.cs | 2 +- .../Tools/Rust/QdrantEdgeInfo.cs | 27 + .../{QdrantStatus.cs => QdrantEdgeStatus.cs} | 4 +- .../Tools/Rust/QdrantInfo.cs | 23 - .../Tools/Services/RustService.Databases.cs | 54 +- documentation/Build.md | 9 +- metadata.txt | 2 +- runtime/Cargo.toml | 1 + runtime/capabilities/default.json | 5 - .../resources/databases/qdrant/config.yaml | 354 ------------ runtime/src/app_window.rs | 10 +- runtime/src/lib.rs | 2 +- runtime/src/main.rs | 2 +- runtime/src/metadata.rs | 6 +- runtime/src/qdrant.rs | 374 ------------ runtime/src/qdrant_edge_database.rs | 537 ++++++++++++++++++ runtime/src/runtime_api.rs | 8 +- runtime/src/sidecar_types.rs | 2 - runtime/tauri.conf.json | 4 +- 33 files changed, 796 insertions(+), 1330 deletions(-) delete mode 100644 app/Build/Commands/Qdrant.cs delete mode 100644 app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs delete mode 100644 app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantClientImplementation.cs create mode 100644 app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs rename app/MindWork AI Studio/Tools/Databases/{ => VectorStore}/VectorStoragePoint.cs (88%) create mode 100644 app/MindWork AI Studio/Tools/Rust/QdrantEdgeInfo.cs rename app/MindWork AI Studio/Tools/Rust/{QdrantStatus.cs => QdrantEdgeStatus.cs} (72%) delete mode 100644 app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs delete mode 100644 runtime/resources/databases/qdrant/config.yaml delete mode 100644 runtime/src/qdrant.rs create mode 100644 runtime/src/qdrant_edge_database.rs diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index 3bd6ddf9..a205a399 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -558,129 +558,6 @@ jobs: } catch { Write-Warning "Could not fully clean up temporary directory: $TMP. This is usually harmless as Windows will clean it up later. Error: $($_.Exception.Message)" } - - name: Deploy Qdrant (Unix) - if: matrix.platform != 'windows-latest' - env: - QDRANT_VERSION: ${{ env.QDRANT_VERSION }} - DOTNET_RUNTIME: ${{ matrix.dotnet_runtime }} - RUST_TARGET: ${{ matrix.rust_target }} - run: | - set -e - - # Target directory: - TDB_DIR="runtime/target/databases/qdrant" - mkdir -p "$TDB_DIR" - - case "${DOTNET_RUNTIME}" in - linux-x64) - QDRANT_FILE="x86_64-unknown-linux-gnu.tar.gz" - DB_SOURCE="qdrant" - DB_TARGET="qdrant-${RUST_TARGET}" - ;; - linux-arm64) - QDRANT_FILE="aarch64-unknown-linux-musl.tar.gz" - DB_SOURCE="qdrant" - DB_TARGET="qdrant-${RUST_TARGET}" - ;; - osx-x64) - QDRANT_FILE="x86_64-apple-darwin.tar.gz" - DB_SOURCE="qdrant" - DB_TARGET="qdrant-${RUST_TARGET}" - ;; - osx-arm64) - QDRANT_FILE="aarch64-apple-darwin.tar.gz" - DB_SOURCE="qdrant" - DB_TARGET="qdrant-${RUST_TARGET}" - ;; - *) - echo "Unknown platform: ${DOTNET_RUNTIME}" - exit 1 - ;; - esac - - QDRANT_URL="https://github.com/qdrant/qdrant/releases/download/${QDRANT_VERSION}/qdrant-${QDRANT_FILE}" - - echo "Download Qdrant $QDRANT_URL ..." - TMP=$(mktemp -d) - ARCHIVE="${TMP}/qdrant.tgz" - - curl -fsSL -o "$ARCHIVE" "$QDRANT_URL" - - echo "Extracting Qdrant ..." - tar xzf "$ARCHIVE" -C "$TMP" - SRC="${TMP}/${DB_SOURCE}" - - if [ ! -f "$SRC" ]; then - echo "Was not able to find Qdrant source: $SRC" - exit 1 - fi - - echo "Copy Qdrant from ${DB_TARGET} to ${TDB_DIR}/" - cp -f "$SRC" "$TDB_DIR/$DB_TARGET" - - echo "Cleaning up ..." - rm -fr "$TMP" - - - name: Deploy Qdrant (Windows) - if: matrix.platform == 'windows-latest' - env: - QDRANT_VERSION: ${{ env.QDRANT_VERSION }} - DOTNET_RUNTIME: ${{ matrix.dotnet_runtime }} - RUST_TARGET: ${{ matrix.rust_target }} - run: | - $TDB_DIR = "runtime\target\databases\qdrant" - New-Item -ItemType Directory -Force -Path $TDB_DIR | Out-Null - - switch ($env:DOTNET_RUNTIME) { - "win-x64" { - $QDRANT_FILE = "x86_64-pc-windows-msvc.zip" - $DB_SOURCE = "qdrant.exe" - $DB_TARGET = "qdrant-$($env:RUST_TARGET).exe" - } - "win-arm64" { - $QDRANT_FILE = "x86_64-pc-windows-msvc.zip" - $DB_SOURCE = "qdrant.exe" - $DB_TARGET = "qdrant-$($env:RUST_TARGET).exe" - } - default { - Write-Error "Unknown platform: $($env:DOTNET_RUNTIME)" - exit 1 - } - } - - $QDRANT_URL = "https://github.com/qdrant/qdrant/releases/download/$($env:QDRANT_VERSION)/qdrant-$QDRANT_FILE" - Write-Host "Download $QDRANT_URL ..." - - # Create a unique temporary directory (not just a file) - $TMP = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) - New-Item -ItemType Directory -Path $TMP -Force | Out-Null - $ARCHIVE = Join-Path $TMP "qdrant.tgz" - - Invoke-WebRequest -Uri $QDRANT_URL -OutFile $ARCHIVE - - Write-Host "Extracting Qdrant ..." - tar -xzf $ARCHIVE -C $TMP - - $SRC = Join-Path $TMP $DB_SOURCE - if (!(Test-Path $SRC)) { - Write-Error "Cannot find Qdrant source: $SRC" - exit 1 - } - - $DEST = Join-Path $TDB_DIR $DB_TARGET - Copy-Item -Path $SRC -Destination $DEST -Force - - Write-Host "Cleaning up ..." - Remove-Item $ARCHIVE -Force -ErrorAction SilentlyContinue - - # Try to remove the temporary directory, but ignore errors if files are still in use - try { - Remove-Item $TMP -Recurse -Force -ErrorAction Stop - Write-Host "Successfully cleaned up temporary directory: $TMP" - } catch { - Write-Warning "Could not fully clean up temporary directory: $TMP. This is usually harmless as Windows will clean it up later. Error: $($_.Exception.Message)" - } - - name: Build .NET project run: | cd "app/MindWork AI Studio" diff --git a/app/Build/Commands/Qdrant.cs b/app/Build/Commands/Qdrant.cs deleted file mode 100644 index 29369ccf..00000000 --- a/app/Build/Commands/Qdrant.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Formats.Tar; -using System.IO.Compression; - -using SharedTools; - -namespace Build.Commands; - -public static class Qdrant -{ - public static async Task InstallAsync(RID rid, string version) - { - Console.Write($"- Installing Qdrant {version} for {rid.ToUserFriendlyName()} ..."); - - var cwd = Environment.GetRustRuntimeDirectory(); - var qdrantTmpDownloadPath = Path.GetTempFileName(); - var qdrantTmpExtractPath = Directory.CreateTempSubdirectory(); - var qdrantUrl = GetQdrantDownloadUrl(rid, version); - - // - // Download the file: - // - Console.Write(" downloading ..."); - using (var client = new HttpClient()) - { - var response = await client.GetAsync(qdrantUrl); - if (!response.IsSuccessStatusCode) - { - Console.WriteLine($" failed to download Qdrant {version} for {rid.ToUserFriendlyName()} from {qdrantUrl}"); - return; - } - - await using var fileStream = File.Create(qdrantTmpDownloadPath); - await response.Content.CopyToAsync(fileStream); - } - - // - // Extract the downloaded file: - // - Console.Write(" extracting ..."); - await using(var zStream = File.Open(qdrantTmpDownloadPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - { - if (rid == RID.WIN_X64) - { - using var archive = new ZipArchive(zStream, ZipArchiveMode.Read); - archive.ExtractToDirectory(qdrantTmpExtractPath.FullName, overwriteFiles: true); - } - else - { - await using var uncompressedStream = new GZipStream(zStream, CompressionMode.Decompress); - await TarFile.ExtractToDirectoryAsync(uncompressedStream, qdrantTmpExtractPath.FullName, true); - } - } - - // - // Copy the database to the target directory: - // - Console.Write(" deploying ..."); - var database = GetDatabasePath(rid); - if (string.IsNullOrWhiteSpace(database.Path)) - { - Console.WriteLine($" failed to find the database path for {rid.ToUserFriendlyName()}"); - return; - } - - var qdrantDbSourcePath = Path.Join(qdrantTmpExtractPath.FullName, database.Path); - var qdrantDbTargetPath = Path.Join(cwd, "target", "databases", "qdrant",database.Filename); - if (!File.Exists(qdrantDbSourcePath)) - { - Console.WriteLine($" failed to find the database file '{qdrantDbSourcePath}'"); - return; - } - - Directory.CreateDirectory(Path.Join(cwd, "target", "databases", "qdrant")); - if (File.Exists(qdrantDbTargetPath)) - File.Delete(qdrantDbTargetPath); - - File.Copy(qdrantDbSourcePath, qdrantDbTargetPath); - - // - // Cleanup: - // - Console.Write(" cleaning up ..."); - File.Delete(qdrantTmpDownloadPath); - Directory.Delete(qdrantTmpExtractPath.FullName, true); - - Console.WriteLine(" done."); - } - - private static Database GetDatabasePath(RID rid) => rid switch - { - RID.OSX_ARM64 => new("qdrant", "qdrant-aarch64-apple-darwin"), - RID.OSX_X64 => new("qdrant", "qdrant-x86_64-apple-darwin"), - - RID.LINUX_ARM64 => new("qdrant", "qdrant-aarch64-unknown-linux-gnu"), - RID.LINUX_X64 => new("qdrant", "qdrant-x86_64-unknown-linux-gnu"), - - RID.WIN_X64 => new("qdrant.exe", "qdrant-x86_64-pc-windows-msvc.exe"), - RID.WIN_ARM64 => new("qdrant.exe", "qdrant-aarch64-pc-windows-msvc.exe"), - - _ => new(string.Empty, string.Empty), - }; - - private static string GetQdrantDownloadUrl(RID rid, string version) - { - var baseUrl = $"https://github.com/qdrant/qdrant/releases/download/v{version}/qdrant-"; - return rid switch - { - RID.LINUX_ARM64 => $"{baseUrl}aarch64-unknown-linux-musl.tar.gz", - RID.LINUX_X64 => $"{baseUrl}x86_64-unknown-linux-gnu.tar.gz", - - RID.OSX_ARM64 => $"{baseUrl}aarch64-apple-darwin.tar.gz", - RID.OSX_X64 => $"{baseUrl}x86_64-apple-darwin.tar.gz", - - RID.WIN_X64 => $"{baseUrl}x86_64-pc-windows-msvc.zip", - RID.WIN_ARM64 => $"{baseUrl}x86_64-pc-windows-msvc.zip", - - _ => string.Empty, - }; - } -} \ No newline at end of file diff --git a/app/Build/Commands/UpdateMetadataCommands.cs b/app/Build/Commands/UpdateMetadataCommands.cs index f3b0799e..303edcd5 100644 --- a/app/Build/Commands/UpdateMetadataCommands.cs +++ b/app/Build/Commands/UpdateMetadataCommands.cs @@ -69,6 +69,7 @@ public sealed partial class UpdateMetadataCommands await this.UpdateRustVersion(); await this.UpdateMudBlazorVersion(); await this.UpdateTauriVersion(); + await this.UpdateVectorStoreVersion(); } [Command("prepare", Description = "Prepare the metadata for the next release")] @@ -126,6 +127,7 @@ public sealed partial class UpdateMetadataCommands await this.UpdateRustVersion(); await this.UpdateMudBlazorVersion(); await this.UpdateTauriVersion(); + await this.UpdateVectorStoreVersion(); await this.UpdateProjectCommitHash(); await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "..", "..", "LICENSE.md"))); await this.UpdateLicenceYear(Path.GetFullPath(Path.Combine(Environment.GetAIStudioDirectory(), "Pages", "Information.razor.cs"))); @@ -147,12 +149,11 @@ public sealed partial class UpdateMetadataCommands Console.WriteLine("=============================="); await this.UpdateArchitecture(rid); + await this.UpdateTauriVersion(); + await this.UpdateVectorStoreVersion(); var pdfiumVersion = await this.ReadPdfiumVersion(); await Pdfium.InstallAsync(rid, pdfiumVersion); - - var qdrantVersion = await this.ReadQdrantVersion(); - await Qdrant.InstallAsync(rid, qdrantVersion); Console.Write($"- Start .NET build for {rid.ToUserFriendlyName()} ..."); await this.ReadCommandOutput(pathApp, "dotnet", $"clean --configuration release --runtime {rid.AsMicrosoftRid()}"); @@ -367,16 +368,6 @@ public sealed partial class UpdateMetadataCommands return shortVersion; } - private async Task ReadQdrantVersion() - { - const int QDRANT_VERSION_INDEX = 11; - var pathMetadata = Environment.GetMetadataPath(); - var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); - var currentQdrantVersion = lines[QDRANT_VERSION_INDEX].Trim(); - - return currentQdrantVersion; - } - private async Task UpdateArchitecture(RID rid) { const int ARCHITECTURE_INDEX = 9; @@ -529,7 +520,32 @@ public sealed partial class UpdateMetadataCommands await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); } - + + private async Task UpdateVectorStoreVersion() + { + const int VECTOR_STORE_VERSION_INDEX = 11; + + var pathMetadata = Environment.GetMetadataPath(); + var lines = await File.ReadAllLinesAsync(pathMetadata, Encoding.UTF8); + var currentVectorStoreVersion = lines[VECTOR_STORE_VERSION_INDEX].Trim(); + + var matches = await this.DetermineVersion("Qdrant Edge", Environment.GetRustRuntimeDirectory(), QdrantEdgeVersionRegex(), "cargo", "tree --depth 1"); + if (matches.Count == 0) + return; + + var updatedVectorStoreVersion = matches[0].Groups["version"].Value; + if(currentVectorStoreVersion == updatedVectorStoreVersion) + { + Console.WriteLine("- The vector store version is already up to date."); + return; + } + + Console.WriteLine($"- Updated vector store version from {currentVectorStoreVersion} to {updatedVectorStoreVersion}."); + lines[VECTOR_STORE_VERSION_INDEX] = updatedVectorStoreVersion; + + await File.WriteAllLinesAsync(pathMetadata, lines, Environment.UTF8_NO_BOM); + } + private async Task UpdateMudBlazorVersion() { const int MUD_BLAZOR_VERSION_INDEX = 6; @@ -720,6 +736,9 @@ public sealed partial class UpdateMetadataCommands [GeneratedRegex("""MudBlazor\s+(?[0-9.]+)""")] private static partial Regex MudBlazorVersionRegex(); + [GeneratedRegex("""qdrant-edge\s+v(?[0-9.]+)""")] + private static partial Regex QdrantEdgeVersionRegex(); + [GeneratedRegex("""tauri\s+v(?[0-9.]+)""")] private static partial Regex TauriVersionRegex(); diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index ea78f732..2fcda50c 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6043,6 +6043,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio -- Vector store version UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1124039623"] = "Vector store version" +-- Qdrant Edge is an embedded vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1126023000"] = "Qdrant Edge is an embedded vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." + -- ID mismatch: the plugin ID differs from the enterprise configuration ID. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1137744461"] = "ID mismatch: the plugin ID differs from the enterprise configuration ID." @@ -6067,9 +6070,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1560776885"] = "Encryption secre -- AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1596483935"] = "AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active." --- Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1619832053"] = "Qdrant is a vector database and vector similarity search engine. We use it to realize local RAG—retrieval-augmented generation—within AI Studio. Thanks for the effort and great work that has been and is being put into Qdrant." - -- We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T162898512"] = "We use Lua as the language for plugins. Lua-CSharp lets Lua scripts communicate with AI Studio and vice versa. Thank you, Yusuke Nakada, for this great library." @@ -6112,6 +6112,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2029659664"] = "Copies the follo -- Copies the server URL to the clipboard UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the server URL to the clipboard" +-- This library is used to create temporary folders in runtime tests and supporting filesystem operations. +UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "This library is used to create temporary folders in runtime tests and supporting filesystem operations." + -- This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2173617769"] = "This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file." @@ -6154,9 +6157,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2557066213"] = "Used Open Source -- Build time UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T260228112"] = "Build time" --- This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant. -UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2619858133"] = "This library is used to create temporary folders for saving the certificate and private key for communication with Qdrant." - -- This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems. UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2635482790"] = "This crate provides derive macros for Rust enums, which we use to reduce boilerplate when implementing string conversions and metadata for runtime types. This is helpful for the communication between our Rust and .NET systems." @@ -7006,6 +7006,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T6222351"] = "Sta -- Reason UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T1093747001"] = "Reason" +-- Starting +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T1233211769"] = "Starting" + -- Unavailable UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T3662391977"] = "Unavailable" @@ -7013,19 +7016,19 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T6222351"] = "Status" -- Storage size -UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTCLIENTIMPLEMENTATION::T1230141403"] = "Storage size" +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T1230141403"] = "Storage size" --- HTTP port -UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTCLIENTIMPLEMENTATION::T1717573768"] = "HTTP port" +-- Number of vector stores +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2785004838"] = "Number of vector stores" -- Reported version -UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTCLIENTIMPLEMENTATION::T3556099842"] = "Reported version" +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T3556099842"] = "Reported version" --- gRPC port -UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTCLIENTIMPLEMENTATION::T757840040"] = "gRPC port" +-- Status +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T6222351"] = "Status" --- Number of collections -UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTCLIENTIMPLEMENTATION::T842647336"] = "Number of collections" +-- Qdrant Edge is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge is not available." -- The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment. UI_TEXT_CONTENT["AISTUDIO::TOOLS::ERICLIENT::DATAMODEL::PROVIDERTYPEEXTENSIONS::T1555790630"] = "The related data is not allowed to be sent to any LLM provider. This means that this data source cannot be used at the moment." diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index c8a8464e..263bb29c 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -4,7 +4,6 @@ using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AIJobs; -using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; diff --git a/app/MindWork AI Studio/MindWork AI Studio.csproj b/app/MindWork AI Studio/MindWork AI Studio.csproj index 5421ffc9..a2247811 100644 --- a/app/MindWork AI Studio/MindWork AI Studio.csproj +++ b/app/MindWork AI Studio/MindWork AI Studio.csproj @@ -53,7 +53,6 @@ - diff --git a/app/MindWork AI Studio/Pages/Information.razor b/app/MindWork AI Studio/Pages/Information.razor index 29c4e473..43894385 100644 --- a/app/MindWork AI Studio/Pages/Information.razor +++ b/app/MindWork AI Studio/Pages/Information.razor @@ -285,7 +285,7 @@ } - + @@ -310,7 +310,7 @@ - + diff --git a/app/MindWork AI Studio/Pages/Information.razor.cs b/app/MindWork AI Studio/Pages/Information.razor.cs index 7c018da2..98d29657 100644 --- a/app/MindWork AI Studio/Pages/Information.razor.cs +++ b/app/MindWork AI Studio/Pages/Information.razor.cs @@ -4,6 +4,7 @@ using AIStudio.Components; using AIStudio.Dialogs; using AIStudio.Settings.DataModel; using AIStudio.Tools.Databases; +using AIStudio.Tools.Databases.VectorStore; using AIStudio.Tools.Metadata; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; @@ -272,7 +273,7 @@ public partial class Information : MSGComponentBase } catch (Exception e) { - this.vectorStore = new NoDatabaseClient(refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING); + this.vectorStore = new NoVectorStoreClient(refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING); await foreach (var (label, value) in this.vectorStore.GetDisplayInfo().WithCancellation(cancellationToken)) { this.vectorStoreDisplayInfo.Add(new VectorStoreDisplayInfo(label, value)); diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs deleted file mode 100644 index f3676bb3..00000000 --- a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.Qdrant.cs +++ /dev/null @@ -1,80 +0,0 @@ -using AIStudio.Tools.Databases.VectorStore; -using AIStudio.Tools.Rust; - -namespace AIStudio.Tools.Databases; - -public sealed partial class DatabaseClientProvider -{ - private async Task 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; - } -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs index 55087784..f22efa38 100644 --- a/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs +++ b/app/MindWork AI Studio/Tools/Databases/DatabaseClientProvider.cs @@ -3,7 +3,7 @@ using AIStudio.Tools.Databases.VectorStore; namespace AIStudio.Tools.Databases; -public sealed partial class DatabaseClientProvider(RustService rustService, ILoggerFactory loggerFactory) : IDisposable +public sealed class DatabaseClientProvider(RustService rustService, ILoggerFactory loggerFactory) : IDisposable { private readonly Dictionary clients = new(); private readonly Dictionary locks = new(); @@ -91,17 +91,10 @@ public sealed partial class DatabaseClientProvider(RustService rustService, ILog private async Task CreateClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken) => databaseRole switch { - DatabaseRole.VECTOR_STORE => await this.CreateQdrantClientAsync(cancellationToken), + DatabaseRole.VECTOR_STORE => await QdrantEdgeClientImplementation.CreateAsync(rustService, this.logger, this.databaseClientLogger, cancellationToken), _ => new NoDatabaseClient(databaseRole.ToString(), "The requested database role is not supported.") }; - 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 diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/IVectorStoreClient.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/IVectorStoreClient.cs index f1e96623..363cf902 100644 --- a/app/MindWork AI Studio/Tools/Databases/VectorStore/IVectorStoreClient.cs +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/IVectorStoreClient.cs @@ -2,14 +2,6 @@ public interface IVectorStoreClient { - string Name { get; } - - DatabaseClientStatus Status { get; } - - bool IsAvailable { get; } - - IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo(); - Task EnsureVectorStoreExists(string storeName, int vectorSize, CancellationToken token); Task InsertEmbedding(string storeName, IReadOnlyList points, CancellationToken token); diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/NoVectorStoreClient.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/NoVectorStoreClient.cs index 6f9eaf87..75ed54da 100644 --- a/app/MindWork AI Studio/Tools/Databases/VectorStore/NoVectorStoreClient.cs +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/NoVectorStoreClient.cs @@ -2,19 +2,19 @@ using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.Databases.VectorStore; -public sealed class NoVectorStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : IVectorStoreClient +public sealed class NoVectorStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : DatabaseClient(name, string.Empty), IVectorStoreClient { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoVectorStoreClient).Namespace, nameof(NoVectorStoreClient)); - public string Name => name; + public override DatabaseClientStatus Status => status; - public DatabaseClientStatus Status => status; - - public bool IsAvailable => false; - - public async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() + 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); @@ -36,4 +36,8 @@ public sealed class NoVectorStoreClient(string name, string? unavailableReason, private InvalidOperationException CreateUnavailableException() => new(unavailableReason ?? "The vector store is not available."); + + public override void Dispose() + { + } } diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantClientImplementation.cs deleted file mode 100644 index 97a1c600..00000000 --- a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantClientImplementation.cs +++ /dev/null @@ -1,142 +0,0 @@ -using Qdrant.Client; -using Qdrant.Client.Grpc; -using Grpc.Core; -using AIStudio.Tools.PluginSystem; -using static Qdrant.Client.Grpc.Conditions; - -namespace AIStudio.Tools.Databases.VectorStore; - -public class QdrantClientImplementation : DatabaseClient, IVectorStoreClient -{ - private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(QdrantClientImplementation).Namespace, nameof(QdrantClientImplementation)); - - private int HttpPort { get; } - - private int GrpcPort { get; } - - private QdrantClient GrpcClient { get; } - - private string Fingerprint { get; } - - private string ApiToken { get; } - - public QdrantClientImplementation(string name, string path, int httpPort, int grpcPort, string fingerprint, string apiToken): base(name, path) - { - this.HttpPort = httpPort; - this.GrpcPort = grpcPort; - this.Fingerprint = fingerprint; - 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"; - - private QdrantClient CreateQdrantClient() - { - var address = "https://" + IP_ADDRESS + ":" + this.GrpcPort; - var channel = QdrantChannel.ForAddress(address, new ClientConfiguration - { - ApiKey = this.ApiToken, - CertificateThumbprint = this.Fingerprint - }); - var grpcClient = new QdrantGrpcClient(channel); - return new QdrantClient(grpcClient); - } - - private async Task GetVersion() - { - var operation = await this.GrpcClient.HealthAsync(); - return $"v{operation.Version}"; - } - - public async Task CheckAvailabilityAsync() - { - await this.GrpcClient.HealthAsync(); - } - - private async Task GetCollectionsAmount() - { - var operation = await this.GrpcClient.ListCollectionsAsync(); - return operation.Count.ToString(); - } - - public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() - { - yield return (TB("HTTP port"), this.HttpPort.ToString()); - yield return (TB("gRPC port"), this.GrpcPort.ToString()); - yield return (TB("Reported version"), await this.GetVersion()); - yield return (TB("Storage size"), $"{this.GetStorageSize()}"); - yield return (TB("Number of collections"), await this.GetCollectionsAmount()); - } - - public async Task EnsureVectorStoreExists(string collectionName, int vectorSize, CancellationToken token) - { - var exists = await this.GrpcClient.CollectionExistsAsync(collectionName, token); - if (exists) - return; - - await this.GrpcClient.CreateCollectionAsync( - collectionName, - new VectorParams - { - Size = (ulong)vectorSize, - Distance = Distance.Cosine, - }, - cancellationToken: token); - } - - public Task InsertEmbedding(string collectionName, IReadOnlyList points, CancellationToken token) - { - var qdrantPoints = points.Select(point => new PointStruct - { - Id = Guid.Parse(point.PointId), - Vectors = point.Vector.ToArray(), - Payload = - { - ["data_source_id"] = point.DataSourceId, - ["data_source_name"] = point.DataSourceName, - ["data_source_type"] = point.DataSourceType, - ["file_path"] = point.FilePath, - ["file_name"] = point.FileName, - ["relative_path"] = point.RelativePath, - ["chunk_index"] = (long)point.ChunkIndex, - ["text"] = point.Text, - ["fingerprint"] = point.Fingerprint, - ["last_write_utc"] = point.LastWriteUtc.ToString("O"), - ["embedded_at_utc"] = point.EmbeddedAtUtc.ToString("O"), - } - }).ToList(); - - return this.GrpcClient.UpsertAsync(collectionName, qdrantPoints, true, null, null, token); - } - - public async Task DeleteEmbeddingByFile(string collectionName, string filePath, CancellationToken token) - { - try - { - await this.GrpcClient.DeleteAsync(collectionName, MatchKeyword("file_path", filePath), true, null, null, token); - } - catch (RpcException exception) when (exception.StatusCode is StatusCode.NotFound) - { - } - } - - public async Task DeleteVectorStore(string collectionName, CancellationToken token) - { - var exists = await this.GrpcClient.CollectionExistsAsync(collectionName, token); - if (!exists) - return; - - try - { - await this.GrpcClient.DeleteCollectionAsync(collectionName, cancellationToken: token); - } - catch (RpcException exception) when (exception.StatusCode is StatusCode.NotFound) - { - } - } - - public override void Dispose() => this.GrpcClient.Dispose(); -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs new file mode 100644 index 00000000..6e606246 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs @@ -0,0 +1,109 @@ +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tools.Databases.VectorStore; + +public sealed class QdrantEdgeClientImplementation( + string name, + string path, + string version, + int storesCount, + RustService rustService) : DatabaseClient(name, path), IVectorStoreClient +{ + private const string DATABASE_NAME = "Qdrant Edge"; + private const string INFO_PATH = "/system/qdrant-edge/info"; + private const string ENSURE_PATH = "/system/qdrant-edge/ensure"; + private const string INSERT_PATH = "/system/qdrant-edge/insert"; + private const string DELETE_FILE_PATH = "/system/qdrant-edge/delete-file"; + private const string DELETE_STORE_PATH = "/system/qdrant-edge/delete-store"; + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(QdrantEdgeClientImplementation).Namespace, nameof(QdrantEdgeClientImplementation)); + + public override string CacheKey => $"{this.Name}:{path}:{version}"; + + public static async Task CreateAsync( + RustService rustService, + ILogger logger, + ILogger databaseClientLogger, + CancellationToken cancellationToken) + { + var qdrantEdgeInfo = await rustService.GetDatabaseInfo( + DATABASE_NAME, + INFO_PATH, + QdrantEdgeInfo.Unavailable, + cancellationToken); + + if (qdrantEdgeInfo.Status is QdrantEdgeStatus.STARTING) + { + return CreateNoVectorStoreClient( + DATABASE_NAME, + $"{DATABASE_NAME} is starting. Details will appear shortly.", + DatabaseClientStatus.STARTING, + databaseClientLogger); + } + + if (!qdrantEdgeInfo.IsAvailable || qdrantEdgeInfo.Status is QdrantEdgeStatus.UNAVAILABLE) + { + var reason = qdrantEdgeInfo.UnavailableReason ?? "unknown"; + logger.LogWarning("{VectorStoreName} is not available. Starting without {VectorStoreName} vector store. Reason: '{Reason}'.", DATABASE_NAME, DATABASE_NAME, reason); + return CreateNoVectorStoreClient(DATABASE_NAME, qdrantEdgeInfo.UnavailableReason, DatabaseClientStatus.UNAVAILABLE, databaseClientLogger); + } + + if (qdrantEdgeInfo.Path == string.Empty) + return CreateNoVectorStoreClient(DATABASE_NAME, $"Failed to get the {DATABASE_NAME} path from Rust.", DatabaseClientStatus.UNAVAILABLE, databaseClientLogger); + + var name = string.IsNullOrWhiteSpace(qdrantEdgeInfo.Name) ? DATABASE_NAME : qdrantEdgeInfo.Name; + var client = new QdrantEdgeClientImplementation(name, qdrantEdgeInfo.Path, qdrantEdgeInfo.Version, qdrantEdgeInfo.StoresCount, rustService); + client.SetLogger(databaseClientLogger); + return client; + } + + public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo() + { + var currentInfo = await rustService.GetDatabaseInfo( + DATABASE_NAME, + INFO_PATH, + QdrantEdgeInfo.Unavailable); + var displayVersion = currentInfo.IsAvailable && !string.IsNullOrWhiteSpace(currentInfo.Version) ? currentInfo.Version : version; + var displayStoresCount = currentInfo.IsAvailable ? currentInfo.StoresCount : storesCount; + + if (!currentInfo.IsAvailable) + yield return (TB("Status"), currentInfo.UnavailableReason ?? TB("Qdrant Edge is not available.")); + + yield return (TB("Reported version"), displayVersion); + yield return (TB("Storage size"), $"{this.GetStorageSize()}"); + yield return (TB("Number of vector stores"), displayStoresCount.ToString()); + } + + public Task EnsureVectorStoreExists(string storeName, int vectorSize, CancellationToken token) => + rustService.ExecuteDatabaseOperation(DATABASE_NAME, ENSURE_PATH, new EnsureVectorStoreRequest(storeName, vectorSize), token); + + public Task InsertEmbedding(string storeName, IReadOnlyList points, CancellationToken token) => + rustService.ExecuteDatabaseOperation(DATABASE_NAME, INSERT_PATH, new InsertEmbeddingRequest(storeName, points), token); + + public Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token) => + rustService.ExecuteDatabaseOperation(DATABASE_NAME, DELETE_FILE_PATH, new DeleteEmbeddingByFileRequest(storeName, filePath), token); + + public Task DeleteVectorStore(string storeName, CancellationToken token) => + rustService.ExecuteDatabaseOperation(DATABASE_NAME, DELETE_STORE_PATH, new DeleteVectorStoreRequest(storeName), token); + + public override void Dispose() + { + } + + private static NoVectorStoreClient CreateNoVectorStoreClient(string name, string? unavailableReason, DatabaseClientStatus status, ILogger databaseClientLogger) + { + var client = new NoVectorStoreClient(name, unavailableReason, status); + client.SetLogger(databaseClientLogger); + return client; + } + + private sealed record EnsureVectorStoreRequest(string StoreName, int VectorSize); + + private sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList Points); + + private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath); + + private sealed record DeleteVectorStoreRequest(string StoreName); +} diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStoragePoint.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/VectorStoragePoint.cs similarity index 88% rename from app/MindWork AI Studio/Tools/Databases/VectorStoragePoint.cs rename to app/MindWork AI Studio/Tools/Databases/VectorStore/VectorStoragePoint.cs index a614d664..fc95ed38 100644 --- a/app/MindWork AI Studio/Tools/Databases/VectorStoragePoint.cs +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/VectorStoragePoint.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools.Databases; +namespace AIStudio.Tools.Databases.VectorStore; public sealed record VectorStoragePoint( string PointId, diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantEdgeInfo.cs b/app/MindWork AI Studio/Tools/Rust/QdrantEdgeInfo.cs new file mode 100644 index 00000000..4e438f2f --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/QdrantEdgeInfo.cs @@ -0,0 +1,27 @@ +namespace AIStudio.Tools.Rust; + +/// +/// The response of the Qdrant Edge information request. +/// +public readonly record struct QdrantEdgeInfo +{ + public QdrantEdgeStatus Status { get; init; } + + public bool IsAvailable { get; init; } + + public string? UnavailableReason { get; init; } + + public string Name { get; init; } + + public string Version { get; init; } + + public string Path { get; init; } + + public int StoresCount { get; init; } + + public static QdrantEdgeInfo Unavailable(string reason) => new() + { + Status = QdrantEdgeStatus.UNAVAILABLE, + UnavailableReason = reason + }; +} diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs b/app/MindWork AI Studio/Tools/Rust/QdrantEdgeStatus.cs similarity index 72% rename from app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs rename to app/MindWork AI Studio/Tools/Rust/QdrantEdgeStatus.cs index 10d6246a..fb06dfc5 100644 --- a/app/MindWork AI Studio/Tools/Rust/QdrantStatus.cs +++ b/app/MindWork AI Studio/Tools/Rust/QdrantEdgeStatus.cs @@ -1,8 +1,8 @@ namespace AIStudio.Tools.Rust; -public enum QdrantStatus +public enum QdrantEdgeStatus { STARTING, AVAILABLE, UNAVAILABLE, -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs b/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs deleted file mode 100644 index 30044596..00000000 --- a/app/MindWork AI Studio/Tools/Rust/QdrantInfo.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace AIStudio.Tools.Rust; - -/// -/// The response of the Qdrant information request. -/// -public readonly record struct QdrantInfo -{ - public QdrantStatus Status { get; init; } - - public bool IsAvailable { get; init; } - - public string? UnavailableReason { get; init; } - - public string Path { get; init; } - - public int PortHttp { get; init; } - - public int PortGrpc { get; init; } - - public string Fingerprint { get; init; } - - public string ApiToken { get; init; } -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs index 3efc8050..7760421a 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs @@ -1,43 +1,53 @@ -using AIStudio.Tools.Rust; - namespace AIStudio.Tools.Services; public sealed partial class RustService { - public async Task GetQdrantInfo(CancellationToken cancellationToken = default) + public async Task GetDatabaseInfo( + string databaseName, + string infoPath, + Func unavailableFactory, + CancellationToken cancellationToken = default) { try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(45)); - - return await this.http.GetFromJsonAsync("/system/qdrant/info", this.jsonRustSerializerOptions, cts.Token); + + var databaseInfo = await this.http.GetFromJsonAsync(infoPath, this.jsonRustSerializerOptions, cts.Token); + return databaseInfo ?? unavailableFactory("The database information response was empty."); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { if(this.logger is not null) - this.logger.LogWarning("Fetching Qdrant info from Rust service was cancelled by caller."); + this.logger.LogWarning("Fetching {DatabaseName} info from Rust service was cancelled by caller.", databaseName); else - Console.WriteLine("Fetching Qdrant info from Rust service was cancelled by caller."); - - return new QdrantInfo - { - Status = QdrantStatus.UNAVAILABLE, - UnavailableReason = "Operation cancelled by caller." - }; + Console.WriteLine($"Fetching {databaseName} info from Rust service was cancelled by caller."); + + return unavailableFactory("Operation cancelled by caller."); } catch (Exception e) { if(this.logger is not null) - this.logger.LogError(e, "Error while fetching Qdrant info from Rust service."); + this.logger.LogError(e, "Error while fetching {DatabaseName} info from Rust service.", databaseName); else - Console.WriteLine($"Error while fetching Qdrant info from Rust service: '{e}'."); - - return new QdrantInfo - { - Status = QdrantStatus.UNAVAILABLE, - UnavailableReason = e.Message - }; + Console.WriteLine($"Error while fetching {databaseName} info from Rust service: '{e}'."); + + return unavailableFactory(e.Message); } } -} \ No newline at end of file + + public async Task ExecuteDatabaseOperation(string databaseName, string path, TRequest request, CancellationToken cancellationToken = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(5)); + + var response = await this.http.PostAsJsonAsync(path, request, this.jsonRustSerializerOptions, cts.Token); + response.EnsureSuccessStatusCode(); + + var operation = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, cts.Token); + if (operation is not { Success: true }) + throw new InvalidOperationException(operation?.Issue ?? $"The {databaseName} operation failed."); + } + + private sealed record DatabaseOperationResponse(bool Success, string Issue); +} diff --git a/documentation/Build.md b/documentation/Build.md index 8022cd7d..3301562e 100644 --- a/documentation/Build.md +++ b/documentation/Build.md @@ -50,13 +50,6 @@ You can now test your changes. To stop the application: - Press ``Ctrl+C`` in the terminal where the app is running. - Stop the process via your IDE’s run/debug controls. -> ⚠️ Important: Stopping the app via ``Ctrl+C`` or the IDE may not terminate the Qdrant sidecar process, especially on Windows. This can lead to startup failures when restarting the app. - -If you encounter issues with restarting Tauri, then manually kill the Qdrant process: -- **Linux/macOS:** Run pkill -f qdrant in your terminal. -- **Windows:** Open Task Manager → Find qdrant.exe → Right-click → “End task”. -- Restart your Tauri app. - ## Create a release In order to create a release: 1. To create a new release, you need to be a maintainer of the repository—see step 8. @@ -68,4 +61,4 @@ In order to create a release: 7. Your proposed changes will be reviewed and merged. 8. Once the PR is merged, a member of the maintainers team will create & push an appropriate git tag in the format `vX.Y.Z`. 9. The GitHub Workflow will then build the release and upload it to the [release page](https://github.com/MindWorkAI/AI-Studio/releases/latest). -10. Building the release including virus scanning takes some time. Please be patient. \ No newline at end of file +10. Building the release including virus scanning takes some time. Please be patient. diff --git a/metadata.txt b/metadata.txt index 533a4c14..d7086eb4 100644 --- a/metadata.txt +++ b/metadata.txt @@ -9,4 +9,4 @@ d05ff26e628, release osx-arm64 148.0.7763.0 -1.18.1 \ No newline at end of file +0.6.1 \ No newline at end of file diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 02cf75f0..ba7f5bd1 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -48,6 +48,7 @@ tempfile = "3.27.0" strum_macros = "0.28.0" sysinfo = "0.39.1" bytes = "1.11.1" +qdrant-edge = "0.6.1" [target.'cfg(target_os = "windows")'.dependencies] windows-registry = "0.6.1" diff --git a/runtime/capabilities/default.json b/runtime/capabilities/default.json index 86f14897..edd9c22f 100644 --- a/runtime/capabilities/default.json +++ b/runtime/capabilities/default.json @@ -22,11 +22,6 @@ "name": "mindworkAIStudioServer", "sidecar": true, "args": true - }, - { - "name": "qdrant", - "sidecar": true, - "args": true } ] } diff --git a/runtime/resources/databases/qdrant/config.yaml b/runtime/resources/databases/qdrant/config.yaml deleted file mode 100644 index 50f03e08..00000000 --- a/runtime/resources/databases/qdrant/config.yaml +++ /dev/null @@ -1,354 +0,0 @@ -log_level: INFO - -# Logging configuration -# Qdrant logs to stdout. You may configure to also write logs to a file on disk. -# Be aware that this file may grow indefinitely. -# logger: -# # Logging format, supports `text` and `json` -# format: text -# on_disk: -# enabled: true -# log_file: path/to/log/file.log -# log_level: INFO -# # Logging format, supports `text` and `json` -# format: text -# buffer_size_bytes: 1024 - -storage: - - snapshots_config: - # "local" or "s3" - where to store snapshots - snapshots_storage: local - # s3_config: - # bucket: "" - # region: "" - # access_key: "" - # secret_key: "" - - # Where to store temporary files - # If null, temporary snapshots are stored in: storage/snapshots_temp/ - temp_path: null - - # If true - point payloads will not be stored in memory. - # It will be read from the disk every time it is requested. - # This setting saves RAM by (slightly) increasing the response time. - # Note: those payload values that are involved in filtering and are indexed - remain in RAM. - # - # Default: true - on_disk_payload: true - - # Maximum number of concurrent updates to shard replicas - # If `null` - maximum concurrency is used. - update_concurrency: null - - # Write-ahead-log related configuration - wal: - # Size of a single WAL segment - wal_capacity_mb: 32 - - # Number of WAL segments to create ahead of actual data requirement - wal_segments_ahead: 0 - - # Normal node - receives all updates and answers all queries - node_type: "Normal" - - # Listener node - receives all updates, but does not answer search/read queries - # Useful for setting up a dedicated backup node - # node_type: "Listener" - - performance: - # Number of parallel threads used for search operations. If 0 - auto selection. - max_search_threads: 0 - - # CPU budget, how many CPUs (threads) to allocate for an optimization job. - # If 0 - auto selection, keep 1 or more CPUs unallocated depending on CPU size - # If negative - subtract this number of CPUs from the available CPUs. - # If positive - use this exact number of CPUs. - optimizer_cpu_budget: 0 - - # Prevent DDoS of too many concurrent updates in distributed mode. - # One external update usually triggers multiple internal updates, which breaks internal - # timings. For example, the health check timing and consensus timing. - # If null - auto selection. - update_rate_limit: null - - # Limit for number of incoming automatic shard transfers per collection on this node, does not affect user-requested transfers. - # The same value should be used on all nodes in a cluster. - # Default is to allow 1 transfer. - # If null - allow unlimited transfers. - #incoming_shard_transfers_limit: 1 - - # Limit for number of outgoing automatic shard transfers per collection on this node, does not affect user-requested transfers. - # The same value should be used on all nodes in a cluster. - # Default is to allow 1 transfer. - # If null - allow unlimited transfers. - #outgoing_shard_transfers_limit: 1 - - # Enable async scorer which uses io_uring when rescoring. - # Only supported on Linux, must be enabled in your kernel. - # See: - #async_scorer: false - - optimizers: - # The minimal fraction of deleted vectors in a segment, required to perform segment optimization - deleted_threshold: 0.2 - - # The minimal number of vectors in a segment, required to perform segment optimization - vacuum_min_vector_number: 1000 - - # Target amount of segments optimizer will try to keep. - # Real amount of segments may vary depending on multiple parameters: - # - Amount of stored points - # - Current write RPS - # - # It is recommended to select default number of segments as a factor of the number of search threads, - # so that each segment would be handled evenly by one of the threads. - # If `default_segment_number = 0`, will be automatically selected by the number of available CPUs - default_segment_number: 0 - - # Do not create segments larger this size (in KiloBytes). - # Large segments might require disproportionately long indexation times, - # therefore it makes sense to limit the size of segments. - # - # If indexation speed have more priority for your - make this parameter lower. - # If search speed is more important - make this parameter higher. - # Note: 1Kb = 1 vector of size 256 - # If not set, will be automatically selected considering the number of available CPUs. - max_segment_size_kb: null - - # Maximum size (in KiloBytes) of vectors allowed for plain index. - # Default value based on experiments and observations. - # Note: 1Kb = 1 vector of size 256 - # To explicitly disable vector indexing, set to `0`. - # If not set, the default value will be used. - indexing_threshold_kb: 10000 - - # Interval between forced flushes. - flush_interval_sec: 5 - - # Max number of threads (jobs) for running optimizations per shard. - # Note: each optimization job will also use `max_indexing_threads` threads by itself for index building. - # If null - have no limit and choose dynamically to saturate CPU. - # If 0 - no optimization threads, optimizations will be disabled. - max_optimization_threads: null - - # This section has the same options as 'optimizers' above. All values specified here will overwrite the collections - # optimizers configs regardless of the config above and the options specified at collection creation. - #optimizers_overwrite: - # deleted_threshold: 0.2 - # vacuum_min_vector_number: 1000 - # default_segment_number: 0 - # max_segment_size_kb: null - # indexing_threshold_kb: 10000 - # flush_interval_sec: 5 - # max_optimization_threads: null - - # Default parameters of HNSW Index. Could be overridden for each collection or named vector individually - hnsw_index: - # Number of edges per node in the index graph. Larger the value - more accurate the search, more space required. - m: 16 - - # Number of neighbours to consider during the index building. Larger the value - more accurate the search, more time required to build index. - ef_construct: 100 - - # Minimal size threshold (in KiloBytes) below which full-scan is preferred over HNSW search. - # This measures the total size of vectors being queried against. - # When the maximum estimated amount of points that a condition satisfies is smaller than - # `full_scan_threshold_kb`, the query planner will use full-scan search instead of HNSW index - # traversal for better performance. - # Note: 1Kb = 1 vector of size 256 - full_scan_threshold_kb: 10000 - - # Number of parallel threads used for background index building. - # If 0 - automatically select. - # Best to keep between 8 and 16 to prevent likelihood of building broken/inefficient HNSW graphs. - # On small CPUs, less threads are used. - max_indexing_threads: 0 - - # Store HNSW index on disk. If set to false, index will be stored in RAM. Default: false - on_disk: false - - # Custom M param for hnsw graph built for payload index. If not set, default M will be used. - payload_m: null - - # Default shard transfer method to use if none is defined. - # If null - don't have a shard transfer preference, choose automatically. - # If stream_records, snapshot or wal_delta - prefer this specific method. - # More info: https://qdrant.tech/documentation/guides/distributed_deployment/#shard-transfer-method - shard_transfer_method: null - - # Default parameters for collections - collection: - # Number of replicas of each shard that network tries to maintain - replication_factor: 1 - - # How many replicas should apply the operation for us to consider it successful - write_consistency_factor: 1 - - # Default parameters for vectors. - vectors: - # Whether vectors should be stored in memory or on disk. - on_disk: null - - # shard_number_per_node: 1 - - # Default quantization configuration. - # More info: https://qdrant.tech/documentation/guides/quantization - quantization: null - - # Default strict mode parameters for newly created collections. - #strict_mode: - # Whether strict mode is enabled for a collection or not. - #enabled: false - - # Max allowed `limit` parameter for all APIs that don't have their own max limit. - #max_query_limit: null - - # Max allowed `timeout` parameter. - #max_timeout: null - - # Allow usage of unindexed fields in retrieval based (eg. search) filters. - #unindexed_filtering_retrieve: null - - # Allow usage of unindexed fields in filtered updates (eg. delete by payload). - #unindexed_filtering_update: null - - # Max HNSW value allowed in search parameters. - #search_max_hnsw_ef: null - - # Whether exact search is allowed or not. - #search_allow_exact: null - - # Max oversampling value allowed in search. - #search_max_oversampling: null - - # Maximum number of collections allowed to be created - # If null - no limit. - max_collections: null - -service: - # Maximum size of POST data in a single request in megabytes - max_request_size_mb: 32 - - # Number of parallel workers used for serving the api. If 0 - equal to the number of available cores. - # If missing - Same as storage.max_search_threads - max_workers: 0 - - # Host to bind the service on - host: 127.0.0.1 - - # HTTP(S) port to bind the service on - # http_port: 6333 - - # gRPC port to bind the service on. - # If `null` - gRPC is disabled. Default: null - # Comment to disable gRPC: - # grpc_port: 6334 - - # Enable CORS headers in REST API. - # If enabled, browsers would be allowed to query REST endpoints regardless of query origin. - # More info: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS - # Default: true - enable_cors: false - - # Enable HTTPS for the REST and gRPC API - # TLS is enabled in AI Studio through environment variables when instantiating Qdrant as a sidecar. - # enable_tls: false - - # Check user HTTPS client certificate against CA file specified in tls config - verify_https_client_certificate: false - - # Set an api-key. - # If set, all requests must include a header with the api-key. - # example header: `api-key: ` - # - # If you enable this you should also enable TLS. - # (Either above or via an external service like nginx.) - # Sending an api-key over an unencrypted channel is insecure. - # - # Uncomment to enable. - # api_key: your_secret_api_key_here - - # Set an api-key for read-only operations. - # If set, all requests must include a header with the api-key. - # example header: `api-key: ` - # - # If you enable this you should also enable TLS. - # (Either above or via an external service like nginx.) - # Sending an api-key over an unencrypted channel is insecure. - # - # Uncomment to enable. - # read_only_api_key: your_secret_read_only_api_key_here - - # Uncomment to enable JWT Role Based Access Control (RBAC). - # If enabled, you can generate JWT tokens with fine-grained rules for access control. - # Use generated token instead of API key. - # - # jwt_rbac: true - - # Hardware reporting adds information to the API responses with a - # hint on how many resources were used to execute the request. - # - # Warning: experimental, this feature is still under development and is not supported yet. - # - # Uncomment to enable. - # hardware_reporting: true - # - # Uncomment to enable. - # Prefix for the names of metrics in the /metrics API. - # metrics_prefix: qdrant_ - -cluster: - # Use `enabled: true` to run Qdrant in distributed deployment mode - enabled: false - - # Configuration of the inter-cluster communication - p2p: - # Port for internal communication between peers - port: 6335 - - # Use TLS for communication between peers - enable_tls: false - - # Configuration related to distributed consensus algorithm - consensus: - # How frequently peers should ping each other. - # Setting this parameter to lower value will allow consensus - # to detect disconnected nodes earlier, but too frequent - # tick period may create significant network and CPU overhead. - # We encourage you NOT to change this parameter unless you know what you are doing. - tick_period_ms: 100 - - # Compact consensus operations once we have this amount of applied - # operations. Allows peers to join quickly with a consensus snapshot without - # replaying a huge amount of operations. - # If 0 - disable compaction - compact_wal_entries: 128 - -# Set to true to prevent service from sending usage statistics to the developers. -# Read more: https://qdrant.tech/documentation/guides/telemetry -telemetry_disabled: true - -# TLS configuration. -# Required if either service.enable_tls or cluster.p2p.enable_tls is true. -tls: - # Server certificate chain file - # cert: ./tls/cert.pem - - # Server private key file - # key: ./tls/key.pem - - # Certificate authority certificate file. - # This certificate will be used to validate the certificates - # presented by other nodes during inter-cluster communication. - # - # If verify_https_client_certificate is true, it will verify - # HTTPS client certificate - # - # Required if cluster.p2p.enable_tls is true. - ca_cert: ./tls/cacert.pem - - # TTL in seconds to reload certificate from disk, useful for certificate rotations. - # Only works for HTTPS endpoints. Does not support gRPC (and intra-cluster communication). - # If `null` - TTL is disabled. - cert_ttl: 3600 \ No newline at end of file diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index dd54e205..f2d9c304 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -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::{start_qdrant_server, stop_qdrant_server}; +use crate::qdrant_edge_database::{start_qdrant_edge_database, stop_qdrant_edge_database}; #[cfg(debug_assertions)] use crate::dotnet::create_startup_env_file; @@ -148,7 +148,7 @@ pub fn start_tauri() { start_dotnet_server(app.handle().clone()); } - start_qdrant_server(app.handle().clone()); + start_qdrant_edge_database(app.handle().clone()); info!(Source = "Bootloader Tauri"; "Reconfigure the file logger to use the app data directory {data_path:?}"); switch_to_file_logging(data_path).map_err(|e| error!("Failed to switch logging to file: {e}")).unwrap(); @@ -183,7 +183,7 @@ pub fn start_tauri() { RunEvent::ExitRequested { .. } => { warn!(Source = "Tauri"; "Run event: exit was requested."); - stop_qdrant_server(); + stop_qdrant_edge_database(); if is_prod() { warn!("Try to stop the .NET server as well..."); stop_dotnet_server(); @@ -537,7 +537,7 @@ pub async fn install_update(_token: APIToken) { if is_prod() { stop_dotnet_server(); - stop_qdrant_server(); + stop_qdrant_edge_database(); } else { warn!(Source = "Tauri"; "Development environment detected; do not stop the .NET server."); } @@ -1000,4 +1000,4 @@ mod tests { assert!(!is_tauri_asset_url(&url)); assert!(!is_local_http_url(&url)); } -} \ No newline at end of file +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index b36a1505..ac9f9250 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -13,7 +13,7 @@ pub mod file_data; pub mod metadata; pub mod pdfium; pub mod pandoc; -pub mod qdrant; +pub mod qdrant_edge_database; pub mod certificate_factory; pub mod runtime_api_token; pub mod stale_process_cleanup; diff --git a/runtime/src/main.rs b/runtime/src/main.rs index c03f26dc..a75f73eb 100644 --- a/runtime/src/main.rs +++ b/runtime/src/main.rs @@ -34,7 +34,7 @@ async fn main() { info!(".. MudBlazor: v{mud_blazor_version}", mud_blazor_version = metadata.mud_blazor_version); info!(".. Tauri: v{tauri_version}", tauri_version = metadata.tauri_version); info!(".. PDFium: v{pdfium_version}", pdfium_version = metadata.pdfium_version); - info!(".. Qdrant: v{qdrant_version}", qdrant_version = metadata.qdrant_version); + info!(".. Vector store: v{vector_store_version}", vector_store_version = metadata.vector_store_version); if is_dev() { warn!("Running in development mode."); diff --git a/runtime/src/metadata.rs b/runtime/src/metadata.rs index fa56dd68..df72640b 100644 --- a/runtime/src/metadata.rs +++ b/runtime/src/metadata.rs @@ -16,7 +16,7 @@ pub struct MetaData { pub app_commit_hash: String, pub architecture: String, pub pdfium_version: String, - pub qdrant_version: String, + pub vector_store_version: String, } impl MetaData { @@ -40,7 +40,7 @@ impl MetaData { let app_commit_hash = metadata_lines.next().unwrap(); let architecture = metadata_lines.next().unwrap(); let pdfium_version = metadata_lines.next().unwrap(); - let qdrant_version = metadata_lines.next().unwrap(); + let vector_store_version = metadata_lines.next().unwrap(); let metadata = MetaData { architecture: architecture.to_string(), @@ -54,7 +54,7 @@ impl MetaData { rust_version: rust_version.to_string(), tauri_version: tauri_version.to_string(), pdfium_version: pdfium_version.to_string(), - qdrant_version: qdrant_version.to_string(), + vector_store_version: vector_store_version.to_string(), }; *META_DATA.lock().unwrap() = Some(metadata.clone()); diff --git a/runtime/src/qdrant.rs b/runtime/src/qdrant.rs deleted file mode 100644 index 639dd7c7..00000000 --- a/runtime/src/qdrant.rs +++ /dev/null @@ -1,374 +0,0 @@ -use std::collections::HashMap; -use std::{fs}; -use std::error::Error; -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; -use serde::Serialize; -use crate::api_token::{APIToken}; -use crate::environment::{is_dev, DATA_DIRECTORY}; -use crate::certificate_factory::generate_certificate; -use std::path::PathBuf; -use tauri::Manager; -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; - -// Qdrant server process started in a separate process and can communicate -// via HTTP or gRPC with the .NET server and the runtime process -static QDRANT_SERVER: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(None))); - -// Qdrant server port (default is 6333 for HTTP and 6334 for gRPC) -static QDRANT_SERVER_PORT_HTTP: Lazy = Lazy::new(|| { - crate::network::get_available_port().unwrap_or(6333) -}); - -static QDRANT_SERVER_PORT_GRPC: Lazy = Lazy::new(|| { - crate::network::get_available_port().unwrap_or(6334) -}); - -pub static CERTIFICATE_FINGERPRINT: OnceLock = OnceLock::new(); -static API_TOKEN: Lazy = Lazy::new(|| { - crate::api_token::generate_api_token() -}); - -static TMPDIR: Lazy>> = Lazy::new(|| Mutex::new(None)); -static QDRANT_STATUS: Lazy> = 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 QdrantStatusInfo { - status: QdrantStatus, - unavailable_reason: Option, -} - -fn qdrant_base_path() -> PathBuf { - let qdrant_directory = if is_dev() { "qdrant_test" } else { "qdrant" }; - Path::new(DATA_DIRECTORY.get().unwrap()) - .join("databases") - .join(qdrant_directory) -} - -#[derive(Serialize)] -pub struct ProvideQdrantInfo { - status: QdrantStatus, - path: String, - port_http: u16, - port_grpc: u16, - fingerprint: String, - api_token: String, - is_available: bool, - unavailable_reason: Option, -} - -pub async fn qdrant_port(_token: APIToken) -> Json { - let status = QDRANT_STATUS.lock().unwrap(); - 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 { - String::new() - }, - port_http: if is_available { *QDRANT_SERVER_PORT_HTTP } else { 0 }, - port_grpc: if is_available { *QDRANT_SERVER_PORT_GRPC } else { 0 }, - fingerprint: if is_available { - CERTIFICATE_FINGERPRINT.get().cloned().unwrap_or_default() - } else { - String::new() - }, - api_token: if is_available { - API_TOKEN.to_hex_text().to_string() - } else { - String::new() - }, - is_available, - unavailable_reason, - }) -} - -/// Starts the Qdrant server in a separate process. -pub fn start_qdrant_server(app_handle: tauri::AppHandle){ - set_qdrant_starting(); - tauri::async_runtime::spawn(async move { - cleanup_qdrant(); - start_qdrant_server_internal(app_handle); - }); -} - -fn start_qdrant_server_internal(app_handle: tauri::AppHandle){ - 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); - set_qdrant_unavailable(format!("The Qdrant data directory could not be created: {e}")); - return; - } - - let (cert_path, key_path) = match create_temp_tls_files(&path) { - Ok(paths) => paths, - Err(e) => { - error!(Source="Qdrant"; "TLS files for Qdrant could not be created: {e}"); - set_qdrant_unavailable(format!("TLS files for Qdrant could not be created: {e}")); - return; - } - }; - - 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"); - let init_path_environment = init_path.to_string_lossy().to_string(); - - let qdrant_server_environment: HashMap = 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_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()), - (String::from("QDRANT__TLS__KEY"), key_path.to_string_lossy().to_string()), - (String::from("QDRANT__SERVICE__ENABLE_TLS"), "true".to_string()), - (String::from("QDRANT__SERVICE__API_KEY"), API_TOKEN.to_hex_text().to_string()), - ]); - - let server_spawn_clone = QDRANT_SERVER.clone(); - let qdrant_relative_source_path = "resources/databases/qdrant/config.yaml"; - let qdrant_source_path = match app_handle.path().resolve(qdrant_relative_source_path, BaseDirectory::Resource) { - Ok(path) => path, - Err(_) => { - let reason = format!("The Qdrant config resource '{qdrant_relative_source_path}' could not be resolved."); - error!(Source = "Qdrant"; "{reason} Starting the app without Qdrant."); - set_qdrant_unavailable(reason); - return; - } - }; - - let qdrant_source_path_display = qdrant_source_path.to_string_lossy().to_string(); - tauri::async_runtime::spawn(async move { - let shell = app_handle.shell(); - - let sidecar = match shell.sidecar("qdrant") { - Ok(sidecar) => sidecar, - Err(e) => { - let reason = format!("Failed to create sidecar for Qdrant: {e}"); - error!(Source = "Qdrant"; "{reason}"); - set_qdrant_unavailable(reason); - return; - } - }; - - let (mut rx, child) = match sidecar - .args(["--config-path", qdrant_source_path_display.as_str()]) - .envs(qdrant_server_environment) - .spawn() - { - Ok(process) => process, - Err(e) => { - let reason = format!("Failed to spawn Qdrant server process with config path '{}': {e}", qdrant_source_path_display); - error!(Source = "Qdrant"; "{reason}"); - set_qdrant_unavailable(reason); - return; - } - }; - - let server_pid = child.pid(); - 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 { - CommandEvent::Stdout(line) => { - let line_utf8 = String::from_utf8_lossy(&line).to_string(); - let line = line_utf8.trim_end(); - if line.contains("INFO") || line.contains("info") { - info!(Source = "Qdrant Server"; "{line}"); - } else if line.contains("WARN") || line.contains("warning") { - warn!(Source = "Qdrant Server"; "{line}"); - } else if line.contains("ERROR") || line.contains("error") { - error!(Source = "Qdrant Server"; "{line}"); - } else { - debug!(Source = "Qdrant Server"; "{line}"); - } - }, - - CommandEvent::Stderr(line) => { - 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); - }); -} - -/// Stops the Qdrant server process. -pub fn stop_qdrant_server() { - if let Some(server_process) = QDRANT_SERVER.lock().unwrap().take() { - let server_kill_result = server_process.kill(); - match server_kill_result { - Ok(_) => { - set_qdrant_unavailable("Qdrant server was stopped.".to_string()); - warn!(Source = "Qdrant"; "Qdrant server process was stopped.") - }, - Err(e) => error!(Source = "Qdrant"; "Failed to stop Qdrant server process: {e}."), - } - } else { - warn!(Source = "Qdrant"; "Qdrant server process was not started or is already stopped."); - } - - drop_tmpdir(); - 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> { - let cert = generate_certificate(); - - let temp_dir = init_tmpdir_in(path); - let cert_path = temp_dir.join("cert.pem"); - let key_path = temp_dir.join("key.pem"); - - let mut cert_file = File::create(&cert_path)?; - cert_file.write_all(&cert.certificate)?; - - let mut key_file = File::create(&key_path)?; - key_file.write_all(&cert.private_key)?; - - CERTIFICATE_FINGERPRINT.set(cert.fingerprint).expect("Could not set the certificate fingerprint."); - - Ok((cert_path, key_path)) -} - -pub fn init_tmpdir_in>(path: P) -> PathBuf { - let mut guard = TMPDIR.lock().unwrap(); - let dir = guard.get_or_insert_with(|| { - Builder::new() - .prefix("cert-") - .tempdir_in(path) - .expect("failed to create tempdir") - }); - - dir.path().to_path_buf() -} - -pub fn drop_tmpdir() { - let mut guard = TMPDIR.lock().unwrap(); - *guard = None; - warn!(Source = "Qdrant"; "Temporary directory for TLS was dropped."); -} - -/// Remove old Pid files and kill the corresponding processes -pub fn cleanup_qdrant() { - let path = qdrant_base_path(); - let pid_path = path.join(PID_FILE_NAME); - if let Err(e) = kill_stale_process(pid_path, SIDECAR_TYPE) { - warn!(Source = "Qdrant"; "Error during the cleanup of Qdrant: {}", e); - } - if let Err(e) = delete_old_certificates(path) { - warn!(Source = "Qdrant"; "Error during the cleanup of Qdrant: {}", e); - } - -} - -fn set_qdrant_available() { - let mut status = QDRANT_STATUS.lock().unwrap(); - 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.status = QdrantStatus::Unavailable; - status.unavailable_reason = Some(reason); -} - -pub fn delete_old_certificates(path: PathBuf) -> Result<(), Box> { - if !path.exists() { - return Ok(()); - } - - for entry in fs::read_dir(path)? { - let entry = entry?; - let path = entry.path(); - - if path.is_dir() { - let file_name = entry.file_name(); - let folder_name = file_name.to_string_lossy(); - - if folder_name.starts_with("cert-") { - fs::remove_dir_all(&path)?; - warn!(Source="Qdrant"; "Removed old certificates in: {}", path.display()); - } - } - } - Ok(()) -} \ No newline at end of file diff --git a/runtime/src/qdrant_edge_database.rs b/runtime/src/qdrant_edge_database.rs new file mode 100644 index 00000000..615a8a42 --- /dev/null +++ b/runtime/src/qdrant_edge_database.rs @@ -0,0 +1,537 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use axum::Json; +use log::{error, info, warn}; +use once_cell::sync::Lazy; +use qdrant_edge::external::serde_json::json; +use qdrant_edge::external::uuid::Uuid; +use qdrant_edge::{ + Condition, Distance, EdgeConfig, EdgeOptimizersConfig, EdgeShard, EdgeVectorParams, + FieldCondition, Filter, HnswIndexConfig, Match, MatchValue, PointId, PointInsertOperations, + PointOperations, PointStruct, UpdateOperation, ValueVariants, Vectors, +}; +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +use crate::api_token::APIToken; +use crate::environment::DATA_DIRECTORY; +use crate::metadata::META_DATA; + +const VECTOR_NAME: &str = "embedding"; +const HNSW_M: usize = 16; +const HNSW_EF_CONSTRUCT: usize = 100; +const HNSW_FULL_SCAN_THRESHOLD_KB: usize = 10_000; +const HNSW_MAX_INDEXING_THREADS: usize = 0; +const VECTOR_INDEXING_THRESHOLD_KB: usize = 10_000; + +type QdrantEdgeResult = Result>; + +static QDRANT_EDGE_DATABASE: Lazy>>> = + Lazy::new(|| Arc::new(Mutex::new(None))); + +static QDRANT_EDGE_STATUS: Lazy> = + Lazy::new(|| Mutex::new(QdrantEdgeStatusInfo::default())); + +#[derive(Default)] +struct QdrantEdgeStatusInfo { + status: QdrantEdgeStatus, + unavailable_reason: Option, +} + +#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)] +pub enum QdrantEdgeStatus { + #[default] + Starting, + Available, + Unavailable, +} + +#[derive(Serialize)] +pub struct QdrantEdgeServiceInfo { + pub status: QdrantEdgeStatus, + pub name: String, + pub version: String, + pub path: String, + pub stores_count: usize, + pub is_available: bool, + pub unavailable_reason: Option, +} + +#[derive(Clone, Deserialize)] +pub struct QdrantEdgeStoragePoint { + pub point_id: String, + pub vector: Vec, + pub data_source_id: String, + pub data_source_name: String, + pub data_source_type: String, + pub file_path: String, + pub file_name: String, + pub relative_path: String, + pub chunk_index: i32, + pub text: String, + pub fingerprint: String, + pub last_write_utc: String, + pub embedded_at_utc: String, +} + +#[derive(Deserialize)] +pub struct EnsureQdrantEdgeStoreRequest { + pub store_name: String, + pub vector_size: usize, +} + +#[derive(Deserialize)] +pub struct InsertQdrantEdgeEmbeddingRequest { + pub store_name: String, + pub points: Vec, +} + +#[derive(Deserialize)] +pub struct DeleteQdrantEdgeEmbeddingByFileRequest { + pub store_name: String, + pub file_path: String, +} + +#[derive(Deserialize)] +pub struct DeleteQdrantEdgeStoreRequest { + pub store_name: String, +} + +#[derive(Serialize)] +pub struct QdrantEdgeOperationResponse { + pub success: bool, + pub issue: String, +} + +#[derive(Clone, Serialize)] +pub struct QdrantEdgeInfo { + pub name: String, + pub version: String, + pub path: String, + pub stores_count: usize, +} + +pub struct QdrantEdgeDatabase { + base_path: PathBuf, + shards: HashMap, +} + +impl QdrantEdgeDatabase { + pub fn new(base_path: PathBuf) -> Self { + Self { + base_path, + shards: HashMap::new(), + } + } + + fn store_path(&self, store_name: &str) -> QdrantEdgeResult { + validate_store_name(store_name)?; + Ok(self.base_path.join("stores").join(store_name)) + } + + // To ensure a shard exists and that you can insert a vector + fn get_or_create_store(&mut self, store_name: &str, vector_size: usize) -> QdrantEdgeResult<&EdgeShard> { + if self.shards.contains_key(store_name) { + return Ok(self.shards.get(store_name).unwrap()); + } + + let path = self.store_path(store_name)?; + let shard = if has_existing_store(&path) { + EdgeShard::load(&path, None)? + } else { + fs::create_dir_all(&path)?; + EdgeShard::new(&path, edge_config(vector_size))? + }; + + self.shards.insert(store_name.to_string(), shard); + Ok(self.shards.get(store_name).unwrap()) + } + + // To check whether a shard exists so you can delete a file from it + fn get_existing_store(&mut self, store_name: &str) -> QdrantEdgeResult> { + if self.shards.contains_key(store_name) { + return Ok(self.shards.get(store_name)); + } + + let path = self.store_path(store_name)?; + if !has_existing_store(&path) { + return Ok(None); + } + + let shard = EdgeShard::load(&path, None)?; + self.shards.insert(store_name.to_string(), shard); + Ok(self.shards.get(store_name)) + } + + fn info(&self) -> QdrantEdgeResult { + let stores_path = self.base_path.join("stores"); + let stores_count = if stores_path.exists() { + fs::read_dir(stores_path)? + .filter_map(Result::ok) + .filter(|entry| entry.path().is_dir()) + .count() + } else { + 0 + }; + + Ok(QdrantEdgeInfo { + name: "Qdrant Edge".to_string(), + version: vector_store_version()?, + path: self.base_path.to_string_lossy().to_string(), + stores_count, + }) + } + + fn ensure_store_exists(&mut self, store_name: &str, vector_size: usize) -> QdrantEdgeResult<()> { + self.get_or_create_store(store_name, vector_size)?; + Ok(()) + } + + fn insert_embedding(&mut self, store_name: &str, points: Vec) -> QdrantEdgeResult<()> { + let Some(first_point) = points.first() else { + return Ok(()); + }; + + let vector_size = first_point.vector.len(); + if points.iter().any(|point| point.vector.len() != vector_size) { + return Err("All vectors in one insert request must have the same size.".into()); + } + + let shard = self.get_or_create_store(store_name, vector_size)?; + let points = points + .into_iter() + .map(to_qdrant_edge_point) + .collect::>(); + + shard.update(UpdateOperation::PointOperation( + PointOperations::UpsertPoints(PointInsertOperations::PointsList(points)), + ))?; + shard.flush(); + Ok(()) + } + + fn delete_embedding_by_file(&mut self, store_name: &str, file_path: &str) -> QdrantEdgeResult<()> { + let Some(shard) = self.get_existing_store(store_name)? else { + return Ok(()); + }; + + shard.update(UpdateOperation::PointOperation( + PointOperations::DeletePointsByFilter(match_keyword_filter("file_path", file_path)?), + ))?; + shard.flush(); + Ok(()) + } + + fn delete_store(&mut self, store_name: &str) -> QdrantEdgeResult<()> { + self.shards.remove(store_name); + + let path = self.store_path(store_name)?; + if path.exists() { + fs::remove_dir_all(path)?; + } + + Ok(()) + } + + fn base_path(&self) -> PathBuf { + self.base_path.clone() + } +} + +fn qdrant_edge_base_path() -> PathBuf { + Path::new(DATA_DIRECTORY.get().unwrap()) + .join("databases") + .join("vector_database") +} + +pub async fn qdrant_edge_info(_token: APIToken) -> Json { + let status = QDRANT_EDGE_STATUS.lock().unwrap(); + let current_status = status.status; + let unavailable_reason = status.unavailable_reason.clone(); + drop(status); + + let database_guard = QDRANT_EDGE_DATABASE.lock().unwrap(); + let database_info = database_guard + .as_ref() + .and_then(|database| database.info().ok()); + + let is_available = current_status == QdrantEdgeStatus::Available && database_info.is_some(); + Json(QdrantEdgeServiceInfo { + status: current_status, + name: database_info.as_ref().map(|info| info.name.clone()).unwrap_or_default(), + version: database_info.as_ref().map(|info| info.version.clone()).unwrap_or_default(), + path: database_info.as_ref().map(|info| info.path.clone()).unwrap_or_default(), + stores_count: database_info.as_ref().map(|info| info.stores_count).unwrap_or_default(), + is_available, + unavailable_reason, + }) +} + +pub async fn ensure_qdrant_edge_store(_token: APIToken, Json(request): Json) -> Json { + execute_qdrant_edge_operation(|database| { + database.ensure_store_exists(&request.store_name, request.vector_size) + }) +} + +pub async fn insert_qdrant_edge_embedding(_token: APIToken, Json(request): Json) -> Json { + execute_qdrant_edge_operation(|database| { + database.insert_embedding(&request.store_name, request.points) + }) +} + +pub async fn delete_qdrant_edge_embedding_by_file(_token: APIToken, Json(request): Json) -> Json { + execute_qdrant_edge_operation(|database| { + database.delete_embedding_by_file(&request.store_name, &request.file_path) + }) +} + +pub async fn delete_qdrant_edge_store(_token: APIToken, Json(request): Json) -> Json { + execute_qdrant_edge_operation(|database| { + database.delete_store(&request.store_name) + }) +} + +pub fn start_qdrant_edge_database(app_handle: tauri::AppHandle) { + set_qdrant_edge_starting(); + remove_obsolete_qdrant_sidecar_files(&app_handle); + + let path = qdrant_edge_base_path(); + match fs::create_dir_all(&path) { + Ok(_) => { + let database = QdrantEdgeDatabase::new(path.clone()); + *QDRANT_EDGE_DATABASE.lock().unwrap() = Some(database); + set_qdrant_edge_available(); + info!(Source = "Qdrant Edge"; "Qdrant Edge is available at '{}'.", path.display()); + }, + Err(e) => { + let reason = format!("The Qdrant Edge data directory could not be created: {e}"); + error!(Source = "Qdrant Edge"; "{reason}"); + set_qdrant_edge_unavailable(reason); + }, + } +} + +pub fn stop_qdrant_edge_database() { + if let Some(database) = QDRANT_EDGE_DATABASE.lock().unwrap().take() { + info!(Source = "Qdrant Edge"; "Stopping Qdrant Edge at '{}'.", database.base_path().display()); + drop(database); + } + + set_qdrant_edge_unavailable("Qdrant Edge was stopped.".to_string()); +} + +fn execute_qdrant_edge_operation(operation: F) -> Json +where + F: FnOnce(&mut QdrantEdgeDatabase) -> QdrantEdgeResult<()>, +{ + let mut database_guard = QDRANT_EDGE_DATABASE.lock().unwrap(); + let Some(database) = database_guard.as_mut() else { + return Json(QdrantEdgeOperationResponse { + success: false, + issue: "Qdrant Edge is not available.".to_string(), + }); + }; + + match operation(database) { + Ok(_) => Json(QdrantEdgeOperationResponse { + success: true, + issue: String::new(), + }), + Err(e) => { + let issue = e.to_string(); + error!(Source = "Qdrant Edge"; "Qdrant Edge operation failed: {issue}"); + Json(QdrantEdgeOperationResponse { + success: false, + issue, + }) + }, + } +} + +fn set_qdrant_edge_available() { + let mut status = QDRANT_EDGE_STATUS.lock().unwrap(); + status.status = QdrantEdgeStatus::Available; + status.unavailable_reason = None; +} + +fn set_qdrant_edge_starting() { + let mut status = QDRANT_EDGE_STATUS.lock().unwrap(); + status.status = QdrantEdgeStatus::Starting; + status.unavailable_reason = None; +} + +fn set_qdrant_edge_unavailable(reason: String) { + let mut status = QDRANT_EDGE_STATUS.lock().unwrap(); + status.status = QdrantEdgeStatus::Unavailable; + status.unavailable_reason = Some(reason); +} + +fn remove_obsolete_qdrant_sidecar_files(app_handle: &tauri::AppHandle) { + let mut paths = Vec::new(); + + if let Some(data_directory) = DATA_DIRECTORY.get() { + let databases_directory = Path::new(data_directory).join("databases"); + paths.push(databases_directory.join("qdrant")); + paths.push(databases_directory.join("qdrant_test")); + } + + if let Ok(resource_dir) = app_handle.path().resource_dir() { + paths.push(resource_dir.join("target").join("databases").join("qdrant")); + paths.push(resource_dir.join("resources").join("databases").join("qdrant")); + } + + if let Ok(current_exe) = std::env::current_exe() && let Some(exe_dir) = current_exe.parent() { + paths.push(exe_dir.join("target").join("databases").join("qdrant")); + paths.push(exe_dir.join("qdrant.exe")); + paths.push(exe_dir.join("qdrant")); + } + + for path in paths { + remove_obsolete_qdrant_path(&path); + } +} + +fn remove_obsolete_qdrant_path(path: &Path) { + if !path.exists() { + return; + } + + let result = if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + }; + + match result { + Ok(_) => warn!(Source = "Qdrant Edge"; "Removed obsolete Qdrant sidecar file or directory '{}'.", path.display()), + Err(e) => warn!(Source = "Qdrant Edge"; "Could not remove obsolete Qdrant sidecar file or directory '{}': {e}", path.display()), + } +} + +fn edge_config(vector_size: usize) -> EdgeConfig { + EdgeConfig { + on_disk_payload: true, + vectors: HashMap::from([( + VECTOR_NAME.to_string(), + EdgeVectorParams { + size: vector_size, + distance: Distance::Cosine, + on_disk: Some(true), + quantization_config: None, + multivector_config: None, + datatype: None, + hnsw_config: Some(hnsw_config()), + }, + )]), + sparse_vectors: HashMap::new(), + hnsw_config: hnsw_config(), + quantization_config: None, + optimizers: edge_optimizers_config(), + } +} + +fn hnsw_config() -> HnswIndexConfig { + HnswIndexConfig { + m: HNSW_M, + ef_construct: HNSW_EF_CONSTRUCT, + full_scan_threshold: HNSW_FULL_SCAN_THRESHOLD_KB, + max_indexing_threads: HNSW_MAX_INDEXING_THREADS, + on_disk: Some(true), + payload_m: None, + inline_storage: None, + } +} + +fn edge_optimizers_config() -> EdgeOptimizersConfig { + EdgeOptimizersConfig { + indexing_threshold: Some(VECTOR_INDEXING_THRESHOLD_KB), + prevent_unoptimized: Some(false), + ..Default::default() + } +} + +fn has_existing_store(path: &Path) -> bool { + path.join("edge_config.json").exists() || path.join("segments").exists() +} + +fn vector_store_version() -> QdrantEdgeResult { + let metadata = META_DATA + .lock() + .map_err(|_| "Metadata lock was poisoned.")?; + let Some(metadata) = metadata.as_ref() else { + return Err("Metadata was not initialized.".into()); + }; + + Ok(metadata.vector_store_version.clone()) +} + +fn to_qdrant_edge_point(point: QdrantEdgeStoragePoint) -> qdrant_edge::PointStructPersisted { + PointStruct::new( + to_point_id(&point.point_id), + Vectors::new_named([(VECTOR_NAME, point.vector)]), + json!({ + "data_source_id": point.data_source_id, + "data_source_name": point.data_source_name, + "data_source_type": point.data_source_type, + "file_path": point.file_path, + "file_name": point.file_name, + "relative_path": point.relative_path, + "chunk_index": point.chunk_index, + "text": point.text, + "fingerprint": point.fingerprint, + "last_write_utc": point.last_write_utc, + "embedded_at_utc": point.embedded_at_utc, + }), + ) + .into() +} + +fn to_point_id(point_id: &str) -> PointId { + Uuid::parse_str(point_id) + .map(PointId::Uuid) + .unwrap_or_else(|_| PointId::NumId(stable_u64(point_id))) +} + +fn stable_u64(value: &str) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in value.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + + hash +} + +fn match_keyword_filter(field_name: &str, value: &str) -> QdrantEdgeResult { + Ok(Filter { + should: None, + min_should: None, + must: Some(vec![Condition::Field(FieldCondition::new_match( + field_name + .try_into() + .map_err(|_| format!("Invalid payload field name '{field_name}'."))?, + Match::Value(MatchValue { + value: ValueVariants::String(value.to_string()), + }), + ))]), + must_not: None, + }) +} + +fn validate_store_name(store_name: &str) -> QdrantEdgeResult<()> { + if store_name.is_empty() { + return Err("Vector store name cannot be empty.".into()); + } + + if store_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + { + return Ok(()); + } + + Err(format!("Vector store name '{store_name}' contains unsupported characters.").into()) +} diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 89f6cec0..4e790609 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -32,7 +32,11 @@ pub fn start_runtime_api() { let app = Router::new() .route("/system/dotnet/port", get(crate::dotnet::dotnet_port)) .route("/system/dotnet/ready", get(crate::dotnet::dotnet_ready)) - .route("/system/qdrant/info", get(crate::qdrant::qdrant_port)) + .route("/system/qdrant-edge/info", get(crate::qdrant_edge_database::qdrant_edge_info)) + .route("/system/qdrant-edge/ensure", post(crate::qdrant_edge_database::ensure_qdrant_edge_store)) + .route("/system/qdrant-edge/insert", post(crate::qdrant_edge_database::insert_qdrant_edge_embedding)) + .route("/system/qdrant-edge/delete-file", post(crate::qdrant_edge_database::delete_qdrant_edge_embedding_by_file)) + .route("/system/qdrant-edge/delete-store", post(crate::qdrant_edge_database::delete_qdrant_edge_store)) .route("/clipboard/set", post(crate::clipboard::set_clipboard)) .route("/events", get(crate::app_window::get_event_stream)) .route("/updates/check", get(crate::app_window::check_for_update)) @@ -80,4 +84,4 @@ fn install_rustls_crypto_provider() { RUSTLS_CRYPTO_PROVIDER_INIT.call_once(|| { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); }); -} \ No newline at end of file +} diff --git a/runtime/src/sidecar_types.rs b/runtime/src/sidecar_types.rs index 7e5bfde0..973aa603 100644 --- a/runtime/src/sidecar_types.rs +++ b/runtime/src/sidecar_types.rs @@ -2,14 +2,12 @@ pub enum SidecarType { Dotnet, - Qdrant, } impl fmt::Display for SidecarType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { SidecarType::Dotnet => write!(f, ".Net"), - SidecarType::Qdrant => write!(f, "Qdrant"), } } } \ No newline at end of file diff --git a/runtime/tauri.conf.json b/runtime/tauri.conf.json index 1e1a96e9..e29bb1a4 100644 --- a/runtime/tauri.conf.json +++ b/runtime/tauri.conf.json @@ -24,11 +24,9 @@ "icons/icon.ico" ], "externalBin": [ - "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer", - "target/databases/qdrant/qdrant" + "../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer" ], "resources": [ - "resources/databases/qdrant/config.yaml", "resources/libraries/*" ], "macOS": {