mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-07-16 14:46:26 +00:00
Added audio and video transcription for chats and assistants (#856)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
5dc6282908
commit
aaf77b6882
@ -31,14 +31,16 @@
|
||||
|
||||
@if (this.Body is not null)
|
||||
{
|
||||
<CascadingValue Value="@this.CurrentMediaImportOwner">
|
||||
<CascadingValue Value="@this">
|
||||
<CascadingValue Value="@this.Component">
|
||||
@this.Body
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.Start" Class="mb-3">
|
||||
<MudButton Disabled="@(this.SubmitDisabled || this.IsProcessing)" Variant="Variant.Filled" OnClick="@(async () => await this.Start())" Style="@this.SubmitButtonStyle">
|
||||
<MudButton Disabled="@(this.SubmitDisabled || this.IsProcessing || this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))" Variant="Variant.Filled" OnClick="@(async () => await this.Start())" Style="@this.SubmitButtonStyle">
|
||||
@this.SubmitText
|
||||
</MudButton>
|
||||
@if (this.IsProcessing)
|
||||
@ -158,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;
|
||||
@ -48,6 +49,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
[Inject]
|
||||
protected AIJobService AIJobService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
protected MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
protected abstract string Title { get; }
|
||||
|
||||
protected abstract string Description { get; }
|
||||
@ -132,6 +136,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
protected CancellationTokenSource? CancellationTokenSource;
|
||||
private bool isDisposed;
|
||||
private AssistantSessionKey assistantSessionKey;
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey);
|
||||
private Guid? assistantSessionId;
|
||||
private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot;
|
||||
|
||||
@ -145,6 +150,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
/// </summary>
|
||||
protected bool HasAssistantSession => this.assistantSessionId is not null;
|
||||
|
||||
/// <summary>Gets whether this assistant currently owns active media work.</summary>
|
||||
protected bool IsMediaImportBusy => this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assistant-specific identifier used to distinguish session slots.
|
||||
/// </summary>
|
||||
@ -154,6 +162,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
|
||||
if (!this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Title))
|
||||
@ -176,6 +185,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()
|
||||
@ -223,6 +233,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
private async Task Start()
|
||||
{
|
||||
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
|
||||
return;
|
||||
|
||||
var activeSession = this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey);
|
||||
if (activeSession?.IsActive ?? false)
|
||||
{
|
||||
@ -634,10 +647,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;
|
||||
@ -672,6 +687,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.isDisposed = true;
|
||||
try
|
||||
{
|
||||
@ -686,6 +702,46 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes assistant actions when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.CurrentMediaImportOwner)
|
||||
_ = 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.Warnings.Count > 0)
|
||||
{
|
||||
var message = string.Join(Environment.NewLine, outcome.Warnings.Select(warning => $"{warning.FileName}: {warning.UserMessage}"));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, message));
|
||||
}
|
||||
|
||||
if (outcome.Status is MediaImportStatus.CANCELLED)
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, this.TB("The media transcription was canceled.")));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Assistant sessions
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList Color="Color.Primary" T="DataDocumentAnalysisPolicy" Class="mb-1" SelectedValue="@this.selectedPolicy" SelectedValueChanged="@this.SelectedPolicyChanged">
|
||||
<MudList Disabled="@this.ArePolicyControlsDisabled" Color="Color.Primary" T="DataDocumentAnalysisPolicy" Class="mb-1" SelectedValue="@this.selectedPolicy" SelectedValueChanged="@this.SelectedPolicyChanged">
|
||||
@foreach (var policy in this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies)
|
||||
{
|
||||
@if (policy.IsEnterpriseConfiguration)
|
||||
@ -44,10 +44,10 @@ else
|
||||
}
|
||||
|
||||
<MudStack Row="@true" Class="mt-1">
|
||||
<MudButton OnClick="@this.AddPolicy" Variant="Variant.Filled" Color="Color.Primary">
|
||||
<MudButton OnClick="@this.AddPolicy" Disabled="@this.ArePolicyControlsDisabled" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@T("Add policy")
|
||||
</MudButton>
|
||||
<MudButton OnClick="@this.RemovePolicy" Disabled="@((this.selectedPolicy?.IsProtected ?? true) || (this.selectedPolicy?.IsEnterpriseConfiguration ?? true))" Variant="Variant.Filled" Color="Color.Error">
|
||||
<MudButton OnClick="@this.RemovePolicy" Disabled="@(this.ArePolicyControlsDisabled || (this.selectedPolicy?.IsProtected ?? true) || (this.selectedPolicy?.IsEnterpriseConfiguration ?? true))" Variant="Variant.Filled" Color="Color.Error">
|
||||
@T("Delete this policy")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@ -334,8 +334,13 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
|
||||
private bool IsNoPolicySelected => this.selectedPolicy is null;
|
||||
|
||||
private bool ArePolicyControlsDisabled => this.IsProcessing || this.IsMediaImportBusy;
|
||||
|
||||
private void SelectedPolicyChanged(DataDocumentAnalysisPolicy? policy)
|
||||
{
|
||||
if (this.ArePolicyControlsDisabled)
|
||||
return;
|
||||
|
||||
this.selectedPolicy = policy;
|
||||
this.ResetForm();
|
||||
this.policyDefinitionExpanded = !this.selectedPolicy?.IsProtected ?? true;
|
||||
@ -353,6 +358,9 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
|
||||
private async Task AddPolicy()
|
||||
{
|
||||
if (this.ArePolicyControlsDisabled)
|
||||
return;
|
||||
|
||||
this.SettingsManager.ConfigurationData.DocumentAnalysis.Policies.Add(new ()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
@ -373,6 +381,9 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore<NoSettingsPan
|
||||
|
||||
private async Task RemovePolicy()
|
||||
{
|
||||
if (this.ArePolicyControlsDisabled)
|
||||
return;
|
||||
|
||||
if(this.selectedPolicy is null)
|
||||
return;
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ else
|
||||
{
|
||||
var fileState = this.assistantState.FileContent[fileContent.Name];
|
||||
<div class="@fileContent.Class" style="@GetOptionalStyle(fileContent.Style)">
|
||||
<ReadFileContent @bind-FileContent="@fileState.Content" EnableDragDrop="true" Layer="@DropLayers.ASSISTANTS" CatchAllDocuments="true" />
|
||||
<ReadFileContent @bind-FileContent="@fileState.Content" MediaImportTargetId="@fileContent.Name" EnableDragDrop="true" Layer="@DropLayers.ASSISTANTS" CatchAllDocuments="true" />
|
||||
</div>
|
||||
}
|
||||
break;
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -2293,9 +2299,24 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The ima
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
|
||||
|
||||
-- Media transcription was canceled. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1233815302"] = "Media transcription was canceled. Open the assistant to review it."
|
||||
|
||||
-- Media transcription failed. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2177964639"] = "Media transcription failed. Open the assistant to review it."
|
||||
|
||||
-- Media transcription completed with a warning. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2217674098"] = "Media transcription completed with a warning. Open the assistant to review it."
|
||||
|
||||
-- Media is still being prepared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2600900617"] = "Media is still being prepared."
|
||||
|
||||
-- Assistant is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistant is still running."
|
||||
|
||||
-- The media transcript is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3248321953"] = "The media transcript is ready."
|
||||
|
||||
-- Assistant was canceled. Open it to review the result.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistant was canceled. Open it to review the result."
|
||||
|
||||
@ -2398,18 +2419,33 @@ 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."
|
||||
|
||||
-- Transcribe media files
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2178031033"] = "Transcribe media files"
|
||||
|
||||
-- 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"
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "Document Preview"
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3172443094"] = "Media files require a configured transcription provider. Configure one in the transcription settings."
|
||||
|
||||
-- The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T322693339"] = "The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider."
|
||||
|
||||
-- Clear file list
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "Clear file list"
|
||||
|
||||
@ -2434,6 +2470,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..."
|
||||
|
||||
@ -2446,6 +2485,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."
|
||||
|
||||
@ -2671,6 +2713,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T3511160492"] = "Ac
|
||||
-- Please review this text again. The content was changed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T941885055"] = "Please review this text again. The content was changed."
|
||||
|
||||
-- Waiting to prepare media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1167267986"] = "Waiting to prepare media"
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Stop media transcription"
|
||||
|
||||
-- Stopping media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1878101489"] = "Stopping media transcription"
|
||||
|
||||
-- Inspecting media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Inspecting media"
|
||||
|
||||
-- Transcribing
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2938661425"] = "Transcribing"
|
||||
|
||||
-- Preparing audio
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T3200155905"] = "Preparing audio"
|
||||
|
||||
-- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications."
|
||||
|
||||
@ -2788,18 +2848,33 @@ 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."
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3172443094"] = "Media files require a configured transcription provider. Configure one in the transcription settings."
|
||||
|
||||
-- Use file content as input
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input"
|
||||
|
||||
-- Select file to read its content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "Select file to read its content"
|
||||
|
||||
-- Transcribe media file
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3554222960"] = "Transcribe media file"
|
||||
|
||||
-- The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READWEBCONTENT::T1164201762"] = "The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used."
|
||||
|
||||
@ -3538,9 +3613,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants
|
||||
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Voice recording has been disabled for this session because audio playback could not be initialized on the client."
|
||||
|
||||
-- Failed to create the transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider."
|
||||
|
||||
-- Failed to start audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Failed to start audio recording."
|
||||
|
||||
@ -3559,21 +3631,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transcrip
|
||||
-- Unfortunately, there was an error communicating with the AI system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Unfortunately, there was an error communicating with the AI system."
|
||||
|
||||
-- The configured transcription provider was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "The configured transcription provider was not found."
|
||||
|
||||
-- Failed to stop audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "Failed to stop audio recording."
|
||||
|
||||
-- The configured transcription provider does not meet the minimum confidence level.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3834149033"] = "The configured transcription provider does not meet the minimum confidence level."
|
||||
|
||||
-- An error occurred during transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "An error occurred during transcription."
|
||||
|
||||
-- No transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T663630295"] = "No transcription provider is configured."
|
||||
|
||||
-- The transcription result is empty.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transcription result is empty."
|
||||
|
||||
@ -6748,9 +6811,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the serve
|
||||
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "This library is used to create temporary folders in runtime tests and supporting filesystem operations."
|
||||
|
||||
-- This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2173617769"] = "This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file."
|
||||
|
||||
-- For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2174764529"] = "For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose."
|
||||
|
||||
@ -6769,6 +6829,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration pl
|
||||
-- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK."
|
||||
|
||||
-- We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2345444286"] = "We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding."
|
||||
|
||||
-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view."
|
||||
|
||||
@ -6850,6 +6913,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2929232062"] = "Copies the confi
|
||||
-- Copies the root certificate fingerprint to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "Copies the root certificate fingerprint to the clipboard"
|
||||
|
||||
-- This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
@ -6895,6 +6961,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "External HTTPS c
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "User-language provided by the OS"
|
||||
|
||||
-- webm-iterable provides the EBML and WebM writing path for normalized audio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3385332793"] = "webm-iterable provides the EBML and WebM writing path for normalized audio."
|
||||
|
||||
-- Status:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "Status:"
|
||||
|
||||
@ -6955,6 +7024,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T396609403"] = "not applicable"
|
||||
-- Copies the allowed host configuration to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Copies the allowed host configuration to the clipboard"
|
||||
|
||||
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
|
||||
|
||||
-- Installed Pandoc version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installed Pandoc version"
|
||||
|
||||
@ -6973,6 +7045,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "This library is
|
||||
-- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system."
|
||||
|
||||
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus provides the Opus encoder and decoder used by the media pipeline."
|
||||
|
||||
-- Community & Code
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
|
||||
|
||||
@ -8485,6 +8560,42 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
|
||||
|
||||
-- The configured transcription provider could not be created.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1235984176"] = "The configured transcription provider could not be created."
|
||||
|
||||
-- The selected media file no longer exists.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T129859547"] = "The selected media file no longer exists."
|
||||
|
||||
-- The selected media file does not contain an audio track.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T134825479"] = "The selected media file does not contain an audio track."
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1543974632"] = "The media file could not be transcribed."
|
||||
|
||||
-- The selected file cannot be processed as media.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1707342767"] = "The selected file cannot be processed as media."
|
||||
|
||||
-- The audio track contains no audible signal, so there is nothing to transcribe.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1988190152"] = "The audio track contains no audible signal, so there is nothing to transcribe."
|
||||
|
||||
-- The media file is damaged or its format could not be identified.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2004316549"] = "The media file is damaged or its format could not be identified."
|
||||
|
||||
-- This media format or audio codec is not supported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2142564510"] = "This media format or audio codec is not supported."
|
||||
|
||||
-- No usable transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2282521655"] = "No usable transcription provider is configured."
|
||||
|
||||
-- The media file could not be prepared for transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2749117459"] = "The media file could not be prepared for transcription."
|
||||
|
||||
-- The transcription provider could not transcribe the media file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T3091669215"] = "The transcription provider could not transcribe the media file."
|
||||
|
||||
-- The media pipeline ended without an output file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T632852430"] = "The media pipeline ended without an output file."
|
||||
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
|
||||
@ -24,6 +24,17 @@ public sealed record ChatThread
|
||||
/// </summary>
|
||||
public Guid WorkspaceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The monotonically increasing number used for managed media transcript filenames.
|
||||
/// </summary>
|
||||
public ulong LastMediaTranscriptNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Managed transcript attachments prepared for the composer but not sent yet.
|
||||
/// Empty by default so older serialized threads require no migration.
|
||||
/// </summary>
|
||||
public List<ManagedTranscriptAttachment> PendingMediaTranscripts { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the provider selected for the chat thread.
|
||||
/// </summary>
|
||||
@ -240,14 +251,28 @@ public sealed record ChatThread
|
||||
{
|
||||
var previousBlock = sortedBlocks[index - 1];
|
||||
if (previousBlock.Role is ChatRole.USER && previousBlock.HideFromUser)
|
||||
{
|
||||
DeleteManagedAttachments(previousBlock);
|
||||
this.Blocks.Remove(previousBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeleteManagedAttachments(block);
|
||||
|
||||
// Remove the block from the chat thread:
|
||||
this.Blocks.Remove(block);
|
||||
}
|
||||
|
||||
private static void DeleteManagedAttachments(ContentBlock block)
|
||||
{
|
||||
if (block.Content is not ContentText textContent)
|
||||
return;
|
||||
|
||||
foreach (var attachment in textContent.FileAttachments)
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms this chat thread to an ERI chat thread.
|
||||
/// </summary>
|
||||
|
||||
@ -14,6 +14,7 @@ namespace AIStudio.Chat;
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(FileAttachment), typeDiscriminator: "file")]
|
||||
[JsonDerivedType(typeof(FileAttachmentImage), typeDiscriminator: "image")]
|
||||
[JsonDerivedType(typeof(ManagedTranscriptAttachment), typeDiscriminator: "managed_transcript")]
|
||||
public record FileAttachment(FileAttachmentType Type, string FileName, string FilePath, long FileSizeBytes)
|
||||
{
|
||||
/// <summary>
|
||||
@ -56,7 +57,7 @@ public record FileAttachment(FileAttachmentType Type, string FileName, string Fi
|
||||
/// <summary>
|
||||
/// Rebuilds the attachment from its current file path so file type detection uses the latest rules.
|
||||
/// </summary>
|
||||
public FileAttachment Normalize() => FromPath(this.FilePath);
|
||||
public virtual FileAttachment Normalize() => FromPath(this.FilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a FileAttachment from a file path by automatically determining the type,
|
||||
|
||||
169
app/MindWork AI Studio/Chat/ManagedTranscriptAttachment.cs
Normal file
169
app/MindWork AI Studio/Chat/ManagedTranscriptAttachment.cs
Normal file
@ -0,0 +1,169 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Attachment whose Markdown file is owned and lifecycle-managed by the media feature.
|
||||
/// </summary>
|
||||
/// <param name="FileName">Display file name.</param>
|
||||
/// <param name="FilePath">Absolute staged or chat-owned path.</param>
|
||||
/// <param name="FileSizeBytes">Current file size.</param>
|
||||
/// <param name="OriginalFileName">Original media file name used in the title and stem.</param>
|
||||
/// <param name="IsStaged">Whether the file still lives in operation staging.</param>
|
||||
public sealed record ManagedTranscriptAttachment(string FileName, string FilePath, long FileSizeBytes, string OriginalFileName, bool IsStaged)
|
||||
: FileAttachment(FileAttachmentType.DOCUMENT, FileName, FilePath, FileSizeBytes)
|
||||
{
|
||||
/// <summary>Refreshes the path-derived name and current file size.</summary>
|
||||
public override FileAttachment Normalize()
|
||||
{
|
||||
var size = File.Exists(this.FilePath) ? new FileInfo(this.FilePath).Length : 0;
|
||||
return this with { FileName = Path.GetFileName(this.FilePath), FileSizeBytes = size };
|
||||
}
|
||||
|
||||
/// <summary>Creates a transcript in an operation-specific staging directory.</summary>
|
||||
/// <param name="originalPath">Original media path.</param>
|
||||
/// <param name="transcript">Provider transcript.</param>
|
||||
/// <returns>The staged managed attachment.</returns>
|
||||
public static async Task<ManagedTranscriptAttachment> CreateStagedAsync(string originalPath, string transcript)
|
||||
{
|
||||
var operationDirectory = Path.Combine(SettingsManager.DataDirectory!, "media-staging", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(operationDirectory);
|
||||
var originalFileName = Path.GetFileName(originalPath);
|
||||
var stagingPath = Path.Combine(operationDirectory, $"{Guid.NewGuid():N}.md");
|
||||
await WriteMarkdownAsync(stagingPath, originalFileName, transcript);
|
||||
return FromPath(stagingPath, originalFileName, isStaged: true);
|
||||
}
|
||||
|
||||
/// <summary>Writes transcript Markdown to a temporary file and atomically publishes it.</summary>
|
||||
/// <param name="targetPath">Final managed target path.</param>
|
||||
/// <param name="originalFileName">Original media file name.</param>
|
||||
/// <param name="transcript">Provider transcript.</param>
|
||||
/// <returns>The chat-owned managed attachment.</returns>
|
||||
internal static async Task<ManagedTranscriptAttachment> CreateAtomicAsync(string targetPath, string originalFileName, string transcript)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
var temporaryPath = Path.Combine(Path.GetDirectoryName(targetPath)!, $".{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
await WriteMarkdownAsync(temporaryPath, originalFileName, transcript);
|
||||
File.Move(temporaryPath, targetPath);
|
||||
return FromPath(targetPath, originalFileName, isStaged: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes a file only when its canonical path has an exact managed structure.</summary>
|
||||
/// <param name="attachment">Candidate managed attachment.</param>
|
||||
/// <returns>Whether an owned file was deleted.</returns>
|
||||
public static bool TryDeleteOwnedFile(FileAttachment attachment)
|
||||
{
|
||||
if (attachment is not ManagedTranscriptAttachment managed || !File.Exists(managed.FilePath))
|
||||
return false;
|
||||
|
||||
var fileInfo = new FileInfo(managed.FilePath);
|
||||
var fullFilePath = Path.GetFullPath(fileInfo.FullName);
|
||||
var fullDataRoot = Path.GetFullPath(SettingsManager.DataDirectory!);
|
||||
var relative = Path.GetRelativePath(fullDataRoot, fullFilePath);
|
||||
|
||||
if (Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathComparison))
|
||||
return false;
|
||||
|
||||
if (fileInfo.LinkTarget is not null || HasLinkedDirectory(fileInfo.Directory, fullDataRoot))
|
||||
return false;
|
||||
|
||||
var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var isStaging = segments is ["media-staging", _, _]
|
||||
&& Guid.TryParseExact(segments[1], "N", out _)
|
||||
&& !string.IsNullOrWhiteSpace(segments[2]);
|
||||
|
||||
var isTemporaryChatTranscript = segments is ["tempChats", _, _, _, _]
|
||||
&& Guid.TryParse(segments[1], out _)
|
||||
&& segments[2] == "attachments"
|
||||
&& segments[3] == "transcripts";
|
||||
|
||||
var isWorkspaceChatTranscript = segments is ["workspaces", _, _, _, _, _]
|
||||
&& Guid.TryParse(segments[1], out _)
|
||||
&& Guid.TryParse(segments[2], out _)
|
||||
&& segments[3] == "attachments"
|
||||
&& segments[4] == "transcripts";
|
||||
|
||||
if (!isStaging && !isTemporaryChatTranscript && !isWorkspaceChatTranscript)
|
||||
return false;
|
||||
|
||||
File.Delete(fullFilePath);
|
||||
var parent = Path.GetDirectoryName(fullFilePath);
|
||||
if (isStaging && parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any())
|
||||
Directory.Delete(parent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Rejects paths traversing any symbolic-link or junction directory below the data root.</summary>
|
||||
private static bool HasLinkedDirectory(DirectoryInfo? directory, string fullDataRoot)
|
||||
{
|
||||
while (directory is not null && !string.Equals(Path.GetFullPath(directory.FullName), fullDataRoot, PathComparison))
|
||||
{
|
||||
if (directory.LinkTarget is not null)
|
||||
return true;
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory is null;
|
||||
}
|
||||
|
||||
/// <summary>Normalizes an original stem using Unicode scalar values and cross-platform rules.</summary>
|
||||
/// <param name="originalFileName">Original media file name.</param>
|
||||
/// <returns>A non-empty stem containing at most 80 Unicode text characters.</returns>
|
||||
internal static string NormalizeOriginalStem(string originalFileName)
|
||||
{
|
||||
var stem = Path.GetFileNameWithoutExtension(originalFileName).Normalize(NormalizationForm.FormC);
|
||||
var normalized = new StringBuilder();
|
||||
|
||||
var textCharacters = 0;
|
||||
foreach (var rune in stem.EnumerateRunes())
|
||||
{
|
||||
if (textCharacters == 80)
|
||||
break;
|
||||
|
||||
var replacement = Rune.IsControl(rune) || rune.Value is '/' or '\\' or '<' or '>' or ':' or '"' or '|' or '?' or '*'
|
||||
? new Rune('-')
|
||||
: rune;
|
||||
|
||||
normalized.Append(replacement);
|
||||
textCharacters++;
|
||||
}
|
||||
|
||||
var result = normalized.ToString().Trim(' ', '.', '-');
|
||||
return string.IsNullOrWhiteSpace(result) ? "media" : result;
|
||||
}
|
||||
|
||||
/// <summary>Creates an attachment record from a file already written to disk.</summary>
|
||||
private static ManagedTranscriptAttachment FromPath(string path, string originalFileName, bool isStaged) => new(
|
||||
Path.GetFileName(path),
|
||||
path,
|
||||
new FileInfo(path).Length,
|
||||
originalFileName,
|
||||
isStaged);
|
||||
|
||||
/// <summary>Writes localized transcript Markdown without a UTF-8 byte-order mark.</summary>
|
||||
private static async Task WriteMarkdownAsync(string path, string originalFileName, string transcript)
|
||||
{
|
||||
var markdown = $"""
|
||||
# Transcription: {originalFileName}
|
||||
|
||||
{transcript.Trim()}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
/// <summary>Gets the platform path comparison used for canonical containment checks.</summary>
|
||||
private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
}
|
||||
@ -1,6 +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;
|
||||
|
||||
@ -61,6 +64,9 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
[Inject]
|
||||
private AssistantSessionService AssistantSessionService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
private async Task OpenSettingsDialog()
|
||||
{
|
||||
if (!this.HasSettingsPanel)
|
||||
@ -71,7 +77,7 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
await this.DialogService.ShowAsync<TSettings>(T("Open Settings"), dialogParameters, DialogOptions.FULLSCREEN);
|
||||
}
|
||||
|
||||
private string BorderColor => this.AssistantSessionSnapshot?.IsActive is true ? this.ColorTheme.GetActivityIndicatorColor(this.SettingsManager) : this.SettingsManager.IsDarkMode switch
|
||||
private string BorderColor => this.AssistantSessionSnapshot?.IsActive is true || this.MediaImportSnapshot?.IsBusy is true ? this.ColorTheme.GetActivityIndicatorColor(this.SettingsManager) : this.SettingsManager.IsDarkMode switch
|
||||
{
|
||||
true => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault,
|
||||
false => this.ColorTheme.GetCurrentPalette(this.SettingsManager).GrayDefault,
|
||||
@ -92,10 +98,29 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
? this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.Component == this.Component)
|
||||
: this.AssistantSessionService.GetSnapshots().FirstOrDefault(snapshot => snapshot.Key.InstanceId == this.AssistantSessionInstanceId);
|
||||
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(new AssistantSessionKey(this.Component, this.AssistantSessionInstanceId));
|
||||
|
||||
private MediaImportSnapshot? MediaImportSnapshot => string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId)
|
||||
? this.MediaTranscriptionService.GetSnapshots().FirstOrDefault(snapshot =>
|
||||
snapshot.Owner.Kind is MediaImportOwnerKind.ASSISTANT
|
||||
&& snapshot.Owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal))
|
||||
: this.MediaTranscriptionService.GetSnapshot(this.CurrentMediaImportOwner);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the assistant session indicator shown on top of the assistant icon.
|
||||
/// </summary>
|
||||
private AssistantSessionIndicatorData? AssistantSessionIndicator => this.AssistantSessionSnapshot?.Status switch
|
||||
private AssistantSessionIndicatorData? AssistantSessionIndicator => this.MediaImportSnapshot?.Status switch
|
||||
{
|
||||
MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING => new(Icons.Material.Filled.ChangeCircle, Color.Info, this.T("Media is still being prepared.")),
|
||||
MediaImportStatus.SUCCEEDED => new(Icons.Material.Filled.TaskAlt, Color.Success, this.T("The media transcript is ready.")),
|
||||
MediaImportStatus.WARNING => new(Icons.Material.Filled.WarningAmber, Color.Warning, this.T("Media transcription completed with a warning. Open the assistant to review it.")),
|
||||
MediaImportStatus.FAILED => new(Icons.Material.Filled.Error, Color.Error, this.T("Media transcription failed. Open the assistant to review it.")),
|
||||
MediaImportStatus.CANCELLED => new(Icons.Material.Filled.Cancel, Color.Warning, this.T("Media transcription was canceled. Open the assistant to review it.")),
|
||||
|
||||
_ => this.AssistantSessionIndicatorWithoutMedia,
|
||||
};
|
||||
|
||||
private AssistantSessionIndicatorData? AssistantSessionIndicatorWithoutMedia => this.AssistantSessionSnapshot?.Status switch
|
||||
{
|
||||
AssistantSessionStatus.RUNNING or AssistantSessionStatus.CANCELING => new(Icons.Material.Filled.ChangeCircle, Color.Info, this.T("Assistant is still running.")),
|
||||
AssistantSessionStatus.COMPLETED => new(Icons.Material.Filled.TaskAlt, Color.Success, this.T("The result is ready.")),
|
||||
@ -104,6 +129,28 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
|
||||
_ => null,
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
var matches = string.IsNullOrWhiteSpace(this.AssistantSessionInstanceId)
|
||||
? owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.StartsWith($"{this.Component}:", StringComparison.Ordinal)
|
||||
: owner == this.CurrentMediaImportOwner;
|
||||
|
||||
if (matches)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the block when assistant session activity changes.
|
||||
/// </summary>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
@if (this.UseSmallForm)
|
||||
{
|
||||
<MudStack Spacing="0">
|
||||
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
|
||||
@if (this.isDraggingOver)
|
||||
{
|
||||
@ -19,6 +20,7 @@
|
||||
Typo="Typo.body2"
|
||||
Variant="Variant.Outlined"
|
||||
ReadOnly="true"
|
||||
Disabled="@this.IsUnavailable"
|
||||
/>
|
||||
</MudLink>
|
||||
</MudBadge>
|
||||
@ -35,6 +37,7 @@
|
||||
<MudIconButton
|
||||
Icon="@Icons.Material.Filled.AttachFile"
|
||||
Color="Color.Default"
|
||||
Disabled="@this.IsUnavailable"
|
||||
OnClick="@this.AddFilesManually"/>
|
||||
</MudBadge>
|
||||
</MudTooltip>
|
||||
@ -45,14 +48,20 @@
|
||||
<MudIconButton
|
||||
Icon="@Icons.Material.Filled.AttachFile"
|
||||
Color="Color.Default"
|
||||
Disabled="@this.IsUnavailable"
|
||||
OnClick="@this.AddFilesManually"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</div>
|
||||
@if (this.ShowMediaStatus)
|
||||
{
|
||||
<MediaTranscriptionStatus Owner="@this.EffectiveImportOwner" TargetId="@this.EffectiveMediaImportTarget.TargetId" Compact="true"/>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (!this.Disabled)
|
||||
@if (!this.IsUnavailable)
|
||||
{
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.None" Wrap="Wrap.Wrap">
|
||||
<MudText Typo="Typo.body1" Inline="true">
|
||||
@ -69,11 +78,15 @@ 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)
|
||||
{
|
||||
@if (this.Disabled)
|
||||
@if (this.IsUnavailable)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Dark" Text="@fileAttachment.FileName" tabindex="-1" Icon="@Icons.Material.Filled.Search" OnClick="@(() => this.InvestigateFile(fileAttachment))"/>
|
||||
}
|
||||
@ -84,7 +97,7 @@ else
|
||||
}
|
||||
</MudPaper>
|
||||
</div>
|
||||
@if (!this.Disabled)
|
||||
@if (!this.IsUnavailable)
|
||||
{
|
||||
<MudButton OnClick="@(async () => await this.ClearAllFiles())" Variant="Variant.Filled" Color="Color.Info" Class="mt-2" StartIcon="@Icons.Material.Filled.Delete">
|
||||
@T("Clear file list")
|
||||
|
||||
@ -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;
|
||||
@ -13,6 +14,11 @@ using DialogOptions = Dialogs.DialogOptions;
|
||||
|
||||
public partial class AttachDocuments : MSGComponentBase
|
||||
{
|
||||
private readonly MediaImportOwner fallbackMediaImportOwner = new(MediaImportOwnerKind.CHAT, $"attachments:{Guid.NewGuid():N}");
|
||||
|
||||
[CascadingParameter]
|
||||
private MediaImportOwner? ImportOwner { get; set; }
|
||||
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AttachDocuments).Namespace, nameof(AttachDocuments));
|
||||
|
||||
[Parameter]
|
||||
@ -48,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; }
|
||||
|
||||
@ -63,6 +73,14 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
[Parameter]
|
||||
public AIStudio.Settings.Provider? Provider { get; set; }
|
||||
|
||||
/// <summary>Optional persisted chat that can own transcript files immediately.</summary>
|
||||
[Parameter]
|
||||
public ChatThread? OwnerChat { get; set; }
|
||||
|
||||
/// <summary>Creates and persists a draft owner after media import confirmation.</summary>
|
||||
[Parameter]
|
||||
public Func<string, Task<ChatThread?>> EnsureOwnerChatAsync { get; set; } = _ => Task.FromResult<ChatThread?>(null);
|
||||
|
||||
[Inject]
|
||||
private ILogger<AttachDocuments> Logger { get; set; } = null!;
|
||||
|
||||
@ -75,17 +93,28 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
[Inject]
|
||||
private PandocAvailabilityService PandocAvailabilityService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
|
||||
private static readonly string DROP_FILES_HERE_TEXT = TB("Drop files here to attach them.");
|
||||
|
||||
private uint numDropAreasAboveThis;
|
||||
private bool isComponentHovered;
|
||||
private bool isDraggingOver;
|
||||
private MediaImportOwner EffectiveImportOwner => this.OwnerChat is not null
|
||||
? 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
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
|
||||
|
||||
// Register this drop area:
|
||||
@ -93,9 +122,101 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Rehydrates results after the component is assigned another chat or target.</summary>
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
await this.SyncCompletedMediaAttachmentsAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.EffectiveImportOwner)
|
||||
_ = 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.Warnings.Count > 0)
|
||||
{
|
||||
var message = string.Join(Environment.NewLine, outcome.Warnings.Select(warning => $"{warning.FileName}: {warning.UserMessage}"));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, message));
|
||||
}
|
||||
|
||||
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 delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget);
|
||||
var completed = delivery?.Attachments ?? [];
|
||||
var pending = this.OwnerChat?.PendingMediaTranscripts ?? [];
|
||||
var changed = false;
|
||||
var ownerPendingChanged = false;
|
||||
|
||||
foreach (var attachment in completed.Concat(pending))
|
||||
changed |= this.DocumentPaths.Add(attachment);
|
||||
|
||||
if (this.OwnerChat is not null)
|
||||
{
|
||||
foreach (var attachment in completed.OfType<ManagedTranscriptAttachment>())
|
||||
{
|
||||
if (this.OwnerChat.PendingMediaTranscripts.All(existing => existing.FilePath != attachment.FilePath))
|
||||
{
|
||||
this.OwnerChat.PendingMediaTranscripts.Add(attachment);
|
||||
ownerPendingChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed || ownerPendingChanged)
|
||||
{
|
||||
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>
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
{
|
||||
if (this.Disabled && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
|
||||
if (this.IsUnavailable && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
|
||||
return;
|
||||
|
||||
switch (triggeredEvent)
|
||||
@ -168,29 +289,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure that Pandoc is installed and ready:
|
||||
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
|
||||
showSuccessMessage: false,
|
||||
showDialog: true);
|
||||
|
||||
// If Pandoc is not available (user cancelled installation), abort file drop:
|
||||
if (!pandocState.IsAvailable)
|
||||
{
|
||||
this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file drop.");
|
||||
this.isDraggingOver = false;
|
||||
this.ClearDragClass();
|
||||
this.StateHasChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if(!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
|
||||
continue;
|
||||
|
||||
this.DocumentPaths.Add(FileAttachment.FromPath(path));
|
||||
}
|
||||
|
||||
await this.AddFileBatchAsync(paths);
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
this.isDraggingOver = false;
|
||||
@ -208,54 +307,41 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
private async Task AddFilesManually()
|
||||
{
|
||||
if (this.Disabled)
|
||||
if (this.IsUnavailable)
|
||||
return;
|
||||
|
||||
// Ensure that Pandoc is installed and ready:
|
||||
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
|
||||
showSuccessMessage: false,
|
||||
showDialog: true);
|
||||
|
||||
// If Pandoc is not available (user cancelled installation), abort file selection:
|
||||
if (!pandocState.IsAvailable)
|
||||
{
|
||||
this.Logger.LogWarning("The user cancelled the Pandoc installation or Pandoc is not available. Aborting file selection.");
|
||||
return;
|
||||
}
|
||||
|
||||
var selectFiles = await this.RustService.SelectFiles(T("Select files to attach"));
|
||||
if (selectFiles.UserCancelled)
|
||||
return;
|
||||
|
||||
foreach (var selectedFilePath in selectFiles.SelectedFilePaths)
|
||||
{
|
||||
if (!File.Exists(selectedFilePath))
|
||||
continue;
|
||||
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, selectedFilePath, this.ValidateMediaFileTypes, this.Provider))
|
||||
continue;
|
||||
|
||||
this.DocumentPaths.Add(FileAttachment.FromPath(selectedFilePath));
|
||||
}
|
||||
|
||||
await this.AddFileBatchAsync(selectFiles.SelectedFilePaths);
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
|
||||
private async Task OpenAttachmentsDialog()
|
||||
{
|
||||
if (this.Disabled)
|
||||
if (this.IsUnavailable)
|
||||
return;
|
||||
|
||||
var previousAttachments = this.DocumentPaths.ToHashSet();
|
||||
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
|
||||
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
|
||||
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
}
|
||||
|
||||
private async Task ClearAllFiles()
|
||||
{
|
||||
if (this.Disabled)
|
||||
if (this.IsUnavailable)
|
||||
return;
|
||||
|
||||
foreach (var attachment in this.DocumentPaths)
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment);
|
||||
|
||||
this.DocumentPaths.Clear();
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
@ -266,7 +352,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
private void OnMouseEnter(EventArgs _)
|
||||
{
|
||||
if(this.Disabled || this.PauseCatchingDrops)
|
||||
if(this.IsUnavailable || this.PauseCatchingDrops)
|
||||
return;
|
||||
|
||||
this.Logger.LogDebug("Attach documents component '{Name}' is hovered.", this.Name);
|
||||
@ -277,7 +363,7 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
private void OnMouseLeave(EventArgs _)
|
||||
{
|
||||
if(this.Disabled || this.PauseCatchingDrops)
|
||||
if(this.IsUnavailable || this.PauseCatchingDrops)
|
||||
return;
|
||||
|
||||
this.Logger.LogDebug("Attach documents component '{Name}' is no longer hovered.", this.Name);
|
||||
@ -288,15 +374,98 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
|
||||
private async Task RemoveDocument(FileAttachment fileAttachment)
|
||||
{
|
||||
if (this.Disabled)
|
||||
if (this.IsUnavailable)
|
||||
return;
|
||||
|
||||
this.DocumentPaths.Remove(fileAttachment);
|
||||
ManagedTranscriptAttachment.TryDeleteOwnedFile(fileAttachment);
|
||||
this.ReconcileOwnerPendingTranscripts();
|
||||
|
||||
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
|
||||
await this.OnChange(this.DocumentPaths);
|
||||
}
|
||||
|
||||
/// <summary>Keeps persisted chat-draft transcript references aligned with the composer.</summary>
|
||||
private void ReconcileOwnerPendingTranscripts()
|
||||
{
|
||||
if (this.OwnerChat is null)
|
||||
return;
|
||||
|
||||
var retainedPaths = this.DocumentPaths.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal);
|
||||
this.OwnerChat.PendingMediaTranscripts.RemoveAll(attachment => !retainedPaths.Contains(attachment.FilePath));
|
||||
}
|
||||
|
||||
private async Task AddFileBatchAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var existingPaths = paths.Where(File.Exists).ToList();
|
||||
var mediaPaths = existingPaths.Where(IsTranscribableMedia).ToList();
|
||||
var regularPaths = existingPaths.Except(mediaPaths).ToList();
|
||||
|
||||
var canAddRegularFiles = true;
|
||||
if (regularPaths.Count > 0)
|
||||
{
|
||||
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
|
||||
showSuccessMessage: false,
|
||||
showDialog: true);
|
||||
canAddRegularFiles = pandocState.IsAvailable;
|
||||
}
|
||||
|
||||
foreach (var path in regularPaths)
|
||||
{
|
||||
if (!canAddRegularFiles)
|
||||
break;
|
||||
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(
|
||||
FileExtensionValidation.UseCase.ATTACHING_CONTENT,
|
||||
path,
|
||||
this.ValidateMediaFileTypes,
|
||||
this.Provider))
|
||||
continue;
|
||||
this.DocumentPaths.Add(FileAttachment.FromPath(path));
|
||||
}
|
||||
|
||||
if (mediaPaths.Count is 0)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider))
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(
|
||||
Icons.Material.Filled.VoiceChat,
|
||||
this.T("Media files require a configured transcription provider. Configure one in the transcription settings.")));
|
||||
return;
|
||||
}
|
||||
|
||||
var names = string.Join('\n', mediaPaths.Select(path => $"- {Markdown.EscapeInlineText(Path.GetFileName(path))}"));
|
||||
var message = this.T("The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.");
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{
|
||||
x => x.MarkdownBody,
|
||||
$"""
|
||||
{message}
|
||||
|
||||
{names}
|
||||
"""
|
||||
},
|
||||
};
|
||||
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(
|
||||
this.T("Transcribe media files"),
|
||||
dialogParameters,
|
||||
DialogOptions.FULLSCREEN);
|
||||
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return;
|
||||
|
||||
if (this.OwnerChat is null)
|
||||
this.OwnerChat = await this.EnsureOwnerChatAsync(mediaPaths[0]);
|
||||
|
||||
this.MediaTranscriptionService.TryStartAttachmentBatch(mediaPaths, this.EffectiveMediaImportTarget, this.OwnerChat);
|
||||
}
|
||||
|
||||
private static bool IsTranscribableMedia(string path) => FileTypes.IsAllowedPath(path, FileTypes.AUDIO) || FileTypes.IsAllowedPath(path, FileTypes.VIDEO);
|
||||
|
||||
/// <summary>
|
||||
/// The user might want to check what we actually extract from his file and therefore give the LLM as an input.
|
||||
/// </summary>
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
}
|
||||
</ChildContent>
|
||||
<FooterContent>
|
||||
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
|
||||
<MudElement Style="flex: 0 0 auto;">
|
||||
<MudTextField
|
||||
T="string"
|
||||
@ -45,7 +46,7 @@
|
||||
Label="@this.InputLabel"
|
||||
Placeholder="@this.ProviderPlaceholder"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@Icons.Material.Filled.Send"
|
||||
AdornmentIcon="@(this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner) ? Icons.Material.Filled.HourglassTop : Icons.Material.Filled.Send)"
|
||||
OnAdornmentClick="() => this.SendMessage()"
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
@ -100,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"/>
|
||||
<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,8 @@ 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;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
@ -14,6 +16,7 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
{
|
||||
private readonly Guid draftMediaOwnerId = Guid.NewGuid();
|
||||
private const string CHAT_INPUT_ID = "chat-user-input";
|
||||
private const string MARKDOWN_CODE = "code";
|
||||
private const string MARKDOWN_BOLD = "bold";
|
||||
@ -54,6 +57,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
[Inject]
|
||||
private AIJobService AIJobService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
|
||||
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
|
||||
|
||||
@ -81,6 +87,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
private Guid foregroundChatId = Guid.Empty;
|
||||
private int workspaceHeaderSyncVersion;
|
||||
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForChat(this.ChatThread?.ChatId ?? this.draftMediaOwnerId);
|
||||
|
||||
// Unfortunately, we need the input field reference to blur the focus away. Without
|
||||
// this, we cannot clear the input field.
|
||||
private MudTextField<string> inputField = null!;
|
||||
@ -104,6 +112,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
|
||||
// Apply the filters for the message bus:
|
||||
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
|
||||
|
||||
@ -243,9 +253,50 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
// Select the correct provider:
|
||||
await this.SelectProviderWhenLoadingChat();
|
||||
await this.SyncForegroundChatAsync();
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes send and attachment controls when the media import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.CurrentMediaImportOwner)
|
||||
_ = 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.Warnings.Count > 0)
|
||||
{
|
||||
var message = string.Join(Environment.NewLine, outcome.Warnings.Select(warning => $"{warning.FileName}: {warning.UserMessage}"));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, message));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (firstRender && this.ChatThread is not null && this.mustStoreChat)
|
||||
@ -314,6 +365,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
await this.ApplyLoadedChatParameterAsync();
|
||||
await this.SyncForegroundChatAsync();
|
||||
await this.ConsumeMediaOutcomeAsync();
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
@ -681,8 +733,42 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
this.hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
|
||||
private async Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath)
|
||||
{
|
||||
if (this.ChatThread is not null)
|
||||
return this.ChatThread;
|
||||
|
||||
this.RefreshCurrentProfileAndChatTemplate();
|
||||
var promptName = this.ExtractThreadName(this.ComposerState.UserInput);
|
||||
this.ChatThread = new()
|
||||
{
|
||||
IncludeDateTime = true,
|
||||
SelectedProvider = this.Provider.Id,
|
||||
SelectedProfile = this.currentProfile.Id,
|
||||
SelectedChatTemplate = this.currentChatTemplate.Id,
|
||||
SystemPrompt = SystemPrompts.DEFAULT,
|
||||
WorkspaceId = this.currentWorkspaceId,
|
||||
ChatId = Guid.NewGuid(),
|
||||
DataSourceOptions = this.earlyDataSourceOptions,
|
||||
Name = string.IsNullOrWhiteSpace(this.ComposerState.UserInput)
|
||||
? $"Transkription: {Path.GetFileName(firstMediaPath)}"
|
||||
: promptName,
|
||||
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
|
||||
await this.SyncForegroundChatAsync();
|
||||
return this.ChatThread;
|
||||
}
|
||||
|
||||
private async Task SendMessage(bool reuseLastUserPrompt = false)
|
||||
{
|
||||
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
|
||||
return;
|
||||
|
||||
if (!this.IsProviderSelected)
|
||||
return;
|
||||
|
||||
@ -745,6 +831,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
Text = this.ComposerState.UserInput,
|
||||
FileAttachments = normalizedAttachments,
|
||||
};
|
||||
this.ChatThread.PendingMediaTranscripts.Clear();
|
||||
|
||||
//
|
||||
// Add the user message to the thread:
|
||||
@ -986,12 +1073,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
if (workspaceId == Guid.Empty)
|
||||
return;
|
||||
|
||||
// Delete the chat from the current workspace or the temporary storage:
|
||||
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
|
||||
|
||||
this.ChatThread!.WorkspaceId = workspaceId;
|
||||
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId);
|
||||
this.MarkCurrentChatAsLoadedParameter();
|
||||
await this.SaveThread();
|
||||
|
||||
await this.SyncWorkspaceHeaderWithChatThreadAsync();
|
||||
}
|
||||
@ -1209,6 +1292,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
|
||||
{
|
||||
await this.SaveThread();
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
@inherits MSGComponentBase
|
||||
@inject MediaTranscriptionService MediaTranscriptionService
|
||||
@using AIStudio.Tools.Services
|
||||
|
||||
@if (this.Snapshot is { IsBusy: true } snapshot)
|
||||
{
|
||||
@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>
|
||||
<MudTooltip Text="@T("Stop media transcription")">
|
||||
<MudIconButton Size="Size.Small" Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(async () => await this.MediaTranscriptionService.StopAsync(this.Owner))"/>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
}
|
||||
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>
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
using AIStudio.Tools.Media;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class MediaTranscriptionStatus
|
||||
{
|
||||
/// <summary>The surface owner whose operation is rendered.</summary>
|
||||
[Parameter]
|
||||
public MediaImportOwner Owner { get; set; }
|
||||
|
||||
/// <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
|
||||
{
|
||||
get
|
||||
{
|
||||
var snapshot = this.Snapshot;
|
||||
if (snapshot is null)
|
||||
return 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()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Schedules a render after an import state transition.</summary>
|
||||
private void OnStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.Owner)
|
||||
_ = this.InvokeAsync(this.StateHasChanged);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from singleton import state changes.</summary>
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
}
|
||||
@ -5,19 +5,29 @@
|
||||
<div @onmouseenter="@this.OnMouseEnter" @onmouseleave="@this.OnMouseLeave">
|
||||
<MudPaper Outlined="true" Class="@this.dragClass">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.None" Wrap="Wrap.Wrap">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Disabled="@this.Disabled">
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Disabled="@this.IsUnavailable">
|
||||
@this.ButtonText
|
||||
</MudButton>
|
||||
@if (this.IsCurrentTargetBusy)
|
||||
{
|
||||
<MediaTranscriptionStatus Owner="@this.EffectiveImportOwner" TargetId="@this.EffectiveMediaImportTarget.TargetId" Compact="true"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("Drop one file here to load its content.")
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton StartIcon="@Icons.Material.Filled.Description" OnClick="@(async () => await this.SelectFile())" Variant="Variant.Filled" Class="mb-3" Disabled="@this.Disabled">
|
||||
<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,3 +1,5 @@
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
using AIStudio.Tools.Validation;
|
||||
@ -8,6 +10,22 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class ReadFileContent : MSGComponentBase
|
||||
{
|
||||
private readonly MediaImportOwner fallbackMediaImportOwner = new(MediaImportOwnerKind.ASSISTANT, $"read-file-content:{Guid.NewGuid():N}");
|
||||
|
||||
[CascadingParameter]
|
||||
private MediaImportOwner? ImportOwner { get; set; }
|
||||
|
||||
private MediaImportOwner EffectiveImportOwner => this.ImportOwner ?? this.fallbackMediaImportOwner;
|
||||
|
||||
[Parameter]
|
||||
public string MediaImportTargetId { get; set; } = string.Empty;
|
||||
|
||||
private string EffectiveMediaImportTargetId => string.IsNullOrWhiteSpace(this.MediaImportTargetId)
|
||||
? string.IsNullOrWhiteSpace(this.Text) ? "primary" : this.Text
|
||||
: this.MediaImportTargetId;
|
||||
|
||||
private MediaImportTarget EffectiveMediaImportTarget => new(this.EffectiveImportOwner, this.EffectiveMediaImportTargetId);
|
||||
|
||||
[Parameter]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
@ -47,17 +65,24 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
[Inject]
|
||||
private PandocAvailabilityService PandocAvailabilityService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
private const string DEFAULT_DRAG_CLASS = "relative rounded-lg border-2 border-dashed pa-3 mb-3 mud-width-full";
|
||||
|
||||
private string ButtonText => string.IsNullOrWhiteSpace(this.Text) ? T("Use file content as input") : this.Text;
|
||||
private string dragClass = DEFAULT_DRAG_CLASS;
|
||||
private uint numDropAreasAboveThis;
|
||||
private bool isComponentHovered;
|
||||
private bool IsCurrentTargetBusy => this.MediaTranscriptionService.GetSnapshot(this.EffectiveImportOwner) is { IsBusy: true } snapshot
|
||||
&& snapshot.Target == this.EffectiveMediaImportTarget;
|
||||
private bool IsUnavailable => this.Disabled || this.MediaTranscriptionService.IsBusy(this.EffectiveImportOwner);
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
if (this.EnableDragDrop)
|
||||
{
|
||||
this.ApplyFilters([], [ Event.TAURI_EVENT_RECEIVED, Event.REGISTER_FILE_DROP_AREA, Event.UNREGISTER_FILE_DROP_AREA ]);
|
||||
@ -65,6 +90,69 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
}
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
await this.SyncCompletedMediaTextAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner == this.EffectiveImportOwner)
|
||||
_ = 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.Warnings.Count > 0)
|
||||
{
|
||||
var message = string.Join(Environment.NewLine, outcome.Warnings.Select(warning => $"{warning.FileName}: {warning.UserMessage}"));
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, message));
|
||||
}
|
||||
|
||||
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 delivery = this.MediaTranscriptionService.GetPendingDelivery(this.EffectiveMediaImportTarget);
|
||||
if (delivery is null || delivery.Text is not { } text)
|
||||
return;
|
||||
|
||||
await this.FileContentChanged.InvokeAsync(text);
|
||||
this.MediaTranscriptionService.AcknowledgeDelivery(delivery);
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from the singleton media service.</summary>
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
|
||||
@ -72,7 +160,7 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
if (!this.EnableDragDrop)
|
||||
return;
|
||||
|
||||
if (this.Disabled && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
|
||||
if (this.IsUnavailable && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
|
||||
return;
|
||||
|
||||
switch (triggeredEvent)
|
||||
@ -126,10 +214,7 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
|
||||
private async Task SelectFile()
|
||||
{
|
||||
if (this.Disabled)
|
||||
return;
|
||||
|
||||
if (!await this.EnsurePandocAvailability())
|
||||
if (this.IsUnavailable)
|
||||
return;
|
||||
|
||||
var selectedFile = await this.RustService.SelectFile(T("Select file to read its content"));
|
||||
@ -161,9 +246,6 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
|
||||
private async Task LoadFirstValidFile(List<string> paths)
|
||||
{
|
||||
if (!await this.EnsurePandocAvailability())
|
||||
return;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (await this.LoadFileIfValid(path))
|
||||
@ -179,6 +261,12 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
return false;
|
||||
}
|
||||
|
||||
if (FileTypes.IsAllowedPath(filePath, FileTypes.AUDIO) || FileTypes.IsAllowedPath(filePath, FileTypes.VIDEO))
|
||||
return await this.LoadMediaTranscriptAsync(filePath);
|
||||
|
||||
if (!await this.EnsurePandocAvailability())
|
||||
return false;
|
||||
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.DIRECTLY_LOADING_CONTENT, filePath))
|
||||
{
|
||||
this.Logger.LogWarning("User attempted to load unsupported file: {FilePath}", filePath);
|
||||
@ -200,6 +288,42 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> LoadMediaTranscriptAsync(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider))
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(
|
||||
Icons.Material.Filled.VoiceChat,
|
||||
this.T("Media files require a configured transcription provider. Configure one in the transcription settings.")));
|
||||
return false;
|
||||
}
|
||||
|
||||
var message = this.T("The selected media file will be prepared locally. Its audio will then be uploaded to the configured transcription provider.");
|
||||
var dialogParameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{
|
||||
x => x.MarkdownBody,
|
||||
$"""
|
||||
{message}
|
||||
|
||||
- {Markdown.EscapeInlineText(Path.GetFileName(filePath))}
|
||||
"""
|
||||
},
|
||||
};
|
||||
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(
|
||||
this.T("Transcribe media file"),
|
||||
dialogParameters,
|
||||
Dialogs.DialogOptions.FULLSCREEN);
|
||||
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return false;
|
||||
|
||||
return this.MediaTranscriptionService.TryStartTextImport(
|
||||
filePath,
|
||||
this.EffectiveMediaImportTarget);
|
||||
}
|
||||
|
||||
private bool CanCatchDroppedFile() => this.numDropAreasAboveThis is 0 && (this.isComponentHovered || this.CatchAllDocuments);
|
||||
|
||||
private void SetDragClass() => this.dragClass = $"{DEFAULT_DRAG_CLASS} mud-border-primary border-2";
|
||||
@ -208,7 +332,7 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
|
||||
private void OnMouseEnter(EventArgs _)
|
||||
{
|
||||
if(this.Disabled || this.numDropAreasAboveThis > 0)
|
||||
if(this.IsUnavailable || this.numDropAreasAboveThis > 0)
|
||||
return;
|
||||
|
||||
this.Logger.LogDebug("Read file content component is hovered.");
|
||||
@ -219,7 +343,7 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
|
||||
private void OnMouseLeave(EventArgs _)
|
||||
{
|
||||
if(this.Disabled)
|
||||
if(this.IsUnavailable)
|
||||
return;
|
||||
|
||||
this.Logger.LogDebug("Read file content component is no longer hovered.");
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using AIStudio.Provider;
|
||||
using System.Buffers.Binary;
|
||||
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.MIME;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
@ -10,6 +11,8 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class VoiceRecorder : MSGComponentBase
|
||||
{
|
||||
private const int PCM_WAV_HEADER_SIZE = 44;
|
||||
|
||||
[Inject]
|
||||
private ILogger<VoiceRecorder> Logger { get; init; } = null!;
|
||||
|
||||
@ -25,6 +28,9 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
[Inject]
|
||||
private VoiceRecordingAvailabilityService VoiceRecordingAvailabilityService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@ -93,7 +99,6 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
private bool isTranscribing;
|
||||
private FileStream? currentRecordingStream;
|
||||
private string? currentRecordingPath;
|
||||
private string? currentRecordingMimeType;
|
||||
private string? finalRecordingPath;
|
||||
private DotNetObjectReference<VoiceRecorder>? dotNetReference;
|
||||
|
||||
@ -131,17 +136,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
return;
|
||||
}
|
||||
|
||||
var mimeTypes = GetPreferredMimeTypes(
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WEBM).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.MP3).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.AIFF).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.WAV).Build(),
|
||||
Builder.Create().UseAudio().UseSubtype(AudioSubtype.FLAC).Build()
|
||||
);
|
||||
|
||||
this.Logger.LogInformation("Starting audio recording with preferred MIME types: '{PreferredMimeTypes}'.", string.Join<MIMEType>(", ", mimeTypes));
|
||||
this.Logger.LogInformation("Starting PCM/WAV audio recording.");
|
||||
|
||||
// Create a DotNetObjectReference to pass to JavaScript:
|
||||
this.dotNetReference = DotNetObjectReference.Create(this);
|
||||
@ -151,13 +146,8 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var mimeTypeStrings = mimeTypes.ToStringArray();
|
||||
var actualMimeType = await this.JsRuntime.InvokeAsync<string>("audioRecorder.start", this.dotNetReference, mimeTypeStrings);
|
||||
|
||||
// Store the MIME type for later use:
|
||||
this.currentRecordingMimeType = actualMimeType;
|
||||
|
||||
this.Logger.LogInformation("Audio recording started with MIME type: '{ActualMimeType}'.", actualMimeType);
|
||||
await this.JsRuntime.InvokeVoidAsync("audioRecorder.start", this.dotNetReference);
|
||||
this.Logger.LogInformation("PCM/WAV audio recording started.");
|
||||
this.isPreparing = false;
|
||||
this.isRecording = true;
|
||||
}
|
||||
@ -168,6 +158,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
// Clean up the recording stream if starting failed:
|
||||
await this.FinalizeRecordingStream();
|
||||
await this.ReleaseMicrophoneAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -176,11 +167,11 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
}
|
||||
else
|
||||
{
|
||||
var recordingStoppedSuccessfully = false;
|
||||
try
|
||||
{
|
||||
var result = await this.JsRuntime.InvokeAsync<AudioRecordingResult>("audioRecorder.stop");
|
||||
if (result.ChangedMimeType)
|
||||
this.Logger.LogWarning("The recorded audio MIME type was changed to '{ResultMimeType}'.", result.MimeType);
|
||||
await this.JsRuntime.InvokeVoidAsync("audioRecorder.stop");
|
||||
recordingStoppedSuccessfully = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@ -194,30 +185,23 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
this.isRecording = false;
|
||||
this.StateHasChanged();
|
||||
|
||||
// Start transcription if we have a recording and a configured provider:
|
||||
if (this.finalRecordingPath is not null)
|
||||
if (!recordingStoppedSuccessfully || this.finalRecordingPath is null)
|
||||
{
|
||||
if (recordingStoppedSuccessfully)
|
||||
{
|
||||
this.Logger.LogWarning("The audio recorder did not produce any data.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.MicOff, this.T("Failed to stop audio recording.")));
|
||||
}
|
||||
|
||||
this.DeleteFinalRecording();
|
||||
await this.ReleaseMicrophoneAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.TranscribeRecordingAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static MIMEType[] GetPreferredMimeTypes(params MIMEType[] mimeTypes)
|
||||
{
|
||||
// Default list if no parameters provided:
|
||||
if (mimeTypes.Length is 0)
|
||||
{
|
||||
var audioBuilder = Builder.Create().UseAudio();
|
||||
return
|
||||
[
|
||||
audioBuilder.UseSubtype(AudioSubtype.WEBM).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.OGG).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.MP4).Build(),
|
||||
audioBuilder.UseSubtype(AudioSubtype.MPEG).Build(),
|
||||
];
|
||||
}
|
||||
|
||||
return mimeTypes;
|
||||
}
|
||||
|
||||
private async Task InitializeRecordingStream()
|
||||
{
|
||||
this.numReceivedChunks = 0;
|
||||
@ -226,7 +210,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
if (!Directory.Exists(recordingDirectory))
|
||||
Directory.CreateDirectory(recordingDirectory);
|
||||
|
||||
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.audio";
|
||||
var fileName = $"recording_{DateTime.UtcNow:yyyyMMdd_HHmmss}.wav";
|
||||
this.currentRecordingPath = Path.Combine(recordingDirectory, fileName);
|
||||
this.currentRecordingStream = new FileStream(this.currentRecordingPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 8192, useAsync: true);
|
||||
|
||||
@ -253,6 +237,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Error writing audio chunk to stream.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@ -262,45 +247,56 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
if (this.currentRecordingStream is not null)
|
||||
{
|
||||
await this.currentRecordingStream.FlushAsync();
|
||||
var hasPcmAudioData = await this.FinalizePcmWavHeaderAsync(this.currentRecordingStream);
|
||||
await this.currentRecordingStream.DisposeAsync();
|
||||
this.currentRecordingStream = null;
|
||||
|
||||
// Rename the file with the correct extension based on MIME type:
|
||||
if (this.currentRecordingPath is not null && this.currentRecordingMimeType is not null)
|
||||
if (this.currentRecordingPath is not null && File.Exists(this.currentRecordingPath))
|
||||
{
|
||||
var extension = GetFileExtension(this.currentRecordingMimeType);
|
||||
var newPath = Path.ChangeExtension(this.currentRecordingPath, extension);
|
||||
var fileSize = new FileInfo(this.currentRecordingPath).Length;
|
||||
|
||||
if (File.Exists(this.currentRecordingPath))
|
||||
if (hasPcmAudioData)
|
||||
{
|
||||
File.Move(this.currentRecordingPath, newPath, overwrite: true);
|
||||
this.finalRecordingPath = newPath;
|
||||
this.Logger.LogInformation("Finalized audio recording over {NumChunks} streamed audio chunks to the file '{RecordingPath}'.", this.numReceivedChunks, newPath);
|
||||
this.finalRecordingPath = this.currentRecordingPath;
|
||||
this.Logger.LogInformation("Finalized audio recording over {NumChunks} streamed audio chunks to the file '{RecordingPath}' with {FileSize} bytes.", this.numReceivedChunks, this.currentRecordingPath, fileSize);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Logger.LogWarning("Discarding a PCM/WAV audio recording without audio data ({FileSize} bytes).", fileSize);
|
||||
File.Delete(this.currentRecordingPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentRecordingPath = null;
|
||||
this.currentRecordingMimeType = null;
|
||||
|
||||
// Dispose the .NET reference:
|
||||
this.dotNetReference?.Dispose();
|
||||
this.dotNetReference = null;
|
||||
}
|
||||
|
||||
private static string GetFileExtension(string mimeType)
|
||||
private async Task<bool> FinalizePcmWavHeaderAsync(FileStream recordingStream)
|
||||
{
|
||||
var baseMimeType = mimeType.Split(';')[0].Trim().ToLowerInvariant();
|
||||
return baseMimeType switch
|
||||
{
|
||||
"audio/webm" => ".webm",
|
||||
"audio/ogg" => ".ogg",
|
||||
"audio/mp4" => ".m4a",
|
||||
"audio/mpeg" => ".mp3",
|
||||
"audio/wav" => ".wav",
|
||||
"audio/x-wav" => ".wav",
|
||||
_ => ".audio" // Fallback
|
||||
};
|
||||
if (recordingStream.Length <= PCM_WAV_HEADER_SIZE)
|
||||
return false;
|
||||
|
||||
var pcmDataSize = recordingStream.Length - PCM_WAV_HEADER_SIZE;
|
||||
if (pcmDataSize > uint.MaxValue - 36)
|
||||
throw new InvalidDataException("The streamed PCM recording exceeds the WAV size limit.");
|
||||
|
||||
var valueBuffer = new byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(valueBuffer, checked((uint)(36 + pcmDataSize)));
|
||||
recordingStream.Seek(4, SeekOrigin.Begin);
|
||||
await recordingStream.WriteAsync(valueBuffer);
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(valueBuffer, checked((uint)pcmDataSize));
|
||||
recordingStream.Seek(40, SeekOrigin.Begin);
|
||||
await recordingStream.WriteAsync(valueBuffer);
|
||||
recordingStream.Seek(0, SeekOrigin.End);
|
||||
await recordingStream.FlushAsync();
|
||||
|
||||
this.Logger.LogInformation("Finalized a streamed PCM/WAV header for {PcmDataSize} bytes of audio data.", pcmDataSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task TranscribeRecordingAsync()
|
||||
@ -317,58 +313,22 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
// Get the configured transcription provider ID:
|
||||
var transcriptionProviderId = this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider;
|
||||
if (string.IsNullOrWhiteSpace(transcriptionProviderId))
|
||||
var transcriptionResult = await this.MediaTranscriptionService.TranscribeVoiceAsync(this.finalRecordingPath);
|
||||
if (transcriptionResult.Status is not MediaTranscriptionResultStatus.SUCCEEDED)
|
||||
{
|
||||
this.Logger.LogWarning("No transcription provider is configured.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("No transcription provider is configured.")));
|
||||
if (transcriptionResult.Status is MediaTranscriptionResultStatus.CANCELLED)
|
||||
return;
|
||||
|
||||
if (transcriptionResult.Status is MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL)
|
||||
{
|
||||
await this.MessageBus.SendWarning(new(Icons.Material.Filled.VoiceChat, transcriptionResult.UserMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the transcription provider in the list of configured providers:
|
||||
var transcriptionProviderSettings = this.SettingsManager.ConfigurationData.TranscriptionProviders
|
||||
.FirstOrDefault(x => x.Id == transcriptionProviderId);
|
||||
|
||||
if (transcriptionProviderSettings is null)
|
||||
{
|
||||
this.Logger.LogWarning("The configured transcription provider with ID '{ProviderId}' was not found.", transcriptionProviderId);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The configured transcription provider was not found.")));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the confidence level:
|
||||
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(Tools.Components.NONE);
|
||||
var providerConfidence = transcriptionProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager);
|
||||
if (providerConfidence.Level < minimumLevel)
|
||||
{
|
||||
this.Logger.LogWarning(
|
||||
"The configured transcription provider '{ProviderName}' has a confidence level of '{ProviderLevel}', which is below the minimum required level of '{MinimumLevel}'.",
|
||||
transcriptionProviderSettings.UsedLLMProvider,
|
||||
providerConfidence.Level,
|
||||
minimumLevel);
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The configured transcription provider does not meet the minimum confidence level.")));
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the provider instance:
|
||||
var provider = transcriptionProviderSettings.CreateProvider();
|
||||
if (provider.Provider is LLMProviders.NONE)
|
||||
{
|
||||
this.Logger.LogError("Failed to create the transcription provider instance.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("Failed to create the transcription provider.")));
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the transcription API:
|
||||
this.Logger.LogInformation("Starting transcription with provider '{ProviderName}' and model '{ModelName}'.", transcriptionProviderSettings.UsedLLMProvider, transcriptionProviderSettings.Model.ToString());
|
||||
var transcriptionResult = await provider.TranscribeAudioAsync(transcriptionProviderSettings.Model, this.finalRecordingPath, this.SettingsManager);
|
||||
if (!transcriptionResult.Success)
|
||||
{
|
||||
this.Logger.LogWarning("The transcription request failed.");
|
||||
var userMessage = string.IsNullOrWhiteSpace(transcriptionResult.ErrorMessage)
|
||||
var userMessage = string.IsNullOrWhiteSpace(transcriptionResult.UserMessage)
|
||||
? this.T("Unfortunately, there was an error communicating with the AI system.")
|
||||
: transcriptionResult.ErrorMessage;
|
||||
: transcriptionResult.UserMessage;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, userMessage));
|
||||
return;
|
||||
}
|
||||
@ -406,19 +366,6 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
// Copy the transcribed text to the clipboard:
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, transcribedText);
|
||||
|
||||
// Delete the recording file:
|
||||
try
|
||||
{
|
||||
if (File.Exists(this.finalRecordingPath))
|
||||
{
|
||||
File.Delete(this.finalRecordingPath);
|
||||
this.Logger.LogInformation("Deleted the recording file '{RecordingPath}'.", this.finalRecordingPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", this.finalRecordingPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -428,13 +375,31 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
finally
|
||||
{
|
||||
await this.ReleaseMicrophoneAsync();
|
||||
|
||||
this.finalRecordingPath = null;
|
||||
this.DeleteFinalRecording();
|
||||
this.isTranscribing = false;
|
||||
this.StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFinalRecording()
|
||||
{
|
||||
var recordingPath = this.finalRecordingPath;
|
||||
this.finalRecordingPath = null;
|
||||
|
||||
if (recordingPath is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(recordingPath))
|
||||
File.Delete(recordingPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Failed to delete the recording file '{RecordingPath}'.", recordingPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReleaseMicrophoneAsync()
|
||||
{
|
||||
// Wait a moment for any queued sounds to finish playing, then release the microphone.
|
||||
|
||||
@ -4,6 +4,8 @@ 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;
|
||||
|
||||
@ -22,6 +24,9 @@ public partial class Workspaces : MSGComponentBase
|
||||
[Inject]
|
||||
private AIJobService AIJobService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
[Parameter]
|
||||
public ChatThread? CurrentChatThread { get; set; }
|
||||
|
||||
@ -55,6 +60,7 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
await base.OnInitializedAsync();
|
||||
this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]);
|
||||
_ = this.LoadTreeItemsAsync(startPrefetch: true);
|
||||
@ -376,12 +382,26 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
private bool IsChatTreeItemBusy(TreeItemData treeItem)
|
||||
{
|
||||
return treeItem.Type is TreeItemType.CHAT && this.AIJobService.IsChatGenerationActive(treeItem.ChatId);
|
||||
return treeItem.Type is TreeItemType.CHAT
|
||||
&& (this.AIJobService.IsChatGenerationActive(treeItem.ChatId)
|
||||
|| this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(treeItem.ChatId)));
|
||||
}
|
||||
|
||||
private string GetChatTreeItemTextStyle(TreeItemData treeItem)
|
||||
{
|
||||
return this.IsCurrentChatTreeItem(treeItem) ? "justify-self: start; font-weight: 700;" : "justify-self: start;";
|
||||
var status = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(treeItem.ChatId))?.Status;
|
||||
var color = status switch
|
||||
{
|
||||
MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING => " color: var(--mud-palette-info);",
|
||||
MediaImportStatus.SUCCEEDED => " color: var(--mud-palette-success);",
|
||||
MediaImportStatus.WARNING => " color: var(--mud-palette-warning);",
|
||||
MediaImportStatus.FAILED => " color: var(--mud-palette-error);",
|
||||
MediaImportStatus.CANCELLED => " color: var(--mud-palette-warning);",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
var weight = this.IsCurrentChatTreeItem(treeItem) ? " font-weight: 700;" : string.Empty;
|
||||
return $"justify-self: start;{weight}{color}";
|
||||
}
|
||||
|
||||
private bool IsCurrentChatTreeItem(TreeItemData treeItem)
|
||||
@ -394,6 +414,22 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
private string GetChatTreeIcon(Guid chatId, string defaultIcon)
|
||||
{
|
||||
var mediaStatus = this.MediaTranscriptionService.GetSnapshot(MediaImportOwner.ForChat(chatId))?.Status;
|
||||
if (mediaStatus is not null)
|
||||
{
|
||||
return mediaStatus switch
|
||||
{
|
||||
MediaImportStatus.QUEUED => Icons.Material.Filled.HourglassTop,
|
||||
MediaImportStatus.RUNNING or MediaImportStatus.CANCELING => Icons.Material.Filled.ChangeCircle,
|
||||
MediaImportStatus.SUCCEEDED => Icons.Material.Filled.TaskAlt,
|
||||
MediaImportStatus.WARNING => Icons.Material.Filled.WarningAmber,
|
||||
MediaImportStatus.FAILED => Icons.Material.Filled.Error,
|
||||
MediaImportStatus.CANCELLED => Icons.Material.Filled.Cancel,
|
||||
|
||||
_ => defaultIcon,
|
||||
};
|
||||
}
|
||||
|
||||
var snapshot = this.AIJobService.TryGetChatSnapshot(chatId);
|
||||
if (snapshot is null || !snapshot.IsActive)
|
||||
return defaultIcon;
|
||||
@ -406,6 +442,12 @@ public partial class Workspaces : MSGComponentBase
|
||||
};
|
||||
}
|
||||
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
if (owner.Kind is MediaImportOwnerKind.CHAT)
|
||||
_ = this.SafeStateHasChanged();
|
||||
}
|
||||
|
||||
private async Task SafeStateHasChanged()
|
||||
{
|
||||
if (this.isDisposed)
|
||||
@ -668,7 +710,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)
|
||||
@ -692,6 +735,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)
|
||||
@ -845,16 +889,13 @@ public partial class Workspaces : MSGComponentBase
|
||||
if (workspaceId == Guid.Empty)
|
||||
return;
|
||||
|
||||
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, chat.WorkspaceId, chat.ChatId, askForConfirmation: false);
|
||||
|
||||
chat.WorkspaceId = workspaceId;
|
||||
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
|
||||
if (this.CurrentChatThread?.ChatId == chat.ChatId)
|
||||
{
|
||||
this.CurrentChatThread = chat;
|
||||
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
|
||||
}
|
||||
|
||||
await WorkspaceBehaviour.StoreChatAsync(chat);
|
||||
await this.LoadTreeItemsAsync(startPrefetch: false);
|
||||
}
|
||||
|
||||
@ -914,6 +955,7 @@ public partial class Workspaces : MSGComponentBase
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.isDisposed = true;
|
||||
this.prefetchCancellationTokenSource?.Cancel();
|
||||
this.prefetchCancellationTokenSource?.Dispose();
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
@inherits MSGComponentBase
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if (!string.IsNullOrWhiteSpace(this.MarkdownBody))
|
||||
{
|
||||
<MudJustifiedMarkdown Value="@this.MarkdownBody" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudJustifiedText Typo="Typo.body1">
|
||||
@this.Message
|
||||
</MudJustifiedText>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
|
||||
|
||||
@ -15,6 +15,12 @@ public partial class ConfirmDialog : MSGComponentBase
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional Markdown content rendered instead of using the message property.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string MarkdownBody { get; set; } = string.Empty;
|
||||
|
||||
private void Cancel() => this.MudDialog.Cancel();
|
||||
|
||||
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(true));
|
||||
|
||||
@ -3,6 +3,7 @@ using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.AIJobs;
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
@ -38,6 +39,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
[Inject]
|
||||
private AssistantSessionService AssistantSessionService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; init; } = null!;
|
||||
|
||||
@ -75,6 +79,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
this.NavigationManager.RegisterLocationChangingHandler(this.OnLocationChanging);
|
||||
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
|
||||
|
||||
//
|
||||
// We use the Tauri API (Rust) to get the data and config directories
|
||||
@ -349,6 +354,16 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
this.navItems = new List<NavBarItem>(this.GetNavItems());
|
||||
}
|
||||
|
||||
/// <summary>Refreshes navigation activity colors when a media import changes state.</summary>
|
||||
private void OnMediaImportStateChanged(MediaImportOwner owner)
|
||||
{
|
||||
_ = this.InvokeAsync(() =>
|
||||
{
|
||||
this.LoadNavItems();
|
||||
this.StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerable<NavBarItem> GetNavItems()
|
||||
{
|
||||
var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager);
|
||||
@ -356,10 +371,15 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
var activityIndicatorDarkColor = this.ColorTheme.GetActivityIndicatorDarkColor();
|
||||
var defaultLightColor = palette.DarkLighten;
|
||||
var defaultDarkColor = palette.GrayLight;
|
||||
var chatLightColor = this.AIJobService.HasActiveJobs ? activityIndicatorLightColor : defaultLightColor;
|
||||
var chatDarkColor = this.AIJobService.HasActiveJobs ? activityIndicatorDarkColor : defaultDarkColor;
|
||||
var assistantsLightColor = this.AssistantSessionService.HasActiveSessions ? activityIndicatorLightColor : defaultLightColor;
|
||||
var assistantsDarkColor = this.AssistantSessionService.HasActiveSessions ? activityIndicatorDarkColor : defaultDarkColor;
|
||||
var mediaSnapshots = this.MediaTranscriptionService.GetSnapshots();
|
||||
var hasActiveChatMedia = mediaSnapshots.Any(snapshot => snapshot.IsBusy && snapshot.Owner.Kind is MediaImportOwnerKind.CHAT);
|
||||
var hasActiveAssistantMedia = mediaSnapshots.Any(snapshot => snapshot.IsBusy && snapshot.Owner.Kind is MediaImportOwnerKind.ASSISTANT);
|
||||
var hasActiveChatWork = this.AIJobService.HasActiveJobs || hasActiveChatMedia;
|
||||
var hasActiveAssistantWork = this.AssistantSessionService.HasActiveSessions || hasActiveAssistantMedia;
|
||||
var chatLightColor = hasActiveChatWork ? activityIndicatorLightColor : defaultLightColor;
|
||||
var chatDarkColor = hasActiveChatWork ? activityIndicatorDarkColor : defaultDarkColor;
|
||||
var assistantsLightColor = hasActiveAssistantWork ? activityIndicatorLightColor : defaultLightColor;
|
||||
var assistantsDarkColor = hasActiveAssistantWork ? activityIndicatorDarkColor : defaultDarkColor;
|
||||
|
||||
yield return new(T("Home"), Icons.Material.Filled.Home, defaultLightColor, defaultDarkColor, Routes.HOME, true);
|
||||
yield return new(T("Chat"), Icons.Material.Filled.Chat, chatLightColor, chatDarkColor, Routes.CHAT, false);
|
||||
@ -535,6 +555,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.MessageBus.Unregister(this);
|
||||
this.mandatoryInfoDialogSemaphore.Dispose();
|
||||
}
|
||||
|
||||
@ -308,7 +308,11 @@
|
||||
<ThirdPartyComponent Name="Rust Crypto" Developer="Artyom Pavlov, Tony Arcieri, Brian Warner, Arthur Gautier, Vlad Filippov, Friedel Ziegelmayer, Nicolas Stalder & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/RustCrypto/traits/blob/master/cipher/LICENSE-MIT" RepositoryUrl="https://github.com/RustCrypto" UseCase="@T("When transferring sensitive data between Rust runtime and .NET app, we encrypt the data. We use some libraries from the Rust Crypto project for this purpose: cipher, aes, cbc, pbkdf2, hmac, and sha2. We are thankful for the great work of the Rust Crypto project.")"/>
|
||||
<ThirdPartyComponent Name="rcgen" Developer="RustTLS developers, est31 & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/rustls/rcgen/blob/main/LICENSE" RepositoryUrl="https://github.com/rustls/rcgen" UseCase="@T("For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.")"/>
|
||||
<ThirdPartyComponent Name="windows-registry" Developer="Microsoft, Kenny Kerr, Ryan Levick, Rafael Rivera, sivadeilra, Marijn Suijten & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/microsoft/windows-rs/blob/master/license-mit" RepositoryUrl="https://github.com/microsoft/windows-rs" UseCase="@T("This library is used to access the Windows registry. We use this for Windows enterprise environments to read the desired configuration.")"/>
|
||||
<ThirdPartyComponent Name="file-format" Developer="Mickaël Malécot & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mmalecot/file-format/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/mmalecot/file-format" UseCase="@T("This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file.")"/>
|
||||
<ThirdPartyComponent Name="file-format" Developer="Mickaël Malécot & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/mmalecot/file-format/blob/main/LICENSE-MIT" RepositoryUrl="https://github.com/mmalecot/file-format" UseCase="@T("This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.")"/>
|
||||
<ThirdPartyComponent Name="Symphonia" Developer="Philip Deljanov & Open Source Community" LicenseName="MPL-2.0" LicenseUrl="https://github.com/pdeljanov/Symphonia/blob/v0.6.0/LICENSE" RepositoryUrl="https://github.com/pdeljanov/Symphonia" UseCase="@T("Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.")"/>
|
||||
<ThirdPartyComponent Name="Ropus" Developer="0x4D44, Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon & Open Source Community" LicenseName="BSD-3-Clause" LicenseUrl="https://github.com/0x4D44/ropus/blob/main/LICENSE" RepositoryUrl="https://github.com/0x4d44/ropus" UseCase="@T("Ropus provides the Opus encoder and decoder used by the media pipeline.")"/>
|
||||
<ThirdPartyComponent Name="Rubato" Developer="Henrik Enquist & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/HEnquist/rubato/blob/master/LICENSE-MIT" RepositoryUrl="https://github.com/HEnquist/rubato" UseCase="@T("We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.")"/>
|
||||
<ThirdPartyComponent Name="webm-iterable" Developer="Austin Blake & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/austinleroy/webm-iterable/blob/main/LICENSE" RepositoryUrl="https://github.com/austinleroy/webm-iterable" UseCase="@T("webm-iterable provides the EBML and WebM writing path for normalized audio.")"/>
|
||||
<ThirdPartyComponent Name="calamine" Developer="Johann Tuffe, Joel Natividad, Eric Jolibois, Dmitriy & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/tafia/calamine/blob/master/LICENSE-MIT.md" RepositoryUrl="https://github.com/tafia/calamine" UseCase="@T("This library is used to read Excel and OpenDocument spreadsheet files. This is necessary, e.g., for using spreadsheets as a data source for a chat.")"/>
|
||||
<ThirdPartyComponent Name="PDFium" Developer="Lei Zhang, Tom Sepez, Dan Sinclair, and Foxit, Google, Chromium, Collabora, Ada, DocsCorp, Dropbox, Microsoft, and PSPDFKit Teams & Open Source Community" LicenseName="Apache-2.0" LicenseUrl="https://pdfium.googlesource.com/pdfium/+/refs/heads/main/LICENSE" RepositoryUrl="https://pdfium.googlesource.com/pdfium" UseCase="@T("This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.")"/>
|
||||
<ThirdPartyComponent Name="pdfium-render" Developer="Alastair Carey, Dorian Rudolph & Open Source Community" LicenseName="MIT" LicenseUrl="https://github.com/ajrcarey/pdfium-render/blob/master/LICENSE.md" RepositoryUrl="https://github.com/ajrcarey/pdfium-render" UseCase="@T("This library is used to read PDF files. This is necessary, e.g., for using PDFs as a data source for a chat.")"/>
|
||||
|
||||
@ -306,6 +306,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::AGENDA::NUMBERPARTICIPANTSEXTENSIONS::T81
|
||||
-- Stop generation
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1317408357"] = "Generierung stoppen"
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1543974632"] = "Die Mediendatei konnte nicht transkribiert werden."
|
||||
|
||||
-- Reset
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T180921696"] = "Zurücksetzen"
|
||||
|
||||
@ -315,6 +318,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1809312323"] = "Bitte wä
|
||||
-- The assistant failed. The message is: '{0}'
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T1833836281"] = "Der Assistent ist fehlgeschlagen. Die Meldung lautet: „{0}“"
|
||||
|
||||
-- The media transcription was canceled.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T241403726"] = "Die Transkription des Mediums wurde abgebrochen."
|
||||
|
||||
-- This assistant is already running. AI Studio opens the running session instead.
|
||||
UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::ASSISTANTBASE::T2575715765"] = "Dieser Assistent läuft bereits. AI Studio öffnet stattdessen die laufende Sitzung."
|
||||
|
||||
@ -2295,9 +2301,24 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "Das Bil
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Einstellungen öffnen"
|
||||
|
||||
-- Media transcription was canceled. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1233815302"] = "Die Medientranskription wurde abgebrochen. Öffnen Sie den Assistenten, um sie zu überprüfen."
|
||||
|
||||
-- Media transcription failed. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2177964639"] = "Die Transkription der Medieninhalte ist fehlgeschlagen. Öffnen Sie den Assistenten, um sie zu überprüfen."
|
||||
|
||||
-- Media transcription completed with a warning. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2217674098"] = "Die Medientranskription wurde mit einer Warnung abgeschlossen. Öffnen Sie den Assistenten, um sie zu überprüfen."
|
||||
|
||||
-- Media is still being prepared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2600900617"] = "Die Medien werden noch vorbereitet."
|
||||
|
||||
-- Assistant is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistent läuft noch."
|
||||
|
||||
-- The media transcript is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3248321953"] = "Das Medientranskript ist fertig."
|
||||
|
||||
-- Assistant was canceled. Open it to review the result.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistent wurde abgebrochen. Öffnen Sie ihn, um das Ergebnis zu überprüfen."
|
||||
|
||||
@ -2400,18 +2421,33 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1358313858"] = "Klicken
|
||||
-- Drop files here to attach them.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T143112277"] = "Dateien hier ablegen, um sie anzuhängen."
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1543974632"] = "Die Mediendatei konnte nicht transkribiert werden."
|
||||
|
||||
-- Click here to attach files.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T1875575968"] = "Klicken Sie hier, um Dateien anzuhängen."
|
||||
|
||||
-- Transcribe media files
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2178031033"] = "Mediendateien transkribieren"
|
||||
|
||||
-- Drag and drop files into the marked area or click here to attach documents:
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T230755331"] = "Ziehen Sie Dateien in den markierten Bereich oder klicken Sie hier, um Dokumente anzuhängen:"
|
||||
|
||||
-- The media transcription was canceled.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T241403726"] = "Die Transkription des Mediums wurde abgebrochen."
|
||||
|
||||
-- Select files to attach
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2495931372"] = "Dateien zum Anhängen auswählen"
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "Dokumentenvorschau"
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3172443094"] = "Für Mediendateien muss ein Transkriptionsanbieter eingerichtet sein. Richten Sie in den Einstellungen der Transkriptionen einen Anbieter ein."
|
||||
|
||||
-- The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T322693339"] = "Die ausgewählten Audio- und Videodateien werden lokal vorbereitet. Anschließend werden die Audiodaten an den konfigurierten Transkriptionsanbieter hochgeladen."
|
||||
|
||||
-- Clear file list
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "Dateiliste löschen"
|
||||
|
||||
@ -2436,6 +2472,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1317408357"] = "Generieru
|
||||
-- Save chat
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1516264254"] = "Chat speichern"
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1543974632"] = "Die Mediendatei konnte nicht transkribiert werden."
|
||||
|
||||
-- Type your input here...
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1849313532"] = "Geben Sie hier Ihre Eingabe ein..."
|
||||
|
||||
@ -2448,6 +2487,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code"
|
||||
-- Italic
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Kursiv"
|
||||
|
||||
-- The media transcription was canceled.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "Die Transkription der Mediendatei wurde abgebrochen."
|
||||
|
||||
-- Profile usage is disabled according to your chat template settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Die Profilnutzung ist gemäß den Einstellungen ihrer Chat-Vorlage deaktiviert."
|
||||
|
||||
@ -2673,6 +2715,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T3511160492"] = "Ak
|
||||
-- Please review this text again. The content was changed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T941885055"] = "Bitte lesen Sie diesen Text erneut durch. Der Inhalt wurde geändert."
|
||||
|
||||
-- Waiting to prepare media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1167267986"] = "Warten, bis die Medien vorbereitet sind"
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Medientranskription stoppen"
|
||||
|
||||
-- Stopping media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1878101489"] = "Transkription von Medien wird beendet"
|
||||
|
||||
-- Inspecting media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Medien werden geprüft"
|
||||
|
||||
-- Transcribing
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2938661425"] = "Transkribieren"
|
||||
|
||||
-- Preparing audio
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T3200155905"] = "Audio wird vorbereitet"
|
||||
|
||||
-- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Da mein Arbeitgeber sowohl Windows als auch Linux am Arbeitsplatz nutzt, wollte ich eine plattformübergreifende Lösung, die nahtlos auf allen wichtigen Betriebssystemen, einschließlich macOS, funktioniert. Außerdem wollte ich zeigen, dass es möglich ist, moderne, effiziente und plattformübergreifende Anwendungen zu erstellen, ohne auf Software-Ballast, wie z.B. das Electron-Framework, zurückzugreifen. Die Kombination aus .NET und Rust mit Tauri hat sich dabei als hervorragender Technologie-Stack für den Bau solch robuster Anwendungen erwiesen."
|
||||
|
||||
@ -2790,18 +2850,33 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T4279448758"] = "Nutzt
|
||||
-- Provider
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::PROVIDERSELECTION::T900237532"] = "Anbieter"
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1543974632"] = "Die Mediendatei konnte nicht transkribiert werden."
|
||||
|
||||
-- Failed to load file content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Laden des Dateiinhalts fehlgeschlagen"
|
||||
|
||||
-- Drop one file here to load its content.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Datei hier ablegen, um ihren Inhalt zu laden."
|
||||
|
||||
-- The media transcription was canceled.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T241403726"] = "Die Transkription des Mediums wurde abgebrochen."
|
||||
|
||||
-- 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"] = "Die ausgewählte Mediendatei wird lokal vorbereitet. Anschließend wird die Audiospur an den konfigurierten Transkriptionsanbieter hochgeladen."
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3172443094"] = "Für Mediendateien muss ein Transkriptionsanbieter eingerichtet sein. Richten Sie in den Transkriptionseinstellungen einen Anbieter ein."
|
||||
|
||||
-- Use file content as input
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Dokumenteninhalt als Eingabe verwenden"
|
||||
|
||||
-- Select file to read its content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "Datei auswählen, um den Inhalt zu lesen"
|
||||
|
||||
-- Transcribe media file
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3554222960"] = "Mediendatei transkribieren"
|
||||
|
||||
-- The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READWEBCONTENT::T1164201762"] = "Der Inhalt wird mithilfe eines LLM-Agents bereinigt: Der Hauptinhalt wird extrahiert, Werbung und andere irrelevante Elemente werden nach Möglichkeit entfernt. Relative Links werden nach Möglichkeit in absolute Links umgewandelt, damit sie verwendet werden können."
|
||||
|
||||
@ -3540,9 +3615,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Nützliche Assist
|
||||
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Die Sprachaufnahme wurde für diese Sitzung deaktiviert, da die Audiowiedergabe auf dem Client nicht initialisiert werden konnte."
|
||||
|
||||
-- Failed to create the transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Der Anbieter für die Transkription konnte nicht erstellt werden."
|
||||
|
||||
-- Failed to start audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Audioaufnahme konnte nicht gestartet werden."
|
||||
|
||||
@ -3561,21 +3633,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transkrip
|
||||
-- Unfortunately, there was an error communicating with the AI system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Leider ist bei der Kommunikation mit dem KI-System ein Fehler aufgetreten."
|
||||
|
||||
-- The configured transcription provider was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "Der konfigurierte Anbieter für die Transkription wurde nicht gefunden."
|
||||
|
||||
-- Failed to stop audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "Audioaufnahme konnte nicht beendet werden."
|
||||
|
||||
-- The configured transcription provider does not meet the minimum confidence level.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3834149033"] = "Der konfigurierte Anbieter für die Transkription erfüllt nicht das erforderliche Mindestmaß an Vertrauenswürdigkeit."
|
||||
|
||||
-- An error occurred during transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "Während der Transkription ist ein Fehler aufgetreten."
|
||||
|
||||
-- No transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T663630295"] = "Es ist kein Anbieter für die Transkription konfiguriert."
|
||||
|
||||
-- The transcription result is empty.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "Das Ergebnis der Transkription ist leer."
|
||||
|
||||
@ -6750,9 +6813,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Kopiert die Serv
|
||||
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "Diese Bibliothek wird verwendet, um temporäre Ordner bei Laufzeittests zu erstellen und Dateisystemoperationen zu unterstützen."
|
||||
|
||||
-- This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2173617769"] = "Diese Bibliothek wird verwendet, um den Dateityp einer Datei zu bestimmen. Das ist zum Beispiel notwendig, wenn wir eine Datei streamen möchten."
|
||||
|
||||
-- For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2174764529"] = "Für die sichere Kommunikation zwischen der Benutzeroberfläche und der Laufzeit müssen wir Zertifikate erstellen. Diese Rust-Bibliothek eignet sich hervorragend dafür."
|
||||
|
||||
@ -6771,6 +6831,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Konfigurations-P
|
||||
-- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "Die Programmiersprache C# wird für die Umsetzung der Benutzeroberfläche und des Backends verwendet. Für die Entwicklung der Benutzeroberfläche mit C# kommt die Blazor-Technologie aus ASP.NET Core zum Einsatz. Alle diese Technologien sind im .NET SDK integriert."
|
||||
|
||||
-- We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2345444286"] = "Wir verwenden Rubato, um das dekodierte Audiosignal vor der Opus-Kodierung auf 48 kHz neu abzutasten."
|
||||
|
||||
-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux-AppImages bündeln GStreamer-Komponenten, um den Mikrofonzugriff und WebM-Audioaufnahmen in der eingebetteten WebKitGTK-Webansicht zu unterstützen."
|
||||
|
||||
@ -6852,6 +6915,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2929232062"] = "Kopiert die Quel
|
||||
-- Copies the root certificate fingerprint to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "Kopiert den Fingerabdruck des Stammzertifikats in die Zwischenablage"
|
||||
|
||||
-- This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "Diese Bibliothek identifiziert Dateien anhand ihres Inhalts. Sie wird für das Streaming von Dokumenten sowie als erste Sicherheits- und Medienklassifizierungsstufe vor der lokalen Audioverarbeitung verwendet."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Änderungsprotokoll"
|
||||
|
||||
@ -6897,6 +6963,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "Externe HTTPS-St
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "Vom Betriebssystem bereitgestellte Sprache"
|
||||
|
||||
-- webm-iterable provides the EBML and WebM writing path for normalized audio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3385332793"] = "webm-iterable stellt den EBML- und WebM-Schreibpfad für normalisiertes Audio bereit."
|
||||
|
||||
-- Status:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "Status:"
|
||||
|
||||
@ -6957,6 +7026,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T396609403"] = "nicht zutreffend"
|
||||
-- Copies the allowed host configuration to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Kopiert die zulässige Host-Konfiguration in die Zwischenablage"
|
||||
|
||||
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia wird zum Demultiplexen von Mediencontainern und zur Audiodekodierung verwendet. Der genaue, unter der MPL lizenzierte Quellcode ist im verlinkten Repository verfügbar und in den mit AI Studio gebündelten Offline-Hinweisen angegeben."
|
||||
|
||||
-- Installed Pandoc version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installierte Pandoc-Version"
|
||||
|
||||
@ -6975,6 +7047,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "Diese Bibliothek
|
||||
-- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "Diese Bibliothek wird verwendet, um asynchrone Datenströme in Rust zu erstellen. Sie ermöglicht es uns, mit Datenströmen zu arbeiten, die asynchron bereitgestellt werden, wodurch sich Ereignisse oder Daten, die nach und nach eintreffen, leichter verarbeiten lassen. Wir nutzen dies zum Beispiel, um beliebige Daten aus dem Dateisystem an das Einbettungssystem zu übertragen."
|
||||
|
||||
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus stellt den Opus-Encoder und -Decoder bereit, die von der Medienpipeline verwendet werden."
|
||||
|
||||
-- Community & Code
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
|
||||
|
||||
@ -8487,6 +8562,42 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument"
|
||||
|
||||
-- The configured transcription provider could not be created.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1235984176"] = "Der konfigurierte Transkriptionsanbieter konnte nicht erstellt werden."
|
||||
|
||||
-- The selected media file no longer exists.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T129859547"] = "Die ausgewählte Mediendatei ist nicht mehr vorhanden."
|
||||
|
||||
-- The selected media file does not contain an audio track.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T134825479"] = "Die ausgewählte Mediendatei enthält keine Audiospur."
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1543974632"] = "Die Mediendatei konnte nicht transkribiert werden."
|
||||
|
||||
-- The selected file cannot be processed as media.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1707342767"] = "Die ausgewählte Datei kann nicht als Medium verarbeitet werden."
|
||||
|
||||
-- The audio track contains no audible signal, so there is nothing to transcribe.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1988190152"] = "Die Audiospur enthält kein hörbares Signal. Daher gibt es nichts zu transkribieren."
|
||||
|
||||
-- The media file is damaged or its format could not be identified.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2004316549"] = "Die Mediendatei ist beschädigt oder ihr Format konnte nicht erkannt werden."
|
||||
|
||||
-- This media format or audio codec is not supported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2142564510"] = "Dieses Medienformat oder dieser Audiocodec wird nicht unterstützt."
|
||||
|
||||
-- No usable transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2282521655"] = "Es ist kein nutzbarer Transkriptionsanbieter konfiguriert."
|
||||
|
||||
-- The media file could not be prepared for transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2749117459"] = "Die Mediendatei konnte nicht für die Transkription vorbereitet werden."
|
||||
|
||||
-- The transcription provider could not transcribe the media file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T3091669215"] = "Der Transkriptionsanbieter konnte die Mediendatei nicht transkribieren."
|
||||
|
||||
-- The media pipeline ended without an output file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T632852430"] = "Die Medienpipeline wurde beendet, ohne eine Ausgabedatei zu erzeugen."
|
||||
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc-Installation"
|
||||
|
||||
|
||||
@ -306,6 +306,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"
|
||||
|
||||
@ -315,6 +318,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."
|
||||
|
||||
@ -2295,9 +2301,24 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The ima
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
|
||||
|
||||
-- Media transcription was canceled. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1233815302"] = "Media transcription was canceled. Open the assistant to review it."
|
||||
|
||||
-- Media transcription failed. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2177964639"] = "Media transcription failed. Open the assistant to review it."
|
||||
|
||||
-- Media transcription completed with a warning. Open the assistant to review it.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2217674098"] = "Media transcription completed with a warning. Open the assistant to review it."
|
||||
|
||||
-- Media is still being prepared.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2600900617"] = "Media is still being prepared."
|
||||
|
||||
-- Assistant is still running.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T2719896610"] = "Assistant is still running."
|
||||
|
||||
-- The media transcript is ready.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3248321953"] = "The media transcript is ready."
|
||||
|
||||
-- Assistant was canceled. Open it to review the result.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T3390934621"] = "Assistant was canceled. Open it to review the result."
|
||||
|
||||
@ -2400,18 +2421,33 @@ 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."
|
||||
|
||||
-- Transcribe media files
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2178031033"] = "Transcribe media files"
|
||||
|
||||
-- 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"
|
||||
|
||||
-- Document Preview
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T285154968"] = "Document Preview"
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3172443094"] = "Media files require a configured transcription provider. Configure one in the transcription settings."
|
||||
|
||||
-- The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T322693339"] = "The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider."
|
||||
|
||||
-- Clear file list
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T3759696136"] = "Clear file list"
|
||||
|
||||
@ -2436,6 +2472,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..."
|
||||
|
||||
@ -2448,6 +2487,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."
|
||||
|
||||
@ -2673,6 +2715,24 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T3511160492"] = "Ac
|
||||
-- Please review this text again. The content was changed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MANDATORYINFODISPLAY::T941885055"] = "Please review this text again. The content was changed."
|
||||
|
||||
-- Waiting to prepare media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1167267986"] = "Waiting to prepare media"
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Stop media transcription"
|
||||
|
||||
-- Stopping media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1878101489"] = "Stopping media transcription"
|
||||
|
||||
-- Inspecting media
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2431421733"] = "Inspecting media"
|
||||
|
||||
-- Transcribing
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T2938661425"] = "Transcribing"
|
||||
|
||||
-- Preparing audio
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T3200155905"] = "Preparing audio"
|
||||
|
||||
-- Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MOTIVATION::T1057189794"] = "Given that my employer's workplace uses both Windows and Linux, I wanted a cross-platform solution that would work seamlessly across all major operating systems, including macOS. Additionally, I wanted to demonstrate that it is possible to create modern, efficient, cross-platform applications without resorting to Electron bloatware. The combination of .NET and Rust with Tauri proved to be an excellent technology stack for building such robust applications."
|
||||
|
||||
@ -2790,18 +2850,33 @@ 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."
|
||||
|
||||
-- Media files require a configured transcription provider. Configure one in the transcription settings.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3172443094"] = "Media files require a configured transcription provider. Configure one in the transcription settings."
|
||||
|
||||
-- Use file content as input
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3499386973"] = "Use file content as input"
|
||||
|
||||
-- Select file to read its content
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T354817589"] = "Select file to read its content"
|
||||
|
||||
-- Transcribe media file
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T3554222960"] = "Transcribe media file"
|
||||
|
||||
-- The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READWEBCONTENT::T1164201762"] = "The content is cleaned using an LLM agent: the main content is extracted, advertisements and other irrelevant things are attempted to be removed; relative links are attempted to be converted into absolute links so that they can be used."
|
||||
|
||||
@ -3540,9 +3615,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VISION::T586430036"] = "Useful assistants
|
||||
-- Voice recording has been disabled for this session because audio playback could not be initialized on the client.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1123032432"] = "Voice recording has been disabled for this session because audio playback could not be initialized on the client."
|
||||
|
||||
-- Failed to create the transcription provider.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T1689988905"] = "Failed to create the transcription provider."
|
||||
|
||||
-- Failed to start audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2144994226"] = "Failed to start audio recording."
|
||||
|
||||
@ -3561,21 +3633,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T2851219233"] = "Transcrip
|
||||
-- Unfortunately, there was an error communicating with the AI system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3236134591"] = "Unfortunately, there was an error communicating with the AI system."
|
||||
|
||||
-- The configured transcription provider was not found.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T331613105"] = "The configured transcription provider was not found."
|
||||
|
||||
-- Failed to stop audio recording.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3462568264"] = "Failed to stop audio recording."
|
||||
|
||||
-- The configured transcription provider does not meet the minimum confidence level.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T3834149033"] = "The configured transcription provider does not meet the minimum confidence level."
|
||||
|
||||
-- An error occurred during transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "An error occurred during transcription."
|
||||
|
||||
-- No transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T663630295"] = "No transcription provider is configured."
|
||||
|
||||
-- The transcription result is empty.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transcription result is empty."
|
||||
|
||||
@ -6750,9 +6813,6 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2037899437"] = "Copies the serve
|
||||
-- This library is used to create temporary folders in runtime tests and supporting filesystem operations.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2160280545"] = "This library is used to create temporary folders in runtime tests and supporting filesystem operations."
|
||||
|
||||
-- This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2173617769"] = "This library is used to determine the file type of a file. This is necessary, e.g., when we want to stream a file."
|
||||
|
||||
-- For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2174764529"] = "For the secure communication between the user interface and the runtime, we need to create certificates. This Rust library is great for this purpose."
|
||||
|
||||
@ -6771,6 +6831,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2301484629"] = "Configuration pl
|
||||
-- The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2329884315"] = "The C# language is used for the implementation of the user interface and the backend. To implement the user interface with C#, the Blazor technology from ASP.NET Core is used. All these technologies are integrated into the .NET SDK."
|
||||
|
||||
-- We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2345444286"] = "We use Rubato to resample the decoded audio to 48 kHz before the Opus encoding."
|
||||
|
||||
-- Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T234598990"] = "Linux AppImages bundle GStreamer components to support microphone access and WebM audio recording in the embedded WebKitGTK web view."
|
||||
|
||||
@ -6852,6 +6915,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2929232062"] = "Copies the confi
|
||||
-- Copies the root certificate fingerprint to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T2989678330"] = "Copies the root certificate fingerprint to the clipboard"
|
||||
|
||||
-- This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3002755581"] = "This library identifies files by their content. It is used for document streaming and as the first safety and media classification step before local audio processing."
|
||||
|
||||
-- Changelog
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3017574265"] = "Changelog"
|
||||
|
||||
@ -6897,6 +6963,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3315279770"] = "External HTTPS c
|
||||
-- User-language provided by the OS
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3334355246"] = "User-language provided by the OS"
|
||||
|
||||
-- webm-iterable provides the EBML and WebM writing path for normalized audio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3385332793"] = "webm-iterable provides the EBML and WebM writing path for normalized audio."
|
||||
|
||||
-- Status:
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3396815215"] = "Status:"
|
||||
|
||||
@ -6957,6 +7026,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T396609403"] = "not applicable"
|
||||
-- Copies the allowed host configuration to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3970230163"] = "Copies the allowed host configuration to the clipboard"
|
||||
|
||||
-- Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3971563979"] = "Symphonia is used for media container demuxing and audio decoding. The exact MPL-covered source is available from the repository linked and is identified in the offline notices bundled with AI Studio."
|
||||
|
||||
-- Installed Pandoc version
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T3983971016"] = "Installed Pandoc version"
|
||||
|
||||
@ -6975,6 +7047,9 @@ UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4060906280"] = "This library is
|
||||
-- This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4079152443"] = "This library is used to create asynchronous streams in Rust. It allows us to work with streams of data that can be produced asynchronously, making it easier to handle events or data that arrive over time. We use this, e.g., to stream arbitrary data from the file system to the embedding system."
|
||||
|
||||
-- Ropus provides the Opus encoder and decoder used by the media pipeline.
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4113556626"] = "Ropus provides the Opus encoder and decoder used by the media pipeline."
|
||||
|
||||
-- Community & Code
|
||||
UI_TEXT_CONTENT["AISTUDIO::PAGES::INFORMATION::T4158546761"] = "Community & Code"
|
||||
|
||||
@ -8487,6 +8562,42 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like p
|
||||
-- Document
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
|
||||
|
||||
-- The configured transcription provider could not be created.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1235984176"] = "The configured transcription provider could not be created."
|
||||
|
||||
-- The selected media file no longer exists.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T129859547"] = "The selected media file no longer exists."
|
||||
|
||||
-- The selected media file does not contain an audio track.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T134825479"] = "The selected media file does not contain an audio track."
|
||||
|
||||
-- The media file could not be transcribed.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1543974632"] = "The media file could not be transcribed."
|
||||
|
||||
-- The selected file cannot be processed as media.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1707342767"] = "The selected file cannot be processed as media."
|
||||
|
||||
-- The audio track contains no audible signal, so there is nothing to transcribe.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T1988190152"] = "The audio track contains no audible signal, so there is nothing to transcribe."
|
||||
|
||||
-- The media file is damaged or its format could not be identified.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2004316549"] = "The media file is damaged or its format could not be identified."
|
||||
|
||||
-- This media format or audio codec is not supported.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2142564510"] = "This media format or audio codec is not supported."
|
||||
|
||||
-- No usable transcription provider is configured.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2282521655"] = "No usable transcription provider is configured."
|
||||
|
||||
-- The media file could not be prepared for transcription.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T2749117459"] = "The media file could not be prepared for transcription."
|
||||
|
||||
-- The transcription provider could not transcribe the media file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T3091669215"] = "The transcription provider could not transcribe the media file."
|
||||
|
||||
-- The media pipeline ended without an output file.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::MEDIATRANSCRIPTIONSERVICE::T632852430"] = "The media pipeline ended without an output file."
|
||||
|
||||
-- Pandoc Installation
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation"
|
||||
|
||||
|
||||
@ -136,6 +136,7 @@ internal sealed class Program
|
||||
builder.Services.AddSingleton<AIJobService>();
|
||||
builder.Services.AddSingleton<AssistantSessionService>();
|
||||
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
|
||||
builder.Services.AddSingleton<MediaTranscriptionService>();
|
||||
builder.Services.AddSingleton<AssistantPluginInstallService>();
|
||||
builder.Services.AddSingleton<UpdatePolicy>();
|
||||
builder.Services.AddSingleton<DataSourceService>();
|
||||
@ -148,6 +149,7 @@ internal sealed class Program
|
||||
builder.Services.AddTransient<AssistantPluginAuditService>();
|
||||
builder.Services.AddHostedService<UpdateService>();
|
||||
builder.Services.AddHostedService<TemporaryChatService>();
|
||||
builder.Services.AddHostedService<TranscriptStagingCleanupService>();
|
||||
builder.Services.AddHostedService<EnterpriseEnvironmentService>();
|
||||
builder.Services.AddSingleton<DatabaseClientProvider>();
|
||||
builder.Services.AddHostedService<GlobalShortcutService>();
|
||||
|
||||
@ -1070,6 +1070,10 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
break;
|
||||
}
|
||||
|
||||
this.logger.LogInformation("Uploading transcription media '{FileName}' with content type '{ContentType}' and {FileSize} bytes.",
|
||||
Path.GetFileName(audioFilePath),
|
||||
mimeType.TextRepresentation,
|
||||
fileStream.Length);
|
||||
using var response = await this.HttpClient.SendAsync(request, token);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(token);
|
||||
|
||||
@ -1089,6 +1093,10 @@ public abstract class BaseProvider : IProvider, ISecretId
|
||||
|
||||
return TranscriptionResult.FromText(transcriptionResponse.Text);
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (this.IsTimeoutException(e, token))
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
public sealed class AudioRecordingResult
|
||||
{
|
||||
public string MimeType { get; init; } = string.Empty;
|
||||
|
||||
public bool ChangedMimeType { get; init; }
|
||||
}
|
||||
@ -34,6 +34,30 @@ public static class Markdown
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Escapes arbitrary text for literal display inside Markdown.</summary>
|
||||
public static string EscapeInlineText(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return string.Empty;
|
||||
|
||||
var escaped = new StringBuilder(value.Length);
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (character is '\r' or '\n' or '\t' || char.IsControl(character))
|
||||
{
|
||||
escaped.Append(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character is >= '!' and <= '/' or >= ':' and <= '@' or >= '[' and <= '`' or >= '{' and <= '~')
|
||||
escaped.Append('\\');
|
||||
|
||||
escaped.Append(character);
|
||||
}
|
||||
|
||||
return escaped.ToString();
|
||||
}
|
||||
|
||||
public static string RemoveSharedIndentation(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
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);
|
||||
13
app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs
Normal file
13
app/MindWork AI Studio/Tools/Media/MediaImportOutcome.cs
Normal file
@ -0,0 +1,13 @@
|
||||
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; } = [];
|
||||
|
||||
public IReadOnlyList<MediaImportWarning> Warnings { get; init; } = [];
|
||||
}
|
||||
11
app/MindWork AI Studio/Tools/Media/MediaImportOwner.cs
Normal file
11
app/MindWork AI Studio/Tools/Media/MediaImportOwner.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using AIStudio.Tools.AssistantSessions;
|
||||
|
||||
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)
|
||||
{
|
||||
public static MediaImportOwner ForChat(Guid chatId) => new(MediaImportOwnerKind.CHAT, chatId.ToString("N"));
|
||||
|
||||
public static MediaImportOwner ForAssistant(AssistantSessionKey key) => new(MediaImportOwnerKind.ASSISTANT, key.ToString());
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>Supported persistent media-operation owners.</summary>
|
||||
public enum MediaImportOwnerKind
|
||||
{
|
||||
CHAT,
|
||||
ASSISTANT,
|
||||
}
|
||||
19
app/MindWork AI Studio/Tools/Media/MediaImportSnapshot.cs
Normal file
19
app/MindWork AI Studio/Tools/Media/MediaImportSnapshot.cs
Normal file
@ -0,0 +1,19 @@
|
||||
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; }
|
||||
|
||||
public string CurrentFileName { get; init; } = string.Empty;
|
||||
|
||||
public double? Progress { get; init; }
|
||||
|
||||
public bool IsBusy => this.Status is MediaImportStatus.QUEUED or MediaImportStatus.RUNNING or MediaImportStatus.CANCELING;
|
||||
}
|
||||
13
app/MindWork AI Studio/Tools/Media/MediaImportStatus.cs
Normal file
13
app/MindWork AI Studio/Tools/Media/MediaImportStatus.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>Lifecycle status retained independently for each owner.</summary>
|
||||
public enum MediaImportStatus
|
||||
{
|
||||
QUEUED,
|
||||
RUNNING,
|
||||
CANCELING,
|
||||
SUCCEEDED,
|
||||
WARNING,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
}
|
||||
4
app/MindWork AI Studio/Tools/Media/MediaImportTarget.cs
Normal file
4
app/MindWork AI Studio/Tools/Media/MediaImportTarget.cs
Normal file
@ -0,0 +1,4 @@
|
||||
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);
|
||||
4
app/MindWork AI Studio/Tools/Media/MediaImportWarning.cs
Normal file
4
app/MindWork AI Studio/Tools/Media/MediaImportWarning.cs
Normal file
@ -0,0 +1,4 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>One user-visible media warning retained until its owner is displayed.</summary>
|
||||
public sealed record MediaImportWarning(string FileName, string UserMessage);
|
||||
@ -0,0 +1,23 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>Visible phases of the serialized media import lane.</summary>
|
||||
public enum MediaTranscriptionPhase
|
||||
{
|
||||
/// <summary>No import is active.</summary>
|
||||
IDLE,
|
||||
|
||||
/// <summary>The operation is waiting for the serialized runtime lane.</summary>
|
||||
QUEUED,
|
||||
|
||||
/// <summary>The runtime is inspecting the input.</summary>
|
||||
PROBING,
|
||||
|
||||
/// <summary>The runtime is preparing normalized audio.</summary>
|
||||
TRANSCODING,
|
||||
|
||||
/// <summary>The normalized audio is being transcribed by the provider.</summary>
|
||||
UPLOADING,
|
||||
|
||||
/// <summary>Cancellation was requested and runtime cleanup is in progress.</summary>
|
||||
CANCELING,
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Typed terminal result returned by media import and voice operations.
|
||||
/// </summary>
|
||||
/// <param name="Status">Terminal operation status.</param>
|
||||
/// <param name="Text">Transcript text for a successful operation.</param>
|
||||
/// <param name="UserMessage">Localized message suitable for display after a warning or failure.</param>
|
||||
/// <param name="ErrorCode">Optional stable runtime failure category.</param>
|
||||
public sealed record MediaTranscriptionResult(MediaTranscriptionResultStatus Status, string Text, string UserMessage, MediaJobErrorCode? ErrorCode = null)
|
||||
{
|
||||
/// <summary>Creates a successful result.</summary>
|
||||
/// <param name="text">Provider transcript.</param>
|
||||
public static MediaTranscriptionResult Succeeded(string text) => new(MediaTranscriptionResultStatus.SUCCEEDED, text, string.Empty);
|
||||
|
||||
/// <summary>Creates a failed result.</summary>
|
||||
/// <param name="userMessage">Localized visible message.</param>
|
||||
/// <param name="errorCode">Optional runtime error category.</param>
|
||||
public static MediaTranscriptionResult Failed(string userMessage, MediaJobErrorCode? errorCode = null) => new(MediaTranscriptionResultStatus.FAILED, string.Empty, userMessage, errorCode);
|
||||
|
||||
/// <summary>Creates a warning result for media without an audible signal.</summary>
|
||||
/// <param name="userMessage">Localized visible warning.</param>
|
||||
public static MediaTranscriptionResult NoAudibleSignal(string userMessage) => new(
|
||||
MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL,
|
||||
string.Empty,
|
||||
userMessage);
|
||||
|
||||
/// <summary>Creates a cancelled result without relying on visible text.</summary>
|
||||
public static MediaTranscriptionResult Cancelled() => new(MediaTranscriptionResultStatus.CANCELLED, string.Empty, string.Empty, MediaJobErrorCode.CANCELLED);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
namespace AIStudio.Tools.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Terminal outcome of a media transcription operation.
|
||||
/// </summary>
|
||||
public enum MediaTranscriptionResultStatus
|
||||
{
|
||||
/// <summary>The provider returned a usable transcript.</summary>
|
||||
SUCCEEDED,
|
||||
|
||||
/// <summary>The operation failed.</summary>
|
||||
FAILED,
|
||||
|
||||
/// <summary>The media contains no signal above the practical-silence threshold.</summary>
|
||||
NO_AUDIBLE_SIGNAL,
|
||||
|
||||
/// <summary>The caller or user cancelled the operation.</summary>
|
||||
CANCELLED,
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>Request body used to start a Rust media normalization job.</summary>
|
||||
/// <param name="InputPath">Absolute source media path.</param>
|
||||
/// <param name="OutputPath">Absolute operation-owned output path.</param>
|
||||
/// <param name="MaxPassThroughBytes">Optional pass-through size ceiling.</param>
|
||||
public sealed record CreateMediaJobRequest(string InputPath, string OutputPath, ulong? MaxPassThroughBytes = null);
|
||||
@ -0,0 +1,5 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>Response returned after a Rust media job is registered.</summary>
|
||||
/// <param name="JobId">Opaque runtime job identifier.</param>
|
||||
public sealed record CreateMediaJobResponse(string JobId);
|
||||
@ -61,7 +61,7 @@ public static class FileTypes
|
||||
public static readonly FileTypeFilter IMAGE = FileTypeFilter.Leaf(TB("Image"),
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "svg", "webp", "heic");
|
||||
public static readonly FileTypeFilter AUDIO = FileTypeFilter.Leaf(TB("Audio"),
|
||||
"mp3", "wav", "wave", "aac", "flac", "ogg", "m4a", "wma", "alac", "aiff", "m4b");
|
||||
"mp3", "wav", "wave", "aac", "flac", "ogg", "opus", "m4a", "m4b", "wma", "alac", "aif", "aiff", "caf");
|
||||
public static readonly FileTypeFilter VIDEO = FileTypeFilter.Leaf(TB("Video"),
|
||||
"mp4", "m4v", "avi", "mkv", "mov", "wmv", "flv", "webm");
|
||||
|
||||
|
||||
8
app/MindWork AI Studio/Tools/Rust/MediaJobError.cs
Normal file
8
app/MindWork AI Studio/Tools/Rust/MediaJobError.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime media error containing a stable code and an English log diagnostic.
|
||||
/// </summary>
|
||||
/// <param name="Code">Stable machine-readable error category.</param>
|
||||
/// <param name="Message">US-English diagnostic intended for logs.</param>
|
||||
public sealed record MediaJobError(MediaJobErrorCode Code, string Message);
|
||||
85
app/MindWork AI Studio/Tools/Rust/MediaJobErrorCode.cs
Normal file
85
app/MindWork AI Studio/Tools/Rust/MediaJobErrorCode.cs
Normal file
@ -0,0 +1,85 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>
|
||||
/// Stable failure categories returned by the Rust media pipeline.
|
||||
/// </summary>
|
||||
public enum MediaJobErrorCode
|
||||
{
|
||||
/// <summary>The runtime returned an unrecognized code.</summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>The input file does not exist.</summary>
|
||||
FILE_NOT_FOUND,
|
||||
|
||||
/// <summary>The file type could not be identified.</summary>
|
||||
UNKNOWN_FORMAT,
|
||||
|
||||
/// <summary>Executable input was rejected.</summary>
|
||||
UNSAFE_FILE,
|
||||
|
||||
/// <summary>The input is not media.</summary>
|
||||
NOT_MEDIA,
|
||||
|
||||
/// <summary>The input file could not be opened.</summary>
|
||||
FILE_OPEN_FAILED,
|
||||
|
||||
/// <summary>The container is unsupported.</summary>
|
||||
UNSUPPORTED_CONTAINER,
|
||||
|
||||
/// <summary>The media has no audio track.</summary>
|
||||
NO_AUDIO_TRACK,
|
||||
|
||||
/// <summary>No audio track has a supported decoder.</summary>
|
||||
UNSUPPORTED_CODEC,
|
||||
|
||||
/// <summary>Decoded audio parameters are absent or inconsistent.</summary>
|
||||
INVALID_AUDIO_PARAMETERS,
|
||||
|
||||
/// <summary>The Opus identification header is invalid.</summary>
|
||||
INVALID_OPUS_HEADER,
|
||||
|
||||
/// <summary>The Opus mapping requires unsupported multistream decoding.</summary>
|
||||
UNSUPPORTED_OPUS_MAPPING,
|
||||
|
||||
/// <summary>The decoder could not be initialized.</summary>
|
||||
DECODER_INIT_FAILED,
|
||||
|
||||
/// <summary>The encoder could not be initialized.</summary>
|
||||
ENCODER_INIT_FAILED,
|
||||
|
||||
/// <summary>The stream changed unexpectedly.</summary>
|
||||
STREAM_RESET,
|
||||
|
||||
/// <summary>The container is damaged.</summary>
|
||||
DAMAGED_CONTAINER,
|
||||
|
||||
/// <summary>Audio decoding failed.</summary>
|
||||
DECODE_FAILED,
|
||||
|
||||
/// <summary>Audio resampling failed.</summary>
|
||||
RESAMPLE_FAILED,
|
||||
|
||||
/// <summary>Opus encoding failed.</summary>
|
||||
ENCODE_FAILED,
|
||||
|
||||
/// <summary>The output directory or file could not be created.</summary>
|
||||
OUTPUT_CREATE_FAILED,
|
||||
|
||||
/// <summary>The output could not be written.</summary>
|
||||
OUTPUT_WRITE_FAILED,
|
||||
|
||||
/// <summary>The partial output could not be committed.</summary>
|
||||
OUTPUT_COMMIT_FAILED,
|
||||
|
||||
/// <summary>A WebM relative timestamp overflowed.</summary>
|
||||
WEBM_TIMESTAMP_OVERFLOW,
|
||||
|
||||
/// <summary>WebM serialization failed.</summary>
|
||||
WEBM_WRITE_FAILED,
|
||||
|
||||
/// <summary>The job was cancelled.</summary>
|
||||
CANCELLED,
|
||||
|
||||
/// <summary>The runtime worker failed unexpectedly.</summary>
|
||||
INTERNAL_ERROR,
|
||||
}
|
||||
12
app/MindWork AI Studio/Tools/Rust/MediaJobEvent.cs
Normal file
12
app/MindWork AI Studio/Tools/Rust/MediaJobEvent.cs
Normal file
@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>Snapshot emitted by the Rust media job event stream.</summary>
|
||||
/// <param name="Phase">Current job phase.</param>
|
||||
/// <param name="Progress">Optional progress fraction.</param>
|
||||
/// <param name="Result">Completed result.</param>
|
||||
/// <param name="Error">Failure diagnostic.</param>
|
||||
public sealed record MediaJobEvent(
|
||||
MediaJobPhase Phase,
|
||||
double? Progress,
|
||||
MediaJobResult? Result,
|
||||
MediaJobError? Error);
|
||||
23
app/MindWork AI Studio/Tools/Rust/MediaJobPhase.cs
Normal file
23
app/MindWork AI Studio/Tools/Rust/MediaJobPhase.cs
Normal file
@ -0,0 +1,23 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>Lifecycle phases exposed by the Rust media API.</summary>
|
||||
public enum MediaJobPhase
|
||||
{
|
||||
/// <summary>An unknown future value received from Rust.</summary>
|
||||
UNKNOWN,
|
||||
|
||||
/// <summary>The runtime is identifying the input and selecting audio.</summary>
|
||||
PROBING,
|
||||
|
||||
/// <summary>The runtime is normalizing audio.</summary>
|
||||
TRANSCODING,
|
||||
|
||||
/// <summary>The output was committed successfully.</summary>
|
||||
COMPLETED,
|
||||
|
||||
/// <summary>The job failed.</summary>
|
||||
FAILED,
|
||||
|
||||
/// <summary>Cancellation and temporary-output cleanup completed.</summary>
|
||||
CANCELLED,
|
||||
}
|
||||
20
app/MindWork AI Studio/Tools/Rust/MediaJobResult.cs
Normal file
20
app/MindWork AI Studio/Tools/Rust/MediaJobResult.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>Successful terminal result returned by Rust media normalization.</summary>
|
||||
/// <param name="OutputPath">Committed normalized output path.</param>
|
||||
/// <param name="OutputFormat">Stable normalized container used for provider uploads.</param>
|
||||
/// <param name="OutputCodec">Stable normalized audio codec used for provider uploads.</param>
|
||||
/// <param name="DetectedFormat">Detected container diagnostic.</param>
|
||||
/// <param name="DetectedCodec">Selected codec diagnostic.</param>
|
||||
/// <param name="DurationMs">Normalized duration in milliseconds.</param>
|
||||
/// <param name="PassThrough">Whether the source was copied unchanged.</param>
|
||||
/// <param name="HasAudibleSignal">Whether the normalized audio exceeds the practical-silence threshold.</param>
|
||||
public sealed record MediaJobResult(
|
||||
string OutputPath,
|
||||
string OutputFormat,
|
||||
string OutputCodec,
|
||||
string DetectedFormat,
|
||||
string DetectedCodec,
|
||||
ulong DurationMs,
|
||||
bool PassThrough,
|
||||
bool HasAudibleSignal);
|
||||
@ -0,0 +1,888 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates serialized visible media imports and independent voice transcriptions.
|
||||
/// </summary>
|
||||
public sealed class MediaTranscriptionService(RustService rustService, SettingsManager settingsManager, ILogger<MediaTranscriptionService> logger) : IDisposable
|
||||
{
|
||||
private const string NORMALIZED_OUTPUT_EXTENSION = ".webm";
|
||||
private const string NORMALIZED_OUTPUT_FORMAT = "webm";
|
||||
private const string NORMALIZED_OUTPUT_CODEC = "opus";
|
||||
private static readonly byte[] WEBM_EBML_SIGNATURE = [0x1A, 0x45, 0xDF, 0xA3];
|
||||
|
||||
/// <summary>Serializes attachment and file-content imports.</summary>
|
||||
private readonly SemaphoreSlim importQueue = new(1, 1);
|
||||
|
||||
/// <summary>Protects operation ownership and owner-specific import state.</summary>
|
||||
private readonly Lock stateLock = new();
|
||||
|
||||
/// <summary>All operations retained so disposal can cancel voice and import work.</summary>
|
||||
private readonly HashSet<MediaOperation> operations = [];
|
||||
|
||||
/// <summary>The active or queued import operation for each owner.</summary>
|
||||
private readonly Dictionary<MediaImportOwner, MediaOperation> currentImports = [];
|
||||
|
||||
/// <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 = [];
|
||||
|
||||
/// <summary>Batch-level cancellation keeps Stop effective between two files.</summary>
|
||||
private readonly Dictionary<MediaImportOwner, CancellationTokenSource> batchCancellations = [];
|
||||
|
||||
/// <summary>Prevents new work after disposal.</summary>
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Raised only with the owner whose copied state changed.</summary>
|
||||
public event Action<MediaImportOwner>? StateChanged;
|
||||
|
||||
/// <summary>Gets whether one owner has queued, running, or canceling media work.</summary>
|
||||
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)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
return this.snapshots.GetValueOrDefault(owner);
|
||||
}
|
||||
|
||||
/// <summary>Gets copied retained snapshots for navigation indicators.</summary>
|
||||
public IReadOnlyCollection<MediaImportSnapshot> GetSnapshots()
|
||||
{
|
||||
lock (this.stateLock)
|
||||
return [.. this.snapshots.Values];
|
||||
}
|
||||
|
||||
/// <summary>Gets copied results that have not yet been applied by one target.</summary>
|
||||
public MediaImportDelivery? GetPendingDelivery(MediaImportTarget target)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (!this.pendingDeliveries.TryGetValue(target, out var pending))
|
||||
return null;
|
||||
|
||||
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.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;
|
||||
}
|
||||
|
||||
/// <summary>Starts a reattachable file-content import for one stable assistant field.</summary>
|
||||
public bool TryStartTextImport(string mediaPath, MediaImportTarget target)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
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(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED);
|
||||
_ = Task.Run(() => this.RunTextImportAsync(mediaPath, target));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Completes a field import independently of the originating Blazor component.</summary>
|
||||
private async Task RunTextImportAsync(string mediaPath, MediaImportTarget target)
|
||||
{
|
||||
CancellationTokenSource cancellation;
|
||||
lock (this.stateLock)
|
||||
cancellation = this.batchCancellations[target.Owner];
|
||||
|
||||
var status = MediaImportStatus.SUCCEEDED;
|
||||
List<MediaImportFailure> failures = [];
|
||||
List<MediaImportWarning> warnings = [];
|
||||
try
|
||||
{
|
||||
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 if (result.Status is MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL)
|
||||
{
|
||||
status = MediaImportStatus.WARNING;
|
||||
warnings.Add(new(Path.GetFileName(mediaPath), result.UserMessage));
|
||||
}
|
||||
else
|
||||
{
|
||||
status = MediaImportStatus.FAILED;
|
||||
failures.Add(new(Path.GetFileName(mediaPath), result.UserMessage, result.ErrorCode));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
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
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
this.activeBatches.Remove(target.Owner);
|
||||
if (this.batchCancellations.Remove(target.Owner, out var ownedCancellation))
|
||||
ownedCancellation.Dispose();
|
||||
}
|
||||
|
||||
this.CompleteImport(target, Path.GetFileName(mediaPath), status, failures, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores a completed field transcript for reattachment after navigation.</summary>
|
||||
private void AddCompletedText(MediaImportTarget target, string text)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (!this.pendingDeliveries.TryGetValue(target, out var pending))
|
||||
this.pendingDeliveries[target] = pending = new();
|
||||
|
||||
pending.Text = text;
|
||||
}
|
||||
|
||||
this.NotifyStateChanged(target.Owner);
|
||||
}
|
||||
|
||||
/// <summary>Serially transcribes a complete owner batch while retaining every successful result.</summary>
|
||||
private async Task RunAttachmentBatchAsync(IReadOnlyList<string> mediaPaths, MediaImportTarget target, ChatThread? ownerChat)
|
||||
{
|
||||
CancellationToken batchToken;
|
||||
lock (this.stateLock)
|
||||
batchToken = this.batchCancellations[target.Owner].Token;
|
||||
|
||||
var status = MediaImportStatus.SUCCEEDED;
|
||||
var currentFileName = Path.GetFileName(mediaPaths[0]);
|
||||
List<MediaImportFailure> failures = [];
|
||||
List<MediaImportWarning> warnings = [];
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var mediaPath in mediaPaths)
|
||||
{
|
||||
currentFileName = Path.GetFileName(mediaPath);
|
||||
batchToken.ThrowIfCancellationRequested();
|
||||
var result = await this.TranscribeImportAsync(mediaPath, target, batchToken);
|
||||
if (result.Status is MediaTranscriptionResultStatus.CANCELLED)
|
||||
{
|
||||
status = MediaImportStatus.CANCELLED;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.Status is MediaTranscriptionResultStatus.NO_AUDIBLE_SIGNAL)
|
||||
{
|
||||
if (status is MediaImportStatus.SUCCEEDED)
|
||||
status = MediaImportStatus.WARNING;
|
||||
|
||||
warnings.Add(new(currentFileName, result.UserMessage));
|
||||
continue;
|
||||
}
|
||||
|
||||
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 attachment = isPersistedChat
|
||||
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text)
|
||||
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
|
||||
|
||||
if (ownerChat is not null && attachment is { } managed
|
||||
&& ownerChat.PendingMediaTranscripts.All(existing => existing.FilePath != managed.FilePath))
|
||||
ownerChat.PendingMediaTranscripts.Add(managed);
|
||||
|
||||
if (isPersistedChat)
|
||||
await WorkspaceBehaviour.StoreChatAsync(ownerChat!);
|
||||
|
||||
this.AddCompletedAttachment(target, attachment);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
status = MediaImportStatus.CANCELLED;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
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(target.Owner);
|
||||
if (this.batchCancellations.Remove(target.Owner, out var cancellation))
|
||||
cancellation.Dispose();
|
||||
}
|
||||
|
||||
this.CompleteImport(target, currentFileName, status, failures, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds a successful partial result to the retained owner snapshot.</summary>
|
||||
private void AddCompletedAttachment(MediaImportTarget target, FileAttachment attachment)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (!this.pendingDeliveries.TryGetValue(target, out var pending))
|
||||
this.pendingDeliveries[target] = pending = new();
|
||||
|
||||
if (pending.Attachments.All(existing => existing.FilePath != attachment.FilePath))
|
||||
pending.Attachments.Add(attachment);
|
||||
}
|
||||
|
||||
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="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, MediaImportTarget target, CancellationToken token = default)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
var operation = this.CreateOperation(target, token);
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (!this.currentImports.TryAdd(target.Owner, operation))
|
||||
throw new InvalidOperationException($"Media owner '{target.Owner}' already has an active operation.");
|
||||
}
|
||||
|
||||
this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.QUEUED, null, MediaImportStatus.QUEUED);
|
||||
|
||||
try
|
||||
{
|
||||
await this.importQueue.WaitAsync(operation.Cancellation.Token);
|
||||
operation.HasQueueLease = true;
|
||||
|
||||
this.UpdateImportState(target, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0, MediaImportStatus.RUNNING);
|
||||
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return MediaTranscriptionResult.Cancelled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.currentImports.GetValueOrDefault(target.Owner) == operation)
|
||||
this.currentImports.Remove(target.Owner);
|
||||
}
|
||||
|
||||
this.ReleaseOperation(operation);
|
||||
if (operation.HasQueueLease)
|
||||
this.importQueue.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transcribes a voice recording independently of the visible import lane.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Voice recording path.</param>
|
||||
/// <param name="token">Caller cancellation token.</param>
|
||||
/// <returns>A typed terminal result.</returns>
|
||||
public async Task<MediaTranscriptionResult> TranscribeVoiceAsync(string mediaPath, CancellationToken token = default)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
var operation = this.CreateOperation(null, token);
|
||||
|
||||
try
|
||||
{
|
||||
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.ReleaseOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancels only the queued or active operation belonging to one owner.</summary>
|
||||
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 (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);
|
||||
}
|
||||
|
||||
/// <summary>Runs normalization, provider resolution, and upload for one owned operation.</summary>
|
||||
/// <param name="mediaPath">Source media path.</param>
|
||||
/// <param name="operation">Operation-specific cancellation and Rust-job state.</param>
|
||||
/// <param name="updateImportState">Whether progress belongs to the visible import lane.</param>
|
||||
/// <returns>A typed terminal result.</returns>
|
||||
private async Task<MediaTranscriptionResult> TranscribeCoreAsync(string mediaPath, MediaOperation operation, bool updateImportState)
|
||||
{
|
||||
var normalizedPath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", $"{operation.Id:N}.webm");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(normalizedPath)!);
|
||||
|
||||
try
|
||||
{
|
||||
var normalized = await this.NormalizeAsync(mediaPath, normalizedPath, operation, updateImportState);
|
||||
if (normalized.Result is null)
|
||||
return normalized.Error is null
|
||||
? MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file."))
|
||||
: MediaTranscriptionResult.Failed(UserMessageFor(normalized.Error.Code), normalized.Error.Code);
|
||||
|
||||
var uploadContractError = await ValidateNormalizedProviderUploadAsync(normalized.Result, normalizedPath, operation.Cancellation.Token);
|
||||
if (uploadContractError is not null)
|
||||
{
|
||||
logger.LogError("Refusing the transcription provider upload because the normalized media contract validation failed: {Diagnostic}", uploadContractError);
|
||||
return MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file."));
|
||||
}
|
||||
|
||||
if (!normalized.Result.HasAudibleSignal)
|
||||
{
|
||||
logger.LogInformation("Skipping transcription for '{MediaPath}' because its maximum audio peak does not exceed the practical-silence threshold.", mediaPath);
|
||||
return MediaTranscriptionResult.NoAudibleSignal(TB("The audio track contains no audible signal, so there is nothing to transcribe."));
|
||||
}
|
||||
|
||||
var providerSettings = this.ResolveProvider();
|
||||
if (providerSettings is null)
|
||||
return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured."));
|
||||
|
||||
if (updateImportState)
|
||||
this.UpdateImportState(operation.Target!.Value, Path.GetFileName(mediaPath), MediaTranscriptionPhase.UPLOADING, null, MediaImportStatus.RUNNING);
|
||||
|
||||
var provider = providerSettings.CreateProvider();
|
||||
if (provider.Provider is LLMProviders.NONE)
|
||||
return MediaTranscriptionResult.Failed(TB("The configured transcription provider could not be created."));
|
||||
|
||||
var sourceSize = File.Exists(mediaPath) ? new FileInfo(mediaPath).Length : 0;
|
||||
var normalizedSize = new FileInfo(normalizedPath).Length;
|
||||
var reductionPercent = sourceSize > 0
|
||||
? (1.0 - (double)normalizedSize / sourceSize) * 100.0
|
||||
: 0.0;
|
||||
logger.LogInformation("Transcribing normalized WebM/Opus media '{NormalizedPath}' ({NormalizedSize} bytes; source '{SourcePath}' {SourceSize} bytes; size reduction {ReductionPercent:F1}%) with provider '{Provider}' and model '{Model}'.",
|
||||
normalizedPath,
|
||||
normalizedSize,
|
||||
mediaPath,
|
||||
sourceSize,
|
||||
reductionPercent,
|
||||
providerSettings.UsedLLMProvider,
|
||||
providerSettings.Model);
|
||||
|
||||
var providerResult = await provider.TranscribeAudioAsync(providerSettings.Model, normalizedPath, settingsManager, operation.Cancellation.Token);
|
||||
operation.Cancellation.Token.ThrowIfCancellationRequested();
|
||||
if (!providerResult.Success)
|
||||
{
|
||||
logger.LogWarning("The transcription provider failed for '{MediaPath}': {Diagnostic}", mediaPath, providerResult.ErrorMessage);
|
||||
return MediaTranscriptionResult.Failed(TB("The transcription provider could not transcribe the media file."));
|
||||
}
|
||||
|
||||
return MediaTranscriptionResult.Succeeded(providerResult.Text.Trim());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return MediaTranscriptionResult.Cancelled();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Media transcription failed for '{MediaPath}'.", mediaPath);
|
||||
return MediaTranscriptionResult.Failed(TB("The media file could not be transcribed."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// NormalizeAsync does not return from cancellation until Rust has reached a terminal
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Validates the fail-closed WebM/Opus contract before provider upload.</summary>
|
||||
private static async Task<string?> ValidateNormalizedProviderUploadAsync(MediaJobResult result, string expectedOutputPath, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result.OutputPath))
|
||||
return "Rust returned an empty normalized output path.";
|
||||
|
||||
string actualFullPath;
|
||||
string expectedFullPath;
|
||||
try
|
||||
{
|
||||
actualFullPath = Path.GetFullPath(result.OutputPath);
|
||||
expectedFullPath = Path.GetFullPath(expectedOutputPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return $"The normalized output path is invalid: {exception.Message}";
|
||||
}
|
||||
|
||||
var pathComparison = OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
if (!string.Equals(actualFullPath, expectedFullPath, pathComparison))
|
||||
return $"Rust returned the unexpected output path '{result.OutputPath}' instead of '{expectedOutputPath}'.";
|
||||
|
||||
if (!string.Equals(Path.GetExtension(actualFullPath), NORMALIZED_OUTPUT_EXTENSION, StringComparison.OrdinalIgnoreCase))
|
||||
return $"The normalized output path '{actualFullPath}' does not use the required '{NORMALIZED_OUTPUT_EXTENSION}' extension.";
|
||||
|
||||
if (!string.Equals(result.OutputFormat, NORMALIZED_OUTPUT_FORMAT, StringComparison.Ordinal))
|
||||
return $"Rust returned output format '{result.OutputFormat}' instead of '{NORMALIZED_OUTPUT_FORMAT}'.";
|
||||
|
||||
if (!string.Equals(result.OutputCodec, NORMALIZED_OUTPUT_CODEC, StringComparison.Ordinal))
|
||||
return $"Rust returned output codec '{result.OutputCodec}' instead of '{NORMALIZED_OUTPUT_CODEC}'.";
|
||||
|
||||
if (!File.Exists(actualFullPath))
|
||||
return $"The normalized output file '{actualFullPath}' does not exist.";
|
||||
|
||||
var header = new byte[WEBM_EBML_SIGNATURE.Length];
|
||||
var bytesRead = 0;
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(actualFullPath);
|
||||
while (bytesRead < header.Length)
|
||||
{
|
||||
var count = await stream.ReadAsync(header.AsMemory(bytesRead), token);
|
||||
if (count is 0)
|
||||
break;
|
||||
|
||||
bytesRead += count;
|
||||
}
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
return $"The normalized output file '{actualFullPath}' could not be read: {exception.Message}";
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
return $"The normalized output file '{actualFullPath}' could not be read: {exception.Message}";
|
||||
}
|
||||
|
||||
if (bytesRead != header.Length || !header.AsSpan().SequenceEqual(WEBM_EBML_SIGNATURE))
|
||||
return $"The normalized output file '{actualFullPath}' does not begin with the WebM/Matroska EBML signature.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Runs the Rust normalization job and drains cancellation to a terminal event.</summary>
|
||||
/// <param name="mediaPath">Source media path.</param>
|
||||
/// <param name="normalizedPath">Owned temporary output path.</param>
|
||||
/// <param name="operation">Operation-specific state.</param>
|
||||
/// <param name="updateImportState">Whether progress belongs to the import lane.</param>
|
||||
/// <returns>The terminal runtime result or error.</returns>
|
||||
private async Task<(MediaJobResult? Result, MediaJobError? Error)> NormalizeAsync(
|
||||
string mediaPath,
|
||||
string normalizedPath,
|
||||
MediaOperation operation,
|
||||
bool updateImportState)
|
||||
{
|
||||
// The quick POST is intentionally not cancelled: losing its response could orphan a job
|
||||
// whose ID the client never received. Cancellation is applied immediately after ownership.
|
||||
var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, CancellationToken.None);
|
||||
operation.JobId = jobId;
|
||||
|
||||
try
|
||||
{
|
||||
operation.Cancellation.Token.ThrowIfCancellationRequested();
|
||||
await foreach (var mediaEvent in rustService.StreamMediaJobEventsAsync(jobId, operation.Cancellation.Token))
|
||||
{
|
||||
if (updateImportState && mediaEvent.Phase is MediaJobPhase.PROBING or MediaJobPhase.TRANSCODING)
|
||||
{
|
||||
var phase = mediaEvent.Phase is MediaJobPhase.PROBING
|
||||
? MediaTranscriptionPhase.PROBING
|
||||
: MediaTranscriptionPhase.TRANSCODING;
|
||||
this.UpdateImportState(operation.Target!.Value, Path.GetFileName(mediaPath), phase, mediaEvent.Progress, MediaImportStatus.RUNNING);
|
||||
}
|
||||
|
||||
switch (mediaEvent.Phase)
|
||||
{
|
||||
case MediaJobPhase.COMPLETED:
|
||||
return (mediaEvent.Result, null);
|
||||
|
||||
case MediaJobPhase.FAILED:
|
||||
if (mediaEvent.Error is not null)
|
||||
logger.LogWarning("Rust media normalization failed for '{MediaPath}' with {Code}: {Diagnostic}", mediaPath, mediaEvent.Error.Code, mediaEvent.Error.Message);
|
||||
|
||||
return (null, mediaEvent.Error);
|
||||
|
||||
case MediaJobPhase.CANCELLED:
|
||||
throw new OperationCanceledException(operation.Cancellation.Token);
|
||||
}
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await rustService.CancelMediaJobAsync(jobId, CancellationToken.None);
|
||||
await this.DrainTerminalEventAsync(jobId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for Rust cleanup after cooperative cancellation.</summary>
|
||||
/// <param name="jobId">Owned Rust job identifier.</param>
|
||||
private async Task DrainTerminalEventAsync(string jobId)
|
||||
{
|
||||
await foreach (var _ in rustService.StreamMediaJobEventsAsync(jobId, CancellationToken.None))
|
||||
{
|
||||
// The stream itself ends immediately after the first terminal snapshot or event.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves the configured provider after confidence validation.</summary>
|
||||
private TranscriptionProvider? ResolveProvider()
|
||||
{
|
||||
var providerId = settingsManager.ConfigurationData.App.UseTranscriptionProvider;
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
return null;
|
||||
|
||||
var providerSettings = settingsManager.ConfigurationData.TranscriptionProviders.FirstOrDefault(x => x.Id == providerId);
|
||||
if (providerSettings is null)
|
||||
return null;
|
||||
|
||||
var minimumLevel = settingsManager.GetMinimumConfidenceLevel(Components.NONE);
|
||||
return providerSettings.UsedLLMProvider.GetConfidence(settingsManager).Level >= minimumLevel
|
||||
? providerSettings
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Creates and registers operation-owned cancellation state.</summary>
|
||||
/// <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(MediaImportTarget? target, CancellationToken token)
|
||||
{
|
||||
var operation = new MediaOperation(target, token);
|
||||
lock (this.stateLock)
|
||||
this.operations.Add(operation);
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
/// <summary>Unregisters and disposes completed operation state.</summary>
|
||||
/// <param name="operation">Completed operation.</param>
|
||||
private void ReleaseOperation(MediaOperation operation)
|
||||
{
|
||||
lock (this.stateLock)
|
||||
this.operations.Remove(operation);
|
||||
|
||||
operation.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Updates and publishes copied state for exactly one owner.</summary>
|
||||
private void UpdateImportState(MediaImportTarget target, string fileName, MediaTranscriptionPhase phase, double? progress, MediaImportStatus status)
|
||||
{
|
||||
var snapshot = new MediaImportSnapshot
|
||||
{
|
||||
Owner = target.Owner,
|
||||
Target = target,
|
||||
CurrentFileName = fileName,
|
||||
Phase = phase,
|
||||
Progress = progress,
|
||||
Status = status,
|
||||
};
|
||||
|
||||
lock (this.stateLock)
|
||||
this.snapshots[target.Owner] = snapshot;
|
||||
|
||||
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,
|
||||
IReadOnlyList<MediaImportWarning> warnings)
|
||||
{
|
||||
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],
|
||||
Warnings = [.. warnings],
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
private static string UserMessageFor(MediaJobErrorCode code) => code switch
|
||||
{
|
||||
MediaJobErrorCode.FILE_NOT_FOUND => TB("The selected media file no longer exists."),
|
||||
MediaJobErrorCode.UNSAFE_FILE or MediaJobErrorCode.NOT_MEDIA => TB("The selected file cannot be processed as media."),
|
||||
MediaJobErrorCode.NO_AUDIO_TRACK => TB("The selected media file does not contain an audio track."),
|
||||
MediaJobErrorCode.UNSUPPORTED_CONTAINER or MediaJobErrorCode.UNSUPPORTED_CODEC or MediaJobErrorCode.UNSUPPORTED_OPUS_MAPPING => TB("This media format or audio codec is not supported."),
|
||||
MediaJobErrorCode.UNKNOWN_FORMAT or MediaJobErrorCode.DAMAGED_CONTAINER => TB("The media file is damaged or its format could not be identified."),
|
||||
|
||||
_ => TB("The media file could not be prepared for transcription."),
|
||||
};
|
||||
|
||||
/// <summary>Deletes one operation-owned temporary file on a best-effort basis.</summary>
|
||||
private void DeleteTemporaryFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not delete operation-owned temporary media file '{Path}'.", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retains the exact provider upload only for opt-in debug diagnostics.</summary>
|
||||
private bool RetainNormalizedMediaIfRequested(string normalizedPath, Guid operationId)
|
||||
{
|
||||
#if DEBUG
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("MINDWORK_AI_RETAIN_NORMALIZED_MEDIA"), "true", StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(normalizedPath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var diagnosticDirectory = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", "diagnostics");
|
||||
Directory.CreateDirectory(diagnosticDirectory);
|
||||
var diagnosticPath = Path.Combine(diagnosticDirectory, $"{operationId:N}.webm");
|
||||
File.Move(normalizedPath, diagnosticPath, overwrite: true);
|
||||
|
||||
foreach (var oldPath in new DirectoryInfo(diagnosticDirectory).EnumerateFiles("*.webm").OrderByDescending(file => file.LastWriteTimeUtc).Skip(10))
|
||||
oldPath.Delete();
|
||||
|
||||
logger.LogInformation("Retained normalized media diagnostic '{DiagnosticPath}'.", diagnosticPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not retain normalized media diagnostic for operation '{OperationId}'.", operationId);
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Returns localized text while registering the US-English fallback with I18N.</summary>
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(MediaTranscriptionService).Namespace, nameof(MediaTranscriptionService));
|
||||
|
||||
/// <summary>Throws when a caller attempts to start work after disposal.</summary>
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this.disposed, this);
|
||||
|
||||
/// <summary>Cancels every active import and voice operation and releases owned resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
MediaOperation[] active;
|
||||
CancellationTokenSource[] batches;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.disposed)
|
||||
return;
|
||||
|
||||
this.disposed = true;
|
||||
active = [.. this.operations];
|
||||
batches = [.. this.batchCancellations.Values];
|
||||
}
|
||||
foreach (var operation in active)
|
||||
operation.Cancellation.Cancel();
|
||||
|
||||
foreach (var batch in batches)
|
||||
batch.Cancel();
|
||||
|
||||
// The semaphore may still be released by an operation unwinding after cancellation.
|
||||
}
|
||||
|
||||
/// <summary>Cancellation and runtime-job ownership for exactly one media operation.</summary>
|
||||
private sealed class MediaOperation : IDisposable
|
||||
{
|
||||
/// <summary>Creates operation state linked to a caller token.</summary>
|
||||
/// <param name="target">Optional visible media import target.</param>
|
||||
/// <param name="token">Caller cancellation token.</param>
|
||||
public MediaOperation(MediaImportTarget? target, CancellationToken token)
|
||||
{
|
||||
this.Target = target;
|
||||
this.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
/// <summary>Gets the operation-owned cancellation source.</summary>
|
||||
public CancellationTokenSource Cancellation { get; }
|
||||
|
||||
/// <summary>Gets or sets the Rust job after its POST response establishes ownership.</summary>
|
||||
public string? JobId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether this operation currently owns the serialized lane.</summary>
|
||||
public bool HasQueueLease { get; set; }
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
}
|
||||
69
app/MindWork AI Studio/Tools/Services/RustService.Media.cs
Normal file
69
app/MindWork AI Studio/Tools/Services/RustService.Media.cs
Normal file
@ -0,0 +1,69 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
public partial class RustService
|
||||
{
|
||||
/// <summary>Starts a Rust media normalization job.</summary>
|
||||
/// <param name="inputPath">Absolute source path.</param>
|
||||
/// <param name="outputPath">Absolute operation-owned output path.</param>
|
||||
/// <param name="token">Request cancellation token.</param>
|
||||
/// <returns>The opaque runtime job identifier.</returns>
|
||||
public async Task<string> StartMediaJobAsync(string inputPath, string outputPath, CancellationToken token = default)
|
||||
{
|
||||
using var response = await this.http.PostAsJsonAsync(
|
||||
"/media/jobs",
|
||||
new CreateMediaJobRequest(inputPath, outputPath),
|
||||
this.jsonRustSerializerOptions,
|
||||
token);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<CreateMediaJobResponse>(this.jsonRustSerializerOptions, token);
|
||||
return result?.JobId ?? throw new InvalidOperationException("The Rust runtime did not return a media job ID.");
|
||||
}
|
||||
|
||||
/// <summary>Streams replayed and live snapshots until the media job becomes terminal.</summary>
|
||||
/// <param name="jobId">Runtime job identifier.</param>
|
||||
/// <param name="token">Stream cancellation token.</param>
|
||||
/// <returns>Asynchronous media job snapshots.</returns>
|
||||
public async IAsyncEnumerable<MediaJobEvent> StreamMediaJobEventsAsync(string jobId, [EnumeratorCancellation] CancellationToken token = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/media/jobs/{Uri.EscapeDataString(jobId)}/events");
|
||||
using var response = await this.http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(token);
|
||||
if (line is null)
|
||||
yield break;
|
||||
|
||||
if (!line.StartsWith("data:", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var json = line["data:".Length..].Trim();
|
||||
var mediaEvent = JsonSerializer.Deserialize<MediaJobEvent>(json, this.jsonRustSerializerOptions);
|
||||
if (mediaEvent is not null)
|
||||
yield return mediaEvent;
|
||||
|
||||
if (mediaEvent?.Phase is MediaJobPhase.COMPLETED or MediaJobPhase.FAILED or MediaJobPhase.CANCELLED)
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Requests cooperative cancellation of a Rust media job.</summary>
|
||||
/// <param name="jobId">Runtime job identifier.</param>
|
||||
/// <param name="token">Request cancellation token.</param>
|
||||
public async Task CancelMediaJobAsync(string jobId, CancellationToken token = default)
|
||||
{
|
||||
using var response = await this.http.DeleteAsync($"/media/jobs/{Uri.EscapeDataString(jobId)}", token);
|
||||
if (response is { IsSuccessStatusCode: false, StatusCode: not System.Net.HttpStatusCode.NotFound })
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot startup service that removes transcript staging left by crashes or forced shutdowns.
|
||||
/// </summary>
|
||||
public sealed class TranscriptStagingCleanupService(ILogger<TranscriptStagingCleanupService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Waits for the data directory, performs one cleanup pass, and then exits.</summary>
|
||||
/// <param name="stoppingToken">Host shutdown token.</param>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (string.IsNullOrWhiteSpace(SettingsManager.DataDirectory) && !stoppingToken.IsCancellationRequested)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
var stagingRoot = Path.Combine(SettingsManager.DataDirectory!, "media-staging");
|
||||
if (!Directory.Exists(stagingRoot))
|
||||
{
|
||||
logger.LogInformation("Media transcript staging does not exist; startup cleanup has nothing to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
var directories = Directory.EnumerateDirectories(stagingRoot).ToArray();
|
||||
var files = Directory.EnumerateFiles(stagingRoot).ToArray();
|
||||
logger.LogInformation("Media transcript startup cleanup found {DirectoryCount} directories and {FileCount} loose files.", directories.Length, files.Length);
|
||||
|
||||
if (directories.Length == 0 && files.Length == 0)
|
||||
{
|
||||
logger.LogInformation("Media transcript staging is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
var deletedDirectories = 0;
|
||||
var deletedFiles = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
deletedDirectories++;
|
||||
logger.LogInformation("Removed orphaned media staging directory '{Directory}'.", directory);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failures++;
|
||||
logger.LogWarning(exception, "Could not remove orphaned media staging directory '{Directory}'.", directory);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
deletedFiles++;
|
||||
logger.LogInformation("Removed orphaned media staging file '{File}'.", file);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failures++;
|
||||
logger.LogWarning(exception, "Could not remove orphaned media staging file '{File}'.", file);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Media transcript startup cleanup removed {DirectoryCount} directories and {FileCount} files with {FailureCount} failures.",
|
||||
deletedDirectories,
|
||||
deletedFiles,
|
||||
failures);
|
||||
}
|
||||
}
|
||||
@ -754,6 +754,7 @@ public static class WorkspaceBehaviour
|
||||
|
||||
Directory.CreateDirectory(chatDirectory);
|
||||
|
||||
await FinalizeStagedTranscriptsAsync(chat, chatDirectory);
|
||||
var chatNamePath = Path.Join(chatDirectory, "name");
|
||||
await File.WriteAllTextAsync(chatNamePath, chat.Name);
|
||||
|
||||
@ -769,6 +770,225 @@ public static class WorkspaceBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a transcript atomically inside an already persisted chat.</summary>
|
||||
/// <param name="chat">Persisted chat that owns the transcript counter.</param>
|
||||
/// <param name="originalPath">Original media path.</param>
|
||||
/// <param name="transcript">Provider transcript.</param>
|
||||
/// <returns>The chat-owned managed attachment.</returns>
|
||||
public static async Task<ManagedTranscriptAttachment> CreateManagedTranscriptAsync(ChatThread chat, string originalPath, string transcript)
|
||||
{
|
||||
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(CreateManagedTranscriptAsync));
|
||||
if (!acquired)
|
||||
throw new IOException("The chat transcript directory is busy.");
|
||||
|
||||
try
|
||||
{
|
||||
var chatDirectory = GetChatDirectory(chat.WorkspaceId, chat.ChatId);
|
||||
if (!Directory.Exists(chatDirectory))
|
||||
throw new DirectoryNotFoundException($"The owning chat directory does not exist: '{chatDirectory}'.");
|
||||
|
||||
var transcriptDirectory = Path.Combine(chatDirectory, "attachments", "transcripts");
|
||||
Directory.CreateDirectory(transcriptDirectory);
|
||||
ReconcileTranscriptCounter(chat, transcriptDirectory);
|
||||
|
||||
var targetPath = NextTranscriptPath(chat, transcriptDirectory, Path.GetFileName(originalPath));
|
||||
return await ManagedTranscriptAttachment.CreateAtomicAsync(targetPath, Path.GetFileName(originalPath), transcript);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task MoveChatAsync(ChatThread chat, Guid targetWorkspaceId)
|
||||
{
|
||||
if (chat.WorkspaceId == targetWorkspaceId)
|
||||
return;
|
||||
|
||||
var sourceWorkspaceId = chat.WorkspaceId;
|
||||
var sourceDirectory = GetChatDirectory(sourceWorkspaceId, chat.ChatId);
|
||||
var targetDirectory = GetChatDirectory(targetWorkspaceId, chat.ChatId);
|
||||
var sourceSemaphore = GetChatSemaphore(sourceWorkspaceId, chat.ChatId);
|
||||
var targetSemaphore = GetChatSemaphore(targetWorkspaceId, chat.ChatId);
|
||||
// Always acquire both workspace/chat locks in canonical workspace-ID order. This prevents
|
||||
// opposing moves of the same chat from waiting on one another with reversed lock order.
|
||||
var orderedSemaphores = string.CompareOrdinal(sourceWorkspaceId.ToString("N"), targetWorkspaceId.ToString("N")) <= 0
|
||||
? new[] { sourceSemaphore, targetSemaphore }
|
||||
: new[] { targetSemaphore, sourceSemaphore };
|
||||
|
||||
await orderedSemaphores[0].WaitAsync();
|
||||
await orderedSemaphores[1].WaitAsync();
|
||||
|
||||
var moved = false;
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(sourceDirectory))
|
||||
throw new DirectoryNotFoundException($"The source chat directory does not exist: '{sourceDirectory}'.");
|
||||
|
||||
// Only the workspace parent is created here. Directory.Move requires the chat target
|
||||
// directory itself not to exist so an existing destination is never merged silently.
|
||||
var targetWorkspaceDirectory = Path.GetDirectoryName(targetDirectory)!;
|
||||
Directory.CreateDirectory(targetWorkspaceDirectory);
|
||||
if (Directory.Exists(targetDirectory))
|
||||
throw new IOException($"The target chat directory already exists: '{targetDirectory}'.");
|
||||
|
||||
Directory.Move(sourceDirectory, targetDirectory);
|
||||
moved = true;
|
||||
|
||||
UpdateAttachmentPathsAfterMove(chat, sourceDirectory, targetDirectory);
|
||||
chat.WorkspaceId = targetWorkspaceId;
|
||||
|
||||
await FinalizeStagedTranscriptsAsync(chat, targetDirectory);
|
||||
await StoreMovedChatFilesAsync(chat, targetDirectory);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (moved)
|
||||
{
|
||||
try
|
||||
{
|
||||
UpdateAttachmentPathsAfterMove(chat, targetDirectory, sourceDirectory);
|
||||
chat.WorkspaceId = sourceWorkspaceId;
|
||||
|
||||
if (Directory.Exists(targetDirectory) && !Directory.Exists(sourceDirectory))
|
||||
Directory.Move(targetDirectory, sourceDirectory);
|
||||
|
||||
if (Directory.Exists(sourceDirectory))
|
||||
await StoreMovedChatFilesAsync(chat, sourceDirectory);
|
||||
}
|
||||
catch (Exception rollbackError)
|
||||
{
|
||||
LOG.LogError(rollbackError, "Could not roll back moving chat '{ChatId}' to workspace '{WorkspaceId}'.", chat.ChatId, targetWorkspaceId);
|
||||
}
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
orderedSemaphores[1].Release();
|
||||
orderedSemaphores[0].Release();
|
||||
InvalidateWorkspaceTreeCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Atomically stores the name and thread after a directory move.</summary>
|
||||
private static async Task StoreMovedChatFilesAsync(ChatThread chat, string chatDirectory)
|
||||
{
|
||||
await File.WriteAllTextAsync(Path.Join(chatDirectory, "name"), chat.Name);
|
||||
var chatPath = Path.Join(chatDirectory, "thread.json");
|
||||
var temporaryPath = Path.Join(chatDirectory, $".thread-{Guid.NewGuid():N}.tmp");
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8);
|
||||
File.Move(temporaryPath, chatPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rewrites absolute attachment paths after moving the complete chat directory.</summary>
|
||||
private static void UpdateAttachmentPathsAfterMove(ChatThread chat, string sourceDirectory, string targetDirectory)
|
||||
{
|
||||
var sourcePrefix = sourceDirectory.EndsWith(Path.DirectorySeparatorChar)
|
||||
? sourceDirectory
|
||||
: sourceDirectory + Path.DirectorySeparatorChar;
|
||||
|
||||
var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
foreach (var content in chat.Blocks.Select(block => block.Content).OfType<ContentText>())
|
||||
{
|
||||
for (var index = 0; index < content.FileAttachments.Count; index++)
|
||||
{
|
||||
var attachment = content.FileAttachments[index];
|
||||
if (!Path.GetFullPath(attachment.FilePath).StartsWith(sourcePrefix, pathComparison))
|
||||
continue;
|
||||
|
||||
var relativePath = Path.GetRelativePath(sourceDirectory, attachment.FilePath);
|
||||
var movedPath = Path.Combine(targetDirectory, relativePath);
|
||||
|
||||
content.FileAttachments[index] = attachment switch
|
||||
{
|
||||
ManagedTranscriptAttachment managed => managed with { FilePath = movedPath },
|
||||
FileAttachmentImage image => image with { FilePath = movedPath },
|
||||
_ => attachment with { FilePath = movedPath },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task FinalizeStagedTranscriptsAsync(ChatThread chat, string chatDirectory)
|
||||
{
|
||||
var transcriptDirectory = Path.Combine(chatDirectory, "attachments", "transcripts");
|
||||
ReconcileTranscriptCounter(chat, transcriptDirectory);
|
||||
foreach (var content in chat.Blocks.Select(block => block.Content).OfType<ContentText>())
|
||||
{
|
||||
for (var index = 0; index < content.FileAttachments.Count; index++)
|
||||
{
|
||||
if (content.FileAttachments[index] is not ManagedTranscriptAttachment { IsStaged: true } staged
|
||||
|| !File.Exists(staged.FilePath))
|
||||
continue;
|
||||
|
||||
Directory.CreateDirectory(transcriptDirectory);
|
||||
var targetPath = NextTranscriptPath(chat, transcriptDirectory, staged.OriginalFileName);
|
||||
|
||||
File.Move(staged.FilePath, targetPath);
|
||||
var sourceDirectory = Path.GetDirectoryName(staged.FilePath);
|
||||
if (sourceDirectory is not null && Directory.Exists(sourceDirectory) && !Directory.EnumerateFileSystemEntries(sourceDirectory).Any())
|
||||
Directory.Delete(sourceDirectory);
|
||||
|
||||
content.FileAttachments[index] = new ManagedTranscriptAttachment(
|
||||
Path.GetFileName(targetPath),
|
||||
targetPath,
|
||||
new FileInfo(targetPath).Length,
|
||||
staged.OriginalFileName,
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Raises the persisted counter to the highest transcript suffix found chat-wide.</summary>
|
||||
private static void ReconcileTranscriptCounter(ChatThread chat, string transcriptDirectory)
|
||||
{
|
||||
if (!Directory.Exists(transcriptDirectory))
|
||||
return;
|
||||
|
||||
ulong highest = 0;
|
||||
foreach (var path in Directory.EnumerateFiles(transcriptDirectory, "*-transcript-*.md", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
var marker = name.LastIndexOf("-transcript-", StringComparison.Ordinal);
|
||||
|
||||
if (marker >= 0 && ulong.TryParse(name[(marker + "-transcript-".Length)..], out var number))
|
||||
highest = Math.Max(highest, number);
|
||||
}
|
||||
|
||||
chat.LastMediaTranscriptNumber = Math.Max(chat.LastMediaTranscriptNumber, highest);
|
||||
}
|
||||
|
||||
/// <summary>Allocates the next globally monotonic transcript path for one chat.</summary>
|
||||
private static string NextTranscriptPath(ChatThread chat, string transcriptDirectory, string originalFileName)
|
||||
{
|
||||
string targetPath;
|
||||
do
|
||||
{
|
||||
chat.LastMediaTranscriptNumber++;
|
||||
var stem = ManagedTranscriptAttachment.NormalizeOriginalStem(originalFileName);
|
||||
targetPath = Path.Combine(transcriptDirectory, $"{stem}-transcript-{chat.LastMediaTranscriptNumber:D4}.md");
|
||||
} while (File.Exists(targetPath));
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/// <summary>Returns the canonical storage directory for a chat identity.</summary>
|
||||
private static string GetChatDirectory(Guid workspaceId, Guid chatId) => workspaceId == Guid.Empty
|
||||
? Path.Join(SettingsManager.DataDirectory, "tempChats", chatId.ToString())
|
||||
: Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
|
||||
|
||||
public static async Task<ChatThread?> LoadChatAsync(LoadChat loadChat)
|
||||
{
|
||||
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChatAsync));
|
||||
|
||||
61
app/MindWork AI Studio/wwwroot/audio-recorder-worklet.js
Normal file
61
app/MindWork AI Studio/wwwroot/audio-recorder-worklet.js
Normal file
@ -0,0 +1,61 @@
|
||||
class PCMRecorderProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super();
|
||||
|
||||
const chunkDurationSeconds = options.processorOptions?.chunkDurationSeconds || 3;
|
||||
this.chunkSamples = Math.max(128, Math.round(sampleRate * chunkDurationSeconds));
|
||||
this.samples = new Int16Array(this.chunkSamples);
|
||||
this.numSamples = 0;
|
||||
|
||||
this.port.onmessage = event => {
|
||||
if (event.data?.type === 'flush') {
|
||||
this.flush();
|
||||
this.port.postMessage({ type: 'flushed' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
process(inputs) {
|
||||
const channels = inputs[0];
|
||||
if (!channels || channels.length === 0)
|
||||
return true;
|
||||
|
||||
const numFrames = channels[0].length;
|
||||
for (let frame = 0; frame < numFrames; frame++) {
|
||||
let monoSample = 0;
|
||||
for (const channel of channels) {
|
||||
monoSample += channel[frame] || 0;
|
||||
}
|
||||
|
||||
monoSample = Math.max(-1, Math.min(1, monoSample / channels.length));
|
||||
this.samples[this.numSamples++] = monoSample < 0
|
||||
? Math.round(monoSample * 0x8000)
|
||||
: Math.round(monoSample * 0x7fff);
|
||||
|
||||
if (this.numSamples === this.chunkSamples)
|
||||
this.flush();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
flush() {
|
||||
if (this.numSamples === 0)
|
||||
return;
|
||||
|
||||
const buffer = new ArrayBuffer(this.numSamples * 2);
|
||||
const view = new DataView(buffer);
|
||||
for (let index = 0; index < this.numSamples; index++) {
|
||||
view.setInt16(index * 2, this.samples[index], true);
|
||||
}
|
||||
|
||||
this.port.postMessage({
|
||||
type: 'chunk',
|
||||
buffer: buffer,
|
||||
sampleCount: this.numSamples,
|
||||
}, [buffer]);
|
||||
this.numSamples = 0;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('pcm-recorder-processor', PCMRecorderProcessor);
|
||||
@ -180,22 +180,208 @@ window.playSound = async function(soundPath) {
|
||||
}
|
||||
};
|
||||
|
||||
let mediaRecorder;
|
||||
let actualRecordingMimeType;
|
||||
let changedMimeType = false;
|
||||
let pendingChunkUploads = 0;
|
||||
let chunkUploadPromise = Promise.resolve();
|
||||
let chunkUploadError = null;
|
||||
let recordingError = null;
|
||||
let captureAudioContext = null;
|
||||
let captureSourceNode = null;
|
||||
let captureWorkletNode = null;
|
||||
let captureSilentGainNode = null;
|
||||
let pcmFlushResolve = null;
|
||||
let pcmSamplesReceived = 0;
|
||||
|
||||
// Store the media stream so we can close the microphone later:
|
||||
let activeMediaStream = null;
|
||||
|
||||
// Delay in milliseconds to wait after getUserMedia() for Bluetooth profile switch (A2DP → HFP):
|
||||
const BLUETOOTH_PROFILE_SWITCH_DELAY_MS = 1_600;
|
||||
const PCM_SAMPLE_RATE = 48_000;
|
||||
const PCM_CHUNK_DURATION_SECONDS = 3;
|
||||
const PCM_FLUSH_TIMEOUT_MS = 5_000;
|
||||
|
||||
function queueAudioChunkUpload(upload) {
|
||||
pendingChunkUploads++;
|
||||
chunkUploadPromise = chunkUploadPromise
|
||||
.then(upload)
|
||||
.catch(error => {
|
||||
chunkUploadError ??= error;
|
||||
console.error('Error sending audio chunk to .NET:', error);
|
||||
})
|
||||
.finally(() => pendingChunkUploads--);
|
||||
}
|
||||
|
||||
async function waitForAudioChunkUploads() {
|
||||
let observedUploadPromise;
|
||||
do {
|
||||
observedUploadPromise = chunkUploadPromise;
|
||||
await observedUploadPromise;
|
||||
} while (pendingChunkUploads > 0 || observedUploadPromise !== chunkUploadPromise);
|
||||
}
|
||||
|
||||
function createPcmWavHeader(sampleRate) {
|
||||
const buffer = new ArrayBuffer(44);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
const writeAscii = (offset, value) => {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
};
|
||||
|
||||
writeAscii(0, 'RIFF');
|
||||
view.setUint32(4, 0, true); // Finalized by .NET after all PCM data was written.
|
||||
writeAscii(8, 'WAVE');
|
||||
writeAscii(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true); // PCM
|
||||
view.setUint16(22, 1, true); // Mono
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeAscii(36, 'data');
|
||||
view.setUint32(40, 0, true); // Finalized by .NET after all PCM data was written.
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function observeAudioTrack(track) {
|
||||
console.log('Audio recording - microphone track state:', {
|
||||
label: track.label,
|
||||
enabled: track.enabled,
|
||||
muted: track.muted,
|
||||
readyState: track.readyState,
|
||||
settings: typeof track.getSettings === 'function' ? track.getSettings() : null,
|
||||
});
|
||||
|
||||
track.addEventListener('mute', () => console.warn('Audio recording - microphone track was muted.'));
|
||||
track.addEventListener('unmute', () => console.log('Audio recording - microphone track was unmuted.'));
|
||||
track.addEventListener('ended', () => console.warn('Audio recording - microphone track ended.'));
|
||||
}
|
||||
|
||||
async function startPcmRecording(stream, dotnetRef) {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass || typeof AudioWorkletNode === 'undefined') {
|
||||
throw new Error('PCM audio capture is unavailable because AudioWorklet is not supported.');
|
||||
}
|
||||
|
||||
try {
|
||||
captureAudioContext = new AudioContextClass({
|
||||
latencyHint: 'interactive',
|
||||
sampleRate: PCM_SAMPLE_RATE,
|
||||
});
|
||||
|
||||
if (!captureAudioContext.audioWorklet) {
|
||||
throw new Error('PCM audio capture is unavailable because AudioWorklet is not supported.');
|
||||
}
|
||||
|
||||
await captureAudioContext.audioWorklet.addModule('/audio-recorder-worklet.js');
|
||||
|
||||
const actualSampleRate = captureAudioContext.sampleRate;
|
||||
console.log(`Audio recording - starting PCM/WAV capture at ${actualSampleRate} Hz mono.`);
|
||||
|
||||
if (captureAudioContext.state === 'suspended') {
|
||||
await captureAudioContext.resume();
|
||||
}
|
||||
|
||||
captureSourceNode = captureAudioContext.createMediaStreamSource(stream);
|
||||
captureWorkletNode = new AudioWorkletNode(captureAudioContext, 'pcm-recorder-processor', {
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [1],
|
||||
processorOptions: {
|
||||
chunkDurationSeconds: PCM_CHUNK_DURATION_SECONDS,
|
||||
},
|
||||
});
|
||||
|
||||
captureSilentGainNode = captureAudioContext.createGain();
|
||||
captureSilentGainNode.gain.value = 0;
|
||||
|
||||
captureWorkletNode.port.onmessage = event => {
|
||||
if (event.data?.type === 'chunk') {
|
||||
const chunkBytes = new Uint8Array(event.data.buffer);
|
||||
pcmSamplesReceived += event.data.sampleCount;
|
||||
console.debug(`Audio recording - received ${event.data.sampleCount} PCM samples from AudioWorklet.`);
|
||||
queueAudioChunkUpload(() => dotnetRef.invokeMethodAsync('OnAudioChunkReceived', chunkBytes));
|
||||
} else if (event.data?.type === 'flushed') {
|
||||
pcmFlushResolve?.();
|
||||
pcmFlushResolve = null;
|
||||
}
|
||||
};
|
||||
|
||||
captureWorkletNode.onprocessorerror = event => {
|
||||
recordingError ??= event.error || new Error('The PCM audio processor failed.');
|
||||
console.error('Audio recording - AudioWorklet error:', recordingError);
|
||||
};
|
||||
|
||||
captureSourceNode.connect(captureWorkletNode);
|
||||
captureWorkletNode.connect(captureSilentGainNode);
|
||||
captureSilentGainNode.connect(captureAudioContext.destination);
|
||||
queueAudioChunkUpload(() => dotnetRef.invokeMethodAsync('OnAudioChunkReceived', createPcmWavHeader(actualSampleRate)));
|
||||
} catch (error) {
|
||||
await cleanupPcmCapture();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPcmRecording() {
|
||||
if (!captureWorkletNode) {
|
||||
throw new Error('The PCM audio processor is unavailable.');
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
pcmFlushResolve = null;
|
||||
reject(new Error('Timed out while flushing PCM audio data.'));
|
||||
}, PCM_FLUSH_TIMEOUT_MS);
|
||||
|
||||
pcmFlushResolve = () => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
captureWorkletNode.port.postMessage({ type: 'flush' });
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanupPcmCapture() {
|
||||
captureSourceNode?.disconnect();
|
||||
captureWorkletNode?.disconnect();
|
||||
captureSilentGainNode?.disconnect();
|
||||
captureSourceNode = null;
|
||||
captureWorkletNode = null;
|
||||
captureSilentGainNode = null;
|
||||
pcmFlushResolve = null;
|
||||
|
||||
if (captureAudioContext && captureAudioContext.state !== 'closed') {
|
||||
await captureAudioContext.close();
|
||||
}
|
||||
captureAudioContext = null;
|
||||
}
|
||||
|
||||
window.audioRecorder = {
|
||||
start: async function (dotnetRef, desiredMimeTypes = []) {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
start: async function (dotnetRef) {
|
||||
// Reset the upload and recorder state:
|
||||
pendingChunkUploads = 0;
|
||||
chunkUploadPromise = Promise.resolve();
|
||||
chunkUploadError = null;
|
||||
recordingError = null;
|
||||
pcmSamplesReceived = 0;
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: { ideal: PCM_SAMPLE_RATE },
|
||||
channelCount: { ideal: 1 },
|
||||
},
|
||||
});
|
||||
activeMediaStream = stream;
|
||||
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) {
|
||||
throw new Error('The microphone stream does not contain an audio track.');
|
||||
}
|
||||
observeAudioTrack(audioTracks[0]);
|
||||
|
||||
// Wait for Bluetooth headsets to complete the profile switch from A2DP to HFP.
|
||||
// This prevents the first sound from being cut off during the switch:
|
||||
console.log('Audio recording - waiting for Bluetooth profile switch...');
|
||||
@ -204,91 +390,29 @@ window.audioRecorder = {
|
||||
// Play start recording sound effect:
|
||||
await window.playSound('/sounds/start_recording.ogg');
|
||||
|
||||
// When only one mime type is provided as a string, convert it to an array:
|
||||
if (typeof desiredMimeTypes === 'string') {
|
||||
desiredMimeTypes = [desiredMimeTypes];
|
||||
}
|
||||
|
||||
// Log sent mime types for debugging:
|
||||
console.log('Audio recording - requested mime types: ', desiredMimeTypes);
|
||||
|
||||
let mimeTypes = desiredMimeTypes.filter(type => typeof type === 'string' && type.trim() !== '');
|
||||
|
||||
// Next, we have to ensure that we have some default mime types to check as well.
|
||||
// In case the provided list does not contain these, we append them:
|
||||
// Use provided mime types or fallback to a default list:
|
||||
const defaultMimeTypes = [
|
||||
'audio/webm',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/mpeg',
|
||||
''// Fallback to browser default
|
||||
];
|
||||
|
||||
defaultMimeTypes.forEach(type => {
|
||||
if (!mimeTypes.includes(type)) {
|
||||
mimeTypes.push(type);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Audio recording - final mime types to check (included defaults): ', mimeTypes);
|
||||
|
||||
// Find the first supported mime type:
|
||||
actualRecordingMimeType = mimeTypes.find(type =>
|
||||
type === '' || MediaRecorder.isTypeSupported(type)
|
||||
) || '';
|
||||
|
||||
console.log('Audio recording - the browser selected the following mime type for recording: ', actualRecordingMimeType);
|
||||
const options = actualRecordingMimeType ? { mimeType: actualRecordingMimeType } : {};
|
||||
mediaRecorder = new MediaRecorder(stream, options);
|
||||
|
||||
// In case the browser changed the mime type:
|
||||
actualRecordingMimeType = mediaRecorder.mimeType;
|
||||
console.log('Audio recording - actual mime type used by the browser: ', actualRecordingMimeType);
|
||||
|
||||
// Check the list of desired mime types against the actual one:
|
||||
if (!desiredMimeTypes.includes(actualRecordingMimeType)) {
|
||||
changedMimeType = true;
|
||||
console.warn(`Audio recording - requested mime types ('${desiredMimeTypes.join(', ')}') do not include the actual mime type used by the browser ('${actualRecordingMimeType}').`);
|
||||
} else {
|
||||
changedMimeType = false;
|
||||
}
|
||||
|
||||
// Reset the pending uploads counter:
|
||||
pendingChunkUploads = 0;
|
||||
|
||||
// Stream each chunk directly to .NET as it becomes available:
|
||||
mediaRecorder.ondataavailable = async (event) => {
|
||||
if (event.data.size > 0) {
|
||||
pendingChunkUploads++;
|
||||
try {
|
||||
const arrayBuffer = await event.data.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array);
|
||||
} catch (error) {
|
||||
console.error('Error sending audio chunk to .NET:', error);
|
||||
} finally {
|
||||
pendingChunkUploads--;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start(3000); // read the recorded data in 3-second chunks
|
||||
return actualRecordingMimeType;
|
||||
await startPcmRecording(stream, dotnetRef);
|
||||
},
|
||||
|
||||
stop: async function () {
|
||||
return new Promise((resolve) => {
|
||||
let stopError = null;
|
||||
|
||||
// Add an event listener to handle the stop event:
|
||||
mediaRecorder.onstop = async () => {
|
||||
|
||||
// Wait for all pending chunk uploads to complete before finalizing:
|
||||
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
||||
while (pendingChunkUploads > 0) {
|
||||
await new Promise(r => setTimeout(r, 10)); // wait 10 ms before checking again
|
||||
try {
|
||||
try {
|
||||
await flushPcmRecording();
|
||||
} finally {
|
||||
await cleanupPcmCapture();
|
||||
}
|
||||
|
||||
console.log(`Audio recording - PCM/WAV capture produced ${pcmSamplesReceived} samples.`);
|
||||
if (pcmSamplesReceived === 0) {
|
||||
throw new Error('The microphone did not produce any PCM audio samples.');
|
||||
}
|
||||
} catch (error) {
|
||||
stopError = error;
|
||||
}
|
||||
|
||||
console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`);
|
||||
await waitForAudioChunkUploads();
|
||||
console.log('Audio recording - all chunks uploaded, finalizing.');
|
||||
|
||||
// Play stop recording sound effect:
|
||||
@ -303,22 +427,18 @@ window.audioRecorder = {
|
||||
// Call window.audioRecorder.releaseMicrophone() after the last sound has played.
|
||||
//
|
||||
|
||||
// No need to process data here anymore, just signal completion:
|
||||
resolve({
|
||||
mimeType: actualRecordingMimeType,
|
||||
changedMimeType: changedMimeType,
|
||||
});
|
||||
};
|
||||
|
||||
// Finally, stop the recording (which will actually trigger the onstop event):
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
const error = stopError || recordingError || chunkUploadError;
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Release the microphone after all sounds have been played.
|
||||
// This should be called after the transcription_done sound to allow
|
||||
// Bluetooth headsets to switch back to A2DP profile without interrupting audio:
|
||||
releaseMicrophone: function () {
|
||||
releaseMicrophone: async function () {
|
||||
await cleanupPcmCapture();
|
||||
|
||||
if (activeMediaStream) {
|
||||
console.log('Audio recording - releasing microphone (Bluetooth will switch back to A2DP)');
|
||||
activeMediaStream.getTracks().forEach(track => track.stop());
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
# v26.7.3, build 245 (2026-07-xx xx:xx UTC)
|
||||
- Added support for OpenAI GPT-5.6 Sol, Terra, and Luna; Anthropic Claude Fable 5 and Mythos 5; and Google Gemini 3 Flash, Gemini 3.1 Flash-Lite, Gemini 3.1 Pro, and Gemini 3.5 Flash.
|
||||
- Added audio and video transcription for chats and assistants. AI Studio now prepares supported media locally, sends only normalized audio to the configured transcription provider, and attaches the resulting transcript instead of the original media.
|
||||
- Improved the "My Tasks Assistant": you can now provide one or more documents in addition to text or use documents alone when asking to identify tasks.
|
||||
- Improved update guidance for Flatpak installations and added an enterprise option that lets organizations manage updates entirely through their IT department.
|
||||
- Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep.
|
||||
- Fixed enterprise configuration plugins from Windows-created ZIP files not loading correctly on Linux when the ZIP contained plugin files inside a folder.
|
||||
- Fixed voice recording not starting on Linux.
|
||||
- Fixed an issue that could leave AI Studio unresponsive after waking the computer from sleep. Yes, we know this was an annoying bug, and we apologize for the inconvenience.
|
||||
- Fixed enterprise configuration plugins from Windows-created ZIP files may not load correctly on Linux when the ZIP contained plugin files inside a folder.
|
||||
- Fixed voice recording and transcription on Linux.
|
||||
- Fixed being able to switch document analysis policies while an analysis or media transcription was still in progress.
|
||||
- Upgraded Rust to v1.97.0.
|
||||
- Upgraded Tauri to v2.11.5.
|
||||
- Upgraded common dependencies.
|
||||
@ -3,6 +3,7 @@
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
|
||||
391
runtime/Cargo.lock
generated
391
runtime/Cargo.lock
generated
@ -490,6 +490,43 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audio-core"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24"
|
||||
|
||||
[[package]]
|
||||
name = "audioadapter"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75c3943c6c7279bb25a449a8d1727480730ab2efd7b6fd5d6ca51927096e6e4"
|
||||
dependencies = [
|
||||
"audio-core",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audioadapter-buffers"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ece3390b6eb40379094843a1da5aaccc34bc0d85a8cbf68d09fe092fee6de29e"
|
||||
dependencies = [
|
||||
"audioadapter",
|
||||
"audioadapter-sample",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "audioadapter-sample"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1592f90413568e259413c21a41a3d571feb1774255c209e7966d98f9db708c90"
|
||||
dependencies = [
|
||||
"audio-core",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.3.0"
|
||||
@ -1923,6 +1960,35 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ebml-iterable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5173ac3752f08b526a6991509615e1a345b221ec3c58c7633433e8c9582312"
|
||||
dependencies = [
|
||||
"ebml-iterable-specification",
|
||||
"ebml-iterable-specification-derive",
|
||||
"futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ebml-iterable-specification"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f56467af159a98735d44231f53eaa505e919e6003266f103b99649a93f106784"
|
||||
|
||||
[[package]]
|
||||
name = "ebml-iterable-specification-derive"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b066b81018300fdce40f71c4db355a102699324af96fad28f25ab1b5f87de066"
|
||||
dependencies = [
|
||||
"ebml-iterable-specification",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecow"
|
||||
version = "0.3.0"
|
||||
@ -2112,6 +2178,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fast-float2"
|
||||
version = "0.2.3"
|
||||
@ -4023,11 +4095,14 @@ dependencies = [
|
||||
"rand 0.10.2",
|
||||
"rand_chacha 0.10.0",
|
||||
"rcgen",
|
||||
"ropus",
|
||||
"rubato",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"strum_macros",
|
||||
"symphonia",
|
||||
"sys-locale",
|
||||
"sysinfo 0.39.6",
|
||||
"tauri",
|
||||
@ -4043,6 +4118,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"webkit2gtk",
|
||||
"webm-iterable",
|
||||
"whoami",
|
||||
"windows-native-keyring-store",
|
||||
"windows-registry",
|
||||
@ -5126,6 +5202,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.3.1"
|
||||
@ -5561,6 +5646,15 @@ dependencies = [
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "realfft"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
|
||||
dependencies = [
|
||||
"rustfft",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@ -5613,6 +5707,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
@ -5740,6 +5840,16 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839"
|
||||
|
||||
[[package]]
|
||||
name = "ropus"
|
||||
version = "0.12.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80804dadbfa2851c95fe45ff9ae8f4328d6371fdc0d51b14740d49a8b41d3758"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
@ -5757,6 +5867,22 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rubato"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f57c655d11e929f05a8663b323ff553f8d9773be05dfdc087795955bedeb8d92"
|
||||
dependencies = [
|
||||
"audioadapter",
|
||||
"audioadapter-buffers",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"realfft",
|
||||
"visibility",
|
||||
"windowfunctions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.27"
|
||||
@ -5778,6 +5904,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
@ -5902,6 +6042,15 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -6532,6 +6681,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
@ -6606,6 +6761,192 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-alac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-caf",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-mkv",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-alac"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-common"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"num-complex",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-caf"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cde3ca76633d3400ab57195456c09f8a58d775ff5452329f3f212b6efc8622f5"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-mkv"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb17713e134f5ad316c2690fa3104590ccc85842cdbcf82c3cd1a845cb08aa74"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-common",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"regex-lite",
|
||||
"smallvec",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@ -7356,6 +7697,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -7603,6 +7945,16 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.24.1"
|
||||
@ -7896,6 +8248,17 @@ version = "0.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
|
||||
|
||||
[[package]]
|
||||
name = "visibility"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
@ -8174,6 +8537,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webm-iterable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd9fbf173b4b38f2f8bbb0082a0d4cb21f263a70811f5fccb1663c421c66d9f9"
|
||||
dependencies = [
|
||||
"ebml-iterable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.4"
|
||||
@ -8248,6 +8620,16 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.7.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@ -8294,6 +8676,15 @@ dependencies = [
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windowfunctions"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
|
||||
@ -20,7 +20,7 @@ serde_json = "1.0.150"
|
||||
keyring-core = "1.0.0"
|
||||
arboard = "3.6.1"
|
||||
tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "macros", "process"] }
|
||||
tokio-stream = "0.1.18"
|
||||
tokio-stream = { version = "0.1.18", features = ["sync"] }
|
||||
futures = "0.3.32"
|
||||
async-stream = "0.3.6"
|
||||
flexi_logger = "0.31.9"
|
||||
@ -39,6 +39,10 @@ hmac = "0.13.0"
|
||||
sha2 = "0.11.0"
|
||||
rcgen = { version = "0.14.8", features = ["pem"] }
|
||||
file-format = "0.29.0"
|
||||
symphonia = { version = "0.6", default-features = false, features = ["aac", "aiff", "alac", "caf", "flac", "isomp4", "mkv", "mp1", "mp2", "mp3", "ogg", "pcm", "vorbis", "wav"] }
|
||||
ropus = "=0.12.18"
|
||||
rubato = { version = "4", default-features = false, features = ["fft_resampler"] }
|
||||
webm-iterable = "0.6.4"
|
||||
calamine = "0.36.0"
|
||||
pdfium-render = "0.9.1"
|
||||
sys-locale = "0.3.2"
|
||||
@ -77,3 +81,20 @@ tauri-plugin-updater = "2.10.1"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
# Media normalization is CPU-heavy even when the application itself is built for development.
|
||||
# Keep release settings untouched while optimizing the hot decoder/resampler/container crates.
|
||||
[profile.dev.package.symphonia-core]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.symphonia-format-mkv]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.ropus]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.rubato]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.webm-iterable]
|
||||
opt-level = 3
|
||||
|
||||
162
runtime/resources/notices/THIRD_PARTY_MEDIA_NOTICES.md
Normal file
162
runtime/resources/notices/THIRD_PARTY_MEDIA_NOTICES.md
Normal file
@ -0,0 +1,162 @@
|
||||
# Media pipeline third-party notices
|
||||
|
||||
These notices are bundled offline with MindWork AI Studio.
|
||||
|
||||
## Symphonia 0.6.0
|
||||
|
||||
Copyright (c) 2019-2026 The Project Symphonia Developers.
|
||||
|
||||
MindWork AI Studio uses the unmodified Symphonia 0.6.0 crates. The exact corresponding source is:
|
||||
|
||||
- https://github.com/pdeljanov/Symphonia/tree/v0.6.0
|
||||
- https://crates.io/api/v1/crates/symphonia/0.6.0/download
|
||||
|
||||
If a future AI Studio release modifies MPL-covered Symphonia files, those modifications must be identified and made available separately under MPL-2.0. No such modifications are present in this release.
|
||||
|
||||
Mozilla Public License Version 2.0
|
||||
|
||||
1. Definitions
|
||||
|
||||
1.1. “Contributor” means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software.
|
||||
|
||||
1.2. “Contributor Version” means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution.
|
||||
|
||||
1.3. “Contribution” means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. “Covered Software” means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof.
|
||||
|
||||
1.5. “Incompatible With Secondary Licenses” means that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License.
|
||||
|
||||
1.6. “Executable Form” means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. “Larger Work” means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. “License” means this document.
|
||||
|
||||
1.9. “Licensable” means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License.
|
||||
|
||||
1.10. “Modifications” means any of the following: any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or any new file in Source Code Form that contains any Covered Software.
|
||||
|
||||
1.11. “Patent Claims” of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version.
|
||||
|
||||
1.12. “Secondary License” means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses.
|
||||
|
||||
1.13. “Source Code Form” means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. “You” means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. “Control” means ownership of more than fifty percent of the outstanding shares or beneficial ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
|
||||
2.1. Grants. Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date. The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope. No license is granted in the trademarks, service marks, or logos of any Contributor. Except as otherwise provided in this License, no Contributor grants additional rights by implication, estoppel, or otherwise.
|
||||
|
||||
2.4. Subsequent Licenses. No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License or the terms of a Secondary License.
|
||||
|
||||
2.5. Representation. Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use. This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents.
|
||||
|
||||
2.7. Conditions. Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
|
||||
3.1. Distribution of Source Form. All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form.
|
||||
|
||||
3.2. Distribution of Executable Form. If You distribute Covered Software in Executable Form then such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work. You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s).
|
||||
|
||||
3.4. Notices. You may not remove or alter the substance of any license notices contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. You must include a copy of this License with every copy of the Covered Software You distribute. You may add additional accurate notices of copyright ownership.
|
||||
|
||||
3.5. Application of Additional Terms. You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must comply with the terms of this License to the maximum extent possible and describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License.
|
||||
|
||||
5. Termination
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent infringement claim alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 will terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination.
|
||||
|
||||
6. Disclaimer of Warranty
|
||||
|
||||
Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You.
|
||||
|
||||
7. Limitation of Liability
|
||||
|
||||
Under no circumstances and under no legal theory, whether tort, contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages.
|
||||
|
||||
8. Litigation
|
||||
|
||||
Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
|
||||
This License represents the complete agreement concerning the subject matter hereof. If any provision is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
|
||||
10.1. New Versions. Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License.
|
||||
|
||||
10.2. Effect of New Versions. You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward.
|
||||
|
||||
10.3. Modified Versions. If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses. If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
|
||||
Exhibit B - “Incompatible With Secondary Licenses” Notice
|
||||
|
||||
This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0.
|
||||
|
||||
## Ropus 0.12.18
|
||||
|
||||
Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo, Mozilla, Amazon
|
||||
|
||||
Copyright (c) 2026 Martin Davidson (Rust port additions)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
- Neither the name of Internet Society, IETF or IETF Trust, nor the names of specific contributors, may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Opus is subject to the royalty-free patent licenses specified at:
|
||||
|
||||
- Xiph.Org Foundation: https://datatracker.ietf.org/ipr/1524/
|
||||
- Microsoft Corporation: https://datatracker.ietf.org/ipr/1914/
|
||||
- Broadcom Corporation: https://datatracker.ietf.org/ipr/1526/
|
||||
|
||||
## Rubato 4.0.0 (MIT option)
|
||||
|
||||
Copyright (c) 2020 Henrik Enquist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## webm-iterable 0.6.4
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Austin Blake
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@ -11,6 +11,7 @@ pub mod runtime_api;
|
||||
pub mod runtime_certificate;
|
||||
pub mod file_data;
|
||||
pub mod metadata;
|
||||
pub mod media;
|
||||
pub mod pdfium;
|
||||
pub mod pandoc;
|
||||
pub mod qdrant_edge_database;
|
||||
|
||||
@ -43,6 +43,7 @@ pub fn init_logging() {
|
||||
log_config.push_str("tower_http=info, ");
|
||||
log_config.push_str("rustls=info, ");
|
||||
log_config.push_str("tokio_rustls=info, ");
|
||||
log_config.push_str("symphonia_format_mkv=info, ");
|
||||
log_config.push_str("reqwest=info");
|
||||
|
||||
// Configure the initial filename. On Unix systems, the file should start
|
||||
|
||||
1971
runtime/src/media.rs
Normal file
1971
runtime/src/media.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
use log::info;
|
||||
use once_cell::sync::Lazy;
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use std::net::SocketAddr;
|
||||
@ -59,6 +59,9 @@ pub fn start_runtime_api() {
|
||||
.route("/system/enterprise/config/encryption_secret", get(crate::environment::read_enterprise_env_config_encryption_secret))
|
||||
.route("/system/enterprise/configs", get(crate::environment::read_enterprise_configs))
|
||||
.route("/retrieval/fs/extract", get(crate::file_data::extract_data))
|
||||
.route("/media/jobs", post(crate::media::create_job))
|
||||
.route("/media/jobs/{id}/events", get(crate::media::get_job_events))
|
||||
.route("/media/jobs/{id}", delete(crate::media::cancel_job))
|
||||
.route("/log/paths", get(crate::log::get_log_paths))
|
||||
.route("/log/event", post(crate::log::log_event))
|
||||
.route("/shortcuts/register", post(crate::app_window::register_shortcut))
|
||||
|
||||
@ -27,7 +27,8 @@
|
||||
"../app/MindWork AI Studio/bin/dist/mindworkAIStudioServer"
|
||||
],
|
||||
"resources": [
|
||||
"resources/libraries/*"
|
||||
"resources/libraries/*",
|
||||
"resources/notices/*"
|
||||
],
|
||||
"macOS": {
|
||||
"exceptionDomain": "localhost"
|
||||
|
||||
BIN
runtime/tests/fixtures/media/audio-only.webm
vendored
Normal file
BIN
runtime/tests/fixtures/media/audio-only.webm
vendored
Normal file
Binary file not shown.
1
runtime/tests/fixtures/media/damaged.bin
vendored
Normal file
1
runtime/tests/fixtures/media/damaged.bin
vendored
Normal file
@ -0,0 +1 @@
|
||||
not a valid media container
|
||||
BIN
runtime/tests/fixtures/media/no-audio.webm
vendored
Normal file
BIN
runtime/tests/fixtures/media/no-audio.webm
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.aiff
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.aiff
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.caf
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.caf
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.flac
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.flac
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.m4a
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.m4a
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.mkv
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.mkv
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.mov
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.mov
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.mp3
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.mp3
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.mp4
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.mp4
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.ogg
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.ogg
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/sample.wav
vendored
Normal file
BIN
runtime/tests/fixtures/media/sample.wav
vendored
Normal file
Binary file not shown.
4
runtime/tests/fixtures/media/subtitle.vtt
vendored
Normal file
4
runtime/tests/fixtures/media/subtitle.vtt
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:00.100
|
||||
fixture
|
||||
BIN
runtime/tests/fixtures/media/subtitle.webm
vendored
Normal file
BIN
runtime/tests/fixtures/media/subtitle.webm
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/unknown-codec.mkv
vendored
Normal file
BIN
runtime/tests/fixtures/media/unknown-codec.mkv
vendored
Normal file
Binary file not shown.
BIN
runtime/tests/fixtures/media/video.webm
vendored
Normal file
BIN
runtime/tests/fixtures/media/video.webm
vendored
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user