mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-14 02:53:37 +00:00
Accept dropped files while the attachment dialogs are open (#965)
Some checks are pending
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
8a735c2bbe
commit
bb3ec5da92
@ -6397,6 +6397,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image
|
||||
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."
|
||||
|
||||
-- You can drag another file into this window. We attach it right away and show it here.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2822249202"] = "You can drag another file into this window. We attach it right away and show it here."
|
||||
|
||||
-- See how we load your file. Review the content before we process it further.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
||||
|
||||
@ -7258,12 +7261,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T900713019"] = "Canc
|
||||
-- Embeddings
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T951463987"] = "Embeddings"
|
||||
|
||||
-- Attached {0} files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1736997462"] = "Attached {0} files."
|
||||
|
||||
-- Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1746160064"] = "Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon."
|
||||
|
||||
-- Attached {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "Attached {0}."
|
||||
|
||||
-- There aren't any file attachments available right now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "There aren't any file attachments available right now."
|
||||
|
||||
-- You can drag more files into this window to attach them right away.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "You can drag more files into this window to attach them right away."
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Document Preview"
|
||||
|
||||
|
||||
@ -220,11 +220,31 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
/// Attaches what the user dropped on the zone of this component.
|
||||
/// </summary>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
private async Task PathsDropped(List<string> paths)
|
||||
private async Task PathsDropped(List<string> paths) => await this.AttachDroppedPathsAsync(paths);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches dropped paths and reports which files it made of them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is what the attachment dialogs call while they are open: a drop lands in the dialog the
|
||||
/// user is looking at, yet only this component knows how to turn a path into an attachment. The
|
||||
/// answer is what lets those dialogs show the result, see PathsDropped for our own zone.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
/// <returns>The files this call attached, in the order they were dropped.</returns>
|
||||
private async Task<IReadOnlyList<FileAttachment>> AttachDroppedPathsAsync(List<string> paths)
|
||||
{
|
||||
await this.AddFileBatchAsync(paths);
|
||||
var attached = await this.AddFileBatchAsync(paths);
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
|
||||
//
|
||||
// A dialog reaches us through a delegate rather than through an event callback, so nothing
|
||||
// renders this component afterwards. Without this, the number on the badge would stay at its
|
||||
// old value until something else happens to render us.
|
||||
//
|
||||
this.StateHasChanged();
|
||||
return attached;
|
||||
}
|
||||
|
||||
private async Task AddFilesManually()
|
||||
@ -255,7 +275,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
return;
|
||||
|
||||
var previousAttachments = this.DocumentPaths.ToHashSet();
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable);
|
||||
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
|
||||
|
||||
@ -307,8 +327,18 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
this.OwnerChat.PendingMediaTranscripts.RemoveAll(attachment => !retainedPaths.Contains(attachment.FilePath));
|
||||
}
|
||||
|
||||
private async Task AddFileBatchAsync(IEnumerable<string> paths)
|
||||
/// <summary>
|
||||
/// Validates the given paths and attaches every file which passes.
|
||||
/// </summary>
|
||||
/// <param name="paths">The paths to attach, in the order they arrived.</param>
|
||||
/// <returns>
|
||||
/// The files this call attached, in the order they arrived. Media files are never among them:
|
||||
/// they go to the transcription service and become attachments only once their transcript is
|
||||
/// ready, which is long after this call has returned.
|
||||
/// </returns>
|
||||
private async Task<IReadOnlyList<FileAttachment>> AddFileBatchAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var attached = new List<FileAttachment>();
|
||||
var pathList = paths.ToList();
|
||||
if (this.AllowedFileTypes is { Length: > 0 })
|
||||
{
|
||||
@ -356,18 +386,25 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
|
||||
continue;
|
||||
|
||||
this.DocumentPaths.Add(FileAttachment.FromPath(path));
|
||||
//
|
||||
// This counts as attached even when the set already held the file: the user just
|
||||
// dropped it, and whoever asked us wants to hear about the file they aimed at, not
|
||||
// about whether it happened to be new to us.
|
||||
//
|
||||
var attachment = FileAttachment.FromPath(path);
|
||||
this.DocumentPaths.Add(attachment);
|
||||
attached.Add(attachment);
|
||||
}
|
||||
|
||||
if (mediaPaths.Count is 0)
|
||||
return;
|
||||
return attached;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider))
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(
|
||||
Icons.Material.Filled.VoiceChat,
|
||||
this.T("Media files require a configured transcription provider. Configure one in the transcription settings.")));
|
||||
return;
|
||||
return attached;
|
||||
}
|
||||
|
||||
var names = string.Join('\n', mediaPaths.Select(path => $"- {Markdown.EscapeInlineText(Path.GetFileName(path))}"));
|
||||
@ -391,7 +428,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
return attached;
|
||||
|
||||
if (this.OwnerChat is null)
|
||||
this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]);
|
||||
@ -408,6 +445,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
}
|
||||
|
||||
this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveMediaImportTarget, this.OwnerChat);
|
||||
return attached;
|
||||
}
|
||||
|
||||
private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO);
|
||||
@ -421,6 +459,8 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
var dialogParameters = new DialogParameters<DocumentCheckDialog>
|
||||
{
|
||||
{ x => x.Document, fileAttachment },
|
||||
{ x => x.AttachPaths, this.AttachDroppedPathsAsync },
|
||||
{ x => x.IsAttachingUnavailable, () => this.IsUnavailable },
|
||||
};
|
||||
|
||||
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
|
||||
@ -3,20 +3,34 @@
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
|
||||
<PathDropZone IsArea="@true">
|
||||
<PathDropZone IsArea="@true"
|
||||
IdPrefix="document-check"
|
||||
OnPathsDropped="@this.DropCallback"
|
||||
Disabled="@this.IsZoneDisabled"
|
||||
Context="isDropTarget">
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("See how we load your file. Review the content before we process it further.")
|
||||
</MudJustifiedText>
|
||||
|
||||
@if (this.Document is null)
|
||||
@if (this.CanAttach)
|
||||
{
|
||||
<ReadFileContent Text="@T("Load file")" FileContent="@this.FileContent" FileContentChanged="@this.ApplyLoadedFileContent" EnableDragDrop="true" CatchAllDocuments="true"/>
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("You can drag another file into this window. We attach it right away and show it here.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
|
||||
@if (this.document is null)
|
||||
{
|
||||
<ReadFileContent Text="@T("Load file")" FileContent="@this.fileContent" FileContentChanged="@this.ApplyLoadedFileContent" EnableDragDrop="true" CatchAllDocuments="true"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Keys have to be unique among siblings, no matter the component: this field and the
|
||||
tabs below both stand for the document and would otherwise collide on its path. *@
|
||||
<MudTextField
|
||||
@key="@($"file-path-{this.document.FilePath}")"
|
||||
T="string"
|
||||
Text="@this.Document.FilePath"
|
||||
Text="@this.document.FilePath"
|
||||
AdornmentIcon="@Icons.Material.Filled.FileOpen"
|
||||
Adornment="Adornment.Start"
|
||||
Immediate="@true"
|
||||
@ -29,7 +43,10 @@
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!this.Document?.Exists ?? false)
|
||||
@* The frame shows where a dropped file would land. It is drawn only while this dialog can
|
||||
take one, and keeps its width in both states so that nothing jumps during a drag: *@
|
||||
<div class="@this.PreviewAreaClass(isDropTarget)">
|
||||
@if (!this.document?.Exists ?? false)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
|
||||
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
|
||||
@ -60,11 +77,13 @@
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudTabs Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||
@if (this.Document?.IsImage ?? false)
|
||||
@* Keyed by the document: a switch from an image to a text file changes which panels
|
||||
exist, and a leftover active panel would point at one that is gone. *@
|
||||
<MudTabs @key="@($"preview-tabs-{this.document?.FilePath}")" Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
|
||||
@if (this.document?.IsImage ?? false)
|
||||
{
|
||||
<MudTabPanel Text="@T("Image View")" Icon="@Icons.Material.Filled.Image">
|
||||
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.Document.FilePathAsUrl"/>
|
||||
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.document.FilePathAsUrl"/>
|
||||
</MudTabPanel>
|
||||
}
|
||||
else
|
||||
@ -102,6 +121,7 @@
|
||||
}
|
||||
</MudTabs>
|
||||
}
|
||||
</div>
|
||||
</PathDropZone>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@ -21,6 +21,46 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
[Parameter]
|
||||
public string FileContent { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the files the user drops onto this dialog, and answers which of them it attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null when our caller has no list of attachments to add to, which is the case for the prompt
|
||||
/// guide preview of the Prompt Optimizer. This dialog then shows its document and nothing else,
|
||||
/// exactly as it always did.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? AttachPaths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides, at the moment a drop arrives, whether attaching is possible right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked rather than passed as a value, because the answer changes while this dialog is open:
|
||||
/// dropping a media file starts a transcription, and nothing else may be attached until that
|
||||
/// one is through.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<bool>? IsAttachingUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The document we show right now. It starts out as the one we were opened with and changes
|
||||
/// whenever the user drops another file onto this dialog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Kept in a field rather than read from the parameter: the dialog fragment is rendered again
|
||||
/// with the parameters captured when it was opened, whenever something about the dialog stack
|
||||
/// changes. That happens in the middle of a drop, because attaching may open the Pandoc dialog
|
||||
/// or ask the user about a media file -- reading the parameter would undo the switch right
|
||||
/// after it was made.
|
||||
/// </remarks>
|
||||
private FileAttachment? document;
|
||||
|
||||
/// <summary>
|
||||
/// The content of the document we show, either handed to us by our caller or read by us.
|
||||
/// </summary>
|
||||
private string fileContent = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How many characters we show at most. Rendering a huge document costs us a large Markdown
|
||||
/// syntax tree and an equally large render tree. This dialog answers the question of how we
|
||||
@ -46,9 +86,20 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
private int previewCutOffCharacters;
|
||||
|
||||
/// <summary>
|
||||
/// Ends the extraction when this dialog is gone before the file was read completely.
|
||||
/// Ends the extraction when this dialog is gone, or when another document took the place of
|
||||
/// the one being read, before that file was read completely.
|
||||
/// </summary>
|
||||
private readonly CancellationTokenSource extractionCancellation = new();
|
||||
private CancellationTokenSource extractionCancellation = new();
|
||||
|
||||
/// <summary>
|
||||
/// Numbers the loads, so that a load can tell whether it still owns this dialog.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Cancelling ends the waiting, not the code behind it: what follows every await of an
|
||||
/// abandoned load runs regardless. Without this number, its final block would clear the loading
|
||||
/// state of the load which replaced it, and the new document would never leave its skeletons.
|
||||
/// </remarks>
|
||||
private int loadGeneration;
|
||||
|
||||
/// <summary>
|
||||
/// True once this dialog was disposed. The extraction runs across awaits, so it may return
|
||||
@ -73,56 +124,101 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
//
|
||||
// Decide before the first render whether we have to read the file at all. Images are shown
|
||||
// as they are, a missing file shows its own message, and content a caller already handed
|
||||
// us is reused instead of being extracted a second time:
|
||||
//
|
||||
this.isLoadingContent =
|
||||
this.Document is not null &&
|
||||
!this.Document.IsImage &&
|
||||
this.Document.Exists &&
|
||||
string.IsNullOrWhiteSpace(this.FileContent);
|
||||
this.document = this.Document;
|
||||
this.fileContent = this.FileContent;
|
||||
|
||||
this.isLoadingContent = this.NeedsExtraction();
|
||||
this.UpdatePreview();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && this.Document is not null)
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
if (this.document is null)
|
||||
{
|
||||
if (!this.isLoadingContent)
|
||||
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.LoadDocumentContentAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the document we show has to be read before we can show anything of it. Images are
|
||||
/// shown as they are, a missing file shows its own message, and content a caller already handed
|
||||
/// us is reused instead of being extracted a second time.
|
||||
/// </summary>
|
||||
private bool NeedsExtraction() =>
|
||||
this.document is not null &&
|
||||
!this.document.IsImage &&
|
||||
this.document.Exists &&
|
||||
string.IsNullOrWhiteSpace(this.fileContent);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the content of the document we show and puts it into the preview.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Runs after a render, so the user sees that we are working instead of an empty document. It
|
||||
/// is called for the document this dialog was opened with, and again for every file the user
|
||||
/// drops onto it.
|
||||
/// </remarks>
|
||||
private async Task LoadDocumentContentAsync()
|
||||
{
|
||||
if (this.document is null || !this.isLoadingContent)
|
||||
return;
|
||||
|
||||
//
|
||||
// A drop may arrive while we are still reading the file before it. We number this load and
|
||||
// end the previous one, so that what is left of it recognizes that this dialog has moved on:
|
||||
//
|
||||
var generation = ++this.loadGeneration;
|
||||
var documentToLoad = this.document;
|
||||
|
||||
var previousCancellation = this.extractionCancellation;
|
||||
this.extractionCancellation = new();
|
||||
var cancellationToken = this.extractionCancellation.Token;
|
||||
|
||||
await previousCancellation.CancelAsync();
|
||||
previousCancellation.Dispose();
|
||||
|
||||
if (this.isDisposed || generation != this.loadGeneration)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var extraction = await UserFile.LoadFileData(this.Document.FilePath, this.RustService, this.PandocAvailability, this.extractionCancellation.Token);
|
||||
if (this.isDisposed)
|
||||
var extraction = await UserFile.LoadFileData(documentToLoad.FilePath, this.RustService, this.PandocAvailability, cancellationToken);
|
||||
if (this.isDisposed || generation != this.loadGeneration)
|
||||
return;
|
||||
|
||||
this.FileContent = extraction.Content;
|
||||
this.fileContent = extraction.Content;
|
||||
|
||||
//
|
||||
// This dialog exists so the user can check what we hand to the AI. Showing an
|
||||
// empty document when reading the file failed would answer that question wrong.
|
||||
//
|
||||
if (!extraction.HasUsableContent)
|
||||
this.loadFailureMessage = extraction.ToUserMessage(this.Document.FileName);
|
||||
this.loadFailureMessage = extraction.ToUserMessage(documentToLoad.FileName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The user closed this dialog while we were reading the file. Nothing left to do.
|
||||
// Either the user closed this dialog, or another document took the place of this one
|
||||
// while we were reading it. Nothing left to do in both cases.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", this.Document);
|
||||
this.FileContent = string.Empty;
|
||||
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(this.Document.FileName);
|
||||
this.Logger.LogError(ex, "Failed to load file content from '{FilePath}'", documentToLoad.FilePath);
|
||||
if (this.isDisposed || generation != this.loadGeneration)
|
||||
return;
|
||||
|
||||
this.fileContent = string.Empty;
|
||||
this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(documentToLoad.FileName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!this.isDisposed)
|
||||
if (!this.isDisposed && generation == this.loadGeneration)
|
||||
{
|
||||
this.isLoadingContent = false;
|
||||
this.UpdatePreview();
|
||||
@ -130,18 +226,92 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (firstRender)
|
||||
this.Logger.LogWarning("Document check dialog opened without a valid file path.");
|
||||
|
||||
/// <summary>
|
||||
/// Whether a dropped file can be both attached and shown here, which decides what this dialog
|
||||
/// says and shows -- and whether it takes drops at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without a document, this dialog offers a file to be loaded instead, and that field is the
|
||||
/// default target of this dialog. An area which reports a delegate claims that role for itself
|
||||
/// and would take every drop away from the field, so we stay a plain marker in that case.
|
||||
/// </remarks>
|
||||
private bool CanAttach => this.AttachPaths is not null && this.document is not null;
|
||||
|
||||
private EventCallback<List<string>> DropCallback => this.CanAttach
|
||||
? EventCallback.Factory.Create<List<string>>(this, this.PathsDropped)
|
||||
: default;
|
||||
|
||||
private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Marks the part of this dialog which shows the document while a file hovers over it, so it is
|
||||
/// visible where that file would land. The frame keeps its width in both states; only its color
|
||||
/// changes, or the content would jump by a few pixels with every drag.
|
||||
/// </summary>
|
||||
/// <param name="isDropTarget">Whether this dialog is the target of the drop being aimed right now.</param>
|
||||
private string PreviewAreaClass(bool isDropTarget)
|
||||
{
|
||||
if (!this.CanAttach)
|
||||
return string.Empty;
|
||||
|
||||
return isDropTarget && !this.IsZoneDisabled()
|
||||
? "border-dashed border-2 rounded-lg pa-2 mud-border-primary"
|
||||
: "border-dashed border-2 rounded-lg pa-2 mud-border-lines-default";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches what the user dropped onto this dialog and shows the first file of it.
|
||||
/// </summary>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
private async Task PathsDropped(List<string> paths)
|
||||
{
|
||||
if (this.AttachPaths is null)
|
||||
return;
|
||||
|
||||
var attached = await this.AttachPaths(paths);
|
||||
if (this.isDisposed)
|
||||
return;
|
||||
|
||||
//
|
||||
// Nothing came of the drop: the file is of a kind we do not take, Pandoc is missing, the
|
||||
// validation refused it, or it is a media file whose transcript does not exist yet. The
|
||||
// reason is already on its way to the user, and the document they were looking at stays.
|
||||
//
|
||||
if (attached.Count is 0)
|
||||
return;
|
||||
|
||||
this.ShowDocument(attached[0]);
|
||||
|
||||
//
|
||||
// Render before reading: the skeletons of the loading state are what tells the user that
|
||||
// the preview switched at all, and reading a file may well take a moment.
|
||||
//
|
||||
this.StateHasChanged();
|
||||
await this.LoadDocumentContentAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows another document, discarding everything that belonged to the previous one.
|
||||
/// </summary>
|
||||
/// <param name="attachment">The document to show from now on.</param>
|
||||
private void ShowDocument(FileAttachment attachment)
|
||||
{
|
||||
this.document = attachment;
|
||||
this.fileContent = string.Empty;
|
||||
this.loadFailureMessage = null;
|
||||
this.isLoadingContent = this.NeedsExtraction();
|
||||
this.UpdatePreview();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the user loads a file through this dialog. We don't use a two-way binding here,
|
||||
/// since we have to refresh the preview whenever the content changes.
|
||||
/// </summary>
|
||||
/// <param name="fileContent">The content of the file the user has loaded.</param>
|
||||
private void ApplyLoadedFileContent(string fileContent)
|
||||
/// <param name="loadedContent">The content of the file the user has loaded.</param>
|
||||
private void ApplyLoadedFileContent(string loadedContent)
|
||||
{
|
||||
this.FileContent = fileContent;
|
||||
this.fileContent = loadedContent;
|
||||
this.UpdatePreview();
|
||||
}
|
||||
|
||||
@ -150,9 +320,9 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
/// </summary>
|
||||
private void UpdatePreview()
|
||||
{
|
||||
if (this.FileContent.Length <= PREVIEW_CHARACTER_LIMIT)
|
||||
if (this.fileContent.Length <= PREVIEW_CHARACTER_LIMIT)
|
||||
{
|
||||
this.previewContent = this.FileContent;
|
||||
this.previewContent = this.fileContent;
|
||||
this.previewCutOffCharacters = 0;
|
||||
return;
|
||||
}
|
||||
@ -161,12 +331,12 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
// We cut at the last line break before our limit. Otherwise, we might tear apart a Markdown
|
||||
// construct like a table row or a code fence in the middle of a line:
|
||||
//
|
||||
var cutIndex = this.FileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1;
|
||||
var cutIndex = this.fileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1;
|
||||
if (cutIndex < 1)
|
||||
cutIndex = PREVIEW_CHARACTER_LIMIT;
|
||||
|
||||
this.previewContent = this.FileContent[..cutIndex];
|
||||
this.previewCutOffCharacters = this.FileContent.Length - cutIndex;
|
||||
this.previewContent = this.fileContent[..cutIndex];
|
||||
this.previewCutOffCharacters = this.fileContent.Length - cutIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -177,6 +347,11 @@ public partial class DocumentCheckDialog : MSGComponentBase
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.isDisposed = true;
|
||||
|
||||
//
|
||||
// Only the running load is left to end here: every load we replaced was ended and disposed
|
||||
// the moment its successor started.
|
||||
//
|
||||
this.extractionCancellation.Cancel();
|
||||
this.extractionCancellation.Dispose();
|
||||
|
||||
|
||||
@ -2,13 +2,26 @@
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
|
||||
<PathDropZone IsArea="@true"
|
||||
IdPrefix="review-attachments"
|
||||
OnPathsDropped="@this.DropCallback"
|
||||
Disabled="@this.IsZoneDisabled"
|
||||
Context="isDropTarget">
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.")
|
||||
</MudJustifiedText>
|
||||
|
||||
@if (this.CanAttach)
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
|
||||
@T("You can drag more files into this window to attach them right away.")
|
||||
</MudJustifiedText>
|
||||
}
|
||||
|
||||
<MudDivider Class="mt-3 mb-3"/>
|
||||
|
||||
<div style="max-height: 50vh; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">
|
||||
<div class="@this.AttachmentListClass(isDropTarget)" style="max-height: 50vh; overflow-y: auto; overflow-x: hidden;">
|
||||
@if (!this.DocumentPaths.Any())
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1" Class="mt-3">
|
||||
@ -18,7 +31,7 @@
|
||||
|
||||
@{
|
||||
var currentFolder = string.Empty;
|
||||
foreach (var fileAttachment in this.DocumentPaths)
|
||||
foreach (var fileAttachment in this.OrderedAttachments)
|
||||
{
|
||||
var folderPath = Path.GetDirectoryName(fileAttachment.FilePath);
|
||||
if (folderPath != currentFolder)
|
||||
@ -91,6 +104,7 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</PathDropZone>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
|
||||
|
||||
@ -16,18 +16,123 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
|
||||
[Parameter]
|
||||
public HashSet<FileAttachment> DocumentPaths { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the files the user drops onto this dialog, and answers which of them it attached.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null when this dialog only shows attachments, which is the case for a message that was
|
||||
/// already sent: there is nothing left to attach to. Without this, the dialog behaves as it
|
||||
/// always did and swallows every drop.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? AttachPaths { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Decides, at the moment a drop arrives, whether attaching is possible right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked rather than passed as a value, because the answer changes while this dialog is open:
|
||||
/// dropping a media file here starts a transcription, and nothing else may be attached until
|
||||
/// that one is through.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<bool>? IsAttachingUnavailable { get; set; }
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
private void Close() => this.MudDialog.Close(DialogResult.Ok(this.DocumentPaths));
|
||||
|
||||
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, params HashSet<FileAttachment> documentPaths)
|
||||
/// <summary>Whether this dialog takes files at all, which decides what it says and shows.</summary>
|
||||
private bool CanAttach => this.AttachPaths is not null;
|
||||
|
||||
private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Binds the drop zone only when there is something to attach to. An area which reports a
|
||||
/// delegate claims the role of its own default target, and claiming it without being able to
|
||||
/// use it would swallow drops with no reason the user could see.
|
||||
/// </summary>
|
||||
private EventCallback<List<string>> DropCallback => this.AttachPaths is null
|
||||
? default
|
||||
: EventCallback.Factory.Create<List<string>>(this, this.PathsDropped);
|
||||
|
||||
/// <summary>
|
||||
/// Marks the list of attachments while a file hovers over this dialog, so it is visible where
|
||||
/// the file would land. The frame keeps its width in both states; only its color changes, or
|
||||
/// the list would jump by a few pixels with every drag.
|
||||
/// </summary>
|
||||
/// <param name="isDropTarget">Whether this dialog is the target of the drop being aimed right now.</param>
|
||||
private string AttachmentListClass(bool isDropTarget)
|
||||
{
|
||||
if (!this.CanAttach)
|
||||
return "pa-2";
|
||||
|
||||
return isDropTarget && !this.IsZoneDisabled()
|
||||
? "border-dashed border-2 rounded-lg pa-2 mud-border-primary"
|
||||
: "border-dashed border-2 rounded-lg pa-2 mud-border-lines-default";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The attachments, sorted by their folder and, within it, by their file name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list below starts a new heading whenever the folder changes from one attachment to the
|
||||
/// next, which names every folder exactly once -- but only as long as the attachments of a
|
||||
/// folder arrive together. The set behind them keeps no order of its own to guarantee that:
|
||||
/// removing one attachment already scrambles it, and one attached while this dialog is open
|
||||
/// lands at its end, giving its folder a second heading further down. Sorting here is what that
|
||||
/// list assumes anyway.
|
||||
/// </remarks>
|
||||
private IEnumerable<FileAttachment> OrderedAttachments => this.DocumentPaths
|
||||
.OrderBy(attachment => Path.GetDirectoryName(attachment.FilePath) ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(attachment => attachment.FileName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches what the user dropped onto this dialog and answers which files that became.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every drop takes this way, the ones aimed at the document preview above this dialog
|
||||
/// included. That is why the list is refreshed here and nowhere else.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The dropped paths, in the order the runtime delivered them.</param>
|
||||
/// <returns>The files which were attached, in the order they were dropped.</returns>
|
||||
private async Task<IReadOnlyList<FileAttachment>> AttachPathsAsync(List<string> paths)
|
||||
{
|
||||
if (this.AttachPaths is null)
|
||||
return [];
|
||||
|
||||
var attached = await this.AttachPaths(paths);
|
||||
this.StateHasChanged();
|
||||
|
||||
//
|
||||
// The list scrolls, so a newly attached file may well sit outside the visible part of it.
|
||||
// Saying so is cheaper than scrolling there, and the snackbar is skipped by the hit test,
|
||||
// so it never gets in the way of the next drop.
|
||||
//
|
||||
if (attached.Count > 0)
|
||||
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.AttachFile, attached.Count is 1
|
||||
? string.Format(T("Attached {0}."), attached[0].FileName)
|
||||
: string.Format(T("Attached {0} files."), attached.Count)));
|
||||
|
||||
return attached;
|
||||
}
|
||||
|
||||
private async Task PathsDropped(List<string> paths) => await this.AttachPathsAsync(paths);
|
||||
|
||||
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, HashSet<FileAttachment> documentPaths, Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? attachPaths = null, Func<bool>? isAttachingUnavailable = null)
|
||||
{
|
||||
var dialogParameters = new DialogParameters<ReviewAttachmentsDialog>
|
||||
{
|
||||
{ x => x.DocumentPaths, documentPaths }
|
||||
};
|
||||
|
||||
if (attachPaths is not null)
|
||||
dialogParameters.Add(x => x.AttachPaths, attachPaths);
|
||||
|
||||
if (isAttachingUnavailable is not null)
|
||||
dialogParameters.Add(x => x.IsAttachingUnavailable, isAttachingUnavailable);
|
||||
|
||||
var dialogReference = await dialogService.ShowAsync<ReviewAttachmentsDialog>(TB("Your attached files"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
@ -58,6 +163,19 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
|
||||
{ x => x.Document, fileAttachment },
|
||||
};
|
||||
|
||||
//
|
||||
// Give the preview our own way of attaching, so a file dropped onto it lands in this list
|
||||
// as well. Not when we cannot attach anything ourselves: the preview would then claim every
|
||||
// drop and do nothing with it.
|
||||
//
|
||||
if (this.CanAttach)
|
||||
{
|
||||
dialogParameters.Add(x => x.AttachPaths, this.AttachPathsAsync);
|
||||
|
||||
if (this.IsAttachingUnavailable is not null)
|
||||
dialogParameters.Add(x => x.IsAttachingUnavailable, this.IsAttachingUnavailable);
|
||||
}
|
||||
|
||||
await this.DialogService.ShowAsync<DocumentCheckDialog>(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
}
|
||||
}
|
||||
@ -6399,6 +6399,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Bildan
|
||||
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Ihr Dokument ist groß, daher zeigen wir Ihnen hier nur den Anfang. Die verbleibenden {0:N0} Zeichen werden ausgeblendet. Keine Sorge: Die KI erhält trotzdem Ihr gesamtes Dokument."
|
||||
|
||||
-- You can drag another file into this window. We attach it right away and show it here.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2822249202"] = "Sie können eine weitere Datei in dieses Fenster ziehen. Wir hängen sie sofort an und zeigen sie Ihnen hier."
|
||||
|
||||
-- See how we load your file. Review the content before we process it further.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "So wird Ihre Datei geladen. Überprüfen Sie den Inhalt, bevor wir ihn weiterverarbeiten."
|
||||
|
||||
@ -7260,12 +7263,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T900713019"] = "Abbr
|
||||
-- Embeddings
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T951463987"] = "Einbettungen"
|
||||
|
||||
-- Attached {0} files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1736997462"] = "{0} Dateien angehängt."
|
||||
|
||||
-- Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1746160064"] = "Hier sehen Sie alle angehängten Dateien. Dateien, die nicht mehr gefunden werden können (gelöscht, umbenannt oder verschoben), sind mit einem Warnsymbol und einem durchgestrichenen Namen markiert. Sie können jeden Anhang über das Papierkorbsymbol entfernen."
|
||||
|
||||
-- Attached {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "Angehängt: {0}."
|
||||
|
||||
-- There aren't any file attachments right now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "Derzeit sind keine Dateianhänge vorhanden."
|
||||
|
||||
-- You can drag more files into this window to attach them right away.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "Du kannst weitere Dateien in dieses Fenster ziehen, um sie sofort anzuhängen."
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Dokumentvorschau"
|
||||
|
||||
|
||||
@ -6399,6 +6399,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2199753423"] = "Image
|
||||
-- Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2468296835"] = "Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."
|
||||
|
||||
-- You can drag another file into this window. We attach it right away and show it here.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2822249202"] = "You can drag another file into this window. We attach it right away and show it here."
|
||||
|
||||
-- See how we load your file. Review the content before we process it further.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T3271853346"] = "See how we load your file. Review the content before we process it further."
|
||||
|
||||
@ -7260,12 +7263,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T900713019"] = "Canc
|
||||
-- Embeddings
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::RETRIEVALPROCESSDIALOG::T951463987"] = "Embeddings"
|
||||
|
||||
-- Attached {0} files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1736997462"] = "Attached {0} files."
|
||||
|
||||
-- Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1746160064"] = "Here you can see all attached files. Files that can no longer be found (deleted, renamed, or moved) are marked with a warning icon and a strikethrough name. You can remove any attachment using the trash can icon."
|
||||
|
||||
-- Attached {0}.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T1839536175"] = "Attached {0}."
|
||||
|
||||
-- There aren't any file attachments right now.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2111340711"] = "There aren't any file attachments right now."
|
||||
|
||||
-- You can drag more files into this window to attach them right away.
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T2653077974"] = "You can drag more files into this window to attach them right away."
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::DIALOGS::REVIEWATTACHMENTSDIALOG::T285154968"] = "Document Preview"
|
||||
|
||||
|
||||
@ -23,12 +23,16 @@
|
||||
- Improved loading web content in the assistants: it now uses the same reader as the Read Web Page tool, which extracts the main content of a page more reliably and skips navigation and boilerplate. Pages from your own network, including local servers, keep working as before. When a page cannot be read, AI Studio now says why instead of leaving the field empty.
|
||||
- Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading.
|
||||
- Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you.
|
||||
- Improved the list of your attached files: every file now appears under the folder it came from, and each folder is named only once, no matter in which order you attached your files.
|
||||
- Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet.
|
||||
- Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had.
|
||||
- Fixed model names that a provider writes in its own way not being recognized at all, such as the colon Ollama puts before the variant. Those models were treated as plain text models and lost every other ability.
|
||||
- Fixed a model resold under a plain name not getting the abilities it really has.
|
||||
- Fixed image and video generation models showing up among the chat models.
|
||||
- Fixed a dropped file being processed several times, e.g., after the computer woke up from sleep.
|
||||
- Fixed nothing happening when you dropped a file onto the list of your attached files. You can now add files to that list while it is open.
|
||||
- Fixed the preview of an attached file ignoring dropped files. Drop another file onto the preview, and it is attached and shown right away.
|
||||
- Fixed AI Studio shutting down without warning when two PDF files were read at the same time, e.g., when you previewed one while another was still being read in the background.
|
||||
- Fixed the web address staying in the field when you reset an assistant that loads content from a web page.
|
||||
- Fixed the web address being gone when you leave such an assistant and come back to it later.
|
||||
- Fixed the Visual Briefing Assistant (in preview) not scrolling, which put everything below the window edge out of reach and made the assistant unusable. The briefing preview is now shown at its intended size inside its frame, and switching between the desktop, tablet, and mobile view changes its width as it should.
|
||||
|
||||
@ -3,7 +3,7 @@ use std::collections::VecDeque;
|
||||
use std::convert::Infallible;
|
||||
use crate::api_token::APIToken;
|
||||
use crate::pandoc::PandocProcessBuilder;
|
||||
use crate::pdfium::PdfiumInit;
|
||||
use crate::pdfium::{with_pdfium_access, PdfiumInit};
|
||||
use crate::prompt_injection::{Finding as PromptInjectionFinding, Sanitizer};
|
||||
use async_stream::stream;
|
||||
use axum::extract::Query;
|
||||
@ -1139,7 +1139,7 @@ async fn stream_pdf(file_path: &str) -> Result<ChunkStream> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let doc = match pdfium.load_pdf_from_file(&path, None) {
|
||||
let doc = match with_pdfium_access(|| pdfium.load_pdf_from_file(&path, None)) {
|
||||
Ok(document) => document,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(Err(classify_pdf_load_error(&e).into()));
|
||||
@ -1152,11 +1152,27 @@ async fn stream_pdf(file_path: &str) -> Result<ChunkStream> {
|
||||
let mut number_of_failed_pages = 0;
|
||||
let mut receiver_gone = false;
|
||||
|
||||
for (num_page, page) in doc.pages().iter().enumerate() {
|
||||
let page_number = num_page + 1;
|
||||
//
|
||||
// One page at a time, rather than the whole document: somebody else may be reading a PDF
|
||||
// of their own, and holding PDFium for a thousand-page manual would make them wait for all
|
||||
// of it. Between two pages, their pages get their turn.
|
||||
//
|
||||
let page_count = with_pdfium_access(|| doc.pages().len());
|
||||
|
||||
for page_index in 0..page_count {
|
||||
let page_number = page_index as usize + 1;
|
||||
number_of_pages = page_number;
|
||||
|
||||
let content = match page.text().map(|t| t.all()) {
|
||||
//
|
||||
// The page and its text are opened and closed inside this call. Letting them outlive
|
||||
// it would close them without PDFium to ourselves, which is a call like any other.
|
||||
//
|
||||
let extracted = with_pdfium_access(|| doc
|
||||
.pages()
|
||||
.get(page_index)
|
||||
.and_then(|page| page.text().map(|text| text.all())));
|
||||
|
||||
let content = match extracted {
|
||||
Ok(text_content) => text_content,
|
||||
Err(e) => {
|
||||
//
|
||||
@ -1193,14 +1209,12 @@ async fn stream_pdf(file_path: &str) -> Result<ChunkStream> {
|
||||
|
||||
if receiver_gone {
|
||||
debug!("The consumer stopped reading the PDF stream of '{path}' after {number_of_pages} page(s).");
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
debug!("Extracted {number_of_characters} readable character(s) from {number_of_pages} page(s) of '{path}'; failed pages: {number_of_failed_pages}.");
|
||||
|
||||
//
|
||||
// Without this marker, a PDF without a text layer and a broken extraction both arrive as
|
||||
// an empty document, and the AI would answer as if the file had no content at all.
|
||||
// Without this marker, a PDF without a text layer and a broken extraction both arrive
|
||||
// as an empty document, and the AI would answer as if the file had no content at all.
|
||||
//
|
||||
if number_of_characters == 0 {
|
||||
warn!("No text could be extracted from '{path}': {number_of_pages} page(s), {number_of_failed_pages} failed page(s). The PDF may consist of scanned images without a text layer.");
|
||||
@ -1210,6 +1224,14 @@ async fn stream_pdf(file_path: &str) -> Result<ChunkStream> {
|
||||
format!("No text could be extracted from {number_of_pages} page(s). The PDF may consist of scanned images without a text layer."),
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Closing the document calls PDFium as well, so it waits for its turn like everything else.
|
||||
// This is why the code above says what it has to say instead of returning early: the
|
||||
// document has to be closed on every way out of here.
|
||||
//
|
||||
with_pdfium_access(move || drop(doc));
|
||||
});
|
||||
|
||||
Ok(Box::pin(ReceiverStream::new(rx)))
|
||||
|
||||
@ -7,6 +7,30 @@ use log::{error, info, warn};
|
||||
pub static PDFIUM_LIB_PATH: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
|
||||
static PDFIUM: OnceCell<Pdfium> = OnceCell::new();
|
||||
|
||||
/// Grants one caller at a time the right to talk to PDFium.
|
||||
static PDFIUM_ACCESS: Mutex<()> = Mutex::new(());
|
||||
|
||||
/// Runs the given action with PDFium all to itself.
|
||||
///
|
||||
/// PDFium is not thread-safe, and nothing else guarantees that for us: the `thread_safe` feature of
|
||||
/// `pdfium-render` has only granted `Send` and `Sync` since its release 0.9.0 and no longer locks
|
||||
/// anything, although its documentation still says so. Two documents read at the same time -- a
|
||||
/// chat attachment while a data source is being indexed, say -- therefore corrupt PDFium's memory
|
||||
/// and take the whole runtime down with a segmentation fault.
|
||||
///
|
||||
/// Every call to PDFium belongs in here, and so does everything holding a page or a document open:
|
||||
/// closing them calls PDFium as well. What does not belong in here is anything that waits, our own
|
||||
/// work on the extracted text above all, because everybody else waits along with it.
|
||||
pub fn with_pdfium_access<T>(action: impl FnOnce() -> T) -> T {
|
||||
//
|
||||
// A panic while reading a document poisons this lock. Refusing every PDF from then on would
|
||||
// turn one broken document into a broken feature, so we take the lock either way: what the
|
||||
// panic left behind is inside PDFium, not inside the unit value we guard with.
|
||||
//
|
||||
let _access = PDFIUM_ACCESS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
action()
|
||||
}
|
||||
|
||||
pub trait PdfiumInit {
|
||||
fn ai_studio_init() -> Result<&'static Pdfium, Box<dyn Error + Send + Sync>>;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user