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