diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index ae75e16b..a9d2bd27 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6388,6 +6388,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1205126512"] = "Please -- Markdown View UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1373123357"] = "Markdown View" +-- You can drag another file into this window. We attach it right away and show it here instead. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T1533825620"] = "You can drag another file into this window. We attach it right away and show it here instead." + -- Load file UI_TEXT_CONTENT["AISTUDIO::DIALOGS::DOCUMENTCHECKDIALOG::T2129302565"] = "Load file" diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index 3b8d3071..859afbc3 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -459,6 +459,8 @@ public partial class AttachDocuments : MSGComponentBase var dialogParameters = new DialogParameters { { x => x.Document, fileAttachment }, + { x => x.AttachPaths, this.AttachDroppedPathsAsync }, + { x => x.IsAttachingUnavailable, () => this.IsUnavailable }, }; await this.DialogService.ShowAsync(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN); diff --git a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor index b1851241..81d2dc0f 100644 --- a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor +++ b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor @@ -3,20 +3,34 @@ @* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@ - + @T("See how we load your file. Review the content before we process it further.") - - @if (this.Document is null) + + @if (this.CanAttach) { - + + @T("You can drag another file into this window. We attach it right away and show it here instead.") + + } + + @if (this.document is null) + { + } 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. *@ } - @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: *@ +
+ @if (!this.document?.Exists ?? false) { @T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.") @@ -60,11 +77,13 @@ } - - @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. *@ + + @if (this.document?.IsImage ?? false) { - + } else @@ -102,6 +121,7 @@ } } +
diff --git a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor.cs b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor.cs index e6e7b77c..901f0851 100644 --- a/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/DocumentCheckDialog.razor.cs @@ -21,6 +21,46 @@ public partial class DocumentCheckDialog : MSGComponentBase [Parameter] public string FileContent { get; set; } = string.Empty; + /// + /// Attaches the files the user drops onto this dialog, and answers which of them it attached. + /// + /// + /// 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. + /// + [Parameter] + public Func, Task>>? AttachPaths { get; set; } + + /// + /// Decides, at the moment a drop arrives, whether attaching is possible right now. + /// + /// + /// 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. + /// + [Parameter] + public Func? IsAttachingUnavailable { get; set; } + + /// + /// 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. + /// + /// + /// 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. + /// + private FileAttachment? document; + + /// + /// The content of the document we show, either handed to us by our caller or read by us. + /// + private string fileContent = string.Empty; + /// /// 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; /// - /// 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. /// - private readonly CancellationTokenSource extractionCancellation = new(); + private CancellationTokenSource extractionCancellation = new(); + + /// + /// Numbers the loads, so that a load can tell whether it still owns this dialog. + /// + /// + /// 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. + /// + private int loadGeneration; /// /// True once this dialog was disposed. The extraction runs across awaits, so it may return @@ -73,75 +124,194 @@ 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(); + } + + /// + /// 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. + /// + private bool NeedsExtraction() => + this.document is not null && + !this.document.IsImage && + this.document.Exists && + string.IsNullOrWhiteSpace(this.fileContent); + + /// + /// Reads the content of the document we show and puts it into the preview. + /// + /// + /// 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. + /// + 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(documentToLoad.FilePath, this.RustService, this.PandocAvailability, cancellationToken); + 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) - 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(documentToLoad.FileName); + } + catch (OperationCanceledException) + { + // 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}'", documentToLoad.FilePath); + if (this.isDisposed || generation != this.loadGeneration) + return; - // - // 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); - } - catch (OperationCanceledException) + this.fileContent = string.Empty; + this.loadFailureMessage = FileExtractionErrorCode.INTERNAL.ToUserMessage(documentToLoad.FileName); + } + finally + { + if (!this.isDisposed && generation == this.loadGeneration) { - // The user closed this dialog while we were reading the file. Nothing left to do. - } - 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); - } - finally - { - if (!this.isDisposed) - { - this.isLoadingContent = false; - this.UpdatePreview(); - this.StateHasChanged(); - } + this.isLoadingContent = false; + this.UpdatePreview(); + this.StateHasChanged(); } } - else if (firstRender) - this.Logger.LogWarning("Document check dialog opened without a valid file path."); } - + + /// + /// 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. + /// + /// + /// 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. + /// + private bool CanAttach => this.AttachPaths is not null && this.document is not null; + + private EventCallback> DropCallback => this.CanAttach + ? EventCallback.Factory.Create>(this, this.PathsDropped) + : default; + + private bool IsZoneDisabled() => this.IsAttachingUnavailable?.Invoke() ?? false; + + /// + /// 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. + /// + /// Whether this dialog is the target of the drop being aimed right now. + 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"; + } + + /// + /// Attaches what the user dropped onto this dialog and shows the first file of it. + /// + /// The dropped paths, in the order the runtime delivered them. + private async Task PathsDropped(List 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(); + } + + /// + /// Shows another document, discarding everything that belonged to the previous one. + /// + /// The document to show from now on. + private void ShowDocument(FileAttachment attachment) + { + this.document = attachment; + this.fileContent = string.Empty; + this.loadFailureMessage = null; + this.isLoadingContent = this.NeedsExtraction(); + this.UpdatePreview(); + } + /// /// 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. /// /// The content of the file the user has loaded. - private void ApplyLoadedFileContent(string fileContent) + private void ApplyLoadedFileContent(string loadedContent) { - this.FileContent = fileContent; + this.fileContent = loadedContent; this.UpdatePreview(); } @@ -150,9 +320,9 @@ public partial class DocumentCheckDialog : MSGComponentBase /// 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; } /// @@ -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(); diff --git a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs index 0efcaa0a..daccf3c6 100644 --- a/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/ReviewAttachmentsDialog.razor.cs @@ -163,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(T("Document Preview"), dialogParameters, DialogOptions.FULLSCREEN); } } \ No newline at end of file