Rework after third review

This commit is contained in:
Thorsten Sommer 2026-07-14 16:11:35 +02:00
parent 3fcca39b51
commit 6b92a0bc85
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
27 changed files with 573 additions and 162 deletions

View File

@ -31,7 +31,6 @@
@if (this.Body is not null)
{
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
<CascadingValue Value="@this.CurrentMediaImportOwner">
<CascadingValue Value="@this">
<CascadingValue Value="@this.Component">
@ -161,7 +160,7 @@
@if (this.ShowReset)
{
<MudButton Variant="Variant.Filled" Style="@this.GetResetColor()" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@(async () => await this.InnerResetForm())">
<MudButton Variant="Variant.Filled" Style="@this.GetResetColor()" StartIcon="@Icons.Material.Filled.Refresh" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)" OnClick="@(async () => await this.InnerResetForm())">
@TB("Reset")
</MudButton>
}

View File

@ -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<TSettings> : 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<TSettings> : 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<TSettings> : AssistantLowerBase wher
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (owner == this.CurrentMediaImportOwner)
_ = this.InvokeAsync(this.StateHasChanged);
_ = this.InvokeAsync(async () =>
{
await this.ConsumeMediaOutcomeAsync();
this.StateHasChanged();
});
}
/// <summary>Consumes a terminal media notification when this assistant is visible.</summary>
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

View File

@ -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."

View File

@ -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;

View File

@ -2,53 +2,62 @@
@if (this.UseSmallForm)
{
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
@if (this.isDraggingOver)
{
<MudBadge
Content="@this.DocumentPaths.Count"
Color="Color.Primary"
Overlap="true"
Class="cursor-pointer"
OnClick="@this.OpenAttachmentsDialog">
<MudLink OnClick="@this.AddFilesManually" Style="text-decoration: none;">
<MudTextField T="string"
Text="@DROP_FILES_HERE_TEXT"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.AttachFile"
Typo="Typo.body2"
Variant="Variant.Outlined"
ReadOnly="true"
/>
</MudLink>
</MudBadge>
}
else if (this.DocumentPaths.Any())
{
<MudTooltip Text="@T("Click the paperclip to attach files, or click the number to see your attached files.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudStack Spacing="0">
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
@if (this.isDraggingOver)
{
<MudBadge
Content="@this.DocumentPaths.Count"
Color="Color.Primary"
Overlap="true"
Class="cursor-pointer"
OnClick="@this.OpenAttachmentsDialog">
<MudLink OnClick="@this.AddFilesManually" Style="text-decoration: none;">
<MudTextField T="string"
Text="@DROP_FILES_HERE_TEXT"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.AttachFile"
Typo="Typo.body2"
Variant="Variant.Outlined"
ReadOnly="true"
Disabled="@this.IsUnavailable"
/>
</MudLink>
</MudBadge>
}
else if (this.DocumentPaths.Any())
{
<MudTooltip Text="@T("Click the paperclip to attach files, or click the number to see your attached files.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudBadge
Content="@this.DocumentPaths.Count"
Color="Color.Primary"
Overlap="true"
Class="cursor-pointer"
OnClick="@this.OpenAttachmentsDialog">
<MudIconButton
Icon="@Icons.Material.Filled.AttachFile"
Color="Color.Default"
Disabled="@this.IsUnavailable"
OnClick="@this.AddFilesManually"/>
</MudBadge>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Click here to attach files.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton
Icon="@Icons.Material.Filled.AttachFile"
Color="Color.Default"
Disabled="@this.IsUnavailable"
OnClick="@this.AddFilesManually"/>
</MudBadge>
</MudTooltip>
}
else
</MudTooltip>
}
</div>
@if (this.ShowMediaStatus)
{
<MudTooltip Text="@T("Click here to attach files.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton
Icon="@Icons.Material.Filled.AttachFile"
Color="Color.Default"
OnClick="@this.AddFilesManually"/>
</MudTooltip>
<MediaTranscriptionStatus Owner="@this.EffectiveImportOwner" TargetId="@this.EffectiveMediaImportTarget.TargetId" Compact="true"/>
}
</div>
</MudStack>
}
else
{
@ -69,6 +78,10 @@ else
</MudButton>
</MudStack>
}
@if (this.ShowMediaStatus)
{
<MediaTranscriptionStatus Owner="@this.EffectiveImportOwner" TargetId="@this.EffectiveMediaImportTarget.TargetId"/>
}
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
<MudPaper Height="20em" Outlined="true" Class="@this.dragClass" Style="overflow-y: auto;">
@foreach (var fileAttachment in this.DocumentPaths)

View File

@ -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; }
/// <summary>Whether this control renders its own media status.</summary>
[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();
});
}
/// <summary>Consumes outcomes for dialog-local controls that have no chat or assistant owner surface.</summary>
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.")));
}
}
/// <summary>Reattaches completed owner results after progress updates or navigation.</summary>
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);
}
/// <summary>Unsubscribes from the singleton media service.</summary>
@ -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);

View File

@ -101,7 +101,7 @@
</MudTooltip>
}
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)"/>
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)"/>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>

View File

@ -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();
});
}
/// <summary>Consumes a terminal media notification when its chat is visible.</summary>
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();
}

View File

@ -4,16 +4,31 @@
@if (this.Snapshot is { IsBusy: true } snapshot)
{
<MudPaper Outlined="true" Class="pa-2 mb-2">
<MudStack Row="true" AlignItems="AlignItems.Center">
@if (this.Compact)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Class="pa-1">
<MudProgressCircular Size="Size.Small" Indeterminate="@(snapshot.Progress is null)" Value="@((snapshot.Progress ?? 0) * 100)"/>
<MudText Typo="Typo.body2">
@this.StatusText
</MudText>
<MudSpacer/>
<MudTooltip Text="@T("Stop media transcription")">
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(async () => await this.MediaTranscriptionService.StopAsync(this.Owner))"/>
<MudIconButton Size="Size.Small" Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(async () => await this.MediaTranscriptionService.StopAsync(this.Owner))"/>
</MudTooltip>
</MudStack>
</MudPaper>
}
else
{
<MudPaper Outlined="true" Class="pa-2 mb-2">
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudProgressCircular Size="Size.Small" Indeterminate="@(snapshot.Progress is null)" Value="@((snapshot.Progress ?? 0) * 100)"/>
<MudText Typo="Typo.body2">
@this.StatusText
</MudText>
<MudSpacer/>
<MudTooltip Text="@T("Stop media transcription")">
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(async () => await this.MediaTranscriptionService.StopAsync(this.Owner))"/>
</MudTooltip>
</MudStack>
</MudPaper>
}
}

View File

@ -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);
/// <summary>Optional target filter used by embedded file controls.</summary>
[Parameter]
public string TargetId { get; set; } = string.Empty;
/// <summary>Renders the status without an enclosing paper surface.</summary>
[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;
}
}
/// <summary>Gets the localized visible status for the active import.</summary>
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,
};
}
}
/// <summary>Subscribes to singleton import state changes.</summary>
protected override async Task OnInitializedAsync()

View File

@ -11,13 +11,17 @@
<MudText Typo="Typo.body2">
@T("Drop one file here to load its content.")
</MudText>
<MediaTranscriptionStatus Owner="@this.EffectiveImportOwner" TargetId="@this.EffectiveMediaImportTarget.TargetId" Compact="true"/>
</MudStack>
</MudPaper>
</div>
}
else
{
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Class="mb-3" Disabled="@this.IsUnavailable">
@this.ButtonText
</MudButton>
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.None" Wrap="Wrap.Wrap" Class="mb-3">
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Disabled="@this.IsUnavailable">
@this.ButtonText
</MudButton>
<MediaTranscriptionStatus Owner="@this.EffectiveImportOwner" TargetId="@this.EffectiveMediaImportTarget.TargetId" Compact="true"/>
</MudStack>
}

View File

@ -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();
});
}
/// <summary>Consumes outcomes for dialog-local controls that have no assistant owner surface.</summary>
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.")));
}
}
/// <summary>Applies a completed target transcript after progress or navigation.</summary>
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);
}
/// <summary>Unsubscribes from the singleton media service.</summary>
@ -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);

View File

@ -1,4 +1,5 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Media;
using AIStudio.Tools.MIME;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;

View File

@ -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)

View File

@ -0,0 +1,15 @@
using AIStudio.Chat;
namespace AIStudio.Tools.Media;
/// <summary>Pending media results waiting for one concrete UI target.</summary>
public sealed record MediaImportDelivery
{
public required MediaImportTarget Target { get; init; }
public IReadOnlyList<FileAttachment> Attachments { get; init; } = [];
public string? Text { get; init; }
public bool IsEmpty => this.Attachments.Count is 0 && this.Text is null;
}

View File

@ -0,0 +1,6 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Media;
/// <summary>One user-visible failure retained until its owner is displayed.</summary>
public sealed record MediaImportFailure(string FileName, string UserMessage, MediaJobErrorCode? ErrorCode = null);

View File

@ -0,0 +1,11 @@
namespace AIStudio.Tools.Media;
/// <summary>Terminal batch outcome retained until its owner is displayed.</summary>
public sealed record MediaImportOutcome
{
public required MediaImportOwner Owner { get; init; }
public required MediaImportStatus Status { get; init; }
public IReadOnlyList<MediaImportFailure> Failures { get; init; } = [];
}

View File

@ -1,6 +1,6 @@
using AIStudio.Tools.AssistantSessions;
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>Identifies the chat or assistant that owns a media import.</summary>
public readonly record struct MediaImportOwner(MediaImportOwnerKind Kind, string Id)

View File

@ -1,4 +1,4 @@
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>Supported persistent media-operation owners.</summary>
public enum MediaImportOwnerKind

View File

@ -1,12 +1,12 @@
using AIStudio.Chat;
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>Copied owner-specific state suitable for rendering after navigation.</summary>
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<FileAttachment> CompletedAttachments { get; init; } = [];
public IReadOnlyDictionary<string, string> CompletedTextTargets { get; init; } = new Dictionary<string, string>();
public bool IsBusy => this.Status is MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING;
}

View File

@ -1,4 +1,4 @@
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>Lifecycle status retained independently for each owner.</summary>
public enum MediaImportStatus

View File

@ -1,4 +1,4 @@
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>Identifies the concrete attachment or file-content field inside an owner.</summary>
public readonly record struct MediaImportTarget(MediaImportOwner Owner, string TargetId);

View File

@ -1,4 +1,4 @@
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>Visible phases of the serialized media import lane.</summary>
public enum MediaTranscriptionPhase

View File

@ -1,6 +1,6 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>
/// Typed terminal result returned by media import and voice operations.

View File

@ -1,4 +1,4 @@
namespace AIStudio.Tools.Services;
namespace AIStudio.Tools.Media;
/// <summary>
/// Terminal outcome of a media transcription operation.

View File

@ -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
/// <summary>The active or queued import operation for each owner.</summary>
private readonly Dictionary<MediaImportOwner, MediaOperation> currentImports = [];
/// <summary>The latest copied state retained for each owner across navigation.</summary>
/// <summary>The latest active or unacknowledged terminal state for each owner.</summary>
private readonly Dictionary<MediaImportOwner, MediaImportSnapshot> snapshots = [];
/// <summary>Successful results waiting for their concrete UI target.</summary>
private readonly Dictionary<MediaImportTarget, PendingDelivery> pendingDeliveries = [];
/// <summary>Terminal notifications waiting for their owner surface to be displayed.</summary>
private readonly Dictionary<MediaImportOwner, MediaImportOutcome> outcomes = [];
/// <summary>Owners whose complete file batches are managed by this service.</summary>
private readonly HashSet<MediaImportOwner> activeBatches = [];
@ -39,7 +46,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
public event Action<MediaImportOwner>? StateChanged;
/// <summary>Gets whether one owner has queued, running, or canceling media work.</summary>
public bool IsBusy(MediaImportOwner owner) => this.GetSnapshot(owner)?.IsBusy ?? false;
public bool IsBusy(MediaImportOwner owner)
{
lock (this.stateLock)
return this.activeBatches.Contains(owner);
}
/// <summary>Gets the last retained state for one owner.</summary>
public MediaImportSnapshot? GetSnapshot(MediaImportOwner owner)
@ -55,19 +66,102 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
return [.. this.snapshots.Values];
}
/// <summary>Starts an owner-managed attachment batch and returns without holding the UI event handler.</summary>
public bool TryStartAttachmentBatch(IReadOnlyList<string> mediaPaths, MediaImportOwner owner, ChatThread? ownerChat = null)
/// <summary>Gets copied results that have not yet been applied by one target.</summary>
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,
};
}
}
/// <summary>Removes exactly the results that one target applied successfully.</summary>
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);
}
}
/// <summary>Consumes one terminal notification when its owner surface is displayed.</summary>
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;
}
/// <summary>Discards retained inactive state and deletes unclaimed managed transcript files.</summary>
public void ClearOwnerState(MediaImportOwner owner)
{
List<FileAttachment> 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);
}
/// <summary>Starts an owner-managed attachment batch and returns without holding the UI event handler.</summary>
public bool TryStartAttachmentBatch(IReadOnlyList<string> 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<MediaImportFailure> 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<string, string>(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);
}
/// <summary>Serially transcribes a complete owner batch while retaining every successful result.</summary>
private async Task RunAttachmentBatchAsync(IReadOnlyList<string> mediaPaths, MediaImportOwner owner, ChatThread? ownerChat)
private async Task RunAttachmentBatchAsync(IReadOnlyList<string> 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<MediaImportFailure> 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);
}
}
/// <summary>Adds a successful partial result to the retained owner snapshot.</summary>
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);
}
/// <summary>
/// Transcribes an attachment or file-content import on the serialized visible lane.
/// </summary>
/// <param name="mediaPath">Source media path.</param>
/// <param name="owner">Media import owner.</param>
/// <param name="target">Media import target.</param>
/// <param name="token">Caller cancellation token.</param>
/// <returns>A typed terminal result.</returns>
private async Task<MediaTranscriptionResult> TranscribeImportAsync(string mediaPath, MediaImportOwner owner, CancellationToken token = default)
private async Task<MediaTranscriptionResult> 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
}
/// <summary>Creates and registers operation-owned cancellation state.</summary>
/// <param name="owner">Media import owner.</param>
/// <param name="target">Optional visible media import target.</param>
/// <param name="token">Caller token linked to the operation.</param>
/// <returns>The registered operation.</returns>
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
}
/// <summary>Updates and publishes copied state for exactly one owner.</summary>
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<string, string>(),
};
lock (this.stateLock)
this.snapshots[owner] = snapshot;
this.snapshots[target.Owner] = snapshot;
this.StateChanged?.Invoke(owner);
this.NotifyStateChanged(target.Owner);
}
/// <summary>Publishes one retained terminal result after an entire target batch ended.</summary>
private void CompleteImport(MediaImportTarget target, string fileName, MediaImportStatus status, IReadOnlyList<MediaImportFailure> 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);
}
/// <summary>Publishes state changes without allowing one stale UI subscriber to fault a worker.</summary>
private void NotifyStateChanged(MediaImportOwner owner)
{
if (this.StateChanged is not { } stateChanged)
return;
foreach (var @delegate in stateChanged.GetInvocationList())
{
var handler = (Action<MediaImportOwner>)@delegate;
try
{
handler(owner);
}
catch (Exception exception)
{
logger.LogWarning(exception, "A media state subscriber failed for owner '{Owner}'.", owner);
}
}
}
/// <summary>Maps runtime codes to localized user-facing fallback text.</summary>
@ -578,16 +735,16 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
private sealed class MediaOperation : IDisposable
{
/// <summary>Creates operation state linked to a caller token.</summary>
/// <param name="owner">Media import owner.</param>
/// <param name="target">Optional visible media import target.</param>
/// <param name="token">Caller cancellation token.</param>
public MediaOperation(MediaImportOwner? owner, CancellationToken token)
public MediaOperation(MediaImportTarget? target, CancellationToken token)
{
this.Owner = owner;
this.Target = target;
this.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
}
/// <summary>Gets the optional visible import owner; voice operations have none.</summary>
public MediaImportOwner? Owner { get; }
/// <summary>Gets the optional visible import target; voice operations have none.</summary>
public MediaImportTarget? Target { get; }
/// <summary>Gets the unique temporary-path identifier.</summary>
public Guid Id { get; } = Guid.NewGuid();
@ -604,4 +761,12 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
/// <summary>Disposes operation-owned cancellation state.</summary>
public void Dispose() => this.Cancellation.Dispose();
}
/// <summary>Mutable successful results waiting for acknowledgement by one target.</summary>
private sealed class PendingDelivery
{
public List<FileAttachment> Attachments { get; } = [];
public string? Text { get; set; }
}
}

View File

@ -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();