Compare commits

..
95 changed files with 2876 additions and 3280 deletions

No files matched your search

@@ -3,7 +3,7 @@
@using AIStudio.Settings.DataModel
@using AIStudio.Tools.Rust
<MudText Typo="Typo.h5" Class="mb-3">
<MudText Typo="Typo.h6" Class="mb-3">
@T("Input")
</MudText>
@@ -18,37 +18,40 @@
</MudJustifiedText>
}
<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"/>
<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>
<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.")
</MudJustifiedText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Images in JPG, PNG, WebP, or GIF format are given directly to the model, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start.")
</MudJustifiedText>
<MudStack Class="mb-3">
<MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune">
@(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings"))
</MudButton>
<MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<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")"/>
<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")"/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@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.")
@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.")
</MudJustifiedText>
@* 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"/>
<div class="mt-4">
<FileExclusionEditor @bind-Text="@this.excludePatterns" Disabled="@this.isProcessingBatch" 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>
<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.h5" Class="mb-3 mt-6">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Instructions")
</MudText>
@@ -134,7 +137,7 @@ else
}
}
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Output")
</MudText>
@@ -189,7 +192,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.h5" Class="mb-3 mt-6">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Processing pace")
</MudText>
@@ -230,7 +233,7 @@ else
@if (this.fileResults.Count > 0)
{
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Progress")
</MudText>
@@ -1,87 +1,18 @@
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Tools.Media;
using AIStudio.Tools.Rust;
namespace AIStudio.Assistants.BatchProcessing;
public partial class AssistantBatchProcessing
{
/// <summary>
/// What one file of the batch gives to the model: either text or an image.
/// Loads a document through the Rust content stream or resolves a persistent
/// transcript for an audio or video file.
/// </summary>
private readonly record struct BatchInput(string Text, FileAttachmentImage? Image);
/// <summary>
/// Loads a document through the Rust content stream, resolves a persistent
/// transcript for an audio or video file, or prepares an image for the model.
/// </summary>
/// <returns>The input for the model, or <c>null</c> when the file already got its final status.</returns>
private async Task<BatchInput?> LoadInputContentAsync(BatchProcessingFileResult fileResult, CancellationToken token)
private Task<string?> LoadInputContentAsync(BatchProcessingFileResult fileResult, CancellationToken token)
{
if (IsImage(fileResult.FilePath))
return this.LoadImage(fileResult) is { } image ? new BatchInput(string.Empty, image) : null;
var text = IsTranscribableMedia(fileResult.FilePath)
? await this.LoadMediaTranscriptAsync(fileResult, token)
: await this.LoadDocumentContentAsync(fileResult, token);
return text is null ? null : new BatchInput(text, null);
}
/// <summary>
/// Checks an image before it goes to the model.
/// </summary>
/// <remarks>
/// The request itself would only skip an image it cannot send, with a message, and ask the model
/// anyway. The model would then answer about nothing, and the file would count as done, so that
/// continuing the run would skip it as well. A failed image is processed again when the run is
/// continued, e.g., with a model that can read images.
///
/// How many images a model accepts needs no check of its own: every file gets a thread of its own,
/// see CallAIAsync, so each request carries exactly one image, in every round with the model. A
/// limit of one image is therefore always met, and SupportsImageInput already refuses a limit of zero.
///
/// Unlike the text of documents and transcripts, an image does not pass the prompt injection
/// filter of the runtime, which reads text only. Images in a chat do not either. Instructions
/// within an image therefore meet only the safeguards behind the filter: the system prompt tells
/// the model to ignore them, a thread which read internal content, such as a mailbox, may no
/// longer send data out, and without free address choice, its default, the Read Web Page tool
/// refuses an address which appears only in the image.
/// </remarks>
private FileAttachmentImage? LoadImage(BatchProcessingFileResult fileResult)
{
if (!this.ProviderSettings.SupportsImageInput())
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The selected model cannot read images. Select a model with image support to process this image."));
return null;
}
if (!FileTypes.IsAllowedPath(fileResult.FilePath, FileTypes.MODEL_IMAGE))
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("This image format cannot be given to the model. Please convert the image to JPG, PNG, WebP, or GIF."));
return null;
}
long fileSize;
try
{
fileSize = new FileInfo(fileResult.FilePath).Length;
}
catch (Exception e)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("Was not able to read the file: {0}"), e.Message), e);
return null;
}
var maxImageSizeBytes = IImageSourceExtensions.MaxImageSizeBytesFor(this.ProviderSettings.UsedLLMProvider);
if (fileSize > maxImageSizeBytes)
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, string.Format(T("The image is larger than {0} MB, which is the most the selected provider accepts for one image."), (maxImageSizeBytes / 1_000_000d).ToString("0.#", Tools.PluginSystem.I18N.I.Culture)));
return null;
}
return new FileAttachmentImage(fileResult.FileName, fileResult.FilePath, fileSize);
return IsTranscribableMedia(fileResult.FilePath)
? this.LoadMediaTranscriptAsync(fileResult, token)
: this.LoadDocumentContentAsync(fileResult, token);
}
private async Task<string?> LoadDocumentContentAsync(BatchProcessingFileResult fileResult, CancellationToken token)
@@ -178,7 +109,5 @@ public partial class AssistantBatchProcessing
private static bool IsTranscribableMedia(string filePath) => PersistentMediaTranscript.IsMediaPath(filePath);
private static bool IsImage(string filePath) => FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE);
private static bool HasReusableTranscript(string mediaFilePath) => MediaSidecarTranscripts.HasReusableTranscript(mediaFilePath);
}
@@ -56,8 +56,6 @@ public partial class AssistantBatchProcessing
# Task description
You are a batch document processing agent. Each request contains exactly one DOCUMENT.
Your task is to process this DOCUMENT strictly according to the instructions below.
A DOCUMENT may also be an image attached to the request. Then, the content of the
DOCUMENT is what the image shows, including any text within it.
# Scope and precedence
Use only information explicitly contained in the DOCUMENT and the instructions.
@@ -90,20 +88,11 @@ public partial class AssistantBatchProcessing
""";
}
private static string BuildImageUserPrompt(string fileName)
{
return $"""
# DOCUMENT
File name: {fileName}
Content: The DOCUMENT is the image attached to this request.
""";
}
/// <param name="fileName">The name of the document being processed.</param>
/// <param name="input">The text or the image handed to the model.</param>
/// <param name="fileContent">The content handed to the model.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The answer of the model, and which tools it used to get there.</returns>
private async Task<(string Answer, string UsedTools)> CallAIAsync(string fileName, BatchInput input, CancellationToken token)
private async Task<(string Answer, string UsedTools)> CallAIAsync(string fileName, string fileContent, CancellationToken token)
{
//
// Every file of the batch gets the tools the user picked for the job. The batch builds its
@@ -131,9 +120,10 @@ public partial class AssistantBatchProcessing
};
this.AssignRuntimeIdentity(chatThread);
var userPrompt = input.Image is null
? new ContentText { Text = BuildUserPrompt(fileName, input.Text) }
: new ContentText { Text = BuildImageUserPrompt(fileName), FileAttachments = [input.Image] };
var userPrompt = new ContentText
{
Text = BuildUserPrompt(fileName, fileContent),
};
chatThread.Blocks.Add(new ContentBlock
{
@@ -1,8 +1,6 @@
using System.Diagnostics;
using System.Globalization;
using AIStudio.Settings;
namespace AIStudio.Assistants.BatchProcessing;
public partial class AssistantBatchProcessing
@@ -44,7 +42,6 @@ public partial class AssistantBatchProcessing
}
this.PrepareFileResults(resolvedOutputDirectory, files, previousLog, previousResults);
await this.WarnAboutUnreadableImagesAsync();
await this.CheckpointAssistantSession();
await this.RunBatchAsync(resolvedOutputDirectory);
}
@@ -91,27 +88,6 @@ public partial class AssistantBatchProcessing
}
}
/// <summary>
/// Tells the user before the first file when the images of the run will fail, because the
/// selected model cannot read images.
/// </summary>
/// <remarks>
/// Counts only the images which this run still processes. Images done by an earlier run of a
/// continued batch keep their results and do not fail.
/// </remarks>
private async Task WarnAboutUnreadableImagesAsync()
{
var numImages = this.fileResults.Count(fileResult => fileResult.Status is not BatchProcessingFileStatus.DONE && IsImage(fileResult.FilePath));
if (numImages == 0 || this.ProviderSettings.SupportsImageInput())
return;
var warning = numImages == 1
? T("The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual.")
: string.Format(T("The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual."), numImages);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.ImageNotSupported, warning));
}
/// <summary>
/// Processes all documents which are not restored from a previous run.
/// </summary>
@@ -212,14 +188,14 @@ public partial class AssistantBatchProcessing
/// </remarks>
private async Task ProcessOneFileAsync(BatchProcessingFileResult fileResult, string resolvedOutputDirectory, CancellationToken token)
{
var input = await this.LoadInputContentAsync(fileResult, token);
if (input is null)
var fileContent = await this.LoadInputContentAsync(fileResult, token);
if (fileContent is null)
return;
string aiAnswer;
try
{
(aiAnswer, fileResult.UsedTools) = await this.CallAIAsync(fileResult.FileName, input.Value, token);
(aiAnswer, fileResult.UsedTools) = await this.CallAIAsync(fileResult.FileName, fileContent, token);
}
catch (OperationCanceledException)
{
@@ -241,17 +217,6 @@ public partial class AssistantBatchProcessing
return;
}
//
// The request reads the image anew in every round with the model, e.g., after each tool
// call, and quietly leaves out an image which is gone by then. The answer would be about
// nothing and must not count as a result, so that continuing the run processes the image again:
//
if (input.Value.Image is { Exists: false })
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The image was no longer available while the model was asked about it."));
return;
}
if (string.IsNullOrWhiteSpace(aiAnswer))
{
this.FinishFileResult(fileResult, BatchProcessingFileStatus.FAILED, T("The AI answer was empty."));
@@ -1,4 +1,3 @@
using AIStudio.Settings;
using AIStudio.Tools.Media;
namespace AIStudio.Assistants.BatchProcessing;
@@ -83,18 +82,39 @@ public partial class AssistantBatchProcessing
private IReadOnlyList<string> FindInputFiles(string resolvedOutputDirectory)
{
var selection = new FolderFileSelection(FileNamePatterns.Parse(this.filePatterns), FileExclusions.Parse(this.excludePatterns), this.includeSubdirectories, this.includeHiddenFilesAndFolders, this.maxFileSizeMegabytes);
var outputFolder = new BatchProcessingOutputFolder(this.inputDirectory, resolvedOutputDirectory, this.ResolveResultsFileName());
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
this.numTooLargeFiles = 0;
var normalizedInputDirectory = TrimDirectorySeparator(Path.GetFullPath(this.inputDirectory));
var normalizedOutputDirectory = TrimDirectorySeparator(Path.GetFullPath(resolvedOutputDirectory));
// When the output folder is a folder of its own, we skip everything
// inside it. When it is the input folder itself, we must not skip the
// whole folder: we would not find any document at all. We then skip
// our own output artifacts instead.
var isOutputSeparateFolder = !string.Equals(normalizedInputDirectory, normalizedOutputDirectory, StringComparison.OrdinalIgnoreCase);
// The separator is essential: without it, an output folder named 'out'
// would also exclude a document named 'output-notes.md':
var outputDirectoryPrefix = normalizedOutputDirectory + Path.DirectorySeparatorChar;
foreach (var file in selection.EnumerateCandidates(
this.inputDirectory,
includeDirectory: outputFolder.Enters,
includeDirectory: directory => !isOutputSeparateFolder
|| !string.Equals(TrimDirectorySeparator(Path.GetFullPath(directory)), normalizedOutputDirectory, StringComparison.OrdinalIgnoreCase),
reportInaccessibleDirectory: (directory, exception) =>
throw new IOException($"Could not access directory '{directory}'.", exception)))
{
var normalizedFile = Path.GetFullPath(file);
if (!selection.Selects(normalizedFile) || IsTranscriptArtifact(normalizedFile) || outputFolder.IsOutputOfRun(normalizedFile))
if (!selection.Selects(normalizedFile) || IsTranscriptArtifact(normalizedFile))
continue;
if (isOutputSeparateFolder)
{
if (normalizedFile.StartsWith(outputDirectoryPrefix, StringComparison.OrdinalIgnoreCase))
continue;
}
else if (this.IsOwnOutputArtifact(normalizedFile))
continue;
//
@@ -116,6 +136,25 @@ public partial class AssistantBatchProcessing
return [.. files];
}
private static string TrimDirectorySeparator(string path) => path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
/// <summary>
/// Checks whether a file is an output artifact of this assistant. We need
/// this when the output folder is the input folder: without it, the results
/// of a previous run would be processed as documents.
/// </summary>
private bool IsOwnOutputArtifact(string filePath)
{
var fileName = Path.GetFileName(filePath);
if (string.Equals(fileName, LOG_FILENAME, StringComparison.OrdinalIgnoreCase))
return true;
if (string.Equals(fileName, this.ResolveResultsFileName(), StringComparison.OrdinalIgnoreCase))
return true;
return fileName.EndsWith(RESULT_FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Checks for persistent or interrupted media transcript artifacts. They
/// always live beside their source file, independently of the output folder.
@@ -178,17 +217,6 @@ public partial class AssistantBatchProcessing
return null;
}
//
// A model that cannot read images would get the text of the request alone, without the
// image, and answer about nothing. Such images fail one by one, see LoadImage, while the
// documents next to them are processed. A run of nothing but images does not start at all:
//
if (files.All(IsImage) && !this.ProviderSettings.SupportsImageInput())
{
this.AddInputIssue(T("All selected files are images, but the selected model cannot read images. Select a model with image support."));
return null;
}
try
{
Directory.CreateDirectory(resolvedOutputDirectory);
@@ -18,7 +18,7 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private const string DEFAULT_OUTPUT_DIRECTORY_NAME = "ai-results";
private const string DEFAULT_RESULTS_FILENAME = "batch-results.csv";
private const string CSV_EXTENSION = ".csv";
internal const string RESULT_FILE_SUFFIX = "_result";
private const string RESULT_FILE_SUFFIX = "_result";
private const string TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
private const char LOG_SEPARATOR = ';';
@@ -26,7 +26,7 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
/// The name of the log file. It is fixed, so that a later batch run finds
/// the log of a previous run and can continue it.
/// </summary>
internal const string LOG_FILENAME = "log.csv";
private const string LOG_FILENAME = "log.csv";
protected override Tools.Components Component => Tools.Components.BATCH_PROCESSING_ASSISTANT;
@@ -168,8 +168,6 @@ 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;
@@ -1,88 +0,0 @@
namespace AIStudio.Assistants.BatchProcessing;
/// <summary>
/// Tells the search for documents which of the files it finds a batch run wrote itself.
/// </summary>
/// <remarks>
/// A run writes its result files, its log, and its results table directly into the output folder.
/// What the search must leave out therefore depends on where this folder lies. Below the input
/// folder, the search leaves out all of it. When it is the input folder itself, leaving out the
/// whole folder would leave no document at all, so the search leaves out what a run writes, by
/// name. Anywhere else, above the input folder included, the search never comes across the output
/// folder and leaves out nothing.
/// </remarks>
/// <param name="inputDirectory">The folder the run reads its documents from.</param>
/// <param name="outputDirectory">The folder the run writes into.</param>
/// <param name="resultsFileName">The file name of the results table.</param>
internal sealed class BatchProcessingOutputFolder(string inputDirectory, string outputDirectory, string resultsFileName)
{
private readonly string fullOutputDirectory = Path.GetFullPath(outputDirectory);
private readonly bool isInputFolder = IsSameFolder(inputDirectory, outputDirectory);
//
// The same folder with a separator at its end would count as lying below itself:
//
private readonly bool liesBelowInputFolder = !IsSameFolder(inputDirectory, outputDirectory) && PathTools.IsInsideDirectory(inputDirectory, outputDirectory);
/// <summary>
/// Whether the search enters a subfolder of the input folder: every one but the output folder.
/// </summary>
/// <param name="directory">The subfolder.</param>
public bool Enters(string directory) => !this.liesBelowInputFolder || !IsSameFolder(directory, this.fullOutputDirectory);
/// <summary>
/// Whether a file the search found was written by a run instead of being a document.
/// </summary>
/// <param name="filePath">The full path of the file.</param>
public bool IsOutputOfRun(string filePath)
{
//
// Checked with the separator after the name of the folder: otherwise, an output folder
// named 'out' would also leave out a document named 'output-notes.md':
//
if (this.liesBelowInputFolder)
return PathTools.IsInsideDirectory(this.fullOutputDirectory, filePath);
//
// A run writes into the output folder itself, never into one of its subfolders:
//
return this.isInputFolder
&& Path.GetDirectoryName(Path.GetFullPath(filePath)) is { } fileDirectory
&& IsSameFolder(fileDirectory, this.fullOutputDirectory)
&& this.IsOwnOutputArtifact(filePath);
}
/// <summary>
/// Checks whether a file is an output artifact of the assistant. We need
/// this when the output folder is the input folder: without it, the results
/// of a previous run would be processed as documents.
/// </summary>
/// <remarks>
/// A result file is recognized by its name without the extension, e.g., report_result.md or
/// report_result_2.docx, cf. AssistantBatchProcessing.CreateResultFileName. Its extension does
/// not count, because the user may have chosen another file format since a previous run.
/// </remarks>
private bool IsOwnOutputArtifact(string filePath)
{
var fileName = Path.GetFileName(filePath);
if (string.Equals(fileName, AssistantBatchProcessing.LOG_FILENAME, StringComparison.OrdinalIgnoreCase)
|| string.Equals(fileName, resultsFileName, StringComparison.OrdinalIgnoreCase))
return true;
var stem = Path.GetFileNameWithoutExtension(fileName);
var suffixIndex = stem.LastIndexOf(AssistantBatchProcessing.RESULT_FILE_SUFFIX, StringComparison.OrdinalIgnoreCase);
if (suffixIndex < 0)
return false;
//
// A second result of the same name gets a counter, e.g., report_result_2.md:
//
var counter = stem[(suffixIndex + AssistantBatchProcessing.RESULT_FILE_SUFFIX.Length)..];
return counter.Length is 0 || (counter.Length > 1 && counter[0] is '_' && counter[1..].All(char.IsAsciiDigit));
}
private static bool IsSameFolder(string firstDirectory, string secondDirectory) => string.Equals(TrimDirectorySeparator(Path.GetFullPath(firstDirectory)), TrimDirectorySeparator(Path.GetFullPath(secondDirectory)), PathTools.COMPARISON);
private static string TrimDirectorySeparator(string path) => path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
@@ -409,9 +409,6 @@ 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."
@@ -424,9 +421,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- The selected folder does not exist.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T17705912"] = "The selected folder does not exist."
-- The image was no longer available while the model was asked about it.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1805791865"] = "The image was no longer available while the model was asked about it."
-- Minimum pause between files
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1829787634"] = "Minimum pause between files"
@@ -436,9 +430,18 @@ 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?"
@@ -463,9 +466,6 @@ 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,9 +484,6 @@ 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,14 +529,14 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to write the result file: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2991295581"] = "Was not able to write the result file: {0}"
-- Images in JPG, PNG, WebP, or GIF format are given directly to the model, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3000909441"] = "Images in JPG, PNG, WebP, or GIF format are given directly to the model, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start."
-- 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."
-- The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T324865195"] = "The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual."
-- 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"
@@ -559,24 +556,15 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3361153305"] = "Show Expert Settings"
-- All selected files are images, but the selected model cannot read images. Select a model with image support.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3365102967"] = "All selected files are images, but the selected model cannot read images. Select a model with image support."
-- Header of the result column (optional)
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T340994102"] = "Header of the result column (optional)"
-- 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'."
-- The selected model cannot read images. Select a model with image support to process this image.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T346986514"] = "The selected model cannot read images. Select a model with image support to process this image."
-- Document analysis policy
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3510564924"] = "Document analysis policy"
@@ -607,9 +595,6 @@ 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"
@@ -661,9 +646,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Start batch processing
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T50133258"] = "Start batch processing"
-- This image format cannot be given to the model. Please convert the image to JPG, PNG, WebP, or GIF.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T532496054"] = "This image format cannot be given to the model. Please convert the image to JPG, PNG, WebP, or GIF."
-- Was not able to read the results table of the previous run. Its completed documents cannot be restored and will be processed again.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T544244392"] = "Was not able to read the results table of the previous run. Its completed documents cannot be restored and will be processed again."
@@ -688,18 +670,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Include hidden files and folders?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T85084423"] = "Include hidden files and folders?"
-- The image is larger than {0} MB, which is the most the selected provider accepts for one image.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T87450623"] = "The image is larger than {0} MB, which is the most the selected provider accepts for one image."
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T913168756"] = "Restore default"
-- The AI may use these tools while working on each document. Every tool has to meet the confidence requirements of the selected provider, so a tool may stay unavailable even when it is selected here.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T967206794"] = "The AI may use these tools while working on each document. Every tool has to meet the confidence requirements of the selected provider, so a tool may stay unavailable even when it is selected here."
-- The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T9738678"] = "The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual."
-- Comma (,)
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGCSVSEPARATOREXTENSIONS::T1676507543"] = "Comma (,)"
@@ -4144,6 +4120,9 @@ 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"
@@ -4171,6 +4150,12 @@ 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."
@@ -4453,6 +4438,78 @@ 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."
@@ -6751,6 +6808,9 @@ 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"
@@ -6769,21 +6829,24 @@ 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"
@@ -6799,6 +6862,18 @@ 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?"
@@ -6814,18 +6889,24 @@ 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."
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2460883298"] = "File patterns"
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2499087427"] = "Search results"
-- 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"
@@ -6835,38 +6916,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"
-- 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."
-- 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."
-- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory"
-- 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"
-- Restore default patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T7425959"] = "Restore default patterns"
-- 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})."
@@ -6919,6 +7000,9 @@ 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"
@@ -6931,9 +7015,6 @@ 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"
@@ -6946,9 +7027,6 @@ 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."
@@ -6988,9 +7066,6 @@ 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."
@@ -7006,9 +7081,15 @@ 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."
@@ -7027,6 +7108,9 @@ 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."
@@ -7039,6 +7123,9 @@ 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"
@@ -7051,26 +7138,32 @@ 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."
-- 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."
-- File
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T723007075"] = "File"
-- 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})."
@@ -7078,6 +7171,9 @@ 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"
@@ -8590,9 +8686,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T7117
-- Default minimum pause between files
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1008440099"] = "Default minimum pause between files"
-- Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats as well as images in JPG, PNG, WebP, and GIF format.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1038539663"] = "Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats as well as images in JPG, PNG, WebP, and GIF format."
-- Instructions
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1221801316"] = "Instructions"
@@ -8605,9 +8698,6 @@ 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"
@@ -8620,9 +8710,18 @@ 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"
@@ -8632,9 +8731,6 @@ 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."
@@ -8668,9 +8764,6 @@ 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"
@@ -8686,6 +8779,12 @@ 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"
@@ -8707,6 +8806,9 @@ 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."
@@ -8716,9 +8818,6 @@ 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"
@@ -8761,9 +8860,6 @@ 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"
@@ -10381,9 +10477,6 @@ 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"
@@ -12220,39 +12313,18 @@ 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"
@@ -12415,6 +12487,12 @@ 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/."
@@ -12640,9 +12718,6 @@ 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."
@@ -13435,9 +13510,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
-- Image for AI models
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T791951937"] = "Image for AI models"
-- Plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T927001356"] = "Plugin archive"
@@ -13795,21 +13867,18 @@ 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,4 +1,3 @@
using AIStudio.Provider;
using AIStudio.Tools.MIME;
using AIStudio.Tools.PluginSystem;
@@ -7,23 +6,7 @@ namespace AIStudio.Chat;
public static class IImageSourceExtensions
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(IImageSourceExtensions).Namespace, nameof(IImageSourceExtensions));
/// <summary>
/// The largest image we send to a model. A larger image is skipped by TryAsBase64.
/// </summary>
public const long MAX_IMAGE_SIZE_BYTES = 10_000_000;
/// <summary>
/// The largest image file, in bytes, which we send through the given provider.
/// </summary>
/// <remarks>
/// The limit of the provider where it states one, but never more than MAX_IMAGE_SIZE_BYTES:
/// TryAsBase64 skips every larger image, whatever the provider would accept.
/// </remarks>
/// <param name="llmProvider">The provider the image is sent through.</param>
/// <returns>The size limit of one image file.</returns>
public static long MaxImageSizeBytesFor(LLMProviders llmProvider) => Math.Min(llmProvider.GetMaxImageSizeBytes() ?? MAX_IMAGE_SIZE_BYTES, MAX_IMAGE_SIZE_BYTES);
public static MIMEType DetermineMimeType(this IImageSource image)
{
switch (image.SourceType)
@@ -114,7 +97,7 @@ public static class IImageSourceExtensions
{
// Read the length of the content:
var lengthBytes = response.Content.Headers.ContentLength;
if(lengthBytes > MAX_IMAGE_SIZE_BYTES)
if(lengthBytes > 10_000_000)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The image at the URL is too large (>10 MB). Skipping the image.")));
return (success: false, string.Empty);
@@ -133,7 +116,7 @@ public static class IImageSourceExtensions
{
// Read the content length:
var length = new FileInfo(image.Source).Length;
if(length > MAX_IMAGE_SIZE_BYTES)
if(length > 10_000_000)
{
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ImageNotSupported, TB("The local image file is too large (>10 MB). Skipping the image.")));
return (success: false, string.Empty);
@@ -0,0 +1,3 @@
@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"/>
@@ -0,0 +1,17 @@
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.Text()"
Text="@(this.ExpandToContent ? this.EditingText : this.Text())"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="@this.IconAdornment"
@@ -14,6 +14,7 @@
Lines="@this.NumLines"
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Class="@this.InputClass"
Immediate="@true"
Validation="@this.Validation"
Underline="false"
@@ -25,20 +26,26 @@ else
<div>
<MudTextField
T="string"
Text="@this.Text()"
Text="@(this.ExpandToContent ? this.EditingText : this.Text())"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="@this.IconAdornment"
AdornmentIcon="@this.Icon"
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)"
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"
/>
<RestoreDefaultButton Text="@this.ResetButtonText" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync"/>
@if (!this.ResetButtonIconOnly)
{
<RestoreDefaultButton Text="@this.ResetButtonText" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync"/>
}
</div>
}
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Timer = System.Timers.Timer;
@@ -24,6 +25,18 @@ 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>
@@ -54,6 +67,12 @@ 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>
@@ -61,10 +80,14 @@ 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
@@ -72,6 +95,8 @@ 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;
@@ -83,15 +108,24 @@ public partial class ConfigurationText : ConfigurationBaseCore
protected override async Task OnParametersSetAsync()
{
this.internalText = this.Text();
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;
}
await base.OnParametersSetAsync();
}
#endregion
private bool AutoGrow => this.NumLines > 1;
private bool AutoGrow => this.ExpandToContent || this.NumLines > 1;
private int GetMaxLines => this.AutoGrow ? this.MaxLines : 1;
private int GetMaxLines => this.ExpandToContent ? 0 : this.AutoGrow ? this.MaxLines : 1;
private string InputClass => this.ExpandToContent ? "pattern-text-field" : string.Empty;
//
// MudBlazor centers the icon vertically, so in a field of several lines it floats in the middle
@@ -99,29 +133,50 @@ public partial class ConfigurationText : ConfigurationBaseCore
//
private Adornment IconAdornment => this.NumLines == 1 ? Adornment.Start : Adornment.None;
private void InternalUpdate(string text)
protected void InternalUpdate(string text)
{
if (this.IsDisabled)
return;
this.timer.Stop();
this.internalText = text;
this.timer.Start();
}
private async Task ResetTextAsync()
private EventCallback<MouseEventArgs> ResetAdornmentClick => this.ResetButtonIconOnly
? EventCallback.Factory.Create<MouseEventArgs>(this, this.ResetTextAsync)
: default;
protected 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 = this.ResetValue();
this.internalText = text;
await this.OptionChanged(this.internalText);
}
private async Task OptionChanged(string updatedText)
protected async Task OptionChanged(string updatedText)
{
if (this.Validation?.Invoke(updatedText) is not null)
if (this.IsDisabled || 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,8 +270,70 @@ public partial class DataSourceManagement : MSGComponentBase
private async Task EditDataSource(IDataSource dataSource)
{
if (await DataSourceEditing.EditAsync(this.DialogService, this.SettingsManager, this.DataSourceEmbeddingService, dataSource.Id))
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
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);
}
private async Task DeleteDataSource(IDataSource dataSource)
@@ -0,0 +1,46 @@
@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>
@@ -0,0 +1,118 @@
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;
}
@@ -0,0 +1,89 @@
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);
}
}
@@ -0,0 +1,42 @@
@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>
@@ -0,0 +1,28 @@
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,10 +1,13 @@
@inherits MSGComponentBase
<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>
@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>
}
@@ -18,7 +18,10 @@ public partial class ManagePandocDependency : MSGComponentBase
[Inject]
private RustService RustService { get; init; } = null!;
private PandocInstallation pandocInstallation;
// Null means the silent availability check has not completed yet.
private PandocInstallation? pandocInstallation;
internal static bool ShouldShowInstallationPrompt(PandocInstallation? installation) => installation is { IsAvailable: false };
#region Overrides of MSGComponentBase
@@ -32,10 +35,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private Color DetermineButtonColor()
{
if (this.pandocInstallation == default)
if (this.pandocInstallation is null)
return Color.Default;
switch (this.pandocInstallation)
switch (this.pandocInstallation.Value)
{
case { CheckWasSuccessful: true, IsAvailable: true }:
return Color.Default;
@@ -50,10 +53,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private string DetermineButtonText()
{
if(this.pandocInstallation == default)
if(this.pandocInstallation is null)
return T("Please wait while we check the availability of Pandoc.");
switch (this.pandocInstallation)
switch (this.pandocInstallation.Value)
{
case { CheckWasSuccessful: true, IsAvailable: true }:
return T("Check your Pandoc installation");
@@ -68,10 +71,10 @@ public partial class ManagePandocDependency : MSGComponentBase
private string DetermineIntroText()
{
if (this.pandocInstallation == default)
if (this.pandocInstallation is null)
return $"{this.IntroText} {T("Please wait while we check the availability of Pandoc.")}";
switch (this.pandocInstallation)
switch (this.pandocInstallation.Value)
{
case { CheckWasSuccessful: true, IsAvailable: true }:
return $"{this.IntroText} {T("Your Pandoc installation meets the requirements.")}";
@@ -7,9 +7,11 @@
Text="@this.Value"
Variant="Variant.Outlined"
Margin="Margin.Dense"
Lines="3"
Lines="@this.Lines"
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,6 +9,9 @@ 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;
@@ -18,6 +21,9 @@ 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,12 +10,15 @@
<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-6"
Class="mb-3"
MaxLength="@DataSourceValidation.MAX_NAME_LENGTH"
Counter="@DataSourceValidation.MAX_NAME_LENGTH"
Immediate="@true"
@@ -31,7 +34,7 @@
T="string"
@bind-Text="@this.dataDescription"
Label="@T("Description")"
Class="mb-6"
Class="mb-0"
MaxLength="200"
Counter="200"
Immediate="@true"
@@ -44,10 +47,37 @@
Lines="3"
/>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Folder")
</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Select a root directory for this data source.")
</MudJustifiedText>
<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" />
@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"
/>
}
<MudTextSwitch
Label="@T("Include subfolders?")"
@@ -57,28 +87,42 @@
LabelOff="@T("No, only index files in the selected folder")"
/>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("Files to include")
</MudText>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Select the file patterns this data source should index.")
</MudJustifiedText>
<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"
/>
<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>
<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>
<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>
<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())
{
@@ -94,14 +138,17 @@
}
else if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
<MudJustifiedText Typo="Typo.body2" 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("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<ManagePandocDependency IntroText="@T("MindWork AI Studio uses Pandoc to read HTML files.")"/>
<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">
<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">
@foreach (var level in this.ConfidenceLevels)
{
<MudSelectItem Value="@level.Value">
@@ -123,31 +170,21 @@
</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, index hidden files and folders as well")"
LabelOn="@T("Yes, include hidden files and folders")"
LabelOff="@T("No, ignore hidden files and folders")"
/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@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.")
@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.")
</MudJustifiedText>
@* 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"/>
<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>
<MudNumericField
T="int"
@bind-Value="@this.dataMaxFileSizeMegabytes"
@@ -159,6 +196,10 @@
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
@@ -172,9 +213,6 @@
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"
@@ -202,6 +240,7 @@
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,6 +23,16 @@ 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!;
@@ -205,6 +215,8 @@ 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
@@ -232,7 +244,9 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
Type = DataSourceType.LOCAL_DIRECTORY,
EmbeddingId = this.dataEmbeddingId,
Path = this.dataPath,
// 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,
FilePatterns = this.NormalizeFilePatterns(),
// Kept as typed, since the line breaks group the entries for the eye:
@@ -254,14 +268,6 @@ 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();
//
@@ -282,7 +288,7 @@ public partial class DataSourceLocalDirectoryDialog : MSGComponentBase
private string NormalizeFilePatterns() => FileNamePatterns.Normalize(this.dataFilePatterns);
private void RestoreDefaultExcludePatterns() => this.dataExcludePatterns = FileExclusions.DEFAULT_PATTERNS;
private void RestoreDefaultFilePatterns() => this.dataFilePatterns = DataSourceLocalDirectory.DEFAULT_FILE_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")"/>
<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")"/>
<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")"/>
@if (!this.IsDirectoryAvailable)
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Error" Class="mb-3">
@@ -65,4 +65,4 @@
@T("Close")
</MudButton>
</DialogActions>
</MudDialog>
</MudDialog>
@@ -10,12 +10,15 @@
<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-6"
Class="mb-3"
MaxLength="@DataSourceValidation.MAX_NAME_LENGTH"
Counter="@DataSourceValidation.MAX_NAME_LENGTH"
Immediate="@true"
@@ -31,7 +34,7 @@
T="string"
@bind-Text="@this.dataDescription"
Label="@T("Description")"
Class="mb-6"
Class="mb-0"
MaxLength="200"
Counter="200"
Immediate="@true"
@@ -44,14 +47,44 @@
Lines="3"
/>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
<MudText Typo="Typo.h6" Class="mb-3 mt-6">
@T("File")
</MudText>
<MudJustifiedText Typo="Typo.body2" 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>
<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" />
@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"
/>
}
<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>
<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>
<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())
{
@@ -67,14 +100,17 @@
}
else if (!string.IsNullOrWhiteSpace(this.dataEmbeddingId))
{
<MudJustifiedText Typo="Typo.body1" Color="Color.Tertiary" Class="mb-3">
<MudJustifiedText Typo="Typo.body2" 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("For some data types, such as Office files, MindWork AI Studio requires the open-source application Pandoc.")"/>
<ManagePandocDependency IntroText="@T("MindWork AI Studio uses Pandoc to read HTML files.")"/>
<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">
<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">
@foreach (var level in this.ConfidenceLevels)
{
<MudSelectItem Value="@level.Value">
@@ -96,6 +132,10 @@
</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
@@ -109,9 +149,6 @@
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"
@@ -139,6 +176,7 @@
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,6 +24,16 @@ 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!;
@@ -196,6 +206,8 @@ 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
@@ -223,7 +235,9 @@ public partial class DataSourceLocalFileDialog : MSGComponentBase
Type = DataSourceType.LOCAL_FILE,
EmbeddingId = this.dataEmbeddingId,
FilePath = this.dataFilePath,
// 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,
MaxChunkTokenLength = this.dataMaxChunkTokenLength,
ChunkOverlapTokenLength = this.dataChunkOverlapTokenLength,
ConfidenceLevel = this.dataConfidenceLevel,
@@ -238,14 +252,6 @@ 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,9 +20,18 @@
<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"/>
<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 as well as images in JPG, PNG, WebP, and GIF format.")" 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"/>
<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>
<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 or the data source is where people give up.
the settings of the mailbox is where people give up.
*@
@if (status.Attention is DataSourceAttention.AUTH_FAILED)
{
@@ -320,14 +320,6 @@
</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,25 +509,6 @@ 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>
@@ -650,7 +650,7 @@ CONFIG["SETTINGS"] = {}
--
-- Configure the default file patterns and whether subfolders as well as hidden files and folders are included.
-- Separate multiple patterns with semicolons.
-- CONFIG["SETTINGS"]["DataBatchProcessing.FilePatterns"] = "*.pdf;*.docx;*.pptx;*.xlsx;*.md;*.txt;*.jpg;*.jpeg;*.png;*.webp;*.gif;*.mp3;*.wav;*.wave;*.aac;*.flac;*.ogg;*.opus;*.m4a;*.m4b;*.wma;*.alac;*.aif;*.aiff;*.caf;*.mp4;*.m4v;*.avi;*.mkv;*.mov;*.wmv;*.flv;*.webm"
-- CONFIG["SETTINGS"]["DataBatchProcessing.FilePatterns"] = "*.pdf;*.docx;*.pptx;*.xlsx;*.md;*.txt;*.mp3;*.wav;*.wave;*.aac;*.flac;*.ogg;*.opus;*.m4a;*.m4b;*.wma;*.alac;*.aif;*.aiff;*.caf;*.mp4;*.m4v;*.avi;*.mkv;*.mov;*.wmv;*.flv;*.webm"
-- CONFIG["SETTINGS"]["DataBatchProcessing.IncludeSubdirectories"] = false
-- CONFIG["SETTINGS"]["DataBatchProcessing.IncludeHiddenFilesAndFolders"] = false
--
@@ -411,9 +411,6 @@ 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."
@@ -426,9 +423,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- The selected folder does not exist.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T17705912"] = "Der ausgewählte Ordner existiert nicht."
-- The image was no longer available while the model was asked about it.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1805791865"] = "Das Bild war nicht mehr verfügbar, während das Modell dazu befragt wurde."
-- Minimum pause between files
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1829787634"] = "Mindestpause zwischen Dateien"
@@ -438,9 +432,18 @@ 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?"
@@ -465,9 +468,6 @@ 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,9 +486,6 @@ 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,14 +531,14 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to write the result file: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2991295581"] = "Die Ergebnisdatei konnte nicht geschrieben werden: {0}"
-- Images in JPG, PNG, WebP, or GIF format are given directly to the model, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3000909441"] = "Bilder im JPG-, PNG-, WebP- oder GIF-Format werden direkt an das Modell übergeben, daher benötigen Sie ein Modell, das Bilder versteht. Falls das ausgewählte Modell keine Bilder lesen kann, entfernen Sie die Dateimuster für Bilder, z. B. *.jpg oder *.png, bevor Sie starten."
-- 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."
-- The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T324865195"] = "Das ausgewählte Modell kann keine Bilder lesen, daher schlägt die Verarbeitung der {0} Bilder unter den ausgewählten Dateien fehl. Alle anderen Dateien werden wie gewohnt verarbeitet."
-- 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"
@@ -561,24 +558,15 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3361153305"] = "Experten-Einstellungen anzeigen"
-- All selected files are images, but the selected model cannot read images. Select a model with image support.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3365102967"] = "Alle ausgewählten Dateien sind Bilder, aber das ausgewählte Modell kann keine Bilder lesen. Wählen Sie ein Modell mit Bildunterstützung aus."
-- Header of the result column (optional)
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T340994102"] = "Überschrift der Ergebnisspalte (optional)"
-- 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."
-- The selected model cannot read images. Select a model with image support to process this image.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T346986514"] = "Das ausgewählte Modell kann keine Bilder lesen. Wählen Sie ein Modell mit Bildunterstützung aus, um dieses Bild zu verarbeiten."
-- Document analysis policy
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3510564924"] = "Regelwerk für die Dokumentenanalyse"
@@ -609,9 +597,6 @@ 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"
@@ -663,9 +648,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Start batch processing
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T50133258"] = "Stapelverarbeitung starten"
-- This image format cannot be given to the model. Please convert the image to JPG, PNG, WebP, or GIF.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T532496054"] = "Dieses Bildformat kann nicht an das Modell übergeben werden. Bitte konvertieren Sie das Bild in JPG, PNG, WebP oder GIF."
-- Was not able to read the results table of the previous run. Its completed documents cannot be restored and will be processed again.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T544244392"] = "Die Ergebnistabelle des vorherigen Durchlaufs konnte nicht gelesen werden. Die bereits abgeschlossenen Dokumente können nicht wiederhergestellt werden und werden erneut verarbeitet."
@@ -690,18 +672,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Include hidden files and folders?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T85084423"] = "Versteckte Dateien und Ordner einbeziehen?"
-- The image is larger than {0} MB, which is the most the selected provider accepts for one image.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T87450623"] = "Das Bild überschreitet die maximale Größe von {0} MB, die der ausgewählte Anbieter pro Bild akzeptiert."
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T913168756"] = "Standard wiederherstellen"
-- The AI may use these tools while working on each document. Every tool has to meet the confidence requirements of the selected provider, so a tool may stay unavailable even when it is selected here.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T967206794"] = "Die KI kann diese Werkzeuge bei der Arbeit an jedem Dokument verwenden. Jedes Werkzeug muss die Vertrauensanforderungen des ausgewählten Anbieters erfüllen. Daher kann ein Werkzeug trotz Auswahl hier weiterhin nicht verfügbar sein."
-- The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T9738678"] = "Das ausgewählte Modell kann keine Bilder lesen, daher schlägt die Verarbeitung des Bildes unter den ausgewählten Dateien fehl. Alle anderen Dateien werden wie gewohnt verarbeitet."
-- Comma (,)
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGCSVSEPARATOREXTENSIONS::T1676507543"] = "Komma (,)"
@@ -4084,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)"
@@ -4146,6 +4122,9 @@ 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"
@@ -4173,6 +4152,12 @@ 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."
@@ -4455,6 +4440,78 @@ 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."
@@ -6753,6 +6810,9 @@ 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"
@@ -6771,21 +6831,24 @@ 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"
@@ -6801,6 +6864,18 @@ 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?"
@@ -6816,18 +6891,24 @@ 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."
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2460883298"] = "Dateimuster"
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2499087427"] = "Suchergebnisse"
-- 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"
@@ -6837,38 +6918,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"
-- 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."
-- 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."
-- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Wählen Sie das Stammverzeichnis aus"
-- 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"
-- Restore default patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T7425959"] = "Standardmuster wiederherstellen"
-- 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})."
@@ -6921,6 +7002,9 @@ 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"
@@ -6933,9 +7017,6 @@ 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"
@@ -6948,9 +7029,6 @@ 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."
@@ -6990,9 +7068,6 @@ 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."
@@ -7008,9 +7083,15 @@ 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."
@@ -7029,6 +7110,9 @@ 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."
@@ -7041,6 +7125,9 @@ 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"
@@ -7053,26 +7140,32 @@ 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."
-- 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."
-- File
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T723007075"] = "Datei"
-- 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})."
@@ -7080,6 +7173,9 @@ 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"
@@ -8592,9 +8688,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T7117
-- Default minimum pause between files
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1008440099"] = "Standardmäßige Mindestpause zwischen Dateien"
-- Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats as well as images in JPG, PNG, WebP, and GIF format.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1038539663"] = "Trennen Sie mehrere Dateimuster durch ein Semikolon, z. B. *.pdf;*.docx. Die Standardmuster umfassen alle unterstützten Audio- und Videoformate sowie Bilder im JPG-, PNG-, WebP- und GIF-Format."
-- Instructions
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1221801316"] = "Anweisungen"
@@ -8607,9 +8700,6 @@ 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"
@@ -8622,9 +8712,18 @@ 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"
@@ -8634,9 +8733,6 @@ 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."
@@ -8670,9 +8766,6 @@ 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."
@@ -8688,6 +8781,12 @@ 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"
@@ -8709,6 +8808,9 @@ 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."
@@ -8718,9 +8820,6 @@ 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"
@@ -8763,9 +8862,6 @@ 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"
@@ -10383,9 +10479,6 @@ 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"
@@ -12222,39 +12315,18 @@ 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"
@@ -12417,6 +12489,12 @@ 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/."
@@ -12642,9 +12720,6 @@ 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."
@@ -13437,9 +13512,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument"
-- Image for AI models
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T791951937"] = "Bild für KI-Modelle"
-- Plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T927001356"] = "Plugin-Archiv"
@@ -13797,21 +13869,18 @@ 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,9 +411,6 @@ 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."
@@ -426,9 +423,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- The selected folder does not exist.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T17705912"] = "The selected folder does not exist."
-- The image was no longer available while the model was asked about it.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1805791865"] = "The image was no longer available while the model was asked about it."
-- Minimum pause between files
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T1829787634"] = "Minimum pause between files"
@@ -438,9 +432,18 @@ 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?"
@@ -465,9 +468,6 @@ 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,9 +486,6 @@ 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,14 +531,14 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Was not able to write the result file: {0}
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T2991295581"] = "Was not able to write the result file: {0}"
-- Images in JPG, PNG, WebP, or GIF format are given directly to the model, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3000909441"] = "Images in JPG, PNG, WebP, or GIF format are given directly to the model, so you need a model that understands images. If the selected model cannot read images, remove the image patterns, such as *.jpg or *.png, before you start."
-- 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."
-- The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T324865195"] = "The selected model cannot read images, so the {0} images among the selected files will fail. All other files are processed as usual."
-- 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"
@@ -561,24 +558,15 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Show Expert Settings
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3361153305"] = "Show Expert Settings"
-- All selected files are images, but the selected model cannot read images. Select a model with image support.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3365102967"] = "All selected files are images, but the selected model cannot read images. Select a model with image support."
-- Header of the result column (optional)
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T340994102"] = "Header of the result column (optional)"
-- 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'."
-- The selected model cannot read images. Select a model with image support to process this image.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T346986514"] = "The selected model cannot read images. Select a model with image support to process this image."
-- Document analysis policy
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T3510564924"] = "Document analysis policy"
@@ -609,9 +597,6 @@ 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"
@@ -663,9 +648,6 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Start batch processing
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T50133258"] = "Start batch processing"
-- This image format cannot be given to the model. Please convert the image to JPG, PNG, WebP, or GIF.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T532496054"] = "This image format cannot be given to the model. Please convert the image to JPG, PNG, WebP, or GIF."
-- Was not able to read the results table of the previous run. Its completed documents cannot be restored and will be processed again.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T544244392"] = "Was not able to read the results table of the previous run. Its completed documents cannot be restored and will be processed again."
@@ -690,18 +672,12 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING
-- Include hidden files and folders?
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T85084423"] = "Include hidden files and folders?"
-- The image is larger than {0} MB, which is the most the selected provider accepts for one image.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T87450623"] = "The image is larger than {0} MB, which is the most the selected provider accepts for one image."
-- Restore default
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T913168756"] = "Restore default"
-- The AI may use these tools while working on each document. Every tool has to meet the confidence requirements of the selected provider, so a tool may stay unavailable even when it is selected here.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T967206794"] = "The AI may use these tools while working on each document. Every tool has to meet the confidence requirements of the selected provider, so a tool may stay unavailable even when it is selected here."
-- The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual.
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::ASSISTANTBATCHPROCESSING::T9738678"] = "The selected model cannot read images, so the image among the selected files will fail. All other files are processed as usual."
-- Comma (,)
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::BATCHPROCESSING::BATCHPROCESSINGCSVSEPARATOREXTENSIONS::T1676507543"] = "Comma (,)"
@@ -4146,6 +4122,9 @@ 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"
@@ -4173,6 +4152,12 @@ 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."
@@ -4455,6 +4440,78 @@ 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."
@@ -6753,6 +6810,9 @@ 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"
@@ -6771,21 +6831,24 @@ 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"
@@ -6801,6 +6864,18 @@ 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?"
@@ -6816,18 +6891,24 @@ 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."
-- File patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2460883298"] = "File patterns"
-- Search results
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T2499087427"] = "Search results"
-- 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"
@@ -6837,38 +6918,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"
-- 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."
-- 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."
-- Select the base directory
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T562479068"] = "Select the base directory"
-- 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"
-- Restore default patterns
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALDIRECTORYDIALOG::T7425959"] = "Restore default patterns"
-- 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})."
@@ -6921,6 +7002,9 @@ 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"
@@ -6933,9 +7017,6 @@ 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"
@@ -6948,9 +7029,6 @@ 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."
@@ -6990,9 +7068,6 @@ 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."
@@ -7008,9 +7083,15 @@ 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."
@@ -7029,6 +7110,9 @@ 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."
@@ -7041,6 +7125,9 @@ 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"
@@ -7053,26 +7140,32 @@ 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."
-- 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."
-- File
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DATASOURCELOCALFILEDIALOG::T723007075"] = "File"
-- 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})."
@@ -7080,6 +7173,9 @@ 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"
@@ -8592,9 +8688,6 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGASSISTANTBIAS::T7117
-- Default minimum pause between files
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1008440099"] = "Default minimum pause between files"
-- Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats as well as images in JPG, PNG, WebP, and GIF format.
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1038539663"] = "Separate multiple file patterns with a semicolon, e.g., *.pdf;*.docx. The standard patterns include all supported audio and video formats as well as images in JPG, PNG, WebP, and GIF format."
-- Instructions
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::SETTINGS::SETTINGSDIALOGBATCHPROCESSING::T1221801316"] = "Instructions"
@@ -8607,9 +8700,6 @@ 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"
@@ -8622,9 +8712,18 @@ 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"
@@ -8634,9 +8733,6 @@ 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."
@@ -8670,9 +8766,6 @@ 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"
@@ -8688,6 +8781,12 @@ 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"
@@ -8709,6 +8808,9 @@ 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."
@@ -8718,9 +8820,6 @@ 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"
@@ -8763,9 +8862,6 @@ 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"
@@ -10383,9 +10479,6 @@ 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"
@@ -12222,39 +12315,18 @@ 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"
@@ -12417,6 +12489,12 @@ 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/."
@@ -12642,9 +12720,6 @@ 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."
@@ -13437,9 +13512,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
-- Image for AI models
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T791951937"] = "Image for AI models"
-- Plugin archive
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T927001356"] = "Plugin archive"
@@ -13797,21 +13869,18 @@ 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."
@@ -288,27 +288,6 @@ public static class LLMProvidersExtensions
_ => false,
};
/// <summary>
/// The largest image file, in bytes, which the provider accepts in a request, where it states one.
/// </summary>
/// <remarks>
/// The limit belongs to the API of the provider, not to the model: the same model may accept
/// larger images through another provider. What we actually send is asked through
/// IImageSourceExtensions.MaxImageSizeBytesFor, which also applies our own limit.
/// </remarks>
/// <param name="llmProvider">The provider to check.</param>
/// <returns>The size limit of one image file, or null when the provider states none we know of.</returns>
public static long? GetMaxImageSizeBytes(this LLMProviders llmProvider) => llmProvider switch
{
//
// Anthropic accepts at most 5 MiB (5,242,880 bytes) of base64 per image. Base64 needs four
// characters for every three bytes, which leaves 3,932,160 bytes for the image file itself:
//
LLMProviders.ANTHROPIC => 3_932_160,
_ => null,
};
/// <summary>
/// Creates a new provider instance based on the provider value.
/// </summary>
@@ -11,7 +11,7 @@ namespace AIStudio.Settings.DataModel;
/// <param name="configSelection">The managed-configuration selector.</param>
public sealed class DataBatchProcessing(Expression<Func<Data, DataBatchProcessing>>? configSelection = null)
{
public const string DEFAULT_FILE_PATTERNS = "*.pdf;*.docx;*.pptx;*.xlsx;*.md;*.txt;*.jpg;*.jpeg;*.png;*.webp;*.gif;*.mp3;*.wav;*.wave;*.aac;*.flac;*.ogg;*.opus;*.m4a;*.m4b;*.wma;*.alac;*.aif;*.aiff;*.caf;*.mp4;*.m4v;*.avi;*.mkv;*.mov;*.wmv;*.flv;*.webm";
public const string DEFAULT_FILE_PATTERNS = "*.pdf;*.docx;*.pptx;*.xlsx;*.md;*.txt;*.mp3;*.wav;*.wave;*.aac;*.flac;*.ogg;*.opus;*.m4a;*.m4b;*.wma;*.alac;*.aif;*.aiff;*.caf;*.mp4;*.m4v;*.avi;*.mkv;*.mov;*.wmv;*.flv;*.webm";
public const int MIN_DELAY_SECONDS = 6;
public const int MAX_DELAY_SECONDS = 300;
public const int DEFAULT_MIN_DELAY_SECONDS = 6;
@@ -81,17 +81,10 @@ public static partial class ProviderExtensions
/// wants to know whether an image may be sent has to accept both, which is why the question is asked
/// here instead of at each call site: attaching a file and validating an already attached file must
/// never disagree about it.
///
/// A limit of zero images takes the capability back. Zero is a real answer, e.g., from an operator
/// whose engine accepts no images at all, see ImageLimits; an unknown limit says nothing.
/// </remarks>
/// <param name="provider">The configured provider.</param>
/// <returns><c>true</c> when the model accepts image input.</returns>
public static bool SupportsImageInput(this Provider provider)
{
var profile = provider.GetModelProfile();
return profile.HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT) && profile.Images.MaxInOneMessage is not 0;
}
public static bool SupportsImageInput(this Provider provider) => provider.GetModelProfile().HasAny(Capability.SINGLE_IMAGE_INPUT | Capability.MULTIPLE_IMAGE_INPUT);
/// <summary>
/// Checks whether this model can be used for chatting.
@@ -1,115 +0,0 @@
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,7 +2,6 @@ using System.Text;
using AIStudio.Dialogs;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
@@ -20,10 +19,6 @@ 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
{
@@ -89,60 +84,11 @@ public static class DataSourceReindexWarning
DataSourceEmbeddingProviders.TryResolve(settingsManager, before, out var resolvedBeforeProvider);
var beforeProvider = resolvedBeforeProvider ?? EmbeddingProvider.NONE;
// 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))
if (!EmbeddingChangeImpact.AffectsStoredIndex(before, beforeProvider, after, afterProvider))
return true;
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;
var affected = await embeddingService.GetDataSourcesWithStoredIndexAsync([after], token);
return await ConfirmAsync(dialogService, affected, !afterProvider.IsSelfHosted);
}
/// <summary>
@@ -246,4 +192,4 @@ public static class DataSourceReindexWarning
var dialogResult = await dialogReference.Result;
return dialogResult is not null && !dialogResult.Canceled;
}
}
}
@@ -29,11 +29,6 @@ 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,9 +52,6 @@ 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,9 +149,7 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
file.LastWriteUtc,
file.EmbeddedAtUtc,
file.ChunkCount,
file.TranscriptKey,
file.RelativePath,
file.ParentFileId);
file.TranscriptKey);
}
var permanentFailures = await context.PermanentIndexingFailures
@@ -235,18 +233,6 @@ 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();
@@ -776,4 +762,4 @@ public sealed partial class SqliteIndexStoreClientImplementation(string name, st
client.SetLogger(databaseClientLogger);
return client;
}
}
}
@@ -37,10 +37,7 @@ 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, string parentFileId, CancellationToken token) =>
Task.FromException(this.CreateUnavailableException());
public override Task RelocateEmbeddingFileAsync(string storeName, string parentFileId, string absolutePath, string relativePath, string fingerprint, CancellationToken token) =>
public override Task DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token) =>
Task.FromException(this.CreateUnavailableException());
public override Task OptimizeVectorStore(string storeName, CancellationToken token) =>
@@ -27,7 +27,6 @@ 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";
@@ -157,11 +156,8 @@ public sealed class QdrantEdgeClientImplementation(
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 DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, DELETE_FILE_PATH, new DeleteEmbeddingByFileRequest(storeName, filePath), token);
public override Task OptimizeVectorStore(string storeName, CancellationToken token) =>
rustService.ExecuteDatabaseOperation(DATABASE_NAME, OPTIMIZE_PATH, new OptimizeVectorStoreRequest(storeName), token);
@@ -198,9 +194,7 @@ 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, string ParentFileId);
private sealed record RelocateEmbeddingFileRequest(string StoreName, string ParentFileId, string AbsolutePath, string FileName, string RelativePath, string Fingerprint);
private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath);
private sealed record OptimizeVectorStoreRequest(string StoreName);
@@ -27,12 +27,7 @@ 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, 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 DeleteEmbeddingByFile(string storeName, string filePath, CancellationToken token);
public abstract Task OptimizeVectorStore(string storeName, CancellationToken token);
+26 -3
View File
@@ -112,6 +112,8 @@ 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));
@@ -126,18 +128,36 @@ 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) => GetValidationIssue(patterns) switch
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
{
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."),
_ => throw new ArgumentOutOfRangeException(nameof(patterns)),
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)),
};
/// <summary>
@@ -174,7 +194,7 @@ internal static class FileExclusions
private static bool IsFolderEntry(string entry) => entry.EndsWith(FOLDER_SUFFIX);
private static ValidationIssue GetValidationIssue(string? patterns)
private static ValidationIssue GetValidationIssue(string? patterns, bool? folders = null)
{
foreach (var entry in Parse(patterns))
{
@@ -193,6 +213,9 @@ 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,7 +34,6 @@ internal static class FileNamePatterns
{
NONE,
MISSING,
EMPTY_ENTRY,
UNKNOWN_SELECTOR,
RECURSIVE_WILDCARD,
PATH,
@@ -50,6 +49,9 @@ 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)
@@ -124,7 +126,6 @@ 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."),
@@ -134,16 +135,12 @@ internal static class FileNamePatterns
private static ValidationIssue GetValidationIssue(string? patterns)
{
if (string.IsNullOrWhiteSpace(patterns))
var individualPatterns = Parse(patterns);
if (individualPatterns.Length == 0)
return ValidationIssue.MISSING;
var individualPatterns = patterns.Split(';');
if (individualPatterns.Any(string.IsNullOrWhiteSpace))
return ValidationIssue.EMPTY_ENTRY;
foreach (var patternEntry in individualPatterns)
foreach (var pattern 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.ShouldLockMailboxOriginAsync(storedMailbox.Id) },
{ x => x.LockSource, await embeddingService.ShouldLockDataSourceOriginAsync(storedMailbox.Id) },
};
var dialogReference = await dialogService.ShowAsync<DataSourceMailboxDialog>(TB("Edit Mailbox"), dialogParameters, DialogOptions.FULLSCREEN);
+1 -27
View File
@@ -1,7 +1,7 @@
namespace AIStudio.Tools;
/// <summary>
/// Shared rules for comparing and resolving file system paths.
/// Shared rules for comparing file system paths.
/// </summary>
public static class PathTools
{
@@ -42,30 +42,4 @@ 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,5 +1,6 @@
using System.Text;
using AIStudio.Chat;
using AIStudio.Tools.Security;
namespace AIStudio.Tools.RAG;
@@ -100,17 +100,6 @@ public static class FileTypes
public static readonly FileTypeFilter IMAGE = FileTypeFilter.Leaf(TB("Image"),
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "svg", "webp", "heic", "avif");
/// <summary>
/// The image formats we hand to a model as they are.
/// </summary>
/// <remarks>
/// The providers accept these, while other formats, such as TIFF or HEIC, would end in an
/// error of the provider which tells the user nothing. The default file patterns of the batch
/// processing assistant list the same formats, see DataBatchProcessing.DEFAULT_FILE_PATTERNS.
/// </remarks>
public static readonly FileTypeFilter MODEL_IMAGE = FileTypeFilter.Leaf(TB("Image for AI models"),
"jpg", "jpeg", "png", "webp", "gif");
/// <summary>
/// Gets the prototype visual-asset image formats.
/// </summary>
@@ -24,9 +24,4 @@ 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,27 +1,7 @@
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;
@@ -30,7 +10,7 @@ public sealed class DataSourceEmbeddingManifest
public int VectorSize { get; set; }
public Dictionary<string, EmbeddedFileRecord> Files { get; init; } = new(PATH_COMPARER);
public Dictionary<string, EmbeddedFileRecord> Files { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The files whose indexing began but never came to an end, by their absolute path.
@@ -41,7 +21,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(PATH_COMPARER);
public HashSet<string> UnfinishedFiles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The files whose indexing failed for a reason which lies in the file itself, keyed by their
@@ -52,5 +32,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(PATH_COMPARER);
public Dictionary<string, PermanentIndexingFailureRecord> PermanentFailures { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
@@ -158,18 +158,14 @@ 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.
///
/// 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.
/// 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.
/// </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 switch
{
DataSourceLocalDirectory directory => $"{directory.Path.Length}:{directory.Path}|{FolderFileSelection.Of(directory).Describe()}",
DataSourceLocalFile file => $"{file.FilePath.Length}:{file.FilePath}",
_ => string.Empty,
};
internal static string BuildSelectionSignature(IIndexedDataSource dataSource) => dataSource is DataSourceLocalDirectory directory
? FolderFileSelection.Of(directory).Describe()
: string.Empty;
/// <summary>
/// Describes the indexing run a data source would get with a given embedding provider.
@@ -190,20 +190,24 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
}
/// <summary>
/// Whether a mailbox's origin must stay as it is.
/// Whether the file or folder a data source reads must stay as it is.
/// </summary>
/// <remarks>
/// 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.
/// 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.
///
/// Unclear counts as locked: an unavailable index database says nothing about what is stored.
/// </remarks>
/// <param name="dataSourceId">The mailbox to ask about.</param>
/// <param name="dataSourceId">The data source 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> ShouldLockMailboxOriginAsync(string dataSourceId, CancellationToken token = default)
public async Task<bool> ShouldLockDataSourceOriginAsync(string dataSourceId, CancellationToken token = default)
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
@@ -1500,4 +1504,4 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
{
_ = MessageBus.INSTANCE.SendMessage(null, Event.RAG_EMBEDDING_STATUS_CHANGED, true);
}
}
}
@@ -1,4 +1,3 @@
namespace AIStudio.Tools.Services;
public sealed record EmbeddedFileRecord(string Fingerprint, long FileSize, DateTimeOffset LastWriteUtc, DateTimeOffset EmbeddedAtUtc, int ChunkCount, string? TranscriptKey = null,
string? RelativePath = null, string? ParentFileId = null);
public sealed record EmbeddedFileRecord(string Fingerprint, long FileSize, DateTimeOffset LastWriteUtc, DateTimeOffset EmbeddedAtUtc, int ChunkCount, string? TranscriptKey = 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, state.RelativePath, state.ParentFileId);
context.Manifest.Files[document.Key] = new EmbeddedFileRecord(state.Fingerprint, state.FileSize, state.LastWriteUtc, embeddedAtUtc, chunkCount, state.TranscriptKey);
await context.ForgetPermanentFailureAsync(document.Key, token);
context.Pass.Forget(document.Key);
@@ -1,91 +0,0 @@
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, PathTools.COMPARISON);
return string.Equals(fileName, targetFileName, StringComparison.OrdinalIgnoreCase);
//
// 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 RelocatedFilePaths.IsLocalAbsolute(localDirectory.Path) && Directory.Exists(localDirectory.Path) => new DataSourceWatcherConfiguration(
DataSourceLocalDirectory localDirectory when 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 RelocatedFilePaths.IsLocalAbsolute(localFile.FilePath) && File.Exists(localFile.FilePath) && !string.IsNullOrWhiteSpace(Path.GetDirectoryName(localFile.FilePath)) => new DataSourceWatcherConfiguration(
DataSourceLocalFile localFile when 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, PathTools.COMPARISON)
&& string.Equals(left.TargetFileName, right.TargetFileName, PathTools.COMPARISON)
&& string.Equals(left.RootPath, right.RootPath, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.TargetFileName, right.TargetFileName, StringComparison.OrdinalIgnoreCase)
&& FileNamePatterns.AreEquivalent(left.FilePatterns, right.FilePatterns)
&& FileNamePatterns.AreEquivalent(left.ExcludePatterns, right.ExcludePatterns);
}
@@ -77,25 +77,12 @@ 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;
@@ -377,8 +364,7 @@ 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 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.
/// the index holds, so the next run picks up from there.
/// </remarks>
/// <param name="context">The run.</param>
/// <param name="dataSource">The data source.</param>
@@ -392,7 +378,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
dataSource.Id,
context.Manifest.Files.Count);
DocumentRunProgress.FromStoredState(context, context.Manifest.Files.Count, logger).PublishRunFailure(reason, DataSourceAttention.LOCATION_UNREACHABLE);
DocumentRunProgress.FromStoredState(context, context.Manifest.Files.Count, logger).PublishRunFailure(reason);
return IndexedRunOutcome.DONE;
}
@@ -523,10 +509,9 @@ 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 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.
/// 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.
/// </remarks>
/// <param name="storedPaths">The paths of the files the index knows about.</param>
/// <param name="files">The files this run found.</param>
@@ -536,16 +521,15 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
{
var existingPaths = files
.Select(file => file.FullName)
.ToHashSet(DataSourceEmbeddingManifest.PATH_COMPARER);
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unreadablePrefixes = unreadableDirectories
.Select(Path.GetFullPath)
.Select(directory => Path.EndsInDirectorySeparator(directory) ? directory : directory + Path.DirectorySeparatorChar)
.Select(directory => Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)) + Path.DirectorySeparatorChar)
.ToList();
return storedPaths
.Where(path => !existingPaths.Contains(path))
.Where(path => !unreadablePrefixes.Any(prefix => path.StartsWith(prefix, DataSourceEmbeddingManifest.PATH_COMPARISON)))
.Where(path => !unreadablePrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
@@ -725,7 +709,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
switch (dataSource)
{
case DataSourceLocalFile localFile when RelocatedFilePaths.IsLocalAbsolute(localFile.FilePath) && File.Exists(localFile.FilePath):
case DataSourceLocalFile localFile when File.Exists(localFile.FilePath):
var file = new FileInfo(localFile.FilePath);
// No size limit: the user chose this very file, cf. FolderFileSelection.IsTooLarge.
@@ -734,10 +718,9 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
return result;
case DataSourceLocalDirectory localDirectory when RelocatedFilePaths.IsLocalAbsolute(localDirectory.Path) && Directory.Exists(localDirectory.Path):
case DataSourceLocalDirectory localDirectory when Directory.Exists(localDirectory.Path):
this.EnumerateAccessibleFiles(localDirectory, manifest, mediaTranscriptionAvailability, result);
if (result.UnreadableDirectories.Any(directory => string.Equals(Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)),
Path.TrimEndingDirectorySeparator(Path.GetFullPath(localDirectory.Path)), PathTools.COMPARISON)))
if (result.UnreadableDirectories.Contains(localDirectory.Path))
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;
@@ -749,11 +732,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. 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);
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);
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. 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);
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);
break;
}
@@ -952,8 +935,8 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
private DataSourceMetadataSnapshot BuildDataSourceMetadataSnapshot(IDataSource dataSource, IReadOnlyList<FileInfo> indexedFiles)
{
var fileHashes = indexedFiles
.OrderBy(file => file.FullName, DataSourceEmbeddingManifest.PATH_COMPARER)
.ToDictionary(file => file.FullName, BuildFileMetadataHash, DataSourceEmbeddingManifest.PATH_COMPARER);
.OrderBy(file => file.FullName, StringComparer.OrdinalIgnoreCase)
.ToDictionary(file => file.FullName, BuildFileMetadataHash, StringComparer.OrdinalIgnoreCase);
var sourceHash = dataSource switch
{
@@ -997,7 +980,7 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
indexedFiles.Count.ToString()
};
foreach (var file in indexedFiles.OrderBy(file => file.FullName, DataSourceEmbeddingManifest.PATH_COMPARER))
foreach (var file in indexedFiles.OrderBy(file => file.FullName, StringComparer.OrdinalIgnoreCase))
{
parts.Add(this.TryGetRelativePath(dataSource, file));
parts.Add(fileHashes[file.FullName]);
@@ -1019,24 +1002,14 @@ internal sealed partial class FileSourceIndexer(SettingsManager settingsManager,
"0");
}
return BuildFileMetadataHash(file.FullName, file.Name, file.LastWriteTimeUtc, file.Length);
return BuildMetadataHash(
"file",
file.FullName,
file.Name,
file.LastWriteTimeUtc.Ticks.ToString(),
file.Length.ToString());
}
/// <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);
@@ -1063,7 +1036,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 { ParentFileId = context.GetFileParentId(file.FullName), TranscriptKey = transcript?.Key },
this.CreateEmbeddingStateFile(dataSource, file, fingerprint, 0, DateTimeOffset.UtcNow) with { TranscriptKey = transcript?.Key },
file.Name,
token => this.StreamEmbeddingChunksAsync(file.FullName, transcript, dataSource, context.EmbeddingProvider, context.PromptInjectionScope, token));
@@ -7,11 +7,9 @@ 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 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.
/// 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.
/// </remarks>
internal static class IndexedDocumentIds
{
@@ -19,31 +17,11 @@ 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="source">What the chunk ids of the document derive from, cf. CreateChunkIdSource.</param>
/// <param name="fingerprint">The fingerprint of the document as it was read.</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 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}";
public static string CreateChunkId(string dataSourceId, string fingerprint, int chunkIndex) =>
CreateStableGuid($"{dataSourceId}:chunk:{fingerprint}:{chunkIndex}");
/// <summary>
/// The id of a document's row in the index store, which its chunks point at.
@@ -41,9 +41,6 @@ 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>
@@ -69,38 +66,6 @@ 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>
@@ -159,11 +124,10 @@ 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, chunkIdSource, totalChunkCount), chunk.Text, totalChunkCount, chunk.PageNumber));
batch.Add(new(IndexedDocumentIds.CreateChunkId(dataSource.Id, document.State.Fingerprint, totalChunkCount), chunk.Text, totalChunkCount, chunk.PageNumber));
totalChunkCount++;
reportBlockProgress(totalChunkCount, chunk.PageNumber);
@@ -199,10 +163,7 @@ internal sealed class IndexedRunContext(IIndexedDataSource dataSource, Embedding
/// <param name="token">The cancellation token.</param>
public async Task DeleteDocumentPointsAsync(string documentKey, CancellationToken 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);
await vectorStore.DeleteEmbeddingByFile(this.CollectionName, documentKey, token);
this.hasPendingChanges = true;
}
@@ -440,4 +401,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(DataSourceEmbeddingManifest.PATH_COMPARER);
private readonly Dictionary<string, FailedDocument> failedDocuments = new(StringComparer.OrdinalIgnoreCase);
//
// Which kinds of provider failure the user was already told about in this pass. A rejected API
@@ -1,85 +0,0 @@
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,10 +13,9 @@ public sealed partial class RustService
async () =>
{
var encodedTitle = Uri.EscapeDataString(title);
var startDirectory = initialDirectory is null ? null : PathTools.FindNearestExistingPath(initialDirectory);
var result = startDirectory is null
var result = initialDirectory is null
? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null)
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(startDirectory), this.jsonRustSerializerOptions);
: await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(initialDirectory), this.jsonRustSerializerOptions);
if (result.IsSuccessStatusCode)
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
@@ -55,11 +54,10 @@ public sealed partial class RustService
"select file",
async () =>
{
var startPath = initialFile is null ? null : PathTools.FindNearestExistingPath(initialFile);
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = startPath is null ? null : new (startPath),
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.ToFileDialogFilter(filter)
};
@@ -79,11 +77,10 @@ public sealed partial class RustService
"select files",
async () =>
{
var startPath = initialFile is null ? null : PathTools.FindNearestExistingPath(initialFile);
var payload = new SelectFileOptions
{
Title = title,
PreviousFile = startPath is null ? null : new (startPath),
PreviousFile = initialFile is null ? null : new (initialFile),
Filter = FileTypes.ToFileDialogFilter(filter)
};
+7
View File
@@ -532,6 +532,13 @@ 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
@@ -38,10 +38,8 @@
- Improved local data sources for folders. You can now choose which files AI Studio indexes, for example, only PDF files, and whether it includes subfolders as well as hidden files and folders. By default, AI Studio now also indexes text files it did not recognize before, such as program code of any kind or a LICENSE file, so a Git repository makes a useful data source. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Improved local data sources for folders, which now leave out hidden files and folders as well as build output and downloaded dependencies, such as the folders `node_modules`, `bin`, and `obj`. You can change this in the expert settings of the data source. When an existing data source contains such files, AI Studio removes them from its index during the next update.
- 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 also processes images in JPG, PNG, WebP, and GIF format when the selected model can read images. If you used the assistant before, your saved file patterns stay as they are: click "Restore default patterns" or add patterns such as `*.jpg;*.png` yourself.
- 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.
@@ -73,7 +71,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 and to a folder that suddenly appears empty, which can happen when a drive is not connected.
- 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 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.
@@ -109,9 +107,7 @@
- Fixed AI Studio not checking the transcripts of audio and video files for suspicious instructions (prompt injection) in the Batch Processing assistant and when you load such a file into a field of an assistant. AI Studio now checks these transcripts like all other documents before the AI sees them, even very long ones.
- Fixed AI Studio deleting the API keys you entered yourself for the providers of your organization when your organization changed the secret that protects its configuration. These keys now stay, so you no longer need to enter them again.
- Fixed sharing plugins when the share dialog of Windows or macOS does not open or stops responding. AI Studio now notices this and offers to save the plugin archive to a location of your choice instead. Until you restart AI Studio, the plugin page then offers this export instead of sharing.
- Fixed the Batch Processing assistant finding no documents when you chose an output folder that contains the input folder, such as its parent folder.
- Fixed the Batch Processing assistant processing its own result files again as new documents when you chose the input folder as the output folder.
- Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far.
- Upgraded several libraries to improve security.
- Upgraded to Rust v1.99.1
- Upgraded to Rust v1.99.0
- Upgraded to .NET 9.0.21
@@ -1,165 +0,0 @@
using AIStudio.Assistants.BatchProcessing;
namespace AIStudio.Tests.Assistants;
/// <summary>
/// Checks which files the search of the batch processing assistant leaves out because a batch run
/// wrote them.
/// </summary>
/// <remarks>
/// A run writes everything directly into its output folder. The search comes across any of it only
/// when that folder lies below the input folder or is the input folder itself. An output folder
/// above the input folder once made the search leave out every document.
/// </remarks>
[TestFixture]
public sealed class BatchProcessingOutputFolderTests
{
private const string RESULTS_FILE_NAME = "batch-results.csv";
private static readonly string DESKTOP = Path.Combine(Path.GetTempPath(), "ai-studio-batch-output-folder", "Desktop");
private static readonly string PROJECT_FOLDER = Path.Combine(DESKTOP, "Batch");
private static readonly string INPUT_FOLDER = Path.Combine(PROJECT_FOLDER, "Documents");
[Test]
public void AnOutputFolderAboveTheInputFolderLeavesOutNoDocument()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, PROJECT_FOLDER, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.pdf")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "notes", "minutes.md")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "log.csv")), Is.False, "The log of a run lies in the output folder, so a file of this name in the input folder is a document.");
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "notes")), Is.True);
});
}
[Test]
public void AnOutputFolderSeveralLevelsAboveTheInputFolderLeavesOutNoDocument()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, DESKTOP, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.pdf")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "notes", "minutes.md")), Is.False);
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "notes")), Is.True);
});
}
[Test]
public void TheDefaultOutputFolderBelowTheInputFolderIsLeftOut()
{
var resultsFolder = Path.Combine(INPUT_FOLDER, "ai-results");
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, resultsFolder, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(resultsFolder, "report_result.md")), Is.True);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.pdf")), Is.False);
Assert.That(outputFolder.Enters(resultsFolder), Is.False);
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "notes")), Is.True);
});
}
[Test]
public void AnOutputFolderDeepBelowTheInputFolderIsLeftOutAlone()
{
var parentFolder = Path.Combine(INPUT_FOLDER, "2026", "october");
var resultsFolder = Path.Combine(parentFolder, "results");
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, resultsFolder, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(resultsFolder, "report_result.md")), Is.True);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(parentFolder, "report.pdf")), Is.False);
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "2026")), Is.True);
Assert.That(outputFolder.Enters(parentFolder), Is.True);
Assert.That(outputFolder.Enters(resultsFolder), Is.False);
});
}
[Test]
public void ADocumentWhoseNameBeginsLikeTheOutputFolderIsNoOutput()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, Path.Combine(INPUT_FOLDER, "out"), RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "output-notes.md")), Is.False);
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "output")), Is.True);
});
}
[Test]
public void AnOutputFolderNextToTheInputFolderWithTheSameBeginningLeavesOutNothing()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, $"{INPUT_FOLDER}-results", RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.pdf")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "log.csv")), Is.False);
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "notes")), Is.True);
});
}
[TestCase(false)]
[TestCase(true)]
public void TheInputFolderAsOutputFolderLeavesOutTheLogAndTheResultsTable(bool withTrailingSeparator)
{
var outputDirectory = withTrailingSeparator ? INPUT_FOLDER + Path.DirectorySeparatorChar : INPUT_FOLDER;
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, outputDirectory, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "log.csv")), Is.True);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, RESULTS_FILE_NAME)), Is.True);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.pdf")), Is.False);
Assert.That(outputFolder.Enters(Path.Combine(INPUT_FOLDER, "notes")), Is.True, "Leaving out the output folder would leave out the input folder, too.");
});
}
[Test]
public void TheInputFolderAsOutputFolderLeavesOutTheResultFilesOfEveryFormat()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, INPUT_FOLDER, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report_result.md")), Is.True);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report_result_2.docx")), Is.True, "A second result of the same name gets a counter.");
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "Report_Result.HTML")), Is.True);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.v2_result.md")), Is.True, "Only the last extension of the document makes way for the suffix.");
});
}
[Test]
public void TheInputFolderAsOutputFolderKeepsDocumentsWithSimilarNames()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, INPUT_FOLDER, RESULTS_FILE_NAME);
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report.md")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "result.md")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "my_results.md")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report_result_draft.md")), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(INPUT_FOLDER, "report_result_.md")), Is.False);
});
}
[Test]
public void TheInputFolderAsOutputFolderKeepsFilesInSubfolders()
{
var outputFolder = new BatchProcessingOutputFolder(INPUT_FOLDER, INPUT_FOLDER, RESULTS_FILE_NAME);
var subfolder = Path.Combine(INPUT_FOLDER, "notes");
Assert.Multiple(() =>
{
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(subfolder, "log.csv")), Is.False, "A run writes into the output folder itself, never into one of its subfolders.");
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(subfolder, RESULTS_FILE_NAME)), Is.False);
Assert.That(outputFolder.IsOutputOfRun(Path.Combine(subfolder, "report_result.md")), Is.False);
});
}
}
@@ -0,0 +1,201 @@
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();
}
}
@@ -0,0 +1,377 @@
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;
}
}
@@ -0,0 +1,181 @@
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);
}
}
@@ -0,0 +1,84 @@
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();
}
}
-45
View File
@@ -1,45 +0,0 @@
using AIStudio.Chat;
using AIStudio.Provider;
namespace AIStudio.Tests.Provider;
/// <summary>
/// Checks how large an image may be when we send it through a provider.
/// </summary>
/// <remarks>
/// A provider states its limit or says nothing. Either way, TryAsBase64 skips every image above our
/// own limit, so a larger limit of a provider must never reach the checks done before sending.
/// </remarks>
[TestFixture]
public sealed class ImageSizeLimitTests
{
[Test]
public void AnthropicLeavesRoomForTheBase64OfTheImage()
{
Assert.Multiple(() =>
{
Assert.That(LLMProviders.ANTHROPIC.GetMaxImageSizeBytes(), Is.EqualTo(3_932_160));
Assert.That(IImageSourceExtensions.MaxImageSizeBytesFor(LLMProviders.ANTHROPIC), Is.EqualTo(3_932_160));
});
}
[Test]
public void AProviderWithoutAStatedLimitGetsOurOwn()
{
Assert.Multiple(() =>
{
Assert.That(LLMProviders.OPEN_AI.GetMaxImageSizeBytes(), Is.Null, "Nobody stated a limit, and no number stands in for that.");
Assert.That(IImageSourceExtensions.MaxImageSizeBytesFor(LLMProviders.OPEN_AI), Is.EqualTo(IImageSourceExtensions.MAX_IMAGE_SIZE_BYTES));
});
}
[Test]
public void NoProviderGetsMoreThanTryAsBase64Sends()
{
Assert.Multiple(() =>
{
foreach (var provider in Enum.GetValues<LLMProviders>())
Assert.That(IImageSourceExtensions.MaxImageSizeBytesFor(provider), Is.LessThanOrEqualTo(IImageSourceExtensions.MAX_IMAGE_SIZE_BYTES), provider.ToString());
});
}
}
@@ -1,20 +0,0 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Rust;
namespace AIStudio.Tests.Settings;
public class DataBatchProcessingTests
{
/// <summary>
/// The default patterns are a constant and cannot be built from FileTypes, so this keeps both
/// lists of the images we hand to the model in step.
/// </summary>
[Test]
public void DefaultPatternsHoldEveryImageTheModelCanRead()
{
var patterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS.Split(';');
var imagePatterns = FileTypes.MODEL_IMAGE.FlattenExtensions().Select(extension => $"*.{extension}");
Assert.That(patterns, Is.SupersetOf(imagePatterns));
}
}
@@ -1,63 +0,0 @@
using AIStudio.Provider;
using AIStudio.Settings;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks whether a configured model counts as able to read images.
/// </summary>
/// <remarks>
/// Attaching a file to a chat, building a request, and the Batch Processing assistant all ask this one
/// question. A limit of zero images is a statement about the installation, so it has to win over the
/// capability, while a limit nobody stated must not take anything away.
/// </remarks>
[TestFixture]
public sealed class ImageInputSupportTests
{
private const string MACHINE = "55555555-5555-5555-5555-555555555555";
/// <summary>
/// A model the rules know as reading text only, so that every image capability comes from the overrides.
/// </summary>
private static readonly Model MODEL = new("llama3.3:70b", null);
[Test]
public void AModelWithoutImageCapabilityCannotReadImages()
{
Assert.That(ProviderWith(null).SupportsImageInput(), Is.False);
}
[Test]
public void AnImageCapabilityWithoutAStatedLimitReadsImages()
{
Assert.That(ProviderWith(new() { MultipleImageInput = true }).SupportsImageInput(), Is.True, "Nobody stated a limit, and an unknown limit takes nothing away.");
}
[Test]
public void ALimitOfOneImageStillReadsImages()
{
Assert.That(ProviderWith(new() { MultipleImageInput = true, MaxImagesPerMessage = 1 }).SupportsImageInput(), Is.True);
}
[Test]
public void ALimitOfZeroImagesPerMessageTakesTheCapabilityBack()
{
Assert.That(ProviderWith(new() { MultipleImageInput = true, MaxImagesPerMessage = 0 }).SupportsImageInput(), Is.False);
}
[Test]
public void ALimitOfZeroImagesPerRequestTakesTheCapabilityBack()
{
Assert.That(ProviderWith(new() { MultipleImageInput = true, MaxImagesPerRequest = 0 }).SupportsImageInput(), Is.False, "A message cannot carry more images than its whole request.");
}
/// <summary>
/// A configured self-hosted provider, the way the settings hold one.
/// </summary>
/// <param name="overrides">What the person switched, or nothing when they switched nothing.</param>
/// <returns>The configured provider.</returns>
private static AIStudio.Settings.Provider ProviderWith(ProviderCapabilityOverrides? overrides) => new(1, MACHINE, "A machine of my own", LLMProviders.SELF_HOSTED, MODEL, IsSelfHosted: true)
{
CapabilityOverrides = overrides,
};
}
@@ -1,752 +0,0 @@
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);
}
@@ -1,47 +0,0 @@
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.");
}
}
+13 -1
View File
@@ -40,6 +40,18 @@ 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()
{
@@ -244,4 +256,4 @@ public sealed class EmbeddingSignatureTests
public int ChunkOverlapTokenLength { get; init; }
}
}
}
+65
View File
@@ -36,6 +36,71 @@ 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()
{
@@ -0,0 +1,153 @@
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,15 +35,6 @@ 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()
{
@@ -272,12 +263,29 @@ public sealed class FileSourceIndexerWatcherTests
}
[Test]
public void RelocatingToADirectoryWithDifferentCasingUsesTheLocalPathRules()
public void ReorderingEquivalentExclusionsDoesNotRecreateTheWatcher()
{
var before = DefaultConfiguration(Path.Combine(Path.GetTempPath(), "Documents"));
var after = before with { RootPath = Path.Combine(Path.GetTempPath(), "documents") };
var before = DefaultConfiguration(Path.GetTempPath()) with
{
ExcludePatterns = FileExclusions.Parse("*.mp3;draft-*"),
};
var after = before with { ExcludePatterns = FileExclusions.Parse("DRAFT-*;*.MP3;*.mp3") };
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.");
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);
}
[Test]
@@ -1,217 +0,0 @@
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,21 +31,4 @@ 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,7 +39,8 @@ public sealed class LocalDirectoryFilePatternsTests
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
[TestCase("*.txt;")]
[TestCase(";")]
[TestCase(" ; ; ")]
[TestCase("folder/*.txt")]
public void InvalidStoredPatternsFallBackWithoutRemovingExistingDocuments(string? storedPatterns)
{
@@ -48,12 +49,60 @@ 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()
{
@@ -75,6 +124,7 @@ public sealed class LocalDirectoryFilePatternsTests
{
Type = DataSourceType.LOCAL_DIRECTORY,
FilePatterns = "*.txt;*.tf;.gitignore",
ExcludePatterns = "secret-*;*.backup",
IncludeSubdirectories = false,
IncludeHiddenFilesAndFolders = true,
};
@@ -85,8 +135,10 @@ 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);
});
@@ -419,17 +471,22 @@ 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("*.txt;")]
[TestCase(" ; ;; ")]
[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);
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,192 @@
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");
}
+2 -21
View File
@@ -112,7 +112,7 @@ public sealed class OutdatedRunTests
}
[Test]
public void RelocatingASingleFileStopsTheOldRunWithoutInvalidatingItsEmbeddings()
public void ASingleFileHasNoSelection()
{
var dataSource = new DataSourceLocalFile
{
@@ -125,26 +125,7 @@ public sealed class OutdatedRunTests
FilePath = "/tmp/test-data/report.pdf",
};
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));
});
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.");
}
[Test]
-81
View File
@@ -1,81 +0,0 @@
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
@@ -1,93 +0,0 @@
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
@@ -1,76 +0,0 @@
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);
+3 -60
View File
@@ -62,49 +62,6 @@ 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()
{
@@ -173,15 +130,6 @@ 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,
@@ -204,7 +152,6 @@ 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.");
});
}
@@ -234,16 +181,12 @@ 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,
RelativePath: Path.GetFileName(file), ParentFileId: IndexedDocumentIds.CreateParentId(dataSource.Id, file));
manifest.Files[file] = new EmbeddedFileRecord("FINGERPRINT", 1024, DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch, 4);
manifest.UnfinishedFiles.UnionWith(unfinishedFiles);
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 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!);
+1 -1
View File
@@ -3,7 +3,7 @@
257
9.0.122 (commit 17951ad7c6)
9.0.21 (commit d280e4fb38)
1.99.1 (commit 58cf8f9c3)
1.99.0 (commit b940084d7)
8.15.0
2.11.5
9f102728b59, release
+4 -144
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, PayloadOps, PointId, PointInsertOperations, PointOperations, PointStruct, QueryEnum,
Payload, PointId, PointInsertOperations, PointOperations, PointStruct, QueryEnum,
QueryRequest, ScoredPoint, ScoringQuery, UpdateOperation, ValueVariants, VectorInternal,
SetPayloadOp, Vectors, WithPayloadInterface, WithVector,
Vectors, WithPayloadInterface, WithVector,
};
use serde::{Deserialize, Serialize};
use tauri::Manager;
@@ -131,17 +131,6 @@ 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)]
@@ -470,17 +459,11 @@ impl QdrantEdgeDatabase {
.collect())
}
fn delete_embedding_by_file(&mut self, store_name: &str, file_path: &str, parent_file_id: &str) -> QdrantEdgeResult<()> {
fn delete_embedding_by_file(&mut self, store_name: &str, file_path: &str) -> QdrantEdgeResult<()> {
let Some(shard) = self.get_existing_store(store_name)? else {
return Ok(());
};
// 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)?),
))?;
@@ -488,33 +471,6 @@ 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(());
@@ -820,13 +776,7 @@ 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, &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)
database.delete_embedding_by_file(&request.store_name, &request.file_path)
}).await
}
@@ -1539,49 +1489,6 @@ 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);
@@ -1744,53 +1651,6 @@ 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,7 +42,6 @@ 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))