diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index 9cf151af..b1d3ef12 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -31,7 +31,6 @@ @if (this.Body is not null) { - @@ -161,7 +160,7 @@ @if (this.ShowReset) { - + @TB("Reset") } diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index 9f322d0e..172b27b8 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -4,6 +4,7 @@ using AIStudio.Settings; using AIStudio.Dialogs.Settings; using AIStudio.Tools.AIJobs; using AIStudio.Tools.AssistantSessions; +using AIStudio.Tools.Media; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -181,6 +182,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.CurrentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(this.Component); this.assistantSessionKey = new(this.Component, this.AssistantSessionInstanceId); await this.AttachAssistantSessionIfAvailable(); + await this.ConsumeMediaOutcomeAsync(); } protected override async Task OnParametersSetAsync() @@ -642,10 +644,12 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private async Task InnerResetForm() { - if (this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey)?.IsActive ?? false) + if ((this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey)?.IsActive ?? false) + || this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)) return; await this.AssistantSessionService.ClearAsync(this.assistantSessionKey); + this.MediaTranscriptionService.ClearOwnerState(this.CurrentMediaImportOwner); this.assistantSessionId = null; this.ResultingContentBlock = null; this.ProviderSettings = Settings.Provider.NONE; @@ -699,7 +703,34 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.CurrentMediaImportOwner) - _ = this.InvokeAsync(this.StateHasChanged); + _ = this.InvokeAsync(async () => + { + await this.ConsumeMediaOutcomeAsync(); + this.StateHasChanged(); + }); + } + + /// Consumes a terminal media notification when this assistant is visible. + private async Task ConsumeMediaOutcomeAsync() + { + var outcome = this.MediaTranscriptionService.TryConsumeOutcome(this.CurrentMediaImportOwner); + if (outcome is null) + return; + + if (outcome.Failures.Count > 0) + { + var message = string.Join(Environment.NewLine, outcome.Failures.Select(failure => $"{failure.FileName}: {failure.UserMessage}")); + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, message)); + } + else if (outcome.Status is MediaImportStatus.FAILED) + { + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.TB("The media file could not be transcribed."))); + } + + if (outcome.Status is MediaImportStatus.CANCELLED) + { + await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.TB("The media transcription was canceled."))); + } } #endregion diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index de3e781f..dad9ac1d 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -304,6 +304,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::NUMBERPARTICIPANTSEXTENSIONS::T81 -- Stop generation UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1317408357"] = "Stop generation" +-- The media file could not be transcribed. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1543974632"] = "The media file could not be transcribed." + -- Reset UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T180921696"] = "Reset" @@ -313,6 +316,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1809312323"] = "Please se -- The assistant failed. The message is: '{0}' UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1833836281"] = "The assistant failed. The message is: '{0}'" +-- The media transcription was canceled. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T241403726"] = "The media transcription was canceled." + -- This assistant is already running. AI Studio opens the running session instead. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T2575715765"] = "This assistant is already running. AI Studio opens the running session instead." @@ -2410,6 +2416,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1358313858"] = "Click t -- Drop files here to attach them. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T143112277"] = "Drop files here to attach them." +-- The media file could not be transcribed. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1543974632"] = "The media file could not be transcribed." + -- Click here to attach files. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1875575968"] = "Click here to attach files." @@ -2419,6 +2428,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2178031033"] = "Transcr -- Drag and drop files into the marked area or click here to attach documents: UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T230755331"] = "Drag and drop files into the marked area or click here to attach documents:" +-- The media transcription was canceled. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T241403726"] = "The media transcription was canceled." + -- Select files to attach UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2495931372"] = "Select files to attach" @@ -2455,6 +2467,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1317408357"] = "Stop gene -- Save chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1516264254"] = "Save chat" +-- The media file could not be transcribed. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1543974632"] = "The media file could not be transcribed." + -- Type your input here... UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Type your input here..." @@ -2467,6 +2482,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code" -- Italic UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic" +-- The media transcription was canceled. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "The media transcription was canceled." + -- Profile usage is disabled according to your chat template settings. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile usage is disabled according to your chat template settings." @@ -2827,12 +2845,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T4279448758"] = "Uses -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Provider" +-- The media file could not be transcribed. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1543974632"] = "The media file could not be transcribed." + -- Failed to load file content UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Failed to load file content" -- Drop one file here to load its content. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Drop one file here to load its content." +-- The media transcription was canceled. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T241403726"] = "The media transcription was canceled." + -- The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2839709466"] = "The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider." diff --git a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs index 418add4a..34aba34b 100644 --- a/app/MindWork AI Studio/Components/AssistantBlock.razor.cs +++ b/app/MindWork AI Studio/Components/AssistantBlock.razor.cs @@ -1,7 +1,9 @@ using AIStudio.Dialogs.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AssistantSessions; +using AIStudio.Tools.Media; using AIStudio.Tools.Services; + using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor b/app/MindWork AI Studio/Components/AttachDocuments.razor index 4c3980a0..b707f064 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor @@ -2,53 +2,62 @@ @if (this.UseSmallForm) { -
- @if (this.isDraggingOver) - { - - - - - - } - else if (this.DocumentPaths.Any()) - { - + +
+ @if (this.isDraggingOver) + { + + + + + } + else if (this.DocumentPaths.Any()) + { + + + + + + } + else + { + - - - } - else + + } +
+ @if (this.ShowMediaStatus) { - - - + } -
+ } else { @@ -69,6 +78,10 @@ else
} + @if (this.ShowMediaStatus) + { + + }
@foreach (var fileAttachment in this.DocumentPaths) diff --git a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs index 1efa1e8b..495c17ad 100644 --- a/app/MindWork AI Studio/Components/AttachDocuments.razor.cs +++ b/app/MindWork AI Studio/Components/AttachDocuments.razor.cs @@ -1,5 +1,6 @@ using AIStudio.Chat; using AIStudio.Dialogs; +using AIStudio.Tools.Media; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; @@ -53,6 +54,10 @@ public partial class AttachDocuments : MSGComponentBase [Parameter] public bool UseSmallForm { get; set; } + /// Whether this control renders its own media status. + [Parameter] + public bool ShowMediaStatus { get; set; } = true; + [Parameter] public bool Disabled { get; set; } @@ -101,6 +106,8 @@ public partial class AttachDocuments : MSGComponentBase ? MediaImportOwner.ForChat(this.OwnerChat.ChatId) : this.ImportOwner ?? this.fallbackMediaImportOwner; + private MediaImportTarget EffectiveMediaImportTarget => new(this.EffectiveImportOwner, string.IsNullOrWhiteSpace(this.Name) ? "attachments" : this.Name); + private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner); #region Overrides of MSGComponentBase @@ -123,14 +130,42 @@ public partial class AttachDocuments : MSGComponentBase _ = this.InvokeAsync(async () => { await this.SyncCompletedMediaAttachmentsAsync(); + await this.ConsumeStandaloneMediaOutcomeAsync(); this.StateHasChanged(); }); } + /// Consumes outcomes for dialog-local controls that have no chat or assistant owner surface. + private async Task ConsumeStandaloneMediaOutcomeAsync() + { + if (this.ImportOwner is not null || this.OwnerChat is not null) + return; + + var outcome = this.MediaTranscriptionService.TryConsumeOutcome(this.EffectiveImportOwner); + if (outcome is null) + return; + + if (outcome.Failures.Count > 0) + { + var message = string.Join(Environment.NewLine, outcome.Failures.Select(failure => $"{failure.FileName}: {failure.UserMessage}")); + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, message)); + } + else if (outcome.Status is MediaImportStatus.FAILED) + { + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The media file could not be transcribed."))); + } + + if (outcome.Status is MediaImportStatus.CANCELLED) + { + await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.T("The media transcription was canceled."))); + } + } + /// Reattaches completed owner results after progress updates or navigation. private async Task SyncCompletedMediaAttachmentsAsync() { - var completed = this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner)?.CompletedAttachments ?? []; + var delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget); + var completed = delivery?.Attachments ?? []; var pending = this.OwnerChat?.PendingMediaTranscripts ?? []; var changed = false; @@ -142,6 +177,9 @@ public partial class AttachDocuments : MSGComponentBase await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); await this.OnChange(this.DocumentPaths); } + + if (delivery is not null) + this.MediaTranscriptionService.AcknowledgeDelivery(delivery); } /// Unsubscribes from the singleton media service. @@ -393,7 +431,7 @@ public partial class AttachDocuments : MSGComponentBase if (this.OwnerChat is null) this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]); - this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveImportOwner, this.OwnerChat); + this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveMediaImportTarget, this.OwnerChat); } private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO); diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 83fb26a6..1d622ec3 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -101,7 +101,7 @@ } - + diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index ec182f95..485c5961 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -4,6 +4,7 @@ using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; using AIStudio.Tools.AIJobs; +using AIStudio.Tools.Media; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -252,6 +253,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Select the correct provider: await this.SelectProviderWhenLoadingChat(); await this.SyncForegroundChatAsync(); + await this.ConsumeMediaOutcomeAsync(); await base.OnInitializedAsync(); } @@ -259,7 +261,34 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private void OnMediaImportStateChanged(MediaImportOwner owner) { if (owner == this.CurrentMediaImportOwner) - _ = this.InvokeAsync(this.StateHasChanged); + _ = this.InvokeAsync(async () => + { + await this.ConsumeMediaOutcomeAsync(); + this.StateHasChanged(); + }); + } + + /// Consumes a terminal media notification when its chat is visible. + private async Task ConsumeMediaOutcomeAsync() + { + var outcome = this.MediaTranscriptionService.TryConsumeOutcome(this.CurrentMediaImportOwner); + if (outcome is null) + return; + + if (outcome.Failures.Count > 0) + { + var message = string.Join(Environment.NewLine, outcome.Failures.Select(failure => $"{failure.FileName}: {failure.UserMessage}")); + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, message)); + } + else if (outcome.Status is MediaImportStatus.FAILED) + { + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The media file could not be transcribed."))); + } + + if (outcome.Status is MediaImportStatus.CANCELLED) + { + await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.T("The media transcription was canceled."))); + } } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -330,6 +359,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.ApplyLoadedChatParameterAsync(); await this.SyncForegroundChatAsync(); + await this.ConsumeMediaOutcomeAsync(); await base.OnParametersSetAsync(); } diff --git a/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor b/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor index 1f3a71e1..e2acc313 100644 --- a/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor +++ b/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor @@ -4,16 +4,31 @@ @if (this.Snapshot is { IsBusy: true } snapshot) { - - + @if (this.Compact) + { + @this.StatusText - - + - + } + else + { + + + + + @this.StatusText + + + + + + + + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs b/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs index c87a0673..1a048d61 100644 --- a/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs +++ b/app/MindWork AI Studio/Components/MediaTranscriptionStatus.razor.cs @@ -1,4 +1,4 @@ -using AIStudio.Tools.Services; +using AIStudio.Tools.Media; using Microsoft.AspNetCore.Components; namespace AIStudio.Components; @@ -9,19 +9,46 @@ public partial class MediaTranscriptionStatus [Parameter] public MediaImportOwner Owner { get; set; } - private MediaImportSnapshot? Snapshot => this.MediaTranscriptionService.GetSnapshot(this.Owner); + /// Optional target filter used by embedded file controls. + [Parameter] + public string TargetId { get; set; } = string.Empty; + + /// Renders the status without an enclosing paper surface. + [Parameter] + public bool Compact { get; set; } + + private MediaImportSnapshot? Snapshot + { + get + { + var snapshot = this.MediaTranscriptionService.GetSnapshot(this.Owner); + return string.IsNullOrWhiteSpace(this.TargetId) || snapshot?.Target.TargetId == this.TargetId + ? snapshot + : null; + } + } /// Gets the localized visible status for the active import. - private string StatusText => this.Snapshot?.Phase switch + private string StatusText { - MediaTranscriptionPhase.QUEUED => $"{this.T("Waiting to prepare media")}: {this.Snapshot.CurrentFileName}", - MediaTranscriptionPhase.PROBING => $"{this.T("Inspecting media")}: {this.Snapshot.CurrentFileName}", - MediaTranscriptionPhase.TRANSCODING => $"{this.T("Preparing audio")}: {this.Snapshot.CurrentFileName}", - MediaTranscriptionPhase.UPLOADING => $"{this.T("Transcribing")}: {this.Snapshot.CurrentFileName}", - MediaTranscriptionPhase.CANCELING => $"{this.T("Stopping media transcription")}: {this.Snapshot.CurrentFileName}", + get + { + var snapshot = this.Snapshot; + if (snapshot is null) + return string.Empty; - _ => this.Snapshot?.CurrentFileName ?? string.Empty, - }; + return snapshot.Phase switch + { + MediaTranscriptionPhase.QUEUED => $"{this.T("Waiting to prepare media")}: {snapshot.CurrentFileName}", + MediaTranscriptionPhase.PROBING => $"{this.T("Inspecting media")}: {snapshot.CurrentFileName}", + MediaTranscriptionPhase.TRANSCODING => $"{this.T("Preparing audio")}: {snapshot.CurrentFileName}", + MediaTranscriptionPhase.UPLOADING => $"{this.T("Transcribing")}: {snapshot.CurrentFileName}", + MediaTranscriptionPhase.CANCELING => $"{this.T("Stopping media transcription")}: {snapshot.CurrentFileName}", + + _ => snapshot.CurrentFileName, + }; + } + } /// Subscribes to singleton import state changes. protected override async Task OnInitializedAsync() diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor b/app/MindWork AI Studio/Components/ReadFileContent.razor index 227c55ee..9ab4f412 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor @@ -11,13 +11,17 @@ @T("Drop one file here to load its content.") +
} else { - - @this.ButtonText - + + + @this.ButtonText + + + } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs index 76ed1fdc..53e40de6 100644 --- a/app/MindWork AI Studio/Components/ReadFileContent.razor.cs +++ b/app/MindWork AI Studio/Components/ReadFileContent.razor.cs @@ -1,7 +1,8 @@ +using AIStudio.Dialogs; +using AIStudio.Tools.Media; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; using AIStudio.Tools.Validation; -using AIStudio.Dialogs; using Microsoft.AspNetCore.Components; @@ -23,7 +24,8 @@ public partial class ReadFileContent : MSGComponentBase ? string.IsNullOrWhiteSpace(this.Text) ? "primary" : this.Text : this.MediaImportTargetId; - private string lastAppliedMediaTranscript = string.Empty; + private MediaImportTarget EffectiveMediaImportTarget => new(this.EffectiveImportOwner, this.EffectiveMediaImportTargetId); + [Parameter] public string Text { get; set; } = string.Empty; @@ -96,20 +98,46 @@ public partial class ReadFileContent : MSGComponentBase _ = this.InvokeAsync(async () => { await this.SyncCompletedMediaTextAsync(); + await this.ConsumeStandaloneMediaOutcomeAsync(); this.StateHasChanged(); }); } + /// Consumes outcomes for dialog-local controls that have no assistant owner surface. + private async Task ConsumeStandaloneMediaOutcomeAsync() + { + if (this.ImportOwner is not null) + return; + + var outcome = this.MediaTranscriptionService.TryConsumeOutcome(this.EffectiveImportOwner); + if (outcome is null) + return; + + if (outcome.Failures.Count > 0) + { + var message = string.Join(Environment.NewLine, outcome.Failures.Select(failure => $"{failure.FileName}: {failure.UserMessage}")); + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, message)); + } + else if (outcome.Status is MediaImportStatus.FAILED) + { + await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The media file could not be transcribed."))); + } + + if (outcome.Status is MediaImportStatus.CANCELLED) + { + await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.T("The media transcription was canceled."))); + } + } + /// Applies a completed target transcript after progress or navigation. private async Task SyncCompletedMediaTextAsync() { - var completed = this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner)?.CompletedTextTargets; - if (completed is null || !completed.TryGetValue(this.EffectiveMediaImportTargetId, out var text) - || text == this.lastAppliedMediaTranscript) + var delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget); + if (delivery is null || delivery.Text is not { } text) return; - this.lastAppliedMediaTranscript = text; await this.FileContentChanged.InvokeAsync(text); + this.MediaTranscriptionService.AcknowledgeDelivery(delivery); } /// Unsubscribes from the singleton media service. @@ -280,7 +308,7 @@ public partial class ReadFileContent : MSGComponentBase return this.MediaTranscriptionService.TryStartTextImport( filePath, - new MediaImportTarget(this.EffectiveImportOwner, this.EffectiveMediaImportTargetId)); + this.EffectiveMediaImportTarget); } private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments); diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index 78c0c6e7..b98ace9f 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -1,4 +1,5 @@ using AIStudio.Settings.DataModel; +using AIStudio.Tools.Media; using AIStudio.Tools.MIME; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index 41dd51b9..3f6b8779 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -4,6 +4,7 @@ using System.Text.Json; using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Tools.AIJobs; +using AIStudio.Tools.Media; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; @@ -707,7 +708,8 @@ public partial class Workspaces : MSGComponentBase if (chat is null) return; - if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + var mediaOwner = MediaImportOwner.ForChat(chat.ChatId); + if (this.AIJobService.IsChatGenerationActive(chat.ChatId) || this.MediaTranscriptionService.IsBusy(mediaOwner)) return; if (askForConfirmation) @@ -731,6 +733,7 @@ public partial class Workspaces : MSGComponentBase } await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false); + this.MediaTranscriptionService.ClearOwnerState(mediaOwner); await this.LoadTreeItemsAsync(startPrefetch: false); if (unloadChat && this.CurrentChatThread?.ChatId == chat.ChatId) diff --git a/app/MindWork AI Studio/Tools/Media/MediaImportDelivery.cs b/app/MindWork AI Studio/Tools/Media/MediaImportDelivery.cs new file mode 100644 index 00000000..d2987776 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Media/MediaImportDelivery.cs @@ -0,0 +1,15 @@ +using AIStudio.Chat; + +namespace AIStudio.Tools.Media; + +/// Pending media results waiting for one concrete UI target. +public sealed record MediaImportDelivery +{ + public required MediaImportTarget Target { get; init; } + + public IReadOnlyList Attachments { get; init; } = []; + + public string? Text { get; init; } + + public bool IsEmpty => this.Attachments.Count is 0 && this.Text is null; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Media/MediaImportFailure.cs b/app/MindWork AI Studio/Tools/Media/MediaImportFailure.cs new file mode 100644 index 00000000..99a84a04 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Media/MediaImportFailure.cs @@ -0,0 +1,6 @@ +using AIStudio.Tools.Rust; + +namespace AIStudio.Tools.Media; + +/// One user-visible failure retained until its owner is displayed. +public sealed record MediaImportFailure(string FileName, string UserMessage, MediaJobErrorCode? ErrorCode = null); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs b/app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs new file mode 100644 index 00000000..5fce8345 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs @@ -0,0 +1,11 @@ +namespace AIStudio.Tools.Media; + +/// Terminal batch outcome retained until its owner is displayed. +public sealed record MediaImportOutcome +{ + public required MediaImportOwner Owner { get; init; } + + public required MediaImportStatus Status { get; init; } + + public IReadOnlyList Failures { get; init; } = []; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/MediaImportOwner.cs b/app/MindWork AI Studio/Tools/Media/MediaImportOwner.cs similarity index 92% rename from app/MindWork AI Studio/Tools/Services/MediaImportOwner.cs rename to app/MindWork AI Studio/Tools/Media/MediaImportOwner.cs index df33bf1e..09cb2cdd 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaImportOwner.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaImportOwner.cs @@ -1,6 +1,6 @@ using AIStudio.Tools.AssistantSessions; -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// Identifies the chat or assistant that owns a media import. public readonly record struct MediaImportOwner(MediaImportOwnerKind Kind, string Id) diff --git a/app/MindWork AI Studio/Tools/Services/MediaImportOwnerKind.cs b/app/MindWork AI Studio/Tools/Media/MediaImportOwnerKind.cs similarity index 78% rename from app/MindWork AI Studio/Tools/Services/MediaImportOwnerKind.cs rename to app/MindWork AI Studio/Tools/Media/MediaImportOwnerKind.cs index 5e560f36..e5a58a97 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaImportOwnerKind.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaImportOwnerKind.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// Supported persistent media-operation owners. public enum MediaImportOwnerKind diff --git a/app/MindWork AI Studio/Tools/Services/MediaImportSnapshot.cs b/app/MindWork AI Studio/Tools/Media/MediaImportSnapshot.cs similarity index 68% rename from app/MindWork AI Studio/Tools/Services/MediaImportSnapshot.cs rename to app/MindWork AI Studio/Tools/Media/MediaImportSnapshot.cs index ad128997..92957d91 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaImportSnapshot.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaImportSnapshot.cs @@ -1,12 +1,12 @@ -using AIStudio.Chat; - -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// Copied owner-specific state suitable for rendering after navigation. public sealed record MediaImportSnapshot { public required MediaImportOwner Owner { get; init; } + public required MediaImportTarget Target { get; init; } + public required MediaTranscriptionPhase Phase { get; init; } public required MediaImportStatus Status { get; init; } @@ -15,9 +15,5 @@ public sealed record MediaImportSnapshot public double? Progress { get; init; } - public IReadOnlyList CompletedAttachments { get; init; } = []; - - public IReadOnlyDictionary CompletedTextTargets { get; init; } = new Dictionary(); - public bool IsBusy => this.Status is MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/MediaImportStatus.cs b/app/MindWork AI Studio/Tools/Media/MediaImportStatus.cs similarity index 84% rename from app/MindWork AI Studio/Tools/Services/MediaImportStatus.cs rename to app/MindWork AI Studio/Tools/Media/MediaImportStatus.cs index ab06a2f4..139315fa 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaImportStatus.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaImportStatus.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// Lifecycle status retained independently for each owner. public enum MediaImportStatus diff --git a/app/MindWork AI Studio/Tools/Services/MediaImportTarget.cs b/app/MindWork AI Studio/Tools/Media/MediaImportTarget.cs similarity index 72% rename from app/MindWork AI Studio/Tools/Services/MediaImportTarget.cs rename to app/MindWork AI Studio/Tools/Media/MediaImportTarget.cs index 397b9c30..9ea4e68c 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaImportTarget.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaImportTarget.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// Identifies the concrete attachment or file-content field inside an owner. public readonly record struct MediaImportTarget(MediaImportOwner Owner, string TargetId); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionPhase.cs b/app/MindWork AI Studio/Tools/Media/MediaTranscriptionPhase.cs similarity index 94% rename from app/MindWork AI Studio/Tools/Services/MediaTranscriptionPhase.cs rename to app/MindWork AI Studio/Tools/Media/MediaTranscriptionPhase.cs index d4e65d05..290282d2 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionPhase.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaTranscriptionPhase.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// Visible phases of the serialized media import lane. public enum MediaTranscriptionPhase diff --git a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionResult.cs b/app/MindWork AI Studio/Tools/Media/MediaTranscriptionResult.cs similarity index 97% rename from app/MindWork AI Studio/Tools/Services/MediaTranscriptionResult.cs rename to app/MindWork AI Studio/Tools/Media/MediaTranscriptionResult.cs index 278fc593..92e4c3b6 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionResult.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaTranscriptionResult.cs @@ -1,6 +1,6 @@ using AIStudio.Tools.Rust; -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// /// Typed terminal result returned by media import and voice operations. diff --git a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionResultStatus.cs b/app/MindWork AI Studio/Tools/Media/MediaTranscriptionResultStatus.cs similarity index 91% rename from app/MindWork AI Studio/Tools/Services/MediaTranscriptionResultStatus.cs rename to app/MindWork AI Studio/Tools/Media/MediaTranscriptionResultStatus.cs index 63fe1eb3..8abddc2d 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionResultStatus.cs +++ b/app/MindWork AI Studio/Tools/Media/MediaTranscriptionResultStatus.cs @@ -1,4 +1,4 @@ -namespace AIStudio.Tools.Services; +namespace AIStudio.Tools.Media; /// /// Terminal outcome of a media transcription operation. diff --git a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs b/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs index 03316ed8..b1b39c63 100644 --- a/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs +++ b/app/MindWork AI Studio/Tools/Services/MediaTranscriptionService.cs @@ -1,6 +1,7 @@ using AIStudio.Chat; using AIStudio.Provider; using AIStudio.Settings; +using AIStudio.Tools.Media; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; @@ -23,9 +24,15 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM /// The active or queued import operation for each owner. private readonly Dictionary currentImports = []; - /// The latest copied state retained for each owner across navigation. + /// The latest active or unacknowledged terminal state for each owner. private readonly Dictionary snapshots = []; + /// Successful results waiting for their concrete UI target. + private readonly Dictionary pendingDeliveries = []; + + /// Terminal notifications waiting for their owner surface to be displayed. + private readonly Dictionary outcomes = []; + /// Owners whose complete file batches are managed by this service. private readonly HashSet activeBatches = []; @@ -39,7 +46,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM public event Action? StateChanged; /// Gets whether one owner has queued, running, or canceling media work. - public bool IsBusy(MediaImportOwner owner) => this.GetSnapshot(owner)?.IsBusy ?? false; + public bool IsBusy(MediaImportOwner owner) + { + lock (this.stateLock) + return this.activeBatches.Contains(owner); + } /// Gets the last retained state for one owner. public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner) @@ -55,19 +66,102 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM return [.. this.snapshots.Values]; } - /// Starts an owner-managed attachment batch and returns without holding the UI event handler. - public bool TryStartAttachmentBatch(IReadOnlyList mediaPaths, MediaImportOwner owner, ChatThread? ownerChat = null) + /// Gets copied results that have not yet been applied by one target. + public MediaImportDelivery? GetPendingDelivery(MediaImportTarget target) { - this.ThrowIfDisposed(); lock (this.stateLock) { - if (!this.activeBatches.Add(owner)) - return false; + if (!this.pendingDeliveries.TryGetValue(target, out var pending)) + return null; - this.batchCancellations[owner] = new(); + return new() + { + Target = target, + Attachments = [.. pending.Attachments], + Text = pending.Text, + }; + } + } + + /// Removes exactly the results that one target applied successfully. + public void AcknowledgeDelivery(MediaImportDelivery delivery) + { + lock (this.stateLock) + { + if (!this.pendingDeliveries.TryGetValue(delivery.Target, out var pending)) + return; + + var acknowledgedPaths = delivery.Attachments.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal); + pending.Attachments.RemoveAll(attachment => acknowledgedPaths.Contains(attachment.FilePath)); + + if (delivery.Text is not null && string.Equals(pending.Text, delivery.Text, StringComparison.Ordinal)) + pending.Text = null; + + if (pending.Attachments.Count is 0 && pending.Text is null) + this.pendingDeliveries.Remove(delivery.Target); + } + } + + /// Consumes one terminal notification when its owner surface is displayed. + public MediaImportOutcome? TryConsumeOutcome(MediaImportOwner owner) + { + MediaImportOutcome? outcome; + lock (this.stateLock) + { + if (!this.outcomes.Remove(owner, out outcome)) + return null; + + if (this.snapshots.GetValueOrDefault(owner) is { IsBusy: false }) + this.snapshots.Remove(owner); } - _ = this.RunAttachmentBatchAsync(mediaPaths, owner, ownerChat); + this.NotifyStateChanged(owner); + return outcome; + } + + /// Discards retained inactive state and deletes unclaimed managed transcript files. + public void ClearOwnerState(MediaImportOwner owner) + { + List discardedAttachments = []; + lock (this.stateLock) + { + if (this.activeBatches.Contains(owner)) + return; + + this.snapshots.Remove(owner); + this.outcomes.Remove(owner); + + foreach (var target in this.pendingDeliveries.Keys.Where(target => target.Owner == owner).ToList()) + { + discardedAttachments.AddRange(this.pendingDeliveries[target].Attachments); + this.pendingDeliveries.Remove(target); + } + } + + foreach (var attachment in discardedAttachments) + ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment); + + this.NotifyStateChanged(owner); + } + + /// Starts an owner-managed attachment batch and returns without holding the UI event handler. + public bool TryStartAttachmentBatch(IReadOnlyList mediaPaths, MediaImportTarget target, ChatThread? ownerChat = null) + { + this.ThrowIfDisposed(); + if (mediaPaths.Count is 0) + return false; + + lock (this.stateLock) + { + if (!this.activeBatches.Add(target.Owner)) + return false; + + this.batchCancellations[target.Owner] = new(); + this.outcomes.Remove(target.Owner); + } + + this.UpdateImportState(target, Path.GetFileName(mediaPaths[0]), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); + _ = Task.Run(() => this.RunAttachmentBatchAsync(mediaPaths, target, ownerChat)); return true; } @@ -81,9 +175,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM return false; this.batchCancellations[target.Owner] = new(); + this.outcomes.Remove(target.Owner); } - _ = this.RunTextImportAsync(mediaPath, target); + this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); + _ = Task.Run(() => this.RunTextImportAsync(mediaPath, target)); return true; } @@ -94,20 +190,30 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM lock (this.stateLock) cancellation = this.batchCancellations[target.Owner]; + var status = MediaImportStatus.SUCCEEDED; + List failures = []; try { - var result = await this.TranscribeImportAsync(mediaPath, target.Owner, cancellation.Token); + var result = await this.TranscribeImportAsync(mediaPath, target, cancellation.Token); if (result.Status is MediaTranscriptionResultStatus.SUCCEEDED) this.AddCompletedText(target, result.Text); + else if (result.Status is MediaTranscriptionResultStatus.CANCELLED) + status = MediaImportStatus.CANCELLED; + else + { + status = MediaImportStatus.FAILED; + failures.Add(new(Path.GetFileName(mediaPath), result.UserMessage, result.ErrorCode)); + } } catch (OperationCanceledException) { - var current = this.GetSnapshot(target.Owner); - this.UpdateImportState(target.Owner, current?.CurrentFileName ?? string.Empty, MediaTranscriptionPhase.IDLE, null, MediaImportStatus.CANCELLED); + status = MediaImportStatus.CANCELLED; } catch (Exception exception) { logger.LogError(exception, "Owner media text import failed for '{Owner}' and target '{TargetId}'.", target.Owner, target.TargetId); + status = MediaImportStatus.FAILED; + failures.Add(new(Path.GetFileName(mediaPath), TB("The media file could not be transcribed."))); } finally { @@ -117,6 +223,8 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM if (this.batchCancellations.Remove(target.Owner, out var ownedCancellation)) ownedCancellation.Dispose(); } + + this.CompleteImport(target, Path.GetFileName(mediaPath), status, failures); } } @@ -125,41 +233,47 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM { lock (this.stateLock) { - var current = this.snapshots.GetValueOrDefault(target.Owner); - if (current is null) - return; + if (!this.pendingDeliveries.TryGetValue(target, out var pending)) + this.pendingDeliveries[target] = pending = new(); - var completed = new Dictionary(current.CompletedTextTargets, StringComparer.Ordinal) - { - [target.TargetId] = text, - }; - this.snapshots[target.Owner] = current with { CompletedTextTargets = completed }; + pending.Text = text; } - this.StateChanged?.Invoke(target.Owner); + this.NotifyStateChanged(target.Owner); } /// Serially transcribes a complete owner batch while retaining every successful result. - private async Task RunAttachmentBatchAsync(IReadOnlyList mediaPaths, MediaImportOwner owner, ChatThread? ownerChat) + private async Task RunAttachmentBatchAsync(IReadOnlyList mediaPaths, MediaImportTarget target, ChatThread? ownerChat) { CancellationToken batchToken; lock (this.stateLock) - batchToken = this.batchCancellations[owner].Token; + batchToken = this.batchCancellations[target.Owner].Token; + var status = MediaImportStatus.SUCCEEDED; + var currentFileName = Path.GetFileName(mediaPaths[0]); + List failures = []; + try { foreach (var mediaPath in mediaPaths) { + currentFileName = Path.GetFileName(mediaPath); batchToken.ThrowIfCancellationRequested(); - var result = await this.TranscribeImportAsync(mediaPath, owner, batchToken); + var result = await this.TranscribeImportAsync(mediaPath, target, batchToken); if (result.Status is MediaTranscriptionResultStatus.CANCELLED) + { + status = MediaImportStatus.CANCELLED; break; + } if (result.Status is not MediaTranscriptionResultStatus.SUCCEEDED) + { + status = MediaImportStatus.FAILED; + failures.Add(new(currentFileName, result.UserMessage, result.ErrorCode)); continue; + } - var isPersistedChat = ownerChat is not null - && WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId)); + var isPersistedChat = ownerChat is not null && WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId)); var attachment = isPersistedChat ? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text) : await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text); @@ -171,90 +285,84 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM if (isPersistedChat) await WorkspaceBehaviour.StoreChatAsync(ownerChat!); - this.AddCompletedAttachment(owner, attachment); + this.AddCompletedAttachment(target, attachment); } } catch (OperationCanceledException) { - var current = this.GetSnapshot(owner); - this.UpdateImportState(owner, current?.CurrentFileName ?? string.Empty, MediaTranscriptionPhase.IDLE, null, MediaImportStatus.CANCELLED); + status = MediaImportStatus.CANCELLED; } catch (Exception exception) { - logger.LogError(exception, "Owner media batch failed for '{Owner}'.", owner); + logger.LogError(exception, "Owner media batch failed for '{Owner}'.", target.Owner); + status = MediaImportStatus.FAILED; + failures.Add(new(currentFileName, TB("The media file could not be transcribed."))); } finally { lock (this.stateLock) { - this.activeBatches.Remove(owner); - if (this.batchCancellations.Remove(owner, out var cancellation)) + this.activeBatches.Remove(target.Owner); + if (this.batchCancellations.Remove(target.Owner, out var cancellation)) cancellation.Dispose(); } + + this.CompleteImport(target, currentFileName, status, failures); } } /// Adds a successful partial result to the retained owner snapshot. - private void AddCompletedAttachment(MediaImportOwner owner, FileAttachment attachment) + private void AddCompletedAttachment(MediaImportTarget target, FileAttachment attachment) { lock (this.stateLock) { - var current = this.snapshots.GetValueOrDefault(owner); - if (current is null) - return; + if (!this.pendingDeliveries.TryGetValue(target, out var pending)) + this.pendingDeliveries[target] = pending = new(); - this.snapshots[owner] = current with { CompletedAttachments = [..current.CompletedAttachments, attachment] }; + if (pending.Attachments.All(existing => existing.FilePath != attachment.FilePath)) + pending.Attachments.Add(attachment); } - this.StateChanged?.Invoke(owner); + this.NotifyStateChanged(target.Owner); } /// /// Transcribes an attachment or file-content import on the serialized visible lane. /// /// Source media path. - /// Media import owner. + /// Media import target. /// Caller cancellation token. /// A typed terminal result. - private async Task TranscribeImportAsync(string mediaPath, MediaImportOwner owner, CancellationToken token = default) + private async Task TranscribeImportAsync(string mediaPath, MediaImportTarget target, CancellationToken token = default) { this.ThrowIfDisposed(); - var operation = this.CreateOperation(owner, token); + var operation = this.CreateOperation(target, token); lock (this.stateLock) { - if (!this.currentImports.TryAdd(owner, operation)) - throw new InvalidOperationException($"Media owner '{owner}' already has an active operation."); + if (!this.currentImports.TryAdd(target.Owner, operation)) + throw new InvalidOperationException($"Media owner '{target.Owner}' already has an active operation."); } - this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); + + this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED); try { await this.importQueue.WaitAsync(operation.Cancellation.Token); operation.HasQueueLease = true; - this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0, MediaImportStatus.RUNNING); - var result = await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true); - var status = result.Status switch - { - MediaTranscriptionResultStatus.SUCCEEDED => MediaImportStatus.SUCCEEDED, - MediaTranscriptionResultStatus.CANCELLED => MediaImportStatus.CANCELLED, - _ => MediaImportStatus.FAILED, - }; - - this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.IDLE, null, status); - return result; + this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0, MediaImportStatus.RUNNING); + return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true); } catch (OperationCanceledException) { - this.UpdateImportState(owner, Path.GetFileName(mediaPath), MediaTranscriptionPhase.IDLE, null, MediaImportStatus.CANCELLED); return MediaTranscriptionResult.Cancelled(); } finally { lock (this.stateLock) { - if (this.currentImports.GetValueOrDefault(owner) == operation) - this.currentImports.Remove(owner); + if (this.currentImports.GetValueOrDefault(target.Owner) == operation) + this.currentImports.Remove(target.Owner); } this.ReleaseOperation(operation); @@ -288,15 +396,17 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM public async Task StopAsync(MediaImportOwner owner) { MediaOperation? operation; + MediaImportSnapshot? snapshot; lock (this.stateLock) { operation = this.currentImports.GetValueOrDefault(owner); this.batchCancellations.GetValueOrDefault(owner)?.Cancel(); operation?.Cancellation.Cancel(); + snapshot = this.snapshots.GetValueOrDefault(owner); } - if (operation is not null) - this.UpdateImportState(owner, this.GetSnapshot(owner)?.CurrentFileName ?? string.Empty, MediaTranscriptionPhase.CANCELING, null, MediaImportStatus.CANCELING); + if (snapshot is not null && this.IsBusy(owner)) + this.UpdateImportState(snapshot.Target, snapshot.CurrentFileName, MediaTranscriptionPhase.CANCELING, null, MediaImportStatus.CANCELING); if (!string.IsNullOrWhiteSpace(operation?.JobId)) await rustService.CancelMediaJobAsync(operation.JobId); @@ -325,7 +435,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured.")); if (updateImportState) - this.UpdateImportState(operation.Owner!.Value, Path.GetFileName(mediaPath), MediaTranscriptionPhase.UPLOADING, null, MediaImportStatus.RUNNING); + this.UpdateImportState(operation.Target!.Value, Path.GetFileName(mediaPath), MediaTranscriptionPhase.UPLOADING, null, MediaImportStatus.RUNNING); var provider = providerSettings.CreateProvider(); if (provider.Provider is LLMProviders.NONE) @@ -360,6 +470,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM // phase, so deleting both paths here cannot race a still-writing worker. if (!this.RetainNormalizedMediaIfRequested(normalizedPath, operation.Id)) this.DeleteTemporaryFile(normalizedPath); + this.DeleteTemporaryFile(normalizedPath + ".partial"); } } @@ -391,7 +502,7 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM var phase = mediaEvent.Phase is MediaJobPhase.PROBING ? MediaTranscriptionPhase.PROBING : MediaTranscriptionPhase.TRANSCODING; - this.UpdateImportState(operation.Owner!.Value, Path.GetFileName(mediaPath), phase, mediaEvent.Progress, MediaImportStatus.RUNNING); + this.UpdateImportState(operation.Target!.Value, Path.GetFileName(mediaPath), phase, mediaEvent.Progress, MediaImportStatus.RUNNING); } switch (mediaEvent.Phase) @@ -448,12 +559,12 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM } /// Creates and registers operation-owned cancellation state. - /// Media import owner. + /// Optional visible media import target. /// Caller token linked to the operation. /// The registered operation. - private MediaOperation CreateOperation(MediaImportOwner? owner, CancellationToken token) + private MediaOperation CreateOperation(MediaImportTarget? target, CancellationToken token) { - var operation = new MediaOperation(owner, token); + var operation = new MediaOperation(target, token); lock (this.stateLock) this.operations.Add(operation); @@ -471,23 +582,69 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM } /// Updates and publishes copied state for exactly one owner. - private void UpdateImportState(MediaImportOwner owner, string fileName, MediaTranscriptionPhase phase, double? progress, MediaImportStatus status) + private void UpdateImportState(MediaImportTarget target, string fileName, MediaTranscriptionPhase phase, double? progress, MediaImportStatus status) { var snapshot = new MediaImportSnapshot { - Owner = owner, + Owner = target.Owner, + Target = target, CurrentFileName = fileName, Phase = phase, Progress = progress, Status = status, - CompletedAttachments = this.GetSnapshot(owner)?.CompletedAttachments ?? [], - CompletedTextTargets = this.GetSnapshot(owner)?.CompletedTextTargets ?? new Dictionary(), }; lock (this.stateLock) - this.snapshots[owner] = snapshot; + this.snapshots[target.Owner] = snapshot; - this.StateChanged?.Invoke(owner); + this.NotifyStateChanged(target.Owner); + } + + /// Publishes one retained terminal result after an entire target batch ended. + private void CompleteImport(MediaImportTarget target, string fileName, MediaImportStatus status, IReadOnlyList failures) + { + lock (this.stateLock) + { + this.snapshots[target.Owner] = new() + { + Owner = target.Owner, + Target = target, + CurrentFileName = fileName, + Phase = MediaTranscriptionPhase.IDLE, + Progress = null, + Status = status, + }; + + this.outcomes[target.Owner] = new() + { + Owner = target.Owner, + Status = status, + Failures = [.. failures], + }; + } + + this.NotifyStateChanged(target.Owner); + } + + /// Publishes state changes without allowing one stale UI subscriber to fault a worker. + private void NotifyStateChanged(MediaImportOwner owner) + { + if (this.StateChanged is not { } stateChanged) + return; + + foreach (var @delegate in stateChanged.GetInvocationList()) + { + var handler = (Action)@delegate; + + try + { + handler(owner); + } + catch (Exception exception) + { + logger.LogWarning(exception, "A media state subscriber failed for owner '{Owner}'.", owner); + } + } } /// Maps runtime codes to localized user-facing fallback text. @@ -578,16 +735,16 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM private sealed class MediaOperation : IDisposable { /// Creates operation state linked to a caller token. - /// Media import owner. + /// Optional visible media import target. /// Caller cancellation token. - public MediaOperation(MediaImportOwner? owner, CancellationToken token) + public MediaOperation(MediaImportTarget? target, CancellationToken token) { - this.Owner = owner; + this.Target = target; this.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); } - /// Gets the optional visible import owner; voice operations have none. - public MediaImportOwner? Owner { get; } + /// Gets the optional visible import target; voice operations have none. + public MediaImportTarget? Target { get; } /// Gets the unique temporary-path identifier. public Guid Id { get; } = Guid.NewGuid(); @@ -604,4 +761,12 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM /// Disposes operation-owned cancellation state. public void Dispose() => this.Cancellation.Dispose(); } + + /// Mutable successful results waiting for acknowledgement by one target. + private sealed class PendingDelivery + { + public List Attachments { get; } = []; + + public string? Text { get; set; } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Media.cs b/app/MindWork AI Studio/Tools/Services/RustService.Media.cs index 82bb87ef..6e575836 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Media.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Media.cs @@ -38,10 +38,13 @@ public partial class RustService await using var stream = await response.Content.ReadAsStreamAsync(token); using var reader = new StreamReader(stream); - while (!reader.EndOfStream && !token.IsCancellationRequested) + while (!token.IsCancellationRequested) { var line = await reader.ReadLineAsync(token); - if (line is null || !line.StartsWith("data:", StringComparison.Ordinal)) + if (line is null) + yield break; + + if (!line.StartsWith("data:", StringComparison.Ordinal)) continue; var json = line["data:".Length..].Trim();