mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 18:32:12 +00:00
Rework after third review
This commit is contained in:
parent
3fcca39b51
commit
6b92a0bc85
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;"/>
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.MIME;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
@ -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)
|
||||
|
||||
15
app/MindWork AI Studio/Tools/Media/MediaImportDelivery.cs
Normal file
15
app/MindWork AI Studio/Tools/Media/MediaImportDelivery.cs
Normal 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;
|
||||
}
|
||||
6
app/MindWork AI Studio/Tools/Media/MediaImportFailure.cs
Normal file
6
app/MindWork AI Studio/Tools/Media/MediaImportFailure.cs
Normal 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);
|
||||
11
app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs
Normal file
11
app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs
Normal 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; } = [];
|
||||
}
|
||||
@ -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)
|
||||
@ -1,4 +1,4 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>Supported persistent media-operation owners.</summary>
|
||||
public enum MediaImportOwnerKind
|
||||
@ -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;
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>Lifecycle status retained independently for each owner.</summary>
|
||||
public enum MediaImportStatus
|
||||
@ -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);
|
||||
@ -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
|
||||
@ -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.
|
||||
@ -1,4 +1,4 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Terminal outcome of a media transcription operation.
|
||||
@ -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; }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user