mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 07:17:59 +00:00
Added audio and video transcription for chats and assistants (#856)
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
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:
1 parent
5dc6282908
commit
aaf77b6882
83 files changed
+5985
-442
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user