Compare commits

..
79 changed files with 2602 additions and 2811 deletions

No files matched your search

@@ -3,7 +3,7 @@
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.Rust
<MudText Typo="Typo.h6" Class="mb-3">
<MudText Typo="Typo.h5" Class="mb-3">
@T("Input")
</MudText>
@@ -18,13 +18,8 @@
</MudJustifiedText>
}
<div class="mt-6">
<FilePatternSelection>
<AllowedContent>
<MudTextField T="string" @bind-Text="@this.filePatterns" Validation="@this.ValidateFilePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Files to include")" AdornmentIcon="@Icons.Material.Filled.Restore" Adornment="Adornment.End" OnAdornmentClick="@this.RestoreDefaultFilePatterns" AdornmentAriaLabel="@T("Restore default patterns")" Lines="1" AutoGrow="@true" MaxLines="0" Class="pattern-text-field" Variant="Variant.Outlined" Margin="Margin.Dense" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
</AllowedContent>
</FilePatternSelection>
</div>
<MudTextField T="string" @bind-Text="@this.filePatterns" Validation="@this.ValidateFilePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("File patterns")" HelperText="@T("Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.")" AdornmentIcon="@Icons.Material.Filled.FilterAlt" Adornment="Adornment.Start" Variant="Variant.Outlined" Margin="Margin.Normal" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<RestoreDefaultButton Text="@T("Restore default patterns")" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultFilePatterns" Class="mb-1"/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Supported audio and video files are transcribed automatically without an additional dialog. Each transcript is stored next to its media file as '<media-file>.transcript.md' and reused when an interrupted run is continued.")
@@ -36,22 +31,20 @@
</MudButton>
<MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<MudText Typo="Typo.subtitle2" Class="mb-2">@T("Hidden files and folders")</MudText>
<MudTextSwitch Label="@T("Include hidden files and folders?")" Disabled="@this.isProcessingBatch" Value="@this.includeHiddenFilesAndFolders" ValueChanged="@(v => this.includeHiddenFilesAndFolders = v)" LabelOn="@T("Yes, include hidden files and folders")" LabelOff="@T("No, ignore hidden files and folders")"/>
<MudTextSwitch Label="@T("Include hidden files and folders?")" Disabled="@this.isProcessingBatch" Value="@this.includeHiddenFilesAndFolders" ValueChanged="@(v => this.includeHiddenFilesAndFolders = v)" LabelOn="@T("Yes, process hidden files and folders as well")" LabelOff="@T("No, ignore hidden files and folders")"/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.")
@T("Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.")
</MudJustifiedText>
<div class="mt-4">
<FileExclusionEditor @bind-Text="@this.excludePatterns" Disabled="@this.isProcessingBatch" Label="@T("Excluded files and folders")" ResetButtonText="@T("Restore default")"/>
</div>
@* Without an icon: in a field of several lines, it floats in the middle of the text. *@
<MudTextField T="string" @bind-Text="@this.excludePatterns" Validation="@this.ValidateExcludePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Excluded files and folders")" Lines="@FileExclusions.DEFAULT_LINE_COUNT" AutoGrow="@true" MaxLines="@FileExclusions.MAX_LINE_COUNT" HelperText="@T("These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.")" Variant="Variant.Outlined" Margin="Margin.Normal" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<RestoreDefaultButton Text="@T("Restore default")" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultExcludePatterns" Class="mb-1"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("File size limit")</MudText>
<MudNumericField T="int" @bind-Value="@this.maxFileSizeMegabytes" Min="@FolderFileSelection.SMALLEST_MAX_FILE_SIZE_MEGABYTES" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Ignore files larger than")" HelperText="@T("Matching files above this size are left out, e.g., images of virtual machines.")" Adornment="Adornment.End" AdornmentText="MB" Variant="Variant.Outlined" Margin="Margin.Normal"/>
</MudCollapse>
</MudStack>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Instructions")
</MudText>
@@ -137,7 +130,7 @@ else
}
}
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Output")
</MudText>
@@ -192,7 +185,7 @@ else
@T("We always write a semicolon-separated log named log.csv, which lists every document with its processing time, the model, the status, and the details of any error. When you start another run on the same output folder, we ask you whether to continue that run: documents which failed or are missing in the log are then processed again. When no output folder is selected, everything is written to the subfolder 'ai-results' within the input folder.")
</MudJustifiedText>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Processing pace")
</MudText>
@@ -233,7 +226,7 @@ else
@if (this.fileResults.Count > 0)
{
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Progress")
</MudText>
@@ -168,6 +168,8 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private void RestoreDefaultFilePatterns() => this.filePatterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS;
private void RestoreDefaultExcludePatterns() => this.excludePatterns = FileExclusions.DEFAULT_PATTERNS;
private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings;
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
@@ -409,6 +409,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Select the output folder
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1598970341"] = "Select the output folder"
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1624717965"] = "Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway."
-- The configured default policy no longer exists. Please select another document analysis policy.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T169666151"] = "The configured default policy no longer exists. Please select another document analysis policy."
@@ -430,18 +433,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Please provide a file name without a path, e.g., my-results.csv
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T189587595"] = "Please provide a file name without a path, e.g., my-results.csv"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1902969218"] = "Hidden files and folders"
-- Select the folder containing your documents
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1926838679"] = "Select the folder containing your documents"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1941827239"] = "Yes, include hidden files and folders"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2000156297"] = "Files to include"
-- Include subfolders?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2086334687"] = "Include subfolders?"
@@ -466,6 +460,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to create the output folder: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Was not able to create the output folder: {0}"
-- Yes, process hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2295617720"] = "Yes, process hidden files and folders as well"
-- The AI answer was empty.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T230354366"] = "The AI answer was empty."
@@ -484,6 +481,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to read the existing transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2397111152"] = "Was not able to read the existing transcript: {0}"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2460883298"] = "File patterns"
-- Load prompt from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2474257795"] = "Load prompt from file"
@@ -532,12 +532,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- The batch run finished, but one file could not be processed. See the progress table and log for details.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3201532790"] = "The batch run finished, but one file could not be processed. See the progress table and log for details."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3204694333"] = "Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3225743883"] = "File size limit"
-- Maximum pause between files
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3250003796"] = "Maximum pause between files"
@@ -562,6 +556,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Processing pace
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3428873429"] = "Processing pace"
-- Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3436936162"] = "Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file."
-- The file name of the CSV results table. We add the ending .csv when it is missing. When left empty, we use 'batch-results.csv'.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3439247329"] = "The file name of the CSV results table. We add the ending .csv when it is missing. When left empty, we use 'batch-results.csv'."
@@ -595,6 +592,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to write '{0}'. Please make sure that the file is not opened in another application. The results of this run are incomplete on disk. The message is: '{1}'
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3899869356"] = "Was not able to write '{0}'. Please make sure that the file is not opened in another application. The results of this run are incomplete on disk. The message is: '{1}'"
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3934601817"] = "These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js."
-- Select the file with your instructions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3943995624"] = "Select the file with your instructions"
@@ -4120,9 +4120,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1084943026"] = "Yo
-- Automatic local data source refresh
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1208397349"] = "Automatic local data source refresh"
-- Edit Local Directory Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1215599168"] = "Edit Local Directory Data Source"
-- Refresh
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T135637716"] = "Refresh"
@@ -4150,12 +4147,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2067030738"] = "Ar
-- Local Directory Data Source Information
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2146756020"] = "Local Directory Data Source Information"
-- Edit ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T221059217"] = "Edit ERI v1 Data Source"
-- Edit Local File Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2453292893"] = "Edit Local File Data Source"
-- Local data sources refresh when files change, mailboxes every 16 minutes.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2511157475"] = "Local data sources refresh when files change, mailboxes every 16 minutes."
@@ -4438,78 +4429,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T244391
-- Change what this tile opens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T4272203100"] = "Change what this tile opens"
-- Exclusion help and examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1309128010"] = "Exclusion help and examples"
-- PDF files, regardless of letter case
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T138823545"] = "PDF files, regardless of letter case"
-- Folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1412427146"] = "Folders"
-- the excluded folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1647237555"] = "the excluded folders"
-- File names starting with draft-
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1676890987"] = "File names starting with draft-"
-- Excluded files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T2590452128"] = "Excluded files"
-- Folders ending in build, such as build or app-build
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T27471988"] = "Folders ending in build, such as build or app-build"
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T286958948"] = "Excluded files and folders"
-- Excluded folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3345276250"] = "Excluded folders"
-- Files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3877444400"] = "Files"
-- Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3993888277"] = "Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority."
-- the excluded files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T4083345389"] = "the excluded files"
-- Exclusion examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T727293714"] = "Exclusion examples"
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T913168756"] = "Restore default"
-- Exclude folders ending in build, such as build or app-build
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1241240542"] = "Exclude folders ending in build, such as build or app-build"
-- Pattern help and examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1369669520"] = "Pattern help and examples"
-- Include readable text files, including files without an extension
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1634513321"] = "Include readable text files, including files without an extension"
-- File names starting with draft-
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1676890987"] = "File names starting with draft-"
-- Pattern examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T2430822600"] = "Pattern examples"
-- Separate patterns with semicolons. Exclusions take priority over included files.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T2728429336"] = "Separate patterns with semicolons. Exclusions take priority over included files."
-- PDF and Word files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T3467839719"] = "PDF and Word files"
-- Patterns match file names regardless of letter case. Folder paths are not supported.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T3691425703"] = "Patterns match file names regardless of letter case. Folder paths are not supported."
-- Include all files, including audio and video
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T827447634"] = "Include all files, including audio and video"
-- Include all documents and readable text files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T967949655"] = "Include all documents and readable text files"
-- LLMs can make mistakes. Check important information.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "LLMs can make mistakes. Check important information."
@@ -6808,9 +6727,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG:
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel"
-- Folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T104168189"] = "Folder"
-- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1108876344"] = "Hide Expert Settings"
@@ -6829,24 +6745,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1311820297"
-- Selected base directory for this data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1312296210"] = "Selected base directory for this data source"
-- MindWork AI Studio uses Pandoc to read HTML files.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T133018120"] = "MindWork AI Studio uses Pandoc to read HTML files."
-- No embedding selected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1359179968"] = "No embedding selected"
-- Select a root directory for this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1400580807"] = "Select a root directory for this data source."
-- Provider trust
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T148137342"] = "Provider trust"
-- No, only index files in the selected folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1490813458"] = "No, only index files in the selected folder"
-- Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1588814044"] = "Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens."
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1624717965"] = "Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway."
-- Tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1696723386"] = "Tokenizer"
@@ -6862,18 +6775,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1827669611"
-- Update
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1847791252"] = "Update"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1902969218"] = "Hidden files and folders"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1941827239"] = "Yes, include hidden files and folders"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2000156297"] = "Files to include"
-- Data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2034620186"] = "Data source"
-- Include subfolders?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2086334687"] = "Include subfolders?"
@@ -6889,24 +6790,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T236253137"]
-- Please enter a token limit of at least 1.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2406580478"] = "Please enter a token limit of at least 1."
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2499087427"] = "Search results"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2460883298"] = "File patterns"
-- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2646845972"] = "Add"
-- The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2686150329"] = "The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed."
-- 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"] = "The embedding you selected runs locally or in your organization. Your data is not sent to the cloud."
-- Embedding
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2838542994"] = "Embedding"
-- Choose an embedding provider to make this data source searchable.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2861534545"] = "Choose an embedding provider to make this data source searchable."
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T286958948"] = "Excluded files and folders"
@@ -6916,38 +6811,38 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"
-- Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3017942214"] = "Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Text chunking
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3105524153"] = "Text chunking"
-- Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3119708878"] = "Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3204694333"] = "Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3225743883"] = "File size limit"
-- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length."
-- Ignore files larger than
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3353866230"] = "Ignore files larger than"
-- For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3359366900"] = "For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc."
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3361153305"] = "Show Expert Settings"
-- No, ignore hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3584816527"] = "No, ignore hidden files and folders"
-- Only providers with this confidence level or higher may receive and process data from this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3755939304"] = "Only providers with this confidence level or higher may receive and process data from this data source."
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3934601817"] = "These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js."
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider."
-- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory"
-- Restore default patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T7425959"] = "Restore default patterns"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T599406584"] = "Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider."
-- Yes, index hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Yes, index hidden files and folders as well"
-- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})."
@@ -7000,9 +6895,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T1725856
-- The embedding runs locally or in your organization. Your data is not sent to the cloud.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T1950544032"] = "The embedding runs locally or in your organization. Your data is not sent to the cloud."
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2000156297"] = "Files to include"
-- the file patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2013714981"] = "the file patterns"
@@ -7015,6 +6907,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2333573
-- Required provider confidence level
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T236253137"] = "Required provider confidence level"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2460883298"] = "File patterns"
-- the maximum number of matches per query
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2479753122"] = "the maximum number of matches per query"
@@ -7027,6 +6922,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2658359
-- the data source name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2717738728"] = "the data source name"
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T286958948"] = "Excluded files and folders"
-- The directory chosen for the data source does not exist anymore. Please edit the data source and correct the path.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2875614207"] = "The directory chosen for the data source does not exist anymore. Please edit the data source and correct the path."
@@ -7066,6 +6964,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T3818527
-- Path
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T3949388886"] = "Path"
-- the excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T4001527349"] = "the excluded files and folders"
-- The directory chosen for the data source exists.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T445858624"] = "The directory chosen for the data source exists."
@@ -7081,15 +6982,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "
-- Default tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Default tokenizer"
-- MindWork AI Studio uses Pandoc to read HTML files.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T133018120"] = "MindWork AI Studio uses Pandoc to read HTML files."
-- No embedding selected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1359179968"] = "No embedding selected"
-- Provider trust
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T148137342"] = "Provider trust"
-- Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1588814044"] = "Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens."
@@ -7108,9 +7003,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1827669611"] = "
-- Update
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1847791252"] = "Update"
-- Data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2034620186"] = "Data source"
-- The overlap must be smaller than the effective token limit.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2101951526"] = "The overlap must be smaller than the effective token limit."
@@ -7123,9 +7015,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "
-- This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2468760910"] = "This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2499087427"] = "Search results"
-- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Add"
@@ -7138,32 +7027,26 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2838542994"] = "
-- Describe what data this file contains to help the AI select it.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2859265837"] = "Describe what data this file contains to help the AI select it."
-- Choose an embedding provider to make this data source searchable.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2861534545"] = "Choose an embedding provider to make this data source searchable."
-- Token limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2961294165"] = "Token limit"
-- Text chunking
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3105524153"] = "Text chunking"
-- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length."
-- For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3359366900"] = "For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc."
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3361153305"] = "Show Expert Settings"
-- Select the file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Select the file"
-- Only providers with this confidence level or higher may receive and process data from this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3755939304"] = "Only providers with this confidence level or higher may receive and process data from this data source."
-- This audio or video file is skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T4026150982"] = "This audio or video file is skipped until you choose a transcription provider in the settings."
-- File
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T723007075"] = "File"
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider."
-- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})."
@@ -7171,9 +7054,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T787118522"] = "T
-- Data Source Name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T813773421"] = "Data Source Name"
-- The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T890383764"] = "The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T900713019"] = "Cancel"
@@ -8698,6 +8578,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T15
-- Leave empty to use the ai-results subfolder of the input folder.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1550632323"] = "Leave empty to use the ai-results subfolder of the input folder."
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1624717965"] = "Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway."
-- seconds
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1723256298"] = "seconds"
@@ -8710,18 +8593,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T17
-- Batch processing options are preselected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1893713430"] = "Batch processing options are preselected"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1902969218"] = "Hidden files and folders"
-- Default custom column separator
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T19367494"] = "Default custom column separator"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1941827239"] = "Yes, include hidden files and folders"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2000156297"] = "Files to include"
-- Default document analysis policy
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2015391667"] = "Default document analysis policy"
@@ -8731,6 +8605,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T21
-- Default output folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T223484721"] = "Default output folder"
-- Hidden files and folders are included
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2288334048"] = "Hidden files and folders are included"
-- The lower end of the random pause interval. AI Studio never allows less than 6 seconds.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T237774509"] = "The lower end of the random pause interval. AI Studio never allows less than 6 seconds."
@@ -8743,6 +8620,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T24
-- When enabled, new batch runs start with the defaults configured below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2592677194"] = "When enabled, new batch runs start with the defaults configured below."
-- Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2594325620"] = "Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats."
-- Subfolders are included
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2607092632"] = "Subfolders are included"
@@ -8764,6 +8644,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T28
-- Please enter exactly one punctuation or symbol character. Letters, numbers, spaces, quotation marks, and line breaks cannot be used as CSV separators.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2908365499"] = "Please enter exactly one punctuation or symbol character. Letters, numbers, spaces, quotation marks, and line breaks cannot be used as CSV separators."
-- Default file patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2909693903"] = "Default file patterns"
-- Only the selected folder is processed
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2958253681"] = "Only the selected folder is processed"
@@ -8779,12 +8662,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T31
-- These instructions are applied to every document of a new batch run.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3195548336"] = "These instructions are applied to every document of a new batch run."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3204694333"] = "Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3225743883"] = "File size limit"
-- Ignore files larger than
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3353866230"] = "Ignore files larger than"
@@ -8806,9 +8683,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T34
-- The current content of this Markdown file is loaded whenever the defaults are applied.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3468539567"] = "The current content of this Markdown file is loaded whenever the defaults are applied."
-- No, ignore hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3584816527"] = "No, ignore hidden files and folders"
-- Your organization requires a pause of at least {0} seconds between files. Users can configure only the upper limit.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3663516199"] = "Your organization requires a pause of at least {0} seconds between files. Users can configure only the upper limit."
@@ -8818,6 +8692,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T37
-- Default results table name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3816237687"] = "Default results table name"
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3934601817"] = "These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js."
-- Default Markdown instructions file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3967465682"] = "Default Markdown instructions file"
@@ -8860,6 +8737,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T74
-- Leave empty when an input folder should be selected for every batch run.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T762890100"] = "Leave empty when an input folder should be selected for every batch run."
-- Hidden files and folders are ignored
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T826400562"] = "Hidden files and folders are ignored"
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Restore default"
@@ -10477,6 +10357,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T819555436"] = "Failed mails: {0}"
-- Skipped
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Skipped"
-- Edit this data source
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T878700886"] = "Edit this data source"
-- Open the transcription settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T905182399"] = "Open the transcription settings"
@@ -12313,18 +12196,39 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEM
-- Qdrant Edge is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge is not available."
-- Edit Local Directory Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T1215599168"] = "Edit Local Directory Data Source"
-- Edit ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T221059217"] = "Edit ERI v1 Data Source"
-- Edit Local File Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T2453292893"] = "Edit Local File Data Source"
-- Every other file goes to your embedding provider once more, which takes a while with many files. Files missing at the new location are removed from the index, while your files on disk stay as they are.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T1555760974"] = "Every other file goes to your embedding provider once more, which takes a while with many files. Files missing at the new location are removed from the index, while your files on disk stay as they are."
-- AI Studio keeps what it has already prepared for every file it finds unchanged at the new location, that is, of the same type and with the same size and modification time. In a folder, such a file also has to keep its name and the subfolder it is in.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2205693550"] = "AI Studio keeps what it has already prepared for every file it finds unchanged at the new location, that is, of the same type and with the same size and modification time. In a folder, such a file also has to keep its name and the subfolder it is in."
-- They keep answering keyword searches, but searching them by meaning stops working, and no further documents can be prepared for them. The ones which are already prepared stay tied to this provider as well, so you cannot simply move them to another one.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2343773457"] = "They keep answering keyword searches, but searching them by meaning stops working, and no further documents can be prepared for them. The ones which are already prepared stay tied to this provider as well, so you cannot simply move them to another one."
-- and {0} more.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2519847121"] = "and {0} more."
-- Your embedding provider runs in the cloud, so preparing files again costs money.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2635623258"] = "Your embedding provider runs in the cloud, so preparing files again costs money."
-- This change makes the prepared documents of the following data sources unusable ({0}):
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3337378891"] = "This change makes the prepared documents of the following data sources unusable ({0}):"
-- Do you want to apply this change anyway?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3419411838"] = "Do you want to apply this change anyway?"
-- New Location for This Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3462449568"] = "New Location for This Data Source"
-- Documents Will Be Prepared Again
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T737291513"] = "Documents Will Be Prepared Again"
@@ -12487,12 +12391,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "The
-- policy files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "policy files"
-- Enter folder patterns ending with / in 'Folders'. File patterns must not end with /.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T2019378403"] = "Enter folder patterns ending with / in 'Folders'. File patterns must not end with /."
-- Folder patterns must end with /, e.g., build/. Enter file patterns such as *.pdf in 'Files'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T2695719308"] = "Folder patterns must end with /, e.g., build/. Enter file patterns such as *.pdf in 'Files'."
-- Selectors such as :text: cannot be excluded. Please use file name patterns such as *.min.js or folder names such as bin/.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T405437626"] = "Selectors such as :text: cannot be excluded. Please use file name patterns such as *.min.js or folder names such as bin/."
@@ -12718,6 +12616,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T2207479763"] = "One of the
-- Please provide at least one file pattern, e.g., :documents: or *.txt. Separate multiple patterns with a semicolon.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T3371062811"] = "Please provide at least one file pattern, e.g., :documents: or *.txt. Separate multiple patterns with a semicolon."
-- Please remove empty file patterns. Separate valid patterns with a single semicolon.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T368919579"] = "Please remove empty file patterns. Separate valid patterns with a single semicolon."
-- Please use only single asterisks as wildcards, e.g., *.txt or report-*.md.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T495100949"] = "Please use only single asterisks as wildcards, e.g., *.txt or report-*.md."
@@ -13867,18 +13768,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T8042
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1214987337"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1393648320"] = "The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source."
-- The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1935191670"] = "The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run."
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. If the file was moved or renamed, edit this data source to select its new location. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2268993048"] = "The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. If the file was moved or renamed, edit this data source to select its new location. Once the file is back, refresh this data source. When it is gone for good, remove this data source."
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. If the folder was moved or renamed, edit this data source to select its new location. Once the folder is back, refresh this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2692299965"] = "The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. If the folder was moved or renamed, edit this data source to select its new location. Once the folder is back, refresh this data source."
-- The folder '{0}' currently appears empty. AI Studio kept the index of this data source, because the folder of a drive that is not connected can look empty. Connect the drive and refresh this data source. If you emptied the folder on purpose, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2999277901"] = "The folder '{0}' currently appears empty. AI Studio kept the index of this data source, because the folder of a drive that is not connected can look empty. Connect the drive and refresh this data source. If you emptied the folder on purpose, remove this data source."
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3230000698"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it."
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3722338131"] = "The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source."
-- The file '{0}' could not be read. Please check whether you are allowed to read it.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3924882233"] = "The file '{0}' could not be read. Please check whether you are allowed to read it."
@@ -1,3 +0,0 @@
@inherits ConfigurationText
<FileExclusionEditor Text="@this.EditingText" TextChanged="@this.InternalUpdate" Label="@this.OptionDescription" Disabled="@this.IsDisabled" ShowHelp="@this.ShowHelp" ShowReset="@(this.ResetValue is not null)" ResetValue="@(this.ResetValue?.Invoke() ?? FileExclusions.DEFAULT_PATTERNS)" ResetButtonText="@this.ResetButtonText" ResetRequested="@this.ApplyTextAsync"/>
@@ -1,17 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Reuses ConfigurationText's persistence, validation, reset, and organization locks with the
/// shared exclusion editor. The editor supplies its own outlined fields and heading.
/// </summary>
public partial class ConfigurationFileExclusions : ConfigurationText
{
[Parameter]
public bool ShowHelp { get; set; } = true;
protected override Variant Variant => Variant.Text;
protected override string Label => string.Empty;
}
@@ -4,7 +4,7 @@
{
<MudTextField
T="string"
Text="@(this.ExpandToContent ? this.EditingText : this.Text())"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="@this.IconAdornment"
@@ -14,7 +14,6 @@
Lines="@this.NumLines"
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Class="@this.InputClass"
Immediate="@true"
Validation="@this.Validation"
Underline="false"
@@ -26,26 +25,20 @@ else
<div>
<MudTextField
T="string"
Text="@(this.ExpandToContent ? this.EditingText : this.Text())"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="@(this.ResetButtonIconOnly ? Adornment.End : this.IconAdornment)"
AdornmentIcon="@(this.ResetButtonIconOnly ? Icons.Material.Filled.Restore : this.Icon)"
OnAdornmentClick="@this.ResetAdornmentClick"
AdornmentAriaLabel="@(this.ResetButtonIconOnly ? this.ResetButtonText : null)"
Adornment="@this.IconAdornment"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Lines="@this.NumLines"
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Class="@this.InputClass"
Immediate="@true"
Validation="@this.Validation"
Underline="false"
/>
@if (!this.ResetButtonIconOnly)
{
<RestoreDefaultButton Text="@this.ResetButtonText" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync"/>
}
<RestoreDefaultButton Text="@this.ResetButtonText" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync"/>
</div>
}
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Timer = System.Timers.Timer;
@@ -25,18 +24,6 @@ public partial class ConfigurationText : ConfigurationBaseCore
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Info;
/// <summary>
/// Removes the default bottom margin when the field is part of a compact group.
/// </summary>
[Parameter]
public bool Compact { get; set; }
/// <summary>
/// Wraps and grows to show the full text, including while the field is not focused.
/// </summary>
[Parameter]
public bool ExpandToContent { get; set; }
/// <summary>
/// The color of the icon to use.
/// </summary>
@@ -67,12 +54,6 @@ public partial class ConfigurationText : ConfigurationBaseCore
[Parameter]
public string ResetButtonText { get; set; } = string.Empty;
/// <summary>
/// Uses an accessible reset icon inside the field.
/// </summary>
[Parameter]
public bool ResetButtonIconOnly { get; set; }
/// <summary>
/// Validates the configured text before it is stored.
/// </summary>
@@ -80,14 +61,10 @@ public partial class ConfigurationText : ConfigurationBaseCore
public Func<string, string?>? Validation { get; set; }
private string internalText = string.Empty;
private string? lastSourceText;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
{
AutoReset = false
};
protected string EditingText => this.internalText;
#region Overrides of ConfigurationBase
@@ -95,8 +72,6 @@ public partial class ConfigurationText : ConfigurationBaseCore
protected override bool Stretch => true;
protected override Variant Variant => Variant.Outlined;
protected override string MarginClass => this.Compact ? "mb-0" : base.MarginClass;
protected override string Label => this.OptionDescription;
@@ -108,24 +83,15 @@ public partial class ConfigurationText : ConfigurationBaseCore
protected override async Task OnParametersSetAsync()
{
var sourceText = this.Text();
// Re-rendering the parent must not replace a draft which is waiting for its debounce.
// A genuinely changed setting, e.g. from a configuration plugin, still replaces it.
if (this.IsDisabled || !string.Equals(sourceText, this.lastSourceText, StringComparison.Ordinal))
{
this.internalText = sourceText;
this.lastSourceText = sourceText;
}
this.internalText = this.Text();
await base.OnParametersSetAsync();
}
#endregion
private bool AutoGrow => this.ExpandToContent || this.NumLines > 1;
private bool AutoGrow => this.NumLines > 1;
private int GetMaxLines => this.ExpandToContent ? 0 : this.AutoGrow ? this.MaxLines : 1;
private string InputClass => this.ExpandToContent ? "pattern-text-field" : string.Empty;
private int GetMaxLines => this.AutoGrow ? this.MaxLines : 1;
//
// MudBlazor centers the icon vertically, so in a field of several lines it floats in the middle
@@ -133,50 +99,29 @@ public partial class ConfigurationText : ConfigurationBaseCore
//
private Adornment IconAdornment => this.NumLines == 1 ? Adornment.Start : Adornment.None;
protected void InternalUpdate(string text)
private void InternalUpdate(string text)
{
if (this.IsDisabled)
return;
this.timer.Stop();
this.internalText = text;
this.timer.Start();
}
private EventCallback<MouseEventArgs> ResetAdornmentClick => this.ResetButtonIconOnly
? EventCallback.Factory.Create<MouseEventArgs>(this, this.ResetTextAsync)
: default;
protected async Task ResetTextAsync()
private async Task ResetTextAsync()
{
if (this.ResetValue is null || this.IsDisabled)
return;
await this.ApplyTextAsync(this.ResetValue());
}
/// <summary>
/// Applies an explicit value immediately, including a reset of part of a compound setting.
/// </summary>
protected async Task ApplyTextAsync(string text)
{
if (this.IsDisabled)
return;
this.timer.Stop();
this.internalText = text;
this.internalText = this.ResetValue();
await this.OptionChanged(this.internalText);
}
protected async Task OptionChanged(string updatedText)
private async Task OptionChanged(string updatedText)
{
if (this.IsDisabled || this.Validation?.Invoke(updatedText) is not null)
if (this.Validation?.Invoke(updatedText) is not null)
return;
this.TextUpdate(updatedText);
// A save may finish after another keystroke. Remember the value it applied before
// awaiting storage, so its configuration-change notification cannot erase that keystroke.
this.lastSourceText = this.Text();
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
@@ -270,70 +270,8 @@ public partial class DataSourceManagement : MSGComponentBase
private async Task EditDataSource(IDataSource dataSource)
{
if (dataSource.IsEnterpriseConfiguration)
return;
IDataSource? editedDataSource = null;
var lockDataSourceOrigin = dataSource is IInternalDataSource
&& await this.DataSourceEmbeddingService.ShouldLockDataSourceOriginAsync(dataSource.Id);
switch (dataSource)
{
case DataSourceLocalFile localFile:
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localFile },
{ x => x.LockSource, lockDataSourceOrigin },
};
var localFileDialogReference = await this.DialogService.ShowAsync<DataSourceLocalFileDialog>(T("Edit Local File Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN);
var localFileDialogResult = await localFileDialogReference.Result;
if (localFileDialogResult is null || localFileDialogResult.Canceled)
return;
editedDataSource = (DataSourceLocalFile)localFileDialogResult.Data!;
break;
case DataSourceLocalDirectory localDirectory:
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localDirectory },
{ x => x.LockSource, lockDataSourceOrigin },
};
var localDirectoryDialogReference = await this.DialogService.ShowAsync<DataSourceLocalDirectoryDialog>(T("Edit Local Directory Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
return;
editedDataSource = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
break;
case DataSourceERI_V1 eriDataSource:
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, eriDataSource },
};
var eriDialogReference = await this.DialogService.ShowAsync<DataSourceERI_V1Dialog>(T("Edit ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN);
var eriDialogResult = await eriDialogReference.Result;
if (eriDialogResult is null || eriDialogResult.Canceled)
return;
editedDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
break;
}
if(editedDataSource is null)
return;
this.SettingsManager.ConfigurationData.DataSources[this.SettingsManager.ConfigurationData.DataSources.IndexOf(dataSource)] = editedDataSource;
await this.SettingsManager.StoreSettings();
await this.DataSourceEmbeddingService.QueueDataSourceAsync(editedDataSource);
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
if (await DataSourceEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, dataSource.Id))
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task DeleteDataSource(IDataSource dataSource)
@@ -1,46 +0,0 @@
@inherits MSGComponentBase
<div class="file-exclusion-editor mb-3">
@if (this.ShowHeader || this.ShowHelp)
{
<MudStack Row="@true" AlignItems="AlignItems.Center" Spacing="1" Class="mb-1">
@if (this.ShowHeader)
{
<MudText Typo="Typo.subtitle2" Class="flex-grow-1">@this.EditorLabel</MudText>
}
@if (this.ShowHelp)
{
<MudTooltip Text="@T("Exclusion help and examples")" RootStyle="display: inline-flex;">
<MudIconButton Icon="@Icons.Material.Outlined.HelpOutline" Color="@(this.showHelp ? Color.Info : Color.Default)" OnClick="@this.ToggleHelp" aria-label="@T("Exclusion help and examples")" aria-expanded="@(this.showHelp ? "true" : "false")"/>
</MudTooltip>
}
</MudStack>
}
<MudStack Spacing="1">
@if (this.ReadOnly)
{
<TextInfoLines Icon="@Icons.Material.Filled.FolderOff" Label="@T("Excluded folders")" Value="@this.state.FolderText" Lines="1" MaxLines="0" ClipboardTooltipSubject="@T("the excluded folders")"/>
<TextInfoLines Icon="@Icons.Material.Filled.FilterAltOff" Label="@T("Excluded files")" Value="@this.state.FileText" Lines="1" MaxLines="0" ClipboardTooltipSubject="@T("the excluded files")"/>
}
else
{
<MudTextField T="string" InputId="@this.folderInputId" Text="@this.state.FolderText" TextChanged="@this.ChangeFoldersAsync" Immediate="@true" Disabled="@this.Disabled" Label="@T("Folders")" Lines="1" AutoGrow="@true" MaxLines="0" Class="pattern-text-field" Variant="Variant.Outlined" Margin="Margin.Dense" Validation="@FileExclusions.ValidateFolders" UserAttributes="@INPUT_ATTRIBUTES" Placeholder="node_modules/;bin/;cmake-build-*/" Adornment="@(this.ShowReset ? Adornment.End : Adornment.None)" AdornmentIcon="@Icons.Material.Filled.Restore" OnAdornmentClick="@(() => this.RestoreDefaultsAsync(true))" AdornmentAriaLabel="@this.FolderRestoreLabel"/>
<MudTextField T="string" InputId="@this.fileInputId" Text="@this.state.FileText" TextChanged="@this.ChangeFilesAsync" Immediate="@true" Disabled="@this.Disabled" Label="@T("Files")" Lines="1" AutoGrow="@true" MaxLines="0" Class="pattern-text-field" Variant="Variant.Outlined" Margin="Margin.Dense" Validation="@FileExclusions.ValidateFiles" UserAttributes="@INPUT_ATTRIBUTES" Placeholder="*.min.js;*.webm;draft-*" Adornment="@(this.ShowReset ? Adornment.End : Adornment.None)" AdornmentIcon="@Icons.Material.Filled.Restore" OnAdornmentClick="@(() => this.RestoreDefaultsAsync(false))" AdornmentAriaLabel="@this.FileRestoreLabel"/>
}
</MudStack>
@if (!this.ReadOnly)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary mt-1">@T("Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority.")</MudText>
}
<MudCollapse Expanded="@(this.ShowHelp && this.showHelp)">
<MudSimpleTable Dense="@true" Elevation="0" Class="mt-2" aria-label="@T("Exclusion examples")">
<tbody>
<tr><td><code>*.pdf</code></td><td>@T("PDF files, regardless of letter case")</td></tr>
<tr><td><code>draft-*</code></td><td>@T("File names starting with draft-")</td></tr>
<tr><td><code>*build/</code></td><td>@T("Folders ending in build, such as build or app-build")</td></tr>
</tbody>
</MudSimpleTable>
</MudCollapse>
</div>
@@ -1,118 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A compact, two-group editor for the exclusion string shared by folder-based features.
/// </summary>
public partial class FileExclusionEditor : MSGComponentBase
{
private static readonly Dictionary<string, object?> INPUT_ATTRIBUTES = new()
{
["spellcheck"] = false,
["autocorrect"] = "off",
["autocapitalize"] = "off",
};
[Parameter]
public string? Text { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> TextChanged { get; set; }
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public bool Disabled { get; set; }
/// <summary>
/// Displays both groups with copy buttons instead of editing and reset controls.
/// </summary>
[Parameter]
public bool ReadOnly { get; set; }
[Parameter]
public bool ShowReset { get; set; } = true;
[Parameter]
public bool ShowHelp { get; set; } = true;
[Parameter]
public bool ShowHeader { get; set; } = true;
[Parameter]
public string ResetValue { get; set; } = FileExclusions.DEFAULT_PATTERNS;
[Parameter]
public string ResetButtonText { get; set; } = string.Empty;
/// <summary>
/// Immediately persists the combined value after restoring just one of its groups.
/// Ordinary edits continue through TextChanged.
/// </summary>
[Parameter]
public EventCallback<string> ResetRequested { get; set; }
private readonly FileExclusionEditorState state = new();
private readonly string folderInputId = $"exclusion-folders-{Guid.NewGuid():N}";
private readonly string fileInputId = $"exclusion-files-{Guid.NewGuid():N}";
private bool showHelp;
private string EditorLabel => string.IsNullOrWhiteSpace(this.Label) ? T("Excluded files and folders") : this.Label;
private string RestoreLabel => string.IsNullOrWhiteSpace(this.ResetButtonText) ? T("Restore default") : this.ResetButtonText;
private string FolderRestoreLabel => $"{this.RestoreLabel}: {T("Folders")}";
private string FileRestoreLabel => $"{this.RestoreLabel}: {T("Files")}";
protected override void OnParametersSet()
{
this.state.Receive(this.Text, discardDraft: this.Disabled || this.ReadOnly);
base.OnParametersSet();
}
protected Task ChangeFoldersAsync(string text) => this.ChangePatternsAsync(text, folders: true);
protected Task ChangeFilesAsync(string text) => this.ChangePatternsAsync(text, folders: false);
private async Task ChangePatternsAsync(string text, bool folders)
{
if (this.Disabled || this.ReadOnly)
return;
var previousText = this.state.Text;
var wasValid = this.state.IsValid;
if (folders)
this.state.EditFolders(text);
else
this.state.EditFiles(text);
// Moving a rule to the correct field can keep the combined text identical. It must still
// publish the now-valid draft, because the previous, invalid arrangement was not sent.
if (this.state.IsValid && (!wasValid || !string.Equals(previousText, this.state.Text, StringComparison.Ordinal)))
await this.TextChanged.InvokeAsync(this.state.Text);
}
protected async Task RestoreDefaultsAsync(bool folders)
{
if (this.Disabled || this.ReadOnly || !this.ShowReset)
return;
if (folders)
this.state.RestoreFolders(this.ResetValue);
else
this.state.RestoreFiles(this.ResetValue);
if (!this.state.IsValid)
return;
if (this.ResetRequested.HasDelegate)
await this.ResetRequested.InvokeAsync(this.state.Text);
else
await this.TextChanged.InvokeAsync(this.state.Text);
}
private void ToggleHelp() => this.showHelp = !this.showHelp;
}
@@ -1,89 +0,0 @@
namespace AIStudio.Components;
/// <summary>
/// Presents the existing exclusion string as two editable groups without losing the other group
/// or letting delayed parameter echoes overwrite newer edits.
/// </summary>
internal sealed class FileExclusionEditorState
{
private const string PATTERN_SEPARATOR = "; ";
private string? receivedText;
private readonly HashSet<string> pendingValues = new(StringComparer.Ordinal);
public string Text { get; private set; } = string.Empty;
public string FolderText { get; private set; } = string.Empty;
public string FileText { get; private set; } = string.Empty;
public bool IsValid => FileExclusions.IsValidFolders(this.FolderText) && FileExclusions.IsValidFiles(this.FileText);
public bool Receive(string? text, bool discardDraft = false)
{
text ??= string.Empty;
if (discardDraft)
{
var changed = !string.Equals(text, this.Text, StringComparison.Ordinal);
this.receivedText = text;
this.Reset(text);
return changed;
}
if (string.Equals(text, this.receivedText, StringComparison.Ordinal))
return false;
this.receivedText = text;
if (string.Equals(text, this.Text, StringComparison.Ordinal))
{
this.pendingValues.Clear();
return false;
}
if (this.pendingValues.Remove(text))
return false;
this.Reset(text);
return true;
}
public void Reset(string text)
{
this.pendingValues.Clear();
this.Text = text;
this.Regroup();
}
public void EditFolders(string? text)
{
this.FolderText = text ?? string.Empty;
this.UpdateText();
}
public void EditFiles(string? text)
{
this.FileText = text ?? string.Empty;
this.UpdateText();
}
public void RestoreFolders(string defaults) => this.EditFolders(string.Join(PATTERN_SEPARATOR, FileExclusions.Parse(defaults).Where(IsFolder)));
public void RestoreFiles(string defaults) => this.EditFiles(string.Join(PATTERN_SEPARATOR, FileExclusions.Parse(defaults).Where(pattern => !IsFolder(pattern))));
private void Regroup()
{
var patterns = FileExclusions.Parse(this.Text);
this.FolderText = string.Join(PATTERN_SEPARATOR, patterns.Where(IsFolder));
this.FileText = string.Join(PATTERN_SEPARATOR, patterns.Where(pattern => !IsFolder(pattern)));
}
private static bool IsFolder(string pattern) => pattern.EndsWith(FileExclusions.FOLDER_SUFFIX);
private void UpdateText()
{
this.Text = string.Join(PATTERN_SEPARATOR, FileExclusions.Parse(this.FolderText)
.Concat(FileExclusions.Parse(this.FileText))
.Distinct(StringComparer.OrdinalIgnoreCase));
this.pendingValues.Add(this.Text);
}
}
@@ -1,42 +0,0 @@
@inherits MSGComponentBase
<MudStack Spacing="1" Class="mb-3">
<div style="@PATTERN_ROW_STYLE">
<div style="min-width: 0;">
@this.AllowedContent
</div>
<MudTooltip Text="@T("Pattern help and examples")" RootStyle="display: inline-flex;">
<MudIconButton Icon="@Icons.Material.Outlined.HelpOutline" Color="@(this.showHelp ? Color.Info : Color.Default)" OnClick="@this.ToggleHelp" aria-label="@T("Pattern help and examples")" aria-expanded="@(this.showHelp ? "true" : "false")"/>
</MudTooltip>
</div>
@if (this.HasExclusions)
{
<div style="@PATTERN_ROW_STYLE">
<div style="grid-column: 1; min-width: 0;" class="mt-3">
@this.ExcludedContent
</div>
</div>
}
<MudCollapse Expanded="@this.showHelp">
<div style="@PATTERN_ROW_STYLE">
<MudPaper Outlined="@true" Class="pa-3" Elevation="0" Style="grid-column: 1; min-width: 0;">
<MudText Typo="Typo.subtitle2">@T("Pattern examples")</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-2">@T("Separate patterns with semicolons. Exclusions take priority over included files.")</MudText>
<MudSimpleTable Dense="@true" Elevation="0" aria-label="@T("Pattern examples")">
<tbody>
<tr><td><code>*.pdf;*.docx</code></td><td>@T("PDF and Word files")</td></tr>
<tr><td><code>:documents:</code></td><td>@T("Include all documents and readable text files")</td></tr>
<tr><td><code>*</code></td><td>@T("Include all files, including audio and video")</td></tr>
<tr><td><code>:text:</code></td><td>@T("Include readable text files, including files without an extension")</td></tr>
<tr><td><code>draft-*</code></td><td>@T("File names starting with draft-")</td></tr>
@if (this.HasExclusions)
{
<tr><td><code>*build/</code></td><td>@T("Exclude folders ending in build, such as build or app-build")</td></tr>
}
</tbody>
</MudSimpleTable>
<MudText Typo="Typo.caption" Class="mud-text-secondary mt-2">@T("Patterns match file names regardless of letter case. Folder paths are not supported.")</MudText>
</MudPaper>
</div>
</MudCollapse>
</MudStack>
@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// A file-pattern field with shared, collapsible help and optional, always-visible exclusions.
/// Callers supply their existing fields, preserving validation and managed-configuration locks.
/// </summary>
public partial class FilePatternSelection : MSGComponentBase
{
private const string PATTERN_ROW_STYLE = "display: grid; grid-template-columns: minmax(0, 1fr) 48px; column-gap: 8px; align-items: center;";
[Parameter, EditorRequired]
public RenderFragment AllowedContent { get; set; } = null!;
/// <summary>
/// When supplied, shows the existing exclusion field below the inclusion field.
/// Both groups stay visible and use the same help control.
/// </summary>
[Parameter]
public RenderFragment? ExcludedContent { get; set; }
internal bool HasExclusions => this.ExcludedContent is not null;
private bool showHelp;
private void ToggleHelp() => this.showHelp = !this.showHelp;
}
@@ -1,13 +1,10 @@
@inherits MSGComponentBase
@if (ShouldShowInstallationPrompt(this.pandocInstallation))
{
<MudStack Row="false" Class="mb-3 pa-3 border-dashed border rounded-lg">
<MudJustifiedText Typo="Typo.body2">
@this.DetermineIntroText()
</MudJustifiedText>
<MudButton Color="@this.DetermineButtonColor()" Variant="Variant.Filled" OnClick="() => this.ShowPandocDialogAsync()">
@this.DetermineButtonText()
</MudButton>
</MudStack>
}
<MudStack Row="false" Class="mb-3 pa-3 border-dashed border rounded-lg">
<MudJustifiedText Typo="Typo.body1">
@this.DetermineIntroText()
</MudJustifiedText>
<MudButton Color="@this.DetermineButtonColor()" Variant="Variant.Filled" OnClick="() => this.ShowPandocDialogAsync()">
@this.DetermineButtonText()
</MudButton>
</MudStack>
@@ -18,10 +18,7 @@ public partial class ManagePandocDependency : MSGComponentBase
[Inject]
private RustService RustService { get; init; } = null!;
// Null means the silent availability check has not completed yet.
private PandocInstallation? pandocInstallation;
internal static bool ShouldShowInstallationPrompt(PandocInstallation? installation) => installation is { IsAvailable: false };
private PandocInstallation pandocInstallation;
#region Overrides of MSGComponentBase
@@ -35,10 +32,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private Color DetermineButtonColor()
{
if (this.pandocInstallation is null)
if (this.pandocInstallation == default)
return Color.Default;
switch (this.pandocInstallation.Value)
switch (this.pandocInstallation)
{
case { CheckWasSuccessful: true, IsAvailable: true }:
return Color.Default;
@@ -53,10 +50,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private string DetermineButtonText()
{
if(this.pandocInstallation is null)
if(this.pandocInstallation == default)
return T("Please wait while we check the availability of Pandoc.");
switch (this.pandocInstallation.Value)
switch (this.pandocInstallation)
{
case { CheckWasSuccessful: true, IsAvailable: true }:
return T("Check your Pandoc installation");
@@ -71,10 +68,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private string DetermineIntroText()
{
if (this.pandocInstallation is null)
if (this.pandocInstallation == default)
return $"{this.IntroText} {T("Please wait while we check the availability of Pandoc.")}";
switch (this.pandocInstallation.Value)
switch (this.pandocInstallation)
{
case { CheckWasSuccessful: true, IsAvailable: true }:
return $"{this.IntroText} {T("Your Pandoc installation meets the requirements.")}";
@@ -7,11 +7,9 @@
Text="@this.Value"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="@this.Lines"
Lines="3"
MaxLines="@this.MaxLines"
AutoGrow="@true"
Adornment="@(string.IsNullOrEmpty(this.Icon) ? Adornment.None : Adornment.Start)"
AdornmentIcon="@this.Icon"
Style="@this.GetColor()"
UserAttributes="@USER_INPUT_ATTRIBUTES" />
@@ -9,9 +9,6 @@ public partial class TextInfoLines : MSGComponentBase
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string Icon { get; set; } = string.Empty;
[Parameter]
public string Value { get; set; } = string.Empty;
@@ -21,9 +18,6 @@ public partial class TextInfoLines : MSGComponentBase
[Parameter]
public int MaxLines { get; set; } = 30;
[Parameter]
public int Lines { get; set; } = 3;
[Parameter]
public bool ShowingCopyButton { get; set; } = true;
@@ -10,15 +10,12 @@
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues" FieldChanged="@this.RevalidateAfterFieldChange">
<DataSourceMissingEmbeddingWarning IsInDataSourceDialog="@true" EmbeddingProviderAdded="@this.SelectAddedEmbeddingProvider"/>
<MudText Typo="Typo.h6" Class="mb-3">
@T("Data source")
</MudText>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="@T("Data Source Name")"
Class="mb-3"
Class="mb-6"
MaxLength="@DataSourceValidation.MAX_NAME_LENGTH"
Counter="@DataSourceValidation.MAX_NAME_LENGTH"
Immediate="@true"
@@ -34,7 +31,7 @@
T="string"
@bind-Text="@this.dataDescription"
Label="@T("Description")"
Class="mb-0"
Class="mb-6"
MaxLength="200"
Counter="200"
Immediate="@true"
@@ -47,37 +44,10 @@
Lines="3"
/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Folder")
</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Select a root directory for this data source.")
</MudJustifiedText>
@if (!this.CanChangeSource)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed.")
</MudAlert>
}
@if (this.CanChangeSource)
{
<SelectDirectory @bind-Directory="@this.dataPath" Label="@T("Selected base directory for this data source")" DirectoryDialogTitle="@T("Select the base directory")" Validation="@this.dataSourceValidation.ValidatePath" EnableDragDrop="true" CatchAllDocuments="true" />
}
else
{
<MudTextField
T="string"
Text="@this.dataPath"
Label="@T("Selected base directory for this data source")"
Class="mb-3"
ReadOnly="@true"
Validation="@this.dataSourceValidation.ValidatePath"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Folder"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
/>
}
<SelectDirectory @bind-Directory="@this.dataPath" Label="@T("Selected base directory for this data source")" DirectoryDialogTitle="@T("Select the base directory")" Validation="@this.dataSourceValidation.ValidatePath" EnableDragDrop="true" CatchAllDocuments="true" />
<MudTextSwitch
Label="@T("Include subfolders?")"
@@ -87,42 +57,28 @@
LabelOff="@T("No, only index files in the selected folder")"
/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Files to include")
</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Select the file patterns this data source should index.")
</MudJustifiedText>
<FilePatternSelection>
<AllowedContent>
<MudTextField
T="string"
@bind-Text="@this.dataFilePatterns"
Label="@T("Files to include")"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidateFilePatterns"
Adornment="Adornment.End"
AdornmentIcon="@Icons.Material.Filled.Restore"
OnAdornmentClick="@this.RestoreDefaultFilePatterns"
AdornmentAriaLabel="@T("Restore default patterns")"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="1"
AutoGrow="@true"
MaxLines="0"
Class="pattern-text-field"
/>
</AllowedContent>
</FilePatternSelection>
<MudTextField
T="string"
@bind-Text="@this.dataFilePatterns"
Label="@T("File patterns")"
Class="mb-3"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidateFilePatterns"
HelperText="@T("Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.")"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.FilterAlt"
AdornmentColor="Color.Info"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Embedding")
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
@T("Choose an embedding provider to make this data source searchable.")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.")
</MudJustifiedText>
<MudSelect @ref="@this.embeddingSelect" @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
@foreach (var provider in this.SettingsManager.GetAllEmbeddingProviders())
{
@@ -138,17 +94,14 @@
}
else if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudJustifiedText Typo="Typo.body2" Color="Color.Tertiary" Class="mb-3">
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
@T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
</MudJustifiedText>
}
<ManagePandocDependency IntroText="@T("MindWork AI Studio uses Pandoc to read HTML files.")"/>
<ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Provider trust")
</MudText>
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" HelperText="@T("Only providers with this confidence level or higher may receive and process data from this data source.")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
@foreach (var level in this.ConfidenceLevels)
{
<MudSelectItem Value="@level.Value">
@@ -170,21 +123,31 @@
</MudButton>
<MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<MudText Typo="Typo.subtitle2" Class="mb-2">@T("Hidden files and folders")</MudText>
<MudTextSwitch
Label="@T("Include hidden files and folders?")"
Value="@this.dataIncludeHiddenFilesAndFolders"
ValueChanged="@(value => this.dataIncludeHiddenFilesAndFolders = value)"
LabelOn="@T("Yes, include hidden files and folders")"
LabelOn="@T("Yes, index hidden files and folders as well")"
LabelOff="@T("No, ignore hidden files and folders")"
/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.")
@T("Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.")
</MudJustifiedText>
<div class="mt-4">
<FileExclusionEditor @bind-Text="@this.dataExcludePatterns" Label="@T("Excluded files and folders")" ResetButtonText="@T("Restore default")"/>
</div>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("File size limit")</MudText>
@* Without an icon: in a field of several lines, it floats in the middle of the text. *@
<MudTextField
T="string"
@bind-Text="@this.dataExcludePatterns"
Label="@T("Excluded files and folders")"
Lines="@FileExclusions.DEFAULT_LINE_COUNT"
AutoGrow="@true"
MaxLines="@FileExclusions.MAX_LINE_COUNT"
Immediate="@true"
Validation="@this.dataSourceValidation.ValidateExcludePatterns"
HelperText="@T("These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.")"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
/>
<RestoreDefaultButton Text="@T("Restore default")" OnClick="@this.RestoreDefaultExcludePatterns" Class="mb-3"/>
<MudNumericField
T="int"
@bind-Value="@this.dataMaxFileSizeMegabytes"
@@ -196,10 +159,6 @@
Variant="Variant.Outlined"
Adornment="Adornment.End"
AdornmentText="MB"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("Text chunking")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudTextField
@@ -213,6 +172,9 @@
AdornmentIcon="@Icons.Material.Filled.TextFields"
AdornmentColor="Color.Info"/>
}
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
<MudNumericField
@ref="@this.maxChunkTokenLengthField"
T="int"
@@ -240,7 +202,6 @@
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.CompareArrows"
AdornmentColor="Color.Info"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("Search results")</MudText>
<MudNumericField
T="ushort"
Min="10"
@@ -23,16 +23,6 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
[Parameter]
public DataSourceLocalDirectory DataSource { get; set; }
/// <summary>
/// Whether the folder this data source reads must stay as it is.
/// </summary>
/// <remarks>
/// Set once the index holds something for this data source. The embedding is not locked along
/// with it: it can be changed, and DataSourceReindexWarning asks what that costs.
/// </remarks>
[Parameter]
public bool LockSource { get; set; }
[Inject]
private IDialogService DialogService { get; init; } = null!;
@@ -215,8 +205,6 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
? T("Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.")
: null;
private bool CanChangeSource => !this.IsEditing || !this.LockSource;
private IEnumerable<ConfigurationSelectData<ConfidenceLevel>> ConfidenceLevels => ConfigurationSelectDataFactory.GetDataSourceConfidenceLevelsData();
private string SelectedEmbeddingTokenizerText => this.SelectedEmbedding is null
@@ -244,9 +232,7 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
Type = DataSourceType.LOCAL_DIRECTORY,
EmbeddingId = this.dataEmbeddingId,
// Kept out of reach of the form while the source is locked, so a stale field cannot point an
// indexed data source somewhere else:
Path = this.CanChangeSource ? this.dataPath : this.DataSource.Path,
Path = this.dataPath,
FilePatterns = this.NormalizeFilePatterns(),
// Kept as typed, since the line breaks group the entries for the eye:
@@ -268,6 +254,14 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
if (!this.dataIsValid)
return;
await this.StoreValidatedDataSourceAsync();
}
/// <summary>
/// Continues Save after form validation, asking before the draft is returned to its caller.
/// </summary>
internal async Task StoreValidatedDataSourceAsync()
{
var addedDataSource = this.CreateDataSource();
//
@@ -288,7 +282,7 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
private string NormalizeFilePatterns() => FileNamePatterns.Normalize(this.dataFilePatterns);
private void RestoreDefaultFilePatterns() => this.dataFilePatterns = DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS;
private void RestoreDefaultExcludePatterns() => this.dataExcludePatterns = FileExclusions.DEFAULT_PATTERNS;
/// <summary>
/// Gives the fields which are checked against each other a fresh verdict: the embedding provider
@@ -12,9 +12,9 @@
<TextInfoLine Icon="@Icons.Material.Filled.FolderOpen" Label="@T("Path")" Value="@this.DataSource.Path" ClipboardTooltipSubject="@T("this path")"/>
<TextInfoLine Icon="@Icons.Material.Filled.AccountTree" Label="@T("Include subfolders")" Value="@(this.DataSource.IncludeSubdirectories ? T("Yes") : T("No"))" ClipboardTooltipSubject="@T("whether subfolders are included")"/>
<TextInfoLine Icon="@Icons.Material.Filled.VisibilityOff" Label="@T("Include hidden files and folders")" Value="@(this.DataSource.IncludeHiddenFilesAndFolders ? T("Yes") : T("No"))" ClipboardTooltipSubject="@T("whether hidden files and folders are included")"/>
<TextInfoLines Icon="@Icons.Material.Filled.FilterAlt" Label="@T("Files to include")" Value="@this.DataSource.EffectiveFilePatterns" Lines="1" MaxLines="0" ClipboardTooltipSubject="@T("the file patterns")"/>
<FileExclusionEditor Text="@this.DataSource.EffectiveExcludePatterns" ReadOnly="@true" ShowHeader="@false" ShowHelp="@false"/>
<TextInfoLine Icon="@Icons.Material.Filled.Straighten" Label="@T("Ignore files larger than")" Value="@($"{this.DataSource.EffectiveMaxFileSizeMegabytes} MB")" ClipboardTooltipSubject="@T("the size limit for files")"/>
<TextInfoLine Icon="@Icons.Material.Filled.FilterAlt" Label="@T("File patterns")" Value="@this.DataSource.EffectiveFilePatterns" ClipboardTooltipSubject="@T("the file patterns")"/>
<TextInfoLines Label="@T("Excluded files and folders")" Value="@this.DataSource.EffectiveExcludePatterns" ClipboardTooltipSubject="@T("the excluded files and folders")"/>
<TextInfoLine Icon="@Icons.Material.Filled.Straighten" Label="@T("Ignore files larger than")" Value="@FolderFileSelection.MegabytesToBytes(this.DataSource.EffectiveMaxFileSizeMegabytes).FileSize()" ClipboardTooltipSubject="@T("the size limit for files")"/>
@if (!this.IsDirectoryAvailable)
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Error" Class="mb-3">
@@ -65,4 +65,4 @@
@T("Close")
</MudButton>
</DialogActions>
</MudDialog>
</MudDialog>
@@ -10,15 +10,12 @@
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues" FieldChanged="@this.RevalidateAfterFieldChange">
<DataSourceMissingEmbeddingWarning IsInDataSourceDialog="@true" EmbeddingProviderAdded="@this.SelectAddedEmbeddingProvider"/>
<MudText Typo="Typo.h6" Class="mb-3">
@T("Data source")
</MudText>
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.dataName"
Label="@T("Data Source Name")"
Class="mb-3"
Class="mb-6"
MaxLength="@DataSourceValidation.MAX_NAME_LENGTH"
Counter="@DataSourceValidation.MAX_NAME_LENGTH"
Immediate="@true"
@@ -34,7 +31,7 @@
T="string"
@bind-Text="@this.dataDescription"
Label="@T("Description")"
Class="mb-0"
Class="mb-6"
MaxLength="200"
Counter="200"
Immediate="@true"
@@ -47,44 +44,14 @@
Lines="3"
/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("File")
</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Select a file for this data source. The content of this file will be processed for the data source. An audio or video file is transcribed by your transcription provider first.")
</MudJustifiedText>
@if (!this.CanChangeSource)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed.")
</MudAlert>
}
@if (this.CanChangeSource)
{
<SelectFile @bind-File="@this.dataFilePath" Label="@T("Selected file path for this data source")" FileDialogTitle="@T("Select the file")" Validation="@this.dataSourceValidation.ValidateFilePath" EnableDragDrop="true" CatchAllDocuments="true" />
}
else
{
<MudTextField
T="string"
Text="@this.dataFilePath"
Label="@T("Selected file path for this data source")"
Class="mb-3"
ReadOnly="@true"
Validation="@this.dataSourceValidation.ValidateFilePath"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.AttachFile"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Variant="Variant.Outlined"
/>
}
<SelectFile @bind-File="@this.dataFilePath" Label="@T("Selected file path for this data source")" FileDialogTitle="@T("Select the file")" Validation="@this.dataSourceValidation.ValidateFilePath" EnableDragDrop="true" CatchAllDocuments="true" />
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Embedding")
</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
@T("Choose an embedding provider to make this data source searchable.")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.")
</MudJustifiedText>
<MudSelect @ref="@this.embeddingSelect" @bind-Value="@this.dataEmbeddingId" Label="@T("Embedding")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateEmbeddingProviderAccess">
@foreach (var provider in this.SettingsManager.GetAllEmbeddingProviders())
{
@@ -100,17 +67,14 @@
}
else if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudJustifiedText Typo="Typo.body2" Color="Color.Tertiary" Class="mb-3">
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
@T("The embedding you selected runs locally or in your organization. Your data is not sent to the cloud.")
</MudJustifiedText>
}
<ManagePandocDependency IntroText="@T("MindWork AI Studio uses Pandoc to read HTML files.")"/>
<ManagePandocDependency IntroText="@T("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Provider trust")
</MudText>
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" HelperText="@T("Only providers with this confidence level or higher may receive and process data from this data source.")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
<MudSelect @ref="@this.confidenceLevelSelect" @bind-Value="@this.dataConfidenceLevel" Text="@this.dataConfidenceLevel.GetName()" Label="@T("Required provider confidence level")" Class="mb-3" OpenIcon="@Icons.Material.Filled.ExpandMore" AdornmentColor="Color.Info" Adornment="Adornment.Start" Validation="@this.dataSourceValidation.ValidateDataSourceConfidenceLevel">
@foreach (var level in this.ConfidenceLevels)
{
<MudSelectItem Value="@level.Value">
@@ -132,10 +96,6 @@
</MudButton>
<MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<MudText Typo="Typo.subtitle2" Class="mb-2">@T("Text chunking")</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
@if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudTextField
@@ -149,6 +109,9 @@
AdornmentIcon="@Icons.Material.Filled.TextFields"
AdornmentColor="Color.Info"/>
}
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Optional expert settings for how this data source is split before embedding.")
</MudJustifiedText>
<MudNumericField
@ref="@this.maxChunkTokenLengthField"
T="int"
@@ -176,7 +139,6 @@
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.CompareArrows"
AdornmentColor="Color.Info"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("Search results")</MudText>
<MudNumericField
T="ushort"
Min="10"
@@ -24,16 +24,6 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
[Parameter]
public DataSourceLocalFile DataSource { get; set; }
/// <summary>
/// Whether the file this data source reads must stay as it is.
/// </summary>
/// <remarks>
/// Set once the index holds something for this data source. The embedding is not locked along
/// with it: it can be changed, and DataSourceReindexWarning asks what that costs.
/// </remarks>
[Parameter]
public bool LockSource { get; set; }
[Inject]
private IDialogService DialogService { get; init; } = null!;
@@ -206,8 +196,6 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
? T("This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.")
: null;
private bool CanChangeSource => !this.IsEditing || !this.LockSource;
private IEnumerable<ConfigurationSelectData<ConfidenceLevel>> ConfidenceLevels => ConfigurationSelectDataFactory.GetDataSourceConfidenceLevelsData();
private string SelectedEmbeddingTokenizerText => this.SelectedEmbedding is null
@@ -235,9 +223,7 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
Type = DataSourceType.LOCAL_FILE,
EmbeddingId = this.dataEmbeddingId,
// Kept out of reach of the form while the source is locked, so a stale field cannot point an
// indexed data source somewhere else:
FilePath = this.CanChangeSource ? this.dataFilePath : this.DataSource.FilePath,
FilePath = this.dataFilePath,
MaxChunkTokenLength = this.dataMaxChunkTokenLength,
ChunkOverlapTokenLength = this.dataChunkOverlapTokenLength,
ConfidenceLevel = this.dataConfidenceLevel,
@@ -252,6 +238,14 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
if (!this.dataIsValid)
return;
await this.StoreValidatedDataSourceAsync();
}
/// <summary>
/// Continues Save after form validation, asking before the draft is returned to its caller.
/// </summary>
internal async Task StoreValidatedDataSourceAsync()
{
var addedDataSource = this.CreateDataSource();
//
@@ -20,18 +20,9 @@
<MudText Typo="Typo.h6" Class="mb-3">@T("Input")</MudText>
<ConfigurationDirectory OptionDescription="@T("Default input folder")" Disabled="@this.DefaultsDisabled" DirectoryDialogTitle="@T("Select the default input folder")" Text="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.InputDirectory)" TextUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.InputDirectory = value)" OptionHelp="@T("Leave empty when an input folder should be selected for every batch run.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.InputDirectory, out var meta) && meta.IsLocked"/>
<ConfigurationOption OptionDescription="@T("Include subfolders by default?")" Disabled="@this.DefaultsDisabled" LabelOn="@T("Subfolders are included")" LabelOff="@T("Only the selected folder is processed")" State="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.IncludeSubdirectories)" StateUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.IncludeSubdirectories = value)" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.IncludeSubdirectories, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("Hidden files and folders")</MudText>
<ConfigurationOption OptionDescription="@T("Include hidden files and folders by default?")" Disabled="@this.DefaultsDisabled" LabelOn="@T("Yes, include hidden files and folders")" LabelOff="@T("No, ignore hidden files and folders")" State="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.IncludeHiddenFilesAndFolders)" StateUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.IncludeHiddenFilesAndFolders = value)" OptionHelp="@T("Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.IncludeHiddenFilesAndFolders, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("Files to include")</MudText>
<FilePatternSelection>
<AllowedContent>
<ConfigurationText Compact="@true" ExpandToContent="@true" OptionDescription="@T("Files to include")" Disabled="@this.DefaultsDisabled" Icon="@Icons.Material.Filled.FilterAlt" Text="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.FilePatterns)" TextUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.FilePatterns = value)" ResetValue="@(() => DataBatchProcessing.DEFAULT_FILE_PATTERNS)" ResetButtonText="@T("Restore default patterns")" ResetButtonIconOnly="@true" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.FilePatterns, out var meta) && meta.IsLocked"/>
</AllowedContent>
<ExcludedContent>
<ConfigurationFileExclusions Compact="@true" ShowHelp="@false" OptionDescription="@T("Default excluded files and folders")" Disabled="@this.DefaultsDisabled" Text="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.ExcludePatterns)" TextUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.ExcludePatterns = value)" Validation="@FileExclusions.Validate" ResetValue="@(() => FileExclusions.DEFAULT_PATTERNS)" ResetButtonText="@T("Restore default")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.ExcludePatterns, out var meta) && meta.IsLocked"/>
</ExcludedContent>
</FilePatternSelection>
<MudText Typo="Typo.subtitle2" Class="mb-2 mt-4">@T("File size limit")</MudText>
<ConfigurationOption OptionDescription="@T("Include hidden files and folders by default?")" Disabled="@this.DefaultsDisabled" LabelOn="@T("Hidden files and folders are included")" LabelOff="@T("Hidden files and folders are ignored")" State="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.IncludeHiddenFilesAndFolders)" StateUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.IncludeHiddenFilesAndFolders = value)" OptionHelp="@T("Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.IncludeHiddenFilesAndFolders, out var meta) && meta.IsLocked"/>
<ConfigurationText OptionDescription="@T("Default file patterns")" Disabled="@this.DefaultsDisabled" Icon="@Icons.Material.Filled.FilterAlt" Text="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.FilePatterns)" TextUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.FilePatterns = value)" ResetValue="@(() => DataBatchProcessing.DEFAULT_FILE_PATTERNS)" ResetButtonText="@T("Restore default patterns")" OptionHelp="@T("Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.FilePatterns, out var meta) && meta.IsLocked"/>
<ConfigurationText OptionDescription="@T("Default excluded files and folders")" Disabled="@this.DefaultsDisabled" Icon="@Icons.Material.Filled.FilterAltOff" Text="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.ExcludePatterns)" TextUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.ExcludePatterns = value)" NumLines="@FileExclusions.DEFAULT_LINE_COUNT" MaxLines="@FileExclusions.MAX_LINE_COUNT" ResetValue="@(() => FileExclusions.DEFAULT_PATTERNS)" ResetButtonText="@T("Restore default")" OptionHelp="@T("These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.ExcludePatterns, out var meta) && meta.IsLocked"/>
<ConfigurationNumber T="int" OptionDescription="@T("Ignore files larger than")" Min="@FolderFileSelection.SMALLEST_MAX_FILE_SIZE_MEGABYTES" Unit="MB" Disabled="@this.DefaultsDisabled" Value="@(() => this.SettingsManager.ConfigurationData.BatchProcessing.MaxFileSizeMegabytes)" ValueUpdate="@(value => this.SettingsManager.ConfigurationData.BatchProcessing.MaxFileSizeMegabytes = value)" OptionHelp="@T("Matching files above this size are left out of a batch run, e.g., images of virtual machines.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.BatchProcessing, x => x.MaxFileSizeMegabytes, out var meta) && meta.IsLocked"/>
<MudText Typo="Typo.h6" Class="mb-3 mt-6">@T("Instructions")</MudText>
@@ -296,7 +296,7 @@
@*
What the user has to decide comes with the buttons to decide it. The
sentence above says what is at stake; reading it and then having to find
the settings of the mailbox is where people give up.
the settings of the mailbox or the data source is where people give up.
*@
@if (status.Attention is DataSourceAttention.AUTH_FAILED)
{
@@ -320,6 +320,14 @@
</MudButton>
</MudStack>
}
else if (status.Attention is DataSourceAttention.LOCATION_UNREACHABLE && this.CanEditDataSource(status))
{
<MudStack Row="@true" Wrap="Wrap.Wrap" Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Edit" OnClick="@(async () => await this.EditDataSource(status))">
@T("Edit this data source")
</MudButton>
</MudStack>
}
</MudStack>
</ChildContent>
</MudExpansionPanel>
@@ -509,6 +509,25 @@ public partial class Embeddings : MSGComponentBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
/// <remarks>
/// Not offered for a data source the organization manages: its settings cannot be edited.
/// </remarks>
private bool CanEditDataSource(DataSourceEmbeddingStatus status) => DataSourceEditing.CanEdit(this.SettingsManager, status.DataSourceId);
/// <summary>
/// Opens the settings of a data source, e.g. to select the new location of a moved folder.
/// </summary>
/// <remarks>
/// Saving them indexes the data source right away, and that run reconnects what is already
/// stored to the new location. The page reloads through CONFIGURATION_CHANGED, which it
/// receives like everybody else.
/// </remarks>
private async Task EditDataSource(DataSourceEmbeddingStatus status)
{
if (await DataSourceEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, status.DataSourceId))
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
/// <summary>
/// Agrees to the removal a mailbox held back, for the very number the user read.
/// </summary>
@@ -411,6 +411,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Select the output folder
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1598970341"] = "Wählen Sie den Ausgabeordner aus"
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1624717965"] = "Als versteckt gelten Dateien und Ordner, deren Namen mit einem Punkt beginnen, z. B. .git oder .env, sowie solche, die das Betriebssystem als versteckt kennzeichnet. Ein Dateimuster, das mit einem Punkt beginnt, z. B. .gitignore, wählt solche Dateien trotzdem aus."
-- The configured default policy no longer exists. Please select another document analysis policy.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T169666151"] = "Das konfigurierte Standardregelwerk existiert nicht mehr. Bitte wählen Sie ein anderes Regelwerk für die Dokumentenanalyse aus."
@@ -432,18 +435,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Please provide a file name without a path, e.g., my-results.csv
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T189587595"] = "Bitte geben Sie einen Dateinamen ohne Pfad an, z. B. meine-ergebnisse.csv"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1902969218"] = "Versteckte Dateien und Ordner"
-- Select the folder containing your documents
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1926838679"] = "Wählen Sie den Ordner mit den Dokumenten aus"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1941827239"] = "Ja, versteckte Dateien und Ordner einbeziehen"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2000156297"] = "Inkludierte Dateien"
-- Include subfolders?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2086334687"] = "Unterordner einbeziehen?"
@@ -468,6 +462,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to create the output folder: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Der Ausgabeordner konnte nicht erstellt werden: {0}"
-- Yes, process hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2295617720"] = "Ja, auch versteckte Dateien und Ordner verarbeiten"
-- The AI answer was empty.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T230354366"] = "Die Antwort der KI war leer."
@@ -486,6 +483,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to read the existing transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2397111152"] = "Das vorhandene Transkript konnte nicht gelesen werden: {0}"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2460883298"] = "Dateiendungen"
-- Load prompt from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2474257795"] = "Prompt aus Datei laden"
@@ -534,12 +534,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- The batch run finished, but one file could not be processed. See the progress table and log for details.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3201532790"] = "Die Stapelverarbeitung ist abgeschlossen, aber eine Datei konnte nicht verarbeitet werden. Weitere Informationen finden Sie in der Fortschrittstabelle und im Protokoll."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3204694333"] = "Dateien und Ordner gelten als versteckt, wenn ihr Name mit einem Punkt beginnt oder das Betriebssystem sie als versteckt markiert. Wenn Sie „Nein“ wählen, werden versteckte Elemente übersprungen. Um eine Datei wie .gitignore ausdrücklich zu inkludieren, geben Sie ihren Namen unter „Inkludierte Dateien“ ein. Bei allgemeinen Mustern wie *, :text: oder :documents: werden versteckte Dateien weiterhin übersprungen. Ausschlussregeln haben immer Vorrang; Sie können sie in den Feldern unten ändern."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3225743883"] = "Maximale Dateigröße"
-- Maximum pause between files
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3250003796"] = "Maximale Pause zwischen Dateien"
@@ -564,6 +558,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Processing pace
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3428873429"] = "Verarbeitungsgeschwindigkeit"
-- Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3436936162"] = "Welche Dateien sollen verarbeitet werden? Trennen Sie mehrere Muster durch ein Semikolon, z. B. *.pdf;*.docx. Verwenden Sie :documents: für alle Dokumente und lesbaren Textdateien, :text: nur für lesbare Textdateien oder * für alle Dateien."
-- The file name of the CSV results table. We add the ending .csv when it is missing. When left empty, we use 'batch-results.csv'.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3439247329"] = "Der Dateiname der CSV-Ergebnistabelle. Die Endung .csv wird ergänzt, falls sie fehlt. Wenn Sie das Feld leer lassen, wird 'batch-results.csv' verwendet."
@@ -597,6 +594,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to write '{0}'. Please make sure that the file is not opened in another application. The results of this run are incomplete on disk. The message is: '{1}'
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3899869356"] = "'{0}' konnte nicht geschrieben werden. Bitte stellen Sie sicher, dass die Datei nicht in einem anderen Programm geöffnet ist. Die Ergebnisse dieses Laufs sind auf der Festplatte unvollständig. Die Meldung lautet: '{1}'"
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3934601817"] = "Diese Dateien und Ordner werden auch dann ausgeschlossen, wenn ein Dateimuster auf sie zutrifft. Trennen Sie die Einträge durch ein Semikolon oder einen Zeilenumbruch. Ein Eintrag mit einem Schrägstrich am Ende schließt alle Ordner mit diesem Namen aus, z. B. node_modules/. Alle anderen Einträge beziehen sich auf Dateien, z. B. *.min.js."
-- Select the file with your instructions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3943995624"] = "Datei mit Ihren Anweisungen auswählen"
@@ -4060,7 +4060,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T381231449"] = "Dat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T3859543867"] = "Postfach (IMAP)"
-- Add ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T590005498"] = "ERI-v1-Datenquelle hinzufügen"
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T590005498"] = "ERI v1 Datenquelle hinzufügen"
-- External Data (ERI-Server v1)
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEADDBUTTONS::T774473996"] = "Externe Daten (ERI-Server v1)"
@@ -4122,9 +4122,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1084943026"] = "Si
-- Automatic local data source refresh
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1208397349"] = "Automatische Aktualisierung lokaler Datenquellen"
-- Edit Local Directory Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1215599168"] = "Datenquelle bearbeiten: Lokaler Ordner"
-- Refresh
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T135637716"] = "Aktualisieren"
@@ -4152,12 +4149,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2067030738"] = "M
-- Local Directory Data Source Information
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2146756020"] = "Informationen zur lokalen Ordner-Datenquelle"
-- Edit ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T221059217"] = "ERI v1 Datenquelle bearbeiten"
-- Edit Local File Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2453292893"] = "Datenquelle bearbeiten: Lokale Datei"
-- Local data sources refresh when files change, mailboxes every 16 minutes.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2511157475"] = "Lokale Datenquellen werden aktualisiert, wenn sich Dateien ändern, Postfächer alle 16 Minuten."
@@ -4440,78 +4431,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T244391
-- Change what this tile opens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T4272203100"] = "Ändern, was diese Kachel öffnet"
-- Exclusion help and examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1309128010"] = "Hilfe und Beispiele zu Ausschlussregeln"
-- PDF files, regardless of letter case
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T138823545"] = "PDF-Dateien, unabhängig von der Groß-/Kleinschreibung"
-- Folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1412427146"] = "Ordner"
-- the excluded folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1647237555"] = "die ausgeschlossenen Ordner"
-- File names starting with draft-
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1676890987"] = "Dateinamen, die mit „draft-“ beginnen"
-- Excluded files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T2590452128"] = "Ausgeschlossene Dateien"
-- Folders ending in build, such as build or app-build
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T27471988"] = "Ordnernamen, die auf „build“ enden, z. B. „build“ oder „app-build“"
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T286958948"] = "Ausgeschlossene Dateien und Ordner"
-- Excluded folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3345276250"] = "Ausgeschlossene Ordner"
-- Files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3877444400"] = "Dateien"
-- Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3993888277"] = "Trennen Sie Muster durch Semikolons. Ordnermuster müssen mit / enden. Ausschlussregeln haben Vorrang."
-- the excluded files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T4083345389"] = "die ausgeschlossenen Dateien"
-- Exclusion examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T727293714"] = "Beispiele für Ausschlüsse"
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T913168756"] = "Standard wiederherstellen"
-- Exclude folders ending in build, such as build or app-build
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1241240542"] = "Ordner ausschließen, deren Namen auf „build“ enden, z. B. „build“ oder „app-build“"
-- Pattern help and examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1369669520"] = "Hilfe und Beispiele zu Dateimustern"
-- Include readable text files, including files without an extension
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1634513321"] = "Lesbare Textdateien inkludieren, auch Dateien ohne Dateiendung"
-- File names starting with draft-
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1676890987"] = "Dateinamen, die mit „draft-“ beginnen"
-- Pattern examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T2430822600"] = "Beispiele für Dateimuster"
-- Separate patterns with semicolons. Exclusions take priority over included files.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T2728429336"] = "Trennen Sie Muster durch Semikolons. Ausgeschlossene Dateien werden auch bei passendem Inklusionsmuster übersprungen."
-- PDF and Word files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T3467839719"] = "PDF- und Word-Dateien"
-- Patterns match file names regardless of letter case. Folder paths are not supported.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T3691425703"] = "Beim Abgleich mit Dateinamen wird die Groß-/Kleinschreibung nicht berücksichtigt. Ordnerpfade werden nicht unterstützt."
-- Include all files, including audio and video
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T827447634"] = "Alle Dateien inkludieren, auch Audio- und Videodateien"
-- Include all documents and readable text files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T967949655"] = "Alle Dokumente und lesbaren Textdateien inkludieren"
-- LLMs can make mistakes. Check important information.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "LLMs können Fehler machen. Überprüfen Sie wichtige Informationen."
@@ -6810,9 +6729,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG:
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Abbrechen"
-- Folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T104168189"] = "Ordner"
-- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1108876344"] = "Experten-Einstellungen ausblenden"
@@ -6831,24 +6747,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1311820297"
-- Selected base directory for this data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1312296210"] = "Ausgewähltes Stammverzeichnis für diese Datenquelle"
-- MindWork AI Studio uses Pandoc to read HTML files.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T133018120"] = "MindWork AI Studio verwendet Pandoc zum Lesen von HTML-Dateien."
-- No embedding selected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1359179968"] = "Keine Einbettung ausgewählt"
-- Select a root directory for this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1400580807"] = "Wählen Sie ein Stammverzeichnis für diese Datenquelle aus."
-- Provider trust
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T148137342"] = "Vertrauen in Anbieter"
-- No, only index files in the selected folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1490813458"] = "Nein, nur Dateien im ausgewählten Ordner indexieren"
-- Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1588814044"] = "Anzahl der Token, die am Anfang des nächsten Blocks wiederholt werden. Die Standardüberlappung beträgt {0} Token."
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1624717965"] = "Als versteckt gelten Dateien und Ordner, deren Namen mit einem Punkt beginnen, z. B. .git oder .env, sowie solche, die das Betriebssystem als versteckt kennzeichnet. Ein Dateimuster, das mit einem Punkt beginnt, z. B. .gitignore, wählt solche Dateien trotzdem aus."
-- Tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1696723386"] = "Tokenizer"
@@ -6864,18 +6777,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1827669611"
-- Update
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1847791252"] = "Aktualisieren"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1902969218"] = "Versteckte Dateien und Ordner"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1941827239"] = "Ja, versteckte Dateien und Ordner einbeziehen"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2000156297"] = "Inkludierte Dateien"
-- Data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2034620186"] = "Datenquelle"
-- Include subfolders?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2086334687"] = "Unterordner einbeziehen?"
@@ -6891,24 +6792,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T236253137"]
-- Please enter a token limit of at least 1.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2406580478"] = "Bitte geben Sie ein Token-Limit von mindestens 1 ein."
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2499087427"] = "Suchergebnisse"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2460883298"] = "Dateimuster"
-- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2646845972"] = "Hinzufügen"
-- The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2686150329"] = "Die Dokumente dieser Datenquelle sind bereits vorbereitet, daher kann ihr Ordner nicht geändert werden. Ein anderer Ordner enthält andere Dokumente und ist damit eine andere Datenquelle: Bitte fügen Sie dafür eine neue hinzu. Den Einbettungsanbieter darunter können Sie ändern."
-- 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."
-- Embedding
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2838542994"] = "Einbettung"
-- Choose an embedding provider to make this data source searchable.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2861534545"] = "Wählen Sie einen Einbettungsanbieter aus, um diese Datenquelle durchsuchbar zu machen."
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T286958948"] = "Ausgeschlossene Dateien und Ordner"
@@ -6918,38 +6813,38 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"
-- Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3017942214"] = "Audio- und Videodateien, die diese Datenquelle auswählt, werden übersprungen, weil Ihr Transkriptionsanbieter das Vertrauensniveau nicht erfüllt, das diese Datenquelle erfordert."
-- Text chunking
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3105524153"] = "Textunterteilung"
-- Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3119708878"] = "Audio- und Videodateien, die diese Datenquelle auswählt, werden übersprungen, bis Sie in den Einstellungen einen Transkriptionsanbieter auswählen."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3204694333"] = "Dateien und Ordner gelten als versteckt, wenn ihr Name mit einem Punkt beginnt oder das Betriebssystem sie als versteckt markiert. Wenn Sie „Nein“ wählen, werden versteckte Elemente übersprungen. Um eine Datei wie .gitignore ausdrücklich zu inkludieren, geben Sie ihren Namen unter „Inkludierte Dateien“ ein. Bei allgemeinen Mustern wie *, :text: oder :documents: werden versteckte Dateien weiterhin übersprungen. Ausschlussregeln haben immer Vorrang; Sie können sie in den Feldern unten ändern."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3225743883"] = "Maximale Dateigröße"
-- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Bitte geben Sie 0 oder eine positive Überlappungslänge ein."
-- Ignore files larger than
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3353866230"] = "Dateien ignorieren, die größer sind als"
-- For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3359366900"] = "Für einige Dateitypen, wie zum Beispiel Office-Dateien, benötigt MindWork AI Studio die Open-Source-Anwendung Pandoc."
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3361153305"] = "Experten-Einstellungen anzeigen"
-- No, ignore hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3584816527"] = "Nein, versteckte Dateien und Ordner ignorieren"
-- Only providers with this confidence level or higher may receive and process data from this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3755939304"] = "Nur Anbieter mit mindestens diesem Vertrauensniveau dürfen Daten dieser Datenquelle empfangen und verarbeiten."
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3934601817"] = "Diese Dateien und Ordner werden auch dann ausgeschlossen, wenn ein Dateimuster auf sie zutrifft. Trennen Sie die Einträge durch ein Semikolon oder einen Zeilenumbruch. Ein Eintrag mit einem Schrägstrich am Ende schließt alle Ordner mit diesem Namen aus, z. B. node_modules/. Alle anderen Einträge beziehen sich auf Dateien, z. B. *.min.js."
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T529871858"] = "Damit die KI jederzeit die passenden Daten ermitteln kann, müssen Sie einen Einbettungsanbieter auswählen."
-- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Wählen Sie das Stammverzeichnis aus"
-- Restore default patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T7425959"] = "Standardmuster wiederherstellen"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T599406584"] = "Welche Dateien sollen indexiert werden? Trennen Sie mehrere Muster durch ein Semikolon, z. B. *.txt;*.pdf;*.docx;.gitignore. Verwenden Sie :documents: für alle Dokumente und lesbaren Textdateien, :text: nur für lesbare Textdateien oder * für alle Dateien. Audio- und Videodateien, z. B. *.mp3 oder *.mp4, werden von Ihrem Transkriptionsanbieter transkribiert."
-- Yes, index hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Ja, auch versteckte Dateien und Ordner indexieren"
-- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "Das Token-Limit der Datenquelle darf nicht größer sein als das Token-Limit des Einbettungsanbieters ({0})."
@@ -7002,9 +6897,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T1725856
-- The embedding runs locally or in your organization. Your data is not sent to the cloud.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T1950544032"] = "Die Einbettung läuft lokal oder in ihrer Organisation. Ihre Daten werden nicht in die Cloud gesendet."
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2000156297"] = "Inkludierte Dateien"
-- the file patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2013714981"] = "die Dateimuster"
@@ -7017,6 +6909,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2333573
-- Required provider confidence level
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T236253137"] = "Erforderliches Vertrauensniveau des Anbieters"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2460883298"] = "Dateimuster"
-- the maximum number of matches per query
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2479753122"] = "die maximale Anzahl an Treffern pro Abfrage"
@@ -7029,6 +6924,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2658359
-- the data source name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2717738728"] = "den Namen der Datenquelle"
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T286958948"] = "Ausgeschlossene Dateien und Ordner"
-- The directory chosen for the data source does not exist anymore. Please edit the data source and correct the path.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2875614207"] = "Das für die Datenquelle gewählte Verzeichnis existiert nicht mehr. Bitte bearbeiten Sie die Datenquelle und korrigieren Sie den Pfad."
@@ -7068,6 +6966,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T3818527
-- Path
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T3949388886"] = "Pfad"
-- the excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T4001527349"] = "die ausgeschlossenen Dateien und Ordner"
-- The directory chosen for the data source exists.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T445858624"] = "Das ausgewählte Verzeichnis für die Datenquelle ist vorhanden."
@@ -7083,15 +6984,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "
-- Default tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Standard-Tokenizer"
-- MindWork AI Studio uses Pandoc to read HTML files.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T133018120"] = "MindWork AI Studio verwendet Pandoc zum Lesen von HTML-Dateien."
-- No embedding selected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1359179968"] = "Keine Einbettung ausgewählt"
-- Provider trust
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T148137342"] = "Vertrauen in Anbieter"
-- Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1588814044"] = "Anzahl der Token, die am Anfang des nächsten Blocks wiederholt werden. Die Standardüberlappung beträgt {0} Token."
@@ -7110,9 +7005,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1827669611"] = "
-- Update
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1847791252"] = "Aktualisieren"
-- Data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2034620186"] = "Datenquelle"
-- The overlap must be smaller than the effective token limit.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2101951526"] = "Die Überlappung muss kleiner sein als das effektive Token-Limit."
@@ -7125,9 +7017,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "
-- This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2468760910"] = "Diese Audio- oder Videodatei wird übersprungen, weil Ihr Transkriptionsanbieter das Vertrauensniveau nicht erfüllt, das diese Datenquelle erfordert."
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2499087427"] = "Suchergebnisse"
-- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Hinzufügen"
@@ -7140,32 +7029,26 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2838542994"] = "
-- Describe what data this file contains to help the AI select it.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2859265837"] = "Beschreiben Sie, welche Daten diese Datei enthält, um der KI bei der Auswahl zu helfen."
-- Choose an embedding provider to make this data source searchable.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2861534545"] = "Wählen Sie einen Einbettungsanbieter aus, um diese Datenquelle durchsuchbar zu machen."
-- Token limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2961294165"] = "Token-Limit"
-- Text chunking
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3105524153"] = "Textunterteilung"
-- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3242265813"] = "Bitte geben Sie 0 oder eine positive Überlappungslänge ein."
-- For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3359366900"] = "Für einige Dateitypen, wie zum Beispiel Office-Dateien, benötigt MindWork AI Studio die Open-Source-Anwendung Pandoc."
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3361153305"] = "Experten-Einstellungen anzeigen"
-- Select the file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Datei auswählen"
-- Only providers with this confidence level or higher may receive and process data from this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3755939304"] = "Nur Anbieter mit mindestens diesem Vertrauensniveau dürfen Daten dieser Datenquelle empfangen und verarbeiten."
-- This audio or video file is skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T4026150982"] = "Diese Audio- oder Videodatei wird übersprungen, bis Sie in den Einstellungen einen Transkriptionsanbieter auswählen."
-- File
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T723007075"] = "Datei"
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "Damit die KI jederzeit die passenden Daten ermitteln kann, müssen Sie einen Einbettungsanbieter auswählen."
-- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T787118522"] = "Das Token-Limit der Datenquelle darf nicht größer sein als das Token-Limit des Einbettungsanbieters ({0})."
@@ -7173,9 +7056,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T787118522"] = "D
-- Data Source Name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T813773421"] = "Name der Datenquelle"
-- The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T890383764"] = "Die Dokumente dieser Datenquelle sind bereits vorbereitet, daher kann ihre Datei nicht geändert werden. Eine andere Datei enthält andere Inhalte und ist damit eine andere Datenquelle: Bitte fügen Sie dafür eine neue hinzu. Den Einbettungsanbieter darunter können Sie ändern."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T900713019"] = "Abbrechen"
@@ -8700,6 +8580,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T15
-- Leave empty to use the ai-results subfolder of the input folder.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1550632323"] = "Leer lassen, um den Unterordner „ai-results“ des Eingabeordners zu verwenden."
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1624717965"] = "Als versteckt gelten Dateien und Ordner, deren Namen mit einem Punkt beginnen, z. B. .git oder .env, sowie solche, die das Betriebssystem als versteckt kennzeichnet. Ein Dateimuster, das mit einem Punkt beginnt, z. B. .gitignore, wählt solche Dateien trotzdem aus."
-- seconds
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1723256298"] = "Sekunden"
@@ -8712,18 +8595,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T17
-- Batch processing options are preselected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1893713430"] = "Optionen für die Stapelverarbeitung sind vorausgewählt"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1902969218"] = "Versteckte Dateien und Ordner"
-- Default custom column separator
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T19367494"] = "Standardmäßiges benutzerdefiniertes Trennzeichen für Spalten"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1941827239"] = "Ja, versteckte Dateien und Ordner einbeziehen"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2000156297"] = "Inkludierte Dateien"
-- Default document analysis policy
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2015391667"] = "Standardregelwerk für die Dokumentenanalyse"
@@ -8733,6 +8607,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T21
-- Default output folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T223484721"] = "Standard-Ausgabeordner"
-- Hidden files and folders are included
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2288334048"] = "Versteckte Dateien und Ordner werden einbezogen"
-- The lower end of the random pause interval. AI Studio never allows less than 6 seconds.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T237774509"] = "Das untere Ende des Intervalls für die zufällige Pause. AI Studio erlaubt niemals weniger als 6 Sekunden."
@@ -8745,6 +8622,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T24
-- When enabled, new batch runs start with the defaults configured below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2592677194"] = "Wenn aktiviert, werden neue Stapel-Durchläufe mit den unten konfigurierten Standardwerten gestartet."
-- Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2594325620"] = "Trennen Sie mehrere Dateimuster durch ein Semikolon, z. B. *.pdf;*.docx. Die Standardmuster umfassen alle unterstützten Audio- und Videoformate."
-- Subfolders are included
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2607092632"] = "Unterordner werden einbezogen"
@@ -8766,6 +8646,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T28
-- Please enter exactly one punctuation or symbol character. Letters, numbers, spaces, quotation marks, and line breaks cannot be used as CSV separators.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2908365499"] = "Bitte geben Sie genau ein Satz- oder Sonderzeichen ein. Buchstaben, Zahlen, Leerzeichen, Anführungszeichen und Zeilenumbrüche können nicht als CSV-Trennzeichen verwendet werden."
-- Default file patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2909693903"] = "Standard-Dateimuster"
-- Only the selected folder is processed
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2958253681"] = "Nur der ausgewählte Ordner wird verarbeitet."
@@ -8781,12 +8664,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T31
-- These instructions are applied to every document of a new batch run.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3195548336"] = "Diese Anweisungen werden auf jedes Dokument eines neuen Stapelverarbeitungsdurchlaufs angewendet."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3204694333"] = "Dateien und Ordner gelten als versteckt, wenn ihr Name mit einem Punkt beginnt oder das Betriebssystem sie als versteckt markiert. Wenn Sie „Nein“ wählen, werden versteckte Elemente übersprungen. Um eine Datei wie .gitignore ausdrücklich zu inkludieren, geben Sie ihren Namen unter „Inkludierte Dateien“ ein. Bei allgemeinen Mustern wie *, :text: oder :documents: werden versteckte Dateien weiterhin übersprungen. Ausschlussregeln haben immer Vorrang; Sie können sie in den Feldern unten ändern."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3225743883"] = "Maximale Dateigröße"
-- Ignore files larger than
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3353866230"] = "Dateien ignorieren, die größer sind als"
@@ -8808,9 +8685,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T34
-- The current content of this Markdown file is loaded whenever the defaults are applied.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3468539567"] = "Der aktuelle Inhalt dieser Markdown-Datei wird geladen, wenn die Standardwerte angewendet werden."
-- No, ignore hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3584816527"] = "Nein, versteckte Dateien und Ordner ignorieren"
-- Your organization requires a pause of at least {0} seconds between files. Users can configure only the upper limit.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3663516199"] = "Ihre Organisation verlangt eine Pause von mindestens {0} Sekunden zwischen Dateien. Benutzer können nur die Obergrenze festlegen."
@@ -8820,6 +8694,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T37
-- Default results table name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3816237687"] = "Standardname der Ergebnistabelle"
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3934601817"] = "Diese Dateien und Ordner werden auch dann ausgeschlossen, wenn ein Dateimuster auf sie zutrifft. Trennen Sie die Einträge durch ein Semikolon oder einen Zeilenumbruch. Ein Eintrag mit einem Schrägstrich am Ende schließt alle Ordner mit diesem Namen aus, z. B. node_modules/. Alle anderen Einträge beziehen sich auf Dateien, z. B. *.min.js."
-- Default Markdown instructions file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3967465682"] = "Standarddatei für Markdown-Anweisungen"
@@ -8862,6 +8739,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T74
-- Leave empty when an input folder should be selected for every batch run.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T762890100"] = "Leer lassen, wenn für jeden Stapelverarbeitungsdurchlauf ein Eingabeordner ausgewählt werden soll."
-- Hidden files and folders are ignored
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T826400562"] = "Versteckte Dateien und Ordner werden ignoriert"
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Standard wiederherstellen"
@@ -10479,6 +10359,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T819555436"] = "Fehlgeschlagene E-
-- Skipped
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Übersprungen"
-- Edit this data source
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T878700886"] = "Diese Datenquelle bearbeiten"
-- Open the transcription settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T905182399"] = "Transkriptionseinstellungen öffnen"
@@ -12315,18 +12198,39 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEM
-- Qdrant Edge is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge ist nicht verfügbar."
-- Edit Local Directory Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T1215599168"] = "Datenquelle bearbeiten: Lokaler Ordner"
-- Edit ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T221059217"] = "ERI v1 Datenquelle bearbeiten"
-- Edit Local File Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T2453292893"] = "Datenquelle bearbeiten: Lokale Datei"
-- Every other file goes to your embedding provider once more, which takes a while with many files. Files missing at the new location are removed from the index, while your files on disk stay as they are.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T1555760974"] = "Jede andere Datei wird erneut an Ihren Einbettungsanbieter gesendet, was bei vielen Dateien eine Weile dauert. Dateien, die am neuen Speicherort fehlen, werden aus dem Index entfernt, während Ihre Dateien selbst unverändert bleiben."
-- AI Studio keeps what it has already prepared for every file it finds unchanged at the new location, that is, of the same type and with the same size and modification time. In a folder, such a file also has to keep its name and the subfolder it is in.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2205693550"] = "Was AI Studio bereits vorbereitet hat, bleibt für jede Datei erhalten, die es am neuen Speicherort unverändert findet, also mit demselben Dateityp, derselben Größe und demselben Änderungsdatum. In einem Ordner muss eine solche Datei außerdem ihren Namen behalten und im selben Unterordner liegen."
-- They keep answering keyword searches, but searching them by meaning stops working, and no further documents can be prepared for them. The ones which are already prepared stay tied to this provider as well, so you cannot simply move them to another one.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2343773457"] = "Sie beantworten weiterhin Stichwortsuchen, aber die Suche nach Bedeutung funktioniert nicht mehr, und es können keine weiteren Dokumente für sie vorbereitet werden. Bereits vorbereitete Datenquellen bleiben zudem an diesen Anbieter gebunden, sodass Sie sie nicht einfach auf einen anderen umstellen können."
-- and {0} more.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2519847121"] = "und {0} weitere."
-- Your embedding provider runs in the cloud, so preparing files again costs money.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2635623258"] = "Ihr Einbettungsanbieter läuft in der Cloud, daher kostet es Geld, Dateien erneut vorzubereiten."
-- This change makes the prepared documents of the following data sources unusable ({0}):
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3337378891"] = "Durch diese Änderung werden die vorbereiteten Dokumente der folgenden Datenquellen unbrauchbar ({0}):"
-- Do you want to apply this change anyway?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3419411838"] = "Möchten Sie diese Änderung trotzdem übernehmen?"
-- New Location for This Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3462449568"] = "Neuer Speicherort für diese Datenquelle"
-- Documents Will Be Prepared Again
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T737291513"] = "Dokumente werden erneut vorbereitet"
@@ -12489,12 +12393,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "Das
-- policy files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "Richtliniendateien"
-- Enter folder patterns ending with / in 'Folders'. File patterns must not end with /.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T2019378403"] = "Geben Sie in „Ordner“ Ordnermuster ein, die mit / enden. Dateimuster dürfen nicht mit / enden."
-- Folder patterns must end with /, e.g., build/. Enter file patterns such as *.pdf in 'Files'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T2695719308"] = "Ordnermuster müssen mit / enden, z. B. build/. Geben Sie Dateimuster wie *.pdf im Feld „Dateien“ ein."
-- Selectors such as :text: cannot be excluded. Please use file name patterns such as *.min.js or folder names such as bin/.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T405437626"] = "Selektoren wie :text: können nicht ausgeschlossen werden. Bitte verwenden Sie Dateinamensmuster wie *.min.js oder Ordnernamen wie bin/."
@@ -12720,6 +12618,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T2207479763"] = "Eines der D
-- Please provide at least one file pattern, e.g., :documents: or *.txt. Separate multiple patterns with a semicolon.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T3371062811"] = "Bitte geben Sie mindestens ein Dateimuster an, z. B. :documents: oder *.txt. Trennen Sie mehrere Muster durch ein Semikolon."
-- Please remove empty file patterns. Separate valid patterns with a single semicolon.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T368919579"] = "Bitte entfernen Sie leere Dateimuster. Trennen Sie gültige Muster durch ein einzelnes Semikolon."
-- Please use only single asterisks as wildcards, e.g., *.txt or report-*.md.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T495100949"] = "Bitte verwenden Sie als Platzhalter nur einzelne Sternchen, z. B. *.txt oder report-*.md."
@@ -13869,18 +13770,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T8042
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1214987337"] = "Der Ordner „{0}“ konnte nicht geöffnet werden. Bitte prüfen Sie, ob Sie die erforderlichen Leserechte haben. Aus dem Index wurde nichts entfernt."
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1393648320"] = "Die Datei „{0}“ ist nicht erreichbar. Möglicherweise ist das Laufwerk, auf dem sie sich befindet, nicht verbunden, oder die Datei wurde verschoben oder umbenannt. Aus dem Index wurde nichts entfernt. Sobald die Datei wieder verfügbar ist, aktualisieren Sie diese Datenquelle. Wenn sie dauerhaft nicht mehr vorhanden ist, entfernen Sie diese Datenquelle."
-- The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1935191670"] = "Die Datei „{0}“ wurde während der Indexierung geändert. Die bereits indexierten Inhalte werden verworfen, und die Datei wird beim nächsten Durchlauf erneut verarbeitet."
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. If the file was moved or renamed, edit this data source to select its new location. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2268993048"] = "Die Datei „{0}“ ist nicht erreichbar. Möglicherweise ist das Laufwerk, auf dem sie sich befindet, nicht verbunden, oder die Datei wurde verschoben oder umbenannt. Aus dem Index wurde nichts entfernt. Wenn die Datei verschoben oder umbenannt wurde, bearbeiten Sie diese Datenquelle und wählen Sie den neuen Speicherort der Datei aus. Sobald die Datei wieder verfügbar ist, aktualisieren Sie diese Datenquelle. Wenn sie dauerhaft nicht mehr vorhanden ist, entfernen Sie diese Datenquelle."
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. If the folder was moved or renamed, edit this data source to select its new location. Once the folder is back, refresh this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2692299965"] = "Der Ordner „{0}“ ist nicht erreichbar. Möglicherweise ist das Laufwerk, auf dem er sich befindet, nicht verbunden, oder der Ordner wurde verschoben oder umbenannt. Aus dem Index wurde nichts entfernt. Wenn der Ordner verschoben oder umbenannt wurde, bearbeiten Sie diese Datenquelle und wählen Sie den neuen Speicherort des Ordners aus. Sobald der Ordner wieder verfügbar ist, aktualisieren Sie diese Datenquelle."
-- The folder '{0}' currently appears empty. AI Studio kept the index of this data source, because the folder of a drive that is not connected can look empty. Connect the drive and refresh this data source. If you emptied the folder on purpose, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2999277901"] = "Der Ordner „{0}“ scheint derzeit leer zu sein. AI Studio hat den Index dieser Datenquelle beibehalten, da ein Ordner auf einem nicht verbundenen Laufwerk leer erscheinen kann. Verbinden Sie das Laufwerk und aktualisieren Sie diese Datenquelle. Wenn Sie den Ordner absichtlich geleert haben, entfernen Sie diese Datenquelle."
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3230000698"] = "Der Ordner „{0}“ konnte nicht geöffnet werden. Bitte prüfen Sie, ob Sie ihn lesen dürfen."
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3722338131"] = "Der Ordner „{0}“ ist nicht erreichbar. Möglicherweise ist das Laufwerk, auf dem er sich befindet, nicht verbunden, oder der Ordner wurde verschoben oder umbenannt. Aus dem Index wurde nichts entfernt. Sobald der Ordner wieder verfügbar ist, aktualisieren Sie diese Datenquelle."
-- The file '{0}' could not be read. Please check whether you are allowed to read it.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3924882233"] = "Die Datei „{0}“ konnte nicht gelesen werden. Bitte prüfen Sie, ob Sie berechtigt sind, sie zu lesen."
@@ -411,6 +411,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Select the output folder
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1598970341"] = "Select the output folder"
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1624717965"] = "Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway."
-- The configured default policy no longer exists. Please select another document analysis policy.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T169666151"] = "The configured default policy no longer exists. Please select another document analysis policy."
@@ -432,18 +435,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Please provide a file name without a path, e.g., my-results.csv
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T189587595"] = "Please provide a file name without a path, e.g., my-results.csv"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1902969218"] = "Hidden files and folders"
-- Select the folder containing your documents
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1926838679"] = "Select the folder containing your documents"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1941827239"] = "Yes, include hidden files and folders"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2000156297"] = "Files to include"
-- Include subfolders?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2086334687"] = "Include subfolders?"
@@ -468,6 +462,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to create the output folder: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2290092642"] = "Was not able to create the output folder: {0}"
-- Yes, process hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2295617720"] = "Yes, process hidden files and folders as well"
-- The AI answer was empty.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T230354366"] = "The AI answer was empty."
@@ -486,6 +483,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to read the existing transcript: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2397111152"] = "Was not able to read the existing transcript: {0}"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2460883298"] = "File patterns"
-- Load prompt from file
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2474257795"] = "Load prompt from file"
@@ -534,12 +534,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- The batch run finished, but one file could not be processed. See the progress table and log for details.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3201532790"] = "The batch run finished, but one file could not be processed. See the progress table and log for details."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3204694333"] = "Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3225743883"] = "File size limit"
-- Maximum pause between files
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3250003796"] = "Maximum pause between files"
@@ -564,6 +558,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Processing pace
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3428873429"] = "Processing pace"
-- Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3436936162"] = "Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file."
-- The file name of the CSV results table. We add the ending .csv when it is missing. When left empty, we use 'batch-results.csv'.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3439247329"] = "The file name of the CSV results table. We add the ending .csv when it is missing. When left empty, we use 'batch-results.csv'."
@@ -597,6 +594,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to write '{0}'. Please make sure that the file is not opened in another application. The results of this run are incomplete on disk. The message is: '{1}'
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3899869356"] = "Was not able to write '{0}'. Please make sure that the file is not opened in another application. The results of this run are incomplete on disk. The message is: '{1}'"
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3934601817"] = "These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js."
-- Select the file with your instructions
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3943995624"] = "Select the file with your instructions"
@@ -4122,9 +4122,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1084943026"] = "Yo
-- Automatic local data source refresh
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1208397349"] = "Automatic local data source refresh"
-- Edit Local Directory Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T1215599168"] = "Edit Local Directory Data Source"
-- Refresh
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T135637716"] = "Refresh"
@@ -4152,12 +4149,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2067030738"] = "Ar
-- Local Directory Data Source Information
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2146756020"] = "Local Directory Data Source Information"
-- Edit ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T221059217"] = "Edit ERI v1 Data Source"
-- Edit Local File Data Source
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2453292893"] = "Edit Local File Data Source"
-- Local data sources refresh when files change, mailboxes every 16 minutes.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCEMANAGEMENT::T2511157475"] = "Local data sources refresh when files change, mailboxes every 16 minutes."
@@ -4440,78 +4431,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T244391
-- Change what this tile opens
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T4272203100"] = "Change what this tile opens"
-- Exclusion help and examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1309128010"] = "Exclusion help and examples"
-- PDF files, regardless of letter case
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T138823545"] = "PDF files, regardless of letter case"
-- Folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1412427146"] = "Folders"
-- the excluded folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1647237555"] = "the excluded folders"
-- File names starting with draft-
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T1676890987"] = "File names starting with draft-"
-- Excluded files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T2590452128"] = "Excluded files"
-- Folders ending in build, such as build or app-build
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T27471988"] = "Folders ending in build, such as build or app-build"
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T286958948"] = "Excluded files and folders"
-- Excluded folders
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3345276250"] = "Excluded folders"
-- Files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3877444400"] = "Files"
-- Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T3993888277"] = "Separate patterns with semicolons. Folder patterns must end with /. Exclusions take priority."
-- the excluded files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T4083345389"] = "the excluded files"
-- Exclusion examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T727293714"] = "Exclusion examples"
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXCLUSIONEDITOR::T913168756"] = "Restore default"
-- Exclude folders ending in build, such as build or app-build
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1241240542"] = "Exclude folders ending in build, such as build or app-build"
-- Pattern help and examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1369669520"] = "Pattern help and examples"
-- Include readable text files, including files without an extension
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1634513321"] = "Include readable text files, including files without an extension"
-- File names starting with draft-
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T1676890987"] = "File names starting with draft-"
-- Pattern examples
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T2430822600"] = "Pattern examples"
-- Separate patterns with semicolons. Exclusions take priority over included files.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T2728429336"] = "Separate patterns with semicolons. Exclusions take priority over included files."
-- PDF and Word files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T3467839719"] = "PDF and Word files"
-- Patterns match file names regardless of letter case. Folder paths are not supported.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T3691425703"] = "Patterns match file names regardless of letter case. Folder paths are not supported."
-- Include all files, including audio and video
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T827447634"] = "Include all files, including audio and video"
-- Include all documents and readable text files
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEPATTERNSELECTION::T967949655"] = "Include all documents and readable text files"
-- LLMs can make mistakes. Check important information.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "LLMs can make mistakes. Check important information."
@@ -6810,9 +6729,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG:
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCEERIV1USERNAMEPASSWORDEXPORTDIALOG::T900713019"] = "Cancel"
-- Folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T104168189"] = "Folder"
-- Hide Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1108876344"] = "Hide Expert Settings"
@@ -6831,24 +6747,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1311820297"
-- Selected base directory for this data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1312296210"] = "Selected base directory for this data source"
-- MindWork AI Studio uses Pandoc to read HTML files.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T133018120"] = "MindWork AI Studio uses Pandoc to read HTML files."
-- No embedding selected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1359179968"] = "No embedding selected"
-- Select a root directory for this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1400580807"] = "Select a root directory for this data source."
-- Provider trust
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T148137342"] = "Provider trust"
-- No, only index files in the selected folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1490813458"] = "No, only index files in the selected folder"
-- Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1588814044"] = "Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens."
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1624717965"] = "Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway."
-- Tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1696723386"] = "Tokenizer"
@@ -6864,18 +6777,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1827669611"
-- Update
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1847791252"] = "Update"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1902969218"] = "Hidden files and folders"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T1941827239"] = "Yes, include hidden files and folders"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2000156297"] = "Files to include"
-- Data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2034620186"] = "Data source"
-- Include subfolders?
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2086334687"] = "Include subfolders?"
@@ -6891,24 +6792,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T236253137"]
-- Please enter a token limit of at least 1.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2406580478"] = "Please enter a token limit of at least 1."
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2499087427"] = "Search results"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2460883298"] = "File patterns"
-- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2646845972"] = "Add"
-- The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2686150329"] = "The documents of this data source are already prepared, so its folder cannot be changed. Another folder holds other documents, which makes it another data source: please add one for it. The embedding provider below can be changed."
-- 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"] = "The embedding you selected runs locally or in your organization. Your data is not sent to the cloud."
-- Embedding
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2838542994"] = "Embedding"
-- Choose an embedding provider to make this data source searchable.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2861534545"] = "Choose an embedding provider to make this data source searchable."
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T286958948"] = "Excluded files and folders"
@@ -6918,38 +6813,38 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2961294165"
-- Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3017942214"] = "Audio and video files which this data source selects are skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Text chunking
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3105524153"] = "Text chunking"
-- Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3119708878"] = "Audio and video files which this data source selects are skipped until you choose a transcription provider in the settings."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3204694333"] = "Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3225743883"] = "File size limit"
-- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length."
-- Ignore files larger than
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3353866230"] = "Ignore files larger than"
-- For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3359366900"] = "For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc."
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3361153305"] = "Show Expert Settings"
-- No, ignore hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3584816527"] = "No, ignore hidden files and folders"
-- Only providers with this confidence level or higher may receive and process data from this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3755939304"] = "Only providers with this confidence level or higher may receive and process data from this data source."
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T3934601817"] = "These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js."
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider."
-- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory"
-- Restore default patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T7425959"] = "Restore default patterns"
-- Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T599406584"] = "Which files should be indexed? Separate multiple patterns with a semicolon, e.g., *.txt;*.pdf;*.docx;.gitignore. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file. Audio and video files, e.g., *.mp3 or *.mp4, are transcribed by your transcription provider."
-- Yes, index hidden files and folders as well
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T607153623"] = "Yes, index hidden files and folders as well"
-- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})."
@@ -7002,9 +6897,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T1725856
-- The embedding runs locally or in your organization. Your data is not sent to the cloud.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T1950544032"] = "The embedding runs locally or in your organization. Your data is not sent to the cloud."
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2000156297"] = "Files to include"
-- the file patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2013714981"] = "the file patterns"
@@ -7017,6 +6909,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2333573
-- Required provider confidence level
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T236253137"] = "Required provider confidence level"
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2460883298"] = "File patterns"
-- the maximum number of matches per query
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2479753122"] = "the maximum number of matches per query"
@@ -7029,6 +6924,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2658359
-- the data source name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2717738728"] = "the data source name"
-- Excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T286958948"] = "Excluded files and folders"
-- The directory chosen for the data source does not exist anymore. Please edit the data source and correct the path.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T2875614207"] = "The directory chosen for the data source does not exist anymore. Please edit the data source and correct the path."
@@ -7068,6 +6966,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T3818527
-- Path
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T3949388886"] = "Path"
-- the excluded files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T4001527349"] = "the excluded files and folders"
-- The directory chosen for the data source exists.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYINFODIALOG::T445858624"] = "The directory chosen for the data source exists."
@@ -7083,15 +6984,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1133561850"] = "
-- Default tokenizer
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1220918127"] = "Default tokenizer"
-- MindWork AI Studio uses Pandoc to read HTML files.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T133018120"] = "MindWork AI Studio uses Pandoc to read HTML files."
-- No embedding selected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1359179968"] = "No embedding selected"
-- Provider trust
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T148137342"] = "Provider trust"
-- Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1588814044"] = "Number of tokens repeated at the start of the next chunk. The default overlap is {0} tokens."
@@ -7110,9 +7005,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1827669611"] = "
-- Update
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T1847791252"] = "Update"
-- Data source
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2034620186"] = "Data source"
-- The overlap must be smaller than the effective token limit.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2101951526"] = "The overlap must be smaller than the effective token limit."
@@ -7125,9 +7017,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2406580478"] = "
-- This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2468760910"] = "This audio or video file is skipped, because your transcription provider does not meet the confidence level this data source requires."
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2499087427"] = "Search results"
-- Add
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2646845972"] = "Add"
@@ -7140,32 +7029,26 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2838542994"] = "
-- Describe what data this file contains to help the AI select it.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2859265837"] = "Describe what data this file contains to help the AI select it."
-- Choose an embedding provider to make this data source searchable.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2861534545"] = "Choose an embedding provider to make this data source searchable."
-- Token limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T2961294165"] = "Token limit"
-- Text chunking
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3105524153"] = "Text chunking"
-- Please enter 0 or a positive overlap length.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3242265813"] = "Please enter 0 or a positive overlap length."
-- For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3359366900"] = "For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc."
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3361153305"] = "Show Expert Settings"
-- Select the file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3740148848"] = "Select the file"
-- Only providers with this confidence level or higher may receive and process data from this data source.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T3755939304"] = "Only providers with this confidence level or higher may receive and process data from this data source."
-- This audio or video file is skipped until you choose a transcription provider in the settings.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T4026150982"] = "This audio or video file is skipped until you choose a transcription provider in the settings."
-- File
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T723007075"] = "File"
-- In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T529871858"] = "In order for the AI to be able to determine the appropriate data at any time, you must choose an embedding provider."
-- The data source token limit must not be larger than the embedding provider token limit ({0}).
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T787118522"] = "The data source token limit must not be larger than the embedding provider token limit ({0})."
@@ -7173,9 +7056,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T787118522"] = "T
-- Data Source Name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T813773421"] = "Data Source Name"
-- The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T890383764"] = "The documents of this data source are already prepared, so its file cannot be changed. Another file holds other content, which makes it another data source: please add one for it. The embedding provider below can be changed."
-- Cancel
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T900713019"] = "Cancel"
@@ -8700,6 +8580,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T15
-- Leave empty to use the ai-results subfolder of the input folder.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1550632323"] = "Leave empty to use the ai-results subfolder of the input folder."
-- Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1624717965"] = "Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway."
-- seconds
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1723256298"] = "seconds"
@@ -8712,18 +8595,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T17
-- Batch processing options are preselected
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1893713430"] = "Batch processing options are preselected"
-- Hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1902969218"] = "Hidden files and folders"
-- Default custom column separator
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T19367494"] = "Default custom column separator"
-- Yes, include hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1941827239"] = "Yes, include hidden files and folders"
-- Files to include
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2000156297"] = "Files to include"
-- Default document analysis policy
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2015391667"] = "Default document analysis policy"
@@ -8733,6 +8607,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T21
-- Default output folder
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T223484721"] = "Default output folder"
-- Hidden files and folders are included
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2288334048"] = "Hidden files and folders are included"
-- The lower end of the random pause interval. AI Studio never allows less than 6 seconds.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T237774509"] = "The lower end of the random pause interval. AI Studio never allows less than 6 seconds."
@@ -8745,6 +8622,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T24
-- When enabled, new batch runs start with the defaults configured below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2592677194"] = "When enabled, new batch runs start with the defaults configured below."
-- Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2594325620"] = "Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats."
-- Subfolders are included
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2607092632"] = "Subfolders are included"
@@ -8766,6 +8646,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T28
-- Please enter exactly one punctuation or symbol character. Letters, numbers, spaces, quotation marks, and line breaks cannot be used as CSV separators.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2908365499"] = "Please enter exactly one punctuation or symbol character. Letters, numbers, spaces, quotation marks, and line breaks cannot be used as CSV separators."
-- Default file patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2909693903"] = "Default file patterns"
-- Only the selected folder is processed
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T2958253681"] = "Only the selected folder is processed"
@@ -8781,12 +8664,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T31
-- These instructions are applied to every document of a new batch run.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3195548336"] = "These instructions are applied to every document of a new batch run."
-- Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3204694333"] = "Files and folders are hidden if their names start with a dot or the operating system marks them as hidden. If you choose 'No', hidden items are skipped. To explicitly include a file such as .gitignore, enter its name under 'Files to include'. Using generic patterns such as *, :text: or :documents: still skips hidden files. Exclusion rules always take priority; you can change them in the fields below."
-- File size limit
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3225743883"] = "File size limit"
-- Ignore files larger than
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3353866230"] = "Ignore files larger than"
@@ -8808,9 +8685,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T34
-- The current content of this Markdown file is loaded whenever the defaults are applied.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3468539567"] = "The current content of this Markdown file is loaded whenever the defaults are applied."
-- No, ignore hidden files and folders
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3584816527"] = "No, ignore hidden files and folders"
-- Your organization requires a pause of at least {0} seconds between files. Users can configure only the upper limit.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3663516199"] = "Your organization requires a pause of at least {0} seconds between files. Users can configure only the upper limit."
@@ -8820,6 +8694,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T37
-- Default results table name
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3816237687"] = "Default results table name"
-- These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3934601817"] = "These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js."
-- Default Markdown instructions file
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T3967465682"] = "Default Markdown instructions file"
@@ -8862,6 +8739,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T74
-- Leave empty when an input folder should be selected for every batch run.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T762890100"] = "Leave empty when an input folder should be selected for every batch run."
-- Hidden files and folders are ignored
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T826400562"] = "Hidden files and folders are ignored"
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T913168756"] = "Restore default"
@@ -10479,6 +10359,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T819555436"] = "Failed mails: {0}"
-- Skipped
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T852993367"] = "Skipped"
-- Edit this data source
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T878700886"] = "Edit this data source"
-- Open the transcription settings
UI_TEXT_CONTENT["AISTUDIO::PAGES::EMBEDDINGS::T905182399"] = "Open the transcription settings"
@@ -12315,18 +12198,39 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEM
-- Qdrant Edge is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATABASES::VECTORSTORE::QDRANTEDGECLIENTIMPLEMENTATION::T744445696"] = "Qdrant Edge is not available."
-- Edit Local Directory Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T1215599168"] = "Edit Local Directory Data Source"
-- Edit ERI v1 Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T221059217"] = "Edit ERI v1 Data Source"
-- Edit Local File Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEEDITING::T2453292893"] = "Edit Local File Data Source"
-- Every other file goes to your embedding provider once more, which takes a while with many files. Files missing at the new location are removed from the index, while your files on disk stay as they are.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T1555760974"] = "Every other file goes to your embedding provider once more, which takes a while with many files. Files missing at the new location are removed from the index, while your files on disk stay as they are."
-- AI Studio keeps what it has already prepared for every file it finds unchanged at the new location, that is, of the same type and with the same size and modification time. In a folder, such a file also has to keep its name and the subfolder it is in.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2205693550"] = "AI Studio keeps what it has already prepared for every file it finds unchanged at the new location, that is, of the same type and with the same size and modification time. In a folder, such a file also has to keep its name and the subfolder it is in."
-- They keep answering keyword searches, but searching them by meaning stops working, and no further documents can be prepared for them. The ones which are already prepared stay tied to this provider as well, so you cannot simply move them to another one.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2343773457"] = "They keep answering keyword searches, but searching them by meaning stops working, and no further documents can be prepared for them. The ones which are already prepared stay tied to this provider as well, so you cannot simply move them to another one."
-- and {0} more.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2519847121"] = "and {0} more."
-- Your embedding provider runs in the cloud, so preparing files again costs money.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T2635623258"] = "Your embedding provider runs in the cloud, so preparing files again costs money."
-- This change makes the prepared documents of the following data sources unusable ({0}):
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3337378891"] = "This change makes the prepared documents of the following data sources unusable ({0}):"
-- Do you want to apply this change anyway?
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3419411838"] = "Do you want to apply this change anyway?"
-- New Location for This Data Source
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T3462449568"] = "New Location for This Data Source"
-- Documents Will Be Prepared Again
UI_TEXT_CONTENT["AISTUDIO::TOOLS::DATASOURCEREINDEXWARNING::T737291513"] = "Documents Will Be Prepared Again"
@@ -12489,12 +12393,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T599774443"] = "The
-- policy files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::EXTERNALHTTPCLIENTTIMEOUT::T632340680"] = "policy files"
-- Enter folder patterns ending with / in 'Folders'. File patterns must not end with /.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T2019378403"] = "Enter folder patterns ending with / in 'Folders'. File patterns must not end with /."
-- Folder patterns must end with /, e.g., build/. Enter file patterns such as *.pdf in 'Files'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T2695719308"] = "Folder patterns must end with /, e.g., build/. Enter file patterns such as *.pdf in 'Files'."
-- Selectors such as :text: cannot be excluded. Please use file name patterns such as *.min.js or folder names such as bin/.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILEEXCLUSIONS::T405437626"] = "Selectors such as :text: cannot be excluded. Please use file name patterns such as *.min.js or folder names such as bin/."
@@ -12720,6 +12618,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T2207479763"] = "One of the
-- Please provide at least one file pattern, e.g., :documents: or *.txt. Separate multiple patterns with a semicolon.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T3371062811"] = "Please provide at least one file pattern, e.g., :documents: or *.txt. Separate multiple patterns with a semicolon."
-- Please remove empty file patterns. Separate valid patterns with a single semicolon.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T368919579"] = "Please remove empty file patterns. Separate valid patterns with a single semicolon."
-- Please use only single asterisks as wildcards, e.g., *.txt or report-*.md.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::FILENAMEPATTERNS::T495100949"] = "Please use only single asterisks as wildcards, e.g., *.txt or report-*.md."
@@ -13869,18 +13770,21 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::DOCUMENTRUNPROGRESS::T8042
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1214987337"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1393648320"] = "The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source."
-- The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T1935191670"] = "The file '{0}' changed while it was being indexed. What was indexed of it is discarded, and the file is tried again during the next run."
-- The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. If the file was moved or renamed, edit this data source to select its new location. Once the file is back, refresh this data source. When it is gone for good, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2268993048"] = "The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. If the file was moved or renamed, edit this data source to select its new location. Once the file is back, refresh this data source. When it is gone for good, remove this data source."
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. If the folder was moved or renamed, edit this data source to select its new location. Once the folder is back, refresh this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2692299965"] = "The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. If the folder was moved or renamed, edit this data source to select its new location. Once the folder is back, refresh this data source."
-- The folder '{0}' currently appears empty. AI Studio kept the index of this data source, because the folder of a drive that is not connected can look empty. Connect the drive and refresh this data source. If you emptied the folder on purpose, remove this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T2999277901"] = "The folder '{0}' currently appears empty. AI Studio kept the index of this data source, because the folder of a drive that is not connected can look empty. Connect the drive and refresh this data source. If you emptied the folder on purpose, remove this data source."
-- The folder '{0}' could not be opened. Please check whether you are allowed to read it.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3230000698"] = "The folder '{0}' could not be opened. Please check whether you are allowed to read it."
-- The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3722338131"] = "The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source."
-- The file '{0}' could not be read. Please check whether you are allowed to read it.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::INDEXING::FILESOURCEINDEXER::T3924882233"] = "The file '{0}' could not be read. Please check whether you are allowed to read it."
@@ -0,0 +1,115 @@
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Tools;
/// <summary>
/// Opens the settings of a data source, and stores what the user changed in them.
/// </summary>
/// <remarks>
/// Kept here rather than in the places which offer it -- the data source table and the background
/// embeddings page, which leads to it when the file or the folder of a data source cannot be
/// reached -- so that saving a data source works the same way everywhere: the settings are stored,
/// and the data source is indexed right after. Mailboxes are kept in a list of their own and have
/// MailboxEditing instead.
/// </remarks>
public static class DataSourceEditing
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(DataSourceEditing).Namespace, nameof(DataSourceEditing));
/// <summary>
/// Whether the user may edit a data source.
/// </summary>
/// <param name="settingsManager">The settings, which hold the data source.</param>
/// <param name="dataSourceId">The id of the data source.</param>
/// <returns>False when the data source is gone or managed by the organization.</returns>
public static bool CanEdit(SettingsManager settingsManager, string dataSourceId) =>
settingsManager.ConfigurationData.DataSources.Any(dataSource => dataSource.Id == dataSourceId && !dataSource.IsEnterpriseConfiguration);
/// <summary>
/// Lets the user edit a data source, and stores and indexes it when they save.
/// </summary>
/// <remarks>
/// The caller tells the rest of the app that the configuration changed, as the sender of that message.
/// </remarks>
/// <param name="dialogService">The dialog service to show the settings with.</param>
/// <param name="settingsManager">The settings, which hold the data source.</param>
/// <param name="embeddingService">The service which indexes the data source.</param>
/// <param name="dataSourceId">The id of the data source.</param>
/// <returns>True when the user saved the data source. False when they cancelled, or the data source is gone or managed by the organization.</returns>
public static async Task<bool> EditAsync(IDialogService dialogService, SettingsManager settingsManager, DataSourceEmbeddingService embeddingService, string dataSourceId)
{
var dataSources = settingsManager.ConfigurationData.DataSources;
var storedDataSource = dataSources.Find(dataSource => dataSource.Id == dataSourceId);
if (storedDataSource is null || storedDataSource.IsEnterpriseConfiguration)
return false;
IDataSource? editedDataSource = null;
switch (storedDataSource)
{
case DataSourceLocalFile localFile:
var localFileDialogParameters = new DialogParameters<DataSourceLocalFileDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localFile },
};
var localFileDialogReference = await dialogService.ShowAsync<DataSourceLocalFileDialog>(TB("Edit Local File Data Source"), localFileDialogParameters, DialogOptions.FULLSCREEN);
var localFileDialogResult = await localFileDialogReference.Result;
if (localFileDialogResult is null || localFileDialogResult.Canceled)
return false;
editedDataSource = (DataSourceLocalFile)localFileDialogResult.Data!;
break;
case DataSourceLocalDirectory localDirectory:
var localDirectoryDialogParameters = new DialogParameters<DataSourceLocalDirectoryDialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, localDirectory },
};
var localDirectoryDialogReference = await dialogService.ShowAsync<DataSourceLocalDirectoryDialog>(TB("Edit Local Directory Data Source"), localDirectoryDialogParameters, DialogOptions.FULLSCREEN);
var localDirectoryDialogResult = await localDirectoryDialogReference.Result;
if (localDirectoryDialogResult is null || localDirectoryDialogResult.Canceled)
return false;
editedDataSource = (DataSourceLocalDirectory)localDirectoryDialogResult.Data!;
break;
case DataSourceERI_V1 eriDataSource:
var eriDialogParameters = new DialogParameters<DataSourceERI_V1Dialog>
{
{ x => x.IsEditing, true },
{ x => x.DataSource, eriDataSource },
};
var eriDialogReference = await dialogService.ShowAsync<DataSourceERI_V1Dialog>(TB("Edit ERI v1 Data Source"), eriDialogParameters, DialogOptions.FULLSCREEN);
var eriDialogResult = await eriDialogReference.Result;
if (eriDialogResult is null || eriDialogResult.Canceled)
return false;
editedDataSource = (DataSourceERI_V1)eriDialogResult.Data!;
break;
}
if (editedDataSource is null)
return false;
// Found anew, since the list may have changed while the dialog was open:
var position = dataSources.IndexOf(storedDataSource);
if (position < 0)
return false;
dataSources[position] = editedDataSource;
await settingsManager.StoreSettings();
await embeddingService.QueueDataSourceAsync(editedDataSource);
return true;
}
}
@@ -2,6 +2,7 @@ using System.Text;
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
@@ -19,6 +20,10 @@ namespace AIStudio.Tools;
/// Nothing is asked when nothing is lost. A data source only reaches the question when the edit
/// really changes its embedding signature and when the index already holds something for it, so
/// renaming an embedding provider or editing a data source nobody has indexed yet stays silent.
/// A new location of a local file or folder has a question of its own, under the same condition of
/// a stored index: the signature stays, but every file which is not found unchanged at the new
/// location is prepared again. When the signature changes as well, only its question is asked,
/// since everything is prepared again anyway.
/// </remarks>
public static class DataSourceReindexWarning
{
@@ -84,11 +89,60 @@ public static class DataSourceReindexWarning
DataSourceEmbeddingProviders.TryResolve(settingsManager, before, out var resolvedBeforeProvider);
var beforeProvider = resolvedBeforeProvider ?? EmbeddingProvider.NONE;
if (!EmbeddingChangeImpact.AffectsStoredIndex(before, beforeProvider, after, afterProvider))
// Everything is prepared again, wherever the files are, so this question covers a new location as well:
if (EmbeddingChangeImpact.AffectsStoredIndex(before, beforeProvider, after, afterProvider))
{
var affected = await embeddingService.GetDataSourcesWithStoredIndexAsync([after], token);
return await ConfirmAsync(dialogService, affected, !afterProvider.IsSelfHosted);
}
if (!HasLocationChanged(before, after))
return true;
var affected = await embeddingService.GetDataSourcesWithStoredIndexAsync([after], token);
return await ConfirmAsync(dialogService, affected, !afterProvider.IsSelfHosted);
var indexed = await embeddingService.GetDataSourcesWithStoredIndexAsync([after], token);
return indexed.Count == 0 || await ConfirmNewLocationAsync(dialogService, !afterProvider.IsSelfHosted);
}
private static bool HasLocationChanged(IIndexedDataSource before, IIndexedDataSource after) => (before, after) switch
{
(DataSourceLocalDirectory oldFolder, DataSourceLocalDirectory newFolder) => !string.Equals(oldFolder.Path, newFolder.Path, StringComparison.Ordinal),
(DataSourceLocalFile oldFile, DataSourceLocalFile newFile) => !string.Equals(oldFile.FilePath, newFile.FilePath, StringComparison.Ordinal),
_ => false,
};
/// <summary>
/// Asks before a local file or folder with a stored index is saved with a new location.
/// </summary>
/// <remarks>
/// The conditions for keeping a file are the ones FileSourceIndexer.FindRelocatedFile checks.
/// </remarks>
/// <param name="dialogService">The dialog service to ask with.</param>
/// <param name="usesCloudEmbedding">Whether preparing a file again costs money.</param>
/// <returns>True when the edit may be saved.</returns>
private static async Task<bool> ConfirmNewLocationAsync(IDialogService dialogService, bool usesCloudEmbedding)
{
var body = new StringBuilder();
body.AppendLine(TB("AI Studio keeps what it has already prepared for every file it finds unchanged at the new location, that is, of the same type and with the same size and modification time. In a folder, such a file also has to keep its name and the subfolder it is in."));
body.AppendLine();
body.AppendLine(TB("Every other file goes to your embedding provider once more, which takes a while with many files. Files missing at the new location are removed from the index, while your files on disk stay as they are."));
if (usesCloudEmbedding)
{
body.AppendLine();
body.AppendLine(TB("Your embedding provider runs in the cloud, so preparing files again costs money."));
}
body.AppendLine();
body.AppendLine(TB("Do you want to apply this change anyway?"));
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.MarkdownBody, body.ToString() },
};
var dialogReference = await dialogService.ShowAsync<ConfirmDialog>(TB("New Location for This Data Source"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
return dialogResult is not null && !dialogResult.Canceled;
}
/// <summary>
@@ -192,4 +246,4 @@ public static class DataSourceReindexWarning
var dialogResult = await dialogReference.Result;
return dialogResult is not null && !dialogResult.Canceled;
}
}
}
@@ -29,6 +29,11 @@ public abstract partial class IndexStoreClient(string name, string path) : Datab
public abstract Task UpsertFileAsync(string dataSourceId, EmbeddingStateFile file, CancellationToken token);
/// <summary>
/// Updates the location and fingerprint of a document, preserving its chunks and their ids.
/// </summary>
public abstract Task RelocateFileAsync(string dataSourceId, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token);
public abstract Task DeleteFileAsync(string dataSourceId, string filePath, CancellationToken token);
public abstract Task UpsertPermanentFailureAsync(string dataSourceId, PermanentIndexingFailure failure, CancellationToken token);
@@ -52,6 +52,9 @@ public sealed partial class NoIndexStoreClient(string name, string? unavailableR
public override Task UpsertFileAsync(string dataSourceId, EmbeddingStateFile file, CancellationToken token) => Task.CompletedTask;
public override Task RelocateFileAsync(string dataSourceId, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token) =>
Task.FromException(new InvalidOperationException(unavailableReason ?? "The index of the data sources is not available."));
public override Task DeleteFileAsync(string dataSourceId, string filePath, CancellationToken token) => Task.CompletedTask;
public override Task UpsertPermanentFailureAsync(string dataSourceId, PermanentIndexingFailure failure, CancellationToken token) => Task.CompletedTask;
@@ -149,7 +149,9 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
file.LastWriteUtc,
file.EmbeddedAtUtc,
file.ChunkCount,
file.TranscriptKey);
file.TranscriptKey,
file.RelativePath,
file.ParentFileId);
}
var permanentFailures = await context.PermanentIndexingFailures
@@ -233,6 +235,18 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
await context.SaveChangesAsync(token);
}
public override async Task RelocateFileAsync(string dataSourceId, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token)
{
await using var context = this.CreateContext();
var file = await context.EmbeddedFiles.SingleAsync(file => file.DataSourceId == dataSourceId && file.ParentFileId == parentFileId, token);
// Keep the parent and chunk ids: moving a document does not change its stored vectors.
file.AbsolutePath = absolutePath;
file.RelativePath = relativePath;
file.FileName = Path.GetFileName(absolutePath);
file.Fingerprint = fingerprint;
await context.SaveChangesAsync(token);
}
public override async Task DeleteFileAsync(string dataSourceId, string filePath, CancellationToken token)
{
await using var context = this.CreateContext();
@@ -762,4 +776,4 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
client.SetLogger(databaseClientLogger);
return client;
}
}
}
@@ -37,7 +37,10 @@ public sealed class NoVectorStoreClient(string name, string? unavailableReason,
public override Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, VectorSearchFilter filter, CancellationToken token) =>
Task.FromException<IReadOnlyList<VectorSearchResult>>(this.CreateUnavailableException());
public override Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token) =>
public override Task DeleteEmbeddingByFile(string storeName, string filePath, string parentFileId, CancellationToken token) =>
Task.FromException(this.CreateUnavailableException());
public override Task RelocateEmbeddingFileAsync(string storeName, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token) =>
Task.FromException(this.CreateUnavailableException());
public override Task OptimizeVectorStore(string storeName, CancellationToken token) =>
@@ -27,6 +27,7 @@ public sealed class QdrantEdgeClientImplementation(
private const string INSERT_PATH = "/system/qdrant-edge/insert";
private const string SEARCH_PATH = "/system/qdrant-edge/search";
private const string DELETE_FILE_PATH = "/system/qdrant-edge/delete-file";
private const string RELOCATE_FILE_PATH = "/system/qdrant-edge/relocate-file";
private const string OPTIMIZE_PATH = "/system/qdrant-edge/optimize";
private const string DELETE_STORE_PATH = "/system/qdrant-edge/delete-store";
private const string PREPARE_BACKUP_PATH = "/system/qdrant-edge/backup/prepare";
@@ -156,8 +157,11 @@ public sealed class QdrantEdgeClientImplementation(
token) ?? [];
}
public override Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, DELETE_FILE_PATH, new DeleteEmbeddingByFileRequest(storeName, filePath), token);
public override Task DeleteEmbeddingByFile(string storeName, string filePath, string parentFileId, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, DELETE_FILE_PATH, new DeleteEmbeddingByFileRequest(storeName, filePath, parentFileId), token);
public override Task RelocateEmbeddingFileAsync(string storeName, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, RELOCATE_FILE_PATH, new RelocateEmbeddingFileRequest(storeName, parentFileId, absolutePath, Path.GetFileName(absolutePath), relativePath, fingerprint), token);
public override Task OptimizeVectorStore(string storeName, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, OPTIMIZE_PATH, new OptimizeVectorStoreRequest(storeName), token);
@@ -194,7 +198,9 @@ public sealed class QdrantEdgeClientImplementation(
/// <param name="PointIds">The only points the search may return, or null for the whole store.</param>
private sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList<float> Vector, int MaxMatches, IReadOnlyList<string>? PointIds);
private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath);
private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath, string ParentFileId);
private sealed record RelocateEmbeddingFileRequest(string StoreName, string ParentFileId, string AbsolutePath, string FileName, string RelativePath, string Fingerprint);
private sealed record OptimizeVectorStoreRequest(string StoreName);
@@ -27,7 +27,12 @@ public abstract class VectorStoreClient(string name, string path): DatabaseClien
/// <returns>The matches, best first, all of them inside the filter.</returns>
public abstract Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, VectorSearchFilter filter, CancellationToken token);
public abstract Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token);
public abstract Task DeleteEmbeddingByFile(string storeName, string filePath, string parentFileId, CancellationToken token);
/// <summary>
/// Updates the location of a document's points without changing their ids or vectors.
/// </summary>
public abstract Task RelocateEmbeddingFileAsync(string storeName, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token);
public abstract Task OptimizeVectorStore(string storeName, CancellationToken token);
+3 -26
View File
@@ -112,8 +112,6 @@ internal static class FileExclusions
PATH,
RECURSIVE_WILDCARD,
INVALID_CHARACTER,
FILE_IN_FOLDER_LIST,
FOLDER_IN_FILE_LIST,
}
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileExclusions).Namespace, nameof(FileExclusions));
@@ -128,36 +126,18 @@ internal static class FileExclusions
public static bool IsValid(string? patterns) => GetValidationIssue(patterns) is ValidationIssue.NONE;
public static bool IsValidFolders(string? patterns) => GetValidationIssue(patterns, folders: true) is ValidationIssue.NONE;
public static bool IsValidFiles(string? patterns) => GetValidationIssue(patterns, folders: false) is ValidationIssue.NONE;
/// <summary>
/// Validates a list of excluded files and folders. An empty list is valid: it excludes nothing
/// beyond what is built in.
/// </summary>
public static string? Validate(string? patterns) => DescribeValidationIssue(GetValidationIssue(patterns));
/// <summary>
/// Validates the folder field without guessing whether an entry without a slash was meant as a folder.
/// </summary>
public static string? ValidateFolders(string? patterns) => DescribeValidationIssue(GetValidationIssue(patterns, folders: true));
/// <summary>
/// Validates the file field, directing folder rules to their own field.
/// </summary>
public static string? ValidateFiles(string? patterns) => DescribeValidationIssue(GetValidationIssue(patterns, folders: false));
private static string? DescribeValidationIssue(ValidationIssue issue) => issue switch
public static string? Validate(string? patterns) => GetValidationIssue(patterns) switch
{
ValidationIssue.NONE => null,
ValidationIssue.SELECTOR => TB("Selectors such as :text: cannot be excluded. Please use file name patterns such as *.min.js or folder names such as bin/."),
ValidationIssue.PATH => TB("Please write excluded folders as a name without a path, ending with a slash, e.g., node_modules/ or bin/. Such an entry excludes every folder of that name."),
ValidationIssue.RECURSIVE_WILDCARD => TB("Please use only single asterisks as wildcards, e.g., *.min.js or cmake-build-*/."),
ValidationIssue.INVALID_CHARACTER => TB("One of the excluded files or folders contains an invalid character."),
ValidationIssue.FILE_IN_FOLDER_LIST => TB("Folder patterns must end with /, e.g., build/. Enter file patterns such as *.pdf in 'Files'."),
ValidationIssue.FOLDER_IN_FILE_LIST => TB("Enter folder patterns ending with / in 'Folders'. File patterns must not end with /."),
_ => throw new ArgumentOutOfRangeException(nameof(issue)),
_ => throw new ArgumentOutOfRangeException(nameof(patterns)),
};
/// <summary>
@@ -194,7 +174,7 @@ internal static class FileExclusions
private static bool IsFolderEntry(string entry) => entry.EndsWith(FOLDER_SUFFIX);
private static ValidationIssue GetValidationIssue(string? patterns, bool? folders = null)
private static ValidationIssue GetValidationIssue(string? patterns)
{
foreach (var entry in Parse(patterns))
{
@@ -213,9 +193,6 @@ internal static class FileExclusions
if (name.IndexOfAny(FileNamePatterns.INVALID_PATTERN_CHARACTERS) >= 0)
return ValidationIssue.INVALID_CHARACTER;
if (folders is not null && IsFolderEntry(entry) != folders.Value)
return folders.Value ? ValidationIssue.FILE_IN_FOLDER_LIST : ValidationIssue.FOLDER_IN_FILE_LIST;
}
return ValidationIssue.NONE;
@@ -34,6 +34,7 @@ internal static class FileNamePatterns
{
NONE,
MISSING,
EMPTY_ENTRY,
UNKNOWN_SELECTOR,
RECURSIVE_WILDCARD,
PATH,
@@ -49,9 +50,6 @@ internal static class FileNamePatterns
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileNamePatterns).Namespace, nameof(FileNamePatterns));
/// <summary>
/// Splits the list, ignoring empty entries, including a trailing semicolon.
/// </summary>
public static string[] Parse(string? patterns) => (patterns ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -126,6 +124,7 @@ internal static class FileNamePatterns
{
ValidationIssue.NONE => null,
ValidationIssue.MISSING => TB("Please provide at least one file pattern, e.g., :documents: or *.txt. Separate multiple patterns with a semicolon."),
ValidationIssue.EMPTY_ENTRY => TB("Please remove empty file patterns. Separate valid patterns with a single semicolon."),
ValidationIssue.UNKNOWN_SELECTOR => TB("One of the file patterns is an unknown selector. Please use :documents: or :text:, or a file name pattern such as *.txt."),
ValidationIssue.RECURSIVE_WILDCARD => TB("Please use only single asterisks as wildcards, e.g., *.txt or report-*.md."),
ValidationIssue.PATH => TB("Please use file name patterns without folder paths, e.g., *.txt or report-*.md."),
@@ -135,12 +134,16 @@ internal static class FileNamePatterns
private static ValidationIssue GetValidationIssue(string? patterns)
{
var individualPatterns = Parse(patterns);
if (individualPatterns.Length == 0)
if (string.IsNullOrWhiteSpace(patterns))
return ValidationIssue.MISSING;
foreach (var pattern in individualPatterns)
var individualPatterns = patterns.Split(';');
if (individualPatterns.Any(string.IsNullOrWhiteSpace))
return ValidationIssue.EMPTY_ENTRY;
foreach (var patternEntry in individualPatterns)
{
var pattern = patternEntry.Trim();
if (IsSelector(pattern))
continue;
@@ -43,7 +43,7 @@ public static class MailboxEditing
{
{ x => x.IsEditing, true },
{ x => x.DataSource, storedMailbox },
{ x => x.LockSource, await embeddingService.ShouldLockDataSourceOriginAsync(storedMailbox.Id) },
{ x => x.LockSource, await embeddingService.ShouldLockMailboxOriginAsync(storedMailbox.Id) },
};
var dialogReference = await dialogService.ShowAsync<DataSourceMailboxDialog>(TB("Edit Mailbox"), dialogParameters, DialogOptions.FULLSCREEN);
+27 -1
View File
@@ -1,7 +1,7 @@
namespace AIStudio.Tools;
/// <summary>
/// Shared rules for comparing file system paths.
/// Shared rules for comparing and resolving file system paths.
/// </summary>
public static class PathTools
{
@@ -42,4 +42,30 @@ public static class PathTools
return false;
}
}
/// <summary>
/// Finds the place where a file dialog opens for a path which may no longer exist.
/// </summary>
/// <remarks>
/// Given a file or a folder which was moved or renamed, the native dialog cannot open its place
/// and falls back to wherever it was used last. The nearest folder which still exists is the
/// better start: the file or the folder was moved from there, so its new place is often close by.
/// A relative path, or a path of another operating system, e.g. from a backup, has no such folder.
/// </remarks>
/// <param name="path">The file or the folder the dialog should open at.</param>
/// <returns>The path itself when it exists, else its nearest parent folder which exists, or null when there is none.</returns>
public static string? FindNearestExistingPath(string path)
{
if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path))
return null;
if (File.Exists(path) || Directory.Exists(path))
return path;
for (var directory = Path.GetDirectoryName(path); !string.IsNullOrEmpty(directory); directory = Path.GetDirectoryName(directory))
if (Directory.Exists(directory))
return directory;
return null;
}
}
@@ -1,6 +1,5 @@
using System.Text;
using AIStudio.Chat;
using AIStudio.Tools.Security;
namespace AIStudio.Tools.RAG;
@@ -24,4 +24,9 @@ public enum DataSourceAttention
/// The organization allows only its own mail servers, and the mailbox is on another one. AI Studio does not connect to it until the organization allows the server.
/// </summary>
SERVER_NOT_ALLOWED,
/// <summary>
/// The file or the folder of a local data source cannot be reached, cannot be opened, or appears empty. AI Studio keeps the index until the file or the folder is back, the user selects its new location, or removes the data source.
/// </summary>
LOCATION_UNREACHABLE,
}
@@ -1,7 +1,27 @@
namespace AIStudio.Tools.Services;
/// <summary>
/// What the index store holds for one data source, read at the start of a run.
/// </summary>
public sealed class DataSourceEmbeddingManifest
{
/// <summary>
/// How the index compares the paths of the documents it stores, on every OS.
/// </summary>
/// <remarks>
/// Ignores case, unlike PathTools.COMPARER, which follows the file system. The index store keeps
/// the paths with the NOCASE collation and allows each path of a data source once, so two files
/// which differ in their case alone share one row there. Whatever the indexer keys or compares by
/// these paths has to agree with the store; telling the two files apart would let them replace
/// each other's row on every run.
/// </remarks>
public static readonly StringComparer PATH_COMPARER = StringComparer.OrdinalIgnoreCase;
/// <summary>
/// The comparison matching PATH_COMPARER, e.g., for prefixes.
/// </summary>
public const StringComparison PATH_COMPARISON = StringComparison.OrdinalIgnoreCase;
public string EmbeddingProviderId { get; set; } = string.Empty;
public string EmbeddingSignature { get; set; } = string.Empty;
@@ -10,7 +30,7 @@ public sealed class DataSourceEmbeddingManifest
public int VectorSize { get; set; }
public Dictionary<string, EmbeddedFileRecord> Files { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, EmbeddedFileRecord> Files { get; init; } = new(PATH_COMPARER);
/// <summary>
/// The files whose indexing began but never came to an end, by their absolute path.
@@ -21,7 +41,7 @@ public sealed class DataSourceEmbeddingManifest
/// meanwhile. Indexing such a file again replaces both. A file which is gone, or which the data
/// source no longer selects, is never indexed again, so the indexer has to remove them itself.
/// </remarks>
public HashSet<string> UnfinishedFiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public HashSet<string> UnfinishedFiles { get; init; } = new(PATH_COMPARER);
/// <summary>
/// The files whose indexing failed for a reason which lies in the file itself, keyed by their
@@ -32,5 +52,5 @@ public sealed class DataSourceEmbeddingManifest
/// a folder holding hundreds of scanned documents without a text layer would be read again on
/// every single run, with the outcome known in advance.
/// </remarks>
public Dictionary<string, PermanentIndexingFailureRecord> PermanentFailures { get; init; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, PermanentIndexingFailureRecord> PermanentFailures { get; init; } = new(PATH_COMPARER);
}
@@ -158,14 +158,18 @@ public sealed partial class DataSourceEmbeddingService
/// files which no longer belong to the data source, and the ones which now do have to wait for
/// the next run.
///
/// Only a local folder chooses its documents by file patterns, exclusions, switches, and a size
/// limit. For every other kind of data source, this is empty.
/// Local files and folders include their configured paths, so relocating one stops a run which
/// still reads the old location. Folders also include their file selection. Neither changes the
/// embedding signature: unchanged documents keep their vectors.
/// </remarks>
/// <param name="dataSource">The data source to index.</param>
/// <returns>The description of its selection, or an empty string.</returns>
internal static string BuildSelectionSignature(IIndexedDataSource dataSource) => dataSource is DataSourceLocalDirectory directory
? FolderFileSelection.Of(directory).Describe()
: string.Empty;
internal static string BuildSelectionSignature(IIndexedDataSource dataSource) => dataSource switch
{
DataSourceLocalDirectory directory => $"{directory.Path.Length}:{directory.Path}|{FolderFileSelection.Of(directory).Describe()}",
DataSourceLocalFile file => $"{file.FilePath.Length}:{file.FilePath}",
_ => string.Empty,
};
/// <summary>
/// Describes the indexing run a data source would get with a given embedding provider.
@@ -190,24 +190,20 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
}
/// <summary>
/// Whether the file or folder a data source reads must stay as it is.
/// Whether a mailbox's origin must stay as it is.
/// </summary>
/// <remarks>
/// Locked as soon as the index holds anything, because where a data source reads from is what it
/// is: another folder is another data source, and the path reaches no signature, so swapping it
/// would leave the stored index describing documents nobody points at any more.
///
/// The embedding provider used to be locked along with it and no longer is. It does reach the
/// signature, so changing it rebuilds the index cleanly -- and DataSourceReindexWarning asks
/// before it does. Locking it as well left a data source whose provider was deleted stuck on
/// keyword search for good, with no way back.
/// Locked as soon as the index holds anything: another server, account, or folder holds other
/// mails, which makes it another data source. Local files and folders are not locked, since they
/// may move, and the file indexer finds their documents again, cf. FileSourceIndexer.RelocateStoredFilesAsync.
/// The embedding provider is never locked; DataSourceReindexWarning asks what changing it costs.
///
/// Unclear counts as locked: an unavailable index database says nothing about what is stored.
/// </remarks>
/// <param name="dataSourceId">The data source to ask about.</param>
/// <param name="dataSourceId">The mailbox to ask about.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>True when the source must not be changed.</returns>
public async Task<bool> ShouldLockDataSourceOriginAsync(string dataSourceId, CancellationToken token = default)
public async Task<bool> ShouldLockMailboxOriginAsync(string dataSourceId, CancellationToken token = default)
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
@@ -1504,4 +1500,4 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
{
_ = MessageBus.INSTANCE.SendMessage(null, Event.RAG_EMBEDDING_STATUS_CHANGED, true);
}
}
}
@@ -1,3 +1,4 @@
namespace AIStudio.Tools.Services;
public sealed record EmbeddedFileRecord(string Fingerprint, long FileSize, DateTimeOffset LastWriteUtc, DateTimeOffset EmbeddedAtUtc, int ChunkCount, string? TranscriptKey = null);
public sealed record EmbeddedFileRecord(string Fingerprint, long FileSize, DateTimeOffset LastWriteUtc, DateTimeOffset EmbeddedAtUtc, int ChunkCount, string? TranscriptKey = null,
string? RelativePath = null, string? ParentFileId = null);
@@ -226,7 +226,7 @@ internal sealed class DocumentRunProgress(IndexedRunContext context, int totalDo
var embeddedAtUtc = DateTimeOffset.UtcNow;
var state = document.State with { ChunkCount = chunkCount, EmbeddedAtUtc = embeddedAtUtc };
await context.IndexStore.UpsertFileAsync(context.DataSource.Id, state, token);
context.Manifest.Files[document.Key] = new EmbeddedFileRecord(state.Fingerprint, state.FileSize, state.LastWriteUtc, embeddedAtUtc, chunkCount, state.TranscriptKey);
context.Manifest.Files[document.Key] = new EmbeddedFileRecord(state.Fingerprint, state.FileSize, state.LastWriteUtc, embeddedAtUtc, chunkCount, state.TranscriptKey, state.RelativePath, state.ParentFileId);
await context.ForgetPermanentFailureAsync(document.Key, token);
context.Pass.Forget(document.Key);
@@ -0,0 +1,91 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
namespace AIStudio.Tools.Services.Indexing;
internal sealed partial class FileSourceIndexer
{
/// <summary>
/// Reconnects unchanged files before enumeration and missing-file cleanup. In particular, media
/// must already be known under their new paths when enumeration asks whether they need transcription.
/// </summary>
private async Task<bool> RelocateStoredFilesAsync(IndexedRunContext context, IDataSource dataSource, CancellationToken token)
{
DocumentRunProgress? progress = null;
foreach (var (oldPath, record) in context.Manifest.Files.ToArray())
{
token.ThrowIfCancellationRequested();
var file = FindRelocatedFile(dataSource, oldPath, record);
if (file is null || record.ParentFileId is not { } parentFileId || (!DataSourceEmbeddingManifest.PATH_COMPARER.Equals(oldPath, file.FullName) && context.Manifest.Files.ContainsKey(file.FullName)))
continue;
progress ??= DocumentRunProgress.FromStoredState(context, context.Manifest.Files.Count, logger);
if (progress.HasDoneItsShare(DateTimeOffset.UtcNow))
return false;
progress.Publish();
var relativePath = this.TryGetRelativePath(dataSource, file);
// Use the metadata which passed the reuse check. If the file changes during these
// writes, enumeration must see a different fingerprint and index its new content.
var fingerprint = BuildFileMetadataHash(file.FullName, file.Name, record.LastWriteUtc.UtcDateTime, record.FileSize);
// Vectors first, then SQLite. Selecting by the unchanged parent id makes this safe to
// retry if cancellation or a crash falls between the two writes. A failure aborts the
// run before missing-file cleanup can remove the documents we are reconnecting.
await context.VectorStore.RelocateEmbeddingFileAsync(context.CollectionName, parentFileId, file.FullName, relativePath, fingerprint, token);
token.ThrowIfCancellationRequested();
await context.IndexStore.RelocateFileAsync(dataSource.Id, parentFileId, file.FullName, relativePath, fingerprint, token);
context.Manifest.Files.Remove(oldPath);
context.Manifest.Files[file.FullName] = record with { Fingerprint = fingerprint, RelativePath = relativePath };
logger.LogInformation("Reused stored embeddings for relocated file '{OldPath}' at '{NewPath}' in data source '{DataSourceId}'.", oldPath, file.FullName, dataSource.Id);
}
return true;
}
/// <summary>
/// Uses the same size and modification-time check as ordinary indexing, matching a folder's
/// documents by relative path. It never searches another folder for a missing document.
/// </summary>
/// <remarks>
/// A stored path which still lies below the selected folder stays as it is: it points at its
/// file, and a file which is gone from there is gone. Mapping it through its old relative path
/// would move it, e.g., when the selected folder grows to its parent. A copy at the mapped place
/// would then take over the identity of the document while the original is indexed anew.
/// Checking this first also spares every run of an unmoved folder resolving each stored path.
/// </remarks>
internal static FileInfo? FindRelocatedFile(IDataSource dataSource, string oldPath, EmbeddedFileRecord record)
{
if (record.ParentFileId is null)
return null;
string targetPath;
switch (dataSource)
{
case DataSourceLocalFile localFile when RelocatedFilePaths.IsLocalAbsolute(localFile.FilePath):
targetPath = Path.GetFullPath(localFile.FilePath);
break;
case DataSourceLocalDirectory localDirectory when RelocatedFilePaths.IsBelow(localDirectory.Path, oldPath):
return null;
case DataSourceLocalDirectory localDirectory:
var resolvedPath = RelocatedFilePaths.Resolve(localDirectory.Path, oldPath, record.RelativePath);
if (resolvedPath is null)
return null;
targetPath = resolvedPath;
break;
default:
return null;
}
if (string.Equals(oldPath, targetPath, StringComparison.Ordinal)
|| !string.Equals(Path.GetExtension(RelocatedFilePaths.GetStoredFileName(oldPath)), Path.GetExtension(targetPath), StringComparison.OrdinalIgnoreCase))
return null;
var file = new FileInfo(targetPath);
return file.Exists && file.Length == record.FileSize && file.LastWriteTimeUtc == record.LastWriteUtc.UtcDateTime ? file : null;
}
}
@@ -251,7 +251,7 @@ internal sealed partial class FileSourceIndexer
return true;
if (configuration.TargetFileName is { } targetFileName)
return string.Equals(fileName, targetFileName, StringComparison.OrdinalIgnoreCase);
return string.Equals(fileName, targetFileName, PathTools.COMPARISON);
//
// Git renames .git/index.lock to .git/index with almost every command, and a build or
@@ -297,13 +297,13 @@ internal sealed partial class FileSourceIndexer
private static DataSourceWatcherConfiguration? GetWatchConfiguration(IDataSourceBase dataSource) => dataSource switch
{
DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path) => new DataSourceWatcherConfiguration(
DataSourceLocalDirectory localDirectory when RelocatedFilePaths.IsLocalAbsolute(localDirectory.Path) && Directory.Exists(localDirectory.Path) => new DataSourceWatcherConfiguration(
localDirectory.Path,
localDirectory.IncludeSubdirectories,
localDirectory.IncludeHiddenFilesAndFolders,
FilePatterns: FileNamePatterns.Parse(localDirectory.EffectiveFilePatterns),
ExcludePatterns: FileExclusions.Parse(localDirectory.EffectiveExcludePatterns)),
DataSourceLocalFile localFile when File.Exists(localFile.FilePath) && !string.IsNullOrWhiteSpace(Path.GetDirectoryName(localFile.FilePath)) => new DataSourceWatcherConfiguration(
DataSourceLocalFile localFile when RelocatedFilePaths.IsLocalAbsolute(localFile.FilePath) && File.Exists(localFile.FilePath) && !string.IsNullOrWhiteSpace(Path.GetDirectoryName(localFile.FilePath)) => new DataSourceWatcherConfiguration(
Path.GetDirectoryName(localFile.FilePath)!,
false,
TargetFileName: Path.GetFileName(localFile.FilePath)),
@@ -314,8 +314,8 @@ internal sealed partial class FileSourceIndexer
{
return left.IncludeSubdirectories == right.IncludeSubdirectories
&& left.IncludeHiddenFilesAndFolders == right.IncludeHiddenFilesAndFolders
&& string.Equals(left.RootPath, right.RootPath, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.TargetFileName, right.TargetFileName, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.RootPath, right.RootPath, PathTools.COMPARISON)
&& string.Equals(left.TargetFileName, right.TargetFileName, PathTools.COMPARISON)
&& FileNamePatterns.AreEquivalent(left.FilePatterns, right.FilePatterns)
&& FileNamePatterns.AreEquivalent(left.ExcludePatterns, right.ExcludePatterns);
}
@@ -77,12 +77,25 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
var manifest = context.Manifest;
if (!await this.RelocateStoredFilesAsync(context, dataSource, token))
return await DocumentRunProgress.FromStoredState(context, manifest.Files.Count, logger).PauseRunAsync(null, "file relocation paused", token);
var enumerationStartedUtc = DateTimeOffset.UtcNow;
var inputFiles = this.GetInputFiles(dataSource, manifest);
var enumerationDuration = DateTimeOffset.UtcNow - enumerationStartedUtc;
if (inputFiles.UnreachableRootReason is { } unreachableRootReason)
return this.StopWithoutRoot(context, dataSource, unreachableRootReason);
// An unmounted volume can leave a readable, empty mount directory. There is no way to
// distinguish that from intentionally removing every file, so keep the stored index.
// Explicit exclusions still take effect: they are evidence of a nonempty folder.
if (dataSource is DataSourceLocalDirectory directory
&& (manifest.Files.Count > 0 || manifest.UnfinishedFiles.Count > 0 || manifest.PermanentFailures.Count > 0)
&& inputFiles.Files.Count == 0 && inputFiles.IgnoredFiles.Count == 0
&& inputFiles.FilePatternExclusionCount == 0 && inputFiles.ExcludedFolderCount == 0
&& inputFiles.FailedFiles == 0 && inputFiles.UnreadableDirectories.Count == 0)
return this.StopWithoutRoot(context, dataSource, string.Format(TB("The folder '{0}' currently appears empty. AI Studio kept the index of this data source, because the folder of a drive that is not connected can look empty. Connect the drive and refresh this data source. If you emptied the folder on purpose, remove this data source."), directory.Path));
var indexedFiles = inputFiles.Files;
var totalFiles = indexedFiles.Count + inputFiles.FailedFiles + inputFiles.IgnoredFiles.Count;
@@ -364,7 +377,8 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
/// connected, or a network share while the VPN is down, would remove every embedding of the
/// data source, and embedding it all again once the folder is back costs as long as it took the
/// first time. Nothing is written: the stored hash still tells which state of the data source
/// the index holds, so the next run picks up from there.
/// the index holds, so the next run picks up from there. The status asks for attention, so the
/// embeddings page offers to edit the data source, e.g. to select the new location of a moved folder.
/// </remarks>
/// <param name="context">The run.</param>
/// <param name="dataSource">The data source.</param>
@@ -378,7 +392,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
dataSource.Id,
context.Manifest.Files.Count);
DocumentRunProgress.FromStoredState(context, context.Manifest.Files.Count, logger).PublishRunFailure(reason);
DocumentRunProgress.FromStoredState(context, context.Manifest.Files.Count, logger).PublishRunFailure(reason, DataSourceAttention.LOCATION_UNREACHABLE);
return IndexedRunOutcome.DONE;
}
@@ -509,9 +523,10 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
/// user may not read that folder. Removing it would throw away its index, and embedding it again
/// once the folder can be read costs as long as it took the first time.
/// <br /><br />
/// Paths are compared ignoring their case, as everywhere in the manifest. On Linux and macOS, that
/// keeps a file whose path differs from an unreadable folder in its case alone, which costs no more
/// than a stale entry until the folder can be read again.
/// Paths are compared ignoring their case, as everywhere in the index, cf. the remarks on
/// DataSourceEmbeddingManifest.PATH_COMPARER. On Linux and macOS, that keeps a file whose path
/// differs from an unreadable folder in its case alone, which costs no more than a stale entry
/// until the folder can be read again.
/// </remarks>
/// <param name="storedPaths">The paths of the files the index knows about.</param>
/// <param name="files">The files this run found.</param>
@@ -521,15 +536,16 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
{
var existingPaths = files
.Select(file => file.FullName)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
.ToHashSet(DataSourceEmbeddingManifest.PATH_COMPARER);
var unreadablePrefixes = unreadableDirectories
.Select(directory => Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)) + Path.DirectorySeparatorChar)
.Select(Path.GetFullPath)
.Select(directory => Path.EndsInDirectorySeparator(directory) ? directory : directory + Path.DirectorySeparatorChar)
.ToList();
return storedPaths
.Where(path => !existingPaths.Contains(path))
.Where(path => !unreadablePrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
.Where(path => !unreadablePrefixes.Any(prefix => path.StartsWith(prefix, DataSourceEmbeddingManifest.PATH_COMPARISON)))
.ToList();
}
@@ -709,7 +725,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
switch (dataSource)
{
case DataSourceLocalFile localFile when File.Exists(localFile.FilePath):
case DataSourceLocalFile localFile when RelocatedFilePaths.IsLocalAbsolute(localFile.FilePath) && File.Exists(localFile.FilePath):
var file = new FileInfo(localFile.FilePath);
// No size limit: the user chose this very file, cf. FolderFileSelection.IsTooLarge.
@@ -718,9 +734,10 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return result;
case DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path):
case DataSourceLocalDirectory localDirectory when RelocatedFilePaths.IsLocalAbsolute(localDirectory.Path) && Directory.Exists(localDirectory.Path):
this.EnumerateAccessibleFiles(localDirectory, manifest, mediaTranscriptionAvailability, result);
if (result.UnreadableDirectories.Contains(localDirectory.Path))
if (result.UnreadableDirectories.Any(directory => string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(localDirectory.Path)), PathTools.COMPARISON)))
result.UnreachableRootReason = string.Format(TB("The folder '{0}' could not be opened. Please check whether you are allowed to read it. Nothing was removed from the index."), localDirectory.Path);
return result;
@@ -732,11 +749,11 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
switch (dataSource)
{
case DataSourceLocalFile localFile:
result.UnreachableRootReason = string.Format(TB("The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. Once the file is back, refresh this data source. When it is gone for good, remove this data source."), localFile.FilePath);
result.UnreachableRootReason = string.Format(TB("The file '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the file was moved or renamed. Nothing was removed from the index. If the file was moved or renamed, edit this data source to select its new location. Once the file is back, refresh this data source. When it is gone for good, remove this data source."), localFile.FilePath);
break;
case DataSourceLocalDirectory localDirectory:
result.UnreachableRootReason = string.Format(TB("The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. Once the folder is back, refresh this data source."), localDirectory.Path);
result.UnreachableRootReason = string.Format(TB("The folder '{0}' cannot be reached. Perhaps the drive it is on is not connected, or the folder was moved or renamed. Nothing was removed from the index. If the folder was moved or renamed, edit this data source to select its new location. Once the folder is back, refresh this data source."), localDirectory.Path);
break;
}
@@ -935,8 +952,8 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
private DataSourceMetadataSnapshot BuildDataSourceMetadataSnapshot(IDataSource dataSource, IReadOnlyList<FileInfo> indexedFiles)
{
var fileHashes = indexedFiles
.OrderBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(file => file.FullName, BuildFileMetadataHash, StringComparer.OrdinalIgnoreCase);
.OrderBy(file => file.FullName, DataSourceEmbeddingManifest.PATH_COMPARER)
.ToDictionary(file => file.FullName, BuildFileMetadataHash, DataSourceEmbeddingManifest.PATH_COMPARER);
var sourceHash = dataSource switch
{
@@ -980,7 +997,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
indexedFiles.Count.ToString()
};
foreach (var file in indexedFiles.OrderBy(file => file.FullName, StringComparer.OrdinalIgnoreCase))
foreach (var file in indexedFiles.OrderBy(file => file.FullName, DataSourceEmbeddingManifest.PATH_COMPARER))
{
parts.Add(this.TryGetRelativePath(dataSource, file));
parts.Add(fileHashes[file.FullName]);
@@ -1002,14 +1019,24 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
"0");
}
return BuildMetadataHash(
"file",
file.FullName,
file.Name,
file.LastWriteTimeUtc.Ticks.ToString(),
file.Length.ToString());
return BuildFileMetadataHash(file.FullName, file.Name, file.LastWriteTimeUtc, file.Length);
}
/// <summary>
/// The fingerprint of a file which exists, from its write time and size as they were read.
/// </summary>
/// <remarks>
/// The one formula for both an indexed file and a relocated one: if the two drifted apart, every
/// relocated file would look changed to the next run and be embedded again.
/// </remarks>
/// <param name="fullName">The absolute path of the file.</param>
/// <param name="name">The name of the file.</param>
/// <param name="lastWriteUtc">When the file was last written to.</param>
/// <param name="length">The size of the file in bytes.</param>
/// <returns>The fingerprint.</returns>
private static string BuildFileMetadataHash(string fullName, string name, DateTime lastWriteUtc, long length) =>
BuildMetadataHash("file", fullName, name, lastWriteUtc.Ticks.ToString(), length.ToString());
private static string BuildMetadataHash(params string[] parts)
{
return BuildMetadataHash((IEnumerable<string>)parts);
@@ -1036,7 +1063,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
/// <returns>The document.</returns>
private EmbeddingDocument CreateFileDocument(IndexedRunContext context, IDataSource dataSource, FileInfo file, string fingerprint, MediaTranscriptSlot? transcript) => new(
file.FullName,
this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow) with { TranscriptKey = transcript?.Key },
this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow) with { ParentFileId = context.GetFileParentId(file.FullName), TranscriptKey = transcript?.Key },
file.Name,
token => this.StreamEmbeddingChunksAsync(file.FullName, transcript, dataSource, context.EmbeddingProvider, context.PromptInjectionScope, token));
@@ -7,9 +7,11 @@ namespace AIStudio.Tools.Services.Indexing;
/// The ids under which documents and their chunks are stored.
/// </summary>
/// <remarks>
/// Derived rather than random, so the same document of the same data source always ends up under
/// the same ids, in the vector store as well as in the index store. The formats must stay as they
/// are: every stored id was made with them.
/// Derived rather than random, so the same document of the same data source ends up under the same
/// ids, in the vector store as well as in the index store. Once stored, they are the identity of the
/// document: a file which moves to another folder keeps its ids, although they were derived from its
/// old path and its old fingerprint, cf. FileSourceIndexer.RelocateStoredFilesAsync. The formats must
/// stay as they are: every stored id was made with them.
/// </remarks>
internal static class IndexedDocumentIds
{
@@ -17,11 +19,31 @@ internal static class IndexedDocumentIds
/// The id of one chunk, which is also the id of its point in the vector store.
/// </summary>
/// <param name="dataSourceId">The data source the document belongs to.</param>
/// <param name="fingerprint">The fingerprint of the document as it was read.</param>
/// <param name="source">What the chunk ids of the document derive from, cf. CreateChunkIdSource.</param>
/// <param name="chunkIndex">The position of the chunk within the document.</param>
/// <returns>The chunk id.</returns>
public static string CreateChunkId(string dataSourceId, string fingerprint, int chunkIndex) =>
CreateStableGuid($"{dataSourceId}:chunk:{fingerprint}:{chunkIndex}");
public static string CreateChunkId(string dataSourceId, string source, int chunkIndex) =>
CreateStableGuid($"{dataSourceId}:chunk:{source}:{chunkIndex}");
/// <summary>
/// What the chunk ids of a document derive from: nearly always its fingerprint.
/// </summary>
/// <remarks>
/// A moved file keeps its parent id and its chunk ids, but its fingerprint now names the new path.
/// Another file may later appear at the old path with exactly the fingerprint those chunk ids came
/// from, and the fingerprint alone would hand it the same ids. The two documents would then take
/// over each other's chunks. Hence the parent id joins whenever a document is not stored under the
/// id its key gives, which only happens after a move. Every other document keeps the ids it has.
/// </remarks>
/// <param name="dataSourceId">The data source the document belongs to.</param>
/// <param name="documentKey">The key of the document.</param>
/// <param name="parentId">The id the document is stored under.</param>
/// <param name="fingerprint">The fingerprint of the document as it is about to be read.</param>
/// <returns>What to pass to CreateChunkId.</returns>
public static string CreateChunkIdSource(string dataSourceId, string documentKey, string parentId, string fingerprint) =>
string.Equals(parentId, CreateParentId(dataSourceId, documentKey), StringComparison.Ordinal)
? fingerprint
: $"{parentId}:{fingerprint}";
/// <summary>
/// The id of a document's row in the index store, which its chunks point at.
@@ -41,6 +41,9 @@ internal sealed class IndexedRunContext(IIndexedDataSource dataSource, Embedding
private long storedChunksSinceLastOptimization;
private bool hasPendingChanges;
private Dictionary<string, string>? fileParentIds;
private HashSet<string>? reservedFileParentIds;
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(IndexedRunContext).Namespace, nameof(IndexedRunContext));
/// <summary>
@@ -66,6 +69,38 @@ internal sealed class IndexedRunContext(IIndexedDataSource dataSource, Embedding
public IndexingPass Pass => pass;
/// <summary>
/// The id a file is stored under: the one it already has, or a new one.
/// </summary>
/// <remarks>
/// A relocated file keeps the id it got when it was indexed first, which came from its path back
/// then. A new file may now appear at that very path, so the ids already stored are reserved, and
/// a new file whose derived id is taken gets a random one instead. Its chunk ids include that id,
/// cf. IndexedDocumentIds.CreateChunkIdSource.
/// </remarks>
/// <param name="documentKey">The key of the file, which is its absolute path.</param>
/// <returns>The parent id of the file.</returns>
public string GetFileParentId(string documentKey)
{
if (this.fileParentIds is null)
{
this.fileParentIds = manifest.Files
.Where(file => file.Value.ParentFileId is not null)
.ToDictionary(file => file.Key, file => file.Value.ParentFileId!, DataSourceEmbeddingManifest.PATH_COMPARER);
this.reservedFileParentIds = this.fileParentIds.Values.ToHashSet(StringComparer.Ordinal);
}
if (this.fileParentIds.TryGetValue(documentKey, out var storedId))
return storedId;
var parentId = IndexedDocumentIds.CreateParentId(dataSource.Id, documentKey);
while (!this.reservedFileParentIds!.Add(parentId))
parentId = Guid.NewGuid().ToString();
this.fileParentIds[documentKey] = parentId;
return parentId;
}
/// <summary>
/// The run as one action of the prompt injection filter.
/// </summary>
@@ -124,10 +159,11 @@ internal sealed class IndexedRunContext(IIndexedDataSource dataSource, Embedding
var embeddingBatchSize = Math.Max(1, embeddingProvider.EffectiveEmbeddingBatchSize);
var batch = new List<EmbeddingChunkDraft>(embeddingBatchSize);
var totalChunkCount = 0;
var chunkIdSource = IndexedDocumentIds.CreateChunkIdSource(dataSource.Id, document.Key, document.State.ParentFileId, document.State.Fingerprint);
await foreach (var chunk in document.StreamChunks(token))
{
batch.Add(new(IndexedDocumentIds.CreateChunkId(dataSource.Id, document.State.Fingerprint, totalChunkCount), chunk.Text, totalChunkCount, chunk.PageNumber));
batch.Add(new(IndexedDocumentIds.CreateChunkId(dataSource.Id, chunkIdSource, totalChunkCount), chunk.Text, totalChunkCount, chunk.PageNumber));
totalChunkCount++;
reportBlockProgress(totalChunkCount, chunk.PageNumber);
@@ -163,7 +199,10 @@ internal sealed class IndexedRunContext(IIndexedDataSource dataSource, Embedding
/// <param name="token">The cancellation token.</param>
public async Task DeleteDocumentPointsAsync(string documentKey, CancellationToken token)
{
await vectorStore.DeleteEmbeddingByFile(this.CollectionName, documentKey, token);
// A relocated document keeps its original parent id. Delete by that identity as well as
// by path, including when a previous relocation stopped between its two database writes.
var parentId = manifest.Files.GetValueOrDefault(documentKey)?.ParentFileId ?? string.Empty;
await vectorStore.DeleteEmbeddingByFile(this.CollectionName, documentKey, parentId, token);
this.hasPendingChanges = true;
}
@@ -401,4 +440,4 @@ internal sealed class IndexedRunContext(IIndexedDataSource dataSource, Embedding
batch.Clear();
}
}
}
@@ -44,7 +44,7 @@ internal sealed class IndexingPass(DataSourceRunSignature runSignature)
/// <summary>
/// The documents which failed during this pass for a reason which may pass, by their keys.
/// </summary>
private readonly Dictionary<string, FailedDocument> failedDocuments = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FailedDocument> failedDocuments = new(DataSourceEmbeddingManifest.PATH_COMPARER);
//
// Which kinds of provider failure the user was already told about in this pass. A rejected API
@@ -0,0 +1,85 @@
namespace AIStudio.Tools.Services.Indexing;
/// <summary>
/// Reads paths stored by the computer which created an index. Its path syntax may differ from
/// that of the computer restoring the backup; never resolve an old absolute path on this machine.
/// </summary>
internal static class RelocatedFilePaths
{
internal static bool IsWindowsAbsolute(string path) => path.StartsWith(@"\\", StringComparison.Ordinal)
|| (path.Length >= 3 && char.IsAsciiLetter(path[0]) && path[1] == ':' && path[2] is '\\' or '/');
internal static bool IsLocalAbsolute(string path) => !string.IsNullOrWhiteSpace(path) && Path.IsPathFullyQualified(path)
&& (OperatingSystem.IsWindows() || !IsWindowsAbsolute(path));
internal static string GetStoredFileName(string path)
{
var separator = IsWindowsAbsolute(path) ? Math.Max(path.LastIndexOf('/'), path.LastIndexOf('\\')) : path.LastIndexOf('/');
return path[(separator + 1)..];
}
/// <summary>
/// Whether a stored path still lies below a root, spelled exactly as the root is now.
/// </summary>
/// <remarks>
/// Compared ordinally, unlike PathTools.IsInsideDirectory: once the name of a folder changed in its
/// case alone, its stored paths no longer match the paths a run finds, on Windows as well.
/// </remarks>
internal static bool IsBelow(string root, string storedPath)
{
if (!IsLocalAbsolute(root))
return false;
try
{
var prefix = Path.GetFullPath(root);
if (!Path.EndsInDirectorySeparator(prefix))
prefix += Path.DirectorySeparatorChar;
return storedPath.StartsWith(prefix, StringComparison.Ordinal);
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
{
return false;
}
}
/// <summary>
/// Maps a stored relative path below a newly selected root, including drive roots and mounted
/// volumes. Backslashes are separators only for a Windows index: they are legal Unix file names.
/// </summary>
internal static string? Resolve(string root, string oldAbsolutePath, string? relativePath)
{
if (!IsLocalAbsolute(root) || string.IsNullOrWhiteSpace(relativePath))
return null;
var windowsSource = IsWindowsAbsolute(oldAbsolutePath);
if (!windowsSource && !oldAbsolutePath.StartsWith('/'))
return null;
var parts = relativePath.Split(windowsSource ? ['/', '\\'] : ['/']);
foreach (var part in parts)
{
// Check after decoding the source separators, so a Windows '..\\' cannot evade the
// containment check on Unix. Reject target-invalid names rather than changing them.
if (part is "" or "." or ".." || part.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
return null;
if (windowsSource && part.Contains(':'))
return null;
if (OperatingSystem.IsWindows() && (part.EndsWith('.') || part.EndsWith(' ')))
return null;
}
try
{
var target = Path.GetFullPath(Path.Combine(root, Path.Combine(parts)));
return PathTools.IsInsideDirectory(root, target) ? target : null;
}
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
{
return null;
}
}
}
@@ -13,9 +13,10 @@ public sealed partial class RustService
async () =>
{
var encodedTitle = Uri.EscapeDataString(title);
var result = initialDirectory is null
var startDirectory = initialDirectory is null ? null : PathTools.FindNearestExistingPath(initialDirectory);
var result = startDirectory is null
? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null)
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions);
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(startDirectory), this.jsonRustSerializerOptions);
if (result.IsSuccessStatusCode)
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
@@ -54,10 +55,11 @@ public sealed partial class RustService
"select file",
async () =>
{
var startPath = initialFile is null ? null : PathTools.FindNearestExistingPath(initialFile);
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
PreviousFile = startPath is null ? null : new (startPath),
Filter = FileTypes.ToFileDialogFilter(filter)
};
@@ -77,10 +79,11 @@ public sealed partial class RustService
"select files",
async () =>
{
var startPath = initialFile is null ? null : PathTools.FindNearestExistingPath(initialFile);
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = initialFile is null ? null : new (initialFile),
PreviousFile = startPath is null ? null : new (startPath),
Filter = FileTypes.ToFileDialogFilter(filter)
};
-7
View File
@@ -532,13 +532,6 @@ tr:has(> .provider-group-header) .mud-icon-button {
padding-bottom: 0.5rem;
}
/* Pattern lists wrap and grow to their full content without an internal scrollbar. */
.pattern-text-field textarea.mud-input-slot {
white-space: pre-wrap;
overflow-wrap: anywhere;
overflow: hidden !important;
}
/*
* Dialogs whose footer carries buttons on the left as well, such as Add and Import next to Close.
* MudBlazor pads the content with 24px to the left and right, but the footer with only 8px, so the
@@ -40,6 +40,7 @@
- Improved the background embeddings page, which now lists the files of a local data source that AI Studio left out, for example, images or files that are too large. It also shows which file types and folders lie outside what the data source selects.
- Improved the Batch Processing assistant, which now leaves out hidden files and folders as well as build output and downloaded dependencies, such as the folder `node_modules`. You can change both in the expert settings of the assistant.
- Improved local data sources for folders and the Batch Processing assistant, which now leave out files larger than 1 GB, such as images of virtual machines. You can change this limit.
- Improved local data sources for files and folders. When you move or rename the file or folder, for example, to another drive or a new computer, you can now select its new location when you edit the data source. AI Studio keeps what it has already prepared for every unchanged file, so these files do not go to your embedding provider again. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Improved the organization-wide management of the Batch Processing assistant. IT departments can now preset whether it includes hidden files and folders, which files and folders it leaves out, and how large a file may be, either fixed or as a default which users may change.
- Improved the organization-wide management of the app settings. IT departments can now also set whether AI Studio saves energy while the AI writes its answers, which helps on virtual desktops, for example.
- Improved attaching files to a chat. You can now attach any file that contains readable text, for example, program code or configuration files of a kind AI Studio did not know before.
@@ -71,7 +72,7 @@
- Fixed AI Studio continuing to update the index of a data source with the old settings after you, or the configuration of your organization, changed its embedding provider or the expert settings of the data source, which could take hours for nothing. AI Studio now stops right away and starts over with the new settings.
- Fixed AI Studio continuing to send the content of a data source to an embedding provider after you deleted that provider, or after a confidence level changed so that the provider may no longer read the data source. AI Studio now stops right away.
- Fixed data sources staying unavailable after the configuration of your organization changed their embedding provider, until one of their documents changed, or you restarted AI Studio. When AI Studio updates your data sources automatically, it now rebuilds their index right away.
- Fixed AI Studio deleting the index of a local data source when it could not reach its folder or file, for example, because the drive was not connected. Once the folder was back, AI Studio indexed everything again, which could take hours. AI Studio now keeps the index and tells you that it cannot reach the folder. The same applies to subfolders you are not allowed to open.
- Fixed AI Studio deleting the index of a local data source when it could not reach its folder or file, for example, because the drive was not connected. Once the folder was back, AI Studio indexed everything again, which could take hours. AI Studio now keeps the index and tells you that it cannot reach the folder. The same applies to subfolders you are not allowed to open and to a folder that suddenly appears empty, which can happen when a drive is not connected.
- Fixed AI Studio rebuilding the index of a data source from scratch without asking you when part of the index was missing on your computer. Since this sends every document to your embedding provider once more, AI Studio now tells you about the missing index and offers to repair the data source.
- Fixed saving a local data source whose embedding provider had been deleted, without choosing a new one. The dialog now asks you to select an embedding provider, so that AI Studio can prepare the data source again.
- Fixed justified texts being hyphenated by the rules of English even when AI Studio shows another language, such as German. Screen readers now also know which language AI Studio uses.
@@ -1,201 +0,0 @@
using AIStudio.Components;
namespace AIStudio.Tests.Components;
public sealed class ConfigurationFileExclusionsTests
{
[Test]
public async Task AParentRefreshDoesNotReplaceAnUncommittedDraft()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
await component.RefreshAsync();
Assert.That(component.Draft, Is.EqualTo("*.new"));
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task AnExternalSettingsChangeReplacesTheDraft()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
component.StoredText = "*.external";
await component.RefreshAsync();
Assert.That(component.Draft, Is.EqualTo("*.external"));
}
finally
{
component.ReleaseResources();
}
}
[TestCase(true, false)]
[TestCase(false, true)]
public async Task DisabledOrLockedFieldsRejectEdits(bool disabled, bool locked)
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.DisableField = disabled;
component.LockField = locked;
component.Edit("*.new");
Assert.That(component.Draft, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[TestCase(true, false)]
[TestCase(false, true)]
public async Task BecomingDisabledOrLockedBeforeAutosaveRejectsThePendingWrite(bool disabled, bool locked)
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
component.DisableField = disabled;
component.LockField = locked;
await component.FlushAsync();
Assert.That(component.StoredText, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task LockingAFieldReplacesItsDraftWithTheStoredSetting()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.Edit("*.new");
component.LockField = true;
await component.RefreshAsync();
Assert.That(component.Draft, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task InvalidRulesAreNotPersisted()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.RejectEdits();
component.Edit(":text:");
await component.FlushAsync();
Assert.That(component.StoredText, Is.EqualTo("*.original"));
}
finally
{
component.ReleaseResources();
}
}
[TestCase(true, false)]
[TestCase(false, true)]
public async Task DisabledOrLockedFieldsRejectExplicitReplacement(bool disabled, bool locked)
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.DisableField = disabled;
component.LockField = locked;
await component.ReplaceAsync("bin/;*.tmp");
Assert.Multiple(() =>
{
Assert.That(component.Draft, Is.EqualTo("*.original"));
Assert.That(component.StoredText, Is.EqualTo("*.original"));
});
}
finally
{
component.ReleaseResources();
}
}
[Test]
public async Task ExplicitReplacementKeepsAnInvalidOtherGroupsDraftWithoutPersistingIt()
{
var component = new TestableConfigurationFileExclusions();
try
{
await component.RefreshAsync();
component.RejectEdits();
component.Edit("old/;:text:");
await component.ReplaceAsync("bin/;:text:");
Assert.Multiple(() =>
{
Assert.That(component.Draft, Is.EqualTo("bin/;:text:"));
Assert.That(component.StoredText, Is.EqualTo("*.original"));
});
}
finally
{
component.ReleaseResources();
}
}
private sealed class TestableConfigurationFileExclusions : ConfigurationFileExclusions
{
public string StoredText { get; set; } = "*.original";
public bool DisableField { get; set; }
public bool LockField { get; set; }
public string Draft => this.EditingText;
public TestableConfigurationFileExclusions()
{
this.Text = () => this.StoredText;
this.TextUpdate = text => this.StoredText = text;
this.Disabled = () => this.DisableField;
this.IsLocked = () => this.LockField;
}
public void Edit(string text) => this.InternalUpdate(text);
public void RejectEdits() => this.Validation = _ => "Invalid rule";
public Task RefreshAsync() => this.OnParametersSetAsync();
public Task FlushAsync() => this.OptionChanged(this.EditingText);
public Task ReplaceAsync(string text) => this.ApplyTextAsync(text);
// These state-only tests never register with a message bus or render the component.
public void ReleaseResources() => this.DisposeResources();
}
}
@@ -1,377 +0,0 @@
using AIStudio.Components;
using AIStudio.Tools;
namespace AIStudio.Tests.Components;
public sealed class FileExclusionEditorStateTests
{
[TestCase("")]
[TestCase(FileExclusions.DEFAULT_PATTERNS)]
[TestCase(" node_modules/ ; bin/\n*.min.js;draft-* ")]
public void OpeningTheEditorDoesNotRewriteTheStoredText(string text)
{
var state = new FileExclusionEditorState();
state.Receive(text);
Assert.That(state.Text, Is.EqualTo(text));
}
[TestCase("node_modules/;bin/;*.min.js;draft-*")]
[TestCase("node_modules/\nbin/\n*.min.js\ndraft-*")]
[TestCase("node_modules/\r\nbin/\r\n*.min.js\r\ndraft-*")]
[TestCase("node_modules/; bin/\r\n*.min.js; draft-*")]
public void BothGroupsDisplaySemicolonSeparatedPatternsWithoutRewritingStoredLists(string text)
{
var state = Create(text);
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo("node_modules/; bin/"));
Assert.That(state.FileText, Is.EqualTo("*.min.js; draft-*"));
Assert.That(state.Text, Is.EqualTo(text));
});
}
[Test]
public void EditingFoldersKeepsEveryFilePattern()
{
var state = Create("bin/;*.min.js;draft-*");
state.EditFolders("obj/\nbuild/");
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "obj/", "build/", "*.min.js", "draft-*" }));
Assert.That(state.FileText, Is.EqualTo("*.min.js; draft-*"));
Assert.That(state.Text, Is.EqualTo("obj/; build/; *.min.js; draft-*"));
});
}
[Test]
public void EditingFilesKeepsEveryFolderPattern()
{
var state = Create("bin/;node_modules/;*.min.js");
state.EditFiles("*.wav;*.md");
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "bin/", "node_modules/", "*.wav", "*.md" }));
Assert.That(state.FolderText, Is.EqualTo("bin/; node_modules/"));
Assert.That(state.Text, Is.EqualTo("bin/; node_modules/; *.wav; *.md"));
});
}
[TestCase("")]
[TestCase(" ")]
public void ClearingOneGroupKeepsTheOtherGroup(string emptyText)
{
var state = Create("bin/;*.min.js");
state.EditFolders(emptyText);
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "*.min.js" }));
state = Create("bin/;*.min.js");
state.EditFiles(emptyText);
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "bin/" }));
}
[Test]
public void ExplicitFolderRulesKeepTheirExistingMatchingRules()
{
var state = Create(string.Empty);
state.EditFolders("node_modules/;cmake-build-*/");
var patterns = FileExclusions.Parse(state.Text);
Assert.Multiple(() =>
{
Assert.That(patterns, Is.EqualTo(new[] { "node_modules/", "cmake-build-*/" }));
Assert.That(FileExclusions.IsValid(state.Text), Is.True);
Assert.That(state.IsValid, Is.True);
Assert.That(FileExclusions.ExcludesFolder("node_modules", patterns), Is.True);
Assert.That(FileExclusions.ExcludesFolder("cmake-build-debug", patterns), Is.True);
Assert.That(FileExclusions.ExcludesFile("node_modules", patterns), Is.False);
});
}
[TestCase(":text:")]
[TestCase("src/bin/")]
[TestCase("/")]
[TestCase("**/*.md")]
public void EditingTheOtherGroupNeverDropsAnInvalidStoredRule(string invalidRule)
{
var state = Create(invalidRule);
if (invalidRule.EndsWith('/'))
state.EditFiles("*.tmp");
else
state.EditFolders("bin/");
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Does.Contain(invalidRule));
Assert.That(FileExclusions.IsValid(state.Text), Is.False);
});
}
[TestCase(":text:")]
[TestCase(":documents:")]
public void FolderEditingDoesNotTurnAnInvalidSelectorIntoAValidLiteralFolder(string selector)
{
var state = Create(string.Empty);
state.EditFolders(selector);
Assert.Multiple(() =>
{
Assert.That(state.Text, Is.EqualTo(selector));
Assert.That(FileExclusions.IsValid(state.Text), Is.False);
});
}
[Test]
public void ExistingFolderNamesWhichResembleSelectorsArePreservedExactly()
{
var state = Create(":text:/;*.tmp");
state.EditFiles("*.bak");
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { ":text:/", "*.bak" }));
}
[Test]
public void RestoringOneGroupDeduplicatesRulesWithoutLosingOtherRules()
{
var state = Create("custom/;*.TMP;*.tmp;Caddyfile");
state.RestoreFolders("node_modules/;NODE_MODULES/;*.default");
Assert.That(FileExclusions.Parse(state.Text), Is.EqualTo(new[] { "node_modules/", "*.TMP", "Caddyfile" }));
}
[TestCase(true)]
[TestCase(false)]
public void RestoringOneFieldPreservesTheOtherFieldsDraft(bool folders)
{
var state = Create("custom/;*.custom");
state.EditFolders("latest/; ");
state.EditFiles("*.latest; ");
const string DEFAULTS = "bin/;node_modules/;*.tmp;*.bak";
if (folders)
state.RestoreFolders(DEFAULTS);
else
state.RestoreFiles(DEFAULTS);
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo(folders ? "bin/; node_modules/" : "latest/; "));
Assert.That(state.FileText, Is.EqualTo(folders ? "*.latest; " : "*.tmp; *.bak"));
Assert.That(state.Text, Is.EqualTo(folders ? "bin/; node_modules/; *.latest" : "latest/; *.tmp; *.bak"));
});
}
[TestCase(true)]
[TestCase(false)]
public void EmptyDefaultsClearOnlyTheRestoredField(bool folders)
{
var state = Create("custom/;*.custom");
if (folders)
state.RestoreFolders(string.Empty);
else
state.RestoreFiles(string.Empty);
Assert.That(state.Text, Is.EqualTo(folders ? "*.custom" : "custom/"));
}
[TestCase(true, ":text:")]
[TestCase(false, "src/bin/")]
public void RestoringOneFieldPreservesInvalidRulesInTheOtherField(bool folders, string invalidRule)
{
var state = Create(invalidRule);
if (folders)
state.RestoreFolders(FileExclusions.DEFAULT_PATTERNS);
else
state.RestoreFiles(FileExclusions.DEFAULT_PATTERNS);
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Parse(state.Text), Does.Contain(invalidRule));
Assert.That(FileExclusions.IsValid(state.Text), Is.False);
});
}
[Test]
public void EditingTheFileFieldDoesNotSilentlyMoveFolderRules()
{
var state = Create("bin/;*.min.js");
state.EditFiles("obj/;*.wav;draft-*");
Assert.Multiple(() =>
{
Assert.That(state.IsValid, Is.False);
Assert.That(state.Text, Is.EqualTo("bin/; obj/; *.wav; draft-*"));
Assert.That(state.FolderText, Is.EqualTo("bin/"));
Assert.That(state.FileText, Is.EqualTo("obj/;*.wav;draft-*"));
});
}
[TestCase(true)]
[TestCase(false)]
public void EditingBothVisibleFieldsPreservesBothDraftsAndAllRules(bool foldersFirst)
{
var state = Create("bin/;*.tmp");
string earlierValue;
if (foldersFirst)
{
state.EditFolders("obj/; build/; ");
earlierValue = state.Text;
state.EditFiles("*.wav; draft-*; ");
}
else
{
state.EditFiles("*.wav; draft-*; ");
earlierValue = state.Text;
state.EditFolders("obj/; build/; ");
}
state.Receive(earlierValue);
state.Receive(state.Text);
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo("obj/; build/; "));
Assert.That(state.FileText, Is.EqualTo("*.wav; draft-*; "));
Assert.That(state.Text, Is.EqualTo("obj/; build/; *.wav; draft-*"));
});
}
[Test]
public void ADebouncedEchoDoesNotOverwriteANewerDraft()
{
var state = Create("*.original");
state.EditFiles("*.first");
var earlierValue = state.Text;
state.EditFiles("*.latest");
state.Receive(earlierValue);
Assert.Multiple(() =>
{
Assert.That(state.Text, Is.EqualTo("*.latest"));
Assert.That(state.FileText, Is.EqualTo("*.latest"));
});
}
[Test]
public void AnUnchangedParentValueDoesNotDiscardAnUncommittedEdit()
{
var state = Create("*.original");
state.EditFiles("*.latest; ");
state.Receive("*.original");
Assert.That(state.FileText, Is.EqualTo("*.latest; "));
}
[Test]
public void ANewExternalValueReplacesBothGroups()
{
var state = Create("bin/;*.tmp");
state.EditFiles("*.latest");
state.Receive("policy/;*.secret");
Assert.Multiple(() =>
{
Assert.That(state.FolderText, Is.EqualTo("policy/"));
Assert.That(state.FileText, Is.EqualTo("*.secret"));
});
}
[Test]
public void ADisabledEditorShowsTheActualSettingEvenWhenItMatchesAnOlderDraft()
{
var state = Create("*.original");
state.EditFiles("*.earlier");
state.EditFiles("*.latest");
state.Receive("*.earlier", discardDraft: true);
Assert.That(state.FileText, Is.EqualTo("*.earlier"));
}
[Test]
public void ReformattingTheDefaultsDoesNotChangeTheSelectionSignature()
{
var state = Create(FileExclusions.DEFAULT_PATTERNS);
state.EditFiles(state.FileText);
var before = new FolderFileSelection(
["*"], FileExclusions.Parse(FileExclusions.DEFAULT_PATTERNS), true, false,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
var after = new FolderFileSelection(
["*"], FileExclusions.Parse(state.Text), true, false,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.That(after.Describe(), Is.EqualTo(before.Describe()));
}
[Test]
public void RestoringDefaultsReplacesBothGroupsAndKeepsTheExactDefaultString()
{
var state = Create("custom/;*.custom");
state.EditFolders(":text:");
state.Reset(FileExclusions.DEFAULT_PATTERNS);
Assert.Multiple(() =>
{
Assert.That(state.Text, Is.EqualTo(FileExclusions.DEFAULT_PATTERNS));
Assert.That(FileExclusions.IsValid(state.Text), Is.True);
Assert.That(state.FolderText, Does.Contain("node_modules/"));
Assert.That(state.FileText, Does.Contain("*.exe"));
Assert.That(state.FolderText, Does.Not.Contain("\n").And.Not.Contain("\r"));
Assert.That(state.FileText, Does.Not.Contain("\n").And.Not.Contain("\r"));
});
}
[Test]
public void EditingADefaultGroupPreservesAllRulesInTheOtherGroup()
{
var state = Create(FileExclusions.DEFAULT_PATTERNS);
state.EditFiles(state.FileText + "\n*.scratch");
Assert.That(
FileExclusions.Parse(state.Text),
Is.EquivalentTo(FileExclusions.Parse(FileExclusions.DEFAULT_PATTERNS).Append("*.scratch")));
}
[TestCase("*.pdf")]
[TestCase("Dockerfile")]
[TestCase("node_modules")]
public void WrongFieldDraftsStayVisibleWithoutAddingAFolderSuffix(string pattern)
{
var state = Create("bin/;*.tmp");
state.EditFolders(pattern);
Assert.Multiple(() =>
{
Assert.That(state.IsValid, Is.False);
Assert.That(state.FolderText, Is.EqualTo(pattern));
Assert.That(FileExclusions.Parse(state.Text), Does.Contain(pattern));
Assert.That(FileExclusions.Parse(state.Text), Does.Not.Contain(pattern + "/"));
Assert.That(state.FileText, Is.EqualTo("*.tmp"));
});
}
[Test]
public void FolderRulesInTheFileFieldStayVisibleAndInvalidAfterAParentEcho()
{
var state = Create("bin/;*.tmp");
state.EditFiles("*.bak");
var earlierValue = state.Text;
state.EditFiles("*.bak;obj/");
state.Receive(earlierValue);
Assert.Multiple(() =>
{
Assert.That(state.IsValid, Is.False);
Assert.That(state.FileText, Is.EqualTo("*.bak;obj/"));
Assert.That(state.FolderText, Is.EqualTo("bin/"));
});
}
private static FileExclusionEditorState Create(string text)
{
var state = new FileExclusionEditorState();
state.Receive(text);
return state;
}
}
@@ -1,181 +0,0 @@
using AIStudio.Components;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tests.Components;
public sealed class FileExclusionEditorTests
{
[TestCase(true, "*.pdf")]
[TestCase(true, "Dockerfile")]
[TestCase(true, "node_modules")]
[TestCase(false, "bin/")]
[TestCase(false, "*.pdf;obj/")]
[TestCase(true, ":text:")]
[TestCase(false, ":documents:")]
[TestCase(true, "src/bin/")]
[TestCase(false, "**/*.tmp")]
public async Task WrongFieldAndInvalidRulesAreNotPublished(bool folders, string patterns)
{
var published = new List<string>();
var editor = Create(published);
await editor.EditAsync(patterns, folders);
Assert.That(published, Is.Empty);
}
[Test]
public async Task EditingTheOtherFieldCannotPublishAnInvalidDraft()
{
var published = new List<string>();
var editor = Create(published);
await editor.EditAsync("*.pdf", folders: true);
await editor.EditAsync("*.bak", folders: false);
Assert.That(published, Is.Empty);
await editor.EditAsync("build/", folders: true);
Assert.That(published, Is.EqualTo(new[] { "build/; *.bak" }));
}
[Test]
public async Task MovingARuleToItsCorrectFieldPublishesItEvenIfTheCombinedTextDoesNotChange()
{
var published = new List<string>();
var editor = Create(published, "bin/");
await editor.EditAsync("bin/;*.pdf", folders: true);
await editor.EditAsync("*.pdf", folders: false);
Assert.That(published, Is.Empty);
await editor.EditAsync("bin/", folders: true);
Assert.That(published, Is.EqualTo(new[] { "bin/; *.pdf" }));
}
[TestCase(true, true)]
[TestCase(true, false)]
[TestCase(false, true)]
[TestCase(false, false)]
public async Task ResettingOneFieldCannotPersistInvalidRulesInTheOtherField(bool folders, bool explicitReset)
{
var published = new List<string>();
var editor = Create(published);
var resets = new List<string>();
if (explicitReset)
editor.ResetRequested = EventCallback.Factory.Create<string>(new object(), (string value) => resets.Add(value));
await editor.EditAsync(folders ? "*.keep;wrong/" : "*.wrong", folders: !folders);
await editor.ResetAsync(folders);
Assert.Multiple(() =>
{
Assert.That(published, Is.Empty);
Assert.That(resets, Is.Empty);
});
await editor.EditAsync(folders ? "*.keep" : "keep/", folders: !folders);
Assert.That(published, Is.EqualTo(new[] { folders ? "bin/; *.keep" : "keep/; *.tmp" }));
}
[TestCase(true)]
[TestCase(false)]
public async Task ResettingTheInvalidFieldRestoresAValidValue(bool folders)
{
var published = new List<string>();
var editor = Create(published);
var resets = new List<string>();
editor.ResetRequested = EventCallback.Factory.Create<string>(new object(), (string value) => resets.Add(value));
await editor.EditAsync(folders ? "*.wrong" : "wrong/", folders);
await editor.ResetAsync(folders);
Assert.Multiple(() =>
{
Assert.That(published, Is.Empty);
Assert.That(resets, Is.EqualTo(new[] { folders ? "bin/; *.custom" : "custom/; *.tmp" }));
});
}
[Test]
public async Task DisabledEditorRejectsEditsAndResets()
{
var published = new List<string>();
var editor = Create(published);
editor.Disabled = true;
await editor.EditAsync("changed/", folders: true);
await editor.EditAsync("*.changed", folders: false);
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.That(published, Is.Empty);
}
[Test]
public async Task ReadOnlyEditorRejectsEditsAndBothResetCallbacks()
{
var published = new List<string>();
var resets = new List<string>();
var editor = Create(published);
editor.ReadOnly = true;
editor.ResetRequested = EventCallback.Factory.Create<string>(new object(), (string value) => resets.Add(value));
editor.LoadParameters();
await editor.EditAsync("changed/", folders: true);
await editor.EditAsync("*.changed", folders: false);
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.Multiple(() =>
{
Assert.That(published, Is.Empty);
Assert.That(resets, Is.Empty);
});
editor.ResetRequested = default;
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.That(published, Is.Empty);
// Returning to editing must reveal the unchanged source, not either attempted edit.
editor.ReadOnly = false;
editor.LoadParameters();
await editor.EditAsync("*.other", folders: false);
Assert.That(published, Is.EqualTo(new[] { "custom/; *.other" }));
}
[Test]
public async Task HiddenResetButtonsCannotChangeTheRules()
{
var published = new List<string>();
var editor = Create(published);
editor.ShowReset = false;
await editor.ResetAsync(folders: true);
await editor.ResetAsync(folders: false);
Assert.That(published, Is.Empty);
}
private static TestableFileExclusionEditor Create(List<string> published, string initial = "custom/;*.custom")
{
var editor = new TestableFileExclusionEditor
{
Text = initial,
ResetValue = "bin/;*.tmp",
TextChanged = EventCallback.Factory.Create<string>(new object(), (string value) => published.Add(value)),
};
editor.LoadParameters();
return editor;
}
private sealed class TestableFileExclusionEditor : FileExclusionEditor
{
public void LoadParameters() => this.OnParametersSet();
public Task EditAsync(string patterns, bool folders) => folders ? this.ChangeFoldersAsync(patterns) : this.ChangeFilesAsync(patterns);
public Task ResetAsync(bool folders) => this.RestoreDefaultsAsync(folders);
}
}
@@ -1,84 +0,0 @@
using AIStudio.Components;
using AIStudio.Tools;
namespace AIStudio.Tests.Components;
public sealed class FilePatternSelectionTests
{
[TestCase("")]
[TestCase("*.mp3;draft-*")]
[TestCase(FileExclusions.DEFAULT_PATTERNS)]
public void ValidExclusionsArePermanentlyVisible(string patterns)
{
var component = new TestableFilePatternSelection();
component.SetExclusions(patterns);
Assert.That(component.HasExclusions, Is.True);
}
[Test]
public void AParentRefreshKeepsTheExistingExclusionFieldVisible()
{
var component = new TestableFilePatternSelection();
component.SetExclusions("*.mp3;draft-*");
var field = component.ExcludedContent;
component.RefreshParameters();
Assert.Multiple(() =>
{
Assert.That(component.HasExclusions, Is.True);
Assert.That(component.ExcludedContent, Is.SameAs(field), "A parent render must keep the existing field and its validation.");
});
}
[TestCase(":text:")]
[TestCase("src/bin/")]
[TestCase("**/*.txt")]
public void InvalidStoredExclusionsRemainVisible(string patterns)
{
var component = new TestableFilePatternSelection();
component.SetExclusions(patterns);
Assert.That(component.HasExclusions, Is.True);
}
[Test]
public void InclusionOnlyCallersHaveNoExclusionSection()
{
var component = new TestableFilePatternSelection();
component.RefreshParameters();
Assert.Multiple(() =>
{
Assert.That(component.ExcludedContent, Is.Null);
Assert.That(component.HasExclusions, Is.False);
});
}
[Test]
public void RemovingTheOptionalFieldRemovesTheExclusionSection()
{
var component = new TestableFilePatternSelection();
component.SetExclusions("*.mp3");
component.RemoveExclusions();
Assert.That(component.HasExclusions, Is.False);
}
private sealed class TestableFilePatternSelection : FilePatternSelection
{
public void SetExclusions(string patterns)
{
this.ExcludedContent = builder => builder.AddContent(0, patterns);
this.OnParametersSet();
}
public void RemoveExclusions()
{
this.ExcludedContent = null;
this.OnParametersSet();
}
public void RefreshParameters() => this.OnParametersSet();
}
}
@@ -0,0 +1,752 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AIStudio.Components;
using AIStudio.Dialogs;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tests.Tools.Databases;
using AIStudio.Tools;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.DataBackup;
using AIStudio.Tools.Security;
using AIStudio.Tools.Services;
using AIStudio.Tools.Services.Indexing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using MudBlazor;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Runs the actual edit, queue, cancellation, manifest preparation, and file indexing code against
/// a migrated SQLite store. Only the vector store and dialog rendering are replaced.
/// </summary>
[NonParallelizable]
public sealed class DataSourceRelocationWorkflowTests
{
private const string EMBEDDING_ID = "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01";
// Frozen from the format before relocation support. Do not generate this with the current
// signature builder: doing that would hide an accidental rebuild of every existing index.
private const string LEGACY_SIGNATURE = "2|b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01|OPEN_AI|text-embedding-3-small|NONE|http://localhost:1234|NONE||8192|512|100";
private static readonly TimeSpan TEST_TIMEOUT = TimeSpan.FromSeconds(10);
private string testRoot = string.Empty;
private TemporaryIndexStore store = null!;
private SettingsManager settings = null!;
private DatabaseClientProvider databases = null!;
private DataSourceEmbeddingService service = null!;
private RelocationVectorStore vectors = null!;
private IInternalDataSource source = null!;
private readonly List<string> chunkIds = [];
private readonly List<Task> runningTasks = [];
private CancellationTokenSource lifetime = null!;
private string OriginalFolder => Path.Combine(this.testRoot, "original");
[SetUp]
public async Task SetUp()
{
this.testRoot = Path.Combine(Path.GetTempPath(), $"ai-studio-relocation-workflow-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testRoot);
this.store = await TemporaryIndexStore.CreateAsync();
this.settings = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
this.settings.ConfigurationData.App.EnabledPreviewFeatures.Add(PreviewFeatures.PRE_RAG_2024);
this.settings.ConfigurationData.App.DataSourceIndexing.AutomaticRefresh = false;
this.settings.ConfigurationData.EmbeddingProviders.Add(new EmbeddingProvider(1, EMBEDDING_ID, "Test embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small")));
this.vectors = new RelocationVectorStore();
this.databases = new DatabaseClientProvider(null!, NullLoggerFactory.Instance);
var clients = Field<Dictionary<DatabaseRole, DatabaseClient>>(this.databases, "clients");
clients.Add(DatabaseRole.INDEX_STORE, this.store.Client);
clients.Add(DatabaseRole.VECTOR_STORE, this.vectors);
var guard = new PromptInjectionGuardService(null!, this.settings, NullLogger<PromptInjectionGuardService>.Instance, NullLoggerFactory.Instance);
this.service = new DataSourceEmbeddingService(this.settings, null!, this.databases, guard, null!, NullLogger<DataSourceEmbeddingService>.Instance);
this.lifetime = new CancellationTokenSource();
this.chunkIds.Clear();
this.runningTasks.Clear();
}
[TearDown]
public async Task TearDown()
{
this.lifetime.Cancel();
try
{
await Task.WhenAll(this.runningTasks).WaitAsync(TEST_TIMEOUT);
}
catch (OperationCanceledException) when (this.lifetime.IsCancellationRequested)
{
// A failed test may leave its deliberately blocked run for teardown to stop.
}
finally
{
this.service.Dispose();
this.databases.Dispose();
await this.store.DisposeAsync();
this.lifetime.Dispose();
Directory.Delete(this.testRoot, true);
}
}
[TestCase(false)]
[TestCase(true)]
public async Task AnExistingIndexSurvivesTheUpgradeWithoutReembedding(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.RelocatedPaths, Is.Empty, "An upgrade is not a relocation.");
Assert.That(this.vectors.DeletedStores, Is.Zero);
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
});
}
[TestCase(false)]
[TestCase(true)]
public async Task EditingAndCancelingDoNothingAndSavingOnlyRelocatesUnchangedFiles(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
var newPath = this.MoveSource("moved");
var original = this.source;
var edit = this.OpenEdit(newPath);
Assert.Multiple(() =>
{
Assert.That(this.settings.ConfigurationData.DataSources.Single(), Is.EqualTo(original));
Assert.That(this.Queue.TryDequeue(out _), Is.False);
Assert.That(this.vectors.RelocatedPaths, Is.Empty);
Assert.That(edit.Warning.ShowCount, Is.Zero, "Typing a path must not open the save confirmation.");
});
edit.Dialog.Instance.Cancel();
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
Assert.That(this.Queue.TryDequeue(out _), Is.False);
await this.SaveEditAsync(newPath);
Assert.That(this.vectors.RelocatedPaths, Is.Empty, "Saving queues the operation; the dialog must not write to either index.");
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(this.chunkIds.Count));
Assert.That(this.vectors.DeletedStores, Is.Zero);
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
});
var manifest = await this.store.Client.GetManifestAsync(this.source.Id, CancellationToken.None);
Assert.That(manifest.Files.Keys, Is.EquivalentTo(this.vectors.Locations.Values));
}
[TestCase(false)]
[TestCase(true)]
public async Task SavingAnUnchangedPathDoesNotWarnOrRelocate(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
var currentPath = this.source is DataSourceLocalDirectory folder ? folder.Path : ((DataSourceLocalFile)this.source).FilePath;
var edit = this.OpenEdit(currentPath);
await InvokeTask(edit.Component, "StoreValidatedDataSourceAsync").WaitAsync(TEST_TIMEOUT);
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.Multiple(() =>
{
Assert.That(edit.Warning.ShowCount, Is.Zero);
Assert.That(this.vectors.RelocatedPaths, Is.Empty);
Assert.That(this.vectors.DeletedStores, Is.Zero);
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
});
}
[TestCase(false)]
[TestCase(true)]
public async Task SavingWaitsForThePathWarningBeforeChangingConfigurationOrQueueingWork(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
var original = this.source;
var edit = this.OpenEdit(this.MoveSource("moved"));
var save = InvokeTask(edit.Component, "StoreValidatedDataSourceAsync");
Assert.Multiple(() =>
{
Assert.That(edit.Warning.ShowCount, Is.EqualTo(1));
Assert.That(save.IsCompleted, Is.False);
Assert.That(edit.ParentTask.IsCompleted, Is.False);
Assert.That(this.settings.ConfigurationData.DataSources.Single(), Is.EqualTo(original));
Assert.That(this.Queue.TryDequeue(out _), Is.False);
Assert.That(this.vectors.RelocatedPaths, Is.Empty);
});
edit.Warning.Complete(DialogResult.Ok(true));
await save.WaitAsync(TEST_TIMEOUT);
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
}
[TestCase(false)]
[TestCase(true)]
public async Task CancelingThePathWarningKeepsTheConfiguredPathAndQueuesNothing(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
var original = this.source;
var edit = this.OpenEdit(this.MoveSource("moved"));
edit.Warning.Complete(DialogResult.Cancel());
await InvokeTask(edit.Component, "StoreValidatedDataSourceAsync");
Assert.Multiple(() =>
{
Assert.That(edit.Warning.ShowCount, Is.EqualTo(1));
Assert.That(edit.ParentTask.IsCompleted, Is.False, "Rejecting the warning leaves the edit dialog open.");
Assert.That(this.settings.ConfigurationData.DataSources.Single(), Is.EqualTo(original));
Assert.That(this.Queue.TryDequeue(out _), Is.False);
Assert.That(this.vectors.RelocatedPaths, Is.Empty);
});
edit.Dialog.Instance.Cancel();
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
}
[TestCase(false)]
[TestCase(true)]
public async Task ANewLocationOfADataSourceWithoutAnIndexIsSavedWithoutAQuestion(bool singleFile)
{
await this.AddSourceAsync(singleFile);
var newPath = this.MoveSource("moved");
var edit = this.OpenEdit(newPath);
await InvokeTask(edit.Component, "StoreValidatedDataSourceAsync").WaitAsync(TEST_TIMEOUT);
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
var saved = this.settings.ConfigurationData.DataSources.Single();
Assert.Multiple(() =>
{
Assert.That(edit.Warning.ShowCount, Is.Zero, "Nothing was prepared for the data source, so nothing can be lost.");
Assert.That(saved is DataSourceLocalDirectory folder ? folder.Path : ((DataSourceLocalFile)saved).FilePath, Is.EqualTo(newPath));
});
}
[TestCase(false)]
[TestCase(true)]
public async Task ANewLocationTogetherWithANewEmbeddingAsksOnlyOnce(bool singleFile)
{
const string OTHER_EMBEDDING_ID = "c1b5d5e3-2a4f-4b1b-9d0e-6b7c8d9e0f12";
this.settings.ConfigurationData.EmbeddingProviders.Add(new EmbeddingProvider(2, OTHER_EMBEDDING_ID, "Other embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-large", "text-embedding-3-large")));
await this.SeedLegacyIndexAsync(singleFile);
var edit = this.OpenEdit(this.MoveSource("moved"));
SetField(edit.Component, "dataEmbeddingId", OTHER_EMBEDDING_ID);
edit.Warning.Complete(DialogResult.Ok(true));
await InvokeTask(edit.Component, "StoreValidatedDataSourceAsync").WaitAsync(TEST_TIMEOUT);
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
Assert.Multiple(() =>
{
Assert.That(edit.Warning.ShowCount, Is.EqualTo(1), "Everything is prepared again anyway, so the location needs no question of its own.");
Assert.That(edit.Warning.Title, Is.EqualTo("Documents Will Be Prepared Again"));
});
}
[TestCase(false)]
[TestCase(true)]
public async Task ASecondSavedPathCancelsTheFirstRelocationAndRunsWithTheLatestPath(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
await this.SaveEditAsync(this.MoveSource("first"));
this.vectors.BlockNextRelocation = true;
var firstRun = this.RunNextAsync();
await this.vectors.RelocationStarted.Task.WaitAsync(TEST_TIMEOUT);
var secondPath = this.MoveSource("second");
await this.SaveEditAsync(secondPath);
await firstRun.WaitAsync(TEST_TIMEOUT);
Assert.That(this.vectors.CanceledRelocations, Is.EqualTo(1), "The first run must be canceled without finishing its remaining documents.");
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(1));
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.RelocatedPaths.Skip(1).ToArray(), Has.Length.EqualTo(this.chunkIds.Count));
Assert.That(this.vectors.Locations.Values.All(path => singleFile ? path == secondPath : path.StartsWith(secondPath + Path.DirectorySeparatorChar, StringComparison.Ordinal)), Is.True);
Assert.That(this.vectors.DeletedStores, Is.Zero);
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.Queue.TryDequeue(out _), Is.False, "The obsolete destination must not leave another run behind.");
});
var manifest = await this.store.Client.GetManifestAsync(this.source.Id, CancellationToken.None);
Assert.That(manifest.Files.Keys, Is.EquivalentTo(this.vectors.Locations.Values));
}
[TestCase(false)]
[TestCase(true)]
public async Task DeletingTheSourceCancelsRelocationBeforeRemovingTheStoredIndex(bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
await this.SaveEditAsync(this.MoveSource("moved"));
this.vectors.BlockNextRelocation = true;
var run = this.RunNextAsync();
await this.vectors.RelocationStarted.Task.WaitAsync(TEST_TIMEOUT);
// A second save also leaves a pending run; deletion has to discard that one as well.
await this.service.QueueDataSourceAsync(this.source);
this.settings.ConfigurationData.DataSources.Clear();
await this.service.RemoveDataSourceAsync(this.source).WaitAsync(TEST_TIMEOUT);
await run.WaitAsync(TEST_TIMEOUT);
var manifest = await this.store.Client.GetManifestAsync(this.source.Id, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(this.vectors.CanceledRelocations, Is.EqualTo(1));
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(1));
Assert.That(this.vectors.DeletedStores, Is.EqualTo(1));
Assert.That(this.vectors.Locations, Is.Empty);
Assert.That(manifest.Files, Is.Empty);
Assert.That(this.service.GetStatuses(), Is.Empty);
Assert.That(this.Queue.TryDequeue(out _), Is.False);
});
}
private DataSourceRunQueue Queue => Field<DataSourceRunQueue>(this.service, "runQueue");
[TestCase(@"E:\Documents", '\\', false)]
[TestCase(@"\\server\share\Documents", '\\', false)]
[TestCase("/home/alice/Documents", '/', false)]
[TestCase(@"E:\Documents", '\\', true)]
[TestCase(@"\\server\share\Documents", '\\', true)]
[TestCase("/Volumes/External drive/Documents", '/', true)]
public async Task ABackupCanReconnectFilesFromAnotherOSWithoutReembedding(string oldRoot, char separator, bool singleFile)
{
await this.SeedLegacyIndexAsync(singleFile);
var originalFolder = Path.Combine(this.testRoot, "original");
await this.StoreForeignPathsAsync(oldRoot, separator);
var restored = await this.BackupAndRestoreIndexAsync();
// Opening the restored index while the old drive or network share is unavailable must not
// prune anything. No real external volume or network connection is needed by these tests.
if (!RelocatedFilePaths.IsLocalAbsolute(oldRoot))
{
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
await this.AssertOriginalChunksAsync();
}
// User documents are not in an AI Studio backup. Copy them separately, preserving their
// modification times, as when reconnecting an external drive after restoring a computer.
var destination = Path.Combine(this.testRoot, "mounted volume", "Übertragene Dokumente");
foreach (var original in Directory.EnumerateFiles(originalFolder, "*", SearchOption.AllDirectories))
{
var localPath = Path.Combine(destination, Path.GetRelativePath(originalFolder, original));
Directory.CreateDirectory(Path.GetDirectoryName(localPath)!);
File.Copy(original, localPath);
File.SetLastWriteTimeUtc(localPath, File.GetLastWriteTimeUtc(original));
}
var newPath = singleFile ? Path.Combine(destination, "notes.md") : destination;
await this.SaveEditAsync(newPath);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
var manifest = await restored.GetManifestAsync(this.source.Id, CancellationToken.None);
var hits = await restored.SearchChunksAsync(this.source.Id, "indexed", 10, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(this.chunkIds.Count));
Assert.That(this.vectors.DeletedStores, Is.Zero);
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
Assert.That(manifest.Files.Keys, Is.EquivalentTo(this.vectors.Locations.Values));
Assert.That(hits.Select(hit => hit.AbsolutePath), Is.EquivalentTo(this.vectors.Locations.Values));
Assert.That(hits.All(hit => PathTools.IsInsideDirectory(destination, hit.AbsolutePath)), Is.True);
});
// A restart/hash check after relocation must not relocate or prepare everything a second time.
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(this.chunkIds.Count));
await this.AssertOriginalChunksAsync();
// A disconnected external drive is not an empty folder. Reconnecting it must also keep
// the embeddings, rather than paying to prepare the same documents again.
var disconnected = destination + "-disconnected";
Directory.Move(destination, disconnected);
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.FAILED));
await this.AssertOriginalChunksAsync();
Directory.Move(disconnected, destination);
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(this.chunkIds.Count));
});
}
private async Task StoreForeignPathsAsync(string oldRoot, char separator)
{
await using var context = this.store.CreateContext();
foreach (var file in await context.EmbeddedFiles.ToListAsync())
{
var relative = file.RelativePath.Replace(Path.DirectorySeparatorChar, separator);
var oldPath = oldRoot + separator + relative;
file.AbsolutePath = oldPath;
file.RelativePath = relative;
file.Fingerprint = LegacyFingerprint(oldPath, file.FileName, file.LastWriteUtc.UtcDateTime.Ticks, file.FileSize);
this.vectors.Locations[file.ParentFileId] = oldPath;
}
await context.SaveChangesAsync();
this.source = this.source switch
{
DataSourceLocalDirectory folder => folder with { Path = oldRoot },
DataSourceLocalFile file => file with { FilePath = oldRoot + separator + "notes.md" },
_ => throw new InvalidOperationException(),
};
this.settings.ConfigurationData.DataSources[0] = this.source;
}
private async Task<SqliteIndexStoreClientImplementation> BackupAndRestoreIndexAsync()
{
var dataDirectory = Path.Combine(this.testRoot, "source-data");
var configDirectory = Path.Combine(this.testRoot, "source-config");
var copies = Path.Combine(this.testRoot, "index-copies");
Directory.CreateDirectory(dataDirectory);
Directory.CreateDirectory(configDirectory);
await this.store.Client.BackupToDirectoryAsync(Path.Combine(copies, "databases", "sqlite"), CancellationToken.None);
// This fixture's vector client stores metadata only. The runtime tests separately check
// backing up, reopening, and relocating actual Qdrant vectors without changing their values.
var vectorDirectory = Path.Combine(copies, "databases", "vector_database", "stores", "store_test");
Directory.CreateDirectory(vectorDirectory);
await File.WriteAllTextAsync(Path.Combine(vectorDirectory, "locations.json"), JsonSerializer.Serialize(this.vectors.Locations));
var archivePath = Path.Combine(this.testRoot, "backup.zip");
var settingsJson = JsonSerializer.Serialize(this.settings.ConfigurationData, SettingsManager.JSON_OPTIONS);
await using (var output = File.Create(archivePath))
await DataBackupWriter.WriteAsync(output, new DataBackupSources(dataDirectory, configDirectory, settingsJson, copies, []),
new DataBackupManifest(DataBackupFormat.FORMAT_VERSION, "26.10.1", "V6", DateTimeOffset.UtcNow, dataDirectory, configDirectory, true, []), null, CancellationToken.None);
var target = new DataRestoreTarget(Path.Combine(this.testRoot, "restored-data"), Path.Combine(this.testRoot, "restored-config"), "26.10.1", AIStudio.Settings.Version.V6, []);
var result = await DataRestorePreparer.PrepareAsync(archivePath, target, null, CancellationToken.None);
Assert.That(result.Success, Is.True);
// The runtime applies this prepared directory at restart. Open the exact restored bytes
// here, so this test exercises the real backup writer and restore preparer, not a rebuilt DB.
var pendingData = Path.Combine(target.DataDirectory, DataRestoreFormat.PENDING_DIRECTORY);
var restoredPath = Path.Combine(pendingData, "databases", "sqlite", "rag-index.sqlite3");
var restored = new SqliteIndexStoreClientImplementation("Restored SQLite", restoredPath, pendingData, string.Empty);
Field<Dictionary<DatabaseRole, DatabaseClient>>(this.databases, "clients")[DatabaseRole.INDEX_STORE] = restored;
this.vectors.Locations.Clear();
foreach (var (id, path) in JsonSerializer.Deserialize<Dictionary<string, string>>(await File.ReadAllTextAsync(Path.Combine(pendingData, "databases", "vector_database", "stores", "store_test", "locations.json")))!)
this.vectors.Locations.Add(id, path);
var restoredSettings = JsonSerializer.Deserialize<Data>(await File.ReadAllTextAsync(Path.Combine(target.ConfigDirectory, DataRestoreFormat.PENDING_DIRECTORY, "settings.json")), SettingsManager.JSON_OPTIONS)!;
this.settings.ConfigurationData.DataSources.Clear();
this.settings.ConfigurationData.DataSources.AddRange(restoredSettings.DataSources);
this.source = (IInternalDataSource)this.settings.ConfigurationData.DataSources.Single();
return restored;
}
[TestCase(false)]
[TestCase(true)]
public async Task AnEmptyMountDirectoryKeepsTheIndexAndReconnectingReusesIt(bool relocated)
{
await this.SeedLegacyIndexAsync(false);
var original = ((DataSourceLocalDirectory)this.source).Path;
var disconnected = Path.Combine(this.testRoot, "disconnected-drive");
Directory.Move(original, disconnected);
var mount = relocated ? Path.Combine(this.testRoot, "new-mount") : original;
Directory.CreateDirectory(mount);
var storedHash = (await this.store.Client.GetDataSourceStateAsync(this.source.Id, CancellationToken.None))!.SourceHash;
if (relocated)
await this.SaveEditAsync(mount);
else
await this.service.QueueDataSourceAsync(this.source);
// A second refresh must not accept the empty mount as a new baseline and prune the index.
for (var attempt = 0; attempt < 2; attempt++)
{
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
var state = await this.store.Client.GetDataSourceStateAsync(this.source.Id, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.FAILED));
Assert.That(this.service.GetStatuses().Single().LastError, Does.Contain("appears empty"));
Assert.That(state!.SourceHash, Is.EqualTo(storedHash));
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
});
if (attempt == 0)
await this.service.QueueDataSourceAsync(this.source);
}
Directory.Delete(mount);
Directory.Move(disconnected, mount);
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(relocated ? this.chunkIds.Count : 0));
});
}
[Test]
public async Task RemovingSomeFilesStillCleansUpOnlyTheirIndexEntries()
{
await this.SeedLegacyIndexAsync(false);
var removed = Path.Combine(((DataSourceLocalDirectory)this.source).Path, "notes.md");
var removedChunkId = IndexedDocumentIds.CreateChunkId(this.source.Id, LegacyFingerprint(new FileInfo(removed)), 0);
File.Delete(removed);
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
var chunks = await this.store.Client.SearchChunksAsync(this.source.Id, "indexed", 10, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(chunks.Select(chunk => chunk.ChunkId), Is.EquivalentTo(this.chunkIds.Except([removedChunkId])));
Assert.That(this.vectors.DeletedDocuments, Is.EqualTo(1));
Assert.That(this.vectors.InsertedBatches, Is.Zero);
});
}
[Test]
public async Task ExplicitlyExcludingEveryFileStillRemovesTheirIndexEntries()
{
await this.SeedLegacyIndexAsync(false);
this.source = ((DataSourceLocalDirectory)this.source) with { FilePatterns = "*.pdf" };
this.settings.ConfigurationData.DataSources[0] = this.source;
await this.service.QueueDataSourceAsync(this.source);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
var manifest = await this.store.Client.GetManifestAsync(this.source.Id, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(manifest.Files, Is.Empty);
Assert.That(this.vectors.Locations, Is.Empty);
Assert.That(this.vectors.DeletedDocuments, Is.EqualTo(2));
});
}
[Test]
public async Task AFolderWhoseNameChangesOnlyInCaseKeepsItsEmbeddings()
{
await this.SeedLegacyIndexAsync(false);
var renamed = Path.Combine(this.testRoot, "Original");
// Two moves, since a volume which ignores case may refuse to rename a folder to its own name:
var detour = Path.Combine(this.testRoot, "renaming");
Directory.Move(((DataSourceLocalDirectory)this.source).Path, detour);
Directory.Move(detour, renamed);
await this.SaveEditAsync(renamed);
await this.RunNextAsync().WaitAsync(TEST_TIMEOUT);
await this.AssertOriginalChunksAsync();
var manifest = await this.store.Client.GetManifestAsync(this.source.Id, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(this.service.GetStatuses().Single().State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(this.vectors.RelocatedPaths, Has.Count.EqualTo(this.chunkIds.Count));
Assert.That(this.vectors.DeletedDocuments, Is.Zero);
Assert.That(this.vectors.InsertedBatches, Is.Zero);
Assert.That(manifest.Files.Keys.All(path => path.StartsWith(renamed + Path.DirectorySeparatorChar, StringComparison.Ordinal)), Is.True, "The index must store the new spelling of the folder.");
});
}
private async Task SeedLegacyIndexAsync(bool singleFile)
{
var paths = await this.AddSourceAsync(singleFile);
var id = this.source.Id;
await this.store.Client.UpsertDataSourceAsync(id, this.source.Type.ToString(), EMBEDDING_ID, LEGACY_SIGNATURE, "legacy-source-hash", 3, CancellationToken.None);
foreach (var relative in paths)
{
var path = Path.Combine(this.OriginalFolder, relative);
var file = new FileInfo(path);
var fingerprint = LegacyFingerprint(file);
var parentId = IndexedDocumentIds.CreateParentId(id, path);
var chunkId = IndexedDocumentIds.CreateChunkId(id, fingerprint, 0);
var embeddedAt = DateTimeOffset.UtcNow.AddDays(-10);
await this.store.Client.UpsertFileAsync(id, new EmbeddingStateFile(parentId, path, file.Name, relative, "md", fingerprint, file.Length,
new DateTimeOffset(file.CreationTimeUtc), new DateTimeOffset(file.LastWriteTimeUtc), embeddedAt, 1), CancellationToken.None);
await this.store.Client.UpsertChunksAsync(id, [new EmbeddingStateChunk(chunkId, parentId, 1, 0, "Already indexed report", embeddedAt)], CancellationToken.None);
this.vectors.Locations.Add(parentId, path);
this.chunkIds.Add(chunkId);
}
}
/// <summary>
/// Writes the documents of a data source and configures it, without anything in the index.
/// </summary>
/// <returns>The paths of the documents, relative to the folder.</returns>
private async Task<string[]> AddSourceAsync(bool singleFile)
{
var root = this.OriginalFolder;
Directory.CreateDirectory(Path.Combine(root, "nested"));
var id = Guid.NewGuid().ToString();
var folder = new DataSourceLocalDirectory
{
Id = id, Name = "Existing documents", Type = DataSourceType.LOCAL_DIRECTORY, Path = root,
EmbeddingId = EMBEDDING_ID, ConfidenceLevel = ConfidenceLevel.NONE, MaxChunkTokenLength = 512, ChunkOverlapTokenLength = 100,
FilePatterns = "*.md", ExcludePatterns = string.Empty,
};
this.source = singleFile ? new DataSourceLocalFile
{
Id = id, Name = folder.Name, Type = DataSourceType.LOCAL_FILE, FilePath = Path.Combine(root, "notes.md"),
EmbeddingId = EMBEDDING_ID, ConfidenceLevel = ConfidenceLevel.NONE, MaxChunkTokenLength = 512, ChunkOverlapTokenLength = 100,
} : folder;
this.settings.ConfigurationData.DataSources.Add(this.source);
var paths = singleFile ? new[] { "notes.md" } : new[] { "notes.md", Path.Combine("nested", "report.md") };
foreach (var relative in paths)
await File.WriteAllTextAsync(Path.Combine(root, relative), "Already indexed report");
return paths;
}
private string MoveSource(string destinationName)
{
var current = (IInternalDataSource)this.settings.ConfigurationData.DataSources.Single();
var destination = Path.Combine(this.testRoot, destinationName);
if (current is DataSourceLocalDirectory folder)
Directory.Move(folder.Path, destination);
else
{
destination += ".md";
File.Move(((DataSourceLocalFile)current).FilePath, destination);
}
return destination;
}
private async Task SaveEditAsync(string newPath)
{
var edit = this.OpenEdit(newPath);
edit.Warning.Complete(DialogResult.Ok(true));
await InvokeTask(edit.Component, "StoreValidatedDataSourceAsync").WaitAsync(TEST_TIMEOUT);
await edit.ParentTask.WaitAsync(TEST_TIMEOUT);
Assert.Multiple(() =>
{
Assert.That(edit.Warning.ShowCount, Is.EqualTo(1));
Assert.That(edit.Warning.ComponentType, Is.EqualTo(typeof(ConfirmDialog)));
Assert.That(edit.Warning.Title, Is.EqualTo("New Location for This Data Source"));
Assert.That(edit.Warning.Parameters!.Get<string>(nameof(ConfirmDialog.MarkdownBody)), Does.Contain("unchanged at the new location").And.Contain("removed from the index").And.Contain("files on disk stay as they are").And.Contain("costs money"));
});
this.source = (IInternalDataSource)this.settings.ConfigurationData.DataSources.Single();
}
private OpenedEdit OpenEdit(string newPath)
{
var storedSource = (IInternalDataSource)this.settings.ConfigurationData.DataSources.Single();
var dialog = new TestDataSourceDialog();
var warning = new TestDataSourceDialog();
var parent = new DataSourceManagement();
Property(parent, "SettingsManager", this.settings);
Property(parent, "MessageBus", MessageBus.INSTANCE);
Property(parent, "DataSourceEmbeddingService", this.service);
Property(parent, "DialogService", dialog.Service);
var parentTask = InvokeTask(parent, "EditDataSource", storedSource);
object component = storedSource is DataSourceLocalDirectory ? new DataSourceLocalDirectoryDialog() : new DataSourceLocalFileDialog();
Property(component, "SettingsManager", this.settings);
Property(component, "DialogService", warning.Service);
Property(component, "DataSourceEmbeddingService", this.service);
Property(component, "MudDialog", dialog.Instance);
Property(component, "IsEditing", true);
Property(component, "DataSource", storedSource);
SetField(component, "dataId", storedSource.Id);
SetField(component, "dataName", storedSource.Name);
SetField(component, "dataEmbeddingId", storedSource.EmbeddingId);
SetField(component, "dataConfidenceLevel", storedSource.ConfidenceLevel);
SetField(component, "dataMaxChunkTokenLength", storedSource.MaxChunkTokenLength);
SetField(component, "dataChunkOverlapTokenLength", storedSource.ChunkOverlapTokenLength);
SetField(component, storedSource is DataSourceLocalDirectory ? "dataPath" : "dataFilePath", newPath);
if (storedSource is DataSourceLocalDirectory folder)
{
SetField(component, "dataFilePatterns", folder.FilePatterns);
SetField(component, "dataExcludePatterns", folder.ExcludePatterns);
}
return new(component, dialog, warning, parentTask);
}
private Task RunNextAsync()
{
Assert.That(this.Queue.TryDequeue(out var request), Is.True, "Saving must queue a run.");
var task = InvokeTask(this.service, "RunDataSourceAsync", request!.DataSourceId, request.RefreshMode, this.lifetime.Token);
this.runningTasks.Add(task);
return task;
}
private async Task AssertOriginalChunksAsync()
{
var index = await this.databases.GetIndexStoreAsync();
var chunks = await index.SearchChunksAsync(this.source.Id, "indexed", 100, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(chunks.Select(chunk => chunk.ChunkId), Is.EquivalentTo(this.chunkIds));
Assert.That(chunks.All(chunk => chunk.ChunkText == "Already indexed report"), Is.True);
Assert.That(chunks.All(chunk => chunk.EmbeddedAtUtc < DateTimeOffset.UtcNow.AddDays(-9)), Is.True, "Updating the app or its paths must not recreate existing chunks.");
});
}
private static string LegacyFingerprint(FileInfo file)
{
return LegacyFingerprint(file.FullName, file.Name, file.LastWriteTimeUtc.Ticks, file.Length);
}
private static string LegacyFingerprint(string path, string name, long writeTicks, long size)
{
var parts = new[] { "file", path, name, writeTicks.ToString(), size.ToString() };
var serialized = string.Concat(parts.Select(part => $"{part.Length}:{part}|"));
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(serialized)));
}
// Keep reflection at the fixture boundary: exercise the production queue worker without
// starting the unrelated application startup loop, and install the isolated database clients.
private static T Field<T>(object instance, string name) => (T)instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(instance)!;
private static void SetField(object instance, string name, object value) => instance.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(instance, value);
private static void Property(object instance, string name, object value)
{
for (var type = instance.GetType(); type is not null; type = type.BaseType)
if (type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly) is { } property)
{
property.SetValue(instance, value);
return;
}
throw new MissingMemberException(name);
}
private static Task InvokeTask(object instance, string name, params object[] arguments) =>
(Task)instance.GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(instance, arguments)!;
private sealed record OpenedEdit(object Component, TestDataSourceDialog Dialog, TestDataSourceDialog Warning, Task ParentTask);
}
@@ -0,0 +1,47 @@
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Services.Indexing;
namespace AIStudio.Tests.Tools.Databases;
public sealed class IndexStoreRelocationTests
{
[Test]
public async Task RelocationPreservesChunksAndTranscriptAndUpdatesSearchLocations()
{
await using var store = await TemporaryIndexStore.CreateAsync();
var sourceId = Guid.NewGuid().ToString();
var oldPath = Path.Combine(Path.GetTempPath(), "original", "notes.md");
var newPath = Path.Combine(Path.GetTempPath(), "moved", "renamed.md");
var parentId = IndexedDocumentIds.CreateParentId(sourceId, oldPath);
var chunkId = IndexedDocumentIds.CreateChunkId(sourceId, "old-fingerprint", 0);
var now = DateTimeOffset.UtcNow;
var token = CancellationToken.None;
await store.Client.UpsertDataSourceAsync(sourceId, "LOCAL_DIRECTORY", "provider", "signature", "old-hash", 3, token);
await store.Client.UpsertFileAsync(sourceId, new EmbeddingStateFile(parentId, oldPath, "notes.md", "notes.md", "md", "old-fingerprint", 100, now, now, now, 1) { TranscriptKey = "transcript" }, token);
await store.Client.UpsertChunksAsync(sourceId, [new EmbeddingStateChunk(chunkId, parentId, 2, 0, "report about relocation", now)], token);
// Repeating the metadata write after an interrupted relocation must keep the same chunks.
await store.Client.RelocateFileAsync(sourceId, parentId, newPath, "renamed.md", "new-fingerprint", token);
await store.Client.RelocateFileAsync(sourceId, parentId, newPath, "renamed.md", "new-fingerprint", token);
var manifest = await store.Client.GetManifestAsync(sourceId, token);
var hits = await store.Client.SearchChunksAsync(sourceId, "relocation", 10, token);
Assert.Multiple(() =>
{
Assert.That(manifest.Files.Keys, Is.EquivalentTo(new[] { newPath }));
Assert.That(manifest.Files[newPath].Fingerprint, Is.EqualTo("new-fingerprint"));
Assert.That(manifest.Files[newPath].ParentFileId, Is.EqualTo(parentId));
Assert.That(manifest.Files[newPath].RelativePath, Is.EqualTo("renamed.md"));
Assert.That(manifest.Files[newPath].TranscriptKey, Is.EqualTo("transcript"));
Assert.That(hits, Has.Count.EqualTo(1));
Assert.That(hits[0].ChunkId, Is.EqualTo(chunkId));
Assert.That(hits[0].AbsolutePath, Is.EqualTo(newPath));
Assert.That(hits[0].FileName, Is.EqualTo("renamed.md"));
Assert.That(hits[0].PageNumber, Is.EqualTo(2));
Assert.That(hits[0].ChunkText, Is.EqualTo("report about relocation"));
});
await store.Client.DeleteFileAsync(sourceId, newPath, token);
Assert.That(await store.Client.SearchChunksAsync(sourceId, "relocation", 10, token), Is.Empty, "Ordinary removal must still find a relocated document's chunks.");
}
}
+1 -13
View File
@@ -40,18 +40,6 @@ public sealed class EmbeddingSignatureTests
"Patterns change which files belong to the source, not how retained files are embedded; the indexing run removes and adds only the affected files.");
}
[Test]
public void ChangingDirectoryExclusionsKeepsTheStoredEmbeddingsOfRetainedFiles()
{
var allFiles = DataSource(ConfidenceLevel.LOW);
var filteredFiles = allFiles with { ExcludePatterns = "*.mp3;draft-*" };
Assert.That(
Signature(filteredFiles),
Is.EqualTo(Signature(allFiles)),
"Exclusions change which files belong to the source, not how retained files are embedded.");
}
[Test]
public void ChangingDirectoryRecursionKeepsTheStoredEmbeddingsOfRetainedFiles()
{
@@ -256,4 +244,4 @@ public sealed class EmbeddingSignatureTests
public int ChunkOverlapTokenLength { get; init; }
}
}
}
-65
View File
@@ -36,71 +36,6 @@ public sealed class FileExclusionsTests
Assert.That(new DataSourceValidation().ValidateExcludePatterns(patterns), Is.Not.Null);
}
[TestCase(null, true)]
[TestCase("", true)]
[TestCase(" ; \r\n", true)]
[TestCase("build/;cmake-build-*/", true)]
[TestCase(".env/;archive.v1/;*.vmwarevm/", true)]
[TestCase("*.pdf", false)]
[TestCase("Dockerfile", false)]
[TestCase("node_modules", false)]
[TestCase("build/;*.txt", false)]
public void FolderFieldRequiresExplicitFolderRules(string? patterns, bool valid)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValidFolders(patterns), Is.EqualTo(valid));
Assert.That(FileExclusions.ValidateFolders(patterns) is null, Is.EqualTo(valid));
});
}
[TestCase(null, true)]
[TestCase("", true)]
[TestCase(" ; \r\n", true)]
[TestCase("*.pdf;draft-*;report-?.md", true)]
[TestCase("Dockerfile;Caddyfile;.gitignore", true)]
[TestCase("build", true)]
[TestCase("build/", false)]
[TestCase("*.tmp;obj/", false)]
public void FileFieldRejectsFolderRulesButAcceptsExtensionlessFiles(string? patterns, bool valid)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValidFiles(patterns), Is.EqualTo(valid));
Assert.That(FileExclusions.ValidateFiles(patterns) is null, Is.EqualTo(valid));
});
}
[TestCase(":text:")]
[TestCase(":documents:")]
[TestCase("src/build/")]
[TestCase("**/*.tmp")]
[TestCase("/")]
[TestCase("./")]
public void BothFieldValidatorsReuseTheExistingSyntaxErrors(string patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValidFolders(patterns), Is.False);
Assert.That(FileExclusions.IsValidFiles(patterns), Is.False);
Assert.That(FileExclusions.ValidateFolders(patterns), Is.EqualTo(FileExclusions.Validate(patterns)));
Assert.That(FileExclusions.ValidateFiles(patterns), Is.EqualTo(FileExclusions.Validate(patterns)));
});
}
[Test]
public void StoredListsStillAcceptBothKindsOfRulesTogether()
{
const string PATTERNS = "bin/;*.pdf;Dockerfile;.git/";
Assert.Multiple(() =>
{
Assert.That(FileExclusions.IsValid(PATTERNS), Is.True);
Assert.That(FileExclusions.Validate(PATTERNS), Is.Null);
Assert.That(FileExclusions.ExcludesFolder("bin", FileExclusions.Parse(PATTERNS)), Is.True);
Assert.That(FileExclusions.ExcludesFile("report.pdf", FileExclusions.Parse(PATTERNS)), Is.True);
});
}
[Test]
public void EntriesAreSeparatedBySemicolonsAndLineBreaks()
{
@@ -1,153 +0,0 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
public sealed class FilePatternExclusionTests
{
[TestCase("*", "*.mp3;*.webm", "recording.WEBM", false)]
[TestCase("*", "*.mp3;*.webm", "report.pdf", true)]
[TestCase("*.txt;*.pdf", "draft-*", "draft-report.pdf", false)]
[TestCase("*.txt;*.pdf", "draft-*", "report.pdf", true)]
[TestCase("*.txt", "*.mp3", "report.pdf", false)]
[TestCase("*", "Dockerfile;Caddyfile", "Dockerfile", false)]
[TestCase("*", ".env", ".env", false)]
[TestCase("*", "*", "report.pdf", false)]
[TestCase("*", "", "report.pdf", true)]
[TestCase("*", "*-draft-*", "report-draft-final.md", false)]
[TestCase("*", "draft-*", "draft-", false)]
[TestCase("*", "report-?.md", "report-a.md", false)]
[TestCase("*", "report-?.md", "report-ab.md", true)]
public void ExclusionsOverrideIncludesButNeverSelectOtherFiles(string include, string exclude, string fileName, bool expected)
{
var path = Path.Combine(Path.GetTempPath(), "nested", fileName);
var selection = new FolderFileSelection(
FileNamePatterns.Parse(include),
FileExclusions.Parse(exclude),
false,
false,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.That(selection.Selects(path), Is.EqualTo(expected));
}
[TestCase("build", true)]
[TestCase("app-build", true)]
[TestCase("my-build", true)]
[TestCase("BUILD", true)]
[TestCase("builder", false)]
[TestCase("build-output", false)]
public void TheFolderExampleMatchesNamesEndingInBuildButNotFiles(string name, bool expected)
{
var patterns = FileExclusions.Parse("*build/");
var path = Path.Combine(Path.GetTempPath(), "test-data", name);
Assert.Multiple(() =>
{
Assert.That(FileExclusions.ExcludesFolder(path, patterns), Is.EqualTo(expected));
Assert.That(FileExclusions.ExcludesFile(path, patterns), Is.False);
});
}
[TestCase(false, "", true, false)]
[TestCase(true, "", true, true)]
[TestCase(false, ".gitignore", false, false)]
[TestCase(true, ".gitignore", false, false)]
[TestCase(false, ".hidden/", true, false)]
[TestCase(true, ".hidden/", true, false)]
public async Task ExplicitDotPatternsBypassOnlyTheHiddenFileRuleNotExclusionsOrHiddenFolders(
bool includeHidden, string exclusions, bool selectsRootFile, bool selectsNestedFile)
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-dot-pattern-priority-{Guid.NewGuid():N}");
var hiddenDirectory = Path.Combine(directoryPath, ".hidden");
var rootFile = Path.Combine(directoryPath, ".gitignore");
var nestedFile = Path.Combine(hiddenDirectory, ".gitignore");
Directory.CreateDirectory(hiddenDirectory);
try
{
await File.WriteAllTextAsync(rootFile, "bin/");
await File.WriteAllTextAsync(nestedFile, "obj/");
var selection = new FolderFileSelection(
[".gitignore"], FileExclusions.Parse(exclusions), true, includeHidden,
FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
var selectedFiles = selection.EnumerateCandidates(directoryPath).Where(selection.Selects).ToArray();
Assert.Multiple(() =>
{
Assert.That(selectedFiles.Contains(rootFile), Is.EqualTo(selectsRootFile));
Assert.That(selectedFiles.Contains(nestedFile), Is.EqualTo(selectsNestedFile));
Assert.That(selection.LeavesOut(directoryPath, rootFile), Is.EqualTo(!selectsRootFile));
Assert.That(selection.LeavesOut(directoryPath, nestedFile), Is.EqualTo(!selectsNestedFile));
});
}
finally
{
Directory.Delete(directoryPath, true);
}
}
[Test]
public void EnvironmentDefaultsExcludeVisibleEnvironmentFoldersRatherThanDotEnvFiles()
{
var defaults = FileExclusions.Parse(FileExclusions.DEFAULT_PATTERNS);
Assert.Multiple(() =>
{
Assert.That(FileExclusions.ExcludesFolder("env", defaults), Is.True);
Assert.That(FileExclusions.ExcludesFolder("venv", defaults), Is.True);
Assert.That(FileExclusions.ExcludesFile(".env", defaults), Is.False);
Assert.That(FileExclusions.ExcludesFolder(".env", defaults), Is.False,
"Hidden folders are handled by the hidden-file switch, not by the default exclusion list.");
});
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void ExclusionsMayBeEmptyWhileIncludesAreRequired(string? patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Validate(patterns), Is.Null);
Assert.That(FileExclusions.IsValid(patterns), Is.True);
Assert.That(FileNamePatterns.IsValid(patterns), Is.False);
});
}
[TestCase("**/*.txt")]
[TestCase("folder/*.txt")]
public void OptionalExclusionsStillRejectMalformedRules(string patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileExclusions.Validate(patterns), Is.Not.Null);
Assert.That(FileExclusions.IsValid(patterns), Is.False);
});
}
[Test]
public async Task TextSelectorsCanBeCombinedWithFilenameExclusions()
{
var directoryPath = Path.Combine(Path.GetTempPath(), $"ai-studio-exclusions-{Guid.NewGuid():N}");
Directory.CreateDirectory(directoryPath);
try
{
var terraform = Path.Combine(directoryPath, "main.tf");
var caddy = Path.Combine(directoryPath, "Caddyfile");
await File.WriteAllTextAsync(terraform, "resource example {}");
await File.WriteAllTextAsync(caddy, "localhost { respond hello }");
var selection = new FolderFileSelection(
[":text:"], ["*.tf"], false, false, FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.Multiple(() =>
{
Assert.That(selection.Selects(terraform), Is.False);
Assert.That(selection.Selects(caddy), Is.True);
});
}
finally
{
Directory.Delete(directoryPath, true);
}
}
}
@@ -35,6 +35,15 @@ public sealed class FileSourceIndexerWatcherTests
Is.False);
}
[Test]
public void SingleFileWatcherIgnoresAnotherCaseDistinctFileOnUnix()
{
var configuration = new FileSourceIndexer.DataSourceWatcherConfiguration(Path.GetTempPath(), false, TargetFileName: "Report.md");
Assert.That(FileSourceIndexer.IsRelevantWatcherPath(configuration, Path.Combine(Path.GetTempPath(), "report.md"), WatcherChangeTypes.Changed),
Is.EqualTo(OperatingSystem.IsWindows()));
}
[Test]
public void SingleFileWatcherRecognizesARenameAwayFromTheConfiguredFile()
{
@@ -263,29 +272,12 @@ public sealed class FileSourceIndexerWatcherTests
}
[Test]
public void ReorderingEquivalentExclusionsDoesNotRecreateTheWatcher()
public void RelocatingToADirectoryWithDifferentCasingUsesTheLocalPathRules()
{
var before = DefaultConfiguration(Path.GetTempPath()) with
{
ExcludePatterns = FileExclusions.Parse("*.mp3;draft-*"),
};
var after = before with { ExcludePatterns = FileExclusions.Parse("DRAFT-*;*.MP3;*.mp3") };
var before = DefaultConfiguration(Path.Combine(Path.GetTempPath(), "Documents"));
var after = before with { RootPath = Path.Combine(Path.GetTempPath(), "documents") };
Assert.That(FileSourceIndexer.IsSameWatchConfiguration(before, after), Is.True);
}
[Test]
public void RenamingAnIncludedFileIntoAnExcludedNameStillRefreshesTheIndex()
{
var rootPath = Path.Combine(Path.GetTempPath(), $"ai-studio-excluded-rename-{Guid.NewGuid():N}");
var configuration = DefaultConfiguration(rootPath) with
{
FilePatterns = FileNamePatterns.Parse("*.md"),
ExcludePatterns = FileExclusions.Parse("draft-*"),
};
var renamed = new RenamedEventArgs(WatcherChangeTypes.Renamed, rootPath, "draft-report.md", "report.md");
Assert.That(FileSourceIndexer.IsRelevantWatcherEvent(configuration, renamed), Is.True);
Assert.That(FileSourceIndexer.IsSameWatchConfiguration(before, after), Is.EqualTo(OperatingSystem.IsWindows()), "On Unix these can be different directories; their watchers must not be treated as interchangeable.");
}
[Test]
@@ -0,0 +1,217 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
using AIStudio.Tools.Services.Indexing;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
public sealed class FileSourceRelocationTests
{
private string testRoot = string.Empty;
[SetUp]
public void SetUp()
{
this.testRoot = Path.Combine(Path.GetTempPath(), $"ai-studio-relocation-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testRoot);
}
[TearDown]
public void TearDown() => Directory.Delete(this.testRoot, true);
[Test]
public void AMovedFolderFindsAnUnchangedNestedDocument()
{
var oldRoot = Path.Combine(this.testRoot, "original");
var oldFile = Path.Combine(oldRoot, "nested", "notes.md");
Directory.CreateDirectory(Path.GetDirectoryName(oldFile)!);
File.WriteAllText(oldFile, "Some indexed text");
var record = Record(oldFile, Path.Combine("nested", "notes.md"));
var newRoot = Path.Combine(this.testRoot, "moved");
Directory.Move(oldRoot, newRoot);
var found = FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = newRoot }, oldFile, record);
Assert.That(found?.FullName, Is.EqualTo(Path.Combine(newRoot, "nested", "notes.md")));
}
[TestCase("size")]
[TestCase("modification time")]
[TestCase("missing file")]
public void AChangedOrMissingDocumentCannotReuseItsEmbeddings(string change)
{
var file = Path.Combine(this.testRoot, "notes.md");
File.WriteAllText(file, "Some indexed text");
var record = Record(file, "notes.md");
switch (change)
{
case "size":
File.AppendAllText(file, " with more text");
File.SetLastWriteTimeUtc(file, record.LastWriteUtc.UtcDateTime);
break;
case "modification time":
File.SetLastWriteTimeUtc(file, record.LastWriteUtc.UtcDateTime.AddMinutes(1));
break;
case "missing file":
File.Delete(file);
break;
}
// The old place lies outside the selected folder, so only the change can prevent the reuse:
var oldPath = Path.Combine(Path.GetDirectoryName(this.testRoot)!, "moved-away", "notes.md");
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = this.testRoot }, oldPath, record), Is.Null);
}
[Test]
public void AStoredPathBelowTheSelectedFolderIsNotMappedToACopy()
{
// The folder grows to its parent, which holds a copy with the same size and time at the
// place the old relative path names. The original is still there and stays the document.
var original = Path.Combine(this.testRoot, "project", "notes.md");
Directory.CreateDirectory(Path.GetDirectoryName(original)!);
File.WriteAllText(original, "Some indexed text");
var copy = Path.Combine(this.testRoot, "notes.md");
File.Copy(original, copy);
File.SetLastWriteTimeUtc(copy, File.GetLastWriteTimeUtc(original));
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = this.testRoot }, original, Record(original, "notes.md")), Is.Null);
}
[Test]
public void ARenamedSingleFileCanReuseItsEmbeddings()
{
var oldFile = Path.Combine(this.testRoot, "notes.md");
File.WriteAllText(oldFile, "Some indexed text");
var record = Record(oldFile, "notes.md");
var newFile = Path.Combine(this.testRoot, "renamed.md");
File.Move(oldFile, newFile);
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalFile { FilePath = newFile }, oldFile, record)?.FullName, Is.EqualTo(newFile));
}
[Test]
public void ARelativePathCannotEscapeTheConfiguredFolder()
{
var outside = Path.Combine(this.testRoot, "notes.md");
File.WriteAllText(outside, "Some indexed text");
var record = Record(outside, Path.Combine("..", "notes.md"));
var folder = Path.Combine(this.testRoot, "selected");
Directory.CreateDirectory(folder);
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = folder }, Path.Combine(this.testRoot, "old", "notes.md"), record), Is.Null);
}
private static EmbeddedFileRecord Record(string path, string relativePath)
{
var file = new FileInfo(path);
return new("old-fingerprint", file.Length, new DateTimeOffset(file.LastWriteTimeUtc), DateTimeOffset.UtcNow, 1, RelativePath: relativePath, ParentFileId: Guid.NewGuid().ToString());
}
[TestCase(@"E:\Documents\nested\notes.md", @"nested\notes.md")]
[TestCase(@"\\server\share\Documents\nested\notes.md", @"nested\notes.md")]
[TestCase(@"\\?\E:\Documents\nested\notes.md", @"nested\notes.md")]
[TestCase("E:/Documents/nested/notes.md", "nested/notes.md")]
[TestCase("/home/alice/Documents/nested/notes.md", "nested/notes.md")]
[TestCase("/Volumes/External drive/Documents/nested/notes.md", "nested/notes.md")]
public void ABackupFromAnotherPathSyntaxFindsANestedFile(string oldPath, string relativePath)
{
var localFile = Path.Combine(this.testRoot, "nested", "notes.md");
Directory.CreateDirectory(Path.GetDirectoryName(localFile)!);
File.WriteAllText(localFile, "Some indexed text");
var found = FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = this.testRoot }, oldPath, Record(localFile, relativePath));
Assert.That(found?.FullName, Is.EqualTo(localFile));
}
[TestCase(@"C:\a.folder\README")]
[TestCase(@"\\server\a.folder\README")]
[TestCase("/media/a.folder/README")]
public void AForeignDirectoryNameDoesNotBecomeTheExtensionOfAnExtensionlessFile(string oldPath)
{
var localFile = Path.Combine(this.testRoot, "README");
File.WriteAllText(localFile, "Some indexed text");
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalFile { FilePath = localFile }, oldPath, Record(localFile, "README"))?.FullName, Is.EqualTo(localFile));
}
[TestCase(@"..\notes.md")]
[TestCase("../notes.md")]
[TestCase(@"nested\..\..\notes.md")]
[TestCase(@"C:\notes.md")]
[TestCase(@"C:notes.md")]
[TestCase(@"\\server\share\notes.md")]
[TestCase("/etc/notes.md")]
public void AStoredWindowsPathCannotEscapeTheNewRoot(string relativePath)
{
Assert.That(RelocatedFilePaths.Resolve(this.testRoot, @"E:\Documents\notes.md", relativePath), Is.Null);
}
[Test]
public void AnExternalDriveRootIsAValidRelocationDestination()
{
// No file needs to be written at the drive root: this checks resolving the destination.
var root = Path.GetPathRoot(this.testRoot)!;
Assert.That(RelocatedFilePaths.Resolve(root, @"E:\notes.md", "notes.md"), Is.EqualTo(Path.Combine(root, "notes.md")));
}
[Test]
public void AUnixBackslashInAFileNameIsNotReinterpretedAsADirectory()
{
if (OperatingSystem.IsWindows())
{
Assert.That(RelocatedFilePaths.Resolve(this.testRoot, @"/home/alice/notes\draft.md", @"notes\draft.md"), Is.Null, "A Windows filename cannot contain a backslash from a Unix filename.");
return;
}
var localFile = Path.Combine(this.testRoot, @"notes\draft.md");
File.WriteAllText(localFile, "Some indexed text");
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = this.testRoot }, @"/home/alice/notes\draft.md", Record(localFile, @"notes\draft.md"))?.FullName, Is.EqualTo(localFile));
}
[Test]
public void AnOldForeignRootIsNotResolvedRelativeToTheWorkingDirectory()
{
var foreignRoot = OperatingSystem.IsWindows() ? "/home/alice/Documents" : @"E:\Documents";
Assert.That(RelocatedFilePaths.Resolve(foreignRoot, @"E:\Documents\notes.md", "notes.md"), Is.Null);
}
[Test]
public void ReducedTimestampPrecisionOnAnotherDriveDoesNotSilentlyReuseOldContent()
{
var localFile = Path.Combine(this.testRoot, "notes.md");
File.WriteAllText(localFile, "Some indexed text");
var record = Record(localFile, "notes.md");
// The old filesystem stored a finer timestamp than the copied file now reports. Without
// a stored content hash, equal size alone is not enough to prove that it is unchanged.
record = record with { LastWriteUtc = record.LastWriteUtc.AddTicks(1) };
Assert.That(FileSourceIndexer.FindRelocatedFile(new DataSourceLocalDirectory { Path = this.testRoot }, @"E:\Documents\notes.md", record), Is.Null);
}
[Test]
public void ANewFileAtAnOldLocationCannotOverwriteTheRelocatedDocument()
{
var source = new DataSourceLocalDirectory { Id = Guid.NewGuid().ToString(), Path = this.testRoot };
var originalPath = Path.Combine(this.testRoot, "nested", "notes.md");
var relocatedPath = Path.Combine(this.testRoot, "notes.md");
var originalId = IndexedDocumentIds.CreateParentId(source.Id, originalPath);
var manifest = new DataSourceEmbeddingManifest();
manifest.Files[relocatedPath] = new("fingerprint", 100, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, 1, RelativePath: "notes.md", ParentFileId: originalId);
// Only identity assignment is exercised; none of the run's services are called.
var context = new IndexedRunContext(source, null!, null!, null!, null!, manifest,
new RunShare(RunShare.DEFAULT_DURATION), new IndexingPass(new DataSourceRunSignature("signature", "selection")), null!, null!, _ => { }, NullLogger.Instance);
var newId = context.GetFileParentId(originalPath);
Assert.Multiple(() =>
{
Assert.That(context.GetFileParentId(relocatedPath), Is.EqualTo(originalId));
Assert.That(newId, Is.Not.EqualTo(originalId));
Assert.That(context.GetFileParentId(originalPath), Is.EqualTo(newId), "A media file may ask for its identity once before and once after transcription.");
});
}
}
@@ -31,4 +31,21 @@ public sealed class IndexedDocumentIdsTests
{
Assert.That(IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, "/tmp/test-data/report.pdf"), Is.EqualTo("d06c4351-2e57-2941-aa4c-d7a814bfa418"));
}
[Test]
public void ADocumentStoredUnderTheIdOfItsKeyKeepsItsChunkIds()
{
const string KEY = "/tmp/test-data/report.pdf";
var source = IndexedDocumentIds.CreateChunkIdSource(DATA_SOURCE_ID, KEY, IndexedDocumentIds.CreateParentId(DATA_SOURCE_ID, KEY), "3F9A0C");
Assert.That(IndexedDocumentIds.CreateChunkId(DATA_SOURCE_ID, source, 0), Is.EqualTo("7c9d2822-f9d1-a94e-8d83-30cea4f0856f"), "Files and mails which never moved must keep the ids they are stored under.");
}
[Test]
public void TheChunkIdOfAMovedDocumentIsPinned()
{
var source = IndexedDocumentIds.CreateChunkIdSource(DATA_SOURCE_ID, "/tmp/moved/report.pdf", "0b5c2f7e-3d41-4a8e-9f60-2c7d1e8a4b93", "3F9A0C");
Assert.That(IndexedDocumentIds.CreateChunkId(DATA_SOURCE_ID, source, 0), Is.EqualTo("aa597d9b-ede8-5449-80d0-f209cde0ecc0"), "A file at the old place with the same fingerprint must not get the chunk ids of the moved one.");
}
}
@@ -39,8 +39,7 @@ public sealed class LocalDirectoryFilePatternsTests
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase(";")]
[TestCase(" ; ; ")]
[TestCase("*.txt;")]
[TestCase("folder/*.txt")]
public void InvalidStoredPatternsFallBackWithoutRemovingExistingDocuments(string? storedPatterns)
{
@@ -49,60 +48,12 @@ public sealed class LocalDirectoryFilePatternsTests
Assert.That(dataSource.EffectiveFilePatterns, Is.EqualTo(DataSourceLocalDirectory.DEFAULT_FILE_PATTERNS));
}
[Test]
public void BatchExclusionsSurviveSettingsSerialization()
{
var written = new DataBatchProcessing { ExcludePatterns = "*.mp3;*.webm;draft-*" };
var json = JsonSerializer.Serialize(written, SettingsManager.JSON_OPTIONS);
var read = JsonSerializer.Deserialize<DataBatchProcessing>(json, SettingsManager.JSON_OPTIONS);
Assert.That(read!.ExcludePatterns, Is.EqualTo(written.ExcludePatterns));
}
[Test]
public void PatternsAreSplitAndTrimmed()
{
Assert.That(FileNamePatterns.Parse(" *.txt ; *.tf ; .gitignore "), Is.EqualTo(new[] { "*.txt", "*.tf", ".gitignore" }));
}
[TestCase("*.txt;")]
[TestCase(";*.txt")]
[TestCase(";; *.txt ; ; ")]
public void EmptyEntriesAreIgnoredWithoutBroadeningTheSelection(string patterns)
{
var parsed = FileNamePatterns.Parse(patterns);
var dataSource = new DataSourceLocalDirectory { FilePatterns = patterns };
var selection = new FolderFileSelection(parsed, [], false, false, FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
var canonical = new FolderFileSelection(["*.txt"], [], false, false, FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES);
Assert.Multiple(() =>
{
Assert.That(FileNamePatterns.IsValid(patterns), Is.True);
Assert.That(FileNamePatterns.Validate(patterns), Is.Null);
Assert.That(parsed, Is.EqualTo(new[] { "*.txt" }));
Assert.That(dataSource.EffectiveFilePatterns, Is.EqualTo(patterns), "Empty entries must not trigger the default document selection.");
Assert.That(selection.Selects("report.txt"), Is.True);
Assert.That(selection.Selects("report.md"), Is.False);
Assert.That(selection.Selects("recording.wav"), Is.False);
Assert.That(selection.Describe(), Is.EqualTo(canonical.Describe()), "Empty entries must not change the indexing selection signature.");
});
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase(";")]
[TestCase(" ; ;; ")]
public void IncludesStillRequireAtLeastOneNonEmptyPattern(string? patterns)
{
Assert.Multiple(() =>
{
Assert.That(FileNamePatterns.Parse(patterns), Is.Empty);
Assert.That(FileNamePatterns.IsValid(patterns), Is.False);
Assert.That(FileNamePatterns.Validate(patterns), Is.Not.Null);
});
}
[Test]
public void NormalizingPatternsRemovesCaseInsensitiveDuplicates()
{
@@ -124,7 +75,6 @@ public sealed class LocalDirectoryFilePatternsTests
{
Type = DataSourceType.LOCAL_DIRECTORY,
FilePatterns = "*.txt;*.tf;.gitignore",
ExcludePatterns = "secret-*;*.backup",
IncludeSubdirectories = false,
IncludeHiddenFilesAndFolders = true,
};
@@ -135,10 +85,8 @@ public sealed class LocalDirectoryFilePatternsTests
Assert.Multiple(() =>
{
Assert.That(json, Does.Not.Contain(nameof(DataSourceLocalDirectory.EffectiveFilePatterns)));
Assert.That(json, Does.Not.Contain(nameof(DataSourceLocalDirectory.EffectiveExcludePatterns)));
Assert.That(read, Is.TypeOf<DataSourceLocalDirectory>());
Assert.That(((DataSourceLocalDirectory)read!).FilePatterns, Is.EqualTo("*.txt;*.tf;.gitignore"));
Assert.That(((DataSourceLocalDirectory)read).ExcludePatterns, Is.EqualTo("secret-*;*.backup"));
Assert.That(((DataSourceLocalDirectory)read).IncludeSubdirectories, Is.False);
Assert.That(((DataSourceLocalDirectory)read).IncludeHiddenFilesAndFolders, Is.True);
});
@@ -471,22 +419,17 @@ public sealed class LocalDirectoryFilePatternsTests
[TestCase(":documents:")]
[TestCase(":DOCUMENTS:;*.tf")]
[TestCase("report-??.md")]
[TestCase("*.txt;")]
[TestCase(";*.txt;; *.tf ; ;")]
[TestCase(":documents:;;")]
public void DataSourceValidationAcceptsSimplePatterns(string patterns)
{
Assert.That(new DataSourceValidation().ValidateFilePatterns(patterns), Is.Null);
}
[TestCase("")]
[TestCase(" ; ;; ")]
[TestCase("*.txt;")]
[TestCase("**/*.txt")]
[TestCase("folder/*.txt")]
[TestCase(":document:")]
[TestCase("*.txt;:docs:")]
[TestCase(";*.txt;; :docs: ;")]
[TestCase("*.txt;;folder/*.md;")]
public void DataSourceValidationRejectsInvalidPatterns(string patterns)
{
Assert.That(new DataSourceValidation().ValidateFilePatterns(patterns), Is.Not.Null);
@@ -1,31 +0,0 @@
using AIStudio.Components;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
public sealed class ManagePandocDependencyTests
{
[Test]
public void TheInstallationPromptStaysHiddenWhileAvailabilityIsChecked()
{
Assert.That(ManagePandocDependency.ShouldShowInstallationPrompt(null), Is.False);
}
[TestCase(true)]
[TestCase(false)]
public void AnAvailableInstallationNeedsNoPrompt(bool isLocal)
{
var installation = new PandocInstallation(true, string.Empty, true, "3.7.0.2", isLocal);
Assert.That(ManagePandocDependency.ShouldShowInstallationPrompt(installation), Is.False);
}
[TestCase(true)]
[TestCase(false)]
public void AnUnavailableOrOutdatedInstallationShowsThePrompt(bool checkWasSuccessful)
{
var installation = new PandocInstallation(checkWasSuccessful, "Unavailable", false, string.Empty, false);
Assert.That(ManagePandocDependency.ShouldShowInstallationPrompt(installation), Is.True);
}
}
-192
View File
@@ -1,192 +0,0 @@
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
using AIStudio.Tools.Services.Indexing;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks combinations of media, transcripts, and ordinary Markdown against the current folder
/// selection rules. Transcript reuse exercises the shared implementation used by RAG and batch.
/// </summary>
public sealed class MediaFileSelectionTests
{
private const string TRANSCRIPT_TEXT = "# Transcript\nSpoken content from the recording.";
private string rootPath = string.Empty;
private string userFolder = string.Empty;
[SetUp]
public void CreateDirectory()
{
this.rootPath = Path.Combine(Path.GetTempPath(), $"ai-studio-media-selection-{Guid.NewGuid():N}");
this.userFolder = Path.Combine(this.rootPath, "user");
Directory.CreateDirectory(this.userFolder);
}
[TearDown]
public void DeleteDirectory() => Directory.Delete(this.rootPath, true);
[TestCase("*.wav", "*.md")]
[TestCase("*.wav;*.md", "*.md")]
[TestCase("*", "*.md")]
public async Task ExcludingMarkdownStillSelectsTheMedia(string includes, string excludes)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
Assert.That(this.Discover(includes, excludes), Is.EqualTo(new[] { mediaPath }));
}
[TestCase("*.md")]
[TestCase("*.wav;*.md")]
[TestCase("*")]
public async Task ExcludingMediaKeepsOrdinaryMarkdownAndTheIndependentTranscript(string includes)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
Assert.That(this.Discover(includes, "*.wav"), Is.EquivalentTo(new[]
{
MediaSidecarTranscripts.GetFilePath(mediaPath),
Path.Combine(this.userFolder, "notes.md"),
Path.Combine(this.userFolder, "readme.md"),
}));
}
[Test]
public async Task SelectingMediaAndMarkdownDoesNotProcessTheTranscriptTwice()
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
Assert.That(this.Discover("*.wav;*.md", string.Empty), Is.EquivalentTo(new[]
{
mediaPath,
Path.Combine(this.userFolder, "notes.md"),
Path.Combine(this.userFolder, "readme.md"),
}));
}
[TestCase(false)]
[TestCase(true)]
public async Task ExistingTranscriptsAreReusedWithoutAProviderOrARewrite(bool dataSourceTranscript)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
IMediaTranscriptLocation location = dataSourceTranscript
? this.TranscriptStore()
: MediaSidecarTranscripts.INSTANCE;
var slot = await location.ResolveAsync(mediaPath, CancellationToken.None);
Directory.CreateDirectory(Path.GetDirectoryName(slot.Path)!);
await File.WriteAllTextAsync(slot.Path, TRANSCRIPT_TEXT);
var originalWriteTime = File.GetLastWriteTimeUtc(slot.Path);
var calls = 0;
Task<MediaTranscriptionResult> Transcribe(string _, TranscriptionProvider __, CancellationToken ___)
{
calls++;
throw new InvalidOperationException("An existing transcript must not ask a provider.");
}
for (var attempt = 0; attempt < 2; attempt++)
{
var result = await PersistentMediaTranscript.ReadOrCreateAsync(
location, mediaPath, null, Transcribe, NullLogger.Instance, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.READY));
Assert.That(result.Reused, Is.True);
Assert.That(File.ReadAllText(result.TranscriptPath), Is.EqualTo(TRANSCRIPT_TEXT));
Assert.That(File.GetLastWriteTimeUtc(slot.Path), Is.EqualTo(originalWriteTime));
Assert.That(calls, Is.Zero);
});
}
}
[Test]
public async Task ChangedMediaDoesNotReuseAnOldDataSourceTranscriptEvenWithTheSameSizeAndWriteTime()
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
var store = this.TranscriptStore();
var originalSlot = await store.ResolveAsync(mediaPath, CancellationToken.None);
Directory.CreateDirectory(Path.GetDirectoryName(originalSlot.Path)!);
await File.WriteAllTextAsync(originalSlot.Path, TRANSCRIPT_TEXT);
var originalWriteTime = File.GetLastWriteTimeUtc(mediaPath);
await File.WriteAllBytesAsync(mediaPath, [4, 5, 6]);
File.SetLastWriteTimeUtc(mediaPath, originalWriteTime);
var changedSlot = await store.ResolveAsync(mediaPath, CancellationToken.None);
var result = await PersistentMediaTranscript.ReadOrCreateAsync(
store, mediaPath, null,
(_, _, _) => throw new InvalidOperationException("No provider was configured."),
NullLogger.Instance, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That(changedSlot.Key, Is.Not.EqualTo(originalSlot.Key));
Assert.That(result.Status, Is.EqualTo(PersistentMediaTranscriptStatus.NO_PROVIDER));
Assert.That(File.ReadAllText(originalSlot.Path), Is.EqualTo(TRANSCRIPT_TEXT));
});
}
[TestCase("*.wav", "*.md", false, false)]
[TestCase("*.md", "*.wav", true, true)]
[TestCase("*.wav;*.md", "", true, false)]
public async Task WatcherUsesTheSelectionOfTheMediaWhenDecidingAboutItsSidecar(
string includes, string excludes, bool markdownChangesMatter, bool transcriptChangesMatter)
{
var mediaPath = await this.CreateMediaAndDocumentsAsync();
var configuration = new FileSourceIndexer.DataSourceWatcherConfiguration(
this.userFolder,
false,
FilePatterns: FileNamePatterns.Parse(includes),
ExcludePatterns: FileExclusions.Parse(excludes));
Assert.Multiple(() =>
{
Assert.That(
FileSourceIndexer.IsRelevantWatcherPath(configuration, Path.Combine(this.userFolder, "notes.md"), WatcherChangeTypes.Changed),
Is.EqualTo(markdownChangesMatter));
Assert.That(
FileSourceIndexer.IsRelevantWatcherPath(configuration, MediaSidecarTranscripts.GetFilePath(mediaPath), WatcherChangeTypes.Changed),
Is.EqualTo(transcriptChangesMatter));
});
}
private async Task<string> CreateMediaAndDocumentsAsync()
{
var mediaPath = Path.Combine(this.userFolder, "test123.wav");
// These bytes cannot be decoded: reuse must find the transcript before asking a provider.
await File.WriteAllBytesAsync(mediaPath, [1, 2, 3]);
await File.WriteAllTextAsync(MediaSidecarTranscripts.GetFilePath(mediaPath), TRANSCRIPT_TEXT);
await File.WriteAllTextAsync(Path.Combine(this.userFolder, "notes.md"), "# Ordinary notes");
await File.WriteAllTextAsync(Path.Combine(this.userFolder, "readme.md"), "# Ordinary readme");
return mediaPath;
}
private IReadOnlyList<string> Discover(string includes, string excludes)
{
using var indexer = new FileSourceIndexer(null!, null!, null!, null!, NullLogger.Instance);
var result = indexer.GetInputFiles(new DataSourceLocalDirectory
{
Type = DataSourceType.LOCAL_DIRECTORY,
Path = this.userFolder,
FilePatterns = includes,
ExcludePatterns = excludes,
IncludeSubdirectories = false,
}, new DataSourceEmbeddingManifest(),
MediaTranscriptionAvailability.Allowed(new TranscriptionProvider { Id = "test-transcription-provider" }));
Assert.Multiple(() =>
{
Assert.That(result.Failures, Is.Empty);
Assert.That(result.IgnoredFiles, Is.Empty);
});
return result.Files.Select(file => file.FullName).ToArray();
}
private DataSourceTranscriptStore TranscriptStore() => new(
Path.Combine(this.rootPath, "data"), "d401ce54-9a42-4e78-85de-2402e07f7f7e");
}
+21 -2
View File
@@ -112,7 +112,7 @@ public sealed class OutdatedRunTests
}
[Test]
public void ASingleFileHasNoSelection()
public void RelocatingASingleFileStopsTheOldRunWithoutInvalidatingItsEmbeddings()
{
var dataSource = new DataSourceLocalFile
{
@@ -125,7 +125,26 @@ public sealed class OutdatedRunTests
FilePath = "/tmp/test-data/report.pdf",
};
Assert.That(DataSourceEmbeddingService.GetRunSignature(dataSource, TestEmbeddingProvider(), ConfidenceLevel.MEDIUM).SelectionSignature, Is.Empty, "The file is the one document of its data source, so there is nothing to choose among.");
var before = DataSourceEmbeddingService.GetRunSignature(dataSource, TestEmbeddingProvider(), ConfidenceLevel.MEDIUM);
var after = DataSourceEmbeddingService.GetRunSignature(dataSource with { FilePath = "/tmp/moved/report.pdf" }, TestEmbeddingProvider(), ConfidenceLevel.MEDIUM);
Assert.Multiple(() =>
{
Assert.That(DataSourceEmbeddingService.IsRunOutdated(before, after), Is.True);
Assert.That(after.EmbeddingSignature, Is.EqualTo(before.EmbeddingSignature));
});
}
[Test]
public void RelocatingAFolderStopsTheOldRunWithoutInvalidatingItsEmbeddings()
{
var dataSource = DataSource(ConfidenceLevel.LOW);
var before = DataSourceEmbeddingService.GetRunSignature(dataSource, TestEmbeddingProvider(), ConfidenceLevel.MEDIUM);
var after = DataSourceEmbeddingService.GetRunSignature(dataSource with { Path = "/tmp/moved" }, TestEmbeddingProvider(), ConfidenceLevel.MEDIUM);
Assert.Multiple(() =>
{
Assert.That(DataSourceEmbeddingService.IsRunOutdated(before, after), Is.True);
Assert.That(after.EmbeddingSignature, Is.EqualTo(before.EmbeddingSignature));
});
}
[Test]
+81
View File
@@ -0,0 +1,81 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks where a file dialog opens for a file or a folder which was moved or renamed.
/// </summary>
/// <remarks>
/// The native dialog cannot open a place which no longer exists and falls back to wherever it was
/// used last, which may be anywhere. The nearest folder which still exists is where the file or the
/// folder was moved from, so its new place is often close by.
/// </remarks>
[TestFixture]
public sealed class PathToolsTests
{
private string testRoot = string.Empty;
[SetUp]
public void CreateTestRoot()
{
this.testRoot = Path.Combine(Path.GetTempPath(), $"ai-studio-path-tools-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testRoot);
}
[TearDown]
public void DeleteTestRoot()
{
if (Directory.Exists(this.testRoot))
Directory.Delete(this.testRoot, true);
}
[Test]
public void AnExistingFolderOpensAsItIs()
{
var folder = Directory.CreateDirectory(Path.Combine(this.testRoot, "documents")).FullName;
Assert.That(PathTools.FindNearestExistingPath(folder), Is.EqualTo(folder));
}
[Test]
public void AnExistingFileOpensAsItIs()
{
var file = Path.Combine(this.testRoot, "report.pdf");
File.WriteAllText(file, "report");
Assert.That(PathTools.FindNearestExistingPath(file), Is.EqualTo(file));
}
[Test]
public void AMovedFolderOpensAtItsNearestFolderWhichStillExists()
{
var parent = Directory.CreateDirectory(Path.Combine(this.testRoot, "projects")).FullName;
var movedFolder = Path.Combine(parent, "2026", "documents");
Assert.That(PathTools.FindNearestExistingPath(movedFolder), Is.EqualTo(parent), "Every parent folder counts, not only the immediate one.");
}
[Test]
public void AMovedFileOpensAtItsFolder()
{
var file = Path.Combine(this.testRoot, "report.pdf");
Assert.That(PathTools.FindNearestExistingPath(file), Is.EqualTo(this.testRoot));
}
[TestCase("")]
[TestCase(" ")]
[TestCase("documents/report.pdf")]
public void APathWithoutAPlaceOpensWhereverTheDialogWants(string path)
{
Assert.That(PathTools.FindNearestExistingPath(path), Is.Null);
}
[Test]
public void APathOfAnotherOperatingSystemOpensWhereverTheDialogWants()
{
var path = OperatingSystem.IsWindows() ? "/Users/somebody/documents" : @"C:\Users\somebody\documents";
Assert.That(PathTools.FindNearestExistingPath(path), Is.Null, "A backup from another computer must not make the dialog open at a folder which happens to exist here.");
}
}
+93
View File
@@ -0,0 +1,93 @@
using AIStudio.Tools.Databases.VectorStore;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Keeps the identities and locations of existing vectors. New embeddings are forbidden: these
/// tests reconnect previously indexed files and must never contact an embedding provider.
/// </summary>
internal sealed class RelocationVectorStore() : VectorStoreClient("Relocation test vectors", string.Empty)
{
public Dictionary<string, string> Locations { get; } = new(StringComparer.Ordinal);
public List<string> RelocatedPaths { get; } = [];
public TaskCompletionSource<object?> RelocationStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool BlockNextRelocation { get; set; }
public int CanceledRelocations { get; private set; }
public int DeletedStores { get; private set; }
public int DeletedDocuments { get; private set; }
public int InsertedBatches { get; private set; }
public override async Task RelocateEmbeddingFileAsync(string storeName, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token)
{
token.ThrowIfCancellationRequested();
Assert.That(this.Locations.ContainsKey(parentFileId), Is.True, "Relocation must address an existing vector identity.");
this.Locations[parentFileId] = absolutePath;
this.RelocatedPaths.Add(absolutePath);
if (!this.BlockNextRelocation)
return;
this.BlockNextRelocation = false;
this.RelocationStarted.TrySetResult(null);
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, token);
}
catch (OperationCanceledException)
{
this.CanceledRelocations++;
throw;
}
}
public override Task<VectorStoreEnsureResult> EnsureVectorStoreExists(string storeName, string dataSourceName, int vectorSize, CancellationToken token) => Task.FromResult(new VectorStoreEnsureResult(false));
public override Task<bool> VectorStoreExists(string storeName, CancellationToken token) => Task.FromResult(true);
public override Task InsertEmbedding(string storeName, IReadOnlyList<VectorStoragePoint> points, CancellationToken token)
{
this.InsertedBatches++;
throw new InvalidOperationException("Existing unchanged files must not be embedded again.");
}
public override Task DeleteEmbeddingByFile(string storeName, string filePath, string parentFileId, CancellationToken token)
{
this.DeletedDocuments++;
foreach (var id in this.Locations.Where(entry => entry.Key == parentFileId || entry.Value == filePath).Select(entry => entry.Key).ToArray())
this.Locations.Remove(id);
return Task.CompletedTask;
}
public override Task DeleteVectorStore(string storeName, CancellationToken token)
{
this.DeletedStores++;
this.Locations.Clear();
return Task.CompletedTask;
}
public override Task OptimizeVectorStore(string storeName, CancellationToken token) => Task.CompletedTask;
public override Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, CancellationToken token) => throw new NotSupportedException();
public override Task<IReadOnlyList<VectorSearchResult>> SearchEmbeddingAsync(string storeName, IReadOnlyList<float> vector, int maxMatches, VectorSearchFilter filter, CancellationToken token) => throw new NotSupportedException();
public override Task<VectorStoreBackupPreparation> PrepareBackupAsync(CancellationToken token) => throw new NotSupportedException();
public override Task<long> CopyStoreForBackupAsync(string backupId, string storeName, CancellationToken token) => throw new NotSupportedException();
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
await Task.CompletedTask;
yield break;
}
public override void Dispose()
{
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.Reflection;
using MudBlazor;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Controls a dialog result without rendering a Blazor circuit. The real dialog save handler and
/// the real parent edit handler still run, with the same parameters and result as in the UI.
/// </summary>
internal sealed class TestDataSourceDialog
{
private readonly TaskCompletionSource<DialogResult?> result = new(TaskCreationOptions.RunContinuationsAsynchronously);
public IDialogService Service { get; }
public IMudDialogInstance Instance { get; }
public DialogParameters? Parameters { get; private set; }
public Type? ComponentType { get; private set; }
public string? Title { get; private set; }
public int ShowCount { get; private set; }
public TestDataSourceDialog()
{
var reference = Create<IDialogReference>((method, _) => method.Name == "get_Result"
? this.result.Task
: throw new NotSupportedException(method.Name));
this.Service = Create<IDialogService>((method, arguments) =>
{
if (method.Name != "ShowAsync")
throw new NotSupportedException(method.Name);
ArgumentNullException.ThrowIfNull(arguments);
this.ShowCount++;
this.ComponentType = method.GetGenericArguments().Single();
this.Title = arguments.OfType<string>().FirstOrDefault();
this.Parameters = arguments.OfType<DialogParameters>().Single();
return Task.FromResult(reference);
});
this.Instance = Create<IMudDialogInstance>((method, arguments) =>
{
switch (method.Name)
{
case "Close":
this.Complete(arguments!.OfType<DialogResult>().Single());
return null;
case "Cancel":
this.Complete(DialogResult.Cancel());
return null;
default:
throw new NotSupportedException(method.Name);
}
});
}
public void Complete(DialogResult dialogResult) => this.result.TrySetResult(dialogResult);
private static T Create<T>(Func<MethodInfo, object?[]?, object?> invoke) where T : class
{
var proxy = DispatchProxy.Create<T, DialogCallProxy>();
((DialogCallProxy)(object)proxy).InvokeCall = invoke;
return proxy;
}
}
// DispatchProxy generates a subclass, so this type must be public and cannot be sealed.
public class DialogCallProxy : DispatchProxy
{
public Func<MethodInfo, object?[]?, object?> InvokeCall { get; set; } = null!;
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) => this.InvokeCall(targetMethod!, args);
}
@@ -92,7 +92,7 @@ public sealed class SemanticSearchToolUnreachableTests
}
[Test]
public void TheResultNamesAnUnreachableDataSourceWithoutItsID()
public void TheResultNamesAnUnreachableDataSourceWithoutItsId()
{
var result = SemanticSearchTool.DescribeUnreachableResult(INTRANET);
+60 -3
View File
@@ -62,6 +62,49 @@ public sealed class UnreachableDataSourceTests
AssertTheIndexWasKept(outcome, statuses, manifest, 2, folder);
}
[Test]
public async Task AMissingRelocationTargetKeepsTheOriginalIndex()
{
var folder = Path.Combine(this.testRoot, "moved-but-unavailable");
var (outcome, statuses, manifest) = await RunAsync(Folder(folder), Path.Combine(this.testRoot, "original", "report.pdf"));
AssertTheIndexWasKept(outcome, statuses, manifest, 1, folder);
}
[TestCase(false)]
[TestCase(true)]
public async Task AReadableEmptyMountDirectoryKeepsItsIndex(bool emptySubfolder)
{
var folder = Path.Combine(this.testRoot, "mounted-drive");
Directory.CreateDirectory(emptySubfolder ? Path.Combine(folder, "documents") : folder);
var unfinishedFile = Path.Combine(folder, "unfinished.pdf");
var (outcome, statuses, manifest) = await RunAsync(Folder(folder), [Path.Combine(folder, "report.pdf")], [unfinishedFile]);
AssertTheIndexWasKept(outcome, statuses, manifest, 1, folder);
Assert.Multiple(() =>
{
Assert.That(manifest.UnfinishedFiles, Does.Contain(unfinishedFile), "An unmounted drive must not discard interrupted work either.");
Assert.That(statuses[^1].LastError, Does.Contain("appears empty").And.Contain("remove this data source"));
});
}
[Test]
public async Task ANewEmptyFolderCanFinishWithoutAnIndex()
{
var folder = Path.Combine(this.testRoot, "new-empty-folder");
Directory.CreateDirectory(folder);
var (outcome, statuses, manifest) = await RunAsync(Folder(folder));
Assert.Multiple(() =>
{
Assert.That(outcome, Is.EqualTo(IndexedRunOutcome.DONE));
Assert.That(statuses[^1].State, Is.EqualTo(DataSourceEmbeddingState.COMPLETED));
Assert.That(manifest.Files, Is.Empty);
});
}
[Test]
public async Task AMissingFileKeepsItsIndex()
{
@@ -130,6 +173,15 @@ public sealed class UnreachableDataSourceTests
Assert.That(goneFiles, Is.EquivalentTo([deleted, besideSubfolder]), "A folder whose name only begins like the one which could not be read is no part of it.");
}
[Test]
public void AnUnreadableDriveRootKeepsItsIndexedFiles()
{
var root = Path.GetPathRoot(Path.GetFullPath(Path.GetTempPath()))!;
var file = Path.Combine(root, "documents", "notes.md");
Assert.That(FileSourceIndexer.GetGoneFiles([file], [], [root]), Is.Empty, "A drive root already ends in its separator; adding another would fail to protect its files.");
}
private static DataSourceLocalDirectory Folder(string path) => new()
{
Num = 1,
@@ -152,6 +204,7 @@ public sealed class UnreachableDataSourceTests
Assert.That(statuses[^1].State, Is.EqualTo(DataSourceEmbeddingState.FAILED));
Assert.That(statuses[^1].IndexedDocuments, Is.EqualTo(indexedFiles), "The index still holds these files, and the page has to say so.");
Assert.That(statuses[^1].LastError, Does.Contain(unreachablePath));
Assert.That(statuses[^1].Attention, Is.EqualTo(DataSourceAttention.LOCATION_UNREACHABLE), "The embeddings page offers to edit the data source, e.g. to select the new location of a moved folder.");
});
}
@@ -181,12 +234,16 @@ public sealed class UnreachableDataSourceTests
var statuses = new List<DataSourceEmbeddingStatus>();
var manifest = new DataSourceEmbeddingManifest { SourceHash = STORED_SOURCE_HASH };
foreach (var file in indexedFiles)
manifest.Files[file] = new EmbeddedFileRecord("FINGERPRINT", 1024, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, 4);
manifest.Files[file] = new EmbeddedFileRecord("FINGERPRINT", 1024, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, 4,
RelativePath: Path.GetFileName(file), ParentFileId: IndexedDocumentIds.CreateParentId(dataSource.Id, file));
manifest.UnfinishedFiles.UnionWith(unfinishedFiles);
var scannedFile = Path.Combine(Path.GetDirectoryName(indexedFiles[0])!, "scan.pdf");
manifest.PermanentFailures[scannedFile] = new PermanentIndexingFailureRecord("FINGERPRINT", FileExtractionErrorCode.NO_TEXT_EXTRACTED, "No text could be read.", DateTimeOffset.UnixEpoch);
if (indexedFiles.Length > 0)
{
var scannedFile = Path.Combine(Path.GetDirectoryName(indexedFiles[0])!, "scan.pdf");
manifest.PermanentFailures[scannedFile] = new PermanentIndexingFailureRecord("FINGERPRINT", FileExtractionErrorCode.NO_TEXT_EXTRACTED, "No text could be read.", DateTimeOffset.UnixEpoch);
}
var embeddingProvider = new EmbeddingProvider(1, EMBEDDING_ID, "Test embeddings", LLMProviders.OPEN_AI, new("text-embedding-3-small", "text-embedding-3-small"));
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
+144 -4
View File
@@ -11,9 +11,9 @@ use qdrant_edge::external::uuid::Uuid;
use qdrant_edge::{
Condition, Distance, EdgeConfig, EdgeOptimizersConfig, EdgeShard, EdgeVectorParams,
FieldCondition, Filter, HasIdCondition, HnswIndexConfig, Match, MatchValue, NamedQuery,
Payload, PointId, PointInsertOperations, PointOperations, PointStruct, QueryEnum,
Payload, PayloadOps, PointId, PointInsertOperations, PointOperations, PointStruct, QueryEnum,
QueryRequest, ScoredPoint, ScoringQuery, UpdateOperation, ValueVariants, VectorInternal,
Vectors, WithPayloadInterface, WithVector,
SetPayloadOp, Vectors, WithPayloadInterface, WithVector,
};
use serde::{Deserialize, Serialize};
use tauri::Manager;
@@ -131,6 +131,17 @@ pub struct SearchQdrantEdgeEmbeddingRequest {
pub struct DeleteQdrantEdgeEmbeddingByFileRequest {
pub store_name: String,
pub file_path: String,
pub parent_file_id: String,
}
#[derive(Deserialize)]
pub struct RelocateQdrantEdgeEmbeddingFileRequest {
pub store_name: String,
pub parent_file_id: String,
pub absolute_path: String,
pub file_name: String,
pub relative_path: String,
pub fingerprint: String,
}
#[derive(Deserialize)]
@@ -459,11 +470,17 @@ impl QdrantEdgeDatabase {
.collect())
}
fn delete_embedding_by_file(&mut self, store_name: &str, file_path: &str) -> QdrantEdgeResult<()> {
fn delete_embedding_by_file(&mut self, store_name: &str, file_path: &str, parent_file_id: &str) -> QdrantEdgeResult<()> {
let Some(shard) = self.get_existing_store(store_name)? else {
return Ok(());
};
// The location may already have changed if a relocation stopped before SQLite caught up.
if !parent_file_id.is_empty() {
shard.update(UpdateOperation::PointOperation(
PointOperations::DeletePointsByFilter(match_keyword_filter("parent_file_id", parent_file_id)?),
))?;
}
shard.update(UpdateOperation::PointOperation(
PointOperations::DeletePointsByFilter(match_keyword_filter("file_path", file_path)?),
))?;
@@ -471,6 +488,33 @@ impl QdrantEdgeDatabase {
Ok(())
}
fn relocate_embedding_file(&mut self, request: RelocateQdrantEdgeEmbeddingFileRequest) -> QdrantEdgeResult<()> {
if request.parent_file_id.is_empty() {
return Err("Relocating a document requires its parent file id.".into());
}
let Some(shard) = self.get_existing_store(&request.store_name)? else {
return Err("The vector store for the relocated document is missing.".into());
};
let payload = json!({
"file_path": request.absolute_path,
"absolute_path": request.absolute_path,
"file_name": request.file_name,
"relative_path": request.relative_path,
"fingerprint": request.fingerprint,
});
// Parent and chunk ids are identities, even when they were originally derived from a path.
// Keep them and the vectors intact; only the document's location changes.
shard.update(UpdateOperation::PayloadOperation(PayloadOps::SetPayload(SetPayloadOp {
payload: qdrant_edge::external::serde_json::from_value(payload)?,
points: None,
filter: Some(match_keyword_filter("parent_file_id", &request.parent_file_id)?),
key: None,
})))?;
shard.flush()?;
Ok(())
}
fn optimize_store(&mut self, store_name: &str) -> QdrantEdgeResult<()> {
let Some(shard) = self.get_existing_store(store_name)? else {
return Ok(());
@@ -776,7 +820,13 @@ pub async fn search_qdrant_edge_embeddings(Json(request): Json<SearchQdrantEdgeE
pub async fn delete_qdrant_edge_embedding_by_file(Json(request): Json<DeleteQdrantEdgeEmbeddingByFileRequest>) -> Json<QdrantEdgeResponse<()>> {
run_qdrant_edge_request(move |database| {
database.delete_embedding_by_file(&request.store_name, &request.file_path)
database.delete_embedding_by_file(&request.store_name, &request.file_path, &request.parent_file_id)
}).await
}
pub async fn relocate_qdrant_edge_embedding_file(Json(request): Json<RelocateQdrantEdgeEmbeddingFileRequest>) -> Json<QdrantEdgeResponse<()>> {
run_qdrant_edge_request(move |database| {
database.relocate_embedding_file(request)
}).await
}
@@ -1489,6 +1539,49 @@ mod tests {
);
}
#[test]
fn relocating_a_document_preserves_its_vectors_and_can_be_retried() {
let test_directory = test_directory("relocation");
let mut database = QdrantEdgeDatabase::new(test_directory.clone());
let mut point = test_point(POINT_X, search_vector());
point.parent_file_id = "original-parent".to_string();
point.page_number = Some(2);
database.insert_embedding(SEARCH_STORE, vec![point]).unwrap();
for _ in 0..2 {
database.relocate_embedding_file(RelocateQdrantEdgeEmbeddingFileRequest {
store_name: SEARCH_STORE.to_string(),
parent_file_id: "original-parent".to_string(),
absolute_path: "/tmp/moved/renamed.md".to_string(),
file_name: "renamed.md".to_string(),
relative_path: "renamed.md".to_string(),
fingerprint: "new-fingerprint".to_string(),
}).unwrap();
}
drop(database);
let mut database = QdrantEdgeDatabase::new(test_directory.clone());
let found = database.search_embedding(SEARCH_STORE, search_vector(), 10, None).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].point_id, POINT_X);
assert_eq!(found[0].parent_file_id, "original-parent");
assert_eq!(found[0].file_path, "/tmp/moved/renamed.md");
assert_eq!(found[0].absolute_path, "/tmp/moved/renamed.md");
assert_eq!(found[0].file_name, "renamed.md");
assert_eq!(found[0].relative_path, "renamed.md");
assert_eq!(found[0].fingerprint, "new-fingerprint");
assert_eq!(found[0].text, "Some text.");
assert_eq!(found[0].page_number, Some(2));
assert!((found[0].score - 1.0).abs() < 0.0001);
// SQLite may still know the old path when a relocation was interrupted. Its stable parent
// id must be enough to remove the vectors if the document changed before the next retry.
database.delete_embedding_by_file(SEARCH_STORE, "/tmp/test-data/notes.md", "original-parent").unwrap();
assert!(database.search_embedding(SEARCH_STORE, search_vector(), 10, None).unwrap().is_empty());
drop(database);
fs::remove_dir_all(test_directory).unwrap();
}
#[test]
fn obsolete_wal_segments_keeps_the_newest_closed_segment() {
let file_names = ["closed-120", "open-7", "closed-0", "tmp-open-8", "closed-40", ".wal", "first-index", "closed-abc"].map(String::from);
@@ -1651,6 +1744,53 @@ mod tests {
fs::remove_dir_all(data_directory).unwrap();
}
#[test]
fn a_restored_store_can_relocate_foreign_paths_without_replacing_its_vectors() {
for old_path in [r"E:\Documents\nested\notes.md", r"\\server\share\nested\notes.md", "/Volumes/External drive/nested/notes.md"] {
let data_directory = test_directory("backup-relocation");
let base_path = data_directory.join("databases").join("vector_database");
let mut database = QdrantEdgeDatabase::new(base_path);
let mut point = test_point(POINT_X, vec![1.0, 0.0, 0.0]);
point.parent_file_id = "original-parent".to_string();
point.file_path = old_path.to_string();
point.absolute_path = old_path.to_string();
point.embedded_at_utc = "2026-10-01T12:00:00Z".to_string();
database.insert_embedding(SEARCH_STORE, vec![point]).unwrap();
let preparation = database.prepare_backup(&data_directory).unwrap();
database.copy_store_for_backup(SEARCH_STORE, &data_directory, &preparation.backup_id).unwrap();
let restored_base = PathBuf::from(&preparation.staging_path).join("databases").join("vector_database");
let mut restored = QdrantEdgeDatabase::new(restored_base.clone());
let new_path = data_directory.join("mounted drive").join("nested").join("notes.md").to_string_lossy().to_string();
restored.relocate_embedding_file(RelocateQdrantEdgeEmbeddingFileRequest {
store_name: SEARCH_STORE.to_string(),
parent_file_id: "original-parent".to_string(),
absolute_path: new_path.clone(),
file_name: "notes.md".to_string(),
relative_path: Path::new("nested").join("notes.md").to_string_lossy().to_string(),
fingerprint: "relocated-fingerprint".to_string(),
}).unwrap();
drop(restored);
let mut restored = QdrantEdgeDatabase::new(restored_base);
let found = restored.search_embedding(SEARCH_STORE, vec![1.0, 0.0, 0.0], 10, None).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].point_id, POINT_X);
assert_eq!(found[0].parent_file_id, "original-parent");
assert_eq!(found[0].file_path, new_path);
assert_eq!(found[0].absolute_path, new_path);
assert_eq!(found[0].text, "Some text.");
assert_eq!(found[0].embedded_at_utc, "2026-10-01T12:00:00Z");
assert!((found[0].score - 1.0).abs() < 0.0001);
let original = database.search_embedding(SEARCH_STORE, vec![1.0, 0.0, 0.0], 10, None).unwrap();
assert_eq!(original[0].file_path, old_path, "relocating a restored copy must not change the original store");
drop(restored);
drop(database);
fs::remove_dir_all(data_directory).unwrap();
}
}
#[test]
fn a_backup_id_cannot_name_another_directory() {
let data_directory = Path::new("/data");
+1
View File
@@ -42,6 +42,7 @@ fn create_router() -> Router {
.route("/system/qdrant-edge/insert", post(crate::qdrant_edge_database::insert_qdrant_edge_embedding))
.route("/system/qdrant-edge/search", post(crate::qdrant_edge_database::search_qdrant_edge_embeddings))
.route("/system/qdrant-edge/delete-file", post(crate::qdrant_edge_database::delete_qdrant_edge_embedding_by_file))
.route("/system/qdrant-edge/relocate-file", post(crate::qdrant_edge_database::relocate_qdrant_edge_embedding_file))
.route("/system/qdrant-edge/optimize", post(crate::qdrant_edge_database::optimize_qdrant_edge_store))
.route("/system/qdrant-edge/delete-store", post(crate::qdrant_edge_database::delete_qdrant_edge_store))
.route("/system/qdrant-edge/backup/prepare", post(crate::qdrant_edge_database::prepare_qdrant_edge_backup))