Merge branch 'main' into connect-dynamic-assistants-with-eri-sources

This commit is contained in:
Thorsten Sommer 2026-09-18 17:38:01 +02:00 committed by GitHub
commit ee05e8a528
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 4734 additions and 1143 deletions

View File

@ -214,9 +214,45 @@ RAG integration is currently in development (preview feature). Architecture:
- **Data Sources** - Local files and external data via ERI servers
- **Agents** - AI agents select data sources and validate retrieval quality
- **Embedding providers** - Support for various embedding models
- **Vector database** - Planned integration with Qdrant for vector storage
- **Vector database** - Qdrant Edge, embedded in the Rust runtime; see "Databases" below
- **Index database** - SQLite, holding the file fingerprints and the chunk texts for full-text search; see "Databases" below
- **File processing** - Extracts text from PDF, DOCX, XLSX via Rust runtime
## Databases
Local RAG runs on two databases, addressed through `DatabaseRole`:
- **`VECTOR_STORE`** — Qdrant Edge through the `qdrant-edge` crate, running **in-process inside the
Rust runtime**. There is no sidecar process, no port 6333 and no Qdrant API key; .NET reaches it
over the internal runtime API (`/system/qdrant-edge/*`, see `runtime/src/qdrant_edge_database.rs`),
secured by the same TLS and API token as every other runtime call. One store per data source,
named `rag_<data source guid>`, holding a single named vector `embedding` per point.
- **`INDEX_STORE`** — SQLite at `<data directory>/databases/sqlite/rag-index.sqlite3`, reached
through EF Core. It holds the data sources, the file fingerprints, the chunk texts and an FTS5
index over them, plus the files which permanently failed to index.
`DatabaseClientProvider` is the only way to a client. It caches one per role and guards each role
with its own semaphore, so never construct a client yourself.
When working on these, keep in mind:
- **`GetDisplayInfo()` feeds the information page.** A new diagnostic value belongs in the client
that knows it, not in `Pages/Information.razor.cs`. The page renders whatever label-value pairs it
receives and stays free of per-database knowledge.
- **Let every probe in `GetDisplayInfo()` catch its own failure.** When the method throws, the page
replaces the *entire* block with the fallback client, so one unreadable value costs all the others
as well.
- **Raw SQL against SQLite goes through `context.Database.GetDbConnection()`**, not through
`SqlQueryRaw<T>`: that one expects a column named `Value` and wraps the statement, so a `PRAGMA`
never works with it.
- **A new EF Core migration needs a `[DynamicDependency]`** in `IndexStoreSchemaMigrator`, because
`PublishTrimmed` is on and the migration type would otherwise be trimmed away. The "Schema version"
line on the information page shows the applied and pending counts, so a forgotten entry becomes
visible there.
- **Counts in the UI go through `long.CompactCount()` / `int.CompactCount()`** (`Tools/LongExtensions.cs`),
which shortens anything above 999 to `1.46k` or `4.51M` and formats it with the culture of the
active language plugin. Storage sizes are the exception: they keep using the byte formatters.
## Enterprise IT Support
AI Studio supports centralized configuration for enterprise environments:
@ -245,6 +281,7 @@ Multi-level confidence scheme allows users to control which providers see which
- keyring - OS keyring integration
- pdfium-render - PDF text extraction
- calamine - Excel file parsing
- qdrant-edge - Embedded vector database
**.NET:**
- Blazor Server - UI framework
@ -252,6 +289,7 @@ Multi-level confidence scheme allows users to control which providers see which
- LuaCSharp - Lua scripting engine
- HtmlAgilityPack - HTML parsing
- ReverseMarkdown - HTML to Markdown conversion
- EF Core Sqlite + SQLitePCLRaw - the local RAG index
## Security

View File

@ -184,7 +184,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
this.formChangeTimer.Elapsed += (_, _) =>
{
this.formChangeTimer.Stop();
this.OnFormChange().Observe($"{nameof(AssistantBase<TSettings>)}: handling a form change");
this.InvokeAsync(this.OnFormChange).Observe($"{nameof(AssistantBase<TSettings>)}: handling a form change");
};
this.MightPreselectValues();

View File

@ -104,13 +104,13 @@ else
@T("Note: This setting only takes effect when this policy is exported and distributed via a configuration plugin to other users. When enabled, users will only see the document selection interface and cannot view or modify the policy details. This setting does NOT affect your local view - you will always see the full policy definition for policies you create.")
</MudJustifiedText>
<ConfigurationMinConfidenceSelection Disabled="@(() => this.IsNoPolicySelectedOrProtected)" RestrictToGlobalMinimumConfidence="true" SelectedValue="@(() => this.policyMinimumProviderConfidence)" SelectionUpdateAsync="@(async level => await this.PolicyMinimumConfidenceWasChangedAsync(level))" />
<ConfigurationMinConfidenceSelection Disabled="@(() => this.IsNoPolicySelectedOrProtected)" RestrictToGlobalMinimumConfidence="true" SelectedValue="@(() => this.policyMinimumProviderConfidence)" SelectionUpdate="@this.PolicyMinimumConfidenceWasChanged" />
<ToolSelectionField Component="@this.Component" SelectedToolIds="@this.policyAllowedToolIds" SelectedToolIdsChanged="@this.PolicyAllowedToolsWasChangedAsync" Disabled="@this.IsNoPolicySelectedOrProtected" Label="@T("Tools this policy permits")" Help="@T("Only the tools selected here can be used by the AI for an analysis with this policy. Every tool still has to meet the confidence requirements of the selected provider, so a tool may remain unavailable even when this policy permits it.")"/>
<ToolSelectionField Component="@this.Component" SelectedToolIds="@this.policyAllowedToolIds" SelectedToolIdsChanged="@this.PolicyAllowedToolsWasChanged" Disabled="@this.IsNoPolicySelectedOrProtected" Label="@T("Tools this policy permits")" Help="@T("Only the tools selected here can be used by the AI for an analysis with this policy. Every tool still has to meet the confidence requirements of the selected provider, so a tool may remain unavailable even when this policy permits it.")"/>
<ConfigurationProviderSelection Component="Components.DOCUMENT_ANALYSIS_ASSISTANT" Data="@this.availableLLMProviders" Disabled="@(() => this.IsNoPolicySelectedOrProtected)" SelectedValue="@(() => this.policyPreselectedProviderId)" SelectionUpdate="@this.PolicyPreselectedProviderWasChanged" ExplicitMinimumConfidence="@this.GetPolicyMinimumConfidenceLevel()"/>
<ConfigurationSelect OptionDescription="@T("Preselect a profile")" Disabled="@(() => this.IsNoPolicySelected)" SelectedValue="@(() => this.policyPreselectedProfile)" Data="@ConfigurationSelectDataFactory.GetComponentProfilesData(this.SettingsManager.ConfigurationData.Profiles)" SelectionUpdateAsync="@(async selection => await this.PolicyPreselectedProfileWasChangedAsync(selection))" OptionHelp="@T("Choose whether the policy should use the app default profile, no profile, or a specific profile.")"/>
<ConfigurationSelect OptionDescription="@T("Preselect a profile")" Disabled="@(() => this.IsNoPolicySelected)" SelectedValue="@(() => this.policyPreselectedProfile)" Data="@ConfigurationSelectDataFactory.GetComponentProfilesData(this.SettingsManager.ConfigurationData.Profiles)" SelectionUpdate="@this.PolicyPreselectedProfileWasChanged" OptionHelp="@T("Choose whether the policy should use the app default profile, no profile, or a specific profile.")"/>
<MudTextSwitch Disabled="@(this.IsNoPolicySelected || (this.selectedPolicy?.IsEnterpriseConfiguration ?? true))" Label="@T("Would you like to protect this policy so that you cannot accidentally edit or delete it?")" Value="@this.policyIsProtected" ValueChanged="async state => await this.PolicyProtectionWasChanged(state)" LabelOn="@T("Yes, protect this policy")" LabelOff="@T("No, the policy can be edited")" />

View File

@ -255,33 +255,80 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
private async Task AutoSave(bool force = false)
{
if(this.selectedPolicy is null)
//
// A pending store is a property of the settings, not of the selected policy: the value has
// been written into its policy already, so what is still outstanding is the store itself.
// It therefore outlives a form reset and a switch to another policy, and only a completed
// store clears it.
//
var hasChanges = this.policyStorePending;
if(this.selectedPolicy is { } policy)
hasChanges |= this.ApplyFormToPolicy(policy, force);
if (!hasChanges)
return;
// The preselected profile is always user-adjustable, even for protected policies and enterprise configurations:
this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfile;
// Enterprise configurations cannot be modified at all:
if(this.selectedPolicy.IsEnterpriseConfiguration)
return;
var canEditProtectedFields = force || (!this.selectedPolicy.IsProtected && !this.policyIsProtected);
if (canEditProtectedFields)
{
this.selectedPolicy.PreselectedProvider = this.policyPreselectedProviderId;
this.selectedPolicy.PolicyName = this.policyName;
this.selectedPolicy.PolicyDescription = this.policyDescription;
this.selectedPolicy.IsProtected = this.policyIsProtected;
this.selectedPolicy.HidePolicyDefinition = this.policyHidePolicyDefinition;
this.selectedPolicy.AnalysisRules = this.policyAnalysisRules;
this.selectedPolicy.OutputRules = this.policyOutputRules;
this.selectedPolicy.MinimumProviderConfidence = this.policyMinimumProviderConfidence;
this.selectedPolicy.AllowedToolIds = [..this.policyAllowedToolIds];
}
await this.SettingsManager.StoreSettings();
this.policyStorePending = false;
}
/// <summary>
/// Takes the form values over into the given policy.
/// </summary>
/// <param name="policy">The policy to write the form values to.</param>
/// <param name="force">Whether the protected fields may be written as well.</param>
/// <returns>True when this changed anything about the policy, false otherwise.</returns>
private bool ApplyFormToPolicy(DataDocumentAnalysisPolicy policy, bool force)
{
// The preselected profile is always user-adjustable, even for protected policies and enterprise configurations:
var hasChanges = policy.PreselectedProfile != this.policyPreselectedProfile;
policy.PreselectedProfile = this.policyPreselectedProfile;
// Enterprise configurations cannot be modified at all:
if(policy.IsEnterpriseConfiguration)
return hasChanges;
var canEditProtectedFields = force || (!policy.IsProtected && !this.policyIsProtected);
if (!canEditProtectedFields)
return hasChanges;
hasChanges = hasChanges
|| policy.PolicyName != this.policyName
|| policy.PreselectedProvider != this.policyPreselectedProviderId
|| policy.PolicyDescription != this.policyDescription
|| policy.IsProtected != this.policyIsProtected
|| policy.HidePolicyDefinition != this.policyHidePolicyDefinition
|| policy.AnalysisRules != this.policyAnalysisRules
|| policy.OutputRules != this.policyOutputRules
|| policy.MinimumProviderConfidence != this.policyMinimumProviderConfidence
|| !policy.AllowedToolIds.SetEquals(this.policyAllowedToolIds);
policy.PreselectedProvider = this.policyPreselectedProviderId;
policy.PolicyName = this.policyName;
policy.PolicyDescription = this.policyDescription;
policy.IsProtected = this.policyIsProtected;
policy.HidePolicyDefinition = this.policyHidePolicyDefinition;
policy.AnalysisRules = this.policyAnalysisRules;
policy.OutputRules = this.policyOutputRules;
policy.MinimumProviderConfidence = this.policyMinimumProviderConfidence;
policy.AllowedToolIds = [..this.policyAllowedToolIds];
return hasChanges;
}
/// <summary>
/// Whether the given policy may take over an edit of one of its protected fields right now.
/// </summary>
/// <remarks>
/// The handlers which write their value straight into the policy have to ask this themselves.
/// ApplyFormToPolicy asks the same question, but it never gets to judge their fields: they have
/// already brought policy and form in line, so nothing is left for it to compare. The markup
/// disables those controls for a protected policy and an enterprise policy is always a protected
/// one, which is why nobody should ever reach a handler that way -- this keeps the rule in the
/// code as well, where the next handler will look for it. The form value counts alongside the
/// stored one, because the protection switch is flipped before the store which writes it has run.
/// </remarks>
private bool AcceptsProtectedFieldEdits(DataDocumentAnalysisPolicy policy) => policy is { IsEnterpriseConfiguration: false, IsProtected: false } && !this.policyIsProtected;
private DataDocumentAnalysisPolicy? selectedPolicy;
private bool policyIsProtected;
private bool policyHidePolicyDefinition;
@ -298,6 +345,21 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
/// </remarks>
private bool documentSelectionExpanded;
private string policyName = string.Empty;
/// <summary>
/// Whether an edit already applied to a policy still waits to be written to the settings file.
/// </summary>
/// <remarks>
/// Some handlers apply their value to the selected policy at once, because the rest of the
/// assistant reads it back from there right away: the policy list has to show a new name while
/// it is being typed, and the provider preselection is recomputed from the policy, not from the
/// form. Doing so leaves the auto-save nothing to compare the form against -- form and policy
/// already agree -- so every such handler has to announce the store itself. That is what this
/// flag is for. It belongs to no particular policy: the value has long arrived where it
/// belongs, only the file has not caught up yet, which is why a form reset or a switch to
/// another policy does not clear it. Only a completed store does.
/// </remarks>
private bool policyStorePending;
private string policyDescription = string.Empty;
private string policyAnalysisRules = string.Empty;
private string policyOutputRules = string.Empty;
@ -477,6 +539,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
return;
this.selectedPolicy.PolicyName = this.policyName;
this.policyStorePending = true;
}
private async Task PolicyProtectionWasChanged(bool state)
@ -488,7 +551,6 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
return;
this.policyIsProtected = state;
this.selectedPolicy.IsProtected = state;
this.policyDefinitionExpanded = !state;
this.documentSelectionExpanded = state;
await this.AutoSave(true);
@ -503,7 +565,6 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
return;
this.policyHidePolicyDefinition = state;
this.selectedPolicy.HidePolicyDefinition = state;
await this.AutoSave(true);
}
@ -580,39 +641,50 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
/// <summary>
/// Takes over the tools this policy permits.
/// </summary>
private async Task PolicyAllowedToolsWasChangedAsync(HashSet<string> allowedToolIds)
private void PolicyAllowedToolsWasChanged(HashSet<string> allowedToolIds)
{
this.policyAllowedToolIds = allowedToolIds;
await this.AutoSave();
if (this.selectedPolicy is not { } policy || !this.AcceptsProtectedFieldEdits(policy))
return;
policy.AllowedToolIds = [..allowedToolIds];
this.policyStorePending = true;
}
private async Task PolicyMinimumConfidenceWasChangedAsync(ConfidenceLevel level)
private void PolicyMinimumConfidenceWasChanged(ConfidenceLevel level)
{
this.policyMinimumProviderConfidence = level;
await this.AutoSave();
if (this.selectedPolicy is { } policy && this.AcceptsProtectedFieldEdits(policy))
{
policy.MinimumProviderConfidence = level;
this.policyStorePending = true;
}
this.ApplyPolicyPreselection();
}
private void PolicyPreselectedProviderWasChanged(string providerId)
{
if (this.selectedPolicy is null)
if (this.selectedPolicy is not { } policy || !this.AcceptsProtectedFieldEdits(policy))
return;
this.policyPreselectedProviderId = providerId;
this.selectedPolicy.PreselectedProvider = providerId;
policy.PreselectedProvider = providerId;
this.policyStorePending = true;
this.ProviderSettings = Settings.Provider.NONE;
this.ApplyPolicyPreselection();
}
private async Task PolicyPreselectedProfileWasChangedAsync(ProfilePreselection selection)
private void PolicyPreselectedProfileWasChanged(ProfilePreselection selection)
{
this.policyPreselectedProfile = selection;
if (this.selectedPolicy is not null)
{
this.selectedPolicy.PreselectedProfile = this.policyPreselectedProfile;
this.policyStorePending = true;
}
this.CurrentProfile = this.ResolveProfileSelection();
await this.AutoSave();
}
#region Overrides of MSGComponentBase

View File

@ -3814,6 +3814,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2675917723"] = "No
-- No valid embedding
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2698203405"] = "No valid embedding"
-- Repair this data source by indexing it anew
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2771708618"] = "Repair this data source by indexing it anew"
-- Embedding
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2838542994"] = "Embedding"
@ -3958,6 +3961,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T700666808"] = "Mana
-- Available Data Sources
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T86053874"] = "Available Data Sources"
-- This data source is waiting to be indexed again. Until that is finished, it cannot be searched.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTIONROW::T1692539409"] = "This data source is waiting to be indexed again. Until that is finished, it cannot be searched."
-- The index of this data source cannot be read anymore. Open your data source settings with the gear icon above, then use the repair action there.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTIONROW::T4047623216"] = "The index of this data source cannot be read anymore. Open your data source settings with the gear icon above, then use the repair action there."
-- Tools (Optional)
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1019749907"] = "Tools (Optional)"
@ -4222,6 +4231,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P
-- You can switch between your profiles here
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here"
-- No LLM providers are configured yet. Add a provider in the app settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "No LLM providers are configured yet. Add a provider in the app settings."
-- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings."
-- Audio input possible
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible"
@ -5035,6 +5050,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78
-- Are you sure you want to delete the transcription provider '{0}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Are you sure you want to delete the transcription provider '{0}'?"
-- Could not open the file location.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Could not open the file location."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Could not open the file location: {0}"
-- Show this file in the file manager of your system
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Show this file in the file manager of your system"
-- Opens this document in the program your system uses for it
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Opens this document in the program your system uses for it"
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unknown error"
-- Could not open the document.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Could not open the document."
-- Could not open the document: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Could not open the document: {0}"
-- Copy {0} to the clipboard
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard"
@ -9076,12 +9112,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1143368054"] = "Other cause"
-- Current file: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Current file: {0}"
-- File {0} of {1} is being indexed: block {2}, page {3}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "File {0} of {1} is being indexed: block {2}, page {3}."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1455637941"] = "Could not open the file location: {0}"
-- Open the settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1582896271"] = "Open the settings"
-- File {0} of {1} is being indexed.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1616414701"] = "File {0} of {1} is being indexed."
-- Tried again during the next run
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Tried again during the next run"
@ -9109,6 +9151,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T2525374657"] = "{0} of {1} files
-- Background embeddings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T2547971789"] = "Background embeddings"
-- Repair this data source by indexing it anew
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T2771708618"] = "Repair this data source by indexing it anew"
-- Refresh this data source
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T2901874229"] = "Refresh this data source"
@ -9124,6 +9169,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T309404893"] = "Failed files: {0}"
-- Show this file in the file browser of your system
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3273105305"] = "Show this file in the file browser of your system"
-- Data source {0} of {1} is being worked on. The others are waiting their turn.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3389674086"] = "Data source {0} of {1} is being worked on. The others are waiting their turn."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3461425987"] = "Unknown error"
@ -9139,6 +9187,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3865031940"] = "Actions"
-- Skipped until the file changes
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T542386347"] = "Skipped until the file changes"
-- File {0} of {1} is being indexed: block {2}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T615458954"] = "File {0} of {1} is being indexed: block {2}."
-- File
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T723007075"] = "File"
@ -9235,9 +9286,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T103551060"] = "The configured ro
-- Browse AI Studio's source code on GitHub — we welcome your contributions.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions."
-- 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."
@ -9340,6 +9388,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is
-- Encryption secret: is configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured"
-- Index database
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1949702956"] = "Index database"
-- The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1985806792"] = "The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others."
@ -9517,9 +9568,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3019585985"] = "Test configurati
-- External HTTPS custom root certificates are configured but not active.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3021325354"] = "External HTTPS custom root certificates are configured but not active."
-- Vector store
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3046399223"] = "Vector store"
-- Enterprise configuration ID:
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3092349641"] = "Enterprise configuration ID:"
@ -9646,6 +9694,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Copies the allow
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
-- Vector database
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3977811115"] = "Vector database"
-- Installed Pandoc version
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installed Pandoc version"
@ -10663,23 +10714,50 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T10
-- Starting
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T1233211769"] = "Starting"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T2608177081"] = "unknown"
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T3662391977"] = "Unavailable"
-- Process architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T563161633"] = "Process architecture"
-- Status
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T6222351"] = "Status"
-- Database path
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1100578143"] = "Database path"
-- Native library
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T634426545"] = "Native library"
-- no migration applied
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1014567907"] = "no migration applied"
-- Storage size
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1230141403"] = "Storage size"
-- Full-text search (FTS5)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1396060361"] = "Full-text search (FTS5)"
-- Wrapper version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1469427584"] = "Wrapper version"
-- available
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1727918744"] = "available"
-- Indexed files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2235289713"] = "Indexed files"
-- Search chunks
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2333737457"] = "Search chunks"
-- {0} ({1} applied)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2286846332"] = "{0} ({1} applied)"
-- Journal mode
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2411639995"] = "Journal mode"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2608177081"] = "unknown"
-- Database tables
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3279078157"] = "Database tables"
-- Indexed data sources
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3524534748"] = "Indexed data sources"
@ -10687,9 +10765,27 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTI
-- Reported version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3556099842"] = "Reported version"
-- not available
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3574465749"] = "not available"
-- Permanently skipped files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3965853089"] = "Permanently skipped files"
-- Schema version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T424290830"] = "Schema version"
-- Process architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T563161633"] = "Process architecture"
-- Native library
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T634426545"] = "Native library"
-- System architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T818259255"] = "System architecture"
-- {0} ({1} applied, {2} pending)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T983802795"] = "{0} ({1} applied, {2} pending)"
-- Reason
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Reason"
@ -10714,6 +10810,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T
-- Storage size
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T1230141403"] = "Storage size"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2608177081"] = "unknown"
-- Number of vector stores
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2785004838"] = "Number of vector stores"
@ -10723,9 +10822,18 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEM
-- Status
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T6222351"] = "Status"
-- Stored vectors
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T701689894"] = "Stored vectors"
-- Qdrant Edge is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge is not available."
-- Repair Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T4175865785"] = "Repair Data Source"
-- The index of the data source '{0}' cannot be read anymore. Repairing it means building the index from scratch: everything indexed so far is thrown away, and every document of this data source is sent to your embedding provider once more. With a cloud provider, this costs money, and with a large data source it takes a while. Do you want to repair this data source now?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREPAIR::T857336889"] = "The index of the data source '{0}' cannot be read anymore. Repairing it means building the index from scratch: everything indexed so far is thrown away, and every document of this data source is sent to your embedding provider once more. With a cloud provider, this costs money, and with a large data source it takes a while. Do you want to repair this data source now?"
-- 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."
@ -11920,6 +12028,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T4515612
-- The embedding provider answered with {0} vectors for {1} parts of the file '{2}'. Please select another embedding model or provider.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T667058890"] = "The embedding provider answered with {0} vectors for {1} parts of the file '{2}'. Please select another embedding model or provider."
-- The index of the data source '{0}' cannot be read anymore. The data source stays out of your chats until its index was built anew. Use the repair action to start that.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T831900720"] = "The index of the data source '{0}' cannot be read anymore. The data source stays out of your chats until its index was built anew. Use the repair action to start that."
-- The folder '{0}' does not exist.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCEEMBEDDINGSERVICE::T871336081"] = "The folder '{0}' does not exist."
@ -11962,9 +12073,15 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T29
-- The data source '{0}' was left out of the answer: its embedding provider '{1}' did not return a vector for your message.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T3469074321"] = "The data source '{0}' was left out of the answer: its embedding provider '{1}' did not return a vector for your message."
-- The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T4022014739"] = "The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished."
-- Page {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T4127287940"] = "Page {0}"
-- The data source '{0}' was left out of the answer: its index cannot be read anymore. You can repair it in your data source settings.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T59210871"] = "The data source '{0}' was left out of the answer: its index cannot be read anymore. You can repair it in your data source settings."
-- The data source '{0}' was left out of the answer because searching it failed.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T934856625"] = "The data source '{0}' was left out of the answer because searching it failed."
@ -12205,6 +12322,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "No com
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
-- The runtime document endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "The runtime document endpoint returned '{0}'."
-- The global shortcut could not be registered because of a desktop integration error.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "The global shortcut could not be registered because of a desktop integration error."
@ -12232,6 +12352,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Succes
-- The desktop service returned an invalid response while registering the global shortcut.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "The desktop service returned an invalid response while registering the global shortcut."
-- The runtime document endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "The runtime document endpoint failed without details."
-- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default."
@ -12250,6 +12373,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "No sav
-- Failed to get the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Failed to get the secret data due to an API issue."
-- The runtime document endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "The runtime document endpoint is not available."
-- AI Studio could not access secure storage. See the log for technical details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio could not access secure storage. See the log for technical details."

View File

@ -48,7 +48,7 @@
{
<MudTooltip Text="@T("Number of sources")" Placement="Placement.Bottom">
<MudBadge Content="@this.Content.Sources.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
<MudIconButton Icon="@Icons.Material.Filled.Link"/>
<MudIconButton Icon="@Icons.Material.Filled.Link" Disabled="@(!this.HasSourcesToShow)" OnClick="@this.ShowSources"/>
</MudBadge>
</MudTooltip>
}
@ -223,7 +223,7 @@
}
@if (textContent.Sources.Count > 0)
{
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
<SourcesList @ref="this.sourcesList" Sources="@textContent.Sources"/>
}
</div>
}

View File

@ -123,6 +123,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private IReadOnlyList<MessageTable> cachedMessageTables = [];
private char csvSeparator = ',';
private ElementReference mathContentContainer;
private SourcesList? sourcesList;
private string lastMathRenderSignature = string.Empty;
private bool hasActiveMathContainer;
private bool isDisposed;
@ -815,6 +816,25 @@ public partial class ContentBlockComponent : MSGComponentBase
this.Content.FileAttachments = [.. result];
}
/// <summary>
/// Whether the sources of this block stand below the answer, where the counter can take the reader.
/// </summary>
/// <remarks>
/// The same condition the block itself renders the list under. While an answer is still coming
/// in, its sources may already be known, but there is nothing on the page yet to scroll to --
/// so the counter says it cannot do anything rather than doing nothing when clicked.
/// </remarks>
private bool HasSourcesToShow => this.Content is { InitialRemoteWait: false, IsStreaming: false, Sources.Count: > 0 };
/// <summary>
/// Takes the reader from the source counter down to the sources themselves.
/// </summary>
private async Task ShowSources()
{
if (this.sourcesList is not null)
await this.sourcesList.ScrollIntoViewAsync();
}
protected override async ValueTask DisposeResourcesAsync()
{
if (this.isDisposed)

View File

@ -55,8 +55,11 @@ public static class IContentExtensions
/// </remarks>
/// <param name="content">The content to read.</param>
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page. Only a
/// format whose reader stumbles over such a link says no here; the clipboard and every text
/// format keep the page.</param>
/// <returns>True, when this content carries Markdown text.</returns>
public static bool TryGetExportMarkdown(this IContent content, out string markdown)
public static bool TryGetExportMarkdown(this IContent content, out string markdown, bool keepPageAnchors = true)
{
if (content is not ContentText text)
{
@ -65,7 +68,7 @@ public static class IContentExtensions
}
var answer = text.Text.Trim();
var sources = text.Sources.ToExportMarkdown();
var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
if (sources.Length == 0)
{
markdown = answer;

View File

@ -0,0 +1,29 @@
namespace AIStudio.Components;
/// <summary>
/// Why a data source is listed in the selection, but cannot be picked.
/// </summary>
/// <remarks>
/// A reason rather than a yes or no, because the row has to say something different for each of
/// them: one asks the user to wait, the other one asks them to act. Asking somebody to wait for
/// something which will never happen on its own is the worse of the two mistakes.
/// </remarks>
public enum DataSourceBlockReason
{
/// <summary>
/// Nothing is in the way, the data source can be picked.
/// </summary>
NONE,
/// <summary>
/// The index has to be built anew before this data source can answer a search. This passes by
/// itself, as soon as the background indexing has worked through the data source.
/// </summary>
AWAITING_REINDEX,
/// <summary>
/// The index cannot be read anymore. This does not pass by itself: only the user can start the
/// rebuild, because it sends every document to the embedding provider once more.
/// </summary>
NEEDS_REPAIR,
}

View File

@ -59,6 +59,18 @@
<MudTooltip Text="@T("Information")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Outlined.Info" OnClick="@(() => this.ShowInformation(context))"/>
</MudTooltip>
@*
Outside the two branches below on purpose: an index which cannot be read is a
matter of this machine, not of the configuration. Hiding the repair for a data
source the organization manages would leave it locked out of every chat with no
way back, and the selection points here for it.
*@
@if (this.CanRepairDataSource(context))
{
<MudTooltip Text="@T("Repair this data source by indexing it anew")">
<MudIconButton Color="Color.Warning" Icon="@Icons.Material.Filled.Build" OnClick="@(() => this.RepairDataSource(context))"/>
</MudTooltip>
}
@if (context.IsEnterpriseConfiguration)
{
<MudTooltip Text="@T("This data source is managed by your organization.")">

View File

@ -128,6 +128,25 @@ public partial class DataSourceManagement : MSGComponentBase
return this.SettingsManager.ConfigurationData.DataSources.Any(this.CanRefreshDataSource);
}
/// <remarks>
/// Shown only while the index of this data source cannot be read. The refresh button next to it
/// stays as it is: it would open the same store and fail the same way, but it is offered for
/// every internal data source regardless of state, and singling this one out would say more
/// about the state than that button ever has.
/// </remarks>
private bool CanRepairDataSource(IDataSource dataSource)
{
return this.DataSourceEmbeddingService.NeedsIndexRepair(dataSource);
}
private async Task RepairDataSource(IDataSource dataSource)
{
if (!this.CanRepairDataSource(dataSource))
return;
await DataSourceRepair.ConfirmAndRepairAsync(this.DialogService, this.DataSourceEmbeddingService, dataSource.Id, dataSource.Name);
}
private async Task AutomaticRefreshChanged(bool enabled)
{
this.SettingsManager.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh = enabled;

View File

@ -69,7 +69,7 @@
@switch (this.aiBasedSourceSelection)
{
case true when this.availableDataSources.Count == 0:
case true when this.GetListedDataSources().Count == 0:
<MudText Typo="Typo.body2" Class="mb-2">
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
</MudText>
@ -81,7 +81,7 @@
</MudText>
break;
case false when this.availableDataSources.Count == 0:
case false when this.GetListedDataSources().Count == 0:
<MudText Typo="Typo.body2" Class="mb-2">
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
</MudText>
@ -90,22 +90,9 @@
case false:
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@this.aiBasedSourceSelection">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" Style="max-height: 14em;">
@foreach (var source in this.availableDataSources)
@foreach (var source in this.GetListedDataSources())
{
<MudListItem Value="@source">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.Name
</MudText>
@if (source is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
</MudListItem>
<DataSourceSelectionRow DataSource="@source" BlockReason="@this.GetBlockReason(source)"/>
}
</MudList>
</MudField>
@ -115,22 +102,9 @@
<MudExpansionPanels MultiExpansion="@false" Class="mt-3" Style="max-height: 14em;">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.TouchApp" IconSize="Size.Small" HeaderTypo="Typo.subtitle1" HeaderClass="expansion-panel-header-compact" HeaderText="@T("Available Data Sources")">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="MudBlazor.SelectionMode.SingleSelection" SelectedValues="@this.selectedDataSources" Style="max-height: 14em;">
@foreach (var source in this.availableDataSources)
@foreach (var source in this.GetListedDataSources())
{
<MudListItem Value="@source">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.Name
</MudText>
@if (source is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
</MudListItem>
<DataSourceSelectionRow DataSource="@source" BlockReason="@this.GetBlockReason(source)"/>
}
</MudList>
</ExpansionPanel>
@ -166,13 +140,13 @@
break;
}
@if (!this.aiBasedSourceSelection && this.GetUnavailablePreselectedDataSources().Count > 0)
@if (!this.aiBasedSourceSelection && this.GetUnavailablePreselectedDataSourcesToList().Count > 0)
{
<MudJustifiedText Typo="Typo.body2" Color="Color.Warning">
@T("These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:")
</MudJustifiedText>
<ul class="unavailable-data-sources mb-3 mt-1">
@foreach (var source in this.GetUnavailablePreselectedDataSources())
@foreach (var source in this.GetUnavailablePreselectedDataSourcesToList())
{
<li>@source.Name</li>
}
@ -212,22 +186,13 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Disabled="@this.IsPreselectedDataSourcesAutomaticValidationLocked()"/>
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-3" Disabled="@(this.aiBasedSourceSelection || this.IsPreselectedDataSourceIdsLocked())">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" ReadOnly="@this.IsPreselectedDataSourceIdsLocked()">
@*
The configuration mode lists what was configured, without filtering it, so no
row here is ever waiting for an index.
*@
@foreach (var source in this.availableDataSources)
{
<MudListItem Value="@source">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.Name
</MudText>
@if (source is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
</MudListItem>
<DataSourceSelectionRow DataSource="@source"/>
}
</MudList>
</MudField>

View File

@ -49,6 +49,10 @@ public partial class DataSourceSelection : MSGComponentBase
private bool showDataSourceSelection;
private bool waitingForDataSources = true;
private IReadOnlyList<IDataSource> availableDataSources = [];
private IReadOnlyList<IDataSource> dataSourcesAwaitingReindex = [];
private HashSet<string> dataSourceIdsAwaitingReindex = new(StringComparer.Ordinal);
private IReadOnlyList<IDataSource> dataSourcesNeedingRepair = [];
private HashSet<string> dataSourceIdsNeedingRepair = new(StringComparer.Ordinal);
private IReadOnlyCollection<IDataSource> selectedDataSources = [];
private bool aiBasedSourceSelection;
private bool aiBasedValidation;
@ -226,11 +230,62 @@ public partial class DataSourceSelection : MSGComponentBase
return;
this.availableDataSources = sources.AllowedDataSources;
this.dataSourcesAwaitingReindex = sources.DataSourcesAwaitingReindex;
this.dataSourceIdsAwaitingReindex = sources.DataSourcesAwaitingReindex.Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
this.dataSourcesNeedingRepair = sources.DataSourcesNeedingRepair;
this.dataSourceIdsNeedingRepair = sources.DataSourcesNeedingRepair.Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
this.selectedDataSources = sources.SelectedDataSources;
this.waitingForDataSources = false;
this.StateHasChanged();
}
/// <summary>
/// Why a data source is listed but cannot be picked, if it cannot.
/// </summary>
/// <remarks>
/// The repair is asked about first. The service hands a data source to one of the two lists
/// only, but should that ever change, the reason the user can act on is the one worth showing.
/// </remarks>
private DataSourceBlockReason GetBlockReason(IDataSource dataSource)
{
if (this.dataSourceIdsNeedingRepair.Contains(dataSource.Id))
return DataSourceBlockReason.NEEDS_REPAIR;
if (this.dataSourceIdsAwaitingReindex.Contains(dataSource.Id))
return DataSourceBlockReason.AWAITING_REINDEX;
return DataSourceBlockReason.NONE;
}
/// <summary>
/// The data sources the list shows: the usable ones, plus the ones which cannot be searched.
/// </summary>
/// <remarks>
/// Kept in the order the data sources were configured in, rather than usable ones first. A row
/// which jumps to another place the moment its data source starts being re-indexed is a row the
/// user has to find again.
/// </remarks>
private IReadOnlyList<IDataSource> GetListedDataSources()
{
if (this.dataSourcesAwaitingReindex.Count == 0 && this.dataSourcesNeedingRepair.Count == 0)
return this.availableDataSources;
var listedIds = this.availableDataSources.Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
listedIds.UnionWith(this.dataSourceIdsAwaitingReindex);
listedIds.UnionWith(this.dataSourceIdsNeedingRepair);
return this.GetConfiguredDataSourcesSnapshot().Where(source => listedIds.Contains(source.Id)).ToList();
}
/// <summary>
/// The preselected but unusable data sources the warning box lists.
/// </summary>
/// <remarks>
/// The ones which are only blocked are left out: they have a row of their own in the list
/// above, which says the same thing in the place the user is already looking.
/// </remarks>
private IReadOnlyList<IDataSource> GetUnavailablePreselectedDataSourcesToList() =>
this.GetUnavailablePreselectedDataSources().Where(source => this.GetBlockReason(source) is DataSourceBlockReason.NONE).ToList();
private async Task EnabledChanged(bool state)
{
this.areDataSourcesEnabled = state;

View File

@ -0,0 +1,24 @@
@using AIStudio.Settings
@using AIStudio.Provider
@inherits MSGComponentBase
<MudTooltip Text="@this.GetBlockedTooltip()" Disabled="@(!this.IsBlocked)" RootStyle="display: block;" Placement="Placement.Top">
<MudListItem Value="@this.DataSource" Disabled="@this.IsBlocked">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@this.DataSource.Name
</MudText>
@if (this.DataSource is IInternalDataSource internalSource)
{
<MudSpacer/>
@if (this.IsBlocked)
{
<MudIcon Icon="@this.GetBlockedIcon()" Size="Size.Small" Color="@this.GetBlockedIconColor()" Style="flex-shrink: 0;"/>
}
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
</MudListItem>
</MudTooltip>

View File

@ -0,0 +1,60 @@
using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// One row of a data source list: what the source is called, how confidential it is, and whether it
/// can be used right now.
/// </summary>
/// <remarks>
/// A data source which cannot be used stays in the list instead of disappearing from it, but cannot
/// be picked, and the tooltip says why. The tool selection next to it in the chat answers the same
/// question the same way.
///
/// Why it cannot be used decides what the row says and which icon it wears: an index being built
/// anew is a matter of waiting, an index which cannot be read is a matter of acting. Both are the
/// same row otherwise, which is why this is one component with a reason rather than two components.
///
/// The tooltip sits around the list item rather than inside it: a disabled item has its pointer
/// events switched off and would swallow the hover.
/// </remarks>
public partial class DataSourceSelectionRow : MSGComponentBase
{
/// <summary>
/// The data source this row stands for.
/// </summary>
[Parameter]
public required IDataSource DataSource { get; set; }
/// <summary>
/// Why this data source cannot be picked right now, if it cannot.
/// </summary>
[Parameter]
public DataSourceBlockReason BlockReason { get; set; } = DataSourceBlockReason.NONE;
private bool IsBlocked => this.BlockReason is not DataSourceBlockReason.NONE;
private string GetBlockedTooltip() => this.BlockReason switch
{
DataSourceBlockReason.AWAITING_REINDEX => T("This data source is waiting to be indexed again. Until that is finished, it cannot be searched."),
DataSourceBlockReason.NEEDS_REPAIR => T("The index of this data source cannot be read anymore. Open your data source settings with the gear icon above, then use the repair action there."),
_ => string.Empty,
};
private string GetBlockedIcon() => this.BlockReason switch
{
DataSourceBlockReason.NEEDS_REPAIR => Icons.Material.Filled.ReportProblem,
_ => Icons.Material.Filled.HourglassTop,
};
private Color GetBlockedIconColor() => this.BlockReason switch
{
DataSourceBlockReason.NEEDS_REPAIR => Color.Error,
_ => Color.Warning,
};
private string GetConfidenceIconStyle(IInternalDataSource dataSource) => $"{dataSource.ConfidenceLevel.SetColorStyle(this.SettingsManager)} flex-shrink: 0;";
}

View File

@ -1,7 +1,10 @@
@using AIStudio.Settings
@inherits MSGComponentBase
@{
var availableProviderItems = this.GetAvailableProviderSelectionItems().ToList();
}
<MudSelect T="Provider" Value="@this.ProviderSettings" ValueChanged="@this.SelectionChanged" Validation="@this.ValidateProvider" Margin="Margin.Dense" Label="@T("Provider")" Class="mb-3 rounded-lg" OuterClass="flex-grow-0" Variant="Variant.Outlined" Disabled="@this.Disabled">
@foreach (var providerItem in this.GetAvailableProviderSelectionItems())
@foreach (var providerItem in availableProviderItems)
{
<MudSelectItem Value="@providerItem.Provider">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="w-100" Wrap="Wrap.NoWrap">
@ -21,3 +24,9 @@
</MudSelectItem>
}
</MudSelect>
@if (availableProviderItems.Count is 0 && this.GetEmptySelectionHint() is { } emptySelectionHint)
{
<MudText Typo="Typo.body2" Color="Color.Error" Class="mb-3">
@emptySelectionHint
</MudText>
}

View File

@ -53,6 +53,28 @@ public partial class ProviderSelection : MSGComponentBase
yield return new(provider, this.GetCapabilityIcons(provider));
}
/// <summary>
/// Says why there is nothing to choose from, or nothing at all when that is not the user's doing.
/// </summary>
/// <remarks>
/// An empty list has two causes the user can act on, and they lead to different places in the
/// settings: there is no provider yet, or none of the configured ones reaches the confidence
/// this component asks for. Naming the wrong one sends the user looking in the wrong place --
/// a first start has nobody to blame for a confidence level it never set. A missing or invalid
/// component is a third case and neither of those: it is a defect, it was logged as one, and
/// any explanation offered to the user here would be a guess.
/// </remarks>
private string? GetEmptySelectionHint()
{
if (this.Component is null or Tools.Components.NONE)
return null;
if (!this.SettingsManager.GetAllProviders().Any(x => x.UsedLLMProvider is not LLMProviders.NONE))
return this.T("No LLM providers are configured yet. Add a provider in the app settings.");
return this.T("No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings.");
}
private IReadOnlyList<CapabilityIcon> GetCapabilityIcons(AIStudio.Settings.Provider provider)
{
var profile = provider.GetModelProfile();

View File

@ -0,0 +1,39 @@
@inherits MSGComponentBase
@* The class is what the Markdown renderer wraps its own output in, so the headings and the list
keep the look they had while this list was Markdown. *@
<div @ref="this.listElement" class="mud-markdown-body">
@foreach (var group in this.groups)
{
@* A level-two heading was shown as h5 while this list was Markdown, because that is what
Markdown.DefaultConfig overrides it to. The heading keeps that size here. *@
<MudText Typo="Typo.h5">
@group.Heading
</MudText>
<ul>
@foreach (var entry in group.Entries)
{
<li>
@($"[{entry.Number}] ")
@if (entry.Document is { } document)
{
<MudTooltip Text="@T("Opens this document in the program your system uses for it")" Placement="Placement.Top">
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(document))">
@entry.Title
</MudLink>
</MudTooltip>
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@(() => this.ShowInFileManager(document))"/>
</MudTooltip>
}
else
{
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
@entry.Title
</MudLink>
}
</li>
}
</ul>
}
</div>

View File

@ -0,0 +1,164 @@
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows the sources an answer rests on, grouped and numbered the way the export is.
/// </summary>
/// <remarks>
/// This list used to be Markdown, which read correctly but could not be clicked where it mattered:
/// a Markdown renderer hands every link to the browser, and the browser refuses a file address on a
/// page it loaded over http. A source of the user's own documents therefore did nothing at all.
/// Written out as components, an entry can hand its document to the runtime instead, together with
/// the page the passage was found on.
/// </remarks>
public partial class SourcesList : MSGComponentBase
{
//
// The name is about the alignment the function uses, not about the page: it brings the element
// into view with its end at the bottom, which for a list at the end of an answer shows all of it.
//
private const string SCROLL_INTO_VIEW_FUNCTION = "scrollToBottom";
/// <summary>
/// The sources to show.
/// </summary>
[Parameter]
public IList<Source> Sources { get; set; } = [];
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
[Inject]
private ILogger<SourcesList> Logger { get; init; } = null!;
private readonly List<SourceEntryGroup> groups = [];
private ElementReference listElement;
/// <summary>
/// Brings this list into view.
/// </summary>
/// <remarks>
/// The counter above an answer says how many sources it rests on; this is how it takes the
/// reader to them. The element stays here, where it is rendered, rather than being handed to
/// whoever wants to scroll to it.
/// </remarks>
public async Task ScrollIntoViewAsync() => await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, SCROLL_INTO_VIEW_FUNCTION, this.listElement);
#region Overrides of ComponentBase
protected override async Task OnParametersSetAsync()
{
this.RebuildGroups();
await base.OnParametersSetAsync();
}
#endregion
/// <summary>
/// Reads the sources once per render instead of once per entry and render.
/// </summary>
/// <remarks>
/// Where a source points is answered by looking at its link, and while an answer streams, this
/// runs again for every chunk. The previous Markdown list was rebuilt and parsed just as often,
/// so this is the cheaper of the two, but it is still worth doing once for the whole list.
/// </remarks>
private void RebuildGroups()
{
this.groups.Clear();
foreach (var group in this.Sources.GroupSources())
{
var entries = new List<SourceEntry>(group.Sources.Count);
foreach (var numberedSource in group.Sources)
{
var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null;
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document));
}
this.groups.Add(new(group.Heading, entries));
}
}
/// <summary>
/// Opens a document in the program the system uses for it.
/// </summary>
/// <remarks>
/// Whether the program can be sent to a page is the runtime's business, and it says afterwards
/// whether it managed to. Nothing is shown about that here: the document is open, and the title
/// of the source names the page anyway.
/// </remarks>
/// <param name="document">The document to open, and the page to show.</param>
private async Task OpenDocument(SourceDocumentLocation document)
{
OpenDocumentResponse response;
try
{
response = await this.RustService.TryOpenDocumentInSystemViewer(document.Path, document.PageNumber);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Could not open a source document.");
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, T("Could not open the document.")));
return;
}
if (response.Success)
return;
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, string.Format(T("Could not open the document: {0}"), issue)));
}
/// <summary>
/// Opens the file browser of the system and selects the document in it.
/// </summary>
/// <remarks>
/// The second way out of the list: a document which the system opens in the wrong program, or
/// which the user wants to move or send on instead of read, is reached from here without being
/// opened. This is the same way out the embeddings page offers for a file it could not read.
/// </remarks>
/// <param name="document">The document to show.</param>
private async Task ShowInFileManager(SourceDocumentLocation document)
{
OpenPathResponse response;
try
{
response = await this.RustService.TryOpenPathInRuntimeFileManager(document.Path);
}
catch (Exception e)
{
this.Logger.LogWarning(e, "Could not show a source document in the file manager.");
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location.")));
return;
}
if (response.Success)
return;
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue)));
}
/// <summary>
/// One group of the list, prepared so that the markup only has to show it.
/// </summary>
/// <param name="Heading">The heading above the group.</param>
/// <param name="Entries">The entries of the group, in the order they are shown.</param>
private readonly record struct SourceEntryGroup(string Heading, IReadOnlyList<SourceEntry> Entries);
/// <summary>
/// One entry of the list, prepared so that the markup only has to show it.
/// </summary>
/// <param name="Number">The number the source is listed under.</param>
/// <param name="Title">The title of the source.</param>
/// <param name="Link">The address of the source, which a web source is opened by.</param>
/// <param name="Document">The document the source names, or null when it names none.</param>
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document);
}

View File

@ -60,6 +60,22 @@ public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase
private bool IsDirectoryAvailable => this.directoryInfo.Exists;
/// <summary>
/// Takes the next file which the directory scan found.
/// </summary>
/// <remarks>
/// This runs on the scan's own thread, and it does so deliberately, although the scan asks its
/// callers to reach for a dispatcher. That request is about updating the UI, and none of these
/// callbacks does: they write fields and nothing else.<br/><br/>
/// Why that holds is worth writing down, because the code does not show it. The string builder
/// has exactly one writer -- this method, on that one thread -- and nobody else ever reads it.
/// What the renderer reads is the text field beside it, and assigning a string reference is
/// atomic, so a render sees the whole previous text or the whole new one, never half of either.
/// Building that text anew costs little, because the scan stops reporting files once it has
/// reported a hundred. And a render happens only when the refresh timer ticks, which goes
/// through the dispatcher, so a reading taken a moment too early is replaced 1.6 seconds later
/// anyway.
/// </remarks>
private void UpdateFileList(string file)
{
this.directoryFiles.Append("- ");
@ -67,13 +83,38 @@ public partial class DataSourceLocalDirectoryInfoDialog : MSGComponentBase
this.directoryFilesText = this.directoryFiles.ToString();
}
/// <summary>
/// Takes the size which the directory scan has added up so far.
/// </summary>
/// <remarks>
/// Two threads call this, but never at the same time: the scan reports its progress from its
/// own thread, and the final figure follows once that thread has finished. A long is written in
/// one piece on all six targets we ship, which are 64 bit throughout, and the renderer reads it
/// only when the refresh timer ticks. The remark on the file list carries the reasoning these
/// callbacks share.
/// </remarks>
private void UpdateDirectorySize(long size)
{
this.directorySizeBytes = size;
}
/// <summary>
/// Takes the number of files which the directory scan has counted so far.
/// </summary>
/// <remarks>
/// Reported from the same two threads as the size above, and safe for the same reason.
/// </remarks>
private void UpdateDirectoryFiles(long numFiles) => this.directorySizeNumFiles = numFiles;
/// <summary>
/// Takes the news that the directory scan has finished.
/// </summary>
/// <remarks>
/// This one, unlike the three above, does not run on the scan's thread. The scan invokes it
/// after awaiting its worker, and that continuation returns to the dispatcher this dialog was
/// initialized on. Stopping the timer and asking for a render from here is therefore no
/// different from doing either in a lifecycle method.
/// </remarks>
private void DirectoryOperationDone()
{
this.refreshTimer.Stop();

View File

@ -209,17 +209,10 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED)
this.dataManuallyModel = this.DataModel.Id;
//
// We cannot load the API key for self-hosted providers:
//
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is not Host.OLLAMA && this.DataHost is not Host.VLLM)
{
await this.ReloadModels();
await base.OnInitializedAsync();
return;
}
// Load the API key:
// Load the API key. A self-hosted server may well need one: LM Studio can ask for a
// token of its own, and any of these servers can sit behind an authenticating proxy.
// So we try for every host and treat a missing key as the normal case (isTrying).
// ReloadModels() below reads dataAPIKey, so the key has to be here before it runs:
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.EMBEDDING_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
if (requestedSecret.Success)
{

View File

@ -264,17 +264,10 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId
if (this.DataLLMProvider.IsLLMModelProvidedManually())
this.dataManuallyModel = this.DataModel.Id;
//
// We cannot load the API key for self-hosted providers:
//
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is not Host.OLLAMA && this.DataHost is not Host.VLLM)
{
await this.ReloadModels();
await base.OnInitializedAsync();
return;
}
// Load the API key:
// Load the API key. A self-hosted server may well need one: LM Studio can ask for a
// token of its own, and any of these servers can sit behind an authenticating proxy.
// So we try for every host and treat a missing key as the normal case (isTrying).
// ReloadModels() below reads dataAPIKey, so the key has to be here before it runs:
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.LLM_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
if (requestedSecret.Success)
{

View File

@ -199,17 +199,10 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED)
this.dataManuallyModel = this.DataModel.Id;
//
// We cannot load the API key for self-hosted providers:
//
if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is not Host.OLLAMA)
{
await this.ReloadModels();
await base.OnInitializedAsync();
return;
}
// Load the API key:
// Load the API key. A self-hosted server may well need one: LM Studio can ask for a
// token of its own, and any of these servers can sit behind an authenticating proxy.
// So we try for every host and treat a missing key as the normal case (isTrying).
// ReloadModels() below reads dataAPIKey, so the key has to be here before it runs:
var requestedSecret = await this.RustService.GetAPIKey(this, SecretStoreType.TRANSCRIPTION_PROVIDER, isTrying: this.DataLLMProvider is LLMProviders.SELF_HOSTED);
if (requestedSecret.Success)
{

View File

@ -36,7 +36,7 @@
</CascadingValue>
@if (this.AreWorkspacesVisible)
{
<MudSplitter Dimension="@this.ReadSplitterPosition" DimensionChanged="this.SplitterChanged" EnableSlide="@this.AreWorkspacesVisible" EnableMargin="@false" StartContentStyle="margin-right: 1em;" BarStyle="" EndContentStyle="margin-left: 1em;">
<MudSplitter Dimension="@this.ReadSplitterPosition" DimensionChanged="this.SplitterChanged" Sensitivity="0.05" EnableSlide="@this.AreWorkspacesVisible" EnableMargin="@false" StartContentStyle="margin-right: 1em;" BarStyle="" EndContentStyle="margin-left: 1em;">
<StartContent>
@if (this.SettingsManager.ConfigurationData.Workspace.DisplayBehavior is WorkspaceDisplayBehavior.TOGGLE_SIDEBAR && this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible)
{

View File

@ -27,6 +27,7 @@ public partial class Chat : MSGComponentBase
private string currentWorkspaceName = string.Empty;
private Workspaces? workspaces;
private double splitterPosition = 30;
private bool skipRenderAfterSplitterChange;
private readonly ChatComposerState composerState = new();
private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6));
@ -39,6 +40,16 @@ public partial class Chat : MSGComponentBase
this.splitterPosition = this.SettingsManager.ConfigurationData.Workspace.SplitterPosition;
this.splitterSaveTimer.AutoReset = false;
//
// Mind that this handler deliberately stays off the renderer thread, although it writes the
// configuration data from a thread pool thread. The position is a single double, and every
// target we ship is 64 bit, so the write cannot tear -- and the worst a lost one could do is
// a splitter standing somewhere else after the next start. What a jump to the dispatcher
// would cost instead is paid by the user: storing the settings serializes all of them and
// writes two files, and it would do that in the very queue which draws the drag they are in
// the middle of. The splitter then stutters under their hand. Whoever synchronizes the
// configuration data one day should do it without moving that work onto the renderer.
//
this.splitterSaveTimer.Elapsed += (_, _) =>
{
this.SettingsManager.ConfigurationData.Workspace.SplitterPosition = this.splitterPosition;
@ -48,6 +59,30 @@ public partial class Chat : MSGComponentBase
await base.OnInitializedAsync();
}
/// <summary>
/// Decides whether this page renders again.
/// </summary>
/// <remarks>
/// Dragging the splitter reports every movement, and Blazor renders this page after each of
/// them. That render is pure waste: all it would contribute is the position the splitter just
/// reported, and the splitter has it already -- it renders itself after its own event, which is
/// what resizes the two panels. What this page rebuilds instead is everything else it holds,
/// the workspace tree above all, which has no render guard of its own and draws an item with
/// three buttons for every chat. That is what the user sees stutter while they drag.<br/><br/>
/// Dropping that one render costs nothing, because the splitter never needed it. Should a
/// message from the bus ask for a render in the very same moment, this swallows it -- both sit
/// on the same dispatcher and the render of a movement follows it without a gap, so the window
/// is as good as closed, and the next render brings the message along anyway.
/// </remarks>
protected override bool ShouldRender()
{
if (!this.skipRenderAfterSplitterChange)
return true;
this.skipRenderAfterSplitterChange = false;
return false;
}
#endregion
private string WorkspaceSidebarToggleIcon => this.SettingsManager.ConfigurationData.Workspace.IsSidebarVisible ? Icons.Material.Filled.ArrowCircleLeft : Icons.Material.Filled.ArrowCircleRight;
@ -75,6 +110,7 @@ public partial class Chat : MSGComponentBase
this.splitterPosition = position;
this.splitterSaveTimer.Stop();
this.splitterSaveTimer.Start();
this.skipRenderAfterSplitterChange = true;
}
private void ToggleWorkspacesOverlay()

View File

@ -19,6 +19,12 @@
<MudChip T="string" Color="Color.Default" Variant="Variant.Filled">@string.Format(T("Skipped files: {0}"), this.TotalPermanentlySkippedFiles)</MudChip>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled">@string.Format(T("Failed files: {0}"), this.TotalFailedFiles)</MudChip>
</MudStack>
@if (this.IsWorkingThroughDataSources)
{
<MudText Typo="Typo.body2" Class="mt-2">
@string.Format(T("Data source {0} of {1} is being worked on. The others are waiting their turn."), this.CurrentDataSourceNumber, this.Statuses.Count)
</MudText>
}
</MudPaper>
@if (this.Statuses.Count == 0)
@ -55,6 +61,12 @@
<MudIconButton Icon="@Icons.Material.Filled.Sync" Color="Color.Info" Size="Size.Small" OnClick="@(async () => await this.RefreshDataSource(status))" />
</MudTooltip>
}
@if (this.CanRepair(status))
{
<MudTooltip Text="@T("Repair this data source by indexing it anew")">
<MudIconButton Icon="@Icons.Material.Filled.Build" Color="Color.Warning" Size="Size.Small" OnClick="@(async () => await this.RepairDataSource(status))" />
</MudTooltip>
}
</div>
</TitleContent>
<ChildContent>
@ -62,7 +74,7 @@
<MudProgressLinear Value="@status.ProgressPercent" Rounded="@true" Color="@GetStatusColor(status)" />
<MudText Typo="Typo.body2">
@string.Format(T("{0} of {1} files are indexed."), status.IndexedFiles, status.TotalFiles)
@this.GetFileProgressText(status)
</MudText>
@if (status.PermanentlySkippedFiles > 0)

View File

@ -1,3 +1,5 @@
using System.Globalization;
using AIStudio.Components;
using AIStudio.Dialogs.Settings;
using AIStudio.Provider;
@ -35,6 +37,13 @@ public partial class Embeddings : MSGComponentBase
private string? expandedDataSourceId;
private bool userChoseExpansion;
/// <remarks>
/// The language of AI Studio is chosen in its settings and does not move the thread's culture
/// along with it. Without this, a German reading a German page would find a file count written
/// with English separators.
/// </remarks>
private CultureInfo currentCulture = CultureInfo.InvariantCulture;
private int TotalIndexedFiles => this.Statuses.Sum(status => status.IndexedFiles);
private int TotalPendingFiles => this.Statuses.Sum(status => Math.Max(0, status.TotalFiles - status.IndexedFiles - status.FailedFiles - status.PermanentlySkippedFiles));
@ -43,6 +52,19 @@ public partial class Embeddings : MSGComponentBase
private int TotalPermanentlySkippedFiles => this.Statuses.Sum(status => status.PermanentlySkippedFiles);
/// <remarks>
/// The chips above count files, which says nothing about how far the list of data sources itself
/// has come. While several of them wait their turn, this is the one line saying so. With a single
/// data source there is nothing to say: its own row already tells the whole story.
/// </remarks>
private bool IsWorkingThroughDataSources => this.Statuses.Count > 1 && this.Statuses.Any(status => status.State is DataSourceEmbeddingState.RUNNING or DataSourceEmbeddingState.QUEUED);
/// <remarks>
/// The one being worked on is the one after those which are done. A data source which needs
/// attention counts as done here: nothing is going to happen to it during this pass.
/// </remarks>
private int CurrentDataSourceNumber => Math.Min(this.Statuses.Count, this.Statuses.Count(status => status.State is DataSourceEmbeddingState.COMPLETED or DataSourceEmbeddingState.FAILED) + 1);
protected override async Task OnInitializedAsync()
{
//
@ -56,20 +78,28 @@ public partial class Embeddings : MSGComponentBase
return;
}
this.ApplyFilters([], [ Event.RAG_EMBEDDING_STATUS_CHANGED, Event.CONFIGURATION_CHANGED ]);
this.ApplyFilters([], [ Event.RAG_EMBEDDING_STATUS_CHANGED, Event.CONFIGURATION_CHANGED, Event.PLUGINS_RELOADED ]);
await this.RefreshCulture();
await base.OnInitializedAsync();
this.ReloadStatuses();
}
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
if (triggeredEvent is Event.RAG_EMBEDDING_STATUS_CHANGED or Event.CONFIGURATION_CHANGED)
if (triggeredEvent is Event.CONFIGURATION_CHANGED or Event.PLUGINS_RELOADED)
await this.RefreshCulture();
if (triggeredEvent is Event.RAG_EMBEDDING_STATUS_CHANGED or Event.CONFIGURATION_CHANGED or Event.PLUGINS_RELOADED)
{
this.ReloadStatuses();
this.StateHasChanged();
}
}
return Task.CompletedTask;
private async Task RefreshCulture()
{
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
this.currentCulture = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
}
private void ReloadStatuses()
@ -142,6 +172,42 @@ public partial class Embeddings : MSGComponentBase
await dialogReference.Result;
}
/// <summary>
/// What the panel of a data source says about its progress through the files.
/// </summary>
/// <remarks>
/// While a file is being worked on, the sentence names that file and how far into it we are.
/// Counting finished files alone leaves the same sentence standing for hours on a document of
/// several thousand pages, and a progress which never moves cannot be told apart from one which
/// is stuck. The total number of blocks is not part of it: the blocks are produced while the
/// file is read, so nobody knows how many there will be until the file is done.
///
/// Which sentence is shown depends on the file, not on the block. A file has no blocks yet
/// while it is being read, and hanging the choice on the block number let the line jump back
/// and forth between two entirely different sentences at every file. Now the beginning of the
/// sentence stays put and the blocks are appended to it as soon as the first one arrives.
/// </remarks>
private string GetFileProgressText(DataSourceEmbeddingStatus status)
{
if (status.State is not DataSourceEmbeddingState.RUNNING || string.IsNullOrWhiteSpace(status.CurrentFile))
return string.Format(T("{0} of {1} files are indexed."), this.FormatNumber(status.IndexedFiles), this.FormatNumber(status.TotalFiles));
//
// Everything already dealt with, plus the one in hand. Skipped and failed files are part of
// that: they are behind us in the folder, and leaving them out would let the number fall
// behind the file whose name is shown right next to it.
//
var currentFileNumber = Math.Min(status.TotalFiles, status.IndexedFiles + status.PermanentlySkippedFiles + status.FailedFiles + 1);
return status switch
{
{ CurrentFileBlock: { } block, CurrentFilePage: { } page } => string.Format(T("File {0} of {1} is being indexed: block {2}, page {3}."), this.FormatNumber(currentFileNumber), this.FormatNumber(status.TotalFiles), this.FormatNumber(block), this.FormatNumber(page)),
{ CurrentFileBlock: { } block } => string.Format(T("File {0} of {1} is being indexed: block {2}."), this.FormatNumber(currentFileNumber), this.FormatNumber(status.TotalFiles), this.FormatNumber(block)),
_ => string.Format(T("File {0} of {1} is being indexed."), this.FormatNumber(currentFileNumber), this.FormatNumber(status.TotalFiles)),
};
}
private string FormatNumber(int value) => value.ToString("N0", this.currentCulture);
private static Color GetStatusColor(DataSourceEmbeddingStatus status) => status.State switch
{
DataSourceEmbeddingState.RUNNING => Color.Warning,
@ -252,13 +318,28 @@ public partial class Embeddings : MSGComponentBase
await this.MessageBus.SendError(new(Icons.Material.Filled.Folder, string.Format(T("Could not open the file location: {0}"), issue)));
}
/// <remarks>
/// An unreadable index is left to the repair button below: another attempt would open the same
/// store and fail the same way, so offering both would be offering one that does nothing.
/// </remarks>
private bool CanRefresh(DataSourceEmbeddingStatus status)
{
return this.DataSourceEmbeddingService.CanRefreshDataSource(status.DataSourceId) &&
status.State is not DataSourceEmbeddingState.RUNNING and not DataSourceEmbeddingState.QUEUED &&
status is { VectorStoreUnreadable: false, State: not DataSourceEmbeddingState.RUNNING and not DataSourceEmbeddingState.QUEUED } &&
(status.State is DataSourceEmbeddingState.FAILED || status.FailedFiles > 0);
}
/// <remarks>
/// Offered for the one failure which no further attempt gets past. It is a button of its own
/// and not the refresh one, because what it does is not what the user expects of a refresh:
/// everything indexed so far is thrown away and paid for again.
/// </remarks>
private bool CanRepair(DataSourceEmbeddingStatus status)
{
return this.DataSourceEmbeddingService.CanRefreshDataSource(status.DataSourceId) &&
status is { State: DataSourceEmbeddingState.FAILED, VectorStoreUnreadable: true };
}
/// <summary>
/// Takes the user to the settings, where the embedding providers are configured.
/// </summary>
@ -274,4 +355,13 @@ public partial class Embeddings : MSGComponentBase
this.ReloadStatuses();
await this.InvokeAsync(this.StateHasChanged);
}
private async Task RepairDataSource(DataSourceEmbeddingStatus status)
{
if (!await DataSourceRepair.ConfirmAndRepairAsync(this.DialogService, this.DataSourceEmbeddingService, status.DataSourceId, status.DataSourceName))
return;
this.ReloadStatuses();
await this.InvokeAsync(this.StateHasChanged);
}
}

View File

@ -22,25 +22,40 @@
<MudListItem T="string" Icon="@Icons.Material.Outlined.Build" Text="@this.VersionRust"/>
<MudListItem T="string" Icon="@Icons.Material.Outlined.Storage">
<MudText Typo="Typo.body1">
@this.VersionVectorStore
@this.DatabaseHeaderText(this.vectorStoreSection)
</MudText>
<MudCollapse Expanded="@this.showVectorStoreDetails">
<MudCollapse Expanded="@this.vectorStoreSection.ShowDetails">
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
@foreach (var item in this.vectorStoreDisplayInfo)
@foreach (var item in this.BuildDatabaseInfoItems(this.vectorStoreSection))
{
<div style="display: flex; align-items: center; gap: 8px;">
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
<span>@item.Label: @item.Value</span>
<MudCopyClipboardButton TooltipMessage="@(T("Copies the following to the clipboard")+": "+item.Value)" StringContent=@item.Value/>
</div>
<ConfigInfoRow Item="@item"/>
}
</MudText>
</MudCollapse>
<MudButton StartIcon="@(this.showVectorStoreDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
<MudButton StartIcon="@(this.vectorStoreSection.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
Variant="Variant.Text"
OnClick="@this.ToggleVectorStoreDetails">
@(this.showVectorStoreDetails ? T("Hide Details") : T("Show Details"))
OnClick="@(() => this.ToggleDatabaseDetails(this.vectorStoreSection))">
@(this.vectorStoreSection.ShowDetails ? T("Hide Details") : T("Show Details"))
</MudButton>
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Outlined.ManageSearch">
<MudText Typo="Typo.body1">
@this.DatabaseHeaderText(this.indexStoreSection)
</MudText>
<MudCollapse Expanded="@this.indexStoreSection.ShowDetails">
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
@foreach (var item in this.BuildDatabaseInfoItems(this.indexStoreSection))
{
<ConfigInfoRow Item="@item"/>
}
</MudText>
</MudCollapse>
<MudButton StartIcon="@(this.indexStoreSection.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
Variant="Variant.Text"
OnClick="@(() => this.ToggleDatabaseDetails(this.indexStoreSection))">
@(this.indexStoreSection.ShowDetails ? T("Hide Details") : T("Show Details"))
</MudButton>
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Outlined.DocumentScanner" Text="@this.VersionPdfium"/>

View File

@ -4,7 +4,6 @@ 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;
@ -98,20 +97,38 @@ public partial class Information : MSGComponentBase
private string VersionPdfium => $"{T("Used PDFium version")}: v{META_DATA_LIBRARIES.PdfiumVersion}";
private string VersionVectorStore
/// <summary>
/// Builds the headline of one database block.
/// </summary>
/// <remarks>
/// The vector store names the version from the build metadata, which is what this page always
/// showed. The index store names the one its client read from the running database instead:
/// there, which SQLite build actually got loaded is the whole point of showing it.
/// </remarks>
private string DatabaseHeaderText(DatabaseSection section)
{
get
var nameLabel = section.Role switch
{
if (this.vectorStore is null)
return $"{T("Vector store")}: {T("checking availability")}";
return this.vectorStore.Status switch
{
DatabaseClientStatus.AVAILABLE => $"{T("Vector store version")}: {this.vectorStore.Name} v{META_DATA_VECTOR_STORE.VectorStoreVersion}",
DatabaseClientStatus.STARTING => $"{T("Vector store")}: {this.vectorStore.Name} - {T("starting")}",
_ => $"{T("Vector store")}: {this.vectorStore.Name} - {T("not available")}"
DatabaseRole.VECTOR_STORE => T("Vector database"),
_ => T("Index database"),
};
if (section.Client is null)
return $"{nameLabel}: {T("checking availability")}";
var version = section.Role switch
{
DatabaseRole.VECTOR_STORE => META_DATA_VECTOR_STORE.VectorStoreVersion,
_ => section.Client.Version
};
return section.Client.Status switch
{
DatabaseClientStatus.AVAILABLE when !string.IsNullOrWhiteSpace(version) => $"{nameLabel}: {section.Client.Name} v{version}",
DatabaseClientStatus.AVAILABLE => $"{nameLabel}: {section.Client.Name}",
DatabaseClientStatus.STARTING => $"{nameLabel}: {section.Client.Name} - {T("starting")}",
_ => $"{nameLabel}: {section.Client.Name} - {T("not available")}"
};
}
}
private string versionPandoc = TB("Determine Pandoc version, please wait...");
@ -121,7 +138,6 @@ public partial class Information : MSGComponentBase
private bool showEnterpriseConfigDetails;
private bool showVectorStoreDetails;
private bool showExternalHttpCustomRootCertificateDetails;
private List<IAvailablePlugin> configPlugins = [];
@ -141,10 +157,30 @@ public partial class Information : MSGComponentBase
private sealed record MandatoryInfoPanelData(string HeaderText, string PluginName, DataMandatoryInfo Info, DataMandatoryInfoAcceptance? Acceptance);
private sealed record VectorStoreDisplayInfo(string Label, string Value);
private readonly List<VectorStoreDisplayInfo> vectorStoreDisplayInfo = new();
private DatabaseClient? vectorStore;
private CancellationTokenSource? vectorStoreRefreshCancellationTokenSource;
private sealed record DatabaseDisplayInfo(string Label, string Value);
/// <summary>
/// Everything one database block on this page needs to show itself.
/// </summary>
/// <remarks>
/// Both blocks work the same way, so they share their state and their methods and differ only
/// in their role. Whoever adds a third database adds one field here, not another set of methods.
/// </remarks>
private sealed class DatabaseSection(DatabaseRole role)
{
public DatabaseRole Role => role;
public DatabaseClient? Client { get; set; }
public bool ShowDetails { get; set; }
public List<DatabaseDisplayInfo> DisplayInfo { get; } = [];
public CancellationTokenSource? RefreshCancellationTokenSource { get; set; }
}
private readonly DatabaseSection vectorStoreSection = new(DatabaseRole.VECTOR_STORE);
private readonly DatabaseSection indexStoreSection = new(DatabaseRole.INDEX_STORE);
private bool HasAnyActiveEnvironment => this.enterpriseEnvironments.Any(e => e.IsActive);
@ -191,9 +227,16 @@ public partial class Information : MSGComponentBase
this.updatePolicyMode = this.UpdatePolicy.CurrentMode;
this.logPaths = await this.RustService.GetLogPaths();
await this.RefreshVectorStoreInfo(CancellationToken.None);
if (this.vectorStore?.Status is DatabaseClientStatus.STARTING)
this.StartShortVectorStoreRefreshLoop();
// The index store goes first: the vector store asks it for the number of stored vectors,
// and this way that client is already cached when it does.
await this.RefreshDatabaseInfo(this.indexStoreSection, CancellationToken.None);
await this.RefreshDatabaseInfo(this.vectorStoreSection, CancellationToken.None);
if (this.indexStoreSection.Client?.Status is DatabaseClientStatus.STARTING)
this.StartShortDatabaseRefreshLoop(this.indexStoreSection);
if (this.vectorStoreSection.Client?.Status is DatabaseClientStatus.STARTING)
this.StartShortDatabaseRefreshLoop(this.vectorStoreSection);
// Determine the Pandoc version may take some time, so we start it here
// without waiting for the result:
@ -301,22 +344,31 @@ public partial class Information : MSGComponentBase
this.showExternalHttpCustomRootCertificateDetails = !this.showExternalHttpCustomRootCertificateDetails;
}
private void ToggleVectorStoreDetails()
private void ToggleDatabaseDetails(DatabaseSection section)
{
this.showVectorStoreDetails = !this.showVectorStoreDetails;
section.ShowDetails = !section.ShowDetails;
}
private async Task RefreshVectorStoreInfo(CancellationToken cancellationToken)
private IReadOnlyList<ConfigInfoRowItem> BuildDatabaseInfoItems(DatabaseSection section) => section.DisplayInfo
.Select((item, index) => new ConfigInfoRowItem(
Icons.Material.Filled.ArrowRightAlt,
$"{item.Label}: {item.Value}",
item.Value,
$"{T("Copies the following to the clipboard")}: {item.Value}",
index == 0 ? string.Empty : "margin-top: 4px;"))
.ToList();
private async Task RefreshDatabaseInfo(DatabaseSection section, CancellationToken cancellationToken)
{
var refreshedClient = await this.DatabaseClientProvider.RefreshClientAsync(DatabaseRole.VECTOR_STORE, cancellationToken);
this.vectorStore = refreshedClient;
this.vectorStoreDisplayInfo.Clear();
var refreshedClient = await this.DatabaseClientProvider.RefreshClientAsync(section.Role, cancellationToken);
section.Client = refreshedClient;
section.DisplayInfo.Clear();
try
{
await foreach (var (label, value) in refreshedClient.GetDisplayInfo().WithCancellation(cancellationToken))
{
this.vectorStoreDisplayInfo.Add(new VectorStoreDisplayInfo(label, value));
section.DisplayInfo.Add(new DatabaseDisplayInfo(label, value));
}
}
catch (OperationCanceledException)
@ -325,20 +377,24 @@ public partial class Information : MSGComponentBase
}
catch (Exception e)
{
this.vectorStore = new NoVectorStoreClient(refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING);
await foreach (var (label, value) in this.vectorStore.GetDisplayInfo().WithCancellation(cancellationToken))
// Drop whatever came in before the failure: those lines would otherwise stand next to
// the status and reason of the stand-in client and read like current values.
section.DisplayInfo.Clear();
section.Client = DatabaseClientProvider.CreateUnavailableClient(section.Role, refreshedClient.Name, e.Message, DatabaseClientStatus.STARTING);
await foreach (var (label, value) in section.Client.GetDisplayInfo().WithCancellation(cancellationToken))
{
this.vectorStoreDisplayInfo.Add(new VectorStoreDisplayInfo(label, value));
section.DisplayInfo.Add(new DatabaseDisplayInfo(label, value));
}
}
}
private void StartShortVectorStoreRefreshLoop()
private void StartShortDatabaseRefreshLoop(DatabaseSection section)
{
this.vectorStoreRefreshCancellationTokenSource?.Cancel();
this.vectorStoreRefreshCancellationTokenSource?.Dispose();
this.vectorStoreRefreshCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = this.vectorStoreRefreshCancellationTokenSource.Token;
section.RefreshCancellationTokenSource?.Cancel();
section.RefreshCancellationTokenSource?.Dispose();
section.RefreshCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = section.RefreshCancellationTokenSource.Token;
Task.Run(async () =>
{
@ -350,11 +406,11 @@ public partial class Information : MSGComponentBase
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
await this.InvokeAsync(async () =>
{
await this.RefreshVectorStoreInfo(cancellationToken);
await this.RefreshDatabaseInfo(section, cancellationToken);
this.StateHasChanged();
});
if (this.vectorStore?.Status is not DatabaseClientStatus.STARTING)
if (section.Client?.Status is not DatabaseClientStatus.STARTING)
return;
}
catch (OperationCanceledException)
@ -366,7 +422,14 @@ public partial class Information : MSGComponentBase
return;
}
}
}, cancellationToken).Observe($"{nameof(Information)}: refreshing the vector store info");
}, cancellationToken).Observe($"{nameof(Information)}: refreshing the {section.Role} info");
}
private void CancelDatabaseRefreshLoop(DatabaseSection section)
{
section.RefreshCancellationTokenSource?.Cancel();
section.RefreshCancellationTokenSource?.Dispose();
section.RefreshCancellationTokenSource = null;
}
private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId)
@ -525,8 +588,8 @@ public partial class Information : MSGComponentBase
protected override void DisposeResources()
{
this.vectorStoreRefreshCancellationTokenSource?.Cancel();
this.vectorStoreRefreshCancellationTokenSource?.Dispose();
this.CancelDatabaseRefreshLoop(this.vectorStoreSection);
this.CancelDatabaseRefreshLoop(this.indexStoreSection);
base.DisposeResources();
}

View File

@ -106,7 +106,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::ASSISTANTAGENDA::T1331274154"] =
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::ASSISTANTAGENDA::T1345848634"] = "Bitte stellen Sie einen Moderator für das Meeting oder Seminar zur Verfügung. Wer wird die Diskussion leiten?"
-- Please start each line of your content list with a dash (-) to create a bullet point list.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::ASSISTANTAGENDA::T1384718254"] = "Bitte beginnen Sie jede Zeile ihrer Inhaltsliste mit einem Strich (-), um eine Aufzählungsliste zu erstellen."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::ASSISTANTAGENDA::T1384718254"] = "Bitte beginnen Sie jede Zeile Ihrer Inhaltsliste mit einem Strich (-), um eine Aufzählungsliste zu erstellen."
-- Describe the objective(s) of the meeting, seminar, etc. What should be achieved?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::ASSISTANTAGENDA::T142537978"] = "Beschreiben Sie das Ziel bzw. die Ziele des Treffens, Seminars usw. Was soll erreicht werden?"
@ -1276,7 +1276,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DYNAMIC::ASSISTANTDYNAMIC::T3167933145"]
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DYNAMIC::ASSISTANTDYNAMIC::T465395981"] = "Bitte wählen Sie eines Ihrer Profile aus."
-- Provide a list of bullet points and some basic information for an e-mail. The assistant will generate an e-mail based on that input.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::EMAIL::ASSISTANTEMAIL::T1143222914"] = "Geben Sie eine Liste von Stichpunkten sowie einige Basisinformationen für eine E-Mail ein. Der Assistent erstellt anschließend eine E-Mail auf Grundlage ihrer Angaben."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::EMAIL::ASSISTANTEMAIL::T1143222914"] = "Geben Sie eine Liste von Stichpunkten sowie einige Basisinformationen für eine E-Mail ein. Der Assistent erstellt anschließend eine E-Mail auf Grundlage Ihrer Angaben."
-- Your name for the closing salutation of your e-mail.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::EMAIL::ASSISTANTEMAIL::T134060413"] = "Ihr Name für die Grußformel am Ende ihrer E-Mail."
@ -1492,7 +1492,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2149175535"] = "Warnu
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2176833082"] = "Einbettungsmethode hinzufügen"
-- For your ERI server, you need to retrieve data that matches a chat or prompt in some way. We call this the retrieval process. You must describe at least one such process. You may offer several retrieval processes from which users can choose. This allows you to test with beta users which process works better. Or you might generally want to give users the choice so they can select the process that best suits their circumstances.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T218617347"] = "Für ihren ERI-Server müssen Sie Daten abrufen, die in irgendeiner Weise zu einem Chat oder einem Prompt passen. Diesen Vorgang nennen wir „Retrieval-Prozess“ (Abrufprozess). Sie müssen mindestens einen solchen Prozess beschreiben. Sie können auch mehrere Abrufprozesse anbieten, aus denen die Nutzer wählen können. So können Sie mit Beta-Nutzern testen, welcher Prozess besser funktioniert. Oder Sie möchten den Nutzern grundsätzlich die Wahl lassen, damit sie den Prozess auswählen können, der am besten zu ihren Bedürfnissen passt."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T218617347"] = "Für Ihren ERI-Server müssen Sie Daten abrufen, die in irgendeiner Weise zu einem Chat oder einem Prompt passen. Diesen Vorgang nennen wir „Retrieval-Prozess“ (Abrufprozess). Sie müssen mindestens einen solchen Prozess beschreiben. Sie können auch mehrere Abrufprozesse anbieten, aus denen die Nutzer wählen können. So können Sie mit Beta-Nutzern testen, welcher Prozess besser funktioniert. Oder Sie möchten den Nutzern grundsätzlich die Wahl lassen, damit sie den Prozess auswählen können, der am besten zu ihren Bedürfnissen passt."
-- You can specify more than one embedding method. This can be useful when you want to use different embeddings for different queries or data types. For example, one embedding for texts, another for images, and a third for videos, etc.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T2202387805"] = "Sie können mehr als eine Einbettungsmethode angeben. Das ist nützlich, wenn Sie unterschiedliche Einbettungen für verschiedene Abfragen oder Datentypen verwenden möchten. Zum Beispiel eine Einbettung für Texte, eine andere für Bilder und eine dritte für Videos usw."
@ -1576,7 +1576,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T315946275"] = "Einbet
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3178184134"] = "Bitte wählen Sie mindestens eine Authentifizierungsmethode aus."
-- The ERI specification will change over time. You probably want to keep your ERI server up to date. This means you might want to regenerate the code for your ERI server. To avoid having to make all inputs each time, all your inputs and decisions can be automatically saved. Would you like this?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3203532492"] = "Die ERI-Spezifikation wird sich im Laufe der Zeit ändern. Sie möchten wahrscheinlich, dass ihr ERI-Server immer auf dem neuesten Stand ist. Das bedeutet, dass Sie den Code für ihren ERI-Server eventuell erneut generieren müssen. Damit Sie nicht jedes Mal alle Eingaben erneut machen müssen, können alle ihre Eingaben und Entscheidungen automatisch gespeichert werden. Möchten Sie das?"
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3203532492"] = "Die ERI-Spezifikation wird sich im Laufe der Zeit ändern. Sie möchten wahrscheinlich, dass Ihr ERI-Server immer auf dem neuesten Stand ist. Das bedeutet, dass Sie den Code für Ihren ERI-Server eventuell erneut generieren müssen. Damit Sie nicht jedes Mal alle Eingaben erneut machen müssen, können alle Ihre Eingaben und Entscheidungen automatisch gespeichert werden. Möchten Sie das?"
-- Edit
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3267849393"] = "Bearbeiten"
@ -1600,13 +1600,13 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3379345517"] = "Wicht
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3443687246"] = "ERI-Server {0}"
-- You will likely use one or more embedding methods to encode the meaning of your data into a typically high-dimensional vector space. In this case, you will use a vector database to store and search these vectors (called embeddings). However, you don't have to use embedding methods. When your retrieval method works without any embedding, you can ignore this section. An example: You store files on a file server, and your retrieval method works exclusively with file names in the file system, so you don't need embeddings.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3446047228"] = "Sie werden wahrscheinlich eine oder mehrere Einbettungs-Methoden verwenden, um die Bedeutung Ihrer Daten in einen typischerweise hochdimensionalen Vektorraum zu kodieren. In diesem Fall nutzen Sie eine Vektordatenbank, um diese Vektoren (sogenannte Einbettungen) zu speichern und zu durchsuchen. Es ist jedoch nicht zwingend erforderlich, Einbettungs-Methoden zu verwenden. Wenn ihre Suchmethode ohne Einbettungen funktioniert, können Sie diesen Abschnitt ignorieren. Ein Beispiel: Sie speichern Dateien auf einem Dateiserver, und ihre Suchmethode arbeitet ausschließlich mit Dateinamen im Dateisystem – dann benötigen Sie keine Einbettungen."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3446047228"] = "Sie werden wahrscheinlich eine oder mehrere Einbettungs-Methoden verwenden, um die Bedeutung Ihrer Daten in einen typischerweise hochdimensionalen Vektorraum zu kodieren. In diesem Fall nutzen Sie eine Vektordatenbank, um diese Vektoren (sogenannte Einbettungen) zu speichern und zu durchsuchen. Es ist jedoch nicht zwingend erforderlich, Einbettungs-Methoden zu verwenden. Wenn Ihre Suchmethode ohne Einbettungen funktioniert, können Sie diesen Abschnitt ignorieren. Ein Beispiel: Sie speichern Dateien auf einem Dateiserver, und Ihre Suchmethode arbeitet ausschließlich mit Dateinamen im Dateisystem – dann benötigen Sie keine Einbettungen."
-- Type
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3512062061"] = "Typ"
-- It may happen that the AI generates a file this time that you manually created last time. In this case, your manually created file will then be overwritten. Therefore, you should always create a Git repository and commit or revert all changes before using this assistant. With a diff visualization, you can immediately see where the AI has made changes. It is best to use an IDE suitable for your selected language for this purpose.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3541842180"] = "Es kann passieren, dass die KI diesmal eine Datei generiert, die Sie beim letzten Mal manuell erstellt haben. In diesem Fall wird ihre manuell erstellte Datei überschrieben. Sie sollten daher immer ein Git-Repository anlegen und alle Änderungen vor der Nutzung dieses Assistenten committen oder gegebenenfalls zurücksetzen. Mit einer Diff-Ansicht können Sie sofort erkennen, wo die KI Änderungen vorgenommen hat. Am besten nutzen Sie dafür eine IDE, die für die von ihnen gewählte Programmiersprache geeignet ist."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3541842180"] = "Es kann passieren, dass die KI diesmal eine Datei generiert, die Sie beim letzten Mal manuell erstellt haben. In diesem Fall wird Ihre manuell erstellte Datei überschrieben. Sie sollten daher immer ein Git-Repository anlegen und alle Änderungen vor der Nutzung dieses Assistenten committen oder gegebenenfalls zurücksetzen. Mit einer Diff-Ansicht können Sie sofort erkennen, wo die KI Änderungen vorgenommen hat. Am besten nutzen Sie dafür eine IDE, die für die von Ihnen gewählte Programmiersprache geeignet ist."
-- Please describe how the selected authentication methods should be used.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T356079033"] = "Bitte beschreiben Sie, wie die ausgewählten Authentifizierungsmethoden verwendet werden sollen."
@ -1618,7 +1618,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3565127422"] = "Authe
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3617128581"] = "Abrufprozess hinzufügen"
-- Please provide a brief description of your ERI server. Describe or explain what your ERI server does and what data it uses for this purpose. This description will be shown to users in AI Studio.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3637826231"] = "Bitte geben Sie eine kurze Beschreibung ihres ERI-Servers an. Beschreiben oder erklären Sie, was Ihr ERI-Server macht und welche Daten dafür verwendet werden. Diese Beschreibung wird den Nutzern in AI Studio angezeigt."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3637826231"] = "Bitte geben Sie eine kurze Beschreibung Ihres ERI-Servers an. Beschreiben oder erklären Sie, was Ihr ERI-Server macht und welche Daten dafür verwendet werden. Diese Beschreibung wird den Nutzern in AI Studio angezeigt."
-- Please provide the port of the data source.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3641304143"] = "Bitte geben Sie den Port der Datenquelle an."
@ -1651,7 +1651,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3897494556"] = "ERI-S
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3956615326"] = "Aber Vorsicht:"
-- Please provide a description for your ERI server. What data will the server retrieve? This description will be used to inform users about the purpose of your ERI server.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3973182416"] = "Bitte geben Sie eine Beschreibung für Ihren ERI-Server an. Welche Daten wird der Server abrufen? Diese Beschreibung wird dazu verwendet, die Nutzer über den Zweck ihres ERI-Servers zu informieren."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T3973182416"] = "Bitte geben Sie eine Beschreibung für Ihren ERI-Server an. Welche Daten wird der Server abrufen? Diese Beschreibung wird dazu verwendet, die Nutzer über den Zweck Ihres ERI-Servers zu informieren."
-- Please select a data source for the ERI server.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4010020894"] = "Bitte wählen Sie eine Datenquelle für den ERI-Server aus."
@ -1663,7 +1663,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4027569219"] = "Bitte
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4078115997"] = "Datenschutzeinstellungen"
-- Please describe the data source of your ERI server.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4156384463"] = "Bitte beschreiben Sie die Datenquelle ihres ERI-Servers."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4156384463"] = "Bitte beschreiben Sie die Datenquelle Ihres ERI-Servers."
-- ERI Server
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4204533420"] = "ERI-Server"
@ -1672,7 +1672,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4204533420"] = "ERI-S
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4215418115"] = "Der Name ihres ERI-Servers muss zwischen 6 und 60 Zeichen lang sein."
-- Describe your data source
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4272497758"] = "Beschreiben Sie ihre Datenquelle"
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T4272497758"] = "Beschreiben Sie Ihre Datenquelle"
-- The ERI is the External Retrieval Interface for AI Studio and other tools. The ERI acts as a contract between decentralized data sources and, e.g., AI Studio. The ERI is implemented by the data sources, allowing them to be integrated into AI Studio later. This means that the data sources assume the server role and AI Studio (or any other LLM tool) assumes the client role of the API. This approach serves to realize a Retrieval-Augmented Generation (RAG) process with external data.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ERI::ASSISTANTERI::T458158948"] = "Das ERI ist die externe Abrufschnittstelle (External Retrieval Interface) für AI Studio und andere Werkzeuge. Das ERI fungiert als Vertrag zwischen dezentralen Datenquellen und beispielsweise AI Studio. Die Implementierung des ERI erfolgt durch die Datenquellen, wodurch diese später in AI Studio integriert werden können. Das bedeutet, dass die Datenquellen die Serverrolle übernehmen und AI Studio (oder ein anderes LLM-Werkzeug) die Rolle des API-Clients einnimmt. Dieser Ansatz dient dazu, einen Retrieval-Augmented Generation (RAG)-Prozess mit externen Daten zu ermöglichen."
@ -1921,7 +1921,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ICONFINDER::ASSISTANTICONFINDER::T4239378
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ICONFINDER::ASSISTANTICONFINDER::T596802185"] = "Ihr Kontext"
-- Please provide a context. This will help the AI to find the right icon. You might type just a keyword or copy a sentence from your text, e.g., from a slide where you want to use the icon.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ICONFINDER::ASSISTANTICONFINDER::T653229070"] = "Bitte geben Sie einen Kontext an. Das hilft der KI, das passende Icon zu finden. Sie können einfach ein Stichwort eingeben oder einen Satz aus ihrem Text kopieren, zum Beispiel von einer Folie, auf der Sie das Icon verwenden möchten."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ICONFINDER::ASSISTANTICONFINDER::T653229070"] = "Bitte geben Sie einen Kontext an. Das hilft der KI, das passende Icon zu finden. Sie können einfach ein Stichwort eingeben oder einen Satz aus Ihrem Text kopieren, zum Beispiel von einer Folie, auf der Sie das Icon verwenden möchten."
-- (Optional) The company name
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::JOBPOSTING::ASSISTANTJOBPOSTINGS::T1134022609"] = "(Optional) Unternehmensname"
@ -2035,13 +2035,13 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T3754447
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4016275181"] = "Stellen Sie ein juristisches Dokument bereit und stellen Sie eine Frage dazu. Dieser Assistent ersetzt keine Rechtsberatung. Wenden Sie sich an einen Anwalt, um professionelle Beratung zu erhalten. Bitte beachten Sie, dass Sprachmodelle Antworten und Fakten erfinden können. Verlassen Sie sich daher nicht auf diese Antworten."
-- Please provide your questions as input.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4154383818"] = "Bitte geben Sie ihre Fragen ein."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4154383818"] = "Bitte geben Sie Ihre Fragen ein."
-- Answer the following questions about a legal document:
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T4254597664"] = "Beantworte die folgenden Fragen zu einem rechtlichen Dokument:"
-- Ask your questions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Stellen Sie ihre Fragen"
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LEGALCHECK::ASSISTANTLEGALCHECK::T467099852"] = "Stellen Sie Ihre Fragen"
-- Find
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::LOGVIEWER::ASSISTANTLOGVIEWER::T1042076026"] = "Suchen"
@ -2326,7 +2326,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T1994150308"] = "Text umformulieren & verbessern"
-- Improve your text
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T2163831433"] = "Verbessern Sie ihren Text"
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T2163831433"] = "Verbessern Sie Ihren Text"
-- Load text from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T2210807298"] = "Text aus Datei laden"
@ -2347,7 +2347,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T3754048862"] = "Schreibstil"
-- Rewrite and improve your text. Please note, that the capabilities of the different LLM providers will vary.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T480915300"] = "Überarbeiten und verbesseren Sie ihren Text. Bitte beachte Sie, dass die Fähigkeiten der verschiedenen LLM-Anbieter unterschiedlich sein können."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T480915300"] = "Überarbeiten und verbessern Sie Ihren Text. Bitte beachten Sie, dass die Fähigkeiten der verschiedenen LLM-Anbieter unterschiedlich sein können."
-- Please provide a custom language.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::REWRITEIMPROVE::ASSISTANTREWRITEIMPROVE::T656744944"] = "Bitte geben Sie eine benutzerdefinierte Sprache an."
@ -3157,7 +3157,7 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTE
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T2192261405"] = "Die Antwort des Modells enthielt unerwartete Felder. Bitte versuche es erneut oder wähle ein anderes Modell aus."
-- AI Studio was closed while this briefing was being built. You can resume the build.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T2197645770"] = "AI Studio wurde geschlossen, während dieses Briefing erstellt wurde. Du kannst die Erstellung fortsetzen."
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T2197645770"] = "AI Studio wurde geschlossen, während dieses Briefing erstellt wurde. Sie können die Erstellung fortsetzen."
-- The presentation of the model response did not match the briefing contract. Please try again or select another model.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGFAILUREEXTENSIONS::T2376983148"] = "Die Darstellung der Modellantwort entsprach nicht den Vorgaben des Briefings. Bitte versuchen Sie es erneut oder wählen Sie ein anderes Modell aus."
@ -3655,7 +3655,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1333844707"] = "N
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1335399555"] = "Einstellungen der Chat-Vorlagen öffnen"
-- Manage your templates
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Verwalten Sie ihre Vorlagen"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Verwalten Sie Ihre Vorlagen"
-- Region
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region"
@ -3916,7 +3916,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1852534051"] = "Die
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1975014927"] = "Ja, ich möchte Datenquellen verwenden."
-- You haven't configured any data sources. To grant the AI access to your data, you need to add such a source. However, if you wish to use data from your device, you first have to set up a so-called embedding. This embedding is necessary so the AI can effectively search your data, find and retrieve the correct information required for each task. In addition to local data, you can also incorporate your company's data. To do so, your company must provide the data through an ERI (External Retrieval Interface).
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T2113594442"] = "Sie haben noch keine Datenquellen konfiguriert. Um der KI Zugriff auf ihre Daten zu ermöglichen, müssen Sie zunächst eine solche Quelle hinzufügen. Wenn Sie jedoch Daten von ihrem Gerät verwenden möchten, müssen Sie zuerst eine sogenannte Einbettung einrichten. Diese Einbettung ist notwendig, damit die KI ihre Daten effektiv durchsuchen, die passenden Informationen finden und für jede Aufgabe bereitstellen kann. Neben lokalen Daten können Sie auch die Daten ihres Unternehmens einbinden. Dafür muss Ihr Unternehmen die Daten über eine ERI (External Retrieval Interface) bereitstellen."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T2113594442"] = "Sie haben noch keine Datenquellen konfiguriert. Um der KI Zugriff auf Ihre Daten zu ermöglichen, müssen Sie zunächst eine solche Quelle hinzufügen. Wenn Sie jedoch Daten von Ihrem Gerät verwenden möchten, müssen Sie zuerst eine sogenannte Einbettung einrichten. Diese Einbettung ist notwendig, damit die KI Ihre Daten effektiv durchsuchen, die passenden Informationen finden und für jede Aufgabe bereitstellen kann. Neben lokalen Daten können Sie auch die Daten Ihres Unternehmens einbinden. Dafür muss Ihr Unternehmen die Daten über eine ERI (External Retrieval Interface) bereitstellen."
-- Select the data you want to use here.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T21181525"] = "Wählen Sie hier die Daten aus, die Sie verwenden möchten."
@ -3960,6 +3960,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T700666808"] = "Date
-- Available Data Sources
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T86053874"] = "Verfügbare Datenquellen"
-- This data source is waiting to be indexed again. Until that is finished, it cannot be searched.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTIONROW::T1692539409"] = "Diese Datenquelle wartet darauf, erneut indexiert zu werden. Bis dies abgeschlossen ist, kann sie nicht durchsucht werden."
-- Tools (Optional)
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1019749907"] = "Werkzeuge (optional)"
@ -4024,7 +4027,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "L
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ISSUES::T3229841001"] = "Probleme"
-- Some tools selected for this run are not fully configured and stay unused: {0}. Please complete their settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEDTOOLSWARNING::T1319635088"] = "Einige der für diesen Durchlauf ausgewählten Werkzeuge sind nicht vollständig eingerichtet und bleiben daher ungenutzt: \"{0}\". Bitte vervollständigen Sie ihre Einstellungen."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEDTOOLSWARNING::T1319635088"] = "Einige der für diesen Durchlauf ausgewählten Werkzeuge sind nicht vollständig eingerichtet und bleiben daher ungenutzt: \"{0}\". Bitte vervollständigen Sie deren Einstellungen."
-- Not all tools selected for this run can be used with the chosen AI provider: {0}. Please choose a provider with a higher confidence level to use all of them.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANAGEDTOOLSWARNING::T2430645786"] = "Nicht alle für diesen Durchlauf ausgewählten Werkzeuge können mit dem gewählten KI-Anbieter „{0}“ verwendet werden. Bitte wählen Sie einen Anbieter mit einer höheren Vertrauensstufe, um alle Werkzeuge zu nutzen."
@ -4216,13 +4219,19 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILEFORMSELECTION::T2003449133"] = "W
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILEFORMSELECTION::T3654011106"] = "Profil-Optionen öffnen"
-- Manage your profiles
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3609533889"] = "Verwalten Sie ihre Profile"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3609533889"] = "Verwalten Sie Ihre Profile"
-- Open Profile Options
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Profil-Optionen öffnen"
-- You can switch between your profiles here
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "Hier können Sie zwischen ihren Profilen wechseln."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "Hier können Sie zwischen Ihren Profilen wechseln."
-- No LLM providers are configured yet. Add a provider in the app settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "Bisher wurden keine LLM-Anbieter konfiguriert. Bitte fügen Sie einen Anbieter in den App-Einstellungen hinzu."
-- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "Kein LLM-Anbieter erfüllt die Vertrauensanforderungen. Bitte konfigurieren Sie einen geeigneten Anbieter in den App-Einstellungen."
-- Audio input possible
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audioeingabe möglich"
@ -4423,7 +4432,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTASSISTANTAUDI
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTASSISTANTAUDIT::T4041192469"] = "Die Aktivierung ist unterhalb des Mindest-Audit-Levels blockiert."
-- Optionally choose a dedicated provider for assistant plugin audits. When left empty, AI Studio falls back to the app-wide default provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTASSISTANTAUDIT::T4166969352"] = "Optional können Sie einen speziellen Provider für Audits auswählen. Wenn dieses Feld leer bleibt, verwendet AI Studio den appweiten Standardprovider."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTASSISTANTAUDIT::T4166969352"] = "Optional können Sie einen speziellen Anbieter für Audits auswählen. Wenn dieses Feld leer bleibt, verwendet AI Studio den appweiten Standardanbieter."
-- This Agent audits newly installed or updated external Plugin-Assistant for security risks before they are activated and stores the latest audit card until the plugin manifest changes.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAGENTASSISTANTAUDIT::T893652865"] = "Dieser Agent überprüft neu installierte oder aktualisierte externe Plugin-Assistenten vor ihrer Aktivierung auf Sicherheitsrisiken und speichert die neueste Audit-Karte, bis sich das Plugin ändert."
@ -4546,7 +4555,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1555038969"]
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Farbschema"
-- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Möchten Sie eines ihrer Profile als Standard für die gesamte App festlegen? Wenn Sie einem Assistenten ein anderes Profil zuweisen, hat dieses immer Vorrang."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Möchten Sie eines Ihrer Profile als Standard für die gesamte App festlegen? Wenn Sie einem Assistenten ein anderes Profil zuweisen, hat dieses immer Vorrang."
-- seconds
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1723256298"] = "Sekunden"
@ -4663,7 +4672,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3914529369"]
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T3985928190"] = "Zusätzliche Stammzertifikate sind deaktiviert"
-- Preselect one of your profiles?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] = "Möchten Sie eines ihrer Profile vorauswählen?"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] = "Möchten Sie eines Ihrer Profile vorauswählen?"
-- When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] = "Wenn aktiviert, ist die Rechtschreibprüfung in allen Eingabefeldern aktiv. Je nach Betriebssystem werden Fehler möglicherweise nicht visuell hervorgehoben, aber ein Rechtsklick kann dennoch Korrekturvorschläge anzeigen."
@ -4825,7 +4834,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T29196
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T305753126"] = "Konfigurierte Anbieter für Einbettungen"
-- This helps AI Studio understand and compare things in a way that's similar to how humans do. When you're working on something, AI Studio can automatically identify related documents and data by comparing their digital fingerprints. For instance, if you're writing about customer service, AI Studio can instantly find other documents in your data that discuss similar topics or experiences, even if they use different words.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3251217940"] = "Dies hilft AI Studio, Dinge auf eine Art und Weise zu verstehen und zu vergleichen, die der menschlichen Denkweise ähnelt. Wenn Sie an etwas arbeiten, kann AI Studio automatisch verwandte Dokumente und Daten erkennen, indem es ihre digitalen Fingerabdrücke vergleicht. Wenn Sie zum Beispiel über Kundenservice schreiben, kann AI Studio sofort andere Dokumente in ihren Daten finden, die über ähnliche Themen oder Erfahrungen sprechen – selbst wenn sie andere Begriffe verwenden."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3251217940"] = "Dies hilft AI Studio, Dinge auf eine Art und Weise zu verstehen und zu vergleichen, die der menschlichen Denkweise ähnelt. Wenn Sie an etwas arbeiten, kann AI Studio automatisch verwandte Dokumente und Daten erkennen, indem es ihre digitalen Fingerabdrücke vergleicht. Wenn Sie zum Beispiel über Kundenservice schreiben, kann AI Studio sofort andere Dokumente in Ihren Daten finden, die über ähnliche Themen oder Erfahrungen sprechen – selbst wenn sie andere Begriffe verwenden."
-- Edit
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T3267849393"] = "Bearbeiten"
@ -5037,6 +5046,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78
-- Are you sure you want to delete the transcription provider '{0}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Möchten Sie den Anbieter für Transkriptionen „{0}“ wirklich löschen?"
-- Could not open the file location.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Der Speicherort der Datei konnte nicht geöffnet werden."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Der Speicherort der Datei konnte nicht geöffnet werden: {0}"
-- Show this file in the file manager of your system
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Diese Datei im Dateimanager Ihres Systems anzeigen"
-- Opens this document in the program your system uses for it
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Öffnet dieses Dokument in dem Programm, das Ihr System dafür verwendet."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unbekannter Fehler"
-- Could not open the document.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Das Dokument konnte nicht geöffnet werden."
-- Could not open the document: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Dokument konnte nicht geöffnet werden: {0}"
-- Copy {0} to the clipboard
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Kopiere {0} in die Zwischenablage"
@ -5125,10 +5155,10 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T4209882371"] = "1 We
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TOOLSELECTIONFIELD::T807707919"] = "{0} Werkzeuge ausgewählt"
-- You'll interact with the AI systems using your voice. To achieve this, we want to integrate voice input (speech-to-text) and output (text-to-speech). However, later on, it should also have a natural conversation flow, i.e., seamless conversation.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1015366320"] = "Sie werden mit den KI-Systemen über ihre Stimme interagieren. Dafür möchten wir Spracheingabe (Sprache-zu-Text) und Sprachausgabe (Text-zu-Sprache) integrieren. Später soll außerdem ein natürlicher Gesprächsfluss möglich sein, also eine nahtlose Unterhaltung."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1015366320"] = "Sie werden mit den KI-Systemen über Ihre Stimme interagieren. Dafür möchten wir Spracheingabe (Sprache-zu-Text) und Sprachausgabe (Text-zu-Sprache) integrieren. Später soll außerdem ein natürlicher Gesprächsfluss möglich sein, also eine nahtlose Unterhaltung."
-- We hope this vision excites you as much as it excites us. Together, let's build a powerful and flexible AI toolkit to support all your creative, professional, and everyday needs with MindWork AI Studio.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1061000046"] = "Wir hoffen, dass diese Vision Sie genauso begeistert wie uns. Lassen Sie uns gemeinsam mit MindWork AI Studio ein leistungsstarkes und flexibles KI-Werkzeug schaffen, das Sie bei all ihren kreativen, beruflichen und alltäglichen Aufgaben unterstützt."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1061000046"] = "Wir hoffen, dass diese Vision Sie genauso begeistert wie uns. Lassen Sie uns gemeinsam mit MindWork AI Studio ein leistungsstarkes und flexibles KI-Werkzeug schaffen, das Sie bei all Ihren kreativen, beruflichen und alltäglichen Aufgaben unterstützt."
-- Integration of enterprise data
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1127694951"] = "Integration von Unternehmensdaten"
@ -5137,13 +5167,13 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1127694951"] = "Integration von
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T127032776"] = "Entspricht ihren Bedürfnissen"
-- We're integrating a writing mode to help you create extensive works, like comprehensive project proposals, tenders, or your next fantasy novel.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1457213518"] = "Wir integrieren einen Schreibmodus, der Ihnen dabei hilft, umfangreiche Werke zu erstellen – zum Beispiel ausführliche Projektvorschläge, Ausschreibungen oder ihren nächsten Fantasyroman."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1457213518"] = "Wir integrieren einen Schreibmodus, der Ihnen dabei hilft, umfangreiche Werke zu erstellen – zum Beispiel ausführliche Projektvorschläge, Ausschreibungen oder Ihren nächsten Fantasyroman."
-- Email monitoring
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1520989255"] = "E-Mail-Überwachung"
-- You'll be able to integrate your data into AI Studio, like your PDF or Office files, or your Markdown notes.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "Sie können ihre Daten in AI Studio integrieren, zum Beispiel ihre PDF- oder Office-Dateien oder ihre Markdown-Notizen."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1648606751"] = "Sie können Ihre Daten in AI Studio integrieren, zum Beispiel Ihre PDF- oder Office-Dateien oder Ihre Markdown-Notizen."
-- It will soon be possible to integrate data from the corporate network using a specified interface (External Retrieval Interface, ERI for short). This will likely require development work by the organization in question.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "Bald wird es möglich sein, Daten aus dem Firmennetzwerk über eine festgelegte Schnittstelle (External Retrieval Interface, kurz ERI) zu integrieren. Dafür wird voraussichtlich Entwicklungsaufwand seitens der jeweiligen Organisation nötig sein."
@ -5152,13 +5182,13 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1926587044"] = "Bald wird es mö
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T1986314327"] = "Demokratisierung von KI"
-- Whatever your job or task is, MindWork AI Studio aims to meet your needs: whether you're a project manager, scientist, artist, author, software developer, or game developer.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Was auch immer ihr Beruf oder ihre Aufgabe ist, MindWork AI Studio möchte ihre Bedürfnisse erfüllen: Egal, ob Sie Projektmanager, Wissenschaftler, Künstler, Autor, Softwareentwickler oder Spieleentwickler sind."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2144737937"] = "Was auch immer Ihr Beruf oder Ihre Aufgabe ist, MindWork AI Studio möchte Ihre Bedürfnisse erfüllen: Egal, ob Sie Projektmanager, Wissenschaftler, Künstler, Autor, Softwareentwickler oder Spieleentwickler sind."
-- We want to contribute to the democratization of AI. MindWork AI Studio runs even on low-cost hardware, including computers around 100 € such as Raspberry Pi. This makes the app and its full feature set accessible to people and families with limited budgets. You can start with local LLMs or use affordable cloud models. MindWork AI Studio itself is available free of charge.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2201645589"] = "Wir möchten zur Demokratisierung von KI beitragen. MindWork AI Studio läuft sogar auf kostengünstiger Hardware, einschließlich Computern für etwa 100 € wie dem Raspberry Pi. Dadurch werden die App und ihr voller Funktionsumfang auch für Menschen und Familien mit begrenztem Budget zugänglich. Sie können mit lokalen LLMs starten oder günstige Cloud-Modelle nutzen. MindWork AI Studio selbst ist kostenlos erhältlich."
-- You can connect your email inboxes with AI Studio. The AI will read your emails and notify you of important events. You'll also be able to access knowledge from your emails in your chats.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "Sie können ihre E-Mail-Postfächer mit AI Studio verbinden. Die KI liest ihre E-Mails und benachrichtigt Sie über wichtige Ereignisse. Außerdem haben Sie in ihren Chats Zugriff auf das Wissen aus ihren E-Mails."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2289234741"] = "Sie können Ihre E-Mail-Postfächer mit AI Studio verbinden. Die KI liest Ihre E-Mails und benachrichtigt Sie über wichtige Ereignisse. Außerdem haben Sie in Ihren Chats Zugriff auf das Wissen aus Ihren E-Mails."
-- Browser usage
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2345974992"] = "Browser-Nutzung"
@ -5179,13 +5209,13 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2868740431"] = "Spezifische Anfo
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T2899555955"] = "Wir werden weitere Assistenten für alltägliche Aufgaben entwickeln."
-- We're working on offering AI Studio features in your browser via a plugin, allowing, e.g., for spell-checking or text rewriting directly in the browser.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T308543246"] = "Wir arbeiten daran, die Funktionen von AI Studio über ein Plugin auch in ihrem Browser anzubieten. So können Sie zum Beispiel direkt im Browser Rechtschreibprüfungen durchführen oder Texte umformulieren lassen."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T308543246"] = "Wir arbeiten daran, die Funktionen von AI Studio über ein Plugin auch in Ihrem Browser anzubieten. So können Sie zum Beispiel direkt im Browser Rechtschreibprüfungen durchführen oder Texte umformulieren lassen."
-- There will be an interface for AI Studio to create content in other apps. You could, for example, create blog posts directly on the target platform or add entries to an internal knowledge management tool. This requires development work by the tool developers.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T3290746961"] = "Es wird eine Schnittstelle für AI Studio geben, um Inhalte in anderen Apps zu erstellen. So könnten Sie zum Beispiel Blogbeiträge direkt auf der Zielplattform verfassen oder Einträge zu einem internen Wissensmanagement-Tool hinzufügen. Dafür ist Entwicklungsarbeit durch die jeweiligen Tool-Entwickler erforderlich."
-- Want an assistant that suits your specific needs? We aim to offer a plugin architecture so organizations and enthusiasts can implement such ideas.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T3440464089"] = "Sie möchten einen Assistenten, der genau auf ihre Bedürfnisse zugeschnitten ist? Wir planen, eine Plugin-Architektur anzubieten, damit Organisationen und Interessierte solche Ideen umsetzen können."
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T3440464089"] = "Sie möchten einen Assistenten, der genau auf Ihre Bedürfnisse zugeschnitten ist? Wir planen, eine Plugin-Architektur anzubieten, damit Organisationen und Interessierte solche Ideen umsetzen können."
-- Writing mode
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T3640675146"] = "Schreibmodus"
@ -5269,7 +5299,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1886517101"] = "Keine Chats
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1939006681"] = "Chat erstellen"
-- Please name your workspace:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T201482774"] = "Bitte benennen Sie ihren Arbeitsbereich:"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T201482774"] = "Bitte benennen Sie Ihren Arbeitsbereich:"
-- Are you sure you want to load another chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2133593288"] = "Möchten Sie wirklich einen anderen Chat laden? Alle ungespeicherten Änderungen gehen dabei verloren."
@ -5299,7 +5329,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3043761007"] = "Sind Sie sic
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3045856778"] = "Chat in den Arbeitsbereich verschieben"
-- Please enter a new or edit the name for your workspace '{0}':
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Bitte geben Sie einen neuen Namen für ihren Arbeitsbereich „{0}“ ein oder bearbeiten Sie ihn:"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Bitte geben Sie einen neuen Namen für Ihren Arbeitsbereich „{0}“ ein oder bearbeiten Sie ihn:"
-- There is already a workspace with this name. Please choose a different name.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "Es gibt bereits einen Arbeitsbereich mit diesem Namen. Bitte wählen Sie einen anderen Namen."
@ -5311,7 +5341,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen"
-- Please enter a new or edit the name for your chat '{0}':
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3419791373"] = "Bitte geben Sie einen neuen Namen für ihren Chat „{0}“ ein oder bearbeiten Sie ihn:"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3419791373"] = "Bitte geben Sie einen neuen Namen für Ihren Chat „{0}“ ein oder bearbeiten Sie ihn:"
-- Search chat contents
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3436662033"] = "Chat-Inhalte durchsuchen"
@ -5389,7 +5419,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T1357418474"] =
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T1423034104"] = "Bei dieser Überprüfung wurden keine Sicherheitsprobleme gefunden."
-- No provider configured
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T1476185409"] = "Kein Provider konfiguriert"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T1476185409"] = "Kein Anbieter konfiguriert"
-- {0:0.##} KB
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T14914764"] = "{0:0.##} KB"
@ -5440,7 +5470,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T2562655035"] =
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T273798258"] = "Das Assistenten-Plugin konnte für die Überprüfung nicht aufgelöst werden."
-- Audit provider
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T2757790517"] = "Provider prüfen"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T2757790517"] = "Anbieter prüfen"
-- Size
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::ASSISTANTPLUGINAUDITDIALOG::T2789707388"] = "Größe"
@ -5632,7 +5662,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T204496403"] = "Der Name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2147062613"] = "Profilnutzung"
-- Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2292424657"] = "Fügen Sie Nachrichten einer Beispiel-Konversation hinzu (Nutzereingabe, gefolgt von einer Antwort des Assistenten), um das gewünschte Interaktionsmuster zu demonstrieren. Diese Beispiele helfen der KI, ihre Erwartungen zu verstehen, indem Sie das korrekte Format, den Stil und den Inhalt von Antworten zeigen, bevor tatsächliche Nutzereingaben erfolgen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2292424657"] = "Fügen Sie Nachrichten einer Beispiel-Konversation hinzu (Nutzereingabe, gefolgt von einer Antwort des Assistenten), um das gewünschte Interaktionsmuster zu demonstrieren. Diese Beispiele helfen der KI, Ihre Erwartungen zu verstehen, indem Sie das korrekte Format, den Stil und den Inhalt von Antworten zeigen, bevor tatsächliche Nutzereingaben erfolgen."
-- File Attachments
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T2294745309"] = "Dateianhänge"
@ -5719,10 +5749,10 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T3893704289"] = "Nachric
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4051106111"] = "Verwenden Sie den Standard-System-Prompt"
-- Tell the AI your predefined user input.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4052406705"] = "Teilen Sie der KI ihre vordefinierte Benutzereingabe mit."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4052406705"] = "Teilen Sie der KI Ihre vordefinierte Benutzereingabe mit."
-- Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4199560726"] = "Erstellen Sie ihre eigene Chat-Vorlage, um das Verhalten des LLMs für bestimmte Aufgaben oder Bereiche anzupassen. Definieren Sie einen individuellen System-Prompt und geben Sie eine Beispiel-Konversation vor, um eine KI-Erfahrung zu gestalten, die genau auf ihre Anforderungen zugeschnitten ist."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T4199560726"] = "Erstellen Sie Ihre eigene Chat-Vorlage, um das Verhalten des LLMs für bestimmte Aufgaben oder Bereiche anzupassen. Definieren Sie einen individuellen System-Prompt und geben Sie eine Beispiel-Konversation vor, um eine KI-Erfahrung zu gestalten, die genau auf Ihre Anforderungen zugeschnitten ist."
-- Enter a message
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CHATTEMPLATEDIALOG::T446374405"] = "Nachricht eingeben"
@ -5752,7 +5782,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2107991661
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2150386772"] = "{0} Pflichtangabe"
-- You can install the plugin again later, but any changes you made to its settings are lost.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2156367745"] = "Du kannst das Plugin später erneut installieren, aber alle Änderungen an seinen Einstellungen gehen verloren."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2156367745"] = "Sie können das Plugin später erneut installieren, aber alle Änderungen an seinen Einstellungen gehen verloren."
-- {0} profile
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::CONFIGURATIONPLUGINDELETEDIALOG::T2342765572"] = "{0} Profil"
@ -6082,7 +6112,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2406580478"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2646845972"] = "Hinzufügen"
-- The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2814869210"] = "Die von Ihnen ausgewählte Einbettung läuft lokal oder innerhalb ihrer Organisation. Ihre Daten werden nicht in die Cloud übertragen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2814869210"] = "Die von Ihnen ausgewählte Einbettung läuft lokal oder innerhalb Ihrer Organisation. Ihre Daten werden nicht in die Cloud übertragen."
-- Embedding
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2838542994"] = "Einbettung"
@ -6238,7 +6268,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Hinzufügen"
-- The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2814869210"] = "Die von Ihnen ausgewählte Einbettung läuft lokal oder innerhalb ihrer Organisation. Ihre Daten werden nicht in die Cloud gesendet."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2814869210"] = "Die von Ihnen ausgewählte Einbettung läuft lokal oder innerhalb Ihrer Organisation. Ihre Daten werden nicht in die Cloud gesendet."
-- Embedding
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2838542994"] = "Einbettung"
@ -6835,7 +6865,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PLUGINIMPORTDIALOG::T994314591"] = "Ziel"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1254789334"] = "Lade aus einer Datei, was die KI tun soll"
-- Tell the AI what you want it to do for you. What are your goals or are you trying to achieve? Like having the AI address you informally.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Teilen Sie der KI mit, was sie machen soll. Was sind ihre Ziele oder was möchten Sie erreichen? Zum Beispiel, dass die KI Sie duzt."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1458195391"] = "Teilen Sie der KI mit, was sie machen soll. Was sind Ihre Ziele oder was möchten Sie erreichen? Zum Beispiel, dass die KI Sie duzt."
-- Please be aware that your profile info becomes part of the system prompt. This means it uses up context space — the “memory” the LLM uses to understand and respond to your request. If your profile is extremely long, the LLM may struggle to focus on your actual task.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1717545317"] = "Bitte beachten Sie, dass Ihre Profilinformationen Teil des System-Prompts werden. Das bedeutet, sie belegen einen Teil des Kontexts – den „Speicher“, den das LLM nutzt, um Ihre Anfrage zu verstehen und darauf zu antworten. Wenn Ihr Profil extrem lang ist, kann das LLM Schwierigkeiten haben, die Aufgabe auszuführen."
@ -6844,7 +6874,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1717545317"] = "Bitte beacht
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T1847791252"] = "Aktualisieren"
-- Tell the AI something about yourself. What is your profession? How experienced are you in this profession? Which technologies do you like?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T2119274961"] = "Erzählen Sie der KI etwas über sich. Was ist ihr Beruf? Wie erfahren sind Sie in diesem Beruf? Welche Technologien verwenden Sie?"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T2119274961"] = "Erzählen Sie der KI etwas über sich. Was ist Ihr Beruf? Wie erfahren sind Sie in diesem Beruf? Welche Technologien verwenden Sie?"
-- What should the AI do for you?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T2261456575"] = "Was soll die KI für Sie tun?"
@ -6868,16 +6898,16 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T3448155331"] = "Schließen"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T3708405102"] = "Bitte geben Sie ein, was das LLM über Sie wissen sollte und/oder welche Aktionen es ausführen soll."
-- The name of the profile is mandatory. Each profile must have a unique name. Whether you provide information about yourself or only fill out the actions is up to you. Only one of these pieces is required.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T4061896123"] = "Der Name des Profils ist erforderlich. Jedes Profil muss einen eindeutigen Namen haben. Ob Sie zusätzliche Angaben zu ihrer Person machen oder nur die Aktionen ausfüllen, bleibt Ihnen überlassen. Es reicht aus, eines von beidem anzugeben."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T4061896123"] = "Der Name des Profils ist erforderlich. Jedes Profil muss einen eindeutigen Namen haben. Ob Sie zusätzliche Angaben zu Ihrer Person machen oder nur die Aktionen ausfüllen, bleibt Ihnen überlassen. Es reicht aus, eines von beidem anzugeben."
-- Store personal data about yourself in various profiles so that the AIs know your personal context. This saves you from having to explain your context each time, for example, in every chat. When you have different roles, you can create a profile for each role.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T4125557797"] = "Speichern Sie persönliche Daten über sich in verschiedenen Profilen, damit die KIs ihren persönlichen Kontext kennen. So müssen Sie ihren Kontext nicht jedes Mal, zum Beispiel in jedem Chat, neu erklären. Wenn Sie unterschiedlichen Rollen haben, können Sie für jede Rolle ein eigenes Profil anlegen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T4125557797"] = "Speichern Sie persönliche Daten über sich in verschiedenen Profilen, damit die KIs Ihren persönlichen Kontext kennen. So müssen Sie Ihren Kontext nicht jedes Mal, zum Beispiel in jedem Chat, neu erklären. Wenn Sie unterschiedliche Rollen haben, können Sie für jede Rolle ein eigenes Profil anlegen."
-- What should the AI know about you?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T4227846635"] = "Was sollte die KI über Sie wissen?"
-- Are you a project manager in a research facility? You might want to create a profile for your project management activities, one for your scientific work, and a profile for when you need to write program code. In these profiles, you can record how much experience you have or which methods you like or dislike using. Later, you can choose when and where you want to use each profile.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T56359901"] = "Sind Sie Projektleiter in einer Forschungseinrichtung? Dann möchten Sie vielleicht ein Profil für ihre Projektmanagement-Aufgaben erstellen, eines für ihre wissenschaftliche Arbeit und ein Profil für das Schreiben von Programmcode. In diesen Profilen können Sie festhalten, wie viel Erfahrung Sie haben oder welche Methoden Sie gerne oder weniger gerne nutzen. Später können Sie auswählen, wann und wo Sie jedes Profil verwenden möchten."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T56359901"] = "Sind Sie Projektleiter in einer Forschungseinrichtung? Dann möchten Sie vielleicht ein Profil für Ihre Projektmanagement-Aufgaben erstellen, eines für Ihre wissenschaftliche Arbeit und ein Profil für das Schreiben von Programmcode. In diesen Profilen können Sie festhalten, wie viel Erfahrung Sie haben oder welche Methoden Sie gerne oder weniger gerne nutzen. Später können Sie auswählen, wann und wo Sie jedes Profil verwenden möchten."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROFILEDIALOG::T900713019"] = "Abbrechen"
@ -6937,7 +6967,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROMPTINJECTIONALERTDIALOG::T871282530"] = "
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1085481431"] = "Hugging Face Inferenz-Anbieter"
-- This provider is managed by your organization. Host, model, and other settings are locked. You can set your own API key below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1090492389"] = "Dieser Provider wird von Ihrer Organisation verwaltet. Host, Modell und andere Einstellungen sind gesperrt. Sie können Ihren eigenen API-Schlüssel unten festlegen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1090492389"] = "Dieser Anbieter wird von Ihrer Organisation verwaltet. Host, Modell und andere Einstellungen sind gesperrt. Sie können Ihren eigenen API-Schlüssel unten festlegen."
-- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T1108876344"] = "Experten-Einstellungen ausblenden"
@ -7177,7 +7207,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T1082847843"] = "Par
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T1093935834"] = "Parameterbeschreibung"
-- The retrieval process name must not be empty. Please name your retrieval process.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T1133451355"] = "Der Name des Abrufprozesses darf nicht leer sein. Bitte benennen Sie ihren Abrufvorgang."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T1133451355"] = "Der Name des Abrufprozesses darf nicht leer sein. Bitte benennen Sie Ihren Abrufvorgang."
-- The parameter name must not be empty. Please name the parameter.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T1359500913"] = "Der Parametername darf nicht leer sein. Bitte geben Sie einen Namen für den Parameter ein."
@ -7216,13 +7246,13 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T2646845972"] = "Hin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T2933579640"] = "Sie haben {0} Methoden zur Einbettung ausgewählt."
-- Please provide some general information about your retrieval process first. This data may be displayed to the users.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3015844908"] = "Bitte geben Sie zunächst einige allgemeine Informationen über ihren Abrufprozess an. Diese Angaben können den Nutzern angezeigt werden."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3015844908"] = "Bitte geben Sie zunächst einige allgemeine Informationen über Ihren Abrufprozess an. Diese Angaben können den Nutzern angezeigt werden."
-- The name of your retrieval process.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3207262684"] = "Der Name ihres Abrufprozesses."
-- You may want to parameterize your retrieval process. However, this is optional. You can specify any parameters that can be set by the user or the system during the call. Nevertheless, you should use sensible default values in your code so that users are not forced to set the parameters manually.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3292152705"] = "Möglicherweise möchten Sie ihren Abrufprozess parameterisieren. Dies ist jedoch optional. Sie können beliebige Parameter angeben, die vom Benutzer oder vom System während des Aufrufs festgelegt werden können. Dennoch sollten Sie sinnvolle Standardwerte in ihrem Code verwenden, damit Benutzer die Parameter nicht manuell einstellen müssen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3292152705"] = "Möglicherweise möchten Sie Ihren Abrufprozess parameterisieren. Dies ist jedoch optional. Sie können beliebige Parameter angeben, die vom Benutzer oder vom System während des Aufrufs festgelegt werden können. Dennoch sollten Sie sinnvolle Standardwerte in Ihrem Code verwenden, damit Benutzer die Parameter nicht manuell einstellen müssen."
-- Select a parameter to show and edit it.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3300669027"] = "Wählen Sie einen Parameter aus, um ihn anzuzeigen und zu bearbeiten."
@ -7243,7 +7273,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3481092305"] = "Nam
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3524519535"] = "Eine kurze Beschreibung des Abrufprozesses."
-- Currently, you have not defined any embedding methods. If your retrieval process does not require embedding, you can ignore this part. Otherwise, you can define one or more embedding methods in the previous view to assign them to your retrieval process here.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3821108204"] = "Derzeit haben Sie keine Methoden zur Einbettung definiert. Falls Ihr Abrufprozess keine Einbettungen benötigt, können Sie diesen Abschnitt ignorieren. Andernfalls können Sie im vorherigen Bereich eine oder mehrere Methoden zur Einbettung festlegen, die Sie hier ihrem Abrufprozess zuweisen können."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3821108204"] = "Derzeit haben Sie keine Methoden zur Einbettung definiert. Falls Ihr Abrufprozess keine Einbettungen benötigt, können Sie diesen Abschnitt ignorieren. Andernfalls können Sie im vorherigen Bereich eine oder mehrere Methoden zur Einbettung festlegen, die Sie hier Ihrem Abrufprozess zuweisen können."
-- Retrieval Process Parameters
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T3894388618"] = "Parameter für den Abrufprozess"
@ -7291,7 +7321,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "An
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "Derzeit sind keine Dateianhänge vorhanden."
-- You can drag more files into this window to attach them right away.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "Du kannst weitere Dateien in dieses Fenster ziehen, um sie sofort anzuhängen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "Sie können weitere Dateien in dieses Fenster ziehen, um sie sofort anzuhängen."
-- Document Preview
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Dokumentvorschau"
@ -7456,7 +7486,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T2322
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T2345162613"] = "Welche Sprache soll vorausgewählt werden?"
-- Reset your bias-of-the-day statistics
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T2350981714"] = "Setzen Sie ihre Statistik zum „Vorurteil des Tages“ zurück"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T2350981714"] = "Setzen Sie Ihre Statistik zum „Vorurteil des Tages“ zurück"
-- Preselect another language
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T2382415529"] = "Eine andere Sprache vorauswählen"
@ -7477,7 +7507,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T3848
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T3875604319"] = "Optionen sind vorausgewählt"
-- Are you sure you want to reset your bias-of-the-day statistics? The system will no longer remember which biases you already know. As a result, biases you are already familiar with may be addressed again.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T405627382"] = "Sind Sie sicher, dass Sie ihre „Vorurteil des Tages“-Statistiken zurücksetzen möchten? Das System merkt sich dann nicht mehr, welche Verzerrungen Sie bereits kennen. Dadurch kann es sein, dass Ihnen bereits bekannte Verzerrungen erneut angezeigt werden."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T405627382"] = "Sind Sie sicher, dass Sie Ihre „Vorurteil des Tages“-Statistiken zurücksetzen möchten? Das System merkt sich dann nicht mehr, welche Verzerrungen Sie bereits kennen. Dadurch kann es sein, dass Ihnen bereits bekannte Verzerrungen erneut angezeigt werden."
-- Assistant: Bias of the Day Options
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T4235808594"] = "Assistent: Optionen für „Bias des Tages“"
@ -7678,10 +7708,10 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T2868379953"]
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T2913693228"] = "Die neueste Nachricht nach dem Laden anzeigen?"
-- Do you want to use any shortcut to send your input?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T2936560092"] = "Möchten Sie eine Tastenkombination verwenden, um ihre Eingabe zu senden?"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T2936560092"] = "Möchten Sie eine Tastenkombination verwenden, um Ihre Eingabe zu senden?"
-- Would you like to set one of your chat templates as the default for chats?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T3234927721"] = "Möchten Sie eine ihrer Chat-Vorlagen als Standard für alle Chats festlegen?"
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T3234927721"] = "Möchten Sie eine Ihrer Chat-Vorlagen als Standard für alle Chats festlegen?"
-- No chat options are preselected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T3383186996"] = "Keine Chat-Optionen sind vorausgewählt"
@ -7714,7 +7744,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T492357592"] =
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHAT::T582516016"] = "Wenn diese Option aktiviert ist, wird nach dem Laden eines Chats die neueste Nachricht angezeigt. Wenn sie deaktiviert ist, wird die erste (älteste) Nachricht angezeigt."
-- Customize your AI experience with chat templates. Whether you want to experiment with prompt engineering, simply use a custom system prompt in the standard chat interface, or create a specialized assistant, chat templates give you full control. Similar to common AI companies' playgrounds, you can define your own system prompts and leverage assistant prompts for providers that support them.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1172171653"] = "Passen Sie ihre KI-Erfahrung mit Chat-Vorlagen an. Egal, ob Sie mit Prompt-Engineering experimentieren, einfach einen eigenen System-Prompt im normalen Chat verwenden oder einen spezialisierten Assistenten erstellen möchten – mit Chat-Vorlagen haben Sie die volle Kontrolle. Ähnlich wie in den Playgrounds gängiger KI-Anbieter können Sie eigene System-Prompts festlegen und bei unterstützenden Anbietern auch Assistenten-Prompts nutzen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1172171653"] = "Passen Sie Ihre KI-Erfahrung mit Chat-Vorlagen an. Egal, ob Sie mit Prompt-Engineering experimentieren, einfach einen eigenen System-Prompt im normalen Chat verwenden oder einen spezialisierten Assistenten erstellen möchten – mit Chat-Vorlagen haben Sie die volle Kontrolle. Ähnlich wie in den Playgrounds gängiger KI-Anbieter können Sie eigene System-Prompts festlegen und bei unterstützenden Anbietern auch Assistenten-Prompts nutzen."
-- Copy attachments into plugin
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGCHATTEMPLATE::T1345613295"] = "Anhänge in das Plugin kopieren"
@ -8089,7 +8119,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T386503194
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T4058414654"] = "Dieses Profil wird von Ihrer Organisation verwaltet."
-- Store personal data about yourself in various profiles so that the AIs know your personal context. This saves you from having to explain your context each time, for example, in every chat. When you have different roles, you can create a profile for each role.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T4125557797"] = "Speichern Sie persönliche Daten über sich in verschiedenen Profilen, damit die KIs ihren persönlichen Kontext kennen. So müssen Sie den Kontext nicht jedes Mal erneut erklären, zum Beispiel in jedem Chat. Wenn Sie verschiedene Rollen haben, können Sie für jede Rolle ein eigenes Profil anlegen."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T4125557797"] = "Speichern Sie persönliche Daten über sich in verschiedenen Profilen, damit die KIs Ihren persönlichen Kontext kennen. So müssen Sie den Kontext nicht jedes Mal erneut erklären, zum Beispiel in jedem Chat. Wenn Sie verschiedene Rollen haben, können Sie für jede Rolle ein eigenes Profil anlegen."
-- View Profile
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T4219233997"] = "Profil anzeigen"
@ -8101,7 +8131,7 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T424806724
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T55364659"] = "Möchten Sie das Profil „{0}“ wirklich löschen?"
-- Are you a project manager in a research facility? You might want to create a profile for your project management activities, one for your scientific work, and a profile for when you need to write program code. In these profiles, you can record how much experience you have or which methods you like or dislike using. Later, you can choose when and where you want to use each profile.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T56359901"] = "Sind Sie Projektleiter in einer Forschungseinrichtung? Dann möchten Sie vielleicht ein Profil für ihre Projektmanagement-Aktivitäten anlegen, eines für ihre wissenschaftliche Arbeit und ein weiteres Profil, wenn Sie Programmcode schreiben müssen. In diesen Profilen können Sie festhalten, wie viel Erfahrung Sie haben oder welche Methoden Sie bevorzugen oder nicht gerne verwenden. Später können Sie dann auswählen, wann und wo Sie jedes Profil nutzen möchten."
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROFILES::T56359901"] = "Sind Sie Projektleiter in einer Forschungseinrichtung? Dann möchten Sie vielleicht ein Profil für Ihre Projektmanagement-Aktivitäten anlegen, eines für Ihre wissenschaftliche Arbeit und ein weiteres Profil, wenn Sie Programmcode schreiben müssen. In diesen Profilen können Sie festhalten, wie viel Erfahrung Sie haben oder welche Methoden Sie bevorzugen oder nicht gerne verwenden. Später können Sie dann auswählen, wann und wo Sie jedes Profil nutzen möchten."
-- Preselect the target language
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGPROMPTOPTIMIZER::T1417990312"] = "Zielsprache vorwählen"
@ -9052,7 +9082,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3046519404"] = "Selbstlöschender Chat"
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3059773282"] = "Arbeitsbereiche durchsuchen"
-- Configure your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3586092784"] = "Konfigurieren Sie ihre Arbeitsbereiche"
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3586092784"] = "Konfigurieren Sie Ihre Arbeitsbereiche"
-- Your workspaces
UI_TEXT_CONTENT["AISTUDIO::PAGES::CHAT::T3745240468"] = "Ihre Arbeitsbereiche"
@ -9078,12 +9108,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1143368054"] = "Andere Ursache"
-- Current file: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Aktuelle Datei: {0}"
-- File {0} of {1} is being indexed: block {2}, page {3}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "Datei {0} von {1} wird indexiert: Block {2}, Seite {3}."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1455637941"] = "Der Speicherort der Datei konnte nicht geöffnet werden: {0}"
-- Open the settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1582896271"] = "Einstellungen öffnen"
-- File {0} of {1} is being indexed.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1616414701"] = "Datei {0} von {1} wird indexiert."
-- Tried again during the next run
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Beim nächsten Durchlauf erneut versucht"
@ -9126,6 +9162,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T309404893"] = "Fehlerhafte Dateie
-- Show this file in the file browser of your system
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3273105305"] = "Diese Datei im Dateibrowser Ihres Systems anzeigen"
-- Data source {0} of {1} is being worked on. The others are waiting their turn.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3389674086"] = "Datenquelle {0} von {1} wird gerade bearbeitet. Die anderen warten, bis sie an der Reihe sind."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3461425987"] = "Unbekannter Fehler"
@ -9141,6 +9180,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3865031940"] = "Aktionen"
-- Skipped until the file changes
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T542386347"] = "Übersprungen, bis sich die Datei ändert"
-- File {0} of {1} is being indexed: block {2}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T615458954"] = "Datei {0} von {1} wird indexiert: Block {2}."
-- File
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T723007075"] = "Datei"
@ -9151,7 +9193,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1009708591"] = "Im Gegensatz zu Dienste
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1024253064"] = "Willkommen bei MindWork AI Studio!"
-- Thank you for considering MindWork AI Studio for your AI needs. This app is designed to help you harness the power of Large Language Models (LLMs). Please note that this app doesn't come with an integrated LLM. Instead, you will need to bring an API key from a suitable provider.
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1146553980"] = "Vielen Dank, dass Sie MindWork AI Studio für ihre KI-Anwendungen in Betracht ziehen. Diese App wurde entwickelt, um Ihnen die Nutzung von leistungsstarken Sprachmodellen (LLMs) zu ermöglichen. Bitte beachten Sie, dass die App kein integriertes LLM enthält. Stattdessen benötigen Sie einen API-Schlüssel von einem passenden Anbieter."
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1146553980"] = "Vielen Dank, dass Sie MindWork AI Studio für Ihre KI-Anwendungen in Betracht ziehen. Diese App wurde entwickelt, um Ihnen die Nutzung von leistungsstarken Sprachmodellen (LLMs) zu ermöglichen. Bitte beachten Sie, dass die App kein integriertes LLM enthält. Stattdessen benötigen Sie einen API-Schlüssel von einem passenden Anbieter."
-- You are not tied to any single provider. Instead, you might choose the provider that best suits your needs. Right now, we support OpenAI (GPT5, o1, etc.), Perplexity, Mistral, Anthropic (Claude), Google Gemini, xAI (Grok), DeepSeek, Alibaba Cloud (Qwen), OpenRouter, Hetzner (experimental), IONOS, LiteLLM, Hugging Face, Groq, Fireworks, and self-hosted models using vLLM, llama.cpp, ollama, or LM Studio. For scientists and employees of research institutions, we also support Helmholtz and GWDG AI services. These are available through federated logins like eduGAIN to all 18 Helmholtz Centers, the Max Planck Society, most German, and many international universities.
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1301599515"] = "Sie sind nicht an einen einzigen Anbieter gebunden. Stattdessen können Sie den Anbieter wählen, der am besten zu Ihren Anforderungen passt. Derzeit unterstützen wir OpenAI (GPT5, o1 usw.), Perplexity, Mistral, Anthropic (Claude), Google Gemini, xAI (Grok), DeepSeek, Alibaba Cloud (Qwen), OpenRouter, Hetzner (experimentell), IONOS, LiteLLM, Hugging Face, Groq, Fireworks sowie selbst gehostete Modelle mit vLLM, llama.cpp, ollama oder LM Studio. Für Wissenschaftlerinnen und Wissenschaftler sowie Mitarbeitende von Forschungseinrichtungen unterstützen wir außerdem die KI-Dienste von Helmholtz und GWDG. Diese sind über föderierte Logins wie eduGAIN für alle 18 Helmholtz-Zentren, die Max-Planck-Gesellschaft, die meisten deutschen sowie viele internationale Universitäten verfügbar."
@ -9160,7 +9202,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1301599515"] = "Sie sind nicht an einen
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T144565305"] = "Die App benötigt nur wenig Speicherplatz für die Installation und verwendet wenig Arbeitsspeicher. Außerdem hat sie einen minimalen Einfluss auf die Systemressourcen, was sich positiv auf die Akkulaufzeit auswirkt."
-- You only pay for what you use, which can be cheaper than monthly subscription services like ChatGPT Plus, especially if used infrequently. But beware, here be dragons: For extremely intensive usage, the API costs can be significantly higher. Unfortunately, providers currently do not offer a way to display current costs in the app. Therefore, check your account with the respective provider to see how your costs are developing. When available, use prepaid and set a cost limit.
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "Sie zahlen nur für das, was Sie tatsächlich nutzen – das kann günstiger sein als monatliche Abos wie ChatGPT Plus, vor allem bei gelegentlicher Nutzung. Aber Vorsicht: Bei sehr intensiver Nutzung können die API-Kosten deutlich höher ausfallen. Leider bieten die Anbieter derzeit keine Möglichkeit, die aktuellen Kosten direkt in der App anzuzeigen. Prüfen Sie deshalb regelmäßig Ihr Konto beim jeweiligen Anbieter, um ihre Ausgaben im Blick zu behalten. Nutzen Sie, wenn möglich, Prepaid-Optionen und legen Sie ein Ausgabenlimit fest."
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T149711988"] = "Sie zahlen nur für das, was Sie tatsächlich nutzen – das kann günstiger sein als monatliche Abos wie ChatGPT Plus, vor allem bei gelegentlicher Nutzung. Aber Vorsicht: Bei sehr intensiver Nutzung können die API-Kosten deutlich höher ausfallen. Leider bieten die Anbieter derzeit keine Möglichkeit, die aktuellen Kosten direkt in der App anzuzeigen. Prüfen Sie deshalb regelmäßig Ihr Konto beim jeweiligen Anbieter, um Ihre Ausgaben im Blick zu behalten. Nutzen Sie, wenn möglich, Prepaid-Optionen und legen Sie ein Ausgabenlimit fest."
-- Version
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T1573770551"] = "Version"
@ -9190,7 +9232,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2331588413"] = "Los geht's"
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2348849647"] = "Letztes Änderungsprotokoll"
-- Choose the provider and model best suited for your current task.
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2588488920"] = "Wählen Sie den Anbieter und das Modell aus, die am besten zu ihrer aktuellen Aufgabe passen."
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T2588488920"] = "Wählen Sie den Anbieter und das Modell aus, die am besten zu Ihrer aktuellen Aufgabe passen."
-- Quick Start Guide
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3002014720"] = "Schnellstart-Anleitung"
@ -9199,7 +9241,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3002014720"] = "Schnellstart-Anleitung"
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3228075421"] = "Sie möchten einfach schnell einen Text übersetzen? Für solche und andere Aufgaben gibt es in AI Studio sogenannte Assistenten. Beim Arbeiten mit diesen Assistenten sind keine Eingabeaufforderungen erforderlich."
-- We hope you enjoy using MindWork AI Studio to bring your AI projects to life!
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3275341342"] = "Wir hoffen, dass Sie viel Freude daran haben, mit MindWork AI Studio ihre KI-Projekte zum Leben zu erwecken!"
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3275341342"] = "Wir hoffen, dass Sie viel Freude daran haben, mit MindWork AI Studio Ihre KI-Projekte zum Leben zu erwecken!"
-- Cost-effective
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3341379752"] = "Kosteneffizient"
@ -9211,7 +9253,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3723223888"] = "Flexibilität"
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T3959064551"] = "Datenschutz"
-- You can control which providers receive your data using the provider confidence settings. For example, you can set different protection levels for writing emails compared to general chats, etc. Additionally, most providers guarantee that they won't use your data to train new AI systems.
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T457410099"] = "Sie können über die Einstellungen zur Anbietervertrauenswürdigkeit steuern, welche Anbieter ihre Daten erhalten. Zum Beispiel können Sie für das Schreiben von E-Mails einen anderen Schutzlevel festlegen als für allgemeine Chats usw. Außerdem garantieren die meisten Anbieter, dass ihre Daten nicht zum Trainieren neuer KI-Systeme verwendet werden."
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T457410099"] = "Sie können über die Einstellungen zur Anbietervertrauenswürdigkeit steuern, welche Anbieter Ihre Daten erhalten. Zum Beispiel können Sie für das Schreiben von E-Mails einen anderen Schutzlevel festlegen als für allgemeine Chats usw. Außerdem garantieren die meisten Anbieter, dass Ihre Daten nicht zum Trainieren neuer KI-Systeme verwendet werden."
-- Free of charge
UI_TEXT_CONTENT["AISTUDIO::PAGES::HOME::T617579208"] = "Kostenlos"
@ -9235,10 +9277,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1019424746"] = "Startprotokollda
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T103551060"] = "Die konfigurierten Root-Zertifikate konnten nicht verwendet werden."
-- Browse AI Studio's source code on GitHub — we welcome your contributions.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Sehen Sie sich den Quellcode von AI Studio auf GitHub an – wir freuen uns über ihre Beiträge."
-- Vector store version
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1124039623"] = "Vektordatenbankversion"
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Sehen Sie sich den Quellcode von AI Studio auf GitHub an – wir freuen uns über Ihre Beiträge."
-- 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 ist eine eingebettete Vektordatenbank und ein Vektoraehnlichkeitssuchmaschine. Wir nutzen sie, um lokal RAG – retrieval-augmented generation – innerhalb von AI Studio zu realisieren. Vielen Dank für die Anstrengungen und die großartige Arbeit, die in Qdrant investiert wurde und weiterhin investiert wird."
@ -9342,6 +9381,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "Diese Bibliothek
-- Encryption secret: is configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Geheimnis für die Verschlüsselung: ist konfiguriert"
-- Index database
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1949702956"] = "Indexdatenbank"
-- The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1985806792"] = "Das Projekt objc2 ermöglicht den Zugriff auf die Objective-C-Frameworks von Apple aus Rust. Unter macOS verwenden wir die Bibliotheken objc2, objc2-app-kit und objc2-foundation, um den nativen macOS-Teilen-Dialog zu öffnen, beispielsweise wenn Sie ein Plugin mit anderen teilen."
@ -9519,14 +9561,11 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3019585985"] = "Testkonfiguratio
-- External HTTPS custom root certificates are configured but not active.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3021325354"] = "Externe benutzerdefinierte Stammzertifikate sind konfiguriert, aber nicht aktiv."
-- Vector store
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3046399223"] = "Vektordatenbank"
-- Enterprise configuration ID:
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3092349641"] = "Unternehmenskonfigurations-ID:"
-- Connect AI Studio to your organization's data with our External Retrieval Interface (ERI).
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T313276297"] = "Verbinden Sie AI Studio mit den Daten ihrer Organisation über unsere Schnittstelle für externe Datenabfrage (ERI)."
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T313276297"] = "Verbinden Sie AI Studio mit den Daten Ihrer Organisation über unsere Schnittstelle für externe Datenabfrage (ERI)."
-- Have feature ideas? Submit suggestions for future AI Studio enhancements.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3178730036"] = "Haben Sie Ideen für neue Funktionen? Senden Sie uns Vorschläge für zukünftige Verbesserungen von AI Studio."
@ -9583,7 +9622,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3444344506"] = "Das Crate „aho
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3449345633"] = "AI Studio wird mit Unternehmenskonfigurationen unter Verwendung von Konfigurations-Plugins betrieben. Eine zentrale Konfigurationsverwaltung wird nicht eingesetzt."
-- You are running a development build of AI Studio, which never updates itself. Pull the latest changes and rebuild the app instead.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3454691558"] = "Du verwendest eine Entwicklerversion von AI Studio, die sich niemals selbst aktualisiert. Hole stattdessen die neuesten Änderungen und erstelle die App neu."
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3454691558"] = "Sie verwenden eine Entwicklerversion von AI Studio, die sich niemals selbst aktualisiert. Holen Sie stattdessen die neuesten Änderungen und erstellen Sie die App neu."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3461425987"] = "Unbekannter Fehler"
@ -9648,6 +9687,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Kopiert die zul
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia wird zum Demultiplexen von Mediencontainern und zur Audiodekodierung verwendet. Der genaue, unter der MPL lizenzierte Quellcode ist im verlinkten Repository verfügbar und in den mit AI Studio gebündelten Offline-Hinweisen angegeben."
-- Vector database
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3977811115"] = "Vektordatenbank"
-- Installed Pandoc version
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installierte Pandoc-Version"
@ -9970,7 +10012,7 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T838479287"] = "Spenden von Untern
UI_TEXT_CONTENT["AISTUDIO::PAGES::SUPPORTERS::T991294232"] = "Vielen herzlichen Dank, Kerstin, dass du dich um die Erstellung des Wikis gekümmert hast."
-- Write your text
UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T2220943334"] = "Schreiben Sie ihren Text"
UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T2220943334"] = "Schreiben Sie Ihren Text"
-- Writer
UI_TEXT_CONTENT["AISTUDIO::PAGES::WRITER::T2979224202"] = "Autor"
@ -10054,7 +10096,7 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T4049517041"] = "Wir haben ve
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T411393889"] = "Der Anbieter „{0}“ kennt das ausgewählte Modell nicht. Bitte wählen Sie ein anderes Modell aus."
-- The text was longer than the selected model accepts, which is {0} tokens. Please select a model which takes longer texts, or reduce the chunk size of the data source.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T479223640"] = "Der Text war länger, als das ausgewählte Modell verarbeiten kann (maximal {0} Token). Bitte wählen Sie ein Modell für längere Texte oder verringern Sie die Chunk-Größe der Datenquelle."
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T479223640"] = "Der Text war länger, als das ausgewählte Modell verarbeiten kann (maximal {0} Token). Bitte wählen Sie ein Modell für längere Texte oder verringern Sie die Blockgröße der Datenquelle."
-- The provider '{0}' reported an error: {1}
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T700894460"] = "Der Anbieter „{0}“ hat einen Fehler gemeldet: {1}"
@ -10066,7 +10108,7 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::BASEPROVIDER::T991839585"] = "Der API-Schl
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T1014558951"] = "Das Vertrauensniveau dieses Anbieters wurde **noch nicht** gründlich **untersucht und bewertet**. Wir wissen nicht, ob ihre Daten sicher sind."
-- You or your organization operate the LLM locally or within your trusted network. In terms of data processing and security, this is the best possible way.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T2124364471"] = "Sie oder ihre Organisation betreiben das LLM lokal oder innerhalb ihres vertrauenswürdigen Netzwerks. In Bezug auf Datenverarbeitung und Sicherheit ist dies die bestmögliche Lösung."
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T2124364471"] = "Sie oder Ihre Organisation betreiben das LLM lokal oder innerhalb Ihres vertrauenswürdigen Netzwerks. In Bezug auf Datenverarbeitung und Sicherheit ist dies die bestmögliche Lösung."
-- The provider operates its service in the EU and is subject to the **GDPR** (General Data Protection Regulation). It provides access to **open source models**. However, the service is currently **experimental**, and performance and availability are not guaranteed. We have no provider-specific information about whether submitted data is used for training.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T2930312134"] = "Der Anbieter betreibt seinen Dienst in der EU und unterliegt der **DSGVO** (Datenschutz-Grundverordnung). Er bietet Zugang zu **Open-Source-Modellen**. Der Dienst befindet sich jedoch derzeit in einer **experimentellen** Phase; Leistung und Verfügbarkeit werden nicht garantiert. Uns liegen keine anbieterspezifischen Informationen dazu vor, ob übermittelte Daten für das Training verwendet werden."
@ -10084,7 +10126,7 @@ UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T3370749159"] = "Sie oder Ihre
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T3528165925"] = "Der Anbieter betreibt seinen Dienst aus den USA und unterliegt der **US-amerikanischen Gerichtsbarkeit**. Bei Verdacht können US-Behörden auf ihre Daten zugreifen. **Ihre Daten werden jedoch nicht für Trainingszwecke** verwendet."
-- The provider operates its service from the USA and is subject to **U.S. jurisdiction**. In case of suspicion, authorities in the USA can access your data. Please inform yourself about the use of your data. We do not know if your data is safe.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T3788466789"] = "Der Anbieter betreibt seinen Service in den USA und unterliegt der **US-amerikanischen Gerichtsbarkeit**. Im Verdachtsfall können US-Behörden auf ihre Daten zugreifen. Bitte informieren Sie sich über die Verwendung ihrer Daten. Wir wissen nicht, ob ihre Daten sicher sind."
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T3788466789"] = "Der Anbieter betreibt seinen Service in den USA und unterliegt der **US-amerikanischen Gerichtsbarkeit**. Im Verdachtsfall können US-Behörden auf Ihre Daten zugreifen. Bitte informieren Sie sich über die Verwendung Ihrer Daten. Wir wissen nicht, ob Ihre Daten sicher sind."
-- The provider operates its service from China. In case of suspicion, authorities in the respective countries of operation may access your data. However, **your data is not used for training** purposes.
UI_TEXT_CONTENT["AISTUDIO::PROVIDER::CONFIDENCE::T991875725"] = "Der Anbieter betreibt seinen Dienst von China aus. Im Verdachtsfall können Behörden in den jeweiligen Ländern auf ihre Daten zugreifen. **Ihre Daten werden jedoch nicht zum Trainieren** verwendet."
@ -10426,7 +10468,7 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T1848
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2056842933"] = "Plugins: Vorschau auf unser Pluginsystems, mit dem Sie die Funktionalität der App erweitern können"
-- RAG: Preview of our RAG implementation where you can refer your files or integrate enterprise data within your company
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708939138"] = "RAG: Vorschau auf unsere RAG-Implementierung, mit der Sie auf ihre Dateien zugreifen oder Unternehmensdaten in ihrem Unternehmen integrieren können"
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2708939138"] = "RAG: Vorschau auf unsere RAG-Implementierung, mit der Sie auf Ihre Dateien zugreifen oder Unternehmensdaten in Ihrem Unternehmen integrieren können"
-- Unknown preview feature
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::PREVIEWFEATURESEXTENSIONS::T2722827307"] = "Unbekannte Vorschau-Funktion"
@ -10665,23 +10707,50 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T10
-- Starting
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T1233211769"] = "Starten"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T2608177081"] = "Unbekannt"
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T3662391977"] = "Nicht verfügbar"
-- Process architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T563161633"] = "Prozessarchitektur"
-- Status
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T6222351"] = "Status"
-- Database path
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1100578143"] = "Datenbankpfad"
-- Native library
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T634426545"] = "Native Bibliothek"
-- no migration applied
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1014567907"] = "Keine Migration angewendet"
-- Storage size
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1230141403"] = "Speichergröße"
-- Full-text search (FTS5)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1396060361"] = "Volltextsuche (FTS5)"
-- Wrapper version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1469427584"] = "Wrapper-Version"
-- available
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1727918744"] = "Verfügbar"
-- Indexed files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2235289713"] = "Indexierte Dateien"
-- Search chunks
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2333737457"] = "Suche in Blöcken"
-- {0} ({1} applied)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2286846332"] = "{0} ({1} angewendet)"
-- Journal mode
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2411639995"] = "Journalmodus"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2608177081"] = "Unbekannt"
-- Database tables
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3279078157"] = "Datenbanktabellen"
-- Indexed data sources
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3524534748"] = "Indexierte Datenquellen"
@ -10689,9 +10758,27 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTI
-- Reported version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3556099842"] = "Gemeldete Version"
-- not available
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3574465749"] = "Nicht verfügbar"
-- Permanently skipped files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3965853089"] = "Dauerhaft übersprungene Dateien"
-- Schema version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T424290830"] = "Schemaversion"
-- Process architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T563161633"] = "Prozessarchitektur"
-- Native library
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T634426545"] = "Native Bibliothek"
-- System architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T818259255"] = "Systemarchitektur"
-- {0} ({1} applied, {2} pending)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T983802795"] = "{0} ({1} angewendet, {2} ausstehend)"
-- Reason
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Grund"
@ -10716,6 +10803,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T
-- Storage size
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T1230141403"] = "Speichergröße"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2608177081"] = "Unbekannt"
-- Number of vector stores
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2785004838"] = "Anzahl der Vektordatenbanken"
@ -10725,6 +10815,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEM
-- Status
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T6222351"] = "Status"
-- Stored vectors
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T701689894"] = "Gespeicherte Vektoren"
-- Qdrant Edge is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge ist nicht verfügbar."
@ -11029,7 +11122,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T2897122009"]
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3262447403"] = "Die Datei „{0}“ ist eine {1}, die AI Studio nicht lesen kann. Daher wurde sie nicht gesendet."
-- The file '{0}' is actually a {1} and was read as such. Please correct its file extension.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "Die Datei „{0}“ ist tatsächlich eine {1} und wurde als solche gelesen. Bitte korrigieren Sie ihre Dateiendung."
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3297602719"] = "Die Datei „{0}“ ist tatsächlich eine {1} und wurde als solche gelesen. Bitte korrigieren Sie deren Dateiendung."
-- The file '{0}' is not a text file and was not sent. Its content could not be read as text, so it might have a wrong file extension.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXTRACTIONRESULTEXTENSIONS::T3303873344"] = "Die Datei „{0}“ ist keine Textdatei und wurde nicht gesendet. Ihr Inhalt konnte nicht als Text gelesen werden; möglicherweise hat sie die falsche Dateiendung."
@ -11970,6 +12063,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T29
-- The data source '{0}' was left out of the answer: its embedding provider '{1}' did not return a vector for your message.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T3469074321"] = "Die Datenquelle „{0}“ wurde in der Antwort nicht berücksichtigt: Der Einbettungsanbieter „{1}“ hat für Ihre Nachricht keinen Vektor zurückgegeben."
-- The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T4022014739"] = "Die Datenquelle „{0}“ wurde in der Antwort nicht berücksichtigt, da sie erneut indexiert wird und erst nach Abschluss dieses Vorgangs durchsucht werden kann."
-- Page {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T4127287940"] = "Seite {0}"
@ -12213,6 +12309,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "Es ist
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Fehler beim Speichern des API-Schlüssels aufgrund eines API-Problems."
-- The runtime document endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "Der Endpunkt des Laufzeitdokuments gab „{0}“ zurück."
-- The global shortcut could not be registered because of a desktop integration error.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "Die globale Tastenkombination konnte aufgrund eines Fehlers bei der Desktop-Integration nicht registriert werden."
@ -12240,6 +12339,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Der Te
-- The desktop service returned an invalid response while registering the global shortcut.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "Der Desktop-Dienst hat beim Registrieren des globalen Tastaturkürzels eine ungültige Antwort zurückgegeben."
-- The runtime document endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "Der Endpunkt für das Laufzeitdokument ist ohne weitere Details fehlgeschlagen."
-- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio konnte nicht auf den sicheren Speicher zugreifen, da keine Standardsammlung konfiguriert ist. Öffnen Sie einen kompatiblen Passwortmanager, erstellen Sie eine Sammlung oder wählen Sie eine aus, entsperren sie und legen Sie diese als Standard fest."
@ -12258,6 +12360,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "Es wur
-- Failed to get the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Abrufen der geheimen Daten aufgrund eines API-Problems fehlgeschlagen."
-- The runtime document endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "Der Laufzeit-Dokumentendpunkt ist nicht verfügbar."
-- AI Studio could not access secure storage. See the log for technical details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio konnte nicht auf den sicheren Speicher zugreifen. Technische Details finden Sie im Protokoll."
@ -12355,7 +12460,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS:
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::WEBSEARCH::SEARXNG::SEARXNGSEARCHBACKEND::T318306081"] = "Dokumentation"
-- Base URL of the SearXNG instance. You can enter either the instance root URL or the /search endpoint. The instance must have the JSON format enabled, which means 'json' has to be listed under 'search.formats' in its settings.yml. Public instances usually serve only the web interface and additionally block automated requests, so a self-hosted instance is the reliable option.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::WEBSEARCH::SEARXNG::SEARXNGSEARCHBACKEND::T4198847064"] = "Basis-URL der SearXNG-Instanz. Sie können entweder die Stamm-URL der Instanz oder den Endpunkt „/search“ eingeben. In der Instanz muss das JSON-Format aktiviert sein, d. h. „json“ muss in ihrer Datei „settings.yml“ unter „search.formats“ aufgeführt sein. Öffentliche Instanzen stellen normalerweise nur die Weboberfläche bereit und blockieren zudem automatisierte Anfragen. Daher ist eine selbst gehostete Instanz die zuverlässige Option."
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::WEBSEARCH::SEARXNG::SEARXNGSEARCHBACKEND::T4198847064"] = "Basis-URL der SearXNG-Instanz. Sie können entweder die Stamm-URL der Instanz oder den Endpunkt „/search“ eingeben. In der Instanz muss das JSON-Format aktiviert sein, d. h. „json“ muss in Ihrer Datei „settings.yml“ unter „search.formats“ aufgeführt sein. Öffentliche Instanzen stellen normalerweise nur die Weboberfläche bereit und blockieren zudem automatisierte Anfragen. Daher ist eine selbst gehostete Instanz die zuverlässige Option."
-- The configured SearXNG URL must start with http:// or https://.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::TOOLCALLINGSYSTEM::TOOLCALLINGIMPLEMENTATIONS::WEBSEARCH::SEARXNG::SEARXNGSEARCHBACKEND::T944878454"] = "Die konfigurierte SearXNG-URL muss mit http:// oder https:// beginnen."
@ -12601,7 +12706,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2025964684"
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2160507967"] = "Der Name darf maximal 40 Zeichen lang sein."
-- Please select your security policy.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Bitte wählen Sie ihre Sicherheitsrichtlinie aus."
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2250909198"] = "Bitte wählen Sie Ihre Sicherheitsrichtlinie aus."
-- Please test the connection before saving.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"] = "Bitte testen Sie die Verbindung, bevor Sie speichern."
@ -12610,7 +12715,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T285470497"]
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T2999173576"] = "Der ausgewählte Einbettungsanbieter hat das Vertrauensniveau „{0}“, aber diese Datenquelle erfordert das Vertrauensniveau „{1}“. Bitte wählen Sie einen Einbettungsanbieter mit gleichem oder höherem Vertrauensniveau oder senken Sie das erforderliche Vertrauensniveau."
-- Please enter your secure access token.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T3086932434"] = "Bitte geben Sie ihren sicheren Zugangstoken ein."
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T3086932434"] = "Bitte geben Sie Ihren sicheren Zugangstoken ein."
-- The path does not exist. Please select a valid directory.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::DATASOURCEVALIDATION::T3146272446"] = "Der Pfad existiert nicht. Bitte wählen Sie einen gültigen Ordner aus."
@ -12712,7 +12817,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T649507886"] =
UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] = "Bitte wählen Sie ein Modell aus."
-- Are you sure you want to delete the chat '{0}' in the workspace '{1}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Möchtest du den Chat '{0}' im Arbeitsbereich '{1}' wirklich löschen?"
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Möchten Sie den Chat '{0}' im Arbeitsbereich '{1}' wirklich löschen?"
-- Unnamed workspace
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannter Arbeitsbereich"
@ -12721,7 +12826,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannt
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen"
-- Are you sure you want to delete the temporary chat '{0}'?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Möchtest du den temporären Chat '{0}' wirklich löschen?"
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Möchten Sie den temporären Chat '{0}' wirklich löschen?"
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat"

View File

@ -3960,6 +3960,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T700666808"] = "Mana
-- Available Data Sources
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T86053874"] = "Available Data Sources"
-- This data source is waiting to be indexed again. Until that is finished, it cannot be searched.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTIONROW::T1692539409"] = "This data source is waiting to be indexed again. Until that is finished, it cannot be searched."
-- Tools (Optional)
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERFORM::T1019749907"] = "Tools (Optional)"
@ -4224,6 +4227,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T3654011106"] = "Open P
-- You can switch between your profiles here
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROFILESELECTION::T918741365"] = "You can switch between your profiles here"
-- No LLM providers are configured yet. Add a provider in the app settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1166628228"] = "No LLM providers are configured yet. Add a provider in the app settings."
-- No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1220991024"] = "No LLM providers meet the confidence requirements. Configure an eligible provider in the app settings."
-- Audio input possible
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T1742581112"] = "Audio input possible"
@ -5037,6 +5046,27 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78
-- Are you sure you want to delete the transcription provider '{0}'?
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Are you sure you want to delete the transcription provider '{0}'?"
-- Could not open the file location.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Could not open the file location."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Could not open the file location: {0}"
-- Show this file in the file manager of your system
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Show this file in the file manager of your system"
-- Opens this document in the program your system uses for it
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Opens this document in the program your system uses for it"
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unknown error"
-- Could not open the document.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Could not open the document."
-- Could not open the document: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Could not open the document: {0}"
-- Copy {0} to the clipboard
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard"
@ -9078,12 +9108,18 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1143368054"] = "Other cause"
-- Current file: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1166856644"] = "Current file: {0}"
-- File {0} of {1} is being indexed: block {2}, page {3}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1298290372"] = "File {0} of {1} is being indexed: block {2}, page {3}."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1455637941"] = "Could not open the file location: {0}"
-- Open the settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1582896271"] = "Open the settings"
-- File {0} of {1} is being indexed.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1616414701"] = "File {0} of {1} is being indexed."
-- Tried again during the next run
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T1946414905"] = "Tried again during the next run"
@ -9126,6 +9162,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T309404893"] = "Failed files: {0}"
-- Show this file in the file browser of your system
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3273105305"] = "Show this file in the file browser of your system"
-- Data source {0} of {1} is being worked on. The others are waiting their turn.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3389674086"] = "Data source {0} of {1} is being worked on. The others are waiting their turn."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3461425987"] = "Unknown error"
@ -9141,6 +9180,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T3865031940"] = "Actions"
-- Skipped until the file changes
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T542386347"] = "Skipped until the file changes"
-- File {0} of {1} is being indexed: block {2}.
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T615458954"] = "File {0} of {1} is being indexed: block {2}."
-- File
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T723007075"] = "File"
@ -9237,9 +9279,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T103551060"] = "The configured ro
-- Browse AI Studio's source code on GitHub — we welcome your contributions.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1107156991"] = "Browse AI Studio's source code on GitHub — we welcome your contributions."
-- 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."
@ -9342,6 +9381,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1924365263"] = "This library is
-- Encryption secret: is configured
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1931141322"] = "Encryption secret: is configured"
-- Index database
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1949702956"] = "Index database"
-- The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T1985806792"] = "The objc2 project provides access to Apple's Objective-C frameworks from Rust. On macOS, we use the libraries objc2, objc2-app-kit, and objc2-foundation to open the native macOS share sheet, e.g., when you share a plugin with others."
@ -9519,9 +9561,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3019585985"] = "Test configurati
-- External HTTPS custom root certificates are configured but not active.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3021325354"] = "External HTTPS custom root certificates are configured but not active."
-- Vector store
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3046399223"] = "Vector store"
-- Enterprise configuration ID:
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3092349641"] = "Enterprise configuration ID:"
@ -9648,6 +9687,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Copies the allow
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
-- Vector database
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3977811115"] = "Vector database"
-- Installed Pandoc version
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installed Pandoc version"
@ -10665,23 +10707,50 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T10
-- Starting
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T1233211769"] = "Starting"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T2608177081"] = "unknown"
-- Unavailable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T3662391977"] = "Unavailable"
-- Process architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T563161633"] = "Process architecture"
-- Status
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T6222351"] = "Status"
-- Database path
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1100578143"] = "Database path"
-- Native library
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::NOINDEXSTORECLIENT::T634426545"] = "Native library"
-- no migration applied
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1014567907"] = "no migration applied"
-- Storage size
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1230141403"] = "Storage size"
-- Full-text search (FTS5)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1396060361"] = "Full-text search (FTS5)"
-- Wrapper version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1469427584"] = "Wrapper version"
-- available
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T1727918744"] = "available"
-- Indexed files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2235289713"] = "Indexed files"
-- Search chunks
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2333737457"] = "Search chunks"
-- {0} ({1} applied)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2286846332"] = "{0} ({1} applied)"
-- Journal mode
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2411639995"] = "Journal mode"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T2608177081"] = "unknown"
-- Database tables
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3279078157"] = "Database tables"
-- Indexed data sources
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3524534748"] = "Indexed data sources"
@ -10689,9 +10758,27 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTI
-- Reported version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3556099842"] = "Reported version"
-- not available
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3574465749"] = "not available"
-- Permanently skipped files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T3965853089"] = "Permanently skipped files"
-- Schema version
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T424290830"] = "Schema version"
-- Process architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T563161633"] = "Process architecture"
-- Native library
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T634426545"] = "Native library"
-- System architecture
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T818259255"] = "System architecture"
-- {0} ({1} applied, {2} pending)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::INDEXSTORE::SQLITEINDEXSTORECLIENTIMPLEMENTATION::T983802795"] = "{0} ({1} applied, {2} pending)"
-- Reason
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::NODATABASECLIENT::T1093747001"] = "Reason"
@ -10716,6 +10803,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::NOVECTORSTORECLIENT::T
-- Storage size
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T1230141403"] = "Storage size"
-- unknown
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2608177081"] = "unknown"
-- Number of vector stores
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T2785004838"] = "Number of vector stores"
@ -10725,6 +10815,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEM
-- Status
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T6222351"] = "Status"
-- Stored vectors
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T701689894"] = "Stored vectors"
-- Qdrant Edge is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge is not available."
@ -11970,6 +12063,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T29
-- The data source '{0}' was left out of the answer: its embedding provider '{1}' did not return a vector for your message.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T3469074321"] = "The data source '{0}' was left out of the answer: its embedding provider '{1}' did not return a vector for your message."
-- The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T4022014739"] = "The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished."
-- Page {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::DATASOURCELOCALRETRIEVALSERVICE::T4127287940"] = "Page {0}"
@ -12213,6 +12309,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "No com
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
-- The runtime document endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "The runtime document endpoint returned '{0}'."
-- The global shortcut could not be registered because of a desktop integration error.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "The global shortcut could not be registered because of a desktop integration error."
@ -12240,6 +12339,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Succes
-- The desktop service returned an invalid response while registering the global shortcut.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "The desktop service returned an invalid response while registering the global shortcut."
-- The runtime document endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "The runtime document endpoint failed without details."
-- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default."
@ -12258,6 +12360,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "No sav
-- Failed to get the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Failed to get the secret data due to an API issue."
-- The runtime document endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "The runtime document endpoint is not available."
-- AI Studio could not access secure storage. See the log for technical details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio could not access secure storage. See the log for technical details."

View File

@ -491,7 +491,11 @@ public static class LLMProvidersExtensions
LLMProviders.GWDG => true,
LLMProviders.HUGGINGFACE => true,
LLMProviders.SELF_HOSTED => host is (Host.OLLAMA or Host.VLLM),
// Every self-hosted engine can ask for a key: LM Studio brings its own tokens, and any of
// them can sit behind a proxy which authenticates. The field is labeled as optional for
// them, so offering it costs nothing where no key is needed, while leaving it out means
// the user cannot enter the one their server expects:
LLMProviders.SELF_HOSTED => host is not Host.NONE,
_ => false,
};

View File

@ -173,12 +173,17 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide
private async Task<ModelLoadResult> LoadModels(SecretStoreType storeType, string[] ignorePhrases, string[] filterPhrases, string? apiKeyProvisional, CancellationToken token)
{
var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, true);
var secretKey = await this.GetModelLoadingSecretKey(storeType, apiKeyProvisional, isTryingSecret: true);
try
{
using var lmStudioRequest = new HttpRequestMessage(HttpMethod.Get, "models");
if(secretKey is not null)
// An empty token is worse than none at all: a proxy which enforces authentication
// rejects an empty bearer with 401, where it would have let a request without any
// authorization header through. The dialogs hand us their key field as it stands, so
// an empty string arrives here whenever the user stored no key:
if(!string.IsNullOrWhiteSpace(secretKey))
lmStudioRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
using var lmStudioResponse = await this.HttpClient.SendAsync(lmStudioRequest, token);

View File

@ -34,6 +34,22 @@ public sealed class SettingsManager
private readonly ILogger<SettingsManager> logger;
private readonly RustService rustService;
/// <summary>
/// Lets only one operation at a time touch the settings files.
/// </summary>
/// <remarks>
/// Reading takes this as well as writing does, for two reasons. A read migrates and backs up
/// what it found, so it writes the very files a store writes. And it re-evaluates whether
/// writes are blocked at all, starting out by clearing that block: a store slipping through
/// that moment would overwrite the settings the block exists to protect.<br/><br/>
/// What this does not do is guard the settings themselves. It guards the files: what one store
/// writes, the next one no longer has to fear. The configuration data behind them stays open to
/// everybody, and a store serializes it while the rest of the app goes on editing it -- a list
/// growing mid-serialization still throws. Whoever wants that answered needs one of their own;
/// this lock is not it.
/// </remarks>
private readonly SemaphoreSlim settingsFileSemaphore = new(1, 1);
/// <summary>
/// The settings manager.
/// </summary>
@ -103,6 +119,19 @@ public sealed class SettingsManager
/// </summary>
/// <returns>A (migrated) settings snapshot, or null if it could not be read.</returns>
public async Task<Data?> TryReadSettingsSnapshot()
{
await this.settingsFileSemaphore.WaitAsync();
try
{
return await this.ReadSettingsSnapshot();
}
finally
{
this.settingsFileSemaphore.Release();
}
}
private async Task<Data?> ReadSettingsSnapshot()
{
this.SettingsWriteBlockReason = SettingsWriteBlockReason.NONE;
if(!this.IsSetUp)
@ -293,6 +322,9 @@ public sealed class SettingsManager
/// Stores the settings to the file system.
/// </summary>
public async Task StoreSettings()
{
await this.settingsFileSemaphore.WaitAsync();
try
{
if(!this.IsSetUp)
{
@ -306,29 +338,57 @@ public sealed class SettingsManager
return;
}
var settingsJson = JsonSerializer.Serialize(this.ConfigurationData, JSON_OPTIONS);
var settingsPath = Path.Combine(ConfigDirectory!, SETTINGS_FILENAME);
await this.StoreSettingsSnapshot(this.ConfigurationData, settingsPath);
await this.StoreCurrentVersionBackup(this.ConfigurationData);
await this.StoreSerializedSettings(settingsJson, settingsPath);
await this.StoreSerializedVersionBackup(this.ConfigurationData.Version, settingsJson);
}
finally
{
this.settingsFileSemaphore.Release();
}
}
private static string GetBackupSettingsFilename(Version version) => $"settings.{version.ToString().ToLowerInvariant()}.json";
private static string GetBackupSettingsPath(Version version) => Path.Combine(ConfigDirectory!, GetBackupSettingsFilename(version));
private async Task StoreCurrentVersionBackup(Data settingsData)
private Task StoreCurrentVersionBackup(Data settingsData) =>
this.StoreSerializedVersionBackup(settingsData.Version, JsonSerializer.Serialize(settingsData, JSON_OPTIONS));
/// <summary>
/// Writes the backup file from settings which were serialized already.
/// </summary>
/// <remarks>
/// The store hands the same JSON to this method and to the one writing the settings file, so
/// that both files say the same thing. Serializing twice cannot promise that: the configuration
/// data may well have changed in between, and the backup would then describe a state the
/// settings file never had.
/// </remarks>
/// <param name="settingsVersion">The version the serialized settings carry.</param>
/// <param name="settingsJson">The serialized settings.</param>
private async Task StoreSerializedVersionBackup(Version settingsVersion, string settingsJson)
{
if(settingsData.Version != CURRENT_SETTINGS_VERSION)
if(settingsVersion != CURRENT_SETTINGS_VERSION)
{
this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsData.Version}' is not the current version '{CURRENT_SETTINGS_VERSION}'.");
this.logger.LogWarning($"Skipping settings backup because the settings version '{settingsVersion}' is not the current version '{CURRENT_SETTINGS_VERSION}'.");
return;
}
var backupSettingsPath = GetBackupSettingsPath(CURRENT_SETTINGS_VERSION);
await this.StoreSettingsSnapshot(settingsData, backupSettingsPath);
await this.StoreSerializedSettings(settingsJson, backupSettingsPath);
this.logger.LogInformation($"Stored the settings backup file '{backupSettingsPath}'.");
}
private async Task StoreSettingsSnapshot(Data settingsData, string settingsPath)
private Task StoreSettingsSnapshot(Data settingsData, string settingsPath) =>
this.StoreSerializedSettings(JsonSerializer.Serialize(settingsData, JSON_OPTIONS), settingsPath);
/// <summary>
/// Writes settings which were serialized already to the given path.
/// </summary>
/// <param name="settingsJson">The serialized settings.</param>
/// <param name="settingsPath">The file to write them to.</param>
private async Task StoreSerializedSettings(string settingsJson, string settingsPath)
{
if(!Directory.Exists(ConfigDirectory))
{
@ -336,8 +396,6 @@ public sealed class SettingsManager
Directory.CreateDirectory(ConfigDirectory!);
}
var settingsJson = JsonSerializer.Serialize(settingsData, JSON_OPTIONS);
//
// We write the new settings next to the previous ones and replace them afterwards, so that
// no crash can leave a half-written settings file behind. The temporary file has to live in

View File

@ -3,11 +3,23 @@ using AIStudio.Settings;
namespace AIStudio.Tools;
/// <summary>
/// Contains both the allowed and selected data sources.
/// Contains the allowed and selected data sources, plus the ones which cannot be searched right now.
/// </summary>
/// <remarks>
/// The selected data sources are a subset of the allowed data sources.
///
/// The data sources waiting for a re-index are deliberately kept apart from the allowed ones rather
/// than mixed in. Everything reading the allowed list -- the data source selection agent above all
/// -- takes it to mean "may be used to answer with", and a source whose index is being rebuilt
/// cannot answer anything. It is listed separately so the user interface can still show it and say
/// why it is greyed out, instead of letting it vanish without a word.
///
/// The same holds for the ones waiting for a repair, and they are a list of their own because the
/// two reasons call for different words: one passes by itself, the other one waits for the user.
/// A data source is in at most one of the two lists.
/// </remarks>
/// <param name="AllowedDataSources">The allowed data sources.</param>
/// <param name="SelectedDataSources">The selected data sources, which are a subset of the allowed data sources.</param>
public readonly record struct AllowedSelectedDataSources(IReadOnlyList<IDataSource> AllowedDataSources, IReadOnlyList<IDataSource> SelectedDataSources);
/// <param name="DataSourcesAwaitingReindex">The data sources which passed every check but cannot be searched until their index has been rebuilt.</param>
/// <param name="DataSourcesNeedingRepair">The data sources which passed every check but whose index cannot be read anymore, so that only the user can get them back.</param>
public readonly record struct AllowedSelectedDataSources(IReadOnlyList<IDataSource> AllowedDataSources, IReadOnlyList<IDataSource> SelectedDataSources, IReadOnlyList<IDataSource> DataSourcesAwaitingReindex, IReadOnlyList<IDataSource> DataSourcesNeedingRepair);

View File

@ -1,17 +1,21 @@
namespace AIStudio.Tools;
/// <summary>
/// Content which a reader held back, together with the token count of exactly that content.
/// Content which a reader held back, together with the token count and the page of exactly that
/// content.
/// </summary>
/// <remarks>
/// Readers which assemble a page or a slide from several stream events cannot pass their content
/// on right away. Its token count has to travel with it: the count describes the content, not the
/// event which happened to arrive at the moment the content was released. Keeping the two together
/// is what stops a page from being sized by the text of the page after it.
/// is what stops a page from being sized by the text of the page after it. The page number travels
/// for the very same reason, and because a number the runtime already stated must not be derived
/// from the text again further down the line.
/// </remarks>
/// <param name="Content">The assembled content.</param>
/// <param name="TokenCount">The number of tokens of that content, or null when it is unknown.</param>
public readonly record struct ContentStreamPendingContent(string Content, int? TokenCount)
/// <param name="PageNumber">The page that content came from, or null when it has none.</param>
public readonly record struct ContentStreamPendingContent(string Content, int? TokenCount, int? PageNumber = null)
{
/// <summary>
/// Adds up two token counts, where an unknown count makes the sum unknown as well.

View File

@ -12,7 +12,8 @@ namespace AIStudio.Tools;
/// <param name="Error">The reported failure, or null when the event was processed successfully.</param>
/// <param name="PromptInjection">What the runtime filtered out of the content, or null when it filtered nothing.</param>
/// <param name="TokenCount">The number of tokens of the content, or null when it is unknown.</param>
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error, ContentStreamPromptInjectionDetails? PromptInjection = null, int? TokenCount = null)
/// <param name="PageNumber">The page the content came from, or null when it has none.</param>
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error, ContentStreamPromptInjectionDetails? PromptInjection = null, int? TokenCount = null, int? PageNumber = null)
{
/// <summary>
/// An event which neither produced content nor reported a failure.
@ -20,16 +21,18 @@ public readonly record struct ContentStreamProcessedEvent(string? Content, Conte
public static readonly ContentStreamProcessedEvent NOTHING = new(null, null);
/// <summary>
/// An event which produced content, with the token count of that very content.
/// An event which produced content, with the token count and the page of that very content.
/// </summary>
/// <remarks>
/// The count travels with the content because a reader may hold content back across several
/// events: pairing it with the count of the event which released it would size it by the
/// wrong text.
/// wrong text. The page travels along for the same reason, and so that whoever indexes the
/// content is told where it came from instead of having to read it back out of the text.
/// </remarks>
/// <param name="content">The content to append.</param>
/// <param name="tokenCount">The number of tokens of that content, or null when it is unknown.</param>
public static ContentStreamProcessedEvent FromContent(string? content, int? tokenCount = null) => new(content, null, TokenCount: tokenCount);
/// <param name="pageNumber">The page that content came from, or null when it has none.</param>
public static ContentStreamProcessedEvent FromContent(string? content, int? tokenCount = null, int? pageNumber = null) => new(content, null, TokenCount: tokenCount, PageNumber: pageNumber);
public static ContentStreamProcessedEvent FromError(ContentStreamErrorDetails? error) => new(null, error);

View File

@ -19,13 +19,19 @@ public static class ContentStreamSseHandler
case ContentStreamTextMetadata:
return ContentStreamProcessedEvent.FromContent(sseEvent.Content, sseEvent.TokenCount);
//
// The heading tells the AI which page it is reading. The number is handed on
// separately as well, because whoever indexes this content needs it as a
// number: reading it back out of the heading would mean guessing at something
// the runtime already stated.
//
case ContentStreamPdfMetadata pdfMetadata:
var pageNumber = pdfMetadata.Pdf?.PageNumber ?? 0;
return ContentStreamProcessedEvent.FromContent($"""
# Page {pageNumber}
{sseEvent.Content}
""", sseEvent.TokenCount);
""", sseEvent.TokenCount, pageNumber > 0 ? pageNumber : null);
case ContentStreamSpreadsheetMetadata spreadsheetMetadata:
var sheetName = spreadsheetMetadata.Spreadsheet?.SheetName;
@ -45,9 +51,10 @@ public static class ContentStreamSseHandler
// a page can follow its Markdown. Documents converted as a whole, e.g. by Pandoc,
// carry no page number and are passed on unchanged.
//
// The buffering is why the count comes back from the reader rather than from
// this event: the page which is released here arrived one event ago, and this
// event's count belongs to the page which is now being buffered.
// The buffering is why the count and the page come back from the reader rather
// than from this event: the page which is released here arrived one event ago,
// and this event's count and number belong to the page which is now being
// buffered.
//
case ContentStreamDocumentMetadata documentMetadata:
if (documentMetadata.Document?.PageNumber is not > 0)
@ -55,7 +62,7 @@ public static class ContentStreamSseHandler
var documentManager = DOCUMENT_MANAGERS.GetOrAdd(sseEvent.StreamId!, _ => new());
var documentContent = documentManager.AddPage(documentMetadata, sseEvent.Content, sseEvent.TokenCount, extractImages);
return documentContent is null ? ContentStreamProcessedEvent.NOTHING : ContentStreamProcessedEvent.FromContent(documentContent.Value.Content, documentContent.Value.TokenCount);
return documentContent is null ? ContentStreamProcessedEvent.NOTHING : ContentStreamProcessedEvent.FromContent(documentContent.Value.Content, documentContent.Value.TokenCount, documentContent.Value.PageNumber);
case ContentStreamImageMetadata:
return ContentStreamProcessedEvent.FromContent(sseEvent.Content, sseEvent.TokenCount);
@ -184,7 +191,9 @@ public static class ContentStreamSseHandler
/// <remarks>
/// The readers which assemble pages or slides always keep the last one of them: nothing tells
/// them that no further image is coming. It is released here, and it carries its own token
/// count, because a chunk without one cannot be sized by the caller.
/// count, because a chunk without one cannot be sized by the caller. Only the page reader
/// states a page; a stream is read by one of them, so there is no second number to weigh
/// against.
/// </remarks>
/// <param name="streamId">The stream to release and forget.</param>
/// <returns>The content which was held back, or null when there was none.</returns>
@ -195,6 +204,7 @@ public static class ContentStreamSseHandler
var finalContentChunk = new StringBuilder();
int? tokenCount = 0;
int? pageNumber = null;
if(SLIDE_MANAGERS.TryGetValue(streamId, out var slideManager)
&& slideManager.GetAllSlidesInOrder() is { } slides
&& !string.IsNullOrWhiteSpace(slides.Content))
@ -209,6 +219,7 @@ public static class ContentStreamSseHandler
{
finalContentChunk.Append(page.Content);
tokenCount = ContentStreamPendingContent.AddTokenCounts(tokenCount, page.TokenCount);
pageNumber = page.PageNumber;
}
SLIDE_MANAGERS.TryRemove(streamId, out _);
@ -217,6 +228,6 @@ public static class ContentStreamSseHandler
foreach (var key in CHUNKED_IMAGES.Keys.Where(k => k.StartsWith(imageIdPrefix, StringComparison.InvariantCultureIgnoreCase)))
CHUNKED_IMAGES.TryRemove(key, out _);
return finalContentChunk.Length > 0 ? new ContentStreamPendingContent(finalContentChunk.ToString(), tokenCount) : null;
return finalContentChunk.Length > 0 ? new ContentStreamPendingContent(finalContentChunk.ToString(), tokenCount, pageNumber) : null;
}
}

View File

@ -0,0 +1,42 @@
using AIStudio.Dialogs;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
namespace AIStudio.Tools;
/// <summary>
/// Asks whether a data source should be indexed anew, and starts the rebuild when the user agrees.
/// </summary>
/// <remarks>
/// Kept here rather than in the two places which offer the repair -- the background embeddings page
/// and the data source table -- so the sentence naming what a rebuild costs cannot drift apart
/// between them. Naming both costs is the whole reason for asking at all.
/// </remarks>
public static class DataSourceRepair
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceRepair).Namespace, nameof(DataSourceRepair));
/// <summary>
/// Asks the user, and rebuilds the index of the data source when they agree.
/// </summary>
/// <param name="dialogService">The dialog service to ask with.</param>
/// <param name="embeddingService">The service which does the rebuild.</param>
/// <param name="dataSourceId">The data source to repair.</param>
/// <param name="dataSourceName">The name of that data source, as the question names it.</param>
/// <returns>True when the rebuild was started.</returns>
public static async Task<bool> ConfirmAndRepairAsync(IDialogService dialogService, DataSourceEmbeddingService embeddingService, string dataSourceId, string dataSourceName)
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, string.Format(TB("The index of the data source '{0}' cannot be read anymore. Repairing it means building the index from scratch: everything indexed so far is thrown away, and every document of this data source is sent to your embedding provider once more. With a cloud provider, this costs money, and with a large data source it takes a while. Do you want to repair this data source now?"), dataSourceName) },
};
var dialogReference = await dialogService.ShowAsync<ConfirmDialog>(TB("Repair Data Source"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return false;
await embeddingService.RepairDataSourceAsync(dataSourceId);
return true;
}
}

View File

@ -8,11 +8,20 @@ public abstract class DatabaseClient(string name, string path)
public virtual DatabaseClientStatus Status => DatabaseClientStatus.AVAILABLE;
/// <summary>
/// The version the running database reports about itself.
/// </summary>
/// <remarks>
/// Empty when the client cannot tell. Callers which want to show a version in a headline read it
/// from here instead of picking it out of the label-value pairs the display info yields.
/// </remarks>
public virtual string Version => string.Empty;
public bool IsAvailable => this.Status is DatabaseClientStatus.AVAILABLE;
private string Path => path;
private ILogger<DatabaseClient>? logger;
protected ILogger<DatabaseClient>? Logger;
public abstract IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo();
@ -20,13 +29,13 @@ public abstract class DatabaseClient(string name, string path)
{
if (string.IsNullOrWhiteSpace(this.Path))
{
this.logger!.LogError($"Error: Database path '{this.Path}' cannot be null or empty.");
this.Logger!.LogError($"Error: Database path '{this.Path}' cannot be null or empty.");
return "0 B";
}
if (!Directory.Exists(this.Path))
{
this.logger!.LogError($"Error: Database path '{this.Path}' does not exist.");
this.Logger!.LogError($"Error: Database path '{this.Path}' does not exist.");
return "0 B";
}
var files = Directory.EnumerateFiles(this.Path, "*", SearchOption.AllDirectories)
@ -52,7 +61,7 @@ public abstract class DatabaseClient(string name, string path)
public void SetLogger(ILogger<DatabaseClient> logService)
{
this.logger = logService;
this.Logger = logService;
}
public abstract void Dispose();

View File

@ -69,6 +69,25 @@ public sealed class DatabaseClientProvider(RustService rustService, ILoggerFacto
client.Status);
}
/// <summary>
/// Builds the client which stands in for a database role that cannot serve right now.
/// </summary>
/// <remarks>
/// Callers outside this namespace get their stand-in from here instead of naming the concrete
/// type themselves, so a new role does not have to be spelled out in every one of them.
/// </remarks>
/// <param name="databaseRole">The role the stand-in has to fill.</param>
/// <param name="name">The name to show for the database.</param>
/// <param name="reason">Why the database is not available.</param>
/// <param name="status">Whether the database is starting or unavailable.</param>
/// <returns>A client which answers every operation without a database behind it.</returns>
public static DatabaseClient CreateUnavailableClient(DatabaseRole databaseRole, string name, string? reason, DatabaseClientStatus status) => databaseRole switch
{
DatabaseRole.VECTOR_STORE => new NoVectorStoreClient(name, reason, status),
DatabaseRole.INDEX_STORE => new NoIndexStoreClient(name, reason, status),
_ => new NoDatabaseClient(name, reason, status)
};
private DatabaseClient CacheIfAvailable(DatabaseRole databaseRole, DatabaseClient client)
{
if (!client.IsAvailable)
@ -104,7 +123,7 @@ public sealed class DatabaseClientProvider(RustService rustService, ILoggerFacto
private async Task<DatabaseClient> CreateClientAsync(DatabaseRole databaseRole, CancellationToken cancellationToken) => databaseRole switch
{
DatabaseRole.VECTOR_STORE => await QdrantEdgeClientImplementation.CreateAsync(rustService, this.logger, this.databaseClientLogger, cancellationToken),
DatabaseRole.VECTOR_STORE => await QdrantEdgeClientImplementation.CreateAsync(rustService, this.GetIndexStoreAsync, this.logger, this.databaseClientLogger, cancellationToken),
DatabaseRole.INDEX_STORE => await SqliteIndexStoreClientImplementation.CreateAsync(this.logger, this.databaseClientLogger, cancellationToken),
_ => new NoDatabaseClient(databaseRole.ToString(), "The requested database role is not supported.")
};

View File

@ -0,0 +1,19 @@
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// What the index knows about a data source as a whole, without its files.
/// </summary>
/// <remarks>
/// The manifest answers the same question, but reads every file and every stored failure of the
/// data source to do so. That is the right thing before a run, and far too much for a question
/// asked about several data sources every time somebody opens the data source selection.
///
/// SourceHash is the telling one: it is written once a run has worked through the whole data
/// source, and resetting the index deletes the row it lives in. So an empty hash means no run has
/// finished since the index was last discarded.
/// </remarks>
/// <param name="EmbeddingProviderId">The embedding provider the stored vectors were created with.</param>
/// <param name="EmbeddingSignature">Identifies the embedding configuration the stored vectors belong to.</param>
/// <param name="SourceHash">The hash of the data source as a whole, written when a run completes.</param>
/// <param name="VectorSize">The dimension of the stored vectors.</param>
public sealed record DataSourceIndexState(string EmbeddingProviderId, string EmbeddingSignature, string SourceHash, int VectorSize);

View File

@ -4,8 +4,6 @@ internal sealed class EmbeddingStateDataSourceEntity
{
public string DataSourceId { get; set; } = string.Empty;
public string DataSourceName { get; set; } = string.Empty;
public string DataSourceType { get; set; } = string.Empty;
public string EmbeddingProviderId { get; set; } = string.Empty;

View File

@ -11,6 +11,4 @@ public sealed record EmbeddingStateFile(
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
int ChunkCount,
string ConfidenceLevel,
int ConfidenceLevelRank);
int ChunkCount);

View File

@ -26,10 +26,6 @@ internal sealed class EmbeddingStateFileEntity
public int ChunkCount { get; set; }
public string ConfidenceLevel { get; set; } = string.Empty;
public int ConfidenceLevelRank { get; set; }
public EmbeddingStateDataSourceEntity? DataSource { get; set; }
public List<EmbeddingStateChunkEntity> Chunks { get; set; } = [];

View File

@ -6,9 +6,16 @@ public abstract class IndexStoreClient(string name, string path) : DatabaseClien
{
public abstract Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token);
/// <summary>
/// Reads what the index knows about a data source as a whole, without its files.
/// </summary>
/// <param name="dataSourceId">The data source to read.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The stored state, or null when the index holds nothing about this data source.</returns>
public abstract Task<DataSourceIndexState?> GetDataSourceStateAsync(string dataSourceId, CancellationToken token);
public abstract Task UpsertDataSourceAsync(
string dataSourceId,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
@ -33,4 +40,17 @@ public abstract class IndexStoreClient(string name, string path) : DatabaseClien
public abstract Task<IReadOnlyList<IndexStoreSearchResult>> SearchChunksAsync(string dataSourceId, string query, int maxMatches, CancellationToken token);
public abstract Task DeleteDataSourceAsync(string dataSourceId, CancellationToken token);
/// <summary>
/// Counts the search chunks the index holds across all data sources.
/// </summary>
/// <remarks>
/// One chunk is one vector: every chunk becomes exactly one point carrying the single named
/// vector "embedding". The vector store reports its vector count from here, because counting
/// the points in Qdrant Edge would have to load every shard first and would hold the global
/// database mutex against ongoing inserts and searches while doing so.
/// </remarks>
/// <param name="token">The cancellation token.</param>
/// <returns>The number of chunks, or null when the index cannot tell. Null and zero mean different things here.</returns>
public abstract Task<long?> GetTotalChunkCountAsync(CancellationToken token);
}

View File

@ -29,7 +29,6 @@ internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext>
entity.HasKey(dataSource => dataSource.DataSourceId);
entity.Property(dataSource => dataSource.DataSourceId).HasColumnName("data_source_id");
entity.Property(dataSource => dataSource.DataSourceName).HasColumnName("data_source_name").IsRequired();
entity.Property(dataSource => dataSource.DataSourceType).HasColumnName("data_source_type").IsRequired();
entity.Property(dataSource => dataSource.EmbeddingProviderId).HasColumnName("embedding_provider_id").IsRequired();
entity.Property(dataSource => dataSource.EmbeddingSignature).HasColumnName("embedding_signature").IsRequired();
@ -67,13 +66,10 @@ internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext>
entity.Property(file => file.LastWriteUtc).HasColumnName("last_write_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.ChunkCount).HasColumnName("chunk_count");
entity.Property(file => file.ConfidenceLevel).HasColumnName("confidence_level").IsRequired();
entity.Property(file => file.ConfidenceLevelRank).HasColumnName("confidence_level_rank");
entity.HasIndex(file => file.DataSourceId).HasDatabaseName("idx_embedded_files_data_source");
entity.HasIndex(file => file.AbsolutePath).HasDatabaseName("idx_embedded_files_absolute_path");
entity.HasIndex(file => file.FileType).HasDatabaseName("idx_embedded_files_file_type");
entity.HasIndex(file => file.ConfidenceLevelRank).HasDatabaseName("idx_embedded_files_confidence");
entity.HasIndex(file => new { file.DataSourceId, file.AbsolutePath }).HasDatabaseName("idx_embedded_files_data_source_absolute_path").IsUnique();
entity

View File

@ -8,6 +8,8 @@ internal static class IndexStoreSchemaMigrator
{
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.InitialRagIndex))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.PermanentIndexingFailures))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropFileConfidenceLevel))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropDataSourceName))]
public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token)
{
await context.Database.MigrateAsync(token);

View File

@ -4,7 +4,6 @@ public sealed record IndexStoreSearchResult(
string ChunkId,
string ParentFileId,
string DataSourceId,
string DataSourceName,
string DataSourceType,
string AbsolutePath,
string FileName,
@ -19,6 +18,4 @@ public sealed record IndexStoreSearchResult(
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
int ChunkCount,
string ConfidenceLevel,
int ConfidenceLevelRank);
int ChunkCount);

View File

@ -8,8 +8,6 @@ internal sealed class IndexStoreSearchResultEntity
public string DataSourceId { get; set; } = string.Empty;
public string DataSourceName { get; set; } = string.Empty;
public string DataSourceType { get; set; } = string.Empty;
public string AbsolutePath { get; set; } = string.Empty;
@ -39,8 +37,4 @@ internal sealed class IndexStoreSearchResultEntity
public DateTimeOffset EmbeddedAtUtc { get; set; }
public int ChunkCount { get; set; }
public string ConfidenceLevel { get; set; } = string.Empty;
public int ConfidenceLevelRank { get; set; }
}

View File

@ -0,0 +1,51 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
/// <summary>
/// Drops the copy of the data source confidence level which every indexed file carried.
/// </summary>
/// <remarks>
/// The confidence level is what a data source asks of a provider. It is a property of the data
/// source, it is enforced live before anything is indexed or answered, and it changes no vector.
/// Keeping a copy per file only meant the index had to be thrown away whenever the setting changed.
/// </remarks>
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20260915000000_DropFileConfidenceLevel")]
public partial class DropFileConfidenceLevel : Migration
{
/// <remarks>
/// The columns go through raw SQL instead of DropColumn on purpose. The SQLite provider answers
/// DropColumn by rebuilding the table, and a rebuild drops the table the trigger
/// embedded_files_file_name_au hangs on, which would silently stop the full-text index from
/// following a renamed file. A native ALTER TABLE ... DROP COLUMN leaves the table itself alone.
/// It does refuse a column an index names, so the index has to go first.
/// </remarks>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_embedded_files_confidence",
table: "embedded_files");
migrationBuilder.Sql("""
ALTER TABLE embedded_files DROP COLUMN confidence_level;
ALTER TABLE embedded_files DROP COLUMN confidence_level_rank;
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE embedded_files ADD COLUMN confidence_level TEXT NOT NULL DEFAULT '';
ALTER TABLE embedded_files ADD COLUMN confidence_level_rank INTEGER NOT NULL DEFAULT 0;
""");
migrationBuilder.CreateIndex(
name: "idx_embedded_files_confidence",
table: "embedded_files",
column: "confidence_level_rank");
}
}

View File

@ -0,0 +1,36 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
/// <summary>
/// Drops the copy of the data source name which the index kept next to each indexed data source.
/// </summary>
/// <remarks>
/// The name a user gives a data source lives in the configuration and is read from there whenever
/// it is needed. The copy here was only ever written, never read, and a copy of a name people are
/// free to change can do nothing but go stale.
/// </remarks>
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20260916000000_DropDataSourceName")]
public partial class DropDataSourceName : Migration
{
/// <remarks>
/// The column goes through raw SQL instead of DropColumn on purpose. The SQLite provider answers
/// DropColumn by rebuilding the table, and dropping the old data_sources table would let the
/// cascade of the foreign key in embedded_files take every indexed file and chunk with it. A
/// native ALTER TABLE ... DROP COLUMN leaves the table itself alone. No index names this column,
/// so nothing has to be dropped first.
/// </remarks>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("ALTER TABLE data_sources DROP COLUMN data_source_name;");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("ALTER TABLE data_sources ADD COLUMN data_source_name TEXT NOT NULL DEFAULT '';");
}
}

View File

@ -23,11 +23,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<string>("DataSourceName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_name");
entity.Property<string>("DataSourceType")
.IsRequired()
.HasColumnType("TEXT")
@ -80,15 +75,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.HasColumnType("INTEGER")
.HasColumnName("chunk_count");
entity.Property<string>("ConfidenceLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("confidence_level");
entity.Property<int>("ConfidenceLevelRank")
.HasColumnType("INTEGER")
.HasColumnName("confidence_level_rank");
entity.Property<DateTimeOffset>("CreationUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
@ -138,9 +124,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
entity.HasIndex("AbsolutePath")
.HasDatabaseName("idx_embedded_files_absolute_path");
entity.HasIndex("ConfidenceLevelRank")
.HasDatabaseName("idx_embedded_files_confidence");
entity.HasIndex("DataSourceId")
.HasDatabaseName("idx_embedded_files_data_source");
@ -278,13 +261,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("ConfidenceLevel")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<int>("ConfidenceLevelRank")
.HasColumnType("INTEGER");
entity.Property<DateTimeOffset>("CreationUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT");
@ -293,10 +269,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("DataSourceName")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("DataSourceType")
.IsRequired()
.HasColumnType("TEXT");

View File

@ -20,15 +20,25 @@ public sealed class NoIndexStoreClient(string name, string? unavailableReason, D
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);
//
// Say which native library this process bound to even though the database itself is out of
// reach. When SQLite cannot be loaded on a platform at all, this client is exactly what the
// user sees, so this is the one place where those details matter most.
//
yield return (TB("Native library"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryName()));
yield return (TB("Process architecture"), OrUnknown(SqliteRuntimeInfo.GetProcessArchitecture()));
await Task.CompletedTask;
}
public override Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token) =>
Task.FromResult(new DataSourceEmbeddingManifest());
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
public override Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token) => Task.FromResult(new DataSourceEmbeddingManifest());
public override Task<DataSourceIndexState?> GetDataSourceStateAsync(string dataSourceId, CancellationToken token) => Task.FromResult<DataSourceIndexState?>(null);
public override Task UpsertDataSourceAsync(
string dataSourceId,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
@ -55,6 +65,8 @@ public sealed class NoIndexStoreClient(string name, string? unavailableReason, D
public override Task DeleteDataSourceAsync(string dataSourceId, CancellationToken token) => Task.CompletedTask;
public override Task<long?> GetTotalChunkCountAsync(CancellationToken token) => Task.FromResult<long?>(null);
public override void Dispose()
{
}

View File

@ -1,3 +1,4 @@
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
@ -11,7 +12,7 @@ namespace AIStudio.Tools.Databases.IndexStore;
public sealed class SqliteIndexStoreClientImplementation(string name, string databasePath, string basePath, string version) : IndexStoreClient(name, basePath)
{
private const string DATABASE_NAME = "Local RAG Index";
private const string DATABASE_NAME = "SQLite";
private const string DATABASE_FILENAME = "rag-index.sqlite3";
private const int MAX_FTS_QUERY_TERMS = 32;
private const int CHUNK_UPSERT_BATCH_SIZE = 500;
@ -25,6 +26,8 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
public override string CacheKey => $"{this.Name}:{this.databasePath}:{version}";
public override string Version => version;
public static async Task<DatabaseClient> CreateAsync(
ILogger logger,
ILogger<DatabaseClient> databaseClientLogger,
@ -58,15 +61,43 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
await using var context = this.CreateContext();
//
// Read everything before yielding the first line: this is an iterator, and a try/catch
// cannot wrap a yield return. Each probe therefore catches its own failure and answers
// with an empty string, which shows up as "unknown" below. Without that, a single failing
// PRAGMA would throw out of here and the information page would replace the entire block
// with the fallback client.
//
var snapshot = await this.ReadDisplaySnapshotAsync();
yield return (TB("Reported version"), version);
yield return (TB("Database path"), this.databasePath);
yield return (TB("Native library"), OrUnknown(SqliteRuntimeInfo.GetNativeLibraryName()));
yield return (TB("Wrapper version"), OrUnknown(SqliteRuntimeInfo.GetWrapperVersion()));
yield return (TB("Process architecture"), OrUnknown(SqliteRuntimeInfo.GetProcessArchitecture()));
// Only worth a line when the process runs on a foreign architecture, Rosetta above all:
var systemArchitecture = SqliteRuntimeInfo.GetSystemArchitecture();
if (!string.IsNullOrWhiteSpace(systemArchitecture))
yield return (TB("System architecture"), systemArchitecture);
yield return (TB("Full-text search (FTS5)"), OrUnknown(snapshot.FullTextSearch));
yield return (TB("Journal mode"), OrUnknown(snapshot.JournalMode));
yield return (TB("Schema version"), OrUnknown(snapshot.SchemaVersion));
yield return (TB("Database tables"), OrUnknown(snapshot.TableCount));
yield return (TB("Storage size"), this.GetStorageSize());
yield return (TB("Indexed data sources"), (await context.DataSources.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Indexed files"), (await context.EmbeddedFiles.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Search chunks"), (await context.EmbeddingChunks.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Permanently skipped files"), (await context.PermanentIndexingFailures.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Indexed data sources"), OrUnknown(snapshot.DataSourceCount));
yield return (TB("Indexed files"), OrUnknown(snapshot.FileCount));
yield return (TB("Permanently skipped files"), OrUnknown(snapshot.FailureCount));
}
public override async Task<DataSourceIndexState?> GetDataSourceStateAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
return await context.DataSources
.AsNoTracking()
.Where(source => source.DataSourceId == dataSourceId)
.Select(source => new DataSourceIndexState(source.EmbeddingProviderId, source.EmbeddingSignature, source.SourceHash, source.VectorSize))
.FirstOrDefaultAsync(token);
}
public override async Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token)
@ -118,7 +149,6 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
public override async Task UpsertDataSourceAsync(
string dataSourceId,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
@ -137,7 +167,7 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
context.DataSources.Add(dataSource);
}
ApplyDataSource(dataSource, dataSourceName, dataSourceType, embeddingProviderId, embeddingSignature, sourceHash, vectorSize);
ApplyDataSource(dataSource, dataSourceType, embeddingProviderId, embeddingSignature, sourceHash, vectorSize);
await context.SaveChangesAsync(token);
}
@ -287,7 +317,6 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
c.chunk_id AS ChunkId,
c.parent_file_id AS ParentFileId,
ds.data_source_id AS DataSourceId,
ds.data_source_name AS DataSourceName,
ds.data_source_type AS DataSourceType,
f.absolute_path AS AbsolutePath,
f.file_name AS FileName,
@ -302,9 +331,7 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
f.creation_utc AS CreationUtc,
f.last_write_utc AS LastWriteUtc,
c.embedded_at_utc AS EmbeddedAtUtc,
f.chunk_count AS ChunkCount,
f.confidence_level AS ConfidenceLevel,
f.confidence_level_rank AS ConfidenceLevelRank
f.chunk_count AS ChunkCount
FROM embedding_chunks_fts
JOIN embedding_chunks c ON c.id = embedding_chunks_fts.rowid
JOIN embedded_files f ON f.parent_file_id = c.parent_file_id
@ -342,10 +369,167 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
await transaction.CommitAsync(token);
}
public override async Task<long?> GetTotalChunkCountAsync(CancellationToken token)
{
try
{
await using var context = this.CreateContext();
//
// Sum the chunk counts the files carry instead of counting the rows of the chunk table:
// embedded_files holds one row per file, embedding_chunks one per chunk. On a large index
// that is a difference of two orders of magnitude, and the information page reads this on
// every visit.
//
return await context.EmbeddedFiles.SumAsync(file => (long)file.ChunkCount, token);
}
catch (Exception exception)
{
this.Logger?.LogWarning(exception, "Failed to count the search chunks of the local RAG index.");
return null;
}
}
public override void Dispose()
{
}
/// <summary>
/// Everything the display info reads out of the database in one go.
/// </summary>
/// <remarks>
/// Every property is empty when its probe could not answer. The caller turns that into "unknown".
/// </remarks>
private sealed record DisplaySnapshot
{
public string FullTextSearch { get; init; } = string.Empty;
public string JournalMode { get; init; } = string.Empty;
public string SchemaVersion { get; init; } = string.Empty;
public string TableCount { get; init; } = string.Empty;
public string DataSourceCount { get; init; } = string.Empty;
public string FileCount { get; init; } = string.Empty;
public string FailureCount { get; init; } = string.Empty;
}
private static string OrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? TB("unknown") : value;
private async Task<DisplaySnapshot> ReadDisplaySnapshotAsync()
{
var token = CancellationToken.None;
try
{
await using var context = this.CreateContext();
return new DisplaySnapshot
{
FullTextSearch = await GetFullTextSearchStateAsync(context, token),
JournalMode = (await QueryScalarTextAsync(context, "PRAGMA journal_mode;", token)).ToUpperInvariant(),
SchemaVersion = await GetSchemaVersionAsync(context, token),
TableCount = await GetTableCountAsync(context, token),
DataSourceCount = await FormatCountAsync(context.DataSources, token),
FileCount = await FormatCountAsync(context.EmbeddedFiles, token),
FailureCount = await FormatCountAsync(context.PermanentIndexingFailures, token),
};
}
catch (Exception exception)
{
//
// Opening the database failed altogether. The runtime details the caller shows next to
// these values still say which library was loaded and for which architecture, which is
// what a support case needs most in exactly this situation. So hand back an empty
// snapshot instead of letting the whole block fall back.
//
this.Logger?.LogWarning(exception, "Failed to read the display details of the local RAG index.");
return new DisplaySnapshot();
}
}
private static async Task<string> QueryScalarTextAsync(IndexStoreDbContext context, string sql, CancellationToken token)
{
try
{
//
// Go through the raw connection rather than through SqlQueryRaw: that one expects a
// column named "Value" and wraps the statement, neither of which works for a PRAGMA.
//
var connection = context.Database.GetDbConnection();
if (connection.State is not ConnectionState.Open)
await connection.OpenAsync(token);
await using var command = connection.CreateCommand();
command.CommandText = sql;
var result = await command.ExecuteScalarAsync(token);
return result?.ToString() ?? string.Empty;
}
catch
{
return string.Empty;
}
}
private static async Task<string> GetFullTextSearchStateAsync(IndexStoreDbContext context, CancellationToken token)
{
var compiledIn = await QueryScalarTextAsync(context, "SELECT sqlite_compileoption_used('ENABLE_FTS5')", token);
return compiledIn switch
{
"1" => TB("available"),
"0" => TB("not available"),
_ => string.Empty
};
}
private static async Task<string> GetTableCountAsync(IndexStoreDbContext context, CancellationToken token)
{
//
// Counts the migration history, the FTS5 virtual table and its shadow tables as well. That
// is the point: a missing shadow table is a finding, not noise.
//
var tables = await QueryScalarTextAsync(context, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", token);
return int.TryParse(tables, NumberStyles.Integer, CultureInfo.InvariantCulture, out var tableCount) ? tableCount.CompactCount() : string.Empty;
}
private static async Task<string> GetSchemaVersionAsync(IndexStoreDbContext context, CancellationToken token)
{
try
{
//
// Reading the applied migrations only touches the history table, no assembly scan. The
// pending ones do scan, but the schema migrator walks that same path on every start, so
// the DynamicDependency attributes over there already keep the migration types alive.
//
var appliedMigrations = (await context.Database.GetAppliedMigrationsAsync(token)).ToList();
if (appliedMigrations.Count == 0)
return TB("no migration applied");
var pendingMigrations = (await context.Database.GetPendingMigrationsAsync(token)).ToList();
return pendingMigrations.Count == 0
? string.Format(I18N.I.Culture, TB("{0} ({1} applied)"), appliedMigrations[^1], appliedMigrations.Count.CompactCount())
: string.Format(I18N.I.Culture, TB("{0} ({1} applied, {2} pending)"), appliedMigrations[^1], appliedMigrations.Count.CompactCount(), pendingMigrations.Count.CompactCount());
}
catch
{
return string.Empty;
}
}
private static async Task<string> FormatCountAsync<T>(IQueryable<T> query, CancellationToken token) where T : class
{
try
{
return (await query.CountAsync(token)).CompactCount();
}
catch
{
return string.Empty;
}
}
private async Task InitializeAsync(CancellationToken token)
{
await using var context = this.CreateContext();
@ -365,14 +549,12 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
private static void ApplyDataSource(
EmbeddingStateDataSourceEntity dataSource,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
string sourceHash,
int vectorSize)
{
dataSource.DataSourceName = dataSourceName;
dataSource.DataSourceType = dataSourceType;
dataSource.EmbeddingProviderId = embeddingProviderId;
dataSource.EmbeddingSignature = embeddingSignature;
@ -394,8 +576,6 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
fileEntity.LastWriteUtc = file.LastWriteUtc;
fileEntity.EmbeddedAtUtc = file.EmbeddedAtUtc;
fileEntity.ChunkCount = file.ChunkCount;
fileEntity.ConfidenceLevel = file.ConfidenceLevel;
fileEntity.ConfidenceLevelRank = file.ConfidenceLevelRank;
}
private static void ApplyPermanentFailure(IndexingFailureEntity failureEntity, string dataSourceId, PermanentIndexingFailure failure)
@ -430,7 +610,6 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
result.ChunkId,
result.ParentFileId,
result.DataSourceId,
result.DataSourceName,
result.DataSourceType,
result.AbsolutePath,
result.FileName,
@ -445,9 +624,7 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
result.CreationUtc,
result.LastWriteUtc,
result.EmbeddedAtUtc,
result.ChunkCount,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
result.ChunkCount);
private static string BuildFtsQuery(string query)
{

View File

@ -0,0 +1,88 @@
using System.Runtime.InteropServices;
namespace AIStudio.Tools.Databases.IndexStore;
/// <summary>
/// What the running process can tell about the SQLite library it has loaded.
/// </summary>
/// <remarks>
/// These are methods instead of static fields on purpose: SQLitePCL.Batteries_V2.Init() runs when the
/// index store client is created, and a type initializer could well run before that. Every member
/// answers with an empty string when it cannot tell, so a single unavailable detail never costs the
/// caller the rest of them.
/// </remarks>
internal static class SqliteRuntimeInfo
{
/// <summary>
/// The name of the native library SQLitePCLRaw has bound to.
/// </summary>
/// <remarks>
/// We ship our own build through the bundle_e_sqlite3 package, so this reads e_sqlite3 on every
/// platform. Anything else means the process bound to a different library than we shipped, which
/// is exactly the kind of thing a support case needs to show.
/// </remarks>
public static string GetNativeLibraryName()
{
try
{
return SQLitePCL.raw.GetNativeLibraryName();
}
catch
{
return string.Empty;
}
}
/// <summary>
/// The version of the managed SQLitePCLRaw wrapper, which is a different thing than the SQLite version.
/// </summary>
public static string GetWrapperVersion()
{
try
{
// Read the assembly name rather than an attribute: reflecting over members would not
// survive trimming, the name does.
var wrapperVersion = typeof(SQLitePCL.raw).Assembly.GetName().Version;
return wrapperVersion is null ? string.Empty : $"SQLitePCLRaw.core {wrapperVersion.ToString(3)}";
}
catch
{
return string.Empty;
}
}
/// <summary>
/// The architecture this process runs as, together with the runtime identifier it was built for.
/// </summary>
public static string GetProcessArchitecture()
{
try
{
return $"{RuntimeInformation.ProcessArchitecture} ({RuntimeInformation.RuntimeIdentifier})";
}
catch
{
return string.Empty;
}
}
/// <summary>
/// The architecture of the machine, but only when it differs from the one of the process.
/// </summary>
/// <remarks>
/// A difference means the process runs through an emulation layer, Rosetta above all. That is a
/// classic reason for a native library failing to load, so it earns its own line when it happens
/// and stays out of the way when it does not.
/// </remarks>
public static string GetSystemArchitecture()
{
try
{
return RuntimeInformation.OSArchitecture == RuntimeInformation.ProcessArchitecture ? string.Empty : RuntimeInformation.OSArchitecture.ToString();
}
catch
{
return string.Empty;
}
}
}

View File

@ -1,15 +1,24 @@
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases.VectorStore;
/// <param name="indexStoreAccessor">
/// Resolves the index store, which is where the number of stored vectors comes from. Counting the
/// points in Qdrant Edge itself would have to load every shard first and would hold the global
/// database mutex against ongoing inserts and searches. The accessor is only called while building
/// the display info, never while this client is created: creating it already holds the vector store
/// lock, and the accessor takes the index store lock, so the two are never held at the same time.
/// </param>
public sealed class QdrantEdgeClientImplementation(
string name,
string path,
string version,
int storesCount,
RustService rustService) : VectorStoreClient(name, path)
RustService rustService,
Func<CancellationToken, Task<IndexStoreClient>> indexStoreAccessor) : VectorStoreClient(name, path)
{
private const string DATABASE_NAME = "Qdrant Edge";
private const string INFO_PATH = "/system/qdrant-edge/info";
@ -28,6 +37,7 @@ public sealed class QdrantEdgeClientImplementation(
public static async Task<DatabaseClient> CreateAsync(
RustService rustService,
Func<CancellationToken, Task<IndexStoreClient>> indexStoreAccessor,
ILogger logger,
ILogger<DatabaseClient> databaseClientLogger,
CancellationToken cancellationToken)
@ -60,7 +70,7 @@ public sealed class QdrantEdgeClientImplementation(
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);
var client = new QdrantEdgeClientImplementation(name, qdrantEdgeInfo.Path, qdrantEdgeInfo.Version, qdrantEdgeInfo.StoresCount, rustService, indexStoreAccessor);
client.SetLogger(databaseClientLogger);
return client;
}
@ -77,9 +87,35 @@ public sealed class QdrantEdgeClientImplementation(
if (!currentInfo.IsAvailable)
yield return (TB("Status"), currentInfo.UnavailableReason ?? TB("Qdrant Edge is not available."));
var storedVectors = await this.GetStoredVectorCountAsync();
yield return (TB("Reported version"), displayVersion);
yield return (TB("Storage size"), $"{this.GetStorageSize()}");
yield return (TB("Number of vector stores"), displayStoresCount.ToString());
yield return (TB("Number of vector stores"), displayStoresCount.CompactCount());
yield return (TB("Stored vectors"), storedVectors?.CompactCount() ?? TB("unknown"));
}
/// <summary>
/// Reads how many vectors the vector stores hold in total.
/// </summary>
/// <returns>The number of vectors, or null when the index store cannot tell.</returns>
private async Task<long?> GetStoredVectorCountAsync()
{
try
{
//
// One chunk is one vector: every chunk becomes exactly one point carrying the single
// named vector "embedding". So the index store knows this number without Qdrant Edge
// having to load a single shard for it.
//
var indexStore = await indexStoreAccessor(CancellationToken.None);
return await indexStore.GetTotalChunkCountAsync(CancellationToken.None);
}
catch (Exception exception)
{
this.Logger?.LogWarning(exception, "Failed to read the number of stored vectors from the index store.");
return null;
}
}
public override async Task<VectorStoreEnsureResult> EnsureVectorStoreExists(string storeName, string dataSourceName, int vectorSize, CancellationToken token) =>

View File

@ -4,7 +4,6 @@ public sealed record VectorSearchResult(
string PointId,
double Score,
string DataSourceId,
string DataSourceName,
string DataSourceType,
string ChunkId,
string ParentFileId,
@ -19,6 +18,4 @@ public sealed record VectorSearchResult(
string Fingerprint,
string CreationUtc,
string LastWriteUtc,
string EmbeddedAtUtc,
string ConfidenceLevel,
int ConfidenceLevelRank);
string EmbeddedAtUtc);

View File

@ -4,7 +4,6 @@ public sealed record VectorStoragePoint(
string PointId,
IReadOnlyList<float> Vector,
string DataSourceId,
string DataSourceName,
string DataSourceType,
string ChunkId,
string ParentFileId,
@ -19,6 +18,4 @@ public sealed record VectorStoragePoint(
string Fingerprint,
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
string ConfidenceLevel,
int ConfidenceLevelRank);
DateTimeOffset EmbeddedAtUtc);

View File

@ -0,0 +1,13 @@
namespace AIStudio.Tools.Databases.VectorStore;
/// <summary>
/// Thrown when a vector store is there on disk, but cannot be opened.
/// </summary>
/// <remarks>
/// Separate from every other database failure, because it is the one which no retry heals and which
/// the app must not heal on its own: building the index anew sends every document to the embedding
/// provider once more, which costs real money and, for a large data source, hours. So this failure
/// travels as its own type up to the places which can say so and offer the rebuild, and the decision
/// stays with the user.
/// </remarks>
public sealed class VectorStoreUnreadableException(string message) : Exception(message);

View File

@ -10,6 +10,7 @@ public sealed class DocumentManager
{
private StringBuilder? currentPageContent;
private int? currentPageTokenCount;
private int? currentPageNumber;
public ContentStreamPendingContent? AddPage(ContentStreamDocumentMetadata metadata, string? content, int? tokenCount, bool extractImages)
{
@ -36,9 +37,12 @@ public sealed class DocumentManager
//
// The count waits here together with the page it belongs to. Handing it out along with
// the page we just completed would size that page by the text of this one.
// the page we just completed would size that page by the text of this one. The page
// number waits for the same reason: it belongs to the page being buffered, not to the
// one leaving here.
//
this.currentPageTokenCount = tokenCount;
this.currentPageNumber = pageNumber;
return completedPage;
}
@ -72,8 +76,10 @@ public sealed class DocumentManager
var result = this.currentPageContent.ToString();
var tokenCount = this.currentPageTokenCount;
var pageNumber = this.currentPageNumber;
this.currentPageContent = null;
this.currentPageTokenCount = null;
return string.IsNullOrWhiteSpace(result) ? null : new ContentStreamPendingContent(result, tokenCount);
this.currentPageNumber = null;
return string.IsNullOrWhiteSpace(result) ? null : new ContentStreamPendingContent(result, tokenCount, pageNumber);
}
}

View File

@ -204,6 +204,26 @@ public static class FileExportFormatExtensions
_ => WITHOUT_BYTE_ORDER_MARK,
};
/// <summary>
/// Determines whether a link into a local file may name the page it points at.
/// </summary>
/// <remarks>
/// A page is named by the fragment of the link, the way the PDF open parameters call for. A
/// browser and a PDF reader follow that and open the document on the page; Word and LibreOffice
/// take the fragment for part of the file name, look for a file which does not exist, and refuse
/// the link altogether. There the page is dropped, so the link at least opens the document --
/// which page it was stays in the title of the source. Verified on 2026-09-15 with LibreOffice
/// on an exported .odt. A format added later keeps the page unless it is known to stumble too.
/// </remarks>
/// <param name="format">The format.</param>
/// <returns>True, when a reader of this format follows such a link.</returns>
public static bool FollowsPageAnchors(this FileExportFormat format) => format switch
{
FileExportFormat.MICROSOFT_WORD or FileExportFormat.OPEN_DOCUMENT_TEXT => false,
_ => true,
};
/// <summary>
/// Returns the name Pandoc knows the format by.
/// </summary>

View File

@ -1,7 +1,46 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools;
public static class LongExtensions
{
private static readonly string[] COUNT_SUFFIXES = ["k", "M", "B", "T"];
/// <summary>
/// Formats a count so that large numbers stay readable: 1456 becomes 1.46k, 4512900 becomes 4.51M.
/// </summary>
/// <remarks>
/// Counts scale in thousands, not in steps of 1024 — that is what FileSize is for, and storage
/// sizes keep using it. Numbers below 1000 stay exact, because shortening them would hide the
/// difference between 4 and 999. The decimal separator follows the culture of the active language
/// plugin rather than the one of the thread, which never moves along with the app's language.
/// </remarks>
/// <param name="count">The number to format.</param>
/// <returns>The formatted number.</returns>
public static string CompactCount(this long count)
{
var culture = I18N.I.Culture;
if (count is > -1_000 and < 1_000)
return count.ToString("N0", culture);
var order = -1;
double value = count;
while (Math.Abs(value) >= 1_000 && order < COUNT_SUFFIXES.Length - 1)
{
order++;
value /= 1_000;
}
return $"{value.ToString("0.##", culture)}{COUNT_SUFFIXES[order]}";
}
/// <summary>
/// Formats a count so that large numbers stay readable.
/// </summary>
/// <param name="count">The number to format.</param>
/// <returns>The formatted number.</returns>
public static string CompactCount(this int count) => ((long)count).CompactCount();
/// <summary>
/// Formats the file size in a human-readable format.
/// </summary>

View File

@ -0,0 +1,13 @@
namespace AIStudio.Tools;
/// <summary>
/// A source together with the number it is listed under.
/// </summary>
/// <remarks>
/// The number runs through the whole list rather than starting over per group, because that is how
/// an answer refers to a source. It is assigned once, where the groups are formed, so the chat and
/// an exported document cannot end up numbering the same list differently.
/// </remarks>
/// <param name="Number">The number this source is listed under, counted from one.</param>
/// <param name="Source">The source itself.</param>
public readonly record struct NumberedSource(int Number, Source Source);

View File

@ -118,7 +118,7 @@ public static class PandocExport
// We read the text before we ask for a path: when there is nothing to convert, the user
// should learn that right away instead of picking a file first and getting an error afterwards.
//
if (!markdownContent.TryGetExportMarkdown(out var markdownText))
if (!markdownContent.TryGetExportMarkdown(out var markdownText, format.FollowsPageAnchors()))
{
LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported.")));

View File

@ -9,6 +9,35 @@ public static class IRetrievalContextExtensions
{
private static readonly ILogger<IRetrievalContext> LOGGER = Program.LOGGER_FACTORY.CreateLogger<IRetrievalContext>();
/// <summary>
/// Writes what the AI is told about a retrieval context, before its content follows.
/// </summary>
/// <remarks>
/// The location is what lets the AI say where an answer comes from. Naming only the file is
/// not enough in a document of two hundred pages, and we know the page: it travels from the
/// runtime through the index into the context. A slide or a sheet has no page, and then
/// nothing is claimed rather than something made up.
/// </remarks>
/// <param name="contextBuilder">The builder to write into.</param>
/// <param name="retrievalContext">The context to describe.</param>
internal static void AppendContextDescription(StringBuilder contextBuilder, IRetrievalContext retrievalContext)
{
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
contextBuilder.AppendLine($"Content category: {retrievalContext.Category}");
contextBuilder.AppendLine($"Content type: {retrievalContext.Type}");
contextBuilder.AppendLine($"Content path: {retrievalContext.Path}");
if(retrievalContext is RetrievalTextContext { PageNumber: > 0 } locatedContext)
contextBuilder.AppendLine($"Content location: page {locatedContext.PageNumber}");
if(retrievalContext.Links.Count is 0)
return;
contextBuilder.AppendLine("Additional links:");
foreach(var link in retrievalContext.Links)
contextBuilder.AppendLine($"- {link}");
}
public static async Task<string> AsMarkdown(this IReadOnlyList<IRetrievalContext> retrievalContexts, StringBuilder? sb = null, CancellationToken token = default)
{
sb ??= new StringBuilder();
@ -49,17 +78,7 @@ public static class IRetrievalContextExtensions
break;
}
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
contextBuilder.AppendLine($"Content category: {retrievalContext.Category}");
contextBuilder.AppendLine($"Content type: {retrievalContext.Type}");
contextBuilder.AppendLine($"Content path: {retrievalContext.Path}");
if(retrievalContext.Links.Count > 0)
{
contextBuilder.AppendLine("Additional links:");
foreach(var link in retrievalContext.Links)
contextBuilder.AppendLine($"- {link}");
}
AppendContextDescription(contextBuilder, retrievalContext);
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path);

View File

@ -50,4 +50,14 @@ public sealed class RetrievalTextContext : IRetrievalContext
/// Optional link used when this context is displayed as a source reference.
/// </summary>
public string ReferenceLink { get; init; } = string.Empty;
/// <summary>
/// The page this passage was found on, or null when it has none.
/// </summary>
/// <remarks>
/// Kept as a number rather than only inside the reference title: the AI is told the page so it
/// can say where an answer comes from, and a source has to name a page a program can be sent
/// to. A slide or a sheet has no page and leaves this empty.
/// </remarks>
public int? PageNumber { get; init; }
}

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Asks the runtime to open a document in the program the system uses for it.
/// </summary>
/// <param name="Path">The document to open.</param>
/// <param name="Page">The page to show, counted from one, or null when the document has none.</param>
public readonly record struct OpenDocumentRequest(string Path, int? Page);

View File

@ -0,0 +1,14 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Says how opening a document went.
/// </summary>
/// <param name="Success">Whether the document was opened at all.</param>
/// <param name="PageApplied">
/// Whether the document was handed to its program together with the page. False means it opens on
/// its first page: no page was asked for, the system uses a program which cannot be told one, or
/// starting that program failed. None of these is an error, so this belongs in the log rather than
/// in front of the user, who is told the page by the source itself.
/// </param>
/// <param name="Issue">Why the document could not be opened, or an empty text when it was.</param>
public readonly record struct OpenDocumentResponse(bool Success, bool PageApplied, string Issue);

View File

@ -1,3 +1,9 @@
namespace AIStudio.Tools.Services;
public sealed record ArbitraryFileDataSegment(string Content, int TokenCount);
/// <summary>
/// One piece of an extracted file, as the runtime delivered it.
/// </summary>
/// <param name="Content">The extracted text.</param>
/// <param name="TokenCount">The number of tokens of that text.</param>
/// <param name="PageNumber">The page that text came from, or null when it has none. Presentations and spreadsheets have none.</param>
public sealed record ArbitraryFileDataSegment(string Content, int TokenCount, int? PageNumber);

View File

@ -2,7 +2,6 @@ using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases.IndexStore;
@ -16,6 +15,22 @@ public sealed partial class DataSourceEmbeddingService
internal const int DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH = 300;
private const bool IMAGE_EMBEDDING_ENABLED = false;
/// <summary>
/// What this build writes next to a chunk besides its text. Raise it whenever that changes.
/// </summary>
/// <remarks>
/// A stored chunk keeps the metadata of the run which wrote it, and nothing recomputes it: the
/// fingerprint of a file says whether the file changed, not whether we got better at reading
/// it. Raising this number makes the embedding signature differ, which drops the index and
/// builds it again — the only way corrected page numbers reach a data source somebody indexed
/// earlier.
///
/// Version 2: the page of a chunk is taken from the runtime metadata instead of being read back
/// out of the chunk text, which is what left Word and OpenDocument files, and passages
/// continuing across a page break, without a page.
/// </remarks>
private const string CHUNK_METADATA_VERSION = "2";
private enum RagFileIndexingDecision
{
INDEXABLE,
@ -23,13 +38,26 @@ public sealed partial class DataSourceEmbeddingService
UNSUPPORTED,
}
private sealed record ExtractedFileSegment(string Text, int? TokenCount);
private sealed record ExtractedFileSegment(string Text, int? TokenCount, int? PageNumber);
private sealed record ExtractedFileContent(string Text, IReadOnlyList<ExtractedFileSegment> SourceSegments);
/// <summary>
/// One chunk as the chunking produced it, together with the page it starts on.
/// </summary>
/// <remarks>
/// The page is carried rather than read back out of the chunk text. The runtime states it, and
/// the chunking knows which source segment a chunk begins in, so nothing has to be derived from
/// a marker in the text — which is what used to leave Word files and continued passages without
/// a page.
/// </remarks>
/// <param name="Text">The chunk itself, overlap prefix included.</param>
/// <param name="PageNumber">The page the chunk's own content starts on, or null when it has none.</param>
private sealed record EmbeddingChunk(string Text, int? PageNumber);
private sealed record EmbeddingChunkDraft(string ChunkId, string Text, int ChunkIndex, int? PageNumber);
private sealed record ChunkingOptions(int MaxChunkTokenLength, int OverlapTokenLength);
internal sealed record ChunkingOptions(int MaxChunkTokenLength, int OverlapTokenLength);
private sealed record ChunkingStrategy(string Name, IReadOnlyList<ChunkingRule> Rules);
@ -37,7 +65,7 @@ public sealed partial class DataSourceEmbeddingService
private sealed record DataSourceMetadataSnapshot(string SourceHash, IReadOnlyDictionary<string, string> FileHashes);
private async IAsyncEnumerable<string> StreamEmbeddingChunksAsync(string filePath, IDataSource dataSource, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
private async IAsyncEnumerable<EmbeddingChunk> StreamEmbeddingChunksAsync(string filePath, IDataSource dataSource, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
{
var options = this.GetChunkingOptions(dataSource, embeddingProvider);
var strategy = this.GetChunkingStrategy(filePath);
@ -55,26 +83,31 @@ public sealed partial class DataSourceEmbeddingService
{
var normalized = NormalizeChunkSegment(segment.Content);
if (!string.IsNullOrWhiteSpace(normalized))
segments.Add(new(normalized, segment.TokenCount));
segments.Add(new(normalized, segment.TokenCount, segment.PageNumber));
}
return new(string.Join("\n", segments.Select(segment => segment.Text)).Trim(), segments);
}
private async IAsyncEnumerable<string> SplitByChunkingStrategyAsync(ExtractedFileContent content, ChunkingStrategy strategy, ChunkingOptions options, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
private async IAsyncEnumerable<EmbeddingChunk> SplitByChunkingStrategyAsync(ExtractedFileContent content, ChunkingStrategy strategy, ChunkingOptions options, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
{
var estimatedTokenCount = SumTokenCounts(content.SourceSegments);
await foreach (var chunk in this.SplitTextByRulesAsync(content.Text, content.SourceSegments, strategy, 0, options, embeddingProvider, token, estimatedTokenCount: estimatedTokenCount))
// The whole text starts where the first segment starts, so that is the page it is on until
// the splitting reaches a segment boundary:
var firstPageNumber = content.SourceSegments.Count > 0 ? content.SourceSegments[0].PageNumber : null;
await foreach (var chunk in this.SplitTextByRulesAsync(content.Text, content.SourceSegments, strategy, 0, options, embeddingProvider, firstPageNumber, token, estimatedTokenCount: estimatedTokenCount))
yield return chunk;
}
private async IAsyncEnumerable<string> SplitTextByRulesAsync(
private async IAsyncEnumerable<EmbeddingChunk> SplitTextByRulesAsync(
string text,
IReadOnlyList<ExtractedFileSegment> sourceSegments,
ChunkingStrategy strategy,
int ruleIndex,
ChunkingOptions options,
EmbeddingProvider embeddingProvider,
int? currentPageNumber,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token,
string requiredOverlapPrefix = "",
int? estimatedTokenCount = null)
@ -91,14 +124,14 @@ public sealed partial class DataSourceEmbeddingService
tokenCount = await this.GetEmbeddingTokenCountAsync(embeddingProvider, textWithOverlap, token);
if (tokenCount <= options.MaxChunkTokenLength)
{
yield return textWithOverlap;
yield return new(textWithOverlap, currentPageNumber);
yield break;
}
}
if (ruleIndex >= strategy.Rules.Count)
{
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, token, requiredOverlapPrefix, estimatedTokenCount))
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, currentPageNumber, token, requiredOverlapPrefix, estimatedTokenCount))
yield return hardChunk;
yield break;
@ -107,7 +140,7 @@ public sealed partial class DataSourceEmbeddingService
var rule = strategy.Rules[ruleIndex];
if (rule.Split is null)
{
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, token, requiredOverlapPrefix, estimatedTokenCount))
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, currentPageNumber, token, requiredOverlapPrefix, estimatedTokenCount))
yield return hardChunk;
yield break;
@ -116,7 +149,7 @@ public sealed partial class DataSourceEmbeddingService
var units = NormalizeSplitUnits(rule.Split(text, sourceSegments.Select(segment => segment.Text).ToList()), text);
if (units.Count <= 1)
{
await foreach (var chunk in this.SplitTextByRulesAsync(text, sourceSegments, strategy, ruleIndex + 1, options, embeddingProvider, token, requiredOverlapPrefix, estimatedTokenCount))
await foreach (var chunk in this.SplitTextByRulesAsync(text, sourceSegments, strategy, ruleIndex + 1, options, embeddingProvider, currentPageNumber, token, requiredOverlapPrefix, estimatedTokenCount))
yield return chunk;
yield break;
@ -135,6 +168,15 @@ public sealed partial class DataSourceEmbeddingService
var overlapPrefix = requiredOverlapPrefix;
var unitTokenCounts = EstimateSplitUnitTokenCounts(units, sourceSegments, rule.UsesSourceSegmentCounts, estimatedTokenCount);
//
// The first rule of every strategy cuts along the segments the runtime delivered, so there
// a unit is a segment and carries that segment's page. Every later rule cuts inside a
// single segment, where all units share the page they were handed. This is what ties a
// chunk to a page without anybody reading the text.
//
var unitsAreSourceSegments = rule.UsesSourceSegmentCounts && sourceSegments.Count == units.Count;
int? PageOfUnit(int unitIndex) => unitsAreSourceSegments ? sourceSegments[unitIndex].PageNumber ?? currentPageNumber : currentPageNumber;
while (index < units.Count)
{
token.ThrowIfCancellationRequested();
@ -145,8 +187,14 @@ public sealed partial class DataSourceEmbeddingService
var rawChunk = string.Concat(units.Skip(index).Take(unitCount)).Trim();
var chunk = AddOverlapPrefix(rawChunk, overlapPrefix);
overlapPrefix = string.Empty;
//
// The page of the first unit this chunk covers, not of the overlap prefix in front
// of it: the prefix repeats what the chunk before already said, while the page has
// to name where this chunk's own content begins.
//
if (!string.IsNullOrWhiteSpace(chunk))
yield return chunk;
yield return new(chunk, PageOfUnit(index));
var nextIndex = index + unitCount;
if (nextIndex >= units.Count)
@ -178,9 +226,10 @@ public sealed partial class DataSourceEmbeddingService
string? lastSplitUnit = null;
var unitTokenCount = unitTokenCounts?[index];
await foreach (var splitUnit in this.SplitTextByRulesAsync(units[index], [new(units[index], unitTokenCount)], strategy, ruleIndex + 1, options, embeddingProvider, token, overlapPrefix, unitTokenCount))
var unitPageNumber = PageOfUnit(index);
await foreach (var splitUnit in this.SplitTextByRulesAsync(units[index], [new(units[index], unitTokenCount, unitPageNumber)], strategy, ruleIndex + 1, options, embeddingProvider, unitPageNumber, token, overlapPrefix, unitTokenCount))
{
lastSplitUnit = splitUnit;
lastSplitUnit = splitUnit.Text;
yield return splitUnit;
}
@ -372,10 +421,15 @@ public sealed partial class DataSourceEmbeddingService
return bestStartIndex <= chunkStartIndex ? chunkEndIndex : bestStartIndex;
}
private async IAsyncEnumerable<string> SplitTextByHardCutAsync(
/// <remarks>
/// The hard cut is only ever reached inside a single piece of text which no rule could split
/// any further, so every chunk it produces sits on the page that piece was handed.
/// </remarks>
private async IAsyncEnumerable<EmbeddingChunk> SplitTextByHardCutAsync(
string text,
ChunkingOptions options,
EmbeddingProvider embeddingProvider,
int? currentPageNumber,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token,
string requiredOverlapPrefix = "",
int? estimatedTokenCount = null)
@ -455,7 +509,7 @@ public sealed partial class DataSourceEmbeddingService
var chunk = AddOverlapPrefix(text[startIndex..bestEndIndex].Trim(), overlapPrefix);
if (!string.IsNullOrWhiteSpace(chunk))
yield return chunk;
yield return new(chunk, currentPageNumber);
if (bestEndIndex >= text.Length)
yield break;
@ -931,9 +985,26 @@ public sealed partial class DataSourceEmbeddingService
}
}
private string BuildEmbeddingSignature(IDataSource dataSource, EmbeddingProvider embeddingProvider, ChunkingOptions chunkingOptions)
/// <summary>
/// Describes how the vectors of a data source were made.
/// </summary>
/// <remarks>
/// What appears here decides when stored embeddings are thrown away: a signature differing from
/// the persisted one drops the whole index and builds it again. So it names the embedding model,
/// where it runs, how the text was cut for it, and the chunk metadata version — the things a
/// vector actually depends on.
///
/// The confidence level a data source asks of a provider is deliberately not among them. It
/// changes no vector, and it is enforced live on every request anyway: DataSourceService checks
/// it against the participating chat providers and against the embedding provider, and this
/// service checks it again before each indexing run. It was part of this signature once, which
/// re-embedded every file of a data source whenever somebody raised or lowered it — real money
/// at a cloud embedding provider, for nothing.
/// </remarks>
internal static string BuildEmbeddingSignature(IDataSource dataSource, EmbeddingProvider embeddingProvider, ChunkingOptions chunkingOptions)
{
return string.Join('|',
CHUNK_METADATA_VERSION,
embeddingProvider.Id,
embeddingProvider.UsedLLMProvider,
embeddingProvider.Model.Id,
@ -941,7 +1012,6 @@ public sealed partial class DataSourceEmbeddingService
embeddingProvider.Hostname,
embeddingProvider.TokenizerPath,
embeddingProvider.EffectiveTokenLimit,
GetDataSourceConfidenceLevel(dataSource).ToString(),
dataSource is IInternalDataSource internalDataSource ? internalDataSource.MaxChunkTokenLength : 0,
dataSource is IInternalDataSource overlapDataSource ? overlapDataSource.ChunkOverlapTokenLength : DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH,
chunkingOptions.MaxChunkTokenLength,
@ -1045,7 +1115,6 @@ public sealed partial class DataSourceEmbeddingService
{
file.Refresh();
var absolutePath = Path.GetFullPath(file.FullName);
var confidenceLevel = GetDataSourceConfidenceLevel(dataSource);
return new(
this.CreateParentFileId(dataSource.Id, absolutePath),
absolutePath,
@ -1057,9 +1126,7 @@ public sealed partial class DataSourceEmbeddingService
file.Exists ? new DateTimeOffset(file.CreationTimeUtc) : DateTimeOffset.UnixEpoch,
file.Exists ? new DateTimeOffset(file.LastWriteTimeUtc) : DateTimeOffset.UnixEpoch,
embeddedAtUtc,
chunkCount,
confidenceLevel.ToString(),
(int)confidenceLevel);
chunkCount);
}
private IReadOnlyList<EmbeddingStateChunk> CreateEmbeddingStateChunks(EmbeddingStateFile parentFile, IReadOnlyList<EmbeddingChunkDraft> batch, DateTimeOffset embeddedAtUtc)
@ -1075,25 +1142,12 @@ public sealed partial class DataSourceEmbeddingService
.ToList();
}
private static ConfidenceLevel GetDataSourceConfidenceLevel(IDataSource dataSource) =>
dataSource is not IInternalDataSource internalDataSource || internalDataSource.ConfidenceLevel is ConfidenceLevel.NONE
? ConfidenceLevel.UNKNOWN
: internalDataSource.ConfidenceLevel;
private static string GetFileType(FileInfo file)
{
var extension = file.Extension.TrimStart('.').ToLowerInvariant();
return string.IsNullOrWhiteSpace(extension) ? "unknown" : extension;
}
private static int? TryExtractPageNumber(string chunk)
{
var match = Regex.Match(chunk, @"^\s*#\s+Page\s+(\d+)\b", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
return match.Success && int.TryParse(match.Groups[1].Value, out var pageNumber) && pageNumber > 0
? pageNumber
: null;
}
private string CreatePointId(string dataSourceId, string fingerprint, int chunkIndex) =>
CreateStableGuid($"{dataSourceId}:chunk:{fingerprint}:{chunkIndex}");

View File

@ -5,6 +5,43 @@ namespace AIStudio.Tools.Services;
public sealed partial class DataSourceEmbeddingService
{
/// <summary>
/// Throws away everything stored for one data source and starts a fresh indexing run.
/// </summary>
/// <remarks>
/// The one way out of an index which cannot be read, and nothing in the app takes it by itself:
/// a rebuild sends every document of the data source to the embedding provider once more, which
/// costs money with a cloud provider and hours with a large data source. It happens because the
/// user asked for it, after being told both.
///
/// An active run is stopped first, the same way deleting a data source does it. The repair is
/// offered for a failed data source only, so there should be none -- but a file watcher may
/// well have queued one between the click and this call, and discarding the index next to a
/// live run would leave it half thrown away.
/// </remarks>
/// <param name="dataSourceId">The data source to build anew.</param>
public async Task RepairDataSourceAsync(string dataSourceId)
{
if (!this.TryGetConfiguredDataSource(dataSourceId, out var dataSource) || !this.IsSupportedInternalDataSource(dataSource))
return;
logger.LogWarning(
"Repairing data source '{DataSourceName}' ({DataSourceId}) on the user's request: the stored index is discarded and built anew.",
dataSource.Name,
dataSource.Id);
var activeRun = this.CancelActiveDataSourceRun(dataSource);
this.ClearQueuedDataSourceState(dataSourceId);
if (activeRun is not null)
await activeRun.Completion.Task;
await this.ResetPersistedStateAsync(dataSourceId, null, null, CancellationToken.None);
this.statuses.TryRemove(dataSourceId, out _);
this.PublishStatusChanged();
await this.QueueDataSourceAsync(dataSource, true, DataSourceEmbeddingRefreshMode.MANUAL_RETRY);
}
private async Task ResetPersistedStateAsync(
string dataSourceId,
VectorStoreClient? vectorStore,

View File

@ -18,6 +18,20 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
{
private const int VECTOR_STORE_OPTIMIZATION_CHUNK_THRESHOLD = 100_000;
/// <summary>
/// How often the block progress within one file is reported to the user interface at most.
/// </summary>
private static readonly TimeSpan BLOCK_PROGRESS_INTERVAL = TimeSpan.FromSeconds(3);
/// <summary>
/// How long the re-index check waits for the index database before it gives up.
/// </summary>
/// <remarks>
/// Asked while somebody waits for the data source selection to open, and possibly while a run
/// writes to the same database.
/// </remarks>
private static readonly TimeSpan REINDEX_CHECK_TIMEOUT = TimeSpan.FromSeconds(2);
private readonly Channel<DataSourceEmbeddingQueueItem> queue = Channel.CreateUnbounded<DataSourceEmbeddingQueueItem>();
private readonly ConcurrentDictionary<string, byte> queuedIds = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> runningIds = new(StringComparer.OrdinalIgnoreCase);
@ -206,6 +220,119 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
|| manifest.PermanentFailures.Count > 0;
}
/// <summary>
/// Whether a data source cannot answer a search right now because its index has to be built anew.
/// </summary>
/// <remarks>
/// Says nothing about a data source which is only catching up with a handful of changed files:
/// everything indexed before is still there and still searchable. What this catches is the case
/// where the whole index was thrown away, or is about to be, because the embedding configuration
/// changed under it. Between discarding the old vectors and finishing the new ones, the data
/// source looks perfectly fine and finds nothing.
///
/// Two things are asked, in this order. The stored signature tells whether the vectors still
/// belong to the current configuration; it is written back right after the reset, so on its own
/// it would call a rebuild in progress finished. The stored hash of the data source closes that
/// gap: it survives an ordinary run but not a reset, so an empty one means no run has completed
/// since the index was discarded.
///
/// Anything unclear counts as not waiting. Whoever asks does so to grey out a row, and a data
/// source wrongly greyed out for good is worse than one which turns out to have nothing to say.
/// </remarks>
/// <param name="dataSource">The data source to ask about.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>True when the data source is waiting for its index to be rebuilt.</returns>
public async Task<bool> IsAwaitingReindexAsync(IDataSource dataSource, CancellationToken token = default)
{
//
// This guard also keeps the index database out of the picture while local RAG is switched
// off: asking for the store creates the database and runs its migrations on the first call,
// which must not happen because somebody opened the data source selection.
//
if (!this.IsSupportedInternalDataSource(dataSource))
return false;
if (!this.TryResolveEmbeddingProvider(dataSource, out var embeddingProvider))
return false;
try
{
//
// A timeout of its own: this runs while the user waits for a popover to open, and the
// embedding service may be writing to the same database at the time.
//
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(REINDEX_CHECK_TIMEOUT);
var indexStore = await databaseClientProvider.GetIndexStoreAsync(timeout.Token);
if (!indexStore.IsAvailable)
return false;
var indexState = await indexStore.GetDataSourceStateAsync(dataSource.Id, timeout.Token);
var chunkingOptions = this.GetChunkingOptions(dataSource, embeddingProvider);
var embeddingSignature = BuildEmbeddingSignature(dataSource, embeddingProvider, chunkingOptions);
var runState = this.statuses.TryGetValue(dataSource.Id, out var status) ? status.State : (DataSourceEmbeddingState?)null;
return IsIndexAwaitingRebuild(indexState, embeddingSignature, runState);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not tell whether data source '{DataSourceName}' ({DataSourceId}) is waiting for a re-index. Treating it as usable.", dataSource.Name, dataSource.Id);
return false;
}
}
/// <summary>
/// Decides from the stored index state alone whether a data source has to be indexed anew.
/// </summary>
/// <remarks>
/// Kept apart from reading the database so the decision itself can be pinned down in a test.
/// The order of the three questions is what makes it correct, see IsAwaitingReindexAsync.
/// </remarks>
/// <param name="indexState">What the index holds about the data source, or null when it holds nothing.</param>
/// <param name="currentEmbeddingSignature">The signature the current embedding configuration produces.</param>
/// <param name="runState">The state of this data source's last or current run, when one is known.</param>
/// <returns>True when the data source is waiting for its index to be rebuilt.</returns>
internal static bool IsIndexAwaitingRebuild(DataSourceIndexState? indexState, string currentEmbeddingSignature, DataSourceEmbeddingState? runState)
{
// Nothing stored at all: this data source has never been indexed, so there is nothing to
// search in it yet.
if (indexState is null)
return true;
// The stored vectors belong to another embedding configuration. They will be thrown away
// as soon as the next run starts, and they are of no use before that either.
if (!string.Equals(indexState.EmbeddingSignature, currentEmbeddingSignature, StringComparison.Ordinal))
return true;
// A run has worked through the whole data source since the index was last discarded.
if (!string.IsNullOrWhiteSpace(indexState.SourceHash))
return false;
//
// The index was discarded and nothing has finished since. A failed run is the exception:
// whatever it managed to index is searchable, and the embeddings page already names the
// problem, so there is nothing to be gained from locking the row as well.
//
return runState is not DataSourceEmbeddingState.FAILED;
}
/// <summary>
/// Whether a data source cannot be searched because its vector store cannot be read anymore.
/// </summary>
/// <remarks>
/// Unlike the re-index check above, this reads no database at all: the state comes from the run
/// or the search which ran into the unreadable store, and is kept in memory only. That it does
/// not survive a restart is deliberate. The very same store may well open on the next start,
/// and a mark written to disk would then be wrong with nobody noticing. Until something touches
/// the store again, the data source counts as usable, and a failing search says so on its own.
/// </remarks>
/// <param name="dataSource">The data source to ask about.</param>
/// <returns>True when the data source waits for the user to have its index rebuilt.</returns>
public bool NeedsIndexRepair(IDataSource dataSource) =>
this.statuses.TryGetValue(dataSource.Id, out var status) &&
status is { State: DataSourceEmbeddingState.FAILED, VectorStoreUnreadable: true };
public Task QueueDataSourceAsync(IDataSource dataSource)
{
return this.QueueDataSourceAsync(dataSource, true, DataSourceEmbeddingRefreshMode.HASH_CHECK);
@ -319,6 +446,20 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
{
break;
}
catch (VectorStoreUnreadableException exception) when (dataSource is not null)
{
//
// Nothing is deleted and nothing is rebuilt here. The data source says what is
// wrong with it, stays out of the selection while it says so, and waits for the
// user to ask for the repair.
//
logger.LogError(
exception,
"The vector store of data source '{DataSourceName}' ({DataSourceId}) cannot be read. The data source is waiting for a repair.",
dataSource.Name,
dataSource.Id);
this.UpsertStatus(this.GetUnreadableVectorStoreStatus(dataSource));
}
catch (Exception exception)
{
if (dataSource is null)
@ -646,6 +787,13 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
this.UpsertStatus(this.CreateStatus(dataSource, DataSourceEmbeddingState.RUNNING, totalFiles, skippedFiles + completedFiles, failedFiles, file.Name, lastError, failureDetails, permanentlySkippedFiles));
//
// What the page says while one file is being worked on. Without it, a document of
// several thousand pages leaves the same sentence standing for hours, and a progress
// which never moves cannot be told apart from one which is stuck.
//
var lastBlockReportUtc = DateTimeOffset.MinValue;
try
{
logger.LogInformation(
@ -658,7 +806,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
skippedFiles + completedFiles + 1,
totalFiles);
var startedAtUtc = DateTimeOffset.UtcNow;
var chunkCount = await this.IndexOneFileAsync(indexStore, vectorStore, dataSource, file, fingerprint, embeddingProvider, provider, manifest, optimizationTracker, token);
var chunkCount = await this.IndexOneFileAsync(indexStore, vectorStore, dataSource, file, fingerprint, embeddingProvider, provider, manifest, optimizationTracker, ReportBlockProgress, token);
token.ThrowIfCancellationRequested();
var fingerprintAfterEmbedding = BuildFileMetadataHash(file);
if (!string.Equals(fingerprint, fingerprintAfterEmbedding, StringComparison.Ordinal))
@ -755,6 +903,15 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
ShortHash(fingerprint));
this.UpsertStatus(this.CreateStatus(dataSource, DataSourceEmbeddingState.RUNNING, totalFiles, skippedFiles + completedFiles, failedFiles, file.Name, lastError, failureDetails, permanentlySkippedFiles));
}
catch (VectorStoreUnreadableException)
{
//
// Not about this one file: the store of the whole data source cannot be opened, so
// every remaining file would fail the same way. Carrying on would fill the list
// with one entry per file and hide the single cause behind them.
//
throw;
}
catch (Exception exception)
{
//
@ -780,6 +937,24 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
logger.LogWarning(exception, "Failed to embed file '{FilePath}' for data source '{DataSourceName}'.", file.FullName, dataSource.Name);
this.UpsertStatus(this.CreateStatus(dataSource, DataSourceEmbeddingState.RUNNING, totalFiles, skippedFiles + completedFiles, failedFiles, file.Name, failureMessage, failureDetails, permanentlySkippedFiles));
}
continue;
void ReportBlockProgress(int blockNumber, int? pageNumber)
{
//
// The first block goes out at once, so the line is there instead of blank. After
// that, at most one message every BLOCK_PROGRESS_INTERVAL: each one re-renders the
// embedding page, the navigation bar and the table in the settings, and the blocks
// of a large file arrive far faster than anybody can read them.
//
var nowUtc = DateTimeOffset.UtcNow;
if (blockNumber > 1 && nowUtc - lastBlockReportUtc < BLOCK_PROGRESS_INTERVAL)
return;
lastBlockReportUtc = nowUtc;
this.UpsertStatus(this.CreateStatus(dataSource, DataSourceEmbeddingState.RUNNING, totalFiles, skippedFiles + completedFiles, failedFiles, file.Name, lastError, failureDetails, permanentlySkippedFiles, blockNumber, pageNumber));
}
}
manifest.SourceHash = metadataSnapshot.SourceHash;
@ -823,6 +998,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
IProvider provider,
DataSourceEmbeddingManifest manifest,
VectorStoreOptimizationTracker optimizationTracker,
Action<int, int?> reportBlockProgress,
CancellationToken token)
{
var collectionName = DataSourceEmbeddingNames.GetCollectionName(dataSource.Id);
@ -843,8 +1019,9 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
await foreach (var chunk in this.StreamEmbeddingChunksAsync(file.FullName, dataSource, embeddingProvider, token))
{
batch.Add(new(this.CreatePointId(dataSource.Id, fingerprint, totalChunkCount), chunk, totalChunkCount, TryExtractPageNumber(chunk)));
batch.Add(new(this.CreatePointId(dataSource.Id, fingerprint, totalChunkCount), chunk.Text, totalChunkCount, chunk.PageNumber));
totalChunkCount++;
reportBlockProgress(totalChunkCount, chunk.PageNumber);
if (batch.Count >= embeddingBatchSize)
await this.FlushBatchAsync(indexStore, vectorStore, dataSource, file, fingerprint, parentFile, embeddingProvider, provider, manifest, optimizationTracker, collectionName, batch, token);
@ -1015,7 +1192,6 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
item.ChunkId,
vectors[index],
dataSource.Id,
dataSource.Name,
dataSource.Type.ToString(),
item.ChunkId,
parentFile.ParentFileId,
@ -1030,9 +1206,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
fingerprint,
parentFile.CreationUtc,
parentFile.LastWriteUtc,
embeddedAtUtc,
parentFile.ConfidenceLevel,
parentFile.ConfidenceLevelRank)).ToList();
embeddedAtUtc)).ToList();
await vectorStore.InsertEmbedding(collectionName, points, token);
}
@ -1167,6 +1341,31 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
"Starting initial persisted hash check for {DataSourceCount} supported internal data source(s). Incomplete or failed local RAG embedding state will be retried during this pass. File watchers will be activated after this check completes.",
supportedDataSources.Count);
//
// Every data source gets its row before the first run starts. This pass works through them
// one after the other, and re-indexing a large source takes its time: without this, the
// embeddings page would show the one source being worked on and nothing else, which reads
// as if the others were gone rather than waiting their turn. The queueing path does the
// same thing when it reserves a slot, which is why it never had this problem.
//
foreach (var dataSource in supportedDataSources)
{
if (this.statuses.TryGetValue(dataSource.Id, out var knownStatus) && knownStatus.State is DataSourceEmbeddingState.RUNNING)
continue;
this.statuses[dataSource.Id] = this.CreateStatus(
dataSource,
DataSourceEmbeddingState.QUEUED,
knownStatus?.TotalFiles ?? 0,
knownStatus?.IndexedFiles ?? 0,
knownStatus?.FailedFiles ?? 0,
failures: knownStatus?.Failures ?? [],
permanentlySkippedFiles: knownStatus?.PermanentlySkippedFiles ?? 0);
}
// One message for the whole list, rather than one per data source:
this.PublishStatusChanged();
foreach (var dataSource in supportedDataSources)
{
token.ThrowIfCancellationRequested();
@ -1178,6 +1377,15 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
{
throw;
}
catch (VectorStoreUnreadableException exception)
{
logger.LogError(
exception,
"The vector store of data source '{DataSourceName}' ({DataSourceId}) cannot be read. The data source is waiting for a repair.",
dataSource.Name,
dataSource.Id);
this.UpsertStatus(this.GetUnreadableVectorStoreStatus(dataSource));
}
catch (Exception exception)
{
logger.LogError(exception, "Initial embedding hash check failed for data source '{DataSourceName}' ({DataSourceId}).", dataSource.Name, dataSource.Id);
@ -1237,7 +1445,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
CancellationToken token)
{
var chunkingOptions = this.GetChunkingOptions(dataSource, embeddingProvider);
var embeddingSignature = this.BuildEmbeddingSignature(dataSource, embeddingProvider, chunkingOptions);
var embeddingSignature = BuildEmbeddingSignature(dataSource, embeddingProvider, chunkingOptions);
var manifest = await indexStore.GetManifestAsync(dataSource.Id, token);
logger.LogInformation(
@ -1276,7 +1484,6 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
await indexStore.UpsertDataSourceAsync(
dataSource.Id,
dataSource.Name,
dataSource.Type.ToString(),
manifest.EmbeddingProviderId,
manifest.EmbeddingSignature,
@ -1422,7 +1629,10 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
string currentFile = "",
string lastError = "",
IReadOnlyList<DataSourceEmbeddingFailure>? failures = null,
int permanentlySkippedFiles = 0)
int permanentlySkippedFiles = 0,
int? currentFileBlock = null,
int? currentFilePage = null,
bool vectorStoreUnreadable = false)
{
return new DataSourceEmbeddingStatus(
dataSource.Id,
@ -1435,7 +1645,10 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
currentFile,
lastError,
failures?.ToList() ?? [],
permanentlySkippedFiles);
permanentlySkippedFiles,
currentFileBlock,
currentFilePage,
vectorStoreUnreadable);
}
/// <remarks>
@ -1472,6 +1685,25 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
failures: [new DataSourceEmbeddingFailure(dataSource.Name, errorMessage, DateTimeOffset.UtcNow)]);
}
/// <remarks>
/// Deliberately not the message which came from the runtime: that one names a store name and a
/// path, is written in English for the log file, and says nothing about what happens next. What
/// the user needs to read is what this means for their chats and where the way out is.
/// </remarks>
private DataSourceEmbeddingStatus GetUnreadableVectorStoreStatus(IDataSource dataSource)
{
var errorMessage = string.Format(TB("The index of the data source '{0}' cannot be read anymore. The data source stays out of your chats until its index was built anew. Use the repair action to start that."), dataSource.Name);
return this.CreateStatus(
dataSource,
DataSourceEmbeddingState.FAILED,
0,
0,
1,
lastError: errorMessage,
failures: [new DataSourceEmbeddingFailure(dataSource.Name, errorMessage, DateTimeOffset.UtcNow)],
vectorStoreUnreadable: true);
}
private DataSourceQueueRequestResult TryReserveDataSourceQueueSlot(string dataSourceId, bool queueAfterCurrentRun)
{
lock (this.queueStateLock)

View File

@ -3,6 +3,15 @@ using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Services;
/// <remarks>
/// CurrentFileBlock and CurrentFilePage are null rather than zero while nothing is known about
/// them: a file which is only about to start has no first block, and not every kind of document
/// has pages to count. Block numbers start at one, the way the page states them.
///
/// VectorStoreUnreadable says why a data source failed, not only that it did. The UI needs that
/// difference to offer the repair for this one case, and it is carried as its own flag so nothing
/// has to read it back out of the message in LastError.
/// </remarks>
public sealed record DataSourceEmbeddingStatus(
string DataSourceId,
string DataSourceName,
@ -14,7 +23,10 @@ public sealed record DataSourceEmbeddingStatus(
string CurrentFile,
string LastError,
IReadOnlyList<DataSourceEmbeddingFailure> Failures,
int PermanentlySkippedFiles = 0)
int PermanentlySkippedFiles = 0,
int? CurrentFileBlock = null,
int? CurrentFilePage = null,
bool VectorStoreUnreadable = false)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceEmbeddingStatus).Namespace, nameof(DataSourceEmbeddingStatus));

View File

@ -13,7 +13,7 @@ namespace AIStudio.Tools.Services;
public sealed class DataSourceLocalRetrievalService(
SettingsManager settingsManager, RustService rustService, DatabaseClientProvider databaseClientProvider,
ILogger<DataSourceLocalRetrievalService> logger)
DataSourceEmbeddingService embeddingService, ILogger<DataSourceLocalRetrievalService> logger)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceLocalRetrievalService).Namespace, nameof(DataSourceLocalRetrievalService));
@ -42,7 +42,6 @@ public sealed class DataSourceLocalRetrievalService(
string ChunkId,
string ParentFileId,
string DataSourceId,
string DataSourceName,
string DataSourceType,
string AbsolutePath,
string FileName,
@ -52,9 +51,7 @@ public sealed class DataSourceLocalRetrievalService(
int ChunkIndex,
string Text,
double Score,
int Rank,
string ConfidenceLevel,
int ConfidenceLevelRank);
int Rank);
// ReSharper restore NotAccessedPositionalProperty.Local
public Task<IReadOnlyList<IRetrievalContext>> RetrieveDataAsync(DataSourceLocalFile dataSource, IContent lastUserPrompt, ChatThread thread, CancellationToken token = default) =>
@ -76,6 +73,23 @@ public sealed class DataSourceLocalRetrievalService(
if (maxMatches == 0)
return [];
//
// A data source waiting for its index is kept out of the selection before the RAG process
// starts. This catches whatever reaches retrieval another way, and turns an answer quietly
// put together without the data into a sentence saying so.
//
// Asked here rather than inside one of the two channels below, because both of them read
// what the rebuild is about to discard: with only the embedding signature changed, the old
// chunks are still in place and the keyword search would happily answer from them while
// the vector search finds nothing.
//
if (await embeddingService.IsAwaitingReindexAsync(dataSource, token))
{
logger.LogWarning("Skipping local retrieval for data source '{DataSourceName}' ({DataSourceId}) because its index has to be built anew.", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, "index-rebuilding", string.Format(TB("The data source '{0}' was left out of the answer: it is being indexed again and cannot be searched until that is finished."), dataSource.Name));
return [];
}
var collectionName = DataSourceEmbeddingNames.GetCollectionName(dataSource.Id);
var vectorTask = this.SearchVectorAsync(dataSource, query, maxMatches, collectionName, token);
var bm25Task = this.SearchBm25Async(dataSource, query, maxMatches, token);
@ -95,7 +109,7 @@ public sealed class DataSourceLocalRetrievalService(
return hits
.Where(hit => !string.IsNullOrWhiteSpace(hit.Text))
.Select(ToRetrievalContext)
.Select(hit => ToRetrievalContext(hit, dataSource))
.ToList();
}
@ -166,6 +180,17 @@ public sealed class DataSourceLocalRetrievalService(
await this.ReportRetrievalGapAsync(dataSource, $"provider-{exception.FailureReason}", string.Format(TB("The data source '{0}' was left out of the answer. {1}"), dataSource.Name, exception.UserMessage));
return [];
}
catch (VectorStoreUnreadableException exception)
{
//
// Its own gap key, because this is not a search which went wrong but an index which has
// to be built anew. Saying that once per session is what turns a silently shortened
// answer into one the user can do something about.
//
logger.LogWarning(exception, "Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId}) because its vector store cannot be read.", dataSource.Name, dataSource.Id);
await this.ReportRetrievalGapAsync(dataSource, "vector-store-unreadable", string.Format(TB("The data source '{0}' was left out of the answer: its index cannot be read anymore. You can repair it in your data source settings."), dataSource.Name));
return [];
}
catch (Exception exception)
{
logger.LogWarning(exception, "Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId}).", dataSource.Name, dataSource.Id);
@ -344,7 +369,6 @@ public sealed class DataSourceLocalRetrievalService(
result.ChunkId,
result.ParentFileId,
result.DataSourceId,
result.DataSourceName,
result.DataSourceType,
FirstNonEmpty(result.AbsolutePath, result.FilePath),
result.FileName,
@ -354,9 +378,7 @@ public sealed class DataSourceLocalRetrievalService(
result.ChunkIndex,
result.Text,
result.Score,
rank,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
rank);
private static LocalRetrievalHit FromBm25Result(IndexStoreSearchResult result, int rank) =>
new(
@ -364,7 +386,6 @@ public sealed class DataSourceLocalRetrievalService(
result.ChunkId,
result.ParentFileId,
result.DataSourceId,
result.DataSourceName,
result.DataSourceType,
result.AbsolutePath,
result.FileName,
@ -374,13 +395,11 @@ public sealed class DataSourceLocalRetrievalService(
result.ChunkIndex,
result.ChunkText,
result.Score,
rank,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
rank);
private static RetrievalTextContext ToRetrievalContext(LocalRetrievalHit hit)
private static RetrievalTextContext ToRetrievalContext(LocalRetrievalHit hit, IInternalDataSource dataSource)
{
var sourceName = FirstNonEmpty(hit.FileName, hit.DataSourceName);
var sourceName = FirstNonEmpty(hit.FileName, dataSource.Name);
var path = FirstNonEmpty(hit.AbsolutePath, hit.RelativePath);
var referenceLink = string.IsNullOrWhiteSpace(path) ? string.Empty : BuildReferenceLink(path, hit);
@ -393,14 +412,15 @@ public sealed class DataSourceLocalRetrievalService(
Links = [],
MatchedText = hit.Text,
SurroundingContent = [],
ReferenceTitle = BuildReferenceTitle(hit),
ReferenceTitle = BuildReferenceTitle(hit, dataSource),
ReferenceLink = referenceLink,
PageNumber = hit.PageNumber is > 0 ? hit.PageNumber : null,
};
}
private static string BuildReferenceTitle(LocalRetrievalHit hit)
private static string BuildReferenceTitle(LocalRetrievalHit hit, IInternalDataSource dataSource)
{
var sourceName = FirstNonEmpty(hit.FileName, hit.DataSourceName);
var sourceName = FirstNonEmpty(hit.FileName, dataSource.Name);
return BuildLocatedReferenceTitle(sourceName, hit.ChunkIndex, hit.PageNumber);
}
@ -413,11 +433,19 @@ public sealed class DataSourceLocalRetrievalService(
return $"{sourceName} ({location})";
}
/// <remarks>
/// A known page is written as the fragment `#page=N`, which is what the PDF open parameters
/// call for: a program which understands them opens the document where the passage is. Without
/// a page there is nothing to send a program to, and the chunk stays in the link so the
/// reference still points at something.
/// </remarks>
private static string BuildReferenceLink(string path, LocalRetrievalHit hit)
{
var link = NormalizeLocalReferencePath(path);
var separator = link.Contains('#', StringComparison.Ordinal) ? "&" : "#";
return $"{link}{separator}chunk={hit.ChunkIndex}";
return hit.PageNumber is > 0
? $"{link}{separator}page={hit.PageNumber}"
: $"{link}{separator}chunk={hit.ChunkIndex}";
}
private static string NormalizeLocalReferencePath(string path)

View File

@ -18,15 +18,17 @@ public sealed class DataSourceService
// ReSharper disable once NotAccessedPositionalProperty.Local
private readonly record struct ParticipatingProvider(string Role, bool IsTrusted, ConfidenceLevel ConfidenceLevel);
private readonly DataSourceEmbeddingService embeddingService;
private readonly RustService rustService;
private readonly SettingsManager settingsManager;
private readonly ILogger<DataSourceService> logger;
public DataSourceService(SettingsManager settingsManager, ILogger<DataSourceService> logger, RustService rustService)
public DataSourceService(SettingsManager settingsManager, ILogger<DataSourceService> logger, RustService rustService, DataSourceEmbeddingService embeddingService)
{
this.logger = logger;
this.rustService = rustService;
this.settingsManager = settingsManager;
this.embeddingService = embeddingService;
this.logger.LogInformation("The data source service has been initialized.");
}
@ -49,7 +51,7 @@ public sealed class DataSourceService
if (selectedLLMProvider == Settings.Provider.NONE)
{
this.logger.LogWarning("The selected LLM provider is not set. We cannot filter the data sources by any means.");
return new([], []);
return new([], [], [], []);
}
var usingTrustedProvider = selectedLLMProvider.IsTrustedForDataSourceSecurityChecks(this.settingsManager);
@ -78,7 +80,16 @@ public sealed class DataSourceService
var usingTrustedProvider = selectedLLMProvider.IsTrustedForDataSourceSecurityChecks(this.settingsManager);
var participatingProviders = this.GetParticipatingProviders(selectedLLMProvider.Id, dataSourceOptions,
new("chat provider", usingTrustedProvider, selectedLLMProvider.GetConfidenceLevel(this.settingsManager)));
return await this.GetAllowedDataSources(usingTrustedProvider, participatingProviders, requestedDataSources);
var allowedDataSources = await this.GetAllowedDataSources(usingTrustedProvider, participatingProviders, requestedDataSources);
//
// Whoever asks this way has no list to show, so a data source which cannot be searched is
// dropped rather than marked. Handing it back would start a chat with a data source which
// finds nothing -- the very thing being greyed out elsewhere is meant to prevent.
//
var unsearchableIds = (await this.GetDataSourcesAwaitingReindex(allowedDataSources)).Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
unsearchableIds.UnionWith(this.GetDataSourcesNeedingRepair(allowedDataSources).Select(source => source.Id));
return allowedDataSources.Where(source => !unsearchableIds.Contains(source.Id)).ToList();
}
/// <summary>
@ -99,7 +110,7 @@ public sealed class DataSourceService
if (selectedLLMProvider is NoProvider)
{
this.logger.LogWarning("The selected LLM provider is the default provider. We cannot filter the data sources by any means.");
return new([], []);
return new([], [], [], []);
}
var usingTrustedProvider = selectedLLMProvider.IsTrustedForDataSourceSecurityChecks(this.settingsManager);
@ -142,9 +153,80 @@ public sealed class DataSourceService
var allDataSources = this.settingsManager.ConfigurationData.DataSources.ToList();
var previousSelectedDataSourceIds = previousSelectedDataSources?.Select(source => source.Id).ToHashSet(StringComparer.Ordinal) ?? [];
var filteredDataSources = await this.GetAllowedDataSources(usingTrustedProvider, participatingProviders, allDataSources);
var filteredSelectedDataSources = filteredDataSources.Where(source => previousSelectedDataSourceIds.Contains(source.Id)).ToList();
return new(filteredDataSources, filteredSelectedDataSources);
//
// Which of the sources that passed every check cannot answer a search right now. They are
// held back from both lists below rather than removed altogether: a source whose index is
// being rebuilt is usable again in a while, and saying so on its own row beats letting it
// disappear from the selection without a word.
//
// A source whose index cannot be read is asked about first and then kept out of the other
// list: both reasons can be true at once, and of the two it is the only one the user can do
// anything about. Telling them to wait instead would be telling them to wait forever.
//
var needingRepair = this.GetDataSourcesNeedingRepair(filteredDataSources);
var needingRepairIds = needingRepair.Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
var awaitingReindex = (await this.GetDataSourcesAwaitingReindex(filteredDataSources)).Where(source => !needingRepairIds.Contains(source.Id)).ToList();
var blockedIds = awaitingReindex.Select(source => source.Id).ToHashSet(StringComparer.Ordinal);
blockedIds.UnionWith(needingRepairIds);
var usableDataSources = filteredDataSources.Where(source => !blockedIds.Contains(source.Id)).ToList();
var filteredSelectedDataSources = usableDataSources.Where(source => previousSelectedDataSourceIds.Contains(source.Id)).ToList();
return new(usableDataSources, filteredSelectedDataSources, awaitingReindex, needingRepair);
}
/// <summary>
/// Picks out the data sources whose index has to be rebuilt before they can be searched.
/// </summary>
/// <remarks>
/// Asked for every data source at once, the same way the checks above run in parallel. Each
/// answer is a single row read from the index database, and anything unclear counts as usable.
/// </remarks>
/// <param name="dataSources">The data sources which passed every other check.</param>
/// <returns>Those of them which are waiting for their index, in the order they came in.</returns>
private async Task<IReadOnlyList<IDataSource>> GetDataSourcesAwaitingReindex(IReadOnlyList<IDataSource> dataSources)
{
var checks = new List<Task<bool>>(dataSources.Count);
foreach (var dataSource in dataSources)
checks.Add(this.embeddingService.IsAwaitingReindexAsync(dataSource));
var awaitingReindex = new List<IDataSource>();
for (var index = 0; index < dataSources.Count; index++)
{
if (await checks[index])
{
this.logger.LogInformation("The data source '{DataSourceName}' ({DataSourceId}) is waiting for its index to be rebuilt. It is shown, but cannot be selected.", dataSources[index].Name, dataSources[index].Id);
awaitingReindex.Add(dataSources[index]);
}
}
return awaitingReindex;
}
/// <summary>
/// Picks out the data sources whose index cannot be read anymore, so that they wait for a repair.
/// </summary>
/// <remarks>
/// Reads nothing from a database, unlike the re-index check above: the state is held in memory
/// by the embedding service, which is why this one needs no parallelism and no timeout.
/// </remarks>
/// <param name="dataSources">The data sources which passed every other check.</param>
/// <returns>Those of them which wait for a repair, in the order they came in.</returns>
private IReadOnlyList<IDataSource> GetDataSourcesNeedingRepair(IReadOnlyList<IDataSource> dataSources)
{
var needingRepair = new List<IDataSource>();
foreach (var dataSource in dataSources)
{
if (!this.embeddingService.NeedsIndexRepair(dataSource))
continue;
this.logger.LogInformation("The index of data source '{DataSourceName}' ({DataSourceId}) cannot be read. It is shown, but cannot be selected until it was repaired.", dataSource.Name, dataSource.Id);
needingRepair.Add(dataSource);
}
return needingRepair;
}
private async Task<IReadOnlyList<IDataSource>> GetAllowedDataSources(bool usingTrustedProvider, IReadOnlyList<ParticipatingProvider> participatingProviders, IReadOnlyCollection<IDataSource> requestedDataSources)

View File

@ -1,7 +1,18 @@
using AIStudio.Tools.Databases.VectorStore;
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
/// <summary>
/// The issue code the Rust runtime sends when a vector store is there, but cannot be opened.
/// </summary>
/// <remarks>
/// Mirrors ISSUE_CODE_STORE_UNREADABLE in runtime/src/qdrant_edge_database.rs. Reading the code
/// rather than the message is what keeps a reworded message on the Rust side harmless here.
/// </remarks>
private const string ISSUE_CODE_STORE_UNREADABLE = "store-unreadable";
public async Task<TDatabaseInfo> GetDatabaseInfo<TDatabaseInfo>(
string databaseName,
string infoPath,
@ -46,7 +57,7 @@ public sealed partial class RustService
var operation = await response.Content.ReadFromJsonAsync<DatabaseOperationResponse>(this.jsonRustSerializerOptions, cts.Token);
if (operation is not { Success: true })
throw new InvalidOperationException(operation?.Issue ?? $"The {databaseName} operation failed.");
throw CreateDatabaseException(operation?.Issue, operation?.IssueCode, $"The {databaseName} operation failed.");
}
public async Task<TResult?> ExecuteDatabaseQuery<TRequest, TResult>(string databaseName, string path, TRequest request, CancellationToken cancellationToken = default)
@ -59,12 +70,31 @@ public sealed partial class RustService
var operation = await response.Content.ReadFromJsonAsync<DatabaseQueryResponse<TResult>>(this.jsonRustSerializerOptions, cts.Token);
if (operation is not { Success: true })
throw new InvalidOperationException(operation?.Issue ?? $"The {databaseName} query failed.");
throw CreateDatabaseException(operation?.Issue, operation?.IssueCode, $"The {databaseName} query failed.");
return operation.Data;
}
private sealed record DatabaseOperationResponse(bool Success, string Issue);
/// <summary>
/// Turns a failed database response into the exception which fits its issue code.
/// </summary>
/// <remarks>
/// Almost every failure says all it has to say in its message. A store which cannot be opened is
/// the exception: the only way out of it is a rebuild which costs the user money and time, so it
/// gets a type of its own and reaches the places which can offer that rebuild instead of
/// starting it unasked.
/// </remarks>
private static Exception CreateDatabaseException(string? issue, string? issueCode, string fallbackMessage)
{
var message = string.IsNullOrWhiteSpace(issue) ? fallbackMessage : issue;
return issueCode switch
{
ISSUE_CODE_STORE_UNREADABLE => new VectorStoreUnreadableException(message),
_ => new InvalidOperationException(message),
};
}
private sealed record DatabaseQueryResponse<TResult>(bool Success, string Issue, TResult? Data);
private sealed record DatabaseOperationResponse(bool Success, string Issue, string IssueCode);
private sealed record DatabaseQueryResponse<TResult>(bool Success, string Issue, string IssueCode, TResult? Data);
}

View File

@ -168,4 +168,66 @@ public sealed partial class RustService
result.Dispose();
}
}
/// <summary>
/// Opens a document in the program the system uses for it, on the given page where possible.
/// </summary>
/// <remarks>
/// The page is best effort and never decides whether this succeeded. Which programs can be
/// told a page is the runtime's business, and it says afterwards whether it managed to.
/// </remarks>
/// <param name="path">The document to open.</param>
/// <param name="pageNumber">The page to show, counted from one, or null when there is none.</param>
/// <returns>Whether the document was opened, whether the page was applied, and what went wrong.</returns>
public async Task<OpenDocumentResponse> TryOpenDocumentInSystemViewer(string path, int? pageNumber)
{
HttpResponseMessage result;
try
{
result = await this.http.PostAsJsonAsync("/open/document", new OpenDocumentRequest(path, pageNumber), this.jsonRustSerializerOptions);
}
catch (HttpRequestException e)
{
this.logger!.LogWarning(e, "Failed to reach the Rust runtime document endpoint.");
return new OpenDocumentResponse(false, false, TB("The runtime document endpoint is not available."));
}
catch (TaskCanceledException e)
{
this.logger!.LogWarning(e, "Timed out while reaching the Rust runtime document endpoint.");
return new OpenDocumentResponse(false, false, TB("The runtime document endpoint is not available."));
}
try
{
if (!result.IsSuccessStatusCode)
{
this.logger!.LogWarning("Failed to open a document through the Rust runtime: '{StatusCode}'", result.StatusCode);
return new OpenDocumentResponse(false, false, string.Format(TB("The runtime document endpoint returned '{0}'."), result.StatusCode));
}
var response = await result.Content.ReadFromJsonAsync<OpenDocumentResponse>(this.jsonRustSerializerOptions);
if (response.Success)
{
//
// A page which was asked for but not applied is noted here and nowhere else: the
// document is open, and the source the user clicked names the page anyway.
//
if (pageNumber is > 0 && !response.PageApplied)
this.logger!.LogInformation("Opened a document without the requested page {PageNumber}, because the system uses a program which cannot be told one.", pageNumber);
return response;
}
return new OpenDocumentResponse(false, false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime document endpoint failed without details.") : response.Issue);
}
catch (Exception e)
{
this.logger!.LogWarning(e, "Failed to process the Rust runtime document endpoint response.");
return new OpenDocumentResponse(false, false, TB("The runtime document endpoint failed without details."));
}
finally
{
result.Dispose();
}
}
}

View File

@ -278,7 +278,7 @@ public sealed partial class RustService
{
if (segment.TokenCount is { } tokenCount)
{
yield return new(segment.Content, tokenCount);
yield return new(segment.Content, tokenCount, segment.PageNumber);
continue;
}
@ -291,7 +291,7 @@ public sealed partial class RustService
var countedSegment = await this.GetTokenCount(embeddingProvider, segment.Content, token);
if (countedSegment is { Success: true } counted)
{
yield return new(segment.Content, counted.TokenCount);
yield return new(segment.Content, counted.TokenCount, segment.PageNumber);
continue;
}
@ -303,7 +303,7 @@ public sealed partial class RustService
}
}
private async IAsyncEnumerable<(string Content, int? TokenCount)> StreamArbitraryFileDataCore(
private async IAsyncEnumerable<(string Content, int? TokenCount, int? PageNumber)> StreamArbitraryFileDataCore(
string path,
bool extractImages,
bool includeTokenCount,
@ -420,12 +420,13 @@ public sealed partial class RustService
}
//
// The count comes from the processed event, not from the event which was just read:
// a reader may hold content back across several events, and the count of the content
// it releases is the count of that content, not of the event that released it.
// The count and the page come from the processed event, not from the event which
// was just read: a reader may hold content back across several events, and the
// count and page of the content it releases describe that content, not the event
// that released it.
//
if (!string.IsNullOrWhiteSpace(processedEvent.Content))
yield return (processedEvent.Content, processedEvent.TokenCount);
yield return (processedEvent.Content, processedEvent.TokenCount, processedEvent.PageNumber);
}
}
finally
@ -434,7 +435,7 @@ public sealed partial class RustService
}
if (finalContentChunk is { } pendingContent && !string.IsNullOrWhiteSpace(pendingContent.Content))
yield return (pendingContent.Content, pendingContent.TokenCount);
yield return (pendingContent.Content, pendingContent.TokenCount, pendingContent.PageNumber);
if (promptInjectionRedactedCount is 0)
yield break;

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools;
/// <summary>
/// Where a source points in the file system, and where inside the document it was found.
/// </summary>
/// <param name="Path">The document in the file system, spelled the way this system spells a path.</param>
/// <param name="PageNumber">The page the passage stands on, counted from one, or null when no page is known.</param>
public readonly record struct SourceDocumentLocation(string Path, int? PageNumber);

View File

@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@ -80,72 +81,81 @@ public static partial class SourceExtensions
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string.
/// Sorts a list of sources into the groups it is shown in, and numbers them.
/// </summary>
/// <param name="sources">The list of sources to convert.</param>
/// <returns>A markdown-formatted string representing the sources.</returns>
public static string ToMarkdown(this IList<Source> sources)
/// <remarks>
/// The order of the groups and the running number are what a reader follows, and they have to
/// be the same wherever the list appears: in the chat, in an exported document, and in the
/// clipboard. This is why both the chat and the Markdown below ask here instead of sorting the
/// list themselves.
/// </remarks>
/// <param name="sources">The list of sources to sort.</param>
/// <returns>The groups which have sources, in the order they are shown; empty when there are none.</returns>
public static IReadOnlyList<SourceGroup> GroupSources(this IList<Source> sources)
{
var sb = new StringBuilder();
var ragSources = new List<ISource>();
var toolSources = new List<ISource>();
var sourceNum = 0;
var addedLLMHeaders = false;
var llmSources = new List<Source>();
var toolSources = new List<Source>();
var ragSources = new List<Source>();
foreach (var source in sources)
{
switch (source.Origin)
{
case SourceOrigin.RAG:
ragSources.Add(source);
break;
case SourceOrigin.LLM:
if (!addedLLMHeaders)
{
sb.Append("## ");
sb.AppendLine(TB("Sources provided by the AI"));
addedLLMHeaders = true;
}
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.AppendLine();
llmSources.Add(source);
break;
case SourceOrigin.TOOL:
toolSources.Add(source);
break;
case SourceOrigin.RAG:
ragSources.Add(source);
break;
}
}
if(toolSources.Count > 0)
var groups = new List<SourceGroup>(3);
var sourceNum = 0;
AddGroup(groups, TB("Sources provided by the AI"), llmSources, ref sourceNum);
AddGroup(groups, TB("Sources used by tools"), toolSources, ref sourceNum);
AddGroup(groups, TB("Sources provided by the data providers"), ragSources, ref sourceNum);
return groups;
}
private static void AddGroup(ICollection<SourceGroup> groups, string heading, IReadOnlyList<Source> sources, ref int sourceNum)
{
if(sb.Length > 0)
if (sources.Count == 0)
return;
var numberedSources = new List<NumberedSource>(sources.Count);
foreach (var source in sources)
numberedSources.Add(new(++sourceNum, source));
groups.Add(new(heading, numberedSources));
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string.
/// </summary>
/// <param name="sources">The list of sources to convert.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page; see the method below.</param>
/// <returns>A markdown-formatted string representing the sources.</returns>
public static string ToMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
{
var sb = new StringBuilder();
foreach (var group in sources.GroupSources())
{
if (sb.Length > 0)
sb.AppendLine();
sb.Append("## ");
sb.AppendLine(TB("Sources used by tools"));
sb.AppendLine(group.Heading);
foreach (var source in toolSources)
foreach (var numberedSource in group.Sources)
{
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.AppendLine();
}
}
if(ragSources.Count > 0)
{
if(sb.Length > 0)
sb.AppendLine();
sb.Append("## ");
sb.AppendLine(TB("Sources provided by the data providers"));
foreach (var source in ragSources)
{
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
var url = keepPageAnchors ? numberedSource.Source.URL : WithoutPageAnchor(numberedSource.Source.URL);
sb.Append($"- [{numberedSource.Number}] ");
AppendMarkdownLink(sb, numberedSource.Source.Title, url);
sb.AppendLine();
}
}
@ -153,6 +163,29 @@ public static partial class SourceExtensions
return sb.ToString();
}
/// <summary>
/// Takes the page off a link into a local file, for a reader which cannot follow it.
/// </summary>
/// <remarks>
/// Everything a local link carries in its fragment is dropped, not only a page: a chunk is no
/// use to any reader either, and what breaks such a link is the fragment itself rather than what
/// stands in it. A web address keeps its fragment untouched, because there the fragment is part
/// of the address and naming a section of a page is exactly what it is for.
/// </remarks>
/// <param name="url">The link of the source.</param>
/// <returns>The link without its fragment, or the link itself when it carries none.</returns>
private static string WithoutPageAnchor(string url)
{
if (string.IsNullOrWhiteSpace(url))
return url;
var cleanedUrl = url.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile || absoluteUri.Fragment.Length == 0)
return url;
return absoluteUri.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Fragment, UriFormat.UriEscaped);
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
/// </summary>
@ -163,16 +196,66 @@ public static partial class SourceExtensions
/// for this and the chat does not.
/// </remarks>
/// <param name="sources">The list of sources to convert.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page.</param>
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
public static string ToExportMarkdown(this IList<Source> sources)
public static string ToExportMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
{
var sourcesMarkdown = sources.ToMarkdown();
var sourcesMarkdown = sources.ToMarkdown(keepPageAnchors);
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
return string.Empty;
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
}
/// <summary>
/// Reads which document a source names, and which page of it.
/// </summary>
/// <remarks>
/// Only a source which names a file has such a location; a web source is opened by the browser
/// and never asks. The page rides in the fragment of the link as `page=N`, which is what the PDF
/// open parameters call for. A chat written before v26.9.1 carries `chunk=N` instead, which names
/// nothing a program could be sent to: such a source keeps its document and loses only the page.
/// </remarks>
/// <param name="source">The source to read.</param>
/// <param name="location">The document and its page, or the default when the source names no file.</param>
/// <returns>Whether the source names a file.</returns>
public static bool TryGetDocumentLocation(this ISource source, out SourceDocumentLocation location)
{
location = default;
if (string.IsNullOrWhiteSpace(source.URL))
return false;
var cleanedUrl = source.URL.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile)
return false;
//
// The link was made from a path of this system, so reading it back gives that path again --
// percent-encoded spaces and umlauts included, and with the separators this system uses.
//
var path = absoluteUri.LocalPath;
if (string.IsNullOrWhiteSpace(path))
return false;
location = new(path, ReadPageFromFragment(absoluteUri.Fragment));
return true;
}
private static int? ReadPageFromFragment(string fragment)
{
const string PAGE_PARAMETER = "page=";
foreach (var parameter in fragment.TrimStart('#').Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (!parameter.StartsWith(PAGE_PARAMETER, StringComparison.OrdinalIgnoreCase))
continue;
if (int.TryParse(parameter.AsSpan(PAGE_PARAMETER.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var pageNumber) && pageNumber > 0)
return pageNumber;
}
return null;
}
/// <summary>
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
/// </summary>

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools;
/// <summary>
/// One group of a source list: a heading and the sources below it.
/// </summary>
/// <param name="Heading">The heading above the group.</param>
/// <param name="Sources">The sources of the group, in the order they are shown.</param>
public readonly record struct SourceGroup(string Heading, IReadOnlyList<NumberedSource> Sources);

View File

@ -49,13 +49,17 @@ public sealed class ProviderValidation
public string? ValidatingAPIKey(string apiKey)
{
if(this.GetProvider() is LLMProviders.SELF_HOSTED)
return null;
// A key which could not be stored in or removed from the operating system has to reach the
// user for every provider. Self-hosted providers are exempt from having to name a key at
// all, not from being told that the one they named was lost on the way:
var apiKeyStorageIssue = this.GetAPIKeyStorageIssue();
if(!string.IsNullOrWhiteSpace(apiKeyStorageIssue))
return apiKeyStorageIssue;
// A self-hosted server may well run without any key, so an empty field is fine for it:
if(this.GetProvider() is LLMProviders.SELF_HOSTED)
return null;
if(string.IsNullOrWhiteSpace(apiKey))
return TB("Please enter an API key.");

View File

@ -17,16 +17,27 @@
- Added model plugins, so IT departments can describe the models their organization runs itself.
- Added local RAG as a beta feature, so the AI can answer from your own documents. You point AI Studio at a folder or at a single file, and it prepares those documents in the background so their contents can be found again later. Ask a question with such a data source selected, and AI Studio looks for the passages that fit your question and hands only those to the model, along with where each one came from. We will keep developing it together with the people who use it: to try it, open the app settings, allow preview features down to beta, and then enable the RAG feature. Many thanks to Paul Koudelka (`PaulKoudelka`) for around ten months of work on the concept and the implementation.
- Added the setup for local data sources. You pick an embedding provider, and AI Studio asks for your confirmation before any document goes to a cloud service. It keeps up with your files as they change, shows the progress on a page of its own, and checks every document for hidden instructions before indexing it. Documents without readable text, such as scanned pages, are remembered as such, so AI Studio does not work through them again after every start — it comes back to them once they change.
- Added a way to open the sources of your own documents: click a source below an answer, and the document opens in the program your system uses for it.
- Added a jump to the right page for the sources of your own documents (RAG), so a PDF opens directly where the passage was found, wherever your system and its program support it.
- Added a way to show a source of your own documents (RAG) in your file manager.
- Added the page of a passage to what the AI is told when it answers from your own documents (RAG), so it can name the page an answer rests on.
- Added the details of the two databases behind local RAG to the information page: which versions they run, how much space they use on your disk, and how much they hold.
- Added a repair for your local data sources. Should the index of a data source ever become unreadable, AI Studio now says so instead of quietly finding nothing and leaves that source out of your chats until it works again.
- Added the repair itself as a button next to each of your data sources. Rebuilding an index sends your documents to your embedding provider once more, so AI Studio asks you first and never starts it on its own.
- Added support for several drop areas on the same page. More complex assistants can now receive files or folders by drag and drop at more than one place.
- Added drag and drop to the input and output folder of the Batch Processing assistant: drop a folder onto either field to choose it.
- Added ways to load text from a file and drop zones for them, throughout the assistants and dialogs. We went through them one by one, so many fields that used to accept typed text only now take the content of a file as well.
- Added an optional API key to every server you host yourself, among them LM Studio, llama.cpp, and whisper.cpp. Such a server may ask for one itself or sit behind a login your organization placed in front of it. So far, only Ollama and vLLM could be given a key.
- Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.
- Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading.
- Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you.
- Improved the list of your attached files: every file now appears under the folder it came from, and each folder is named only once, no matter in which order you attached your files.
- Improved organization-wide provider management: IT departments can now separately prevent users from adding chat, transcription, or embedding providers. The existing master setting still overrides all three provider-specific settings.
- Improved the provider selection throughout the assistants: when there is nothing to choose from, it now says why. Either you have not set up a provider yet, or none of yours is trusted enough for what you are doing. Before, the list was simply empty.
- Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet.
- Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had.
- Fixed the Document Analysis assistant freezing while you edited a policy. It needed a change of yours to be saved in the background just as you were making the next one — picking a provider, for instance — which is why it hit some of you again and again and others never at all.
- Fixed a renamed policy losing its new name in the Document Analysis assistant. The name was kept only when you happened to change something else afterward.
- Fixed model names that a provider writes in its own way not being recognized at all, such as the colon Ollama puts before the variant. Those models were treated as plain text models and lost every other ability.
- Fixed a model resold under a plain name not getting the abilities it really has.
- Fixed image and video generation models showing up among the chat models.
@ -45,4 +56,9 @@
- Fixed the data sources you picked for a chat being forgotten the moment you changed your selection while one of them could not be used. Such a source stays selected and is used again as soon as it is available.
- Fixed the silence when the step that picks the fitting passages out of your documents fails. You are told that the answer rests on everything that was found.
- Fixed the regenerate button taking an answer away without producing a new one. This happened in chats started from a template that holds no question of your own.
- Fixed the counter above an answer, which shows how many sources it rests on, doing nothing when you clicked it. It now takes you down to the sources.
- Fixed the list of models staying empty at a server you host yourself, which made the model you had picked look as if it had vanished. Your key was there all along, it just was not read when the settings opened.
- Fixed AI Studio asking such a server for its models with an empty key attached when you had stored none at all. Servers behind a login turn those requests down.
- Fixed a key that could not be saved going unmentioned for the servers you host yourself. You are now told what went wrong, instead of the settings simply staying open.
- Upgraded the Visual Briefing Assistant (in preview) from the prototype to the beta state. The assistant is now completely implemented and is undergoing a deeper testing phase in preparation for release. To try it, open the app settings, allow preview features down to beta, and then enable the Visual Briefing Assistant there.
- Upgraded the vector database behind local RAG (Qdrant Edge) to version 0.8.0.

View File

@ -0,0 +1,159 @@
using System.Text.Json;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Microsoft.Extensions.Logging.Abstractions;
using Version = AIStudio.Settings.Version;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks what settings operations do to each other when they overlap.
/// </summary>
/// <remarks>
/// The settings are written from everywhere: a timer firing on its own thread, a dialog the user
/// just closed, a configuration plugin which arrived over the network. Nothing keeps two of those
/// from meeting, and what they must never leave behind is a settings file nobody can read -- it is
/// the file the app starts from the next morning. These tests arrange the meeting on purpose and
/// look at what is on the disk afterward.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class SettingsStorageTests
{
/// <summary>
/// How many operations are set against each other.
/// </summary>
/// <remarks>
/// High enough that the operations genuinely overlap on any machine, low enough that the test
/// stays a test. A race which needs more than this to show up would not be one the app meets.
/// </remarks>
private const int CONCURRENT_OPERATIONS = 50;
private const string SETTINGS_FILENAME = "settings.json";
private const string BACKUP_FILENAME = "settings.v6.json";
private string? previousConfigDirectory;
private string? previousDataDirectory;
private string testDirectory = string.Empty;
[SetUp]
public void PrepareTestDirectory()
{
//
// Both directories are static state of the whole application, which is why this fixture
// does not run alongside others. They are put back in the teardown so that a later test
// does not inherit a directory which is gone by then.
//
this.previousConfigDirectory = SettingsManager.ConfigDirectory;
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-settings-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.ConfigDirectory = this.testDirectory;
SettingsManager.DataDirectory = this.testDirectory;
}
[TearDown]
public void RemoveTestDirectory()
{
SettingsManager.ConfigDirectory = this.previousConfigDirectory;
SettingsManager.DataDirectory = this.previousDataDirectory;
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[Test]
public async Task OverlappingStoresLeaveBothFilesReadable()
{
var settingsManager = CreateSettingsManager();
await Task.WhenAll(Enumerable.Range(0, CONCURRENT_OPERATIONS).Select(_ => settingsManager.StoreSettings()));
var settingsPath = Path.Combine(this.testDirectory, SETTINGS_FILENAME);
var backupPath = Path.Combine(this.testDirectory, BACKUP_FILENAME);
Assert.Multiple(() =>
{
Assert.That(File.Exists(settingsPath), Is.True, "The settings file was never written.");
Assert.That(File.Exists(backupPath), Is.True, "The settings backup file was never written.");
Assert.That(ReadSettingsFile(settingsPath)?.Version, Is.EqualTo(Version.V6), "The settings file could not be read back.");
Assert.That(ReadSettingsFile(backupPath)?.Version, Is.EqualTo(Version.V6), "The settings backup file could not be read back.");
});
}
[Test]
public async Task OverlappingStoresLeaveNoTemporaryFilesBehind()
{
var settingsManager = CreateSettingsManager();
await Task.WhenAll(Enumerable.Range(0, CONCURRENT_OPERATIONS).Select(_ => settingsManager.StoreSettings()));
//
// Every store writes its settings next to the previous ones and renames afterwards. The
// temporary file carries a name of its own, so two stores cannot collide over it -- but a
// store which gave up halfway would leave one lying around, and the next start would find
// a configuration directory filling up with them.
//
var leftovers = Directory.GetFiles(this.testDirectory, "*.tmp-*").Select(Path.GetFileName).ToList();
Assert.That(leftovers, Is.Empty, $"Temporary settings files were left behind: {string.Join(", ", leftovers)}.");
}
[Test]
public async Task AStoreCannotSlipThroughWhileAReadReconsidersTheWriteBlock()
{
var settingsPath = Path.Combine(this.testDirectory, SETTINGS_FILENAME);
//
// Settings written by a newer app than this one. Reading them blocks every write, so that
// this app cannot replace settings it does not understand with the little it does. What
// makes this the interesting case is how a read arrives at that verdict: it clears the
// block first and only re-establishes it once it has seen the file. A store meeting that
// moment would find nothing standing in its way and overwrite the very file the block
// exists for -- which is why a read holds the same lock a store does.
//
await File.WriteAllTextAsync(settingsPath, """{"Version": "V99"}""");
var settingsManager = CreateSettingsManager();
await settingsManager.TryReadSettingsSnapshot();
Assert.That(settingsManager.SettingsWriteBlockReason, Is.EqualTo(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP), "The newer settings file did not block writes in the first place.");
var operations = new List<Task>();
for (var i = 0; i < CONCURRENT_OPERATIONS; i++)
{
operations.Add(settingsManager.StoreSettings());
operations.Add(settingsManager.TryReadSettingsSnapshot());
}
await Task.WhenAll(operations);
using var settingsDocument = JsonDocument.Parse(await File.ReadAllTextAsync(settingsPath));
Assert.Multiple(() =>
{
Assert.That(settingsDocument.RootElement.GetProperty("Version").GetString(), Is.EqualTo("V99"), "A store overwrote the newer settings file while a read was reconsidering the write block.");
Assert.That(settingsManager.SettingsWriteBlockReason, Is.EqualTo(SettingsWriteBlockReason.VERSION_NEWER_THAN_APP), "The write block did not survive the reads which re-established it.");
});
}
/// <summary>
/// Builds a settings manager the way these tests need it.
/// </summary>
/// <remarks>
/// The rust service is handed in as null on purpose: neither storing nor reading settings ever
/// asks it anything. Only the active language is read through it, and that is not what is being
/// checked here. Should a future store reach for it, the test says so by failing loudly rather
/// than by quietly testing a different thing.
/// </remarks>
private static SettingsManager CreateSettingsManager() => new(NullLogger<SettingsManager>.Instance, null!);
private static Data? ReadSettingsFile(string settingsPath) => JsonSerializer.Deserialize<Data>(File.ReadAllText(settingsPath), SettingsManager.JSON_OPTIONS);
}

View File

@ -0,0 +1,157 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that the page a passage came from is handed on as a number.
/// </summary>
/// <remarks>
/// The runtime states the page of every page it reads. That number used to be written into the
/// text as a heading and read back out of it further down, which left Word and OpenDocument files
/// without a page for good: they are marked with a comment, not with a heading, so the search for
/// a heading never found anything. The tests here pin the number to the metadata, which is the one
/// place it is actually stated.
/// </remarks>
[TestFixture]
public sealed class ContentStreamPageNumberTests
{
[Test]
public void APdfPageStatesItsNumber()
{
var processed = ContentStreamSseHandler.ProcessEvent(PdfEvent(7, "The mixing console is described here."));
Assert.Multiple(() =>
{
Assert.That(processed.PageNumber, Is.EqualTo(7), "The page comes from the metadata of the event.");
Assert.That(processed.Content, Does.Contain("# Page 7"), "The heading stays, because it is what tells the AI which page it reads.");
});
}
[Test]
public void APdfPageWithoutANumberStatesNone()
{
var processed = ContentStreamSseHandler.ProcessEvent(PdfEvent(null, "A page the runtime could not number."));
Assert.That(processed.PageNumber, Is.Null, "Without a number in the metadata there is no page to state.");
}
/// <remarks>
/// This is the case the old approach got wrong: a document which writes about page numbers
/// looks exactly like the marker that used to be searched for.
/// </remarks>
[Test]
public void ATextWhichReadsLikeAPageMarkerIsNotOne()
{
var processed = ContentStreamSseHandler.ProcessEvent(new()
{
Content = "# Page 42\nStill nothing but the text of the document.",
StreamId = NewStreamId(),
Metadata = new ContentStreamTextMetadata(),
});
Assert.Multiple(() =>
{
Assert.That(processed.PageNumber, Is.Null, "Nothing is read out of the text, so a line which looks like a marker stays text.");
Assert.That(processed.Content, Is.EqualTo("# Page 42\nStill nothing but the text of the document."), "The text itself is passed on untouched.");
});
}
/// <remarks>
/// A Word or OpenDocument page is held back until it is clear that no image follows it, so the
/// page leaving the reader is always the one before the event which released it. Its number has
/// to wait together with it; handing out the number of the arriving event would put every
/// passage one page too far ahead.
/// </remarks>
[Test]
public void ADocumentPageCarriesItsOwnNumberAndNotTheOneWhichReleasedIt()
{
var streamId = NewStreamId();
try
{
var first = ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 1, "What the first page says."));
var second = ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 2, "What the second page says."));
Assert.Multiple(() =>
{
Assert.That(first.Content, Is.Null, "The first page is still being buffered, so nothing is released yet.");
Assert.That(second.PageNumber, Is.EqualTo(1), "What is released here is the first page, so it carries page one.");
Assert.That(second.Content, Does.Contain("What the first page says."), "The content released belongs to the page whose number is stated.");
});
}
finally
{
ContentStreamSseHandler.Clear(streamId);
}
}
[Test]
public void TheLastDocumentPageIsReleasedWithItsNumber()
{
var streamId = NewStreamId();
ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 1, "What the first page says."));
ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 2, "What the second page says."));
var remainder = ContentStreamSseHandler.Clear(streamId);
Assert.That(remainder, Is.Not.Null, "The reader always keeps its last page, so there is something left to release.");
Assert.Multiple(() =>
{
Assert.That(remainder!.Value.PageNumber, Is.EqualTo(2), "The page kept back is the second one.");
Assert.That(remainder.Value.Content, Does.Contain("What the second page says."), "The content released belongs to the page whose number is stated.");
});
}
/// <remarks>
/// A slide is not a page, and no program can be told to open one. Stating none is what later
/// lets a click on such a source open the file and stop there.
/// </remarks>
[Test]
public void ASlideStatesNoPage()
{
var processed = ContentStreamSseHandler.ProcessEvent(new()
{
Content = "What the third slide says.",
StreamId = NewStreamId(),
Metadata = new ContentStreamPresentationMetadata { Presentation = new() { SlideNumber = 3 } },
}, extractImages: false);
Assert.Multiple(() =>
{
Assert.That(processed.PageNumber, Is.Null, "A slide number is not a page number.");
Assert.That(processed.Content, Does.Contain("# Slide 3"), "The heading stays, so the AI still knows which slide it reads.");
});
}
[Test]
public void ASpreadsheetRowStatesNoPage()
{
var processed = ContentStreamSseHandler.ProcessEvent(new()
{
Content = "| Console | Channels |",
StreamId = NewStreamId(),
Metadata = new ContentStreamSpreadsheetMetadata { Spreadsheet = new() { SheetName = "Inventory", RowNumber = 0 } },
});
Assert.That(processed.PageNumber, Is.Null, "A sheet has rows, not pages.");
}
private static ContentStreamSseEvent PdfEvent(int? pageNumber, string content) => new()
{
Content = content,
StreamId = NewStreamId(),
Metadata = new ContentStreamPdfMetadata { Pdf = new() { PageNumber = pageNumber } },
};
private static ContentStreamSseEvent DocumentEvent(string streamId, int pageNumber, string content) => new()
{
Content = content,
StreamId = streamId,
Metadata = new ContentStreamDocumentMetadata { Document = new() { PageNumber = pageNumber } },
};
//
// The readers are kept in static tables keyed by the stream. A test which reuses an ID would
// read the pages another test left behind.
//
private static string NewStreamId() => Guid.NewGuid().ToString();
}

View File

@ -0,0 +1,72 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what makes the stored embeddings of a data source invalid.
/// </summary>
/// <remarks>
/// The embedding signature decides whether an index survives: when it differs from the one persisted
/// for a data source, everything stored is thrown away and embedded again. That is the right answer
/// for anything a vector depends on, and an expensive mistake for everything else. The confidence
/// level a data source asks of a provider used to be part of it, so changing that one setting
/// re-embedded every file of the source — at a cloud embedding provider, for real money and no gain.
/// </remarks>
[TestFixture]
public sealed class EmbeddingSignatureTests
{
[Test]
public void ChangingTheConfidenceLevelKeepsTheStoredEmbeddings()
{
var low = DataSource(ConfidenceLevel.LOW);
var high = DataSource(ConfidenceLevel.HIGH);
Assert.That(Signature(high), Is.EqualTo(Signature(low)), "The confidence level changes no vector, so the stored index stays valid and nothing is embedded again.");
}
[Test]
public void ChangingTheChunkSizeDropsTheStoredEmbeddings()
{
var small = DataSource(ConfidenceLevel.LOW) with { MaxChunkTokenLength = 512 };
var large = DataSource(ConfidenceLevel.LOW) with { MaxChunkTokenLength = 1024 };
Assert.That(Signature(large), Is.Not.EqualTo(Signature(small)), "Other chunk boundaries mean other vectors, so the index has to be built again.");
}
[Test]
public void ChangingTheEmbeddingModelDropsTheStoredEmbeddings()
{
var dataSource = DataSource(ConfidenceLevel.LOW);
Assert.That(
Signature(dataSource, EmbeddingProviderFor("text-embedding-3-large")),
Is.Not.EqualTo(Signature(dataSource, EmbeddingProviderFor("text-embedding-3-small"))),
"Another model means another vector space, so nothing stored may be kept.");
}
private static string Signature(DataSourceLocalDirectory dataSource, EmbeddingProvider? embeddingProvider = null) =>
DataSourceEmbeddingService.BuildEmbeddingSignature(
dataSource,
embeddingProvider ?? EmbeddingProviderFor("text-embedding-3-small"),
new(512, 100));
private static DataSourceLocalDirectory DataSource(ConfidenceLevel confidenceLevel) => new()
{
Num = 1,
Id = "6f1d6a4e-6a5e-4c62-9a4f-0f2d2c8b7a11",
Name = "Test data",
Description = "Documents used by the tests.",
Type = DataSourceType.LOCAL_DIRECTORY,
EmbeddingId = "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01",
MaxChunkTokenLength = 512,
ChunkOverlapTokenLength = 100,
ConfidenceLevel = confidenceLevel,
Path = "/tmp/test-data",
};
private static EmbeddingProvider EmbeddingProviderFor(string modelId) =>
new(1, "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "Test embeddings", LLMProviders.OPEN_AI, new(modelId, modelId));
}

View File

@ -0,0 +1,39 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what AI Studio assumes about the readers of the formats it writes.
/// </summary>
[TestFixture]
public sealed class FileExportFormatTests
{
[Test]
public void OnlyTheTwoOfficeFormatsRefuseAPageInALocalLink()
{
Assert.Multiple(() =>
{
Assert.That(FileExportFormat.MICROSOFT_WORD.FollowsPageAnchors(), Is.False, "Word looks for a file whose name ends in the fragment, finds none, and refuses the link.");
Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.FollowsPageAnchors(), Is.False, "LibreOffice does the same, verified on 2026-09-15 with an exported .odt.");
Assert.That(FileExportFormat.HTML.FollowsPageAnchors(), Is.True, "A browser opens the document on the page the fragment names.");
Assert.That(FileExportFormat.MARKDOWN.FollowsPageAnchors(), Is.True);
Assert.That(FileExportFormat.LATEX.FollowsPageAnchors(), Is.True);
});
}
[Test]
public void EveryFormatAnAnswerIsWrittenAsHasAnAnswerHere()
{
// Whoever adds a format decides what its reader can follow, rather than inheriting an
// assumption. This fails for a format which nobody thought about, because the list below
// has to name it:
Assert.That(FileExportFormatExtensions.ANSWER_FORMATS, Is.EquivalentTo(new[]
{
FileExportFormat.MICROSOFT_WORD,
FileExportFormat.OPEN_DOCUMENT_TEXT,
FileExportFormat.LATEX,
FileExportFormat.MARKDOWN,
FileExportFormat.HTML,
}), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here.");
}
}

View File

@ -0,0 +1,89 @@
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks when a data source counts as waiting for its index to be rebuilt.
/// </summary>
/// <remarks>
/// This decides whether the data source selection greys a row out. Two mistakes are possible and
/// both are bad in their own way: calling a rebuild finished lets the user pick a data source which
/// finds nothing and answers without their data, while calling a healthy data source unusable locks
/// a row for good. The stored signature alone cannot tell the two apart, because it is written back
/// the moment the old index is discarded -- the stored hash of the data source is what closes that
/// gap, since it only appears once a run has worked through everything.
/// </remarks>
[TestFixture]
public sealed class ReindexPendingTests
{
private const string CURRENT_SIGNATURE = "v1|openai|text-embedding-3-small|512|100";
[Test]
public void ADataSourceWhichWasNeverIndexedIsWaiting()
{
Assert.That(
DataSourceEmbeddingService.IsIndexAwaitingRebuild(null, CURRENT_SIGNATURE, null),
Is.True,
"Nothing is stored about this data source, so there is nothing to search in it.");
}
[Test]
public void AnotherEmbeddingConfigurationMeansWaiting()
{
var indexState = new DataSourceIndexState("openai", "v1|openai|text-embedding-3-large|512|100", "source-hash", 1536);
Assert.That(
DataSourceEmbeddingService.IsIndexAwaitingRebuild(indexState, CURRENT_SIGNATURE, DataSourceEmbeddingState.COMPLETED),
Is.True,
"The stored vectors belong to another embedding configuration and are discarded by the next run, so they are of no use now either.");
}
[Test]
public void AFinishedIndexIsNotWaiting()
{
var indexState = new DataSourceIndexState("openai", CURRENT_SIGNATURE, "source-hash", 1536);
Assert.That(
DataSourceEmbeddingService.IsIndexAwaitingRebuild(indexState, CURRENT_SIGNATURE, DataSourceEmbeddingState.COMPLETED),
Is.False,
"A run has worked through the whole data source since the index was last discarded.");
}
[Test]
public void CatchingUpWithChangedFilesIsNotWaiting()
{
var indexState = new DataSourceIndexState("openai", CURRENT_SIGNATURE, "source-hash", 1536);
Assert.That(
DataSourceEmbeddingService.IsIndexAwaitingRebuild(indexState, CURRENT_SIGNATURE, DataSourceEmbeddingState.RUNNING),
Is.False,
"An ordinary run leaves the stored hash in place: everything indexed before is still there and still searchable.");
}
[Test]
public void ARebuildInProgressIsWaiting()
{
//
// What a reset leaves behind: the row was written anew with the current signature, and the
// hash of the data source is empty until a run has been through all of it.
//
var indexState = new DataSourceIndexState("openai", CURRENT_SIGNATURE, string.Empty, 0);
Assert.That(
DataSourceEmbeddingService.IsIndexAwaitingRebuild(indexState, CURRENT_SIGNATURE, DataSourceEmbeddingState.RUNNING),
Is.True,
"The signature matches again, but no run has finished since the vectors were thrown away.");
}
[Test]
public void AFailedRunIsNotWaiting()
{
var indexState = new DataSourceIndexState("openai", CURRENT_SIGNATURE, string.Empty, 1536);
Assert.That(
DataSourceEmbeddingService.IsIndexAwaitingRebuild(indexState, CURRENT_SIGNATURE, DataSourceEmbeddingState.FAILED),
Is.False,
"Whatever the failed run managed to index is searchable, and the embeddings page already names the problem.");
}
}

View File

@ -0,0 +1,84 @@
using System.Text;
using AIStudio.Tools.RAG;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what the AI is told about a passage before it reads it.
/// </summary>
/// <remarks>
/// The page a passage sits on travels from the runtime through the index into the retrieval
/// context, but it used to stop there: the AI was given the file and nothing else, so an answer
/// could name the document it rests on but never the place in it. A source which has no page, a
/// slide for instance, must stay silent rather than claim one.
/// </remarks>
[TestFixture]
public sealed class RetrievalContextDescriptionTests
{
[Test]
public void AKnownPageIsPartOfWhatTheAIIsTold()
{
var description = Describe(TextContext(pageNumber: 12));
Assert.That(description, Does.Contain("Content location: page 12"), "The AI is told the page, so it can say where an answer comes from.");
}
[Test]
public void APassageWithoutAPageClaimsNone()
{
var description = Describe(TextContext(pageNumber: null));
Assert.That(description, Does.Not.Contain("Content location"), "A slide or a sheet has no page, and none is invented for it.");
}
/// <remarks>
/// The location belongs to the document, so it is stated with it and before the passage itself
/// follows further down.
/// </remarks>
[Test]
public void ThePageIsStatedWithTheDocumentItBelongsTo()
{
var description = Describe(TextContext(pageNumber: 12));
var lines = description.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToArray();
Assert.That(lines, Is.EqualTo(new[]
{
"Data source name: Handbooks",
"Content category: TEXT",
"Content type: TEXT_DOCUMENT",
"Content path: /docs/handbook.pdf",
"Content location: page 12",
}), "Name, kind, path and place of the document, in that order.");
}
[Test]
public void AdditionalLinksStillFollowTheLocation()
{
var description = Describe(TextContext(pageNumber: 12, links: ["https://example.com/handbook"]));
Assert.Multiple(() =>
{
Assert.That(description, Does.Contain("Additional links:"), "The links a data source delivers are still passed on.");
Assert.That(description.IndexOf("Content location", StringComparison.Ordinal), Is.LessThan(description.IndexOf("Additional links", StringComparison.Ordinal)), "The place inside the document is stated before links pointing elsewhere.");
});
}
private static string Describe(IRetrievalContext retrievalContext)
{
var builder = new StringBuilder();
IRetrievalContextExtensions.AppendContextDescription(builder, retrievalContext);
return builder.ToString();
}
private static RetrievalTextContext TextContext(int? pageNumber, IReadOnlyList<string>? links = null) => new()
{
DataSourceName = "Handbooks",
Category = RetrievalContentCategory.TEXT,
Type = RetrievalContentType.TEXT_DOCUMENT,
Path = "/docs/handbook.pdf",
Links = links ?? [],
MatchedText = "The mixing console is described here.",
PageNumber = pageNumber,
};
}

View File

@ -85,6 +85,164 @@ public sealed class SourceExtensionsTests
});
}
[Test]
public void TheGroupingIsWhatTheChatAndTheExportBothRead()
{
// Mixed on purpose, and with two sources of one origin, so neither the order of the groups
// nor the order inside a group can come from the order of the input:
IList<Source> sources =
[
new("Handbook", "https://example.org/handbook", SourceOrigin.RAG),
new("Search result", "https://example.org/search", SourceOrigin.TOOL),
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
new("Second handbook", "https://example.org/handbook-2", SourceOrigin.RAG),
];
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
Assert.Multiple(() =>
{
Assert.That(sources.GroupSources(), Has.Count.EqualTo(3), "Each of the three origins has a source, so each of them is a group.");
Assert.That(listed.Select(numbered => numbered.Source.Title), Is.EqualTo(new[] { "Cited by the model", "Search result", "Handbook", "Second handbook" }), "What the AI cited comes first, then what the tools read, then what the data providers gave.");
Assert.That(listed.Select(numbered => numbered.Number), Is.EqualTo(new[] { 1, 2, 3, 4 }), "The number runs through the whole list instead of starting over per group.");
});
}
[Test]
public void AnOriginWithoutSourcesIsNoGroup()
{
IList<Source> sources = [new("Search result", "https://example.org/search", SourceOrigin.TOOL)];
Assert.Multiple(() =>
{
Assert.That(sources.GroupSources().Select(group => group.Sources.Count), Is.EqualTo(new[] { 1 }), "An answer which only used a tool gets one group, not three with two of them empty.");
Assert.That(new List<Source>().GroupSources(), Is.Empty, "An answer nobody had to look up gets no group at all.");
});
}
[Test]
public void TheMarkdownListsExactlyWhatTheGroupingSaysItShould()
{
IList<Source> sources =
[
new("Handbook (Page 12)", "file:///Users/someone/handbook.pdf#page=12", SourceOrigin.RAG),
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
];
var entries = EntriesOf(sources.ToMarkdown());
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
Assert.That(entries, Has.Count.EqualTo(listed.Count), "Every source the grouping lists is written out, and nothing else is.");
for (var index = 0; index < entries.Count; index++)
Assert.That(entries[index], Does.StartWith($"- [{listed[index].Number}] ").And.Contains(listed[index].Source.Title), "The Markdown and the chat read the same grouping, so a source cannot be numbered one way here and another way there.");
}
[Test]
public void AReaderWhichCannotFollowAPageGetsTheDocumentWithoutOne()
{
IList<Source> sources =
[
new("Handbook (Page 266)", "file:///Users/someone/My Documents/handbook.pdf#page=266", SourceOrigin.RAG),
new("An older answer", "file:///Users/someone/handbook.pdf#chunk=3", SourceOrigin.RAG),
new("A section of an article", "https://example.org/article#results", SourceOrigin.LLM),
];
Assert.That(EntriesOf(sources.ToMarkdown(keepPageAnchors: false)), Is.EqualTo(new[]
{
"- [1] [A section of an article](<https://example.org/article#results>)",
"- [2] [Handbook (Page 266)](<file:///Users/someone/My%20Documents/handbook.pdf>)",
"- [3] [An older answer](<file:///Users/someone/handbook.pdf>)",
}), "Word and LibreOffice take the fragment of a local link for part of the file name and refuse the link, so the local links lose it -- and the web link keeps its own, where a fragment names a section of the page and belongs to the address.");
}
[Test]
public void AReaderWhichFollowsAPageIsToldIt()
{
IList<Source> sources = [new("Handbook (Page 266)", "file:///Users/someone/handbook.pdf#page=266", SourceOrigin.RAG)];
Assert.Multiple(() =>
{
Assert.That(EntriesOf(sources.ToMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "A browser and a PDF reader open the document where the passage is, so they are told the page.");
Assert.That(EntriesOf(sources.ToExportMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "The clipboard and every text format keep it as well; only the two office formats ask for it to be dropped.");
});
}
[Test]
public void AKnownPageRidesInTheLinkOfASource()
{
var location = LocationOf("file:///Users/someone/My%20Documents/Gr%C3%B6%C3%9Fere%20%C3%9Cbersicht.pdf#page=12");
Assert.Multiple(() =>
{
Assert.That(location.Path, Does.EndWith("Größere Übersicht.pdf").And.Contains("My Documents"), "The percent-encoding of the link is undone, so the program is handed the name the file really has.");
Assert.That(location.PageNumber, Is.EqualTo(12), "This is the page the passage was found on, and the page the document is opened at.");
});
}
[Test]
public void APathOfAWindowsMachineComesBackAsOne()
{
var location = LocationOf("file:///C:/Users/someone/Documents/handbook.pdf#page=3");
Assert.Multiple(() =>
{
Assert.That(location.Path, Is.EqualTo(@"C:\Users\someone\Documents\handbook.pdf"), "A drive letter and backslashes are what a program on Windows is handed -- and what the link was made from there.");
Assert.That(location.PageNumber, Is.EqualTo(3));
});
}
[Test]
public void AChatFromBeforeThisReleaseKeepsItsDocumentAndLosesOnlyItsPage()
{
var location = LocationOf("file:///Users/someone/handbook.pdf#chunk=3");
Assert.Multiple(() =>
{
Assert.That(location.Path, Does.EndWith("handbook.pdf"), "Such a source still names its document, so the click still opens it.");
Assert.That(location.PageNumber, Is.Null, "A chunk is not a page: no program can be sent to one, so the document opens on its first page.");
});
}
[Test]
public void ALinkWithoutAFragmentNamesNoPage()
{
Assert.That(LocationOf("file:///Users/someone/handbook.pdf").PageNumber, Is.Null);
}
[Test]
public void APageWhichIsNoPageIsReadAsNone()
{
Assert.Multiple(() =>
{
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=0").PageNumber, Is.Null, "Pages are counted from one, so a zero is not a page.");
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=-2").PageNumber, Is.Null);
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=twelve").PageNumber, Is.Null);
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#chunk=3&page=12").PageNumber, Is.EqualTo(12), "A link which already carried a fragment gets the page appended with an ampersand, and it is found there too.");
});
}
[Test]
public void AWebSourceNamesNoDocumentAtAll()
{
// The fragment reads like a page on purpose: what decides is the scheme, not the fragment.
ISource source = new Source("Article", "https://example.org/article#page=12", SourceOrigin.LLM);
Assert.That(source.TryGetDocumentLocation(out _), Is.False, "A web source is opened by the browser and has no path to hand to a program.");
}
/// <summary>
/// Reads where the link of a source points, and fails the test when it points nowhere.
/// </summary>
/// <param name="url">The link of the source.</param>
/// <returns>The document and the page the link names.</returns>
private static SourceDocumentLocation LocationOf(string url)
{
ISource source = new Source("Handbook", url, SourceOrigin.RAG);
Assert.That(source.TryGetDocumentLocation(out var location), Is.True, "This link names a file, so a location is what it has.");
return location;
}
/// <summary>
/// Reads the entries of a source list, without the headings above them.
/// </summary>

View File

@ -9,4 +9,4 @@
3c18a7bfdb3, release
osx-arm64
148.0.7763.0
0.7.2
0.8.0

547
runtime/Cargo.lock generated
View File

@ -113,6 +113,12 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "allocator-api2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880"
[[package]]
name = "android_system_properties"
version = "0.1.5"
@ -884,6 +890,15 @@ dependencies = [
"constant_time_eq 0.1.5",
]
[[package]]
name = "blink-alloc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce4c15bad517bc0fb4a44523adf470e2c3eb3a365769327acdba849948ea3705"
dependencies = [
"allocator-api2 0.4.0",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -952,14 +967,6 @@ dependencies = [
"piper",
]
[[package]]
name = "bm25"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"murmur3_32",
]
[[package]]
name = "brotli"
version = "8.0.2"
@ -1366,53 +1373,6 @@ dependencies = [
"memchr",
]
[[package]]
name = "common"
version = "0.0.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"ahash",
"aligned-vec",
"atomicwrites",
"bincode 1.3.3",
"bitvec",
"bytemuck",
"chrono",
"fs-err",
"fs4",
"fs_extra",
"io-uring",
"itertools",
"log",
"memmap2",
"nix 0.31.3",
"num-traits",
"num_cpus",
"ordered-float 5.3.0",
"parking_lot",
"ph",
"procfs",
"quick_cache",
"rand 0.10.2",
"roaring",
"schemars 0.8.22",
"self_cell",
"semver",
"serde",
"serde_json",
"slab",
"strum",
"tap",
"tar",
"tempfile",
"thiserror 2.0.18",
"thread-priority",
"tokio",
"validator",
"walkdir",
"zerocopy",
]
[[package]]
name = "compact_str"
version = "0.9.1"
@ -1550,6 +1510,17 @@ dependencies = [
"libc",
]
[[package]]
name = "core_affinity"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342"
dependencies = [
"libc",
"num_cpus",
"winapi",
]
[[package]]
name = "cpubits"
version = "0.1.1"
@ -1810,20 +1781,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "dataset"
version = "0.0.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"anyhow",
"flate2",
"fs-err",
"indicatif",
"reqwest",
"serde",
"serde_json",
]
[[package]]
name = "dbus"
version = "0.9.7"
@ -2882,10 +2839,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@ -3046,42 +3001,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "gpu"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"log",
"parking_lot",
"zerocopy",
]
[[package]]
name = "gridstore"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"ahash",
"bitvec",
"bytemuck",
"common",
"dataset",
"ecow",
"fs-err",
"itertools",
"log",
"lz4_flex",
"parking_lot",
"rand 0.10.2",
"serde",
"serde_cbor",
"serde_json",
"smallvec",
"tempfile",
"thiserror 2.0.18",
"zerocopy",
]
[[package]]
name = "gtk"
version = "0.18.2"
@ -3195,7 +3114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
"allocator-api2 0.2.21",
]
[[package]]
@ -3204,7 +3123,7 @@ version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"allocator-api2",
"allocator-api2 0.2.21",
"equivalent",
"foldhash 0.1.5",
]
@ -3215,7 +3134,7 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"allocator-api2 0.2.21",
"equivalent",
"foldhash 0.2.0",
]
@ -3343,6 +3262,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
[[package]]
name = "hybrid-array"
version = "0.4.12"
@ -3761,7 +3686,6 @@ checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb"
dependencies = [
"console",
"portable-atomic",
"rayon",
"unicode-width",
"unit-prefix",
"web-time",
@ -3885,6 +3809,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
@ -4221,17 +4154,11 @@ dependencies = [
"imgref",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lz4_flex"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
[[package]]
name = "lzma-rust2"
@ -4258,16 +4185,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]]
name = "macros"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@ -4382,7 +4299,7 @@ dependencies = [
"strum_macros",
"symphonia",
"sys-locale",
"sysinfo 0.39.6",
"sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@ -5291,7 +5208,7 @@ dependencies = [
"console_error_panic_hook",
"console_log",
"image",
"itertools",
"itertools 0.14.0",
"js-sys",
"libloading 0.8.6",
"log",
@ -5523,16 +5440,6 @@ dependencies = [
"portable-atomic",
]
[[package]]
name = "posting_list"
version = "0.0.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"bitpacking",
"common",
"zerocopy",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@ -5757,25 +5664,97 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "qdrant-edge"
version = "0.7.2"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b8072302c87506a34bffec9bc16dbdcd36df8ab1321406b6e141530348c7e54"
dependencies = [
"ahash",
"bm25",
"common",
"aligned-vec",
"arrayvec 0.7.6",
"atomic_refcell",
"atomicwrites",
"bincode 1.3.3",
"bitpacking",
"bitvec",
"blink-alloc",
"bytemuck",
"byteorder",
"cc",
"cgroups-rs",
"charabia",
"chrono",
"core_affinity",
"crc32c",
"data-encoding",
"docopt",
"duplicate",
"ecow",
"env_logger",
"fnv",
"fs-err",
"itertools",
"fs4",
"fs_extra",
"geo",
"geohash",
"half 2.7.1",
"humantime",
"indexmap 2.14.0",
"integer-encoding",
"io-uring",
"itertools 0.15.0",
"log",
"lz4_flex",
"macro_rules_attribute",
"memmap2",
"murmur3_32",
"nix 0.31.3",
"nom 8.0.0",
"num-cmp",
"num-derive",
"num-traits",
"num_cpus",
"once_cell",
"ordered-float 5.3.0",
"parking_lot",
"permutation_iterator",
"ph",
"procfs",
"qdrant-rust-stemmers",
"quick_cache",
"rand 0.10.2",
"segment",
"rand_distr",
"rayon",
"rmp-serde",
"roaring",
"rustix 1.1.4",
"schemars 0.8.22",
"self_cell",
"semver",
"serde",
"serde-untagged",
"serde-value",
"serde_cbor",
"serde_json",
"shard",
"sparse",
"serde_variant",
"sha2 0.11.0",
"siphasher",
"slab",
"smallvec",
"strum",
"sysinfo",
"tap",
"tar",
"tempfile",
"thiserror 2.0.18",
"thread-priority",
"tinyvec",
"tokio",
"tonic",
"uuid",
"wal",
"validator",
"vaporetto",
"walkdir",
"zerocopy",
]
[[package]]
@ -5797,27 +5776,6 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quantization"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"arrayvec 0.7.6",
"bytemuck",
"cc",
"common",
"fs-err",
"num-traits",
"ordered-float 5.3.0",
"parking_lot",
"permutation_iterator",
"rand 0.10.2",
"rayon",
"serde",
"serde_json",
"strum",
]
[[package]]
name = "quick-error"
version = "2.0.1"
@ -5854,73 +5812,16 @@ dependencies = [
[[package]]
name = "quick_cache"
version = "0.6.22"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c821816e9b928e20e92ed59bb3ac4aab321d16ca2316871c9fe7ca739cd477"
checksum = "403c1a912fec895cafb223201e368234842acb9220aaf08ab042ae89ba5f135c"
dependencies = [
"ahash",
"equivalent",
"hashbrown 0.16.1",
"foldhash 0.2.0",
"hashbrown 0.17.0",
"parking_lot",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.4.2",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@ -6065,7 +5966,7 @@ dependencies = [
"built",
"cfg-if",
"interpolate_name",
"itertools",
"itertools 0.14.0",
"libc",
"libfuzzer-sys",
"log",
@ -6131,7 +6032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
dependencies = [
"either",
"itertools",
"itertools 0.14.0",
"rayon",
]
@ -6266,10 +6167,8 @@ checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
@ -6280,7 +6179,6 @@ dependencies = [
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@ -6526,7 +6424,6 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
@ -6700,78 +6597,6 @@ dependencies = [
"xxhash-rust",
]
[[package]]
name = "segment"
version = "0.6.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"ahash",
"atomic_refcell",
"atomicwrites",
"bincode 1.3.3",
"bitvec",
"bytemuck",
"byteorder",
"cc",
"cgroups-rs",
"charabia",
"chrono",
"common",
"data-encoding",
"duplicate",
"ecow",
"fnv",
"fs-err",
"fs_extra",
"geo",
"geohash",
"gpu",
"gridstore",
"half 2.7.1",
"indexmap 2.14.0",
"integer-encoding",
"io-uring",
"itertools",
"log",
"macro_rules_attribute",
"macros",
"memmap2",
"nom 8.0.0",
"num-cmp",
"num-derive",
"num-traits",
"ordered-float 5.3.0",
"parking_lot",
"posting_list",
"procfs",
"qdrant-rust-stemmers",
"quantization",
"rand 0.10.2",
"rayon",
"roaring",
"schemars 0.8.22",
"self_cell",
"serde",
"serde-untagged",
"serde-value",
"serde_cbor",
"serde_json",
"serde_variant",
"sha2 0.11.0",
"smallvec",
"sparse",
"strum",
"sysinfo 0.38.4",
"tap",
"tempfile",
"thiserror 2.0.18",
"tinyvec",
"uuid",
"validator",
"vaporetto",
"zerocopy",
]
[[package]]
name = "selectors"
version = "0.36.1"
@ -7050,39 +6875,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "shard"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"ahash",
"chrono",
"common",
"fs-err",
"fs4",
"indexmap 2.14.0",
"itertools",
"log",
"ordered-float 5.3.0",
"parking_lot",
"rand 0.10.2",
"rmp-serde",
"schemars 0.8.22",
"segment",
"serde",
"serde_cbor",
"serde_json",
"smallvec",
"sparse",
"strum",
"tempfile",
"thiserror 2.0.18",
"tonic",
"uuid",
"validator",
"wal",
]
[[package]]
name = "shared_child"
version = "1.0.0"
@ -7223,32 +7015,6 @@ dependencies = [
"smallvec",
]
[[package]]
name = "sparse"
version = "0.1.0"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"bincode 1.3.3",
"bitpacking",
"common",
"fs-err",
"gridstore",
"half 2.7.1",
"itertools",
"log",
"memmap2",
"ordered-float 5.3.0",
"parking_lot",
"rand 0.10.2",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"typed-arena",
"validator",
"zerocopy",
]
[[package]]
name = "spm_precompiled"
version = "0.1.4"
@ -7590,20 +7356,6 @@ dependencies = [
"libc",
]
[[package]]
name = "sysinfo"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
]
[[package]]
name = "sysinfo"
version = "0.39.6"
@ -8080,7 +7832,7 @@ dependencies = [
"serde_with",
"swift-rs",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"toml 1.1.4+spec-1.1.0",
"url",
"urlpattern",
"uuid",
@ -8260,7 +8012,7 @@ dependencies = [
"esaxx-rs",
"getrandom 0.3.1",
"indicatif",
"itertools",
"itertools 0.14.0",
"log",
"macro_rules_attribute",
"monostate",
@ -8626,12 +8378,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-arena"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
[[package]]
name = "typed-path"
version = "0.12.2"
@ -8948,25 +8694,6 @@ dependencies = [
"libc",
]
[[package]]
name = "wal"
version = "0.1.4"
source = "git+https://github.com/SommerEngineering/qdrant.git?rev=462c84d82ced126e4a2b7914544bfde16a509eb1#462c84d82ced126e4a2b7914544bfde16a509eb1"
dependencies = [
"byteorder",
"crc32c",
"docopt",
"env_logger",
"fs-err",
"fs4",
"log",
"memmap2",
"rand 0.10.2",
"rand_distr",
"rustix 1.1.4",
"serde",
]
[[package]]
name = "walkdir"
version = "2.5.0"

View File

@ -67,7 +67,7 @@ tempfile = "3.27.0"
strum_macros = "0.28.0"
sysinfo = "0.39.6"
bytes = "1.12.1"
qdrant-edge = "0.7.2"
qdrant-edge = "0.8.0"
# Prompt-injection detection. `regex` gives us linear-time matching without backtracking, so
# a hostile document cannot make a scan blow up, and `aho-corasick` matches the ~1600 fixed
@ -79,12 +79,9 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
tokenizers = "0.23.1"
[patch.crates-io]
# Issue: It was not possible to build qdrant-edge for macOS. See PR 9312: https://github.com/qdrant/qdrant/pull/9312
# State: The PR was merged, but not yet released. We use the git version for now.
qdrant-edge = { git = "https://github.com/SommerEngineering/qdrant.git", rev = "462c84d82ced126e4a2b7914544bfde16a509eb1" }
# Issue: This repo was not updated since 2020. The rand crate was outdated. We patched it to use a newer version of rand.
# State: There is a PR for a long time, but it was not merged. We use the git version for now.
# State: There is a PR for a long time, but it was not merged. We use the git version for now. Qdrant Edge still depends
# on this crate in version 0.1.2, so the patch stays relevant: https://github.com/asimihsan/permutation-iterator-rs/pull/14
permutation_iterator = { git = "https://github.com/SommerEngineering/permutation-iterator-rs.git", rev = "76836ed316d18dfef530ba908f58481c343e80d7" }
[target.'cfg(target_os = "windows")'.dependencies]

View File

@ -2,81 +2,35 @@
This directory documents temporary patches for third-party Rust dependencies.
## Qdrant Edge
## permutation_iterator
AI Studio temporarily uses a pinned commit from `SommerEngineering/qdrant` for `qdrant-edge`.
The fork commit exposes Qdrant's internal `lib/edge` crate as `qdrant-edge` and applies the
trait-solver fix from Qdrant PR #9312.
`qdrant-edge` depends on `permutation_iterator 0.1.2`, and that crate has seen no release since
2019. Its published version pulls in an outdated `rand` line, which drags a second copy of the
whole `rand` family into our dependency tree: `rand 0.7.3`, `rand_core 0.5.1`, `rand_chacha 0.2`,
`rand_hc`, `getrandom 0.1.16`, `wasi 0.9` and `cfg-if 0.1`, next to the current ones everything
else uses.
When updating to a newer Qdrant Edge version, replace the placeholder values first:
The fork `SommerEngineering/permutation-iterator-rs` is the published 0.1.2 with `rand` raised to
0.8, so it still satisfies what `qdrant-edge` asks for. AI Studio pins it in `runtime/Cargo.toml`:
```bash
export QDRANT_EDGE_VERSION="0.7.2"
export QDRANT_BRANCH="ai-studio-qdrant-edge-${QDRANT_EDGE_VERSION}"
export AISTUDIO_REPO="xxx/mindwork-ai-studio"
export QDRANT_REPO="xxx/qdrant"
```toml
[patch.crates-io]
permutation_iterator = { git = "https://github.com/SommerEngineering/permutation-iterator-rs.git", rev = "..." }
```
1. Sync the Qdrant fork with upstream:
The same change was offered upstream in
[asimihsan/permutation-iterator-rs#14](https://github.com/asimihsan/permutation-iterator-rs/pull/14),
where it has been waiting since 2021. This is tree hygiene, not a build failure: without the patch
the runtime still builds, it just carries the old `rand` family along.
```bash
cd "$QDRANT_REPO"
git remote add upstream https://github.com/qdrant/qdrant.git 2>/dev/null || true
git fetch upstream
git fetch origin
git switch master
git merge --ff-only upstream/master
git push origin master
```
### When this patch can go
2. Create a fresh AI Studio branch in the Qdrant fork:
Either of these is enough, and both are worth a look whenever `qdrant-edge` is updated:
```bash
cd "$QDRANT_REPO"
git switch -c "$QDRANT_BRANCH" master
```
- crates.io carries a `permutation_iterator` newer than 0.1.2 which uses a current `rand`. Then the
`[patch.crates-io]` entry goes, and `qdrant-edge`'s own requirement decides the version.
- `qdrant-edge` stops depending on `permutation_iterator` at all. Check with
`cargo tree -i permutation_iterator` after the update.
3. Apply the AI Studio patch if upstream has not released the fix yet:
```bash
cd "$QDRANT_REPO"
git apply "$AISTUDIO_REPO/runtime/patches/qdrant-edge-ai-studio.patch"
```
4. Update the exposed `qdrant-edge` version in the fork:
```bash
cd "$QDRANT_REPO"
perl -0pi -e "s/name = \"qdrant-edge\"\\nversion = \"[^\"]+\"/name = \"qdrant-edge\"\\nversion = \"$ENV{QDRANT_EDGE_VERSION}\"/" lib/edge/Cargo.toml
```
5. Commit and push the fork branch:
```bash
cd "$QDRANT_REPO"
git diff
git add lib/edge/Cargo.toml lib/segment/src/common/anonymize.rs
git commit -m "Expose qdrant-edge ${QDRANT_EDGE_VERSION} package for AI Studio"
git push origin "$QDRANT_BRANCH"
export QDRANT_EDGE_COMMIT="$(git rev-parse HEAD)"
echo "$QDRANT_EDGE_COMMIT"
```
6. Update AI Studio to use the new Qdrant Edge version and fork commit:
```bash
cd "$AISTUDIO_REPO"
perl -0pi -e "s/qdrant-edge = \"[^\"]+\"/qdrant-edge = \"$ENV{QDRANT_EDGE_VERSION}\"/" runtime/Cargo.toml
perl -0pi -e "s/rev = \"[0-9a-f]+\"/rev = \"$ENV{QDRANT_EDGE_COMMIT}\"/" runtime/Cargo.toml
```
7. Refresh the AI Studio lock file and verify the Rust runtime:
```bash
cd "$AISTUDIO_REPO/runtime"
cargo update -p qdrant-edge
cargo check
```
Remove the patch and the `[patch.crates-io]` override once Qdrant publishes a fixed `qdrant-edge`
release on crates.io.
Afterward, `grep 'name = "rand"' -A 2 runtime/Cargo.lock` must not show a 0.7 version anymore.
`SommerEngineering/permutation-iterator-rs` can then be deleted.

View File

@ -1,26 +0,0 @@
diff --git a/lib/edge/Cargo.toml b/lib/edge/Cargo.toml
index 7c2cf6037..d21e3c053 100644
--- a/lib/edge/Cargo.toml
+++ b/lib/edge/Cargo.toml
@@ -1,6 +1,6 @@
[package]
-name = "edge"
-version = "0.1.0"
+name = "qdrant-edge"
+version = "0.7.2"
authors = ["Qdrant Team <info@qdrant.tech>"]
license = "Apache-2.0"
edition = "2024"
diff --git a/lib/segment/src/common/anonymize.rs b/lib/segment/src/common/anonymize.rs
index 6b5d19b12..c73d24433 100644
--- a/lib/segment/src/common/anonymize.rs
+++ b/lib/segment/src/common/anonymize.rs
@@ -105,7 +105,7 @@ where
{
collection_opt
.as_ref()
- .map(|c| anonymize_collection_values(c))
+ .map(|c| anonymize_collection_values::<C, K, V>(c))
}
impl Anonymize for String {

View File

@ -1,11 +1,13 @@
use log::{error, info};
use log::{error, info, warn};
use axum::extract::Query;
use axum::Json;
use file_format::FileFormat;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
use crate::api_token::APIToken;
use crate::app_window::MAIN_WINDOW;
use crate::file_data::is_executable_content;
#[cfg(any(windows, target_os = "macos"))]
use std::process::Command;
@ -55,6 +57,14 @@ pub struct OpenPathOptions {
path: String,
}
#[derive(Clone, Deserialize)]
pub struct OpenDocumentOptions {
path: String,
/// The page to show, counted from one, or `None` when the document has no page to show.
page: Option<u32>,
}
#[derive(Serialize)]
pub struct DirectorySelectionResponse {
user_cancelled: bool,
@ -85,6 +95,20 @@ pub struct OpenPathResponse {
issue: String,
}
#[derive(Serialize)]
pub struct OpenDocumentResponse {
success: bool,
/// Whether the document was handed to a program together with the page it should show.
///
/// False means the document opens on its first page: no page was asked for, the system uses a
/// program we cannot tell a page, or the attempt to start that program failed. None of these
/// is an error — the document opens either way — so the app only notes it in its log.
page_applied: bool,
issue: String,
}
#[derive(Clone, Deserialize)]
pub struct PreviousFile {
file_path: String,
@ -386,6 +410,432 @@ async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> {
}
}
/// Opens a document in the program the system uses for it, on the given page where that is possible.
///
/// The page is best effort and never decides whether this succeeded: a viewer which cannot be told
/// a page still shows the document, which is what the user asked for by clicking a source.
pub async fn open_document(
_token: APIToken,
payload: Json<OpenDocumentOptions>,
) -> Json<OpenDocumentResponse> {
let requested_path = PathBuf::from(payload.path.trim());
if let Some(issue) = refuse_document(&requested_path) {
error!(Source = "Tauri"; "Refused to open a document: {issue}");
return Json(OpenDocumentResponse {
success: false,
page_applied: false,
issue,
});
}
//
// A page of zero is how a caller says it has none: a slide and a spreadsheet row are not
// pages, and neither is a passage whose page the index never learned.
//
let page = payload.page.filter(|page| *page > 0);
if let Some(page) = page && try_open_at_page(&requested_path, page).await {
info!("Opened document at page {page}: {requested_path:?}");
return Json(OpenDocumentResponse {
success: true,
page_applied: true,
issue: String::new(),
});
}
match tauri_plugin_opener::open_path(&requested_path, None::<&str>) {
Ok(()) => {
info!("Opened document: {requested_path:?}");
Json(OpenDocumentResponse {
success: true,
page_applied: false,
issue: String::new(),
})
},
Err(error) => {
let issue = format!("Failed to open the document: {error}");
error!(Source = "Tauri"; "{issue}");
Json(OpenDocumentResponse {
success: false,
page_applied: false,
issue,
})
},
}
}
/// Extensions which start something instead of being something.
///
/// Such a file gives nothing away by its content — a `.desktop` entry and a `.cmd` script are
/// plain text, a `.lnk` is a shortcut — so its name is the only thing left to recognize it by.
const LAUNCHER_EXTENSIONS: [&str; 10] = [
"desktop", "command", "lnk", "url", "bat", "cmd", "ps1", "vbs", "scpt", "app",
];
/// Says why a document must not be opened, or `None` when it may be.
///
/// The path arrives from a data source: a folder the user pointed us at, or an ERI server which is
/// free to name any file it likes. This endpoint hands a file to whatever the system has registered
/// for it, so the line worth drawing is that a document is opened and a program is never started.
/// It is drawn here because this is the one place every caller passes through.
fn refuse_document(requested_path: &Path) -> Option<String> {
if requested_path.as_os_str().is_empty() {
return Some(String::from("The path is empty."));
}
if !requested_path.is_file() {
return Some(format!("The path is not a file: {}", requested_path.to_string_lossy()));
}
let extension = requested_path.extension()
.map(|extension| extension.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if LAUNCHER_EXTENSIONS.contains(&extension.as_str()) {
return Some(format!(
"A file of type '{extension}' starts a program instead of showing a document and is not opened: {}",
requested_path.to_string_lossy(),
));
}
match FileFormat::from_file(requested_path) {
Ok(format) if is_executable_content(format) => Some(format!(
"The file is a program, not a document, and is not opened: {}",
requested_path.to_string_lossy(),
)),
//
// A file whose content we cannot place is not a file we refuse. The format is asked in
// order to catch a program carrying a harmless extension, nothing else; what the system
// makes of anything else is the system's decision, as it is for every other file.
//
Ok(_) => None,
Err(error) => {
warn!(Source = "Tauri"; "Could not identify the content of '{}': {error}", requested_path.to_string_lossy());
None
},
}
}
/// Tries to show the document on the given page, and says whether it did.
#[cfg(any(windows, target_os = "linux"))]
async fn try_open_at_page(path: &Path, page: u32) -> bool {
let DocumentOpenPlan::WithPage { program, arguments } = resolve_document_open_plan(path, page).await else {
return false;
};
match start_page_aware_viewer(&program, &arguments) {
Ok(()) => true,
//
// Failing to start the viewer ourselves is not something the user has to hear about: the
// caller opens the document plainly afterwards, only without the page.
//
Err(issue) => {
warn!(Source = "Tauri"; "Could not open '{}' at page {page}, opening it without a page instead: {issue}", path.to_string_lossy());
false
},
}
}
/// Never shows a page on macOS.
///
/// `open` drops the fragment of a URL before the program it starts ever sees it, with and without
/// `-a`, so a page cannot be named from the command line at all. The document opens on its first
/// page, and the source names the page for the reader.
#[cfg(target_os = "macos")]
async fn try_open_at_page(_path: &Path, _page: u32) -> bool {
false
}
/// How a document viewer wants to be told which page to show.
///
/// They all mean the same thing and every one of them spells it differently. A viewer which is not
/// covered here shows its first page, which is what the system would have done anyway.
///
/// Which spellings exist follows from where a viewer is found: Acrobat is named by the Windows
/// registration and by nothing else, and the three Linux viewers are named by a desktop entry and
/// by nothing else. Only a browser is reached on both, so only its spelling is needed everywhere.
#[cfg(any(windows, target_os = "linux", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PageArgument {
/// The page travels in the URL fragment, the way the PDF Open Parameters define it. Browsers
/// read it, and on Windows a browser is what most people open a PDF with.
UrlFragment,
/// Acrobat and Acrobat Reader take an open action: `/A page=12`.
#[cfg(any(windows, test))]
AcrobatOpenAction,
/// The GNOME document viewer and its forks count from zero, so page 12 is index 11.
#[cfg(any(target_os = "linux", test))]
ZeroBasedIndex,
/// Okular takes `-p 12`.
#[cfg(any(target_os = "linux", test))]
OkularPage,
/// Zathura takes `-P 12`.
#[cfg(any(target_os = "linux", test))]
ZathuraPage,
}
/// What it takes to show a document on a page.
#[cfg(any(windows, target_os = "linux"))]
#[derive(Debug, PartialEq, Eq)]
enum DocumentOpenPlan {
/// Hand the file to the system and let it decide. The document opens on its first page.
Plain,
/// Start this program ourselves, because it takes the page as an argument.
WithPage { program: String, arguments: Vec<String> },
}
/// Whether this file is a PDF.
///
/// Only PDFs are sent to a page: the handler is looked up for PDFs, and the arguments below are
/// the ones PDF viewers understand. A Word file has a page too, but the programs which show one
/// cannot be told to go there.
#[cfg(any(windows, target_os = "linux", test))]
fn is_pdf_document(path: &Path) -> bool {
path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
}
/// Builds the arguments which name the page, in the spelling this viewer expects.
#[cfg(any(windows, target_os = "linux", test))]
fn page_arguments(argument: PageArgument, path: &Path, page: u32) -> Option<Vec<String>> {
let path_argument = path.to_string_lossy().to_string();
Some(match argument {
PageArgument::UrlFragment => vec![document_url_with_page(path, page)?],
#[cfg(any(windows, test))]
PageArgument::AcrobatOpenAction => vec![String::from("/A"), format!("page={page}"), path_argument],
#[cfg(any(target_os = "linux", test))]
PageArgument::ZeroBasedIndex => vec![format!("--page-index={}", page.saturating_sub(1)), path_argument],
#[cfg(any(target_os = "linux", test))]
PageArgument::OkularPage => vec![String::from("-p"), page.to_string(), path_argument],
#[cfg(any(target_os = "linux", test))]
PageArgument::ZathuraPage => vec![String::from("-P"), page.to_string(), path_argument],
})
}
/// Builds a `file:` URL which names the page, the way the PDF Open Parameters define it.
///
/// The URL is built instead of written by hand because a path may hold spaces, umlauts or a hash
/// of its own, and writing one by hand turns those into a different path or into a second fragment.
#[cfg(any(windows, target_os = "linux", test))]
fn document_url_with_page(path: &Path, page: u32) -> Option<String> {
let mut url = tauri::Url::from_file_path(path).ok()?;
url.set_fragment(Some(&format!("page={page}")));
Some(url.to_string())
}
/// Starts the viewer. Success means the program was started, not that it showed the page.
///
/// Waiting for it to say so is not possible: a viewer runs until the user closes it, so waiting
/// would hold the request open for as long as the document stays on screen.
#[cfg(any(windows, target_os = "linux"))]
fn start_page_aware_viewer(program: &str, arguments: &[String]) -> Result<(), String> {
let mut command = std::process::Command::new(program);
command.args(arguments);
#[cfg(windows)]
command.creation_flags(CREATE_NO_WINDOW);
command.spawn()
.map(|_| ())
.map_err(|error| format!("Failed to start '{program}': {error}"))
}
#[cfg(any(windows, target_os = "linux"))]
async fn resolve_document_open_plan(path: &Path, page: u32) -> DocumentOpenPlan {
if !is_pdf_document(path) {
return DocumentOpenPlan::Plain;
}
#[cfg(windows)]
{
let Some(prog_id) = windows_default_pdf_prog_id() else {
return DocumentOpenPlan::Plain;
};
let Some(argument) = windows_page_argument(&prog_id) else {
return DocumentOpenPlan::Plain;
};
let Some(program) = windows_handler_executable(&prog_id) else {
return DocumentOpenPlan::Plain;
};
let Some(arguments) = page_arguments(argument, path, page) else {
return DocumentOpenPlan::Plain;
};
DocumentOpenPlan::WithPage { program, arguments }
}
#[cfg(target_os = "linux")]
{
let Some(desktop_id) = linux_default_pdf_handler().await else {
return DocumentOpenPlan::Plain;
};
let Some((program, argument)) = linux_page_aware_program(&desktop_id) else {
return DocumentOpenPlan::Plain;
};
let Some(arguments) = page_arguments(argument, path, page) else {
return DocumentOpenPlan::Plain;
};
DocumentOpenPlan::WithPage { program, arguments }
}
}
/// Reads which program the user opens PDFs with.
///
/// The user's own choice comes first; the class registration is what is left when they never made
/// one, for instance right after the system was installed.
#[cfg(windows)]
fn windows_default_pdf_prog_id() -> Option<String> {
use windows_registry::*;
const USER_CHOICE_KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.pdf\UserChoice";
if let Ok(key) = CURRENT_USER.open(USER_CHOICE_KEY) && let Ok(prog_id) = key.get_string("ProgId") {
return Some(prog_id);
}
CLASSES_ROOT.open(".pdf").ok()
.and_then(|key| key.get_string("").ok())
.filter(|prog_id| !prog_id.is_empty())
}
/// Reads the program behind a registered file type.
#[cfg(windows)]
fn windows_handler_executable(prog_id: &str) -> Option<String> {
use windows_registry::*;
let command = CLASSES_ROOT.open(format!(r"{prog_id}\shell\open\command")).ok()?
.get_string("").ok()?;
executable_from_command(&command)
}
/// Picks the program out of a registry open command such as
/// `"C:\Program Files\...\msedge.exe" --single-argument %1`.
///
/// The arguments the command carries are dropped on purpose: they are written for a file name, and
/// what follows is a URL naming a page instead.
#[cfg(any(windows, test))]
fn executable_from_command(command: &str) -> Option<String> {
let command = command.trim();
let executable = match command.strip_prefix('"') {
Some(quoted) => quoted.split('"').next()?,
None => command.split_whitespace().next()?,
};
let executable = executable.trim();
if executable.is_empty() {
None
} else {
Some(String::from(executable))
}
}
/// Maps the registered file type onto the way its program wants to hear about a page.
#[cfg(any(windows, test))]
fn windows_page_argument(prog_id: &str) -> Option<PageArgument> {
let prog_id = prog_id.to_ascii_lowercase();
//
// Acrobat is asked about first, because its registration says nothing about a browser while
// the browsers below are recognized by their own name in it.
//
if prog_id.contains("acroexch") || prog_id.contains("acrobat") {
return Some(PageArgument::AcrobatOpenAction);
}
const BROWSERS: [&str; 5] = ["msedge", "chrome", "firefox", "opera", "brave"];
if BROWSERS.iter().any(|browser| prog_id.contains(browser)) {
return Some(PageArgument::UrlFragment);
}
None
}
/// Reads which program the desktop opens PDFs with.
///
/// Inside a Flatpak there is nothing to read: the sandbox has its own list of registered programs
/// rather than the desktop's, and even the right answer would name a program which is not in the
/// sandbox to be started. The document is handed to the desktop portal instead, which opens it on
/// its first page.
#[cfg(target_os = "linux")]
async fn linux_default_pdf_handler() -> Option<String> {
if crate::environment::is_flatpak() {
return None;
}
let output = tokio::process::Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
//
// More than one entry can be registered, and the first one is the one the desktop uses.
//
let desktop_id = String::from_utf8_lossy(&output.stdout).lines().next()?.trim().to_string();
if desktop_id.is_empty() {
None
} else {
Some(desktop_id)
}
}
/// Maps a desktop entry onto the program behind it and the way that program wants to hear about a page.
///
/// A desktop id is not the name of a binary — GNOME's viewer answers `org.gnome.Evince.desktop` —
/// so reading the desktop file would be the thorough way to find the program. Recognizing the few
/// viewers which can be sent to a page at all is the short one, and everything else opens the way
/// it always did, through the desktop's own handler.
#[cfg(any(target_os = "linux", test))]
fn linux_page_aware_program(desktop_id: &str) -> Option<(String, PageArgument)> {
const KNOWN_VIEWERS: [(&str, &str, PageArgument); 9] = [
//
// Atril and Xreader are forks of Evince and count their pages from zero just as it does.
//
("evince", "evince", PageArgument::ZeroBasedIndex),
("atril", "atril", PageArgument::ZeroBasedIndex),
("xreader", "xreader", PageArgument::ZeroBasedIndex),
("okular", "okular", PageArgument::OkularPage),
("zathura", "zathura", PageArgument::ZathuraPage),
//
// Chrome is asked about before Chromium, so that a desktop entry naming both lands on the
// program the user actually installed.
//
("google-chrome", "google-chrome", PageArgument::UrlFragment),
("chromium", "chromium", PageArgument::UrlFragment),
("microsoft-edge", "microsoft-edge", PageArgument::UrlFragment),
("firefox", "firefox", PageArgument::UrlFragment),
];
let desktop_id = desktop_id.to_ascii_lowercase();
KNOWN_VIEWERS.iter()
.find(|(needle, _, _)| desktop_id.contains(needle))
.map(|(_, program, argument)| (String::from(*program), *argument))
}
/// Applies an optional file type filter to a FileDialogBuilder.
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
match filter {
@ -625,4 +1075,153 @@ mod tests {
assert!(resolve_file_manager_target(&invalid_path).is_none());
}
/// The bytes an ELF binary starts with. A file which begins like this is a program, whatever
/// its name promises.
const ELF_HEADER: &[u8] = b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00";
#[test]
fn a_document_may_be_opened() {
let temp_dir = tempfile::tempdir().unwrap();
let document_path = temp_dir.path().join("handbook.pdf");
fs::write(&document_path, b"%PDF-1.7\n% a handbook\n").unwrap();
assert_eq!(refuse_document(&document_path), None);
}
/// A program which carries a harmless extension is the case this guard exists for: nothing
/// about the name says what it is, so the content has to.
#[test]
fn a_program_named_like_a_document_is_refused() {
let temp_dir = tempfile::tempdir().unwrap();
let disguised_path = temp_dir.path().join("handbook.pdf");
fs::write(&disguised_path, ELF_HEADER).unwrap();
let refusal = refuse_document(&disguised_path).unwrap();
assert!(refusal.contains("is a program"), "The refusal says why: {refusal}");
}
/// The other way round: a launcher is plain text and gives nothing away, so it is refused by
/// its name.
#[test]
fn a_launcher_is_refused_although_it_reads_like_text() {
let temp_dir = tempfile::tempdir().unwrap();
let launcher_path = temp_dir.path().join("handbook.desktop");
fs::write(&launcher_path, "[Desktop Entry]\nExec=rm -rf ~\n").unwrap();
let refusal = refuse_document(&launcher_path).unwrap();
assert!(refusal.contains("starts a program"), "The refusal says why: {refusal}");
}
#[test]
fn a_launcher_is_refused_whatever_its_extension_is_spelled_like() {
let temp_dir = tempfile::tempdir().unwrap();
let launcher_path = temp_dir.path().join("handbook.CMD");
fs::write(&launcher_path, "echo nothing to see here\n").unwrap();
assert!(refuse_document(&launcher_path).is_some());
}
#[test]
fn a_path_which_is_no_file_is_refused() {
let temp_dir = tempfile::tempdir().unwrap();
assert!(refuse_document(&temp_dir.path().join("missing.pdf")).is_some(), "A file which is not there cannot be opened.");
assert!(refuse_document(temp_dir.path()).is_some(), "A folder is not a document.");
assert!(refuse_document(Path::new("")).is_some(), "An empty path names nothing.");
}
#[test]
fn only_a_pdf_is_sent_to_a_page() {
assert!(is_pdf_document(Path::new("/docs/handbook.pdf")));
assert!(is_pdf_document(Path::new("/docs/handbook.PDF")), "How the extension is spelled says nothing about the file.");
assert!(!is_pdf_document(Path::new("/docs/handbook.docx")), "A Word file has pages, but no program which shows one can be told to go there.");
assert!(!is_pdf_document(Path::new("/docs/handbook")));
}
/// Writing the URL by hand would leave the space in the name as it is, and the browser would
/// look for a file whose name ends before it.
#[test]
fn a_browser_is_told_the_page_in_the_url() {
let temp_dir = tempfile::tempdir().unwrap();
let document_path = temp_dir.path().join("Größere Übersicht.pdf");
let arguments = page_arguments(PageArgument::UrlFragment, &document_path, 12).unwrap();
assert_eq!(arguments.len(), 1, "A browser takes the document and the page as one URL.");
let url = tauri::Url::parse(&arguments[0]).unwrap();
assert_eq!(url.fragment(), Some("page=12"), "The page travels in the fragment, the way the PDF Open Parameters define it.");
assert_eq!(url.to_file_path().unwrap(), document_path, "A name with spaces and umlauts still names the same file.");
}
/// Everybody means page twelve, and everybody says it differently.
#[test]
fn every_viewer_spells_the_page_its_own_way() {
let document = Path::new("/docs/handbook.pdf");
assert_eq!(
page_arguments(PageArgument::AcrobatOpenAction, document, 12).unwrap(),
vec![String::from("/A"), String::from("page=12"), String::from("/docs/handbook.pdf")],
);
assert_eq!(
page_arguments(PageArgument::ZeroBasedIndex, document, 12).unwrap(),
vec![String::from("--page-index=11"), String::from("/docs/handbook.pdf")],
"The GNOME viewer counts from zero, so page twelve is index eleven.",
);
assert_eq!(
page_arguments(PageArgument::OkularPage, document, 12).unwrap(),
vec![String::from("-p"), String::from("12"), String::from("/docs/handbook.pdf")],
);
assert_eq!(
page_arguments(PageArgument::ZathuraPage, document, 12).unwrap(),
vec![String::from("-P"), String::from("12"), String::from("/docs/handbook.pdf")],
);
}
#[test]
fn windows_recognizes_the_programs_it_can_send_to_a_page() {
assert_eq!(windows_page_argument("AcroExch.Document.DC"), Some(PageArgument::AcrobatOpenAction));
assert_eq!(windows_page_argument("MSEdgePDF"), Some(PageArgument::UrlFragment));
assert_eq!(windows_page_argument("ChromePDF"), Some(PageArgument::UrlFragment));
assert_eq!(windows_page_argument("FirefoxPDF"), Some(PageArgument::UrlFragment));
assert_eq!(windows_page_argument("Applications\\SumatraPDF.exe"), None, "A viewer we know nothing about opens its first page.");
}
#[test]
fn the_program_is_read_out_of_the_registered_command() {
assert_eq!(
executable_from_command(r#""C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --single-argument %1"#).as_deref(),
Some(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
"A quoted program keeps the spaces in its path and loses the arguments written for a file name.",
);
assert_eq!(
executable_from_command(r#"C:\Windows\System32\viewer.exe "%1""#).as_deref(),
Some(r"C:\Windows\System32\viewer.exe"),
);
assert_eq!(executable_from_command(" "), None);
}
#[test]
fn linux_recognizes_the_programs_it_can_send_to_a_page() {
assert_eq!(
linux_page_aware_program("org.gnome.Evince.desktop"),
Some((String::from("evince"), PageArgument::ZeroBasedIndex)),
"A desktop entry is not the name of a binary, and the binary is what we have to start.",
);
assert_eq!(linux_page_aware_program("okularApplication_pdf.desktop"), Some((String::from("okular"), PageArgument::OkularPage)));
assert_eq!(linux_page_aware_program("org.pwmt.zathura.desktop"), Some((String::from("zathura"), PageArgument::ZathuraPage)));
assert_eq!(linux_page_aware_program("firefox.desktop"), Some((String::from("firefox"), PageArgument::UrlFragment)));
assert_eq!(linux_page_aware_program("google-chrome.desktop"), Some((String::from("google-chrome"), PageArgument::UrlFragment)));
assert_eq!(linux_page_aware_program("chromium_chromium.desktop"), Some((String::from("chromium"), PageArgument::UrlFragment)));
assert_eq!(linux_page_aware_program("com.example.SomeViewer.desktop"), None, "A viewer we know nothing about opens its first page.");
}
}

View File

@ -827,6 +827,16 @@ fn route_from_content(fmt: FileFormat) -> Option<ExtractionRoute> {
}
}
/// Whether the content of a file is a program rather than something to read.
///
/// The extension is not asked: recognizing a program by its content is the whole point, because a
/// program which carries a harmless extension is exactly the case worth stopping. Answering this
/// here keeps one place in charge of what counts as a program — the reader which refuses to read
/// one, and the endpoint which refuses to hand one to the system.
pub(crate) fn is_executable_content(fmt: FileFormat) -> bool {
matches!(route_from_content(fmt), Some(ExtractionRoute::Executable))
}
async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) -> Result<ChunkStream> {
if !Path::new(file_path).exists() {
error!("File does not exist: '{file_path}'");

View File

@ -222,12 +222,21 @@ fn encode(image: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, (StatusC
mod tests {
use super::*;
/// A path no other test works on.
///
/// The name is counted rather than timed. The clock looks unique but is not: these tests run
/// in parallel, and two of them reading it within the same tick got the same path, so one
/// removed the file the other was still working on. That failed about one run in twelve, and
/// never when the tests ran one after another.
fn temporary_image_path(extension: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{unique}.{extension}"))
use std::sync::atomic::{AtomicU32, Ordering};
//
// The process id is part of it as well, so that two test runs at once stay apart.
//
static NEXT_IMAGE: AtomicU32 = AtomicU32::new(0);
let unique = NEXT_IMAGE.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{}-{unique}.{extension}", std::process::id()))
}
#[test]

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@ -11,8 +11,9 @@ use qdrant_edge::external::uuid::Uuid;
use qdrant_edge::{
Condition, Distance, EdgeConfig, EdgeOptimizersConfig, EdgeShard, EdgeVectorParams,
FieldCondition, Filter, HnswIndexConfig, Match, MatchValue, NamedQuery, Payload, PointId,
PointInsertOperations, PointOperations, PointStruct, QueryEnum, ScoredPoint, SearchRequest,
UpdateOperation, ValueVariants, VectorInternal, Vectors, WithPayloadInterface, WithVector,
PointInsertOperations, PointOperations, PointStruct, QueryEnum, QueryRequest, ScoredPoint,
ScoringQuery, UpdateOperation, ValueVariants, VectorInternal, Vectors, WithPayloadInterface,
WithVector,
};
use serde::{Deserialize, Serialize};
use tauri::Manager;
@ -32,6 +33,10 @@ const STORE_INITIALIZATION_MARKER_TEMP: &str = "store_name.tmp";
const STORE_DISPLAY_NAME_MARKER: &str = "data_source_name.txt";
const STORE_DISPLAY_NAME_MARKER_TEMP: &str = "data_source_name.tmp";
/// Marks a response whose store exists on disk but cannot be opened. The .NET side keys its repair
/// offer off this value instead of parsing `issue`, so rewording the message stays harmless.
const ISSUE_CODE_STORE_UNREADABLE: &str = "store-unreadable";
type QdrantEdgeResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
static QDRANT_EDGE_DATABASE: Lazy<Mutex<Option<QdrantEdgeDatabase>>> =
@ -70,7 +75,6 @@ pub struct QdrantEdgeStoragePoint {
pub point_id: String,
pub vector: Vec<f32>,
pub data_source_id: String,
pub data_source_name: String,
pub data_source_type: String,
pub chunk_id: String,
pub parent_file_id: String,
@ -86,8 +90,6 @@ pub struct QdrantEdgeStoragePoint {
pub creation_utc: String,
pub last_write_utc: String,
pub embedded_at_utc: String,
pub confidence_level: String,
pub confidence_level_rank: i32,
}
#[derive(Deserialize)]
@ -130,9 +132,39 @@ pub struct DeleteQdrantEdgeStoreRequest {
pub struct QdrantEdgeResponse<T> {
pub success: bool,
pub issue: String,
pub issue_code: &'static str,
pub data: Option<T>,
}
/// A vector store which is initialized on disk but which Qdrant Edge refuses to open.
///
/// This is deliberately its own error type rather than one more formatted string: a broken store
/// is the one failure the user can act on, and the request layer has to recognize it to label the
/// response. Nothing here deletes the store -- rebuilding the embeddings costs the user time and,
/// with a cloud embedding provider, money, so that stays their decision.
#[derive(Debug)]
struct StoreUnreadableError {
store_name: String,
message: String,
}
impl StoreUnreadableError {
fn new(store_name: &str, path: &Path, source: impl std::fmt::Display) -> Self {
Self {
store_name: store_name.to_string(),
message: format!("Failed to load vector store '{store_name}' from '{}': {source}", path.display()),
}
}
}
impl std::fmt::Display for StoreUnreadableError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for StoreUnreadableError {}
#[derive(Serialize)]
pub struct QdrantEdgeEnsureStoreResult {
pub created: bool,
@ -143,7 +175,6 @@ pub struct QdrantEdgeSearchResult {
pub point_id: String,
pub score: f32,
pub data_source_id: String,
pub data_source_name: String,
pub data_source_type: String,
pub chunk_id: String,
pub parent_file_id: String,
@ -159,8 +190,6 @@ pub struct QdrantEdgeSearchResult {
pub creation_utc: String,
pub last_write_utc: String,
pub embedded_at_utc: String,
pub confidence_level: String,
pub confidence_level_rank: i32,
}
#[derive(Clone, Serialize)]
@ -174,6 +203,10 @@ pub struct QdrantEdgeInfo {
pub struct QdrantEdgeDatabase {
base_path: PathBuf,
shards: HashMap<String, EdgeShard>,
/// Stores whose unreadability has already been logged. A broken store is hit by every single
/// request against it, and one log line per request would bury everything else.
reported_unreadable_stores: HashSet<String>,
}
impl QdrantEdgeDatabase {
@ -181,9 +214,16 @@ impl QdrantEdgeDatabase {
Self {
base_path,
shards: HashMap::new(),
reported_unreadable_stores: HashSet::new(),
}
}
/// Whether this store's defect still has to be written to the log. True exactly once per store,
/// until the store loads again.
fn report_unreadable_store(&mut self, store_name: &str) -> bool {
self.reported_unreadable_stores.insert(store_name.to_string())
}
fn store_path(&self, store_name: &str) -> QdrantEdgeResult<PathBuf> {
validate_store_name(store_name)?;
Ok(self.base_path.join("stores").join(store_directory_name(store_name)))
@ -197,9 +237,10 @@ impl QdrantEdgeDatabase {
}
let shard = if is_initialized {
EdgeShard::load(&path, None).map_err(|error| {
format!("Failed to load vector store '{store_name}' from '{}': {error}", path.display())
})?
match EdgeShard::load(&path, None) {
Ok(shard) => shard,
Err(error) => return Err(StoreUnreadableError::new(store_name, &path, error).into()),
}
} else {
fs::create_dir_all(&path).map_err(|error| {
format!("Failed to create directory for vector store '{store_name}' at '{}': {error}", path.display())
@ -221,6 +262,7 @@ impl QdrantEdgeDatabase {
shard
};
self.reported_unreadable_stores.remove(store_name);
self.shards.insert(store_name.to_string(), shard);
Ok((self.shards.get(store_name).unwrap(), !is_initialized))
}
@ -236,9 +278,12 @@ impl QdrantEdgeDatabase {
return Ok(None);
}
let shard = EdgeShard::load(&path, None).map_err(|error| {
format!("Failed to load vector store '{store_name}' from '{}': {error}", path.display())
})?;
let shard = match EdgeShard::load(&path, None) {
Ok(shard) => shard,
Err(error) => return Err(StoreUnreadableError::new(store_name, &path, error).into()),
};
self.reported_unreadable_stores.remove(store_name);
self.shards.insert(store_name.to_string(), shard);
Ok(self.shards.get(store_name))
}
@ -303,15 +348,7 @@ impl QdrantEdgeDatabase {
return Err("All vectors in one insert request must have the same size.".into());
}
let data_source_name = first_point.data_source_name.clone();
validate_data_source_name(&data_source_name)?;
if points.iter().any(|point| point.data_source_name != data_source_name) {
return Err("All points in one insert request must belong to the same data source name.".into());
}
let store_path = self.store_path(store_name)?;
let (shard, _) = self.get_or_create_store(store_name, vector_size)?;
write_store_display_name(&store_path, &data_source_name)?;
let points = points
.into_iter()
.map(to_qdrant_edge_point)
@ -320,7 +357,7 @@ impl QdrantEdgeDatabase {
shard.update(UpdateOperation::PointOperation(
PointOperations::UpsertPoints(PointInsertOperations::PointsList(points)),
))?;
shard.flush();
shard.flush()?;
Ok(())
}
@ -334,18 +371,19 @@ impl QdrantEdgeDatabase {
return Ok(vec![]);
};
let search_results = shard.search(SearchRequest {
query: QueryEnum::Nearest(NamedQuery::new(
let search_results = shard.query(QueryRequest {
prefetches: Vec::new(),
query: Some(ScoringQuery::Vector(QueryEnum::Nearest(NamedQuery::new(
VectorInternal::Dense(vector),
VECTOR_NAME,
)),
)))),
filter: None,
params: None,
score_threshold: None,
limit: max_matches,
offset: 0,
with_payload: Some(WithPayloadInterface::Bool(true)),
with_vector: Some(WithVector::Bool(false)),
score_threshold: None,
params: None,
with_vector: WithVector::Bool(false),
with_payload: WithPayloadInterface::Bool(true),
})?;
Ok(search_results
@ -362,7 +400,7 @@ impl QdrantEdgeDatabase {
shard.update(UpdateOperation::PointOperation(
PointOperations::DeletePointsByFilter(match_keyword_filter("file_path", file_path)?),
))?;
shard.flush();
shard.flush()?;
Ok(())
}
@ -375,7 +413,7 @@ impl QdrantEdgeDatabase {
if optimized {
info!(Source = "Qdrant Edge"; "Optimized vector store '{}'.", store_name);
}
shard.flush();
shard.flush()?;
Ok(())
}
@ -517,6 +555,7 @@ where
return Json(QdrantEdgeResponse {
success: false,
issue: "Qdrant Edge is not available.".to_string(),
issue_code: "",
data: None,
});
};
@ -525,14 +564,36 @@ where
Ok(data) => Json(QdrantEdgeResponse {
success: true,
issue: String::new(),
issue_code: "",
data: Some(data),
}),
Err(e) => {
let issue = e.to_string();
//
// An unreadable store keeps failing for as long as the user leaves it alone, so it is
// logged once and then only answered. Every other failure is logged as it happens,
// because those are one-offs worth seeing each time.
//
let issue_code = match e.downcast_ref::<StoreUnreadableError>() {
Some(unreadable) => {
if database.report_unreadable_store(&unreadable.store_name) {
error!(Source = "Qdrant Edge"; "Qdrant Edge request failed: {issue}");
}
ISSUE_CODE_STORE_UNREADABLE
},
None => {
error!(Source = "Qdrant Edge"; "Qdrant Edge request failed: {issue}");
""
},
};
Json(QdrantEdgeResponse {
success: false,
issue,
issue_code,
data: None,
})
},
@ -609,7 +670,7 @@ fn remove_obsolete_qdrant_path(path: &Path) {
fn edge_config(vector_size: usize) -> EdgeConfig {
EdgeConfig {
on_disk_payload: true,
on_disk_payload: Some(true),
vectors: HashMap::from([(
VECTOR_NAME.to_string(),
EdgeVectorParams {
@ -623,13 +684,20 @@ fn edge_config(vector_size: usize) -> EdgeConfig {
},
)]),
sparse_vectors: HashMap::new(),
hnsw_config: hnsw_config(),
hnsw_config: Some(hnsw_config()),
quantization_config: None,
optimizers: edge_optimizers_config(),
optimizers: Some(edge_optimizers_config()),
wal_options: None,
max_search_threads: None,
search_pool_core: None,
}
}
// `on_disk` is deprecated in favor of `memory`, but Qdrant Edge does not re-export the `Memory`
// type, so the new field cannot be named from here. Leaving both unset is not an option either:
// the effective placement would fall back to cached instead of on-disk, which is a real change
// and would have the optimizers rebuild the HNSW graph.
#[allow(deprecated)]
fn hnsw_config() -> HnswIndexConfig {
HnswIndexConfig {
m: HNSW_M,
@ -637,6 +705,7 @@ fn hnsw_config() -> HnswIndexConfig {
full_scan_threshold: HNSW_FULL_SCAN_THRESHOLD_KB,
max_indexing_threads: HNSW_MAX_INDEXING_THREADS,
on_disk: Some(true),
memory: None,
payload_m: None,
inline_storage: None,
}
@ -742,7 +811,6 @@ fn to_qdrant_edge_point(point: QdrantEdgeStoragePoint) -> QdrantEdgeResult<qdran
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,
"chunk_id": point.chunk_id,
"parent_file_id": point.parent_file_id,
@ -758,8 +826,6 @@ fn to_qdrant_edge_point(point: QdrantEdgeStoragePoint) -> QdrantEdgeResult<qdran
"creation_utc": point.creation_utc,
"last_write_utc": point.last_write_utc,
"embedded_at_utc": point.embedded_at_utc,
"confidence_level": point.confidence_level,
"confidence_level_rank": point.confidence_level_rank,
}),
)
.into())
@ -771,7 +837,6 @@ fn to_qdrant_edge_search_result(point: ScoredPoint) -> QdrantEdgeSearchResult {
point_id: point_id_to_string(point.id),
score: point.score,
data_source_id: payload_string(&payload, "data_source_id"),
data_source_name: payload_string(&payload, "data_source_name"),
data_source_type: payload_string(&payload, "data_source_type"),
chunk_id: payload_string(&payload, "chunk_id"),
parent_file_id: payload_string(&payload, "parent_file_id"),
@ -787,8 +852,6 @@ fn to_qdrant_edge_search_result(point: ScoredPoint) -> QdrantEdgeSearchResult {
creation_utc: payload_string(&payload, "creation_utc"),
last_write_utc: payload_string(&payload, "last_write_utc"),
embedded_at_utc: payload_string(&payload, "embedded_at_utc"),
confidence_level: payload_string(&payload, "confidence_level"),
confidence_level_rank: payload_i32(&payload, "confidence_level_rank").unwrap_or_default(),
}
}
@ -922,6 +985,44 @@ mod tests {
fs::remove_dir_all(test_directory).unwrap();
}
#[test]
fn an_unreadable_store_is_reported_but_never_deleted() {
let test_directory = std::env::temp_dir().join(format!(
"ai-studio-qdrant-unreadable-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let store_name = "rag_6cc665a82b1e4d42bc748015b7b391ec";
let mut database = QdrantEdgeDatabase::new(test_directory.clone());
assert!(database.ensure_store_exists(store_name, "Some source", 3).unwrap().created);
let store_path = database.store_path(store_name).unwrap();
// Release the shard before breaking it, so the files are not held open any more.
drop(database);
fs::write(store_path.join("edge_config.json"), "this is not a config").unwrap();
let mut database = QdrantEdgeDatabase::new(test_directory.clone());
let error = database.get_existing_store(store_name).unwrap_err();
assert!(
error.downcast_ref::<StoreUnreadableError>().is_some(),
"a store which cannot be opened has to be recognizable as such, not just a message"
);
// The whole point: the user's embeddings survive a defect until they ask for a rebuild.
assert!(store_path.join("segments").is_dir());
assert!(store_path.join(STORE_INITIALIZATION_MARKER).is_file());
// And the defect is logged once, not once per request.
assert!(database.report_unreadable_store(store_name));
assert!(!database.report_unreadable_store(store_name));
fs::remove_dir_all(test_directory).unwrap();
}
#[test]
fn point_ids_must_be_valid_uuids() {
assert!(to_point_id("6cc665a8-2b1e-4d42-bc74-8015b7b391ec").is_ok());

Some files were not shown because too many files have changed in this diff Show More