diff --git a/app/MindWork AI Studio/App.razor b/app/MindWork AI Studio/App.razor index 37492a67..b314b033 100644 --- a/app/MindWork AI Studio/App.razor +++ b/app/MindWork AI Studio/App.razor @@ -27,6 +27,7 @@ + \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index c955e59e..105dfb18 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2104,6 +2104,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T14695 -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Add Embedding" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1760715963"] = "Uses the provider-configured model" + -- Are you sure you want to delete the embedding provider '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1825371968"] = "Are you sure you want to delete the embedding provider '{0}'?" @@ -2167,6 +2170,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T162847 -- Description UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1725856265"] = "Description" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1760715963"] = "Uses the provider-configured model" + -- Add Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1806589097"] = "Add Provider" @@ -2209,9 +2215,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T291173 -- Configured LLM Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3019870540"] = "Configured LLM Providers" --- as selected by provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3082210376"] = "as selected by provider" - -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3267849393"] = "Edit" @@ -2269,6 +2272,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T14 -- Add transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1645238629"] = "Add transcription provider" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1760715963"] = "Uses the provider-configured model" + -- Add Transcription Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T2066315685"] = "Add Transcription Provider" @@ -3208,6 +3214,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T290547799"] = "Cur -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T416738168"] = "Model selection" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T808120719"] = "Host" @@ -3415,12 +3424,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Show Expert -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Show available models" +-- This host uses the model configured at the provider level. No model selection is available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "This host uses the model configured at the provider level. No model selection is available." + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually." -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T416738168"] = "Model selection" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T808120719"] = "Host" @@ -4636,9 +4651,15 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T2842060373"] = -- Please enter a transcription model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T3703662664"] = "Please enter a transcription model name." +-- This host uses the model configured at the provider level. No model selection is available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T3783329915"] = "This host uses the model configured at the provider level. No model selection is available." + -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T416738168"] = "Model selection" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T808120719"] = "Host" diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index 4ffc743f..874bc3c9 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -35,7 +35,7 @@ @context.Num @context.Name @context.UsedLLMProvider.ToName() - @GetEmbeddingProviderModelName(context) + @this.GetEmbeddingProviderModelName(context) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs index 50ebeb13..94878987 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor.cs @@ -15,8 +15,12 @@ public partial class SettingsPanelEmbeddings : SettingsPanelBase [Parameter] public EventCallback>> AvailableEmbeddingProvidersChanged { get; set; } - private static string GetEmbeddingProviderModelName(EmbeddingProvider provider) + private string GetEmbeddingProviderModelName(EmbeddingProvider provider) { + // For system models, return localized text: + if (provider.Model.IsSystemModel) + return T("Uses the provider-configured model"); + const int MAX_LENGTH = 36; var modelName = provider.Model.ToString(); return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName; diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index f9567086..3d359408 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -1,6 +1,5 @@ @using AIStudio.Provider @using AIStudio.Settings -@using AIStudio.Provider.SelfHosted @inherits SettingsPanelBase @@ -29,20 +28,7 @@ @context.Num @context.InstanceName @context.UsedLLMProvider.ToName() - - @if (context.UsedLLMProvider is not LLMProviders.SELF_HOSTED) - { - @GetLLMProviderModelName(context) - } - else if (context.UsedLLMProvider is LLMProviders.SELF_HOSTED && context.Host is not Host.LLAMA_CPP) - { - @GetLLMProviderModelName(context) - } - else - { - @T("as selected by provider") - } - + @this.GetLLMProviderModelName(context) @if (context.IsEnterpriseConfiguration) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs index 035543dc..2272959d 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor.cs @@ -134,8 +134,12 @@ public partial class SettingsPanelProviders : SettingsPanelBase await this.MessageBus.SendMessage(this, Event.CONFIGURATION_CHANGED); } - private static string GetLLMProviderModelName(AIStudio.Settings.Provider provider) + private string GetLLMProviderModelName(AIStudio.Settings.Provider provider) { + // For system models, return localized text: + if (provider.Model.IsSystemModel) + return T("Uses the provider-configured model"); + const int MAX_LENGTH = 36; var modelName = provider.Model.ToString(); return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName; diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index 82421e94..0405d6cd 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -32,7 +32,7 @@ @context.Num @context.Name @context.UsedLLMProvider.ToName() - @GetTranscriptionProviderModelName(context) + @this.GetTranscriptionProviderModelName(context) diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs index d564d5cd..243200a3 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor.cs @@ -15,8 +15,12 @@ public partial class SettingsPanelTranscription : SettingsPanelBase [Parameter] public EventCallback>> AvailableTranscriptionProvidersChanged { get; set; } - private static string GetTranscriptionProviderModelName(TranscriptionProvider provider) + private string GetTranscriptionProviderModelName(TranscriptionProvider provider) { + // For system models, return localized text: + if (provider.Model.IsSystemModel) + return T("Uses the provider-configured model"); + const int MAX_LENGTH = 36; var modelName = provider.Model.ToString(); return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName; diff --git a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index 4ea00180..71f3d602 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -20,6 +20,18 @@ public partial class VoiceRecorder : MSGComponentBase [Inject] private ISnackbar Snackbar { get; init; } = null!; + #region Overrides of MSGComponentBase + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + // Initialize sound effects. This "warms up" the AudioContext and preloads all sounds for reliable playback: + await this.JsRuntime.InvokeVoidAsync("initSoundEffects"); + } + + #endregion + private uint numReceivedChunks; private bool isRecording; private bool isTranscribing; @@ -39,6 +51,9 @@ public partial class VoiceRecorder : MSGComponentBase { if (toggled) { + // Warm up sound effects: + await this.JsRuntime.InvokeVoidAsync("initSoundEffects"); + var mimeTypes = GetPreferredMimeTypes( Builder.Create().UseAudio().UseSubtype(AudioSubtype.OGG).Build(), Builder.Create().UseAudio().UseSubtype(AudioSubtype.AAC).Build(), @@ -189,7 +204,11 @@ public partial class VoiceRecorder : MSGComponentBase private async Task TranscribeRecordingAsync() { if (this.finalRecordingPath is null) + { + // No recording to transcribe, but still release the microphone: + await this.ReleaseMicrophoneAsync(); return; + } this.isTranscribing = true; this.StateHasChanged(); @@ -223,7 +242,7 @@ public partial class VoiceRecorder : MSGComponentBase { this.Logger.LogWarning( "The configured transcription provider '{ProviderName}' has a confidence level of '{ProviderLevel}', which is below the minimum required level of '{MinimumLevel}'.", - transcriptionProviderSettings.Name, + transcriptionProviderSettings.UsedLLMProvider, providerConfidence.Level, minimumLevel); await this.MessageBus.SendError(new(Icons.Material.Filled.VoiceChat, this.T("The configured transcription provider does not meet the minimum confidence level."))); @@ -240,7 +259,7 @@ public partial class VoiceRecorder : MSGComponentBase } // Call the transcription API: - this.Logger.LogInformation("Starting transcription with provider '{ProviderName}' and model '{ModelName}'.", transcriptionProviderSettings.Name, transcriptionProviderSettings.Model.DisplayName); + this.Logger.LogInformation("Starting transcription with provider '{ProviderName}' and model '{ModelName}'.", transcriptionProviderSettings.UsedLLMProvider, transcriptionProviderSettings.Model.ToString()); var transcribedText = await provider.TranscribeAudioAsync(transcriptionProviderSettings.Model, this.finalRecordingPath, this.SettingsManager); if (string.IsNullOrWhiteSpace(transcribedText)) @@ -288,12 +307,22 @@ public partial class VoiceRecorder : MSGComponentBase } finally { + await this.ReleaseMicrophoneAsync(); + this.finalRecordingPath = null; this.isTranscribing = false; this.StateHasChanged(); } } + private async Task ReleaseMicrophoneAsync() + { + // Wait a moment for any queued sounds to finish playing, then release the microphone. + // This allows Bluetooth headsets to switch back to A2DP profile without interrupting audio: + await Task.Delay(1_800); + await this.JsRuntime.InvokeVoidAsync("audioRecorder.releaseMicrophone"); + } + private sealed class AudioRecordingResult { public string MimeType { get; init; } = string.Empty; diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor index aaf2cec7..85e6e6ef 100644 --- a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor @@ -44,7 +44,7 @@ @if (this.DataLLMProvider.IsHostNeeded()) { - + @foreach (Host host in Enum.GetValues(typeof(Host))) { if (host.IsEmbeddingSupported()) @@ -101,6 +101,12 @@ } } + @if (!string.IsNullOrWhiteSpace(this.dataLoadingModelsIssue)) + { + + @this.dataLoadingModelsIssue + + } @* ReSharper disable once CSharpWarnings::CS8974 *@ diff --git a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs index 8a0180c5..6520b7ee 100644 --- a/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/EmbeddingProviderDialog.razor.cs @@ -71,7 +71,10 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId [Inject] private RustService RustService { get; init; } = null!; - + + [Inject] + private ILogger Logger { get; init; } = null!; + private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); /// @@ -85,7 +88,8 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId private string dataManuallyModel = string.Empty; private string dataAPIKeyStorageIssue = string.Empty; private string dataEditingPreviousInstanceName = string.Empty; - + private string dataLoadingModelsIssue = string.Empty; + // We get the form reference from Blazor code to validate it manually: private MudForm form = null!; @@ -102,6 +106,7 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId GetPreviousInstanceName = () => this.dataEditingPreviousInstanceName, GetUsedInstanceNames = () => this.UsedInstanceNames, GetHost = () => this.DataHost, + IsModelProvidedManually = () => this.DataLLMProvider is LLMProviders.SELF_HOSTED && this.DataHost is Host.OLLAMA, }; } @@ -208,7 +213,16 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId { await this.form.Validate(); this.dataAPIKeyStorageIssue = string.Empty; - + + // Manually validate the model selection (needed when no models are loaded + // and the MudSelect is not rendered): + var modelValidationError = this.providerValidation.ValidatingModel(this.DataModel); + if (!string.IsNullOrWhiteSpace(modelValidationError)) + { + this.dataIssues = [..this.dataIssues, modelValidationError]; + this.dataIsValid = false; + } + // When the data is not valid, we don't store it: if (!this.dataIsValid) return; @@ -250,21 +264,40 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId await this.form.Validate(); } } - + + private void OnHostChanged(Host selectedHost) + { + // When the host changes, reset the model selection state: + this.DataHost = selectedHost; + this.DataModel = default; + this.dataManuallyModel = string.Empty; + this.availableModels.Clear(); + this.dataLoadingModelsIssue = string.Empty; + } + private async Task ReloadModels() { + this.dataLoadingModelsIssue = string.Empty; var currentEmbeddingProviderSettings = this.CreateEmbeddingProviderSettings(); var provider = currentEmbeddingProviderSettings.CreateProvider(); - if(provider is NoProvider) + if (provider is NoProvider) return; - - var models = await provider.GetEmbeddingModels(this.dataAPIKey); - - // Order descending by ID means that the newest models probably come first: - var orderedModels = models.OrderByDescending(n => n.Id); - - this.availableModels.Clear(); - this.availableModels.AddRange(orderedModels); + + try + { + var models = await provider.GetEmbeddingModels(this.dataAPIKey); + + // Order descending by ID means that the newest models probably come first: + var orderedModels = models.OrderByDescending(n => n.Id); + + this.availableModels.Clear(); + this.availableModels.AddRange(orderedModels); + } + catch (Exception e) + { + this.Logger.LogError($"Failed to load models from provider '{this.DataLLMProvider}' (host={this.DataHost}, hostname='{this.DataHostname}'): {e.Message}"); + this.dataLoadingModelsIssue = T("We are currently unable to communicate with the provider to load models. Please try again later."); + } } private string APIKeyText => this.DataLLMProvider switch diff --git a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor index b424202d..96e94a2f 100644 --- a/app/MindWork AI Studio/Dialogs/ProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/ProviderDialog.razor @@ -41,7 +41,7 @@ @if (this.DataLLMProvider.IsHostNeeded()) { - + @foreach (Host host in Enum.GetValues(typeof(Host))) { @if (host.IsChatSupported()) @@ -71,52 +71,69 @@ @* ReSharper restore Asp.Entity *@ } - - - @if (this.DataLLMProvider.IsLLMModelProvidedManually()) - { - - @T("Show available models") - - - } - else - { - - @T("Load models") - - @if(this.availableModels.Count is 0) + @if (!this.DataLLMProvider.IsLLMModelSelectionHidden(this.DataHost)) + { + + + @if (this.DataLLMProvider.IsLLMModelProvidedManually()) { - - @T("No models loaded or available.") - + + @T("Show available models") + + } else { - - @foreach (var model in this.availableModels) - { - - @model - - } - + + @T("Load models") + + @if(this.availableModels.Count is 0) + { + + @T("No models loaded or available.") + + } + else + { + + @foreach (var model in this.availableModels) + { + + @model + + } + + } } + + @if (!string.IsNullOrWhiteSpace(this.dataLoadingModelsIssue)) + { + + @this.dataLoadingModelsIssue + } - - + + } + else + { + + + @T("This host uses the model configured at the provider level. No model selection is available.") + + + } @* ReSharper disable once CSharpWarnings::CS8974 *@ Logger { get; init; } = null!; + private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); /// @@ -97,6 +100,7 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId private string dataManuallyModel = string.Empty; private string dataAPIKeyStorageIssue = string.Empty; private string dataEditingPreviousInstanceName = string.Empty; + private string dataLoadingModelsIssue = string.Empty; private bool showExpertSettings; // We get the form reference from Blazor code to validate it manually: @@ -115,25 +119,36 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId GetPreviousInstanceName = () => this.dataEditingPreviousInstanceName, GetUsedInstanceNames = () => this.UsedInstanceNames, GetHost = () => this.DataHost, + IsModelProvidedManually = () => this.DataLLMProvider.IsLLMModelProvidedManually(), }; } private AIStudio.Settings.Provider CreateProviderSettings() { var cleanedHostname = this.DataHostname.Trim(); + + // Determine the model based on the provider and host configuration: + Model model; + if (this.DataLLMProvider.IsLLMModelSelectionHidden(this.DataHost)) + { + // Use system model placeholder for hosts that don't support model selection (e.g., llama.cpp): + model = Model.SYSTEM_MODEL; + } + else if (this.DataLLMProvider is LLMProviders.FIREWORKS or LLMProviders.HUGGINGFACE) + { + // These providers require manual model entry: + model = new Model(this.dataManuallyModel, null); + } + else + model = this.DataModel; + return new() { Num = this.DataNum, Id = this.DataId, InstanceName = this.DataInstanceName, UsedLLMProvider = this.DataLLMProvider, - - Model = this.DataLLMProvider switch - { - LLMProviders.FIREWORKS or LLMProviders.HUGGINGFACE => new Model(this.dataManuallyModel, null), - _ => this.DataModel - }, - + Model = model, IsSelfHosted = this.DataLLMProvider is LLMProviders.SELF_HOSTED, IsEnterpriseConfiguration = false, Hostname = cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname, @@ -222,7 +237,16 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId await this.form.Validate(); if (!string.IsNullOrWhiteSpace(this.dataAPIKeyStorageIssue)) this.dataAPIKeyStorageIssue = string.Empty; - + + // Manually validate the model selection (needed when no models are loaded + // and the MudSelect is not rendered): + var modelValidationError = this.providerValidation.ValidatingModel(this.DataModel); + if (!string.IsNullOrWhiteSpace(modelValidationError)) + { + this.dataIssues = [..this.dataIssues, modelValidationError]; + this.dataIsValid = false; + } + // When the data is not valid, we don't store it: if (!this.dataIsValid) return; @@ -264,21 +288,40 @@ public partial class ProviderDialog : MSGComponentBase, ISecretId await this.form.Validate(); } } + + private void OnHostChanged(Host selectedHost) + { + // When the host changes, reset the model selection state: + this.DataHost = selectedHost; + this.DataModel = default; + this.dataManuallyModel = string.Empty; + this.availableModels.Clear(); + this.dataLoadingModelsIssue = string.Empty; + } private async Task ReloadModels() { + this.dataLoadingModelsIssue = string.Empty; var currentProviderSettings = this.CreateProviderSettings(); var provider = currentProviderSettings.CreateProvider(); - if(provider is NoProvider) + if (provider is NoProvider) return; - - var models = await provider.GetTextModels(this.dataAPIKey); - - // Order descending by ID means that the newest models probably come first: - var orderedModels = models.OrderByDescending(n => n.Id); - - this.availableModels.Clear(); - this.availableModels.AddRange(orderedModels); + + try + { + var models = await provider.GetTextModels(this.dataAPIKey); + + // Order descending by ID means that the newest models probably come first: + var orderedModels = models.OrderByDescending(n => n.Id); + + this.availableModels.Clear(); + this.availableModels.AddRange(orderedModels); + } + catch (Exception e) + { + this.Logger.LogError($"Failed to load models from provider '{this.DataLLMProvider}' (host={this.DataHost}, hostname='{this.DataHostname}'): {e.Message}"); + this.dataLoadingModelsIssue = T("We are currently unable to communicate with the provider to load models. Please try again later."); + } } private string APIKeyText => this.DataLLMProvider switch diff --git a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor index 0a2aa3bb..78d2dea2 100644 --- a/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor +++ b/app/MindWork AI Studio/Dialogs/TranscriptionProviderDialog.razor @@ -44,7 +44,7 @@ @if (this.DataLLMProvider.IsHostNeeded()) { - + @foreach (Host host in Enum.GetValues(typeof(Host))) { if (host.IsTranscriptionSupported()) @@ -57,51 +57,68 @@ } - - - @if (this.DataLLMProvider.IsTranscriptionModelProvidedManually(this.DataHost)) - { - - } - else - { - - @T("Load") - - @if(this.availableModels.Count is 0) + @if (!this.DataLLMProvider.IsTranscriptionModelSelectionHidden(this.DataHost)) + { + + + @if (this.DataLLMProvider.IsTranscriptionModelProvidedManually(this.DataHost)) { - - @T("No models loaded or available.") - + } else { - - @foreach (var model in this.availableModels) - { - - @model - - } - + + @T("Load") + + @if(this.availableModels.Count is 0) + { + + @T("No models loaded or available.") + + } + else + { + + @foreach (var model in this.availableModels) + { + + @model + + } + + } } + + @if (!string.IsNullOrWhiteSpace(this.dataLoadingModelsIssue)) + { + + @this.dataLoadingModelsIssue + } - - + + } + else + { + + + @T("This host uses the model configured at the provider level. No model selection is available.") + + + } @* ReSharper disable once CSharpWarnings::CS8974 *@ Logger { get; init; } = null!; + private static readonly Dictionary SPELLCHECK_ATTRIBUTES = new(); /// @@ -85,7 +88,8 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId private string dataManuallyModel = string.Empty; private string dataAPIKeyStorageIssue = string.Empty; private string dataEditingPreviousInstanceName = string.Empty; - + private string dataLoadingModelsIssue = string.Empty; + // We get the form reference from Blazor code to validate it manually: private MudForm form = null!; @@ -102,31 +106,39 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId GetPreviousInstanceName = () => this.dataEditingPreviousInstanceName, GetUsedInstanceNames = () => this.UsedInstanceNames, GetHost = () => this.DataHost, + IsModelProvidedManually = () => this.DataLLMProvider.IsTranscriptionModelProvidedManually(this.DataHost), }; } private TranscriptionProvider CreateTranscriptionProviderSettings() { var cleanedHostname = this.DataHostname.Trim(); - Model model = default; - if(this.DataLLMProvider is LLMProviders.SELF_HOSTED) + + // Determine the model based on the provider and host configuration: + Model model; + if (this.DataLLMProvider.IsTranscriptionModelSelectionHidden(this.DataHost)) + { + // Use system model placeholder for hosts that don't support model selection (e.g., whisper.cpp): + model = Model.SYSTEM_MODEL; + } + else if (this.DataLLMProvider is LLMProviders.SELF_HOSTED) { switch (this.DataHost) { case Host.OLLAMA: model = new Model(this.dataManuallyModel, null); break; - + case Host.VLLM: case Host.LM_STUDIO: - case Host.WHISPER_CPP: + default: model = this.DataModel; break; } } else model = this.DataModel; - + return new() { Num = this.DataNum, @@ -216,7 +228,16 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId { await this.form.Validate(); this.dataAPIKeyStorageIssue = string.Empty; - + + // Manually validate the model selection (needed when no models are loaded + // and the MudSelect is not rendered): + var modelValidationError = this.providerValidation.ValidatingModel(this.DataModel); + if (!string.IsNullOrWhiteSpace(modelValidationError)) + { + this.dataIssues = [..this.dataIssues, modelValidationError]; + this.dataIsValid = false; + } + // When the data is not valid, we don't store it: if (!this.dataIsValid) return; @@ -243,7 +264,7 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId { if (this.DataLLMProvider is LLMProviders.SELF_HOSTED && string.IsNullOrWhiteSpace(manuallyModel)) return T("Please enter a transcription model name."); - + return null; } @@ -258,21 +279,40 @@ public partial class TranscriptionProviderDialog : MSGComponentBase, ISecretId await this.form.Validate(); } } - + + private void OnHostChanged(Host selectedHost) + { + // When the host changes, reset the model selection state: + this.DataHost = selectedHost; + this.DataModel = default; + this.dataManuallyModel = string.Empty; + this.availableModels.Clear(); + this.dataLoadingModelsIssue = string.Empty; + } + private async Task ReloadModels() { + this.dataLoadingModelsIssue = string.Empty; var currentTranscriptionProviderSettings = this.CreateTranscriptionProviderSettings(); var provider = currentTranscriptionProviderSettings.CreateProvider(); - if(provider is NoProvider) + if (provider is NoProvider) return; - - var models = await provider.GetTranscriptionModels(this.dataAPIKey); - - // Order descending by ID means that the newest models probably come first: - var orderedModels = models.OrderByDescending(n => n.Id); - - this.availableModels.Clear(); - this.availableModels.AddRange(orderedModels); + + try + { + var models = await provider.GetTranscriptionModels(this.dataAPIKey); + + // Order descending by ID means that the newest models probably come first: + var orderedModels = models.OrderByDescending(n => n.Id); + + this.availableModels.Clear(); + this.availableModels.AddRange(orderedModels); + } + catch (Exception e) + { + this.Logger.LogError($"Failed to load models from provider '{this.DataLLMProvider}' (host={this.DataHost}, hostname='{this.DataHostname}'): {e.Message}");; + this.dataLoadingModelsIssue = T("We are currently unable to communicate with the provider to load models. Please try again later."); + } } private string APIKeyText => this.DataLLMProvider switch diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index e10ad159..709b053a 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -2106,6 +2106,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T14695 -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Einbettung hinzufügen" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1760715963"] = "Verwendet das vom Anbieter konfigurierte Modell" + -- Are you sure you want to delete the embedding provider '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1825371968"] = "Sind Sie sicher, dass Sie den Einbettungsanbieter '{0}' löschen möchten?" @@ -2169,11 +2172,14 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T162847 -- Description UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1725856265"] = "Beschreibung" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1760715963"] = "Verwendet das vom Anbieter konfigurierte Modell" + -- Add Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1806589097"] = "Anbieter hinzufügen" -- Configure LLM Providers -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1810190350"] = "Anbieter für LLM konfigurieren" +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1810190350"] = "Anbieter für LLMs konfigurieren" -- Edit LLM Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1868766523"] = "LLM-Anbieter bearbeiten" @@ -2209,10 +2215,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T284206 UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T2911731076"] = "Noch keine Anbieter konfiguriert." -- Configured LLM Providers -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3019870540"] = "Konfigurierte Anbieter für LLM" - --- as selected by provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3082210376"] = "wie vom Anbieter ausgewählt" +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3019870540"] = "Konfigurierte Anbieter für LLMs" -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3267849393"] = "Bearbeiten" @@ -2271,6 +2274,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T14 -- Add transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1645238629"] = "Anbieter für Transkriptionen hinzufügen" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1760715963"] = "Verwendet das vom Anbieter konfigurierte Modell" + -- Add Transcription Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T2066315685"] = "Anbieter für Transkriptionen hinzufügen" @@ -2295,6 +2301,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T40 -- Configured Transcription Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T4210863523"] = "Konfigurierte Anbieter für Transkriptionen" +-- With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure LLM providers' section. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T584860404"] = "Mit Unterstützung von Modellen für Transkriptionen kann MindWork AI Studio menschliche Sprache in Text umwandeln. Das ist zum Beispiel hilfreich, wenn Sie Texte diktieren möchten. Sie können aus speziellen Modellen für Transkriptionen wählen, jedoch nicht aus multimodalen LLMs (Large Language Models), die sowohl Sprache als auch Text verarbeiten können. Die Einrichtung multimodaler Modelle erfolgt im Abschnitt „Anbieter für LLMs konfigurieren“." + -- This transcription provider is managed by your organization. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T756131076"] = "Dieser Anbieter für Transkriptionen wird von Ihrer Organisation verwaltet." @@ -2304,9 +2313,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Are you sure you want to delete the transcription provider '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Möchten Sie den Anbieter für Transkriptionen „{0}“ wirklich löschen?" --- With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the \"Configure providers\" section. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T584860404"] = "Mit Unterstützung von Modellen für Transkriptionen kann MindWork AI Studio menschliche Sprache in Text umwandeln. Das ist zum Beispiel hilfreich, wenn Sie Texte diktieren möchten. Sie können aus speziellen Modellen für Transkriptionen wählen, jedoch nicht aus multimodalen LLMs (Large Language Models), die sowohl Sprache als auch Text verarbeiten können. Die Einrichtung multimodaler Modelle erfolgt im Abschnitt „Anbieter für LLM konfigurieren“." - -- Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T900237532"] = "Anbieter" @@ -3210,6 +3216,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T290547799"] = "Der -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T416738168"] = "Modellauswahl" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T504465522"] = "Wir können derzeit nicht mit dem Anbieter kommunizieren, um Modelle zu laden. Bitte versuchen Sie es später erneut." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T808120719"] = "Host" @@ -3417,12 +3426,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Experten-Ei -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Verfügbare Modelle anzeigen" +-- This host uses the model configured at the provider level. No model selection is available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "Dieser Host verwendet das auf Anbieterebene konfigurierte Modell. Es ist keine Modellauswahl verfügbar." + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Derzeit können wir die Modelle für den ausgewählten Anbieter und/oder Host nicht abfragen. Bitte geben Sie daher den Modellnamen manuell ein." -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T416738168"] = "Modellauswahl" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T504465522"] = "Wir können derzeit nicht mit dem Anbieter kommunizieren, um Modelle zu laden. Bitte versuchen Sie es später erneut." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T808120719"] = "Host" @@ -4638,9 +4653,15 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T2842060373"] = -- Please enter a transcription model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T3703662664"] = "Bitte geben Sie den Namen eines Transkriptionsmodells ein." +-- This host uses the model configured at the provider level. No model selection is available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T3783329915"] = "Dieser Host verwendet das auf Anbieterebene konfigurierte Modell. Eine Modellauswahl ist nicht verfügbar." + -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T416738168"] = "Modellauswahl" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T504465522"] = "Wir können derzeit nicht mit dem Anbieter kommunizieren, um Modelle zu laden. Bitte versuchen Sie es später erneut." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T808120719"] = "Host" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 73bca73a..1f75030c 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -2106,6 +2106,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T14695 -- Add Embedding UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1738753945"] = "Add Embedding" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1760715963"] = "Uses the provider-configured model" + -- Are you sure you want to delete the embedding provider '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELEMBEDDINGS::T1825371968"] = "Are you sure you want to delete the embedding provider '{0}'?" @@ -2169,6 +2172,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T162847 -- Description UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1725856265"] = "Description" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1760715963"] = "Uses the provider-configured model" + -- Add Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T1806589097"] = "Add Provider" @@ -2211,9 +2217,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T291173 -- Configured LLM Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3019870540"] = "Configured LLM Providers" --- as selected by provider -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3082210376"] = "as selected by provider" - -- Edit UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELPROVIDERS::T3267849393"] = "Edit" @@ -2271,6 +2274,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T14 -- Add transcription provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1645238629"] = "Add transcription provider" +-- Uses the provider-configured model +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T1760715963"] = "Uses the provider-configured model" + -- Add Transcription Provider UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T2066315685"] = "Add Transcription Provider" @@ -2295,7 +2301,7 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T40 -- Configured Transcription Providers UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T4210863523"] = "Configured Transcription Providers" --- With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure providers' section. +-- With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure LLM providers' section. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T584860404"] = "With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure LLM providers' section." -- This transcription provider is managed by your organization. @@ -3210,6 +3216,9 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T290547799"] = "Cur -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T416738168"] = "Model selection" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::EMBEDDINGPROVIDERDIALOG::T808120719"] = "Host" @@ -3417,12 +3426,18 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3361153305"] = "Show Expert -- Show available models UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3763891899"] = "Show available models" +-- This host uses the model configured at the provider level. No model selection is available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T3783329915"] = "This host uses the model configured at the provider level. No model selection is available." + -- Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T4116737656"] = "Currently, we cannot query the models for the selected provider and/or host. Therefore, please enter the model name manually." -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T416738168"] = "Model selection" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::PROVIDERDIALOG::T808120719"] = "Host" @@ -4638,9 +4653,15 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T2842060373"] = -- Please enter a transcription model name. UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T3703662664"] = "Please enter a transcription model name." +-- This host uses the model configured at the provider level. No model selection is available. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T3783329915"] = "This host uses the model configured at the provider level. No model selection is available." + -- Model selection UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T416738168"] = "Model selection" +-- We are currently unable to communicate with the provider to load models. Please try again later. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T504465522"] = "We are currently unable to communicate with the provider to load models. Please try again later." + -- Host UI_TEXT_CONTENT["AISTUDIO::DIALOGS::TRANSCRIPTIONPROVIDERDIALOG::T808120719"] = "Host" diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 3e207da5..4dd27763 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -554,10 +554,22 @@ public abstract class BaseProvider : IProvider, ISecretId await using var fileStream = File.OpenRead(audioFilePath); using var fileContent = new StreamContent(fileStream); + + // Set the content type based on the file extension: fileContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); - + + // Add the file content to the form data: form.Add(fileContent, "file", Path.GetFileName(audioFilePath)); - form.Add(new StringContent(transcriptionModel.Id), "model"); + + // + // Add the model name to the form data. Ensure that a model name is always provided. + // Otherwise, the StringContent constructor will throw an exception. + // + var modelName = transcriptionModel.Id; + if (string.IsNullOrWhiteSpace(modelName)) + modelName = "placeholder"; + + form.Add(new StringContent(modelName), "model"); using var request = new HttpRequestMessage(HttpMethod.Post, host.TranscriptionURL()); request.Content = form; diff --git a/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs b/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs index ea548923..ffaa0d06 100644 --- a/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs +++ b/app/MindWork AI Studio/Provider/LLMProvidersExtensions.cs @@ -327,6 +327,32 @@ public static class LLMProvidersExtensions _ => false, }; + /// + /// Determines if the model selection should be completely hidden for LLM providers. + /// This is the case when the host does not support model selection (e.g., llama.cpp). + /// + /// The provider. + /// The host for self-hosted providers. + /// True if model selection should be hidden; otherwise, false. + public static bool IsLLMModelSelectionHidden(this LLMProviders provider, Host host) => provider switch + { + LLMProviders.SELF_HOSTED => host is Host.LLAMA_CPP, + _ => false, + }; + + /// + /// Determines if the model selection should be completely hidden for transcription providers. + /// This is the case when the host does not support model selection (e.g., whisper.cpp). + /// + /// The provider. + /// The host for self-hosted providers. + /// True if model selection should be hidden; otherwise, false. + public static bool IsTranscriptionModelSelectionHidden(this LLMProviders provider, Host host) => provider switch + { + LLMProviders.SELF_HOSTED => host is Host.WHISPER_CPP, + _ => false, + }; + public static bool IsHostNeeded(this LLMProviders provider) => provider switch { LLMProviders.SELF_HOSTED => true, @@ -391,13 +417,13 @@ public static class LLMProvidersExtensions { case Host.NONE: case Host.LLAMA_CPP: + case Host.WHISPER_CPP: default: return false; case Host.OLLAMA: case Host.LM_STUDIO: case Host.VLLM: - case Host.WHISPER_CPP: return true; } } diff --git a/app/MindWork AI Studio/Provider/Model.cs b/app/MindWork AI Studio/Provider/Model.cs index 4e582f97..0cd43395 100644 --- a/app/MindWork AI Studio/Provider/Model.cs +++ b/app/MindWork AI Studio/Provider/Model.cs @@ -9,6 +9,22 @@ namespace AIStudio.Provider; /// The model's display name. public readonly record struct Model(string Id, string? DisplayName) { + /// + /// Special model ID used when the model is selected by the system/host + /// and cannot be changed by the user (e.g., llama.cpp, whisper.cpp). + /// + private const string SYSTEM_MODEL_ID = "::system::"; + + /// + /// Creates a system-configured model placeholder. + /// + public static readonly Model SYSTEM_MODEL = new(SYSTEM_MODEL_ID, null); + + /// + /// Checks if this model is the system-configured placeholder. + /// + public bool IsSystemModel => this == SYSTEM_MODEL; + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(Model).Namespace, nameof(Model)); #region Overrides of ValueType diff --git a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs index d63ec0d1..7cfb236b 100644 --- a/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs +++ b/app/MindWork AI Studio/Provider/SelfHosted/ProviderSelfHosted.cs @@ -149,31 +149,30 @@ public sealed class ProviderSelfHosted(Host host, string hostname) : BaseProvide } /// - public override Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) + public override async Task> GetTranscriptionModels(string? apiKeyProvisional = null, CancellationToken token = default) { try { switch (host) { case Host.WHISPER_CPP: - return Task.FromResult>( - new List - { - new("loaded-model", TB("Model as configured by whisper.cpp")), - }); + return new List + { + new("loaded-model", TB("Model as configured by whisper.cpp")), + }; case Host.OLLAMA: case Host.VLLM: - return this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, [], [], token, apiKeyProvisional); + return await this.LoadModels(SecretStoreType.TRANSCRIPTION_PROVIDER, [], [], token, apiKeyProvisional); default: - return Task.FromResult(Enumerable.Empty()); + return []; } } catch (Exception e) { - LOGGER.LogError(e, "Failed to load transcription models from self-hosted provider."); - return Task.FromResult(Enumerable.Empty()); + LOGGER.LogError($"Failed to load transcription models from self-hosted provider: {e.Message}"); + return []; } } diff --git a/app/MindWork AI Studio/Tools/TerminalLogger.cs b/app/MindWork AI Studio/Tools/TerminalLogger.cs index bafd5fa1..f6801e8a 100644 --- a/app/MindWork AI Studio/Tools/TerminalLogger.cs +++ b/app/MindWork AI Studio/Tools/TerminalLogger.cs @@ -64,7 +64,7 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) { textWriter.WriteLine(); foreach (var line in stackTrace.Split('\n')) - textWriter.WriteLine($" {line.TrimEnd()}"); + textWriter.WriteLine($" {colorCode}{line.TrimEnd()}{ANSI_RESET}"); } } else diff --git a/app/MindWork AI Studio/Tools/Validation/ProviderValidation.cs b/app/MindWork AI Studio/Tools/Validation/ProviderValidation.cs index 3b074ba9..bb72feb4 100644 --- a/app/MindWork AI Studio/Tools/Validation/ProviderValidation.cs +++ b/app/MindWork AI Studio/Tools/Validation/ProviderValidation.cs @@ -19,7 +19,9 @@ public sealed class ProviderValidation public Func> GetUsedInstanceNames { get; init; } = () => []; public Func GetHost { get; init; } = () => Host.NONE; - + + public Func IsModelProvidedManually { get; init; } = () => false; + public string? ValidatingHostname(string hostname) { if(this.GetProvider() != LLMProviders.SELF_HOSTED) @@ -70,12 +72,22 @@ public sealed class ProviderValidation public string? ValidatingModel(Model model) { - if(this.GetProvider() is LLMProviders.SELF_HOSTED && this.GetHost() == Host.LLAMA_CPP) + // For NONE providers, no validation is needed: + if (this.GetProvider() is LLMProviders.NONE) return null; - + + // For self-hosted llama.cpp or whisper.cpp, no model selection needed + // (model is loaded at startup): + if (this.GetProvider() is LLMProviders.SELF_HOSTED && this.GetHost() is Host.LLAMA_CPP or Host.WHISPER_CPP) + return null; + + // For manually entered models, this validation doesn't apply: + if (this.IsModelProvidedManually()) + return null; + if (model == default) return TB("Please select a model."); - + return null; } diff --git a/app/MindWork AI Studio/wwwroot/app.js b/app/MindWork AI Studio/wwwroot/app.js index 8a4e036d..aa6b8e2b 100644 --- a/app/MindWork AI Studio/wwwroot/app.js +++ b/app/MindWork AI Studio/wwwroot/app.js @@ -25,133 +25,4 @@ window.clearDiv = function (divName) { window.scrollToBottom = function(element) { element.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); -} - -window.playSound = function(soundPath) { - try { - const audio = new Audio(soundPath); - audio.play().catch(error => { - console.warn('Failed to play sound effect:', error); - }); - } catch (error) { - console.warn('Error creating audio element:', error); - } -}; - -let mediaRecorder; -let actualRecordingMimeType; -let changedMimeType = false; -let pendingChunkUploads = 0; - -window.audioRecorder = { - start: async function (dotnetRef, desiredMimeTypes = []) { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - - // Play start recording sound effect: - window.playSound('/sounds/start_recording.ogg'); - - // When only one mime type is provided as a string, convert it to an array: - if (typeof desiredMimeTypes === 'string') { - desiredMimeTypes = [desiredMimeTypes]; - } - - // Log sent mime types for debugging: - console.log('Audio recording - requested mime types: ', desiredMimeTypes); - - let mimeTypes = desiredMimeTypes.filter(type => typeof type === 'string' && type.trim() !== ''); - - // Next, we have to ensure that we have some default mime types to check as well. - // In case the provided list does not contain these, we append them: - // Use provided mime types or fallback to a default list: - const defaultMimeTypes = [ - 'audio/webm', - 'audio/ogg', - 'audio/mp4', - 'audio/mpeg', - ''// Fallback to browser default - ]; - - defaultMimeTypes.forEach(type => { - if (!mimeTypes.includes(type)) { - mimeTypes.push(type); - } - }); - - console.log('Audio recording - final mime types to check (included defaults): ', mimeTypes); - - // Find the first supported mime type: - actualRecordingMimeType = mimeTypes.find(type => - type === '' || MediaRecorder.isTypeSupported(type) - ) || ''; - - console.log('Audio recording - the browser selected the following mime type for recording: ', actualRecordingMimeType); - const options = actualRecordingMimeType ? { mimeType: actualRecordingMimeType } : {}; - mediaRecorder = new MediaRecorder(stream, options); - - // In case the browser changed the mime type: - actualRecordingMimeType = mediaRecorder.mimeType; - console.log('Audio recording - actual mime type used by the browser: ', actualRecordingMimeType); - - // Check the list of desired mime types against the actual one: - if (!desiredMimeTypes.includes(actualRecordingMimeType)) { - changedMimeType = true; - console.warn(`Audio recording - requested mime types ('${desiredMimeTypes.join(', ')}') do not include the actual mime type used by the browser ('${actualRecordingMimeType}').`); - } else { - changedMimeType = false; - } - - // Reset the pending uploads counter: - pendingChunkUploads = 0; - - // Stream each chunk directly to .NET as it becomes available: - mediaRecorder.ondataavailable = async (event) => { - if (event.data.size > 0) { - pendingChunkUploads++; - try { - const arrayBuffer = await event.data.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); - await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array); - } catch (error) { - console.error('Error sending audio chunk to .NET:', error); - } finally { - pendingChunkUploads--; - } - } - }; - - mediaRecorder.start(3000); // read the recorded data in 3-second chunks - return actualRecordingMimeType; - }, - - stop: async function () { - return new Promise((resolve) => { - - // Add an event listener to handle the stop event: - mediaRecorder.onstop = async () => { - - // Wait for all pending chunk uploads to complete before finalizing: - console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`); - while (pendingChunkUploads > 0) { - await new Promise(r => setTimeout(r, 10)); // wait 10 ms before checking again - } - - console.log('Audio recording - all chunks uploaded, finalizing.'); - - // Play stop recording sound effect: - window.playSound('/sounds/stop_recording.ogg'); - - // Stop all tracks to release the microphone: - mediaRecorder.stream.getTracks().forEach(track => track.stop()); - - // No need to process data here anymore, just signal completion: - resolve({ - mimeType: actualRecordingMimeType, - changedMimeType: changedMimeType, - }); - }; - - // Finally, stop the recording (which will actually trigger the onstop event): - mediaRecorder.stop(); - }); - } -}; \ No newline at end of file +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/audio.js b/app/MindWork AI Studio/wwwroot/audio.js new file mode 100644 index 00000000..689bc50f --- /dev/null +++ b/app/MindWork AI Studio/wwwroot/audio.js @@ -0,0 +1,306 @@ +// Shared the audio context for sound effects (Web Audio API does not register with Media Session): +let soundEffectContext = null; + +// Cache for decoded sound effect audio buffers: +const soundEffectCache = new Map(); + +// Track the preload state: +let soundEffectsPreloaded = false; + +// Queue system: tracks when the next sound can start playing. +// This prevents sounds from overlapping and getting "swallowed" by the audio system: +let nextAvailablePlayTime = 0; + +// Minimum gap between sounds in seconds (small buffer to ensure clean transitions): +const SOUND_GAP_SECONDS = 0.25; + +// List of all sound effects used in the app: +const SOUND_EFFECT_PATHS = [ + '/sounds/start_recording.ogg', + '/sounds/stop_recording.ogg', + '/sounds/transcription_done.ogg' +]; + +// Initialize the audio context with low-latency settings. +// Should be called from a user interaction (click, keypress) +// to satisfy browser autoplay policies: +window.initSoundEffects = async function() { + + if (soundEffectContext && soundEffectContext.state !== 'closed') { + // Already initialized, just ensure it's running: + if (soundEffectContext.state === 'suspended') { + await soundEffectContext.resume(); + } + + return; + } + + try { + // Create the context with the interactive latency hint for the lowest latency: + soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({ + latencyHint: 'interactive' + }); + + // Resume immediately (needed for Safari/macOS): + if (soundEffectContext.state === 'suspended') { + await soundEffectContext.resume(); + } + + // Reset the queue timing: + nextAvailablePlayTime = 0; + + // + // Play a very short silent buffer to "warm up" the audio pipeline. + // This helps prevent the first real sound from being cut off: + // + const silentBuffer = soundEffectContext.createBuffer(1, 1, soundEffectContext.sampleRate); + const silentSource = soundEffectContext.createBufferSource(); + silentSource.buffer = silentBuffer; + silentSource.connect(soundEffectContext.destination); + silentSource.start(0); + + console.log('Sound effects - AudioContext initialized with latency:', soundEffectContext.baseLatency); + + // Preload all sound effects in parallel: + if (!soundEffectsPreloaded) { + await window.preloadSoundEffects(); + } + } catch (error) { + console.warn('Failed to initialize sound effects:', error); + } +}; + +// Preload all sound effect files into the cache: +window.preloadSoundEffects = async function() { + if (soundEffectsPreloaded) { + return; + } + + // Ensure that the context exists: + if (!soundEffectContext || soundEffectContext.state === 'closed') { + soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({ + latencyHint: 'interactive' + }); + } + + console.log('Sound effects - preloading', SOUND_EFFECT_PATHS.length, 'sound files...'); + + const preloadPromises = SOUND_EFFECT_PATHS.map(async (soundPath) => { + try { + const response = await fetch(soundPath); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await soundEffectContext.decodeAudioData(arrayBuffer); + soundEffectCache.set(soundPath, audioBuffer); + + console.log('Sound effects - preloaded:', soundPath, 'duration:', audioBuffer.duration.toFixed(2), 's'); + } catch (error) { + console.warn('Sound effects - failed to preload:', soundPath, error); + } + }); + + await Promise.all(preloadPromises); + soundEffectsPreloaded = true; + console.log('Sound effects - all files preloaded'); +}; + +window.playSound = async function(soundPath) { + try { + // Initialize context if needed (fallback if initSoundEffects wasn't called): + if (!soundEffectContext || soundEffectContext.state === 'closed') { + soundEffectContext = new (window.AudioContext || window.webkitAudioContext)({ + latencyHint: 'interactive' + }); + + nextAvailablePlayTime = 0; + } + + // Resume if suspended (browser autoplay policy): + if (soundEffectContext.state === 'suspended') { + await soundEffectContext.resume(); + } + + // Check the cache for already decoded audio: + let audioBuffer = soundEffectCache.get(soundPath); + + if (!audioBuffer) { + // Fetch and decode the audio file (fallback if not preloaded): + console.log('Sound effects - loading on demand:', soundPath); + const response = await fetch(soundPath); + const arrayBuffer = await response.arrayBuffer(); + audioBuffer = await soundEffectContext.decodeAudioData(arrayBuffer); + soundEffectCache.set(soundPath, audioBuffer); + } + + // Calculate when this sound should start: + const currentTime = soundEffectContext.currentTime; + let startTime; + + if (currentTime >= nextAvailablePlayTime) { + // No sound is playing, or the previous sound has finished; start immediately: + startTime = 0; // 0 means "now" in Web Audio API + nextAvailablePlayTime = currentTime + audioBuffer.duration + SOUND_GAP_SECONDS; + } else { + // A sound is still playing; schedule this sound to start after it: + startTime = nextAvailablePlayTime; + nextAvailablePlayTime = startTime + audioBuffer.duration + SOUND_GAP_SECONDS; + console.log('Sound effects - queued:', soundPath, 'will play in', (startTime - currentTime).toFixed(2), 's'); + } + + // Create a new source node and schedule playback: + const source = soundEffectContext.createBufferSource(); + source.buffer = audioBuffer; + source.connect(soundEffectContext.destination); + source.start(startTime); + console.log('Sound effects - playing:', soundPath); + + } catch (error) { + console.warn('Failed to play sound effect:', error); + } +}; + +let mediaRecorder; +let actualRecordingMimeType; +let changedMimeType = false; +let pendingChunkUploads = 0; + +// Store the media stream so we can close the microphone later: +let activeMediaStream = null; + +// Delay in milliseconds to wait after getUserMedia() for Bluetooth profile switch (A2DP → HFP): +const BLUETOOTH_PROFILE_SWITCH_DELAY_MS = 1_600; + +window.audioRecorder = { + start: async function (dotnetRef, desiredMimeTypes = []) { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + activeMediaStream = stream; + + // Wait for Bluetooth headsets to complete the profile switch from A2DP to HFP. + // This prevents the first sound from being cut off during the switch: + console.log('Audio recording - waiting for Bluetooth profile switch...'); + await new Promise(r => setTimeout(r, BLUETOOTH_PROFILE_SWITCH_DELAY_MS)); + + // Play start recording sound effect: + await window.playSound('/sounds/start_recording.ogg'); + + // When only one mime type is provided as a string, convert it to an array: + if (typeof desiredMimeTypes === 'string') { + desiredMimeTypes = [desiredMimeTypes]; + } + + // Log sent mime types for debugging: + console.log('Audio recording - requested mime types: ', desiredMimeTypes); + + let mimeTypes = desiredMimeTypes.filter(type => typeof type === 'string' && type.trim() !== ''); + + // Next, we have to ensure that we have some default mime types to check as well. + // In case the provided list does not contain these, we append them: + // Use provided mime types or fallback to a default list: + const defaultMimeTypes = [ + 'audio/webm', + 'audio/ogg', + 'audio/mp4', + 'audio/mpeg', + ''// Fallback to browser default + ]; + + defaultMimeTypes.forEach(type => { + if (!mimeTypes.includes(type)) { + mimeTypes.push(type); + } + }); + + console.log('Audio recording - final mime types to check (included defaults): ', mimeTypes); + + // Find the first supported mime type: + actualRecordingMimeType = mimeTypes.find(type => + type === '' || MediaRecorder.isTypeSupported(type) + ) || ''; + + console.log('Audio recording - the browser selected the following mime type for recording: ', actualRecordingMimeType); + const options = actualRecordingMimeType ? { mimeType: actualRecordingMimeType } : {}; + mediaRecorder = new MediaRecorder(stream, options); + + // In case the browser changed the mime type: + actualRecordingMimeType = mediaRecorder.mimeType; + console.log('Audio recording - actual mime type used by the browser: ', actualRecordingMimeType); + + // Check the list of desired mime types against the actual one: + if (!desiredMimeTypes.includes(actualRecordingMimeType)) { + changedMimeType = true; + console.warn(`Audio recording - requested mime types ('${desiredMimeTypes.join(', ')}') do not include the actual mime type used by the browser ('${actualRecordingMimeType}').`); + } else { + changedMimeType = false; + } + + // Reset the pending uploads counter: + pendingChunkUploads = 0; + + // Stream each chunk directly to .NET as it becomes available: + mediaRecorder.ondataavailable = async (event) => { + if (event.data.size > 0) { + pendingChunkUploads++; + try { + const arrayBuffer = await event.data.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + await dotnetRef.invokeMethodAsync('OnAudioChunkReceived', uint8Array); + } catch (error) { + console.error('Error sending audio chunk to .NET:', error); + } finally { + pendingChunkUploads--; + } + } + }; + + mediaRecorder.start(3000); // read the recorded data in 3-second chunks + return actualRecordingMimeType; + }, + + stop: async function () { + return new Promise((resolve) => { + + // Add an event listener to handle the stop event: + mediaRecorder.onstop = async () => { + + // Wait for all pending chunk uploads to complete before finalizing: + console.log(`Audio recording - waiting for ${pendingChunkUploads} pending uploads.`); + while (pendingChunkUploads > 0) { + await new Promise(r => setTimeout(r, 10)); // wait 10 ms before checking again + } + + console.log('Audio recording - all chunks uploaded, finalizing.'); + + // Play stop recording sound effect: + await window.playSound('/sounds/stop_recording.ogg'); + + // + // IMPORTANT: Do NOT release the microphone here! + // Bluetooth headsets switch profiles (HFP → A2DP) when the microphone is released, + // which causes audio to be interrupted. We keep the microphone open so that the + // stop_recording and transcription_done sounds can play without interruption. + // + // Call window.audioRecorder.releaseMicrophone() after the last sound has played. + // + + // No need to process data here anymore, just signal completion: + resolve({ + mimeType: actualRecordingMimeType, + changedMimeType: changedMimeType, + }); + }; + + // Finally, stop the recording (which will actually trigger the onstop event): + mediaRecorder.stop(); + }); + }, + + // Release the microphone after all sounds have been played. + // This should be called after the transcription_done sound to allow + // Bluetooth headsets to switch back to A2DP profile without interrupting audio: + releaseMicrophone: function () { + if (activeMediaStream) { + console.log('Audio recording - releasing microphone (Bluetooth will switch back to A2DP)'); + activeMediaStream.getTracks().forEach(track => track.stop()); + activeMediaStream = null; + } + } +}; diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md index a861ccb0..bdf0faa9 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md @@ -1,3 +1,10 @@ # v26.1.2, build 232 (2026-01-xx xx:xx UTC) - Added the option to hide specific assistants by configuration plugins. This is useful for enterprise environments in organizations. -- Fixed a logging bug that prevented log events from being recorded in some cases. \ No newline at end of file +- Improved error handling for model loading in provider dialogs (LLMs, embeddings, transcriptions). +- Improved the microphone handling (transcription preview) so that all sound effects and the voice recording are processed without interruption. +- Improved the handling of self-hosted providers in the configuration dialogs (LLMs, embeddings, and transcriptions) when the host cannot provide a list of models. +- Fixed a logging bug that prevented log events from being recorded in some cases. +- Fixed a bug that allowed adding a provider (LLM, embedding, or transcription) without selecting a model. +- Fixed a bug with local transcription providers by handling errors correctly when the local provider is unavailable. +- Fixed a bug with local transcription providers by correctly handling empty model IDs. +- Fixed a bug affecting the transcription preview: previously, when you stopped music or other media, recorded or dictated text, and then tried to resume playback, the media wouldn’t resume as expected. This behavior is now fixed. \ No newline at end of file