From 5f18cbe2bf75224cd11bd0d3637ec742f903d063 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 18 Jan 2026 11:34:46 +0100 Subject: [PATCH 1/4] Improved stacktrace logging (#630) --- app/MindWork AI Studio/Tools/TerminalLogger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 0a504276d9e4082e49a89b1afdd12fa8eef32416 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 18 Jan 2026 16:01:02 +0100 Subject: [PATCH 2/4] Fixed audio handling (#631) --- app/MindWork AI Studio/App.razor | 1 + .../Components/VoiceRecorder.razor.cs | 29 ++ app/MindWork AI Studio/wwwroot/app.js | 131 +------- app/MindWork AI Studio/wwwroot/audio.js | 306 ++++++++++++++++++ .../wwwroot/changelog/v26.1.2.md | 4 +- 5 files changed, 340 insertions(+), 131 deletions(-) create mode 100644 app/MindWork AI Studio/wwwroot/audio.js 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/Components/VoiceRecorder.razor.cs b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs index 4ea00180..d29e28dd 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(); @@ -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/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..a1de1efb 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,5 @@ # 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 the microphone handling (transcription preview) so that all sound effects and the voice recording are processed without interruption. +- Fixed a logging bug that prevented log events from being recorded in some cases. +- 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 From 3abc2bf0f13a7e5828adda4a31b5eb4f7e45eb78 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 18 Jan 2026 17:15:18 +0100 Subject: [PATCH 3/4] Fixed several provider dialog issues (#629) --- .../Assistants/I18N/allTexts.lua | 27 ++++- .../Settings/SettingsPanelEmbeddings.razor | 2 +- .../Settings/SettingsPanelEmbeddings.razor.cs | 6 +- .../Settings/SettingsPanelProviders.razor | 16 +-- .../Settings/SettingsPanelProviders.razor.cs | 6 +- .../Settings/SettingsPanelTranscription.razor | 2 +- .../SettingsPanelTranscription.razor.cs | 6 +- .../Components/VoiceRecorder.razor.cs | 4 +- .../Dialogs/EmbeddingProviderDialog.razor | 8 +- .../Dialogs/EmbeddingProviderDialog.razor.cs | 59 ++++++++--- .../Dialogs/ProviderDialog.razor | 99 +++++++++++-------- .../Dialogs/ProviderDialog.razor.cs | 77 +++++++++++---- .../Dialogs/TranscriptionProviderDialog.razor | 97 ++++++++++-------- .../TranscriptionProviderDialog.razor.cs | 78 +++++++++++---- .../plugin.lua | 37 +++++-- .../plugin.lua | 29 +++++- .../Provider/BaseProvider.cs | 16 ++- .../Provider/LLMProvidersExtensions.cs | 28 +++++- app/MindWork AI Studio/Provider/Model.cs | 16 +++ .../Provider/SelfHosted/ProviderSelfHosted.cs | 19 ++-- .../Tools/Validation/ProviderValidation.cs | 20 +++- .../wwwroot/changelog/v26.1.2.md | 5 + 22 files changed, 472 insertions(+), 185 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 1ec8c022..4908fbb6 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -2101,6 +2101,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}'?" @@ -2164,6 +2167,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" @@ -2206,9 +2212,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" @@ -2266,6 +2269,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" @@ -3205,6 +3211,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" @@ -3412,12 +3421,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" @@ -4633,9 +4648,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 d29e28dd..71f3d602 100644 --- a/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs +++ b/app/MindWork AI Studio/Components/VoiceRecorder.razor.cs @@ -242,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."))); @@ -259,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)) 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 ebab2f8c..6facc933 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 @@ -2103,6 +2103,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?" @@ -2166,11 +2169,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" @@ -2206,10 +2212,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" @@ -2268,6 +2271,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" @@ -2292,6 +2298,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." @@ -2301,9 +2310,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" @@ -3207,6 +3213,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" @@ -3414,12 +3423,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" @@ -4635,9 +4650,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 6e0ba76c..915a5485 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 @@ -2103,6 +2103,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}'?" @@ -2166,6 +2169,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" @@ -2208,9 +2214,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" @@ -2268,6 +2271,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" @@ -2292,7 +2298,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. @@ -3207,6 +3213,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" @@ -3414,12 +3423,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" @@ -4635,9 +4650,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/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/changelog/v26.1.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md index a1de1efb..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,5 +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. +- 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 From 85af3ed6513f50f3a0e7a8b60e99d07ec4a4583d Mon Sep 17 00:00:00 2001 From: Sabrina-devops Date: Sun, 18 Jan 2026 19:52:19 +0100 Subject: [PATCH 4/4] Enabled sending document analysis sessions with attachments in the chat (#627) Co-authored-by: Thorsten Sommer --- .../DocumentAnalysisAssistant.razor.cs | 43 +++++- .../Assistants/I18N/allTexts.lua | 9 ++ .../Components/ChatComponent.razor.cs | 3 +- .../plugin.lua | 9 ++ .../plugin.lua | 9 ++ .../Tools/WorkspaceBehaviour.cs | 124 ++++++++++++++---- .../wwwroot/changelog/v26.1.2.md | 4 +- 7 files changed, 171 insertions(+), 30 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index d0265178..612e91eb 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -118,10 +118,47 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore (this.IsNoPolicySelected || this.loadedDocumentPaths.Count==0); - protected override ChatThread ConvertToChatThread => (this.chatThread ?? new()) with + protected override ChatThread ConvertToChatThread { - SystemPrompt = SystemPrompts.DEFAULT, - }; + get + { + if (this.chatThread is null || this.chatThread.Blocks.Count < 2) + { + return new ChatThread + { + SystemPrompt = SystemPrompts.DEFAULT + }; + } + + return new ChatThread + { + ChatId = Guid.NewGuid(), + Name = string.Format(T("{0} - Document Analysis Session"), this.selectedPolicy?.PolicyName ?? T("Empty")), + SystemPrompt = SystemPrompts.DEFAULT, + Blocks = + [ + // Replace the first "user block" (here, it was/is the block generated by the assistant) with a new one + // that includes the loaded document paths and a standard message about the previous analysis session: + new ContentBlock + { + Time = this.chatThread.Blocks.First().Time, + Role = ChatRole.USER, + HideFromUser = false, + ContentType = ContentType.TEXT, + Content = new ContentText + { + Text = this.T("The result of your previous document analysis session."), + FileAttachments = this.loadedDocumentPaths.ToList(), + } + }, + + // Then, append the last block of the current chat thread + // (which is expected to be the AI response): + this.chatThread.Blocks.Last(), + ] + }; + } + } protected override void ResetForm() { diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 4908fbb6..af047746 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -382,6 +382,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::CODING::COMMONCODINGLANGUAGEEXTENSIONS::T -- None UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::CODING::COMMONCODINGLANGUAGEEXTENSIONS::T810547195"] = "None" +-- {0} - Document Analysis Session +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T108097007"] = "{0} - Document Analysis Session" + -- Use the analysis and output rules to define how the AI evaluates your documents and formats the results. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1155482668"] = "Use the analysis and output rules to define how the AI evaluates your documents and formats the results." @@ -436,6 +439,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Export policy as configuration section UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2556564432"] = "Export policy as configuration section" +-- The result of your previous document analysis session. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2570551055"] = "The result of your previous document analysis session." + -- Are you sure you want to delete the document analysis policy '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2582525917"] = "Are you sure you want to delete the document analysis policy '{0}'?" @@ -469,6 +475,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Document Analysis Assistant UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T348883878"] = "Document Analysis Assistant" +-- Empty +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3512147854"] = "Empty" + -- Analysis and output rules UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3555314296"] = "Analysis and output rules" diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index f6110e41..5cf71ff1 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -97,7 +97,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Use chat thread sent by the user: this.ChatThread = deferredContent; - this.Logger.LogInformation($"The chat '{this.ChatThread.Name}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now."); + this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now."); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); // We know already that the chat thread is not null, @@ -202,7 +202,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Select the correct provider: await this.SelectProviderWhenLoadingChat(); - await base.OnInitializedAsync(); } 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 6facc933..5fe8df65 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 @@ -384,6 +384,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::CODING::COMMONCODINGLANGUAGEEXTENSIONS::T -- None UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::CODING::COMMONCODINGLANGUAGEEXTENSIONS::T810547195"] = "Keine" +-- {0} - Document Analysis Session +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T108097007"] = "{0} – Sitzung zur Dokumentenanalyse" + -- Use the analysis and output rules to define how the AI evaluates your documents and formats the results. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1155482668"] = "Verwenden Sie die Analyse- und Ausgaberegeln, um festzulegen, wie die KI Ihre Dokumente bewertet und die Ergebnisse formatiert." @@ -438,6 +441,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Export policy as configuration section UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2556564432"] = "Exportieren Sie das Regelwerk als Konfigurationsabschnitt" +-- The result of your previous document analysis session. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2570551055"] = "Das Ergebnis Ihrer vorherigen Dokumentenanalyse-Sitzung." + -- Are you sure you want to delete the document analysis policy '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2582525917"] = "Möchten Sie das Regelwerk '{0}' wirklich löschen?" @@ -471,6 +477,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Document Analysis Assistant UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T348883878"] = "Assistent für die Dokumentenanalyse" +-- Empty +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3512147854"] = "Leer" + -- Analysis and output rules UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3555314296"] = "Analyse- und Ausgaberegeln" 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 915a5485..ecdb962d 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 @@ -384,6 +384,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::CODING::COMMONCODINGLANGUAGEEXTENSIONS::T -- None UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::CODING::COMMONCODINGLANGUAGEEXTENSIONS::T810547195"] = "None" +-- {0} - Document Analysis Session +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T108097007"] = "{0} - Document Analysis Session" + -- Use the analysis and output rules to define how the AI evaluates your documents and formats the results. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T1155482668"] = "Use the analysis and output rules to define how the AI evaluates your documents and formats the results." @@ -438,6 +441,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Export policy as configuration section UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2556564432"] = "Export policy as configuration section" +-- The result of your previous document analysis session. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2570551055"] = "The result of your previous document analysis session." + -- Are you sure you want to delete the document analysis policy '{0}'? UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T2582525917"] = "Are you sure you want to delete the document analysis policy '{0}'?" @@ -471,6 +477,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTA -- Document Analysis Assistant UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T348883878"] = "Document Analysis Assistant" +-- Empty +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3512147854"] = "Empty" + -- Analysis and output rules UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::DOCUMENTANALYSIS::DOCUMENTANALYSISASSISTANT::T3555314296"] = "Analysis and output rules" diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index 1eab21bc..253b4431 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -11,8 +12,50 @@ namespace AIStudio.Tools; public static class WorkspaceBehaviour { + private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(WorkspaceBehaviour)); + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour)); - + + /// + /// Semaphores for synchronizing chat storage operations per chat. + /// This prevents race conditions when multiple threads try to write + /// the same chat file simultaneously. + /// + private static readonly ConcurrentDictionary CHAT_STORAGE_SEMAPHORES = new(); + + /// + /// Timeout for acquiring the chat storage semaphore. + /// + private static readonly TimeSpan SEMAPHORE_TIMEOUT = TimeSpan.FromSeconds(6); + + private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) + { + var key = $"{workspaceId}_{chatId}"; + return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + } + + /// + /// Tries to acquire the chat storage semaphore within the configured timeout. + /// + /// The workspace ID. + /// The chat ID. + /// The name of the calling method for logging purposes. + /// A tuple containing whether the semaphore was acquired and the semaphore instance. + private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName) + { + var semaphore = GetChatSemaphore(workspaceId, chatId); + var acquired = await semaphore.WaitAsync(SEMAPHORE_TIMEOUT); + + if (!acquired) + LOG.LogWarning("Failed to acquire chat storage semaphore within {Timeout} seconds for workspace '{WorkspaceId}', chat '{ChatId}' in method '{CallerName}'. Skipping operation to prevent potential race conditions or deadlocks.", + SEMAPHORE_TIMEOUT.TotalSeconds, + workspaceId, + chatId, + callerName); + + return (acquired, semaphore); + } + public static readonly JsonSerializerOptions JSON_OPTIONS = new() { WriteIndented = true, @@ -37,35 +80,52 @@ public static class WorkspaceBehaviour public static async Task StoreChat(ChatThread chat) { - string chatDirectory; - if (chat.WorkspaceId == Guid.Empty) - chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); - else - chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); - - // Ensure the directory exists: - Directory.CreateDirectory(chatDirectory); - - // Save the chat name: - var chatNamePath = Path.Join(chatDirectory, "name"); - await File.WriteAllTextAsync(chatNamePath, chat.Name); - - // Save the thread as thread.json: - var chatPath = Path.Join(chatDirectory, "thread.json"); - await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); + // Try to acquire the semaphore for this specific chat to prevent concurrent writes to the same file: + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChat)); + if (!acquired) + return; + + try + { + string chatDirectory; + if (chat.WorkspaceId == Guid.Empty) + chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); + else + chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); + + // Ensure the directory exists: + Directory.CreateDirectory(chatDirectory); + + // Save the chat name: + var chatNamePath = Path.Join(chatDirectory, "name"); + await File.WriteAllTextAsync(chatNamePath, chat.Name); + + // Save the thread as thread.json: + var chatPath = Path.Join(chatDirectory, "thread.json"); + await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); + } + finally + { + semaphore.Release(); + } } public static async Task LoadChat(LoadChat loadChat) { - 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()); - - if(!Directory.Exists(chatPath)) + // Try to acquire the semaphore for this specific chat to prevent concurrent read/writes to the same file: + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChat)); + if (!acquired) return null; - + try { + 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()); + + if(!Directory.Exists(chatPath)) + return null; + var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); var chat = JsonSerializer.Deserialize(chatData, JSON_OPTIONS); return chat; @@ -74,6 +134,10 @@ public static class WorkspaceBehaviour { return null; } + finally + { + semaphore.Release(); + } } public static async Task LoadWorkspaceName(Guid workspaceId) @@ -144,7 +208,19 @@ public static class WorkspaceBehaviour else chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); - Directory.Delete(chatDirectory, true); + // Try to acquire the semaphore to prevent deleting while another thread is writing: + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChat)); + if (!acquired) + return; + + try + { + Directory.Delete(chatDirectory, true); + } + finally + { + semaphore.Release(); + } } private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) 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 bdf0faa9..2e7c9b53 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md @@ -3,8 +3,10 @@ - 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. +- Improved the document analysis assistant (in preview) by allowing users to send results to a new chat to ask follow-up questions. Thanks to Sabrina `Sabrina-devops` for this contribution. - 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 +- 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. +- Fixed a rare bug that occurred when multiple threads tried to manage the same chat thread. \ No newline at end of file