mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 19:32:10 +00:00
Rework after first review
This commit is contained in:
parent
0c02cf427d
commit
8aaa2dc40c
@ -157,6 +157,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))
|
||||
@ -226,6 +227,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
private async Task Start()
|
||||
{
|
||||
if (this.MediaTranscriptionService.IsBusy)
|
||||
return;
|
||||
|
||||
var activeSession = this.AssistantSessionService.TryGetSnapshot(this.assistantSessionKey);
|
||||
if (activeSession?.IsActive ?? false)
|
||||
{
|
||||
@ -675,6 +679,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
|
||||
this.isDisposed = true;
|
||||
try
|
||||
{
|
||||
@ -689,6 +694,9 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
base.DisposeResources();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes assistant actions when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Assistant sessions
|
||||
|
||||
@ -2290,6 +2290,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T3219823625"] = "The lo
|
||||
-- The image at the URL is too large (>10 MB). Skipping the image.
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::IIMAGESOURCEEXTENSIONS::T349928509"] = "The image at the URL is too large (>10 MB). Skipping the image."
|
||||
|
||||
-- Transcription: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::CHAT::MANAGEDTRANSCRIPTATTACHMENT::T1004696017"] = "Transcription: {0}"
|
||||
|
||||
-- Open Settings
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ASSISTANTBLOCK::T1172211894"] = "Open Settings"
|
||||
|
||||
@ -2401,6 +2404,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T143112277"] = "Drop fil
|
||||
-- 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:"
|
||||
|
||||
@ -2410,6 +2416,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::ATTACHDOCUMENTS::T2495931372"] = "Select
|
||||
-- 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"
|
||||
|
||||
@ -2671,6 +2683,18 @@ 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."
|
||||
|
||||
-- Stop media transcription
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::MEDIATRANSCRIPTIONSTATUS::T1868377405"] = "Stop 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."
|
||||
|
||||
@ -2794,12 +2818,21 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T1989554334"] = "Failed
|
||||
-- Drop one file here to load its content.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::READFILECONTENT::T2274562398"] = "Drop one file here to load its content."
|
||||
|
||||
-- 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 +3571,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 +3589,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 +6769,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 +6787,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 +6871,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 +6919,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 +6982,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 +7003,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 +8518,39 @@ 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 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"
|
||||
|
||||
|
||||
@ -1,93 +1,173 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
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");
|
||||
var markdown = $"""
|
||||
# Transcription of {originalFileName}
|
||||
|
||||
{transcript.Trim()}
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(stagingPath, markdown, new UTF8Encoding(false));
|
||||
return new ManagedTranscriptAttachment(
|
||||
Path.GetFileName(stagingPath),
|
||||
stagingPath,
|
||||
new FileInfo(stagingPath).Length,
|
||||
originalFileName,
|
||||
true);
|
||||
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 parentInfo = fileInfo.Directory!;
|
||||
var canonicalParent = parentInfo.ResolveLinkTarget(true)?.FullName ?? parentInfo.FullName;
|
||||
var fullPath = fileInfo.ResolveLinkTarget(true)?.FullName ?? Path.Combine(canonicalParent, fileInfo.Name);
|
||||
var dataDirectory = new DirectoryInfo(SettingsManager.DataDirectory!);
|
||||
var fullFilePath = Path.GetFullPath(fileInfo.FullName);
|
||||
var fullDataRoot = Path.GetFullPath(SettingsManager.DataDirectory!);
|
||||
var relative = Path.GetRelativePath(fullDataRoot, fullFilePath);
|
||||
|
||||
var canonicalDataRoot = EnsureTrailingSeparator(dataDirectory.ResolveLinkTarget(true)?.FullName ?? dataDirectory.FullName);
|
||||
var stagingDirectory = new DirectoryInfo(Path.Combine(SettingsManager.DataDirectory!, "media-staging"));
|
||||
var stagingRoot = EnsureTrailingSeparator(stagingDirectory.ResolveLinkTarget(true)?.FullName ?? stagingDirectory.FullName);
|
||||
if (Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathComparison))
|
||||
return false;
|
||||
|
||||
var transcriptsSegment = $"{Path.DirectorySeparatorChar}attachments{Path.DirectorySeparatorChar}transcripts{Path.DirectorySeparatorChar}";
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var owned = fullPath.StartsWith(stagingRoot, comparison)
|
||||
|| (fullPath.StartsWith(canonicalDataRoot, comparison)
|
||||
&& fullPath.Contains(transcriptsSegment, comparison));
|
||||
|
||||
if (!owned)
|
||||
if (fileInfo.LinkTarget is not null || HasLinkedDirectory(fileInfo.Directory, fullDataRoot))
|
||||
return false;
|
||||
|
||||
File.Delete(fullPath);
|
||||
var parent = Path.GetDirectoryName(fullPath);
|
||||
if (managed.IsStaged && parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any())
|
||||
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 invalid = Path.GetInvalidFileNameChars().ToHashSet();
|
||||
var normalized = new StringBuilder();
|
||||
|
||||
foreach (var character in stem)
|
||||
var textCharacters = 0;
|
||||
foreach (var rune in stem.EnumerateRunes())
|
||||
{
|
||||
normalized.Append(char.IsControl(character)
|
||||
|| invalid.Contains(character)
|
||||
|| character is '/' or '\\'
|
||||
? '-'
|
||||
: character);
|
||||
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(' ', '.', '-');
|
||||
if (string.IsNullOrWhiteSpace(result))
|
||||
result = "media";
|
||||
|
||||
return result.Length <= 80 ? result : result[..80];
|
||||
return string.IsNullOrWhiteSpace(result) ? "media" : result;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path) => path.EndsWith(Path.DirectorySeparatorChar) ? path : path + Path.DirectorySeparatorChar;
|
||||
/// <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;
|
||||
|
||||
/// <summary>Returns localized text while registering the US-English fallback with I18N.</summary>
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ManagedTranscriptAttachment).Namespace, nameof(ManagedTranscriptAttachment));
|
||||
}
|
||||
@ -63,6 +63,10 @@ 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; }
|
||||
|
||||
[Inject]
|
||||
private ILogger<AttachDocuments> Logger { get; set; } = null!;
|
||||
|
||||
@ -90,6 +94,7 @@ public partial class AttachDocuments : 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:
|
||||
@ -97,6 +102,16 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
|
||||
/// <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.IsUnavailable && triggeredEvent == Event.TAURI_EVENT_RECEIVED)
|
||||
@ -277,12 +292,12 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
showDialog: true);
|
||||
canAddRegularFiles = pandocState.IsAvailable;
|
||||
}
|
||||
|
||||
|
||||
foreach (var path in regularPaths)
|
||||
{
|
||||
if (!canAddRegularFiles)
|
||||
break;
|
||||
|
||||
|
||||
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(
|
||||
FileExtensionValidation.UseCase.ATTACHING_CONTENT,
|
||||
path,
|
||||
@ -311,12 +326,12 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
$"{this.T("The selected audio and video files will be prepared locally. Their audio will then be uploaded to the configured transcription provider.")}{Environment.NewLine}{Environment.NewLine}{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;
|
||||
@ -324,17 +339,21 @@ public partial class AttachDocuments : MSGComponentBase
|
||||
var failures = new List<string>();
|
||||
foreach (var mediaPath in mediaPaths)
|
||||
{
|
||||
var result = await this.MediaTranscriptionService.TranscribeAsync(mediaPath);
|
||||
if (!result.Success)
|
||||
var result = await this.MediaTranscriptionService.TranscribeImportAsync(mediaPath);
|
||||
if (result.Status is MediaTranscriptionResultStatus.CANCELLED)
|
||||
break;
|
||||
|
||||
if (result.Status is MediaTranscriptionResultStatus.FAILED)
|
||||
{
|
||||
if (result.ErrorMessage == "The media transcription was cancelled.")
|
||||
break;
|
||||
|
||||
failures.Add($"{Path.GetFileName(mediaPath)}: {result.ErrorMessage}");
|
||||
failures.Add($"{Path.GetFileName(mediaPath)}: {result.UserMessage}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var attachment = this.OwnerChat is not null
|
||||
&& WorkspaceBehaviour.IsChatExisting(new LoadChat(this.OwnerChat.WorkspaceId, this.OwnerChat.ChatId))
|
||||
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(this.OwnerChat, mediaPath, result.Text)
|
||||
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
|
||||
|
||||
var attachment = await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
|
||||
this.DocumentPaths.Add(attachment);
|
||||
}
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
Label="@this.InputLabel"
|
||||
Placeholder="@this.ProviderPlaceholder"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentIcon="@Icons.Material.Filled.Send"
|
||||
AdornmentIcon="@(this.MediaTranscriptionService.IsBusy ? Icons.Material.Filled.HourglassTop : Icons.Material.Filled.Send)"
|
||||
OnAdornmentClick="() => this.SendMessage()"
|
||||
Disabled="@this.IsInputForbidden()"
|
||||
Immediate="@true"
|
||||
@ -101,7 +101,7 @@
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider" Disabled="@this.MediaTranscriptionService.IsBusy"/>
|
||||
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider" OwnerChat="@this.ChatThread" Disabled="@this.MediaTranscriptionService.IsBusy"/>
|
||||
|
||||
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
|
||||
|
||||
|
||||
@ -108,6 +108,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 ]);
|
||||
|
||||
@ -250,6 +252,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes send and attachment controls when the media import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && this.ChatThread is not null && this.mustStoreChat)
|
||||
@ -1212,6 +1217,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();
|
||||
|
||||
@ -4,25 +4,30 @@ namespace AIStudio.Components;
|
||||
|
||||
public partial class MediaTranscriptionStatus
|
||||
{
|
||||
/// <summary>Gets the localized visible status for the active import.</summary>
|
||||
private string StatusText => this.MediaTranscriptionService.Phase switch
|
||||
{
|
||||
MediaTranscriptionPhase.PROBING => $"{this.T("Inspecting media")}: {this.MediaTranscriptionService.CurrentFileName}",
|
||||
MediaTranscriptionPhase.TRANSCODING => $"{this.T("Preparing audio")}: {this.MediaTranscriptionService.CurrentFileName}",
|
||||
MediaTranscriptionPhase.UPLOADING => $"{this.T("Transcribing")}: {this.MediaTranscriptionService.CurrentFileName}",
|
||||
|
||||
|
||||
_ => this.MediaTranscriptionService.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() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
|
||||
/// <summary>Unsubscribes from singleton import state changes.</summary>
|
||||
protected override void DisposeResources()
|
||||
{
|
||||
this.MediaTranscriptionService.StateChanged -= this.OnStateChanged;
|
||||
base.DisposeResources();
|
||||
}
|
||||
}
|
||||
@ -63,6 +63,7 @@ public partial class ReadFileContent : 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 ]);
|
||||
@ -72,6 +73,16 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Refreshes disabled controls when the shared import lane changes.</summary>
|
||||
private void OnMediaImportStateChanged() => _ = this.InvokeAsync(this.StateHasChanged);
|
||||
|
||||
/// <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.EnableDragDrop)
|
||||
@ -226,18 +237,20 @@ public partial class ReadFileContent : MSGComponentBase
|
||||
this.T("Transcribe media file"),
|
||||
dialogParameters,
|
||||
Dialogs.DialogOptions.FULLSCREEN);
|
||||
|
||||
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return false;
|
||||
|
||||
var result = await this.MediaTranscriptionService.TranscribeAsync(filePath);
|
||||
if (!result.Success)
|
||||
var result = await this.MediaTranscriptionService.TranscribeImportAsync(filePath);
|
||||
if (result.Status is not MediaTranscriptionResultStatus.SUCCEEDED)
|
||||
{
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, result.ErrorMessage));
|
||||
if (result.Status is MediaTranscriptionResultStatus.FAILED)
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, result.UserMessage));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
await this.FileContentChanged.InvokeAsync(result.Text);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -103,8 +103,7 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
&& !string.IsNullOrWhiteSpace(this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider);
|
||||
|
||||
private bool IsVoiceRecordingAvailable => this.ShouldRenderVoiceRecording
|
||||
&& this.VoiceRecordingAvailabilityService.IsAvailable
|
||||
&& !this.MediaTranscriptionService.IsBusy;
|
||||
&& this.VoiceRecordingAvailabilityService.IsAvailable;
|
||||
|
||||
private string Tooltip => !this.VoiceRecordingAvailabilityService.IsAvailable
|
||||
? T("Voice recording is unavailable because the client could not initialize audio playback.")
|
||||
@ -320,13 +319,15 @@ public partial class VoiceRecorder : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var transcriptionResult = await this.MediaTranscriptionService.TranscribeAsync(this.finalRecordingPath);
|
||||
if (!transcriptionResult.Success)
|
||||
var transcriptionResult = await this.MediaTranscriptionService.TranscribeVoiceAsync(this.finalRecordingPath);
|
||||
if (transcriptionResult.Status is not MediaTranscriptionResultStatus.SUCCEEDED)
|
||||
{
|
||||
this.Logger.LogWarning("The transcription request failed.");
|
||||
var userMessage = string.IsNullOrWhiteSpace(transcriptionResult.ErrorMessage)
|
||||
if (transcriptionResult.Status is MediaTranscriptionResultStatus.CANCELLED)
|
||||
return;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -845,16 +845,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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +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);
|
||||
@ -1,3 +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);
|
||||
@ -1,3 +1,8 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
public sealed record MediaJobError(string Code, string Message);
|
||||
/// <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,
|
||||
}
|
||||
@ -1,5 +1,10 @@
|
||||
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,
|
||||
|
||||
@ -1,11 +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,
|
||||
}
|
||||
@ -1,5 +1,11 @@
|
||||
namespace AIStudio.Tools.Rust;
|
||||
|
||||
/// <summary>Successful terminal result returned by Rust media normalization.</summary>
|
||||
/// <param name="OutputPath">Committed normalized output path.</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>
|
||||
public sealed record MediaJobResult(
|
||||
string OutputPath,
|
||||
string DetectedFormat,
|
||||
|
||||
@ -1,9 +1,17 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <summary>Visible phases of the serialized media import lane.</summary>
|
||||
public enum MediaTranscriptionPhase
|
||||
{
|
||||
/// <summary>No import is active.</summary>
|
||||
IDLE,
|
||||
|
||||
/// <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,
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <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 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 cancelled result without relying on visible text.</summary>
|
||||
public static MediaTranscriptionResult Cancelled() => new(MediaTranscriptionResultStatus.CANCELLED, string.Empty, string.Empty, MediaJobErrorCode.CANCELLED);
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Tools.Services;
|
||||
|
||||
/// <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 caller or user cancelled the operation.</summary>
|
||||
CANCELLED,
|
||||
}
|
||||
@ -1,144 +1,241 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
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 readonly SemaphoreSlim queue = new(1, 1);
|
||||
private readonly Lock stateLock = new();
|
||||
private CancellationTokenSource? currentCancellation;
|
||||
private string? currentJobId;
|
||||
/// <summary>Serializes attachment and file-content imports.</summary>
|
||||
private readonly SemaphoreSlim importQueue = new(1, 1);
|
||||
|
||||
/// <summary>Protects operation ownership and visible 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 currently visible import operation, if any.</summary>
|
||||
private MediaOperation? currentImport;
|
||||
|
||||
/// <summary>Prevents new work after disposal.</summary>
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Raised whenever visible import state changes.</summary>
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>Gets whether the serialized import lane is active.</summary>
|
||||
public bool IsBusy { get; private set; }
|
||||
|
||||
/// <summary>Gets the file name shown for the active import.</summary>
|
||||
public string CurrentFileName { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the active import phase.</summary>
|
||||
public MediaTranscriptionPhase Phase { get; private set; } = MediaTranscriptionPhase.IDLE;
|
||||
|
||||
/// <summary>Gets optional import progress between zero and one.</summary>
|
||||
public double? Progress { get; private set; }
|
||||
|
||||
public async Task<TranscriptionResult> TranscribeAsync(string mediaPath, CancellationToken token = default)
|
||||
/// <summary>
|
||||
/// Transcribes an attachment or file-content import on the serialized visible lane.
|
||||
/// </summary>
|
||||
/// <param name="mediaPath">Source media path.</param>
|
||||
/// <param name="token">Caller cancellation token.</param>
|
||||
/// <returns>A typed terminal result.</returns>
|
||||
public async Task<MediaTranscriptionResult> TranscribeImportAsync(string mediaPath, CancellationToken token = default)
|
||||
{
|
||||
await this.queue.WaitAsync(token);
|
||||
var normalizedPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"mindwork-ai-studio-media",
|
||||
$"{Guid.NewGuid():N}.webm");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(normalizedPath)!);
|
||||
using var operationCancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
this.ThrowIfDisposed();
|
||||
await this.importQueue.WaitAsync(token);
|
||||
var operation = this.CreateOperation(token);
|
||||
lock (this.stateLock)
|
||||
this.currentCancellation = operationCancellation;
|
||||
this.currentImport = operation;
|
||||
|
||||
try
|
||||
{
|
||||
this.UpdateState(true, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0);
|
||||
var jobId = await rustService.StartMediaJobAsync(mediaPath, normalizedPath, operationCancellation.Token);
|
||||
lock (this.stateLock)
|
||||
this.currentJobId = jobId;
|
||||
|
||||
MediaJobResult? normalized = null;
|
||||
await foreach (var mediaEvent in rustService.StreamMediaJobEventsAsync(jobId, operationCancellation.Token))
|
||||
{
|
||||
switch (mediaEvent.Phase)
|
||||
{
|
||||
case MediaJobPhase.PROBING:
|
||||
this.UpdateState(true, this.CurrentFileName, MediaTranscriptionPhase.PROBING, mediaEvent.Progress);
|
||||
break;
|
||||
|
||||
case MediaJobPhase.TRANSCODING:
|
||||
this.UpdateState(true, this.CurrentFileName, MediaTranscriptionPhase.TRANSCODING, mediaEvent.Progress);
|
||||
break;
|
||||
|
||||
case MediaJobPhase.COMPLETED:
|
||||
normalized = mediaEvent.Result;
|
||||
break;
|
||||
|
||||
case MediaJobPhase.CANCELLED:
|
||||
throw new OperationCanceledException(operationCancellation.Token);
|
||||
|
||||
case MediaJobPhase.FAILED:
|
||||
return TranscriptionResult.Failure(mediaEvent.Error?.Message ?? "The media file could not be prepared.");
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized is null)
|
||||
return TranscriptionResult.Failure("The media pipeline ended without an output file.");
|
||||
|
||||
var providerSettings = this.ResolveProvider();
|
||||
if (providerSettings is null)
|
||||
return TranscriptionResult.Failure("No usable transcription provider is configured.");
|
||||
|
||||
this.UpdateState(true, this.CurrentFileName, MediaTranscriptionPhase.UPLOADING, null);
|
||||
|
||||
var provider = providerSettings.CreateProvider();
|
||||
if (provider.Provider is LLMProviders.NONE)
|
||||
return TranscriptionResult.Failure("The configured transcription provider could not be created.");
|
||||
|
||||
logger.LogInformation(
|
||||
"Transcribing normalized media '{MediaPath}' with provider '{Provider}' and model '{Model}'.",
|
||||
mediaPath,
|
||||
providerSettings.UsedLLMProvider,
|
||||
providerSettings.Model);
|
||||
|
||||
var result = await provider.TranscribeAudioAsync(
|
||||
providerSettings.Model,
|
||||
normalized.OutputPath,
|
||||
settingsManager,
|
||||
operationCancellation.Token);
|
||||
|
||||
return result.Success
|
||||
? TranscriptionResult.FromText(result.Text.Trim())
|
||||
: result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return TranscriptionResult.Failure("The media transcription was cancelled.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Media transcription failed for '{MediaPath}'.", mediaPath);
|
||||
return TranscriptionResult.Failure("The media file could not be transcribed.");
|
||||
this.UpdateImportState(true, Path.GetFileName(mediaPath), MediaTranscriptionPhase.PROBING, 0.0);
|
||||
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (this.stateLock)
|
||||
{
|
||||
this.currentJobId = null;
|
||||
this.currentCancellation = null;
|
||||
if (ReferenceEquals(this.currentImport, operation))
|
||||
this.currentImport = null;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (File.Exists(normalizedPath))
|
||||
File.Delete(normalizedPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not delete normalized media file '{NormalizedPath}'.", normalizedPath);
|
||||
}
|
||||
|
||||
this.UpdateState(false, string.Empty, MediaTranscriptionPhase.IDLE, null);
|
||||
this.queue.Release();
|
||||
|
||||
this.ReleaseOperation(operation);
|
||||
this.UpdateImportState(false, string.Empty, MediaTranscriptionPhase.IDLE, null);
|
||||
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(token);
|
||||
|
||||
try
|
||||
{
|
||||
return await this.TranscribeCoreAsync(mediaPath, operation, updateImportState: false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.ReleaseOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cancels only the active visible import operation.</summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
string? jobId;
|
||||
MediaOperation? operation;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
this.currentCancellation?.Cancel();
|
||||
jobId = this.currentJobId;
|
||||
operation = this.currentImport;
|
||||
operation?.Cancellation.Cancel();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobId))
|
||||
await rustService.CancelMediaJobAsync(jobId);
|
||||
|
||||
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 providerSettings = this.ResolveProvider();
|
||||
if (providerSettings is null)
|
||||
return MediaTranscriptionResult.Failed(TB("No usable transcription provider is configured."));
|
||||
|
||||
if (updateImportState)
|
||||
this.UpdateImportState(true, this.CurrentFileName, MediaTranscriptionPhase.UPLOADING, null);
|
||||
|
||||
var provider = providerSettings.CreateProvider();
|
||||
if (provider.Provider is LLMProviders.NONE)
|
||||
return MediaTranscriptionResult.Failed(TB("The configured transcription provider could not be created."));
|
||||
|
||||
logger.LogInformation("Transcribing normalized media '{MediaPath}' with provider '{Provider}' and model '{Model}'.",
|
||||
mediaPath,
|
||||
providerSettings.UsedLLMProvider,
|
||||
providerSettings.Model);
|
||||
|
||||
var providerResult = await provider.TranscribeAudioAsync(providerSettings.Model, normalized.Result.OutputPath, settingsManager, operation.Cancellation.Token);
|
||||
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.
|
||||
this.DeleteTemporaryFile(normalizedPath);
|
||||
this.DeleteTemporaryFile(normalizedPath + ".partial");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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(true, this.CurrentFileName, phase, mediaEvent.Progress);
|
||||
}
|
||||
|
||||
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;
|
||||
@ -155,7 +252,30 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
: null;
|
||||
}
|
||||
|
||||
private void UpdateState(bool isBusy, string fileName, MediaTranscriptionPhase phase, double? progress)
|
||||
/// <summary>Creates and registers operation-owned cancellation state.</summary>
|
||||
/// <param name="token">Caller token linked to the operation.</param>
|
||||
/// <returns>The registered operation.</returns>
|
||||
private MediaOperation CreateOperation(CancellationToken token)
|
||||
{
|
||||
var operation = new MediaOperation(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 state exposed only by the serialized import lane.</summary>
|
||||
private void UpdateImportState(bool isBusy, string fileName, MediaTranscriptionPhase phase, double? progress)
|
||||
{
|
||||
this.IsBusy = isBusy;
|
||||
this.CurrentFileName = fileName;
|
||||
@ -164,10 +284,76 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
|
||||
this.StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <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>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()
|
||||
{
|
||||
this.currentCancellation?.Cancel();
|
||||
this.currentCancellation?.Dispose();
|
||||
this.queue.Dispose();
|
||||
MediaOperation[] active;
|
||||
lock (this.stateLock)
|
||||
{
|
||||
if (this.disposed)
|
||||
return;
|
||||
|
||||
this.disposed = true;
|
||||
active = [.. this.operations];
|
||||
}
|
||||
foreach (var operation in active)
|
||||
operation.Cancellation.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="token">Caller cancellation token.</param>
|
||||
public MediaOperation(CancellationToken token)
|
||||
{
|
||||
this.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
}
|
||||
|
||||
/// <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>Disposes operation-owned cancellation state.</summary>
|
||||
public void Dispose() => this.Cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,11 @@ 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(
|
||||
@ -14,18 +19,22 @@ public partial class RustService
|
||||
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);
|
||||
|
||||
@ -45,6 +54,9 @@ public partial class RustService
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
@ -2,30 +2,75 @@ 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;
|
||||
}
|
||||
|
||||
foreach (var directory in Directory.EnumerateDirectories(stagingRoot))
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -736,7 +736,7 @@ public static class WorkspaceBehaviour
|
||||
var chatPath = loadChat.WorkspaceId == Guid.Empty
|
||||
? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString())
|
||||
: Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString());
|
||||
|
||||
|
||||
return Directory.Exists(chatPath);
|
||||
}
|
||||
|
||||
@ -770,40 +770,128 @@ 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 sourceDirectory = chat.WorkspaceId == Guid.Empty
|
||||
? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString())
|
||||
: Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
|
||||
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);
|
||||
var orderedSemaphores = string.CompareOrdinal(sourceWorkspaceId.ToString("N"), targetWorkspaceId.ToString("N")) <= 0
|
||||
? new[] { sourceSemaphore, targetSemaphore }
|
||||
: new[] { targetSemaphore, sourceSemaphore };
|
||||
|
||||
var targetDirectory = targetWorkspaceId == Guid.Empty
|
||||
? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString())
|
||||
: Path.Join(SettingsManager.DataDirectory, "workspaces", targetWorkspaceId.ToString(), chat.ChatId.ToString());
|
||||
|
||||
if (Directory.Exists(sourceDirectory))
|
||||
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}'.");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetDirectory)!);
|
||||
if (Directory.Exists(targetDirectory))
|
||||
throw new IOException($"The target chat directory already exists: '{targetDirectory}'.");
|
||||
|
||||
Directory.Move(sourceDirectory, targetDirectory);
|
||||
UpdateAttachmentPathsAfterMove(chat, sourceDirectory, targetDirectory);
|
||||
}
|
||||
|
||||
chat.WorkspaceId = targetWorkspaceId;
|
||||
await StoreChatAsync(chat);
|
||||
InvalidateWorkspaceTreeCache();
|
||||
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>())
|
||||
{
|
||||
@ -812,10 +900,10 @@ public static class WorkspaceBehaviour
|
||||
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 },
|
||||
@ -829,6 +917,7 @@ public static class WorkspaceBehaviour
|
||||
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++)
|
||||
@ -838,19 +927,13 @@ public static class WorkspaceBehaviour
|
||||
continue;
|
||||
|
||||
Directory.CreateDirectory(transcriptDirectory);
|
||||
string targetPath;
|
||||
do
|
||||
{
|
||||
chat.LastMediaTranscriptNumber++;
|
||||
var stem = ManagedTranscriptAttachment.NormalizeOriginalStem(staged.OriginalFileName);
|
||||
targetPath = Path.Combine(transcriptDirectory, $"{stem}-transcript-{chat.LastMediaTranscriptNumber:D4}.md");
|
||||
} while (File.Exists(targetPath));
|
||||
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,
|
||||
@ -859,10 +942,48 @@ public static class WorkspaceBehaviour
|
||||
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));
|
||||
|
||||
1014
runtime/src/media.rs
1014
runtime/src/media.rs
File diff suppressed because it is too large
Load Diff
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