From a0753488b3050af1ea625edf703b13970109f28c Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 24 May 2026 13:50:42 +0200 Subject: [PATCH] Added support for parallel AI job processing (#774) --- .../Assistants/I18N/allTexts.lua | 15 + .../Components/ChatComponent.razor | 8 +- .../Components/ChatComponent.razor.cs | 111 ++--- .../Components/TreeItemData.cs | 6 + .../Components/Workspaces.razor | 14 +- .../Components/Workspaces.razor.cs | 59 ++- .../Layout/MainLayout.razor | 2 +- .../Layout/MainLayout.razor.cs | 16 +- .../plugin.lua | 15 + .../plugin.lua | 17 +- app/MindWork AI Studio/Program.cs | 2 + .../Tools/AIJobs/AIJobKind.cs | 7 + .../Tools/AIJobs/AIJobSchedulingClass.cs | 8 + .../Tools/AIJobs/AIJobService.cs | 384 ++++++++++++++++++ .../Tools/AIJobs/AIJobSnapshot.cs | 34 ++ .../Tools/AIJobs/AIJobStatus.cs | 12 + .../Tools/AIJobs/ChatGenerationRequest.cs | 20 + app/MindWork AI Studio/Tools/Event.cs | 220 +++++++++- .../wwwroot/changelog/v26.5.5.md | 2 + 19 files changed, 883 insertions(+), 69 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs create mode 100644 app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs create mode 100644 app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs create mode 100644 app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs create mode 100644 app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs create mode 100644 app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 72cb83c2..2a4c7e1b 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -6817,6 +6817,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = -- Use no profile UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile" +-- The selected model is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T1578005752"] = "The selected model is not available." + +-- The selected provider is not allowed for this chat. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T174545104"] = "The selected provider is not allowed for this chat." + +-- The AI job failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T237448388"] = "The AI job failed. The message is: '{0}'" + +-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings." + +-- We could load models from '{0}', but the provider did not return any usable text models. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models." + -- SSO (Kerberos) UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index 6ab7d977..db4c1ee1 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -68,7 +68,7 @@ @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY) { - + } @@ -89,14 +89,14 @@ @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) { - + } @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) { - + } @@ -134,7 +134,7 @@ } - @if (this.isStreaming && this.cancellationTokenSource is not null) + @if (this.IsCurrentChatStreaming) { diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 71337e9e..b17a582b 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -3,6 +3,7 @@ using AIStudio.Dialogs; using AIStudio.Provider; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AIJobs; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; @@ -47,6 +48,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable [Inject] private IJSRuntime JsRuntime { get; init; } = null!; + [Inject] + private AIJobService AIJobService { get; init; } = null!; + private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); @@ -58,7 +62,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private bool mustScrollToBottomAfterRender; private InnerScrolling scrollingArea = null!; private byte scrollRenderCountdown; - private bool isStreaming; private string userInput = string.Empty; private bool mustStoreChat; private bool mustLoadChat; @@ -67,8 +70,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private string currentWorkspaceName = string.Empty; private Guid currentWorkspaceId = Guid.Empty; private Guid currentChatThreadId = Guid.Empty; + private Guid foregroundChatId = Guid.Empty; private int workspaceHeaderSyncVersion; - private CancellationTokenSource? cancellationTokenSource; private HashSet chatDocumentPaths = []; // Unfortunately, we need the input field reference to blur the focus away. Without @@ -80,7 +83,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable protected override async Task OnInitializedAsync() { // Apply the filters for the message bus: - this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.WORKSPACE_LOADED_CHAT_CHANGED ]); + this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.WORKSPACE_LOADED_CHAT_CHANGED, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); @@ -217,6 +220,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Select the correct provider: await this.SelectProviderWhenLoadingChat(); + await this.SyncForegroundChatAsync(); await base.OnInitializedAsync(); } @@ -273,6 +277,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable protected override async Task OnParametersSetAsync() { await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.SyncForegroundChatAsync(); await base.OnParametersSetAsync(); } @@ -333,7 +338,23 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.WorkspaceName(this.currentWorkspaceName); } + private async Task SyncForegroundChatAsync() + { + var nextForegroundChatId = this.ChatThread?.ChatId ?? Guid.Empty; + if (this.foregroundChatId == nextForegroundChatId) + return; + + if (this.foregroundChatId != Guid.Empty) + await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); + + this.foregroundChatId = nextForegroundChatId; + if (this.foregroundChatId != Guid.Empty) + await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, true); + } + private bool IsProviderSelected => this.Provider.UsedLLMProvider != LLMProviders.NONE; + + private bool IsCurrentChatStreaming => this.ChatThread is not null && this.AIJobService.IsChatGenerationActive(this.ChatThread.ChatId); private string ProviderPlaceholder => this.IsProviderSelected ? T("Type your input here...") : T("Select a provider first"); @@ -453,7 +474,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if (!this.IsProviderSelected) return true; - if(this.isStreaming) + if(this.IsCurrentChatStreaming) return true; if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) @@ -614,7 +635,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable await this.inputField.BlurAsync(); // Enable the stream state for the chat component: - this.isStreaming = true; this.hasUnsavedChanges = true; if (this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading) @@ -624,38 +644,23 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable } this.Logger.LogDebug($"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'."); - - using (this.cancellationTokenSource = new()) + await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest { - this.StateHasChanged(); - - // Use the selected provider to get the AI response. - // By awaiting this line, we wait for the entire - // content to be streamed. - this.ChatThread = await aiText.CreateFromProviderAsync(this.Provider.CreateProvider(), this.Provider.Model, lastUserPrompt, this.ChatThread, this.cancellationTokenSource.Token); - } - - this.cancellationTokenSource = null; + ChatThread = this.ChatThread!, + AIText = aiText, + LastUserPrompt = lastUserPrompt, + ProviderSettings = this.Provider, + IsForeground = true, + }); - // Save the chat: - if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) - { - await this.SaveThread(); - this.hasUnsavedChanges = false; - } - - // Disable the stream state: - this.isStreaming = false; - - // Update the UI: + await this.SyncForegroundChatAsync(); this.StateHasChanged(); } private async Task CancelStreaming() { - if (this.cancellationTokenSource is not null) - if(!this.cancellationTokenSource.IsCancellationRequested) - await this.cancellationTokenSource.CancelAsync(); + if (this.ChatThread is not null) + await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId); } private async Task SaveThread() @@ -685,7 +690,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // Want the user to manage the chat storage manually? In that case, we have to ask the user // about possible data loss: // - if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) + if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming) { var dialogParameters = new DialogParameters { @@ -718,7 +723,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable // // Reset our state: // - this.isStreaming = false; this.hasUnsavedChanges = false; this.userInput = string.Empty; @@ -788,6 +792,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.ApplyStandardDataSourceOptions(); // Notify the parent component about the change: + await this.SyncForegroundChatAsync(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } @@ -796,7 +801,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(this.ChatThread is null) return; - if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) + if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming) { var confirmationDialogParameters = new DialogParameters { @@ -836,18 +841,21 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private async Task LoadedChatChanged() { - this.isStreaming = false; this.hasUnsavedChanges = false; this.userInput = string.Empty; if (this.ChatThread is not null) { + this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread; + await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.SyncForegroundChatAsync(); this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); } else { this.ClearWorkspaceHeaderState(); + await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); } @@ -863,12 +871,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable private async Task ResetState() { - this.isStreaming = false; this.hasUnsavedChanges = false; this.userInput = string.Empty; this.ClearWorkspaceHeaderState(); this.ChatThread = null; + await this.SyncForegroundChatAsync(); this.ApplyStandardDataSourceOptions(); await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } @@ -995,6 +1003,19 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable case Event.WORKSPACE_LOADED_CHAT_CHANGED: await this.LoadedChatChanged(); break; + + case Event.AI_JOB_CHANGED: + case Event.AI_JOB_FINISHED: + case Event.CHAT_GENERATION_CHANGED: + if (data is AIJobSnapshot { Kind: AIJobKind.CHAT_GENERATION } snapshot && this.ChatThread?.ChatId == snapshot.SubjectId) + { + this.ChatThread = this.AIJobService.TryGetLiveChatThread(snapshot.SubjectId) ?? this.ChatThread; + if (!snapshot.IsActive) + this.hasUnsavedChanges = false; + + this.StateHasChanged(); + } + break; } } @@ -1005,6 +1026,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable case Event.HAS_CHAT_UNSAVED_CHANGES: if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) return Task.FromResult((TResult?) (object) false); + + if (this.IsCurrentChatStreaming) + return Task.FromResult((TResult?) (object) false); return Task.FromResult((TResult?)(object)this.hasUnsavedChanges); } @@ -1024,21 +1048,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.hasUnsavedChanges = false; } - if (this.cancellationTokenSource is not null) - { - try - { - if(!this.cancellationTokenSource.IsCancellationRequested) - await this.cancellationTokenSource.CancelAsync(); - - this.cancellationTokenSource.Dispose(); - } - catch - { - // ignored - } - } + await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false); } #endregion -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/TreeItemData.cs b/app/MindWork AI Studio/Components/TreeItemData.cs index 9ae74dab..4bd951f6 100644 --- a/app/MindWork AI Studio/Components/TreeItemData.cs +++ b/app/MindWork AI Studio/Components/TreeItemData.cs @@ -12,10 +12,16 @@ public class TreeItemData : ITreeItem public string Icon { get; init; } = string.Empty; + public string DefaultIcon { get; init; } = string.Empty; + public TreeItemType Type { get; init; } public string Path { get; init; } = string.Empty; + public Guid ChatId { get; init; } + + public Guid WorkspaceId { get; init; } + public bool Expandable { get; init; } = true; public DateTimeOffset LastEditTime { get; init; } diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index 75d840e9..f49864fc 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -24,7 +24,7 @@ else case TreeItemData treeItem: @if (treeItem.Type is TreeItemType.LOADING) { - + @@ -32,7 +32,7 @@ else } else if (treeItem.Type is TreeItemType.CHAT) { - +
@@ -48,15 +48,15 @@ else
- + - + - +
@@ -65,7 +65,7 @@ else } else if (treeItem.Type is TreeItemType.WORKSPACE) { - +
@@ -86,7 +86,7 @@ else } else { - +
diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index 106d5719..c8220d33 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -4,6 +4,7 @@ using System.Text.Json; using AIStudio.Chat; using AIStudio.Dialogs; using AIStudio.Settings; +using AIStudio.Tools.AIJobs; using Microsoft.AspNetCore.Components; @@ -18,6 +19,9 @@ public partial class Workspaces : MSGComponentBase [Inject] private ILogger Logger { get; init; } = null!; + + [Inject] + private AIJobService AIJobService { get; init; } = null!; [Parameter] public ChatThread? CurrentChatThread { get; set; } @@ -42,6 +46,7 @@ public partial class Workspaces : MSGComponentBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); + this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); _ = this.LoadTreeItemsAsync(startPrefetch: true); } @@ -111,7 +116,7 @@ public partial class Workspaces : MSGComponentBase var temporaryChatsChildren = new List>(); foreach (var temporaryChat in snapshot.TemporaryChats.OrderByDescending(x => x.LastEditTime)) - temporaryChatsChildren.Add(CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer)); + temporaryChatsChildren.Add(this.CreateChatTreeItem(temporaryChat, WorkspaceBranch.TEMPORARY_CHATS, depth: 1, icon: Icons.Material.Filled.Timer)); this.treeItems.Add(new TreeItemData { @@ -136,7 +141,7 @@ public partial class Workspaces : MSGComponentBase if (workspace.ChatsLoaded) { foreach (var workspaceChat in workspace.Chats.OrderByDescending(x => x.LastEditTime)) - children.Add(CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat)); + children.Add(this.CreateChatTreeItem(workspaceChat, WorkspaceBranch.WORKSPACES, depth: 2, icon: Icons.Material.Filled.Chat)); } else if (this.loadingWorkspaceChatLists.Contains(workspace.WorkspaceId)) children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath)); @@ -192,7 +197,7 @@ public partial class Workspaces : MSGComponentBase }; } - private static TreeItemData CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon) + private TreeItemData CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon) { return new TreeItemData { @@ -204,13 +209,43 @@ public partial class Workspaces : MSGComponentBase Branch = branch, Text = chat.Name, Icon = icon, + DefaultIcon = icon, Expandable = false, Path = chat.ChatPath, + ChatId = chat.ChatId, + WorkspaceId = chat.WorkspaceId, LastEditTime = chat.LastEditTime, }, }; } + private string GetTreeItemIcon(TreeItemData treeItem) + { + if (treeItem.Type is not TreeItemType.CHAT) + return treeItem.Icon; + + var defaultIcon = string.IsNullOrWhiteSpace(treeItem.DefaultIcon) ? treeItem.Icon : treeItem.DefaultIcon; + return this.GetChatTreeIcon(treeItem.ChatId, defaultIcon); + } + + private bool IsChatTreeItemBusy(TreeItemData treeItem) + { + return treeItem.Type is TreeItemType.CHAT && this.AIJobService.IsChatGenerationActive(treeItem.ChatId); + } + + private string GetChatTreeIcon(Guid chatId, string defaultIcon) + { + var snapshot = this.AIJobService.TryGetChatSnapshot(chatId); + return snapshot?.Status switch + { + AIJobStatus.WAITING_FOR_REMOTE => Icons.Material.Filled.HourglassTop, + AIJobStatus.RUNNING => Icons.Material.Filled.ChangeCircle, + AIJobStatus.CANCELED => Icons.Material.Filled.Cancel, + AIJobStatus.FAILED => Icons.Material.Filled.Error, + _ => defaultIcon, + }; + } + private async Task SafeStateHasChanged() { if (this.isDisposed) @@ -348,6 +383,9 @@ public partial class Workspaces : MSGComponentBase { var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); var chat = JsonSerializer.Deserialize(chatData, WorkspaceBehaviour.JSON_OPTIONS); + if (chat is not null) + chat = this.AIJobService.TryGetLiveChatThread(chat.ChatId) ?? chat; + if (switchToChat) { this.CurrentChatThread = chat; @@ -371,6 +409,9 @@ public partial class Workspaces : MSGComponentBase if (chat is null) return; + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; + if (askForConfirmation) { var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId); @@ -407,6 +448,9 @@ public partial class Workspaces : MSGComponentBase var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; + + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; var dialogParameters = new DialogParameters { @@ -525,6 +569,9 @@ public partial class Workspaces : MSGComponentBase var chat = await this.LoadChatAsync(chatPath, false); if (chat is null) return; + + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; var dialogParameters = new DialogParameters { @@ -597,6 +644,12 @@ public partial class Workspaces : MSGComponentBase case Event.PLUGINS_RELOADED: await this.ForceRefreshFromDiskAsync(); break; + + case Event.AI_JOB_CHANGED: + case Event.AI_JOB_FINISHED: + case Event.CHAT_GENERATION_CHANGED: + await this.SafeStateHasChanged(); + break; } } diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor b/app/MindWork AI Studio/Layout/MainLayout.razor index 908411f9..75807868 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor +++ b/app/MindWork AI Studio/Layout/MainLayout.razor @@ -17,7 +17,7 @@ @foreach (var navBarItem in this.navItems) { - + @navBarItem.Name } diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index a7a6a8df..f55c0e9f 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -1,6 +1,7 @@ using AIStudio.Dialogs; using AIStudio.Settings; using AIStudio.Settings.DataModel; +using AIStudio.Tools.AIJobs; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.Rust; using AIStudio.Tools.Services; @@ -26,6 +27,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [Inject] private RustService RustService { get; init; } = null!; + + [Inject] + private AIJobService AIJobService { get; init; } = null!; [Inject] private ISnackbar Snackbar { get; init; } = null!; @@ -96,7 +100,8 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [ Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR, Event.SHOW_WARNING, Event.SHOW_SUCCESS, Event.STARTUP_PLUGIN_SYSTEM, Event.PLUGINS_RELOADED, - Event.INSTALL_UPDATE, Event.STARTUP_COMPLETED, + Event.INSTALL_UPDATE, Event.STARTUP_COMPLETED, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, + Event.CHAT_GENERATION_CHANGED, ]); // Set the snackbar for the update service: @@ -186,6 +191,13 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan this.StateHasChanged(); break; + case Event.AI_JOB_CHANGED: + case Event.AI_JOB_FINISHED: + case Event.CHAT_GENERATION_CHANGED: + this.LoadNavItems(); + this.StateHasChanged(); + break; + case Event.SHOW_SUCCESS: if (data is DataSuccessMessage success) success.Show(this.Snackbar); @@ -296,7 +308,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager); yield return new(T("Home"), Icons.Material.Filled.Home, palette.DarkLighten, palette.GrayLight, Routes.HOME, true); - yield return new(T("Chat"), Icons.Material.Filled.Chat, palette.DarkLighten, palette.GrayLight, Routes.CHAT, false); + yield return new(T("Chat"), this.AIJobService.HasActiveJobs ? Icons.Material.Filled.Chat : Icons.Material.Outlined.Chat, palette.DarkLighten, palette.GrayLight, Routes.CHAT, false); yield return new(T("Assistants"), Icons.Material.Filled.Apps, palette.DarkLighten, palette.GrayLight, Routes.ASSISTANTS, false); if (PreviewFeatures.PRE_WRITER_MODE_2024.IsEnabled(this.SettingsManager)) 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 04abccb5..c017ab44 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 @@ -6819,6 +6819,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = -- Use no profile UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Kein Profil verwenden" +-- The selected model is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T1578005752"] = "Das ausgewählte Modell ist nicht verfügbar." + +-- The selected provider is not allowed for this chat. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T174545104"] = "Der ausgewählte Anbieter ist für diesen Chat nicht zulässig." + +-- The AI job failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T237448388"] = "Der KI-Auftrag ist fehlgeschlagen. Die Meldung lautet: „{0}“" + +-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "Das ausgewählte Modell „{0}“ ist bei „{1}“ nicht mehr verfügbar (Anbieter={2}). Bitte passen Sie Ihre Anbietereinstellungen an." + +-- We could load models from '{0}', but the provider did not return any usable text models. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "Wir konnten Modelle von „{0}“ laden, aber der Anbieter hat keine verwendbaren Textmodelle zurückgegeben." + -- SSO (Kerberos) UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" 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 e356ad7a..ec664712 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 @@ -6819,6 +6819,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] = -- Use no profile UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Use no profile" +-- The selected model is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T1578005752"] = "The selected model is not available." + +-- The selected provider is not allowed for this chat. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T174545104"] = "The selected provider is not allowed for this chat." + +-- The AI job failed. The message is: '{0}' +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T237448388"] = "The AI job failed. The message is: '{0}'" + +-- The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings." + +-- We could load models from '{0}', but the provider did not return any usable text models. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "We could load models from '{0}', but the provider did not return any usable text models." + -- SSO (Kerberos) UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" @@ -7810,4 +7825,4 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed w UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" -- Unnamed chat -UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" \ No newline at end of file +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 996c5c43..95ba5490 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -2,6 +2,7 @@ using AIStudio.Agents; using AIStudio.Agents.AssistantAudit; using AIStudio.Settings; using AIStudio.Tools.Databases; +using AIStudio.Tools.AIJobs; using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.Services; @@ -128,6 +129,7 @@ internal sealed class Program builder.Services.AddMudMarkdownClipboardService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs new file mode 100644 index 00000000..b6216caa --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobKind.cs @@ -0,0 +1,7 @@ +namespace AIStudio.Tools.AIJobs; + +public enum AIJobKind +{ + NONE, + CHAT_GENERATION, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs new file mode 100644 index 00000000..687a032a --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobSchedulingClass.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools.AIJobs; + +public enum AIJobSchedulingClass +{ + NONE, + TOP_LEVEL_USER_JOB, + INTERNAL_DEPENDENCY, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs new file mode 100644 index 00000000..4a6c991d --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -0,0 +1,384 @@ +using System.Collections.Concurrent; + +using AIStudio.Chat; +using AIStudio.Provider; +using AIStudio.Settings; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.RAG.RAGProcesses; + +namespace AIStudio.Tools.AIJobs; + +public sealed class AIJobService( + SettingsManager settingsManager, + MessageBus messageBus, + ILogger logger) +{ + private sealed class AIJobState + { + public required CancellationTokenSource CancellationTokenSource { get; init; } + + public required ChatGenerationRequest ChatGenerationRequest { get; init; } + + public required AIJobSnapshot Snapshot { get; set; } + + public DateTimeOffset LastCheckpoint { get; set; } + + public readonly Lock SyncRoot = new(); + } + + private static readonly TimeSpan STREAMING_EVENT_MIN_TIME = TimeSpan.FromSeconds(3); + + private static readonly TimeSpan CHECKPOINT_MIN_TIME = TimeSpan.FromSeconds(3); + + private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AIJobService).Namespace, nameof(AIJobService)); + + private readonly ConcurrentDictionary jobs = new(); + private readonly ConcurrentDictionary activeChatJobsByChatId = new(); + + public IReadOnlyCollection GetSnapshots() + { + return this.jobs.Values + .Select(job => job.Snapshot) + .OrderByDescending(snapshot => snapshot.UpdatedAt) + .ToList(); + } + + public bool HasActiveJobs => this.jobs.Values.Any(job => job.Snapshot.IsActive); + + public bool IsChatGenerationActive(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return false; + + return this.jobs.TryGetValue(jobId, out var job) && job.Snapshot.IsActive; + } + + public AIJobSnapshot? TryGetChatSnapshot(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return this.jobs.Values + .Select(job => job.Snapshot) + .Where(snapshot => snapshot.Kind is AIJobKind.CHAT_GENERATION && snapshot.SubjectId == chatId) + .MaxBy(snapshot => snapshot.UpdatedAt); + + return this.jobs.TryGetValue(jobId, out var activeJob) ? activeJob.Snapshot : null; + } + + public ChatThread? TryGetLiveChatThread(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return null; + + return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest.ChatThread : null; + } + + public async Task TryStartChatGenerationAsync(ChatGenerationRequest request) + { + if (this.activeChatJobsByChatId.TryGetValue(request.ChatThread.ChatId, out var existingJobId)) + return this.jobs.TryGetValue(existingJobId, out var existingJob) ? existingJob.Snapshot : null; + + var jobId = Guid.NewGuid(); + var rootJobId = request.ParentJobId ?? jobId; + var snapshot = new AIJobSnapshot + { + JobId = jobId, + Kind = AIJobKind.CHAT_GENERATION, + SubjectId = request.ChatThread.ChatId, + ParentJobId = request.ParentJobId, + RootJobId = rootJobId, + Priority = request.Priority, + IsForeground = request.IsForeground, + SchedulingClass = AIJobSchedulingClass.TOP_LEVEL_USER_JOB, + Status = AIJobStatus.WAITING_FOR_REMOTE, + Title = request.ChatThread.Name, + ProviderId = request.ProviderSettings.Id, + ModelId = request.ProviderSettings.Model.Id, + UpdatedAt = DateTimeOffset.Now, + }; + + var state = new AIJobState + { + CancellationTokenSource = new CancellationTokenSource(), + ChatGenerationRequest = request, + Snapshot = snapshot, + LastCheckpoint = DateTimeOffset.MinValue, + }; + + if (!this.activeChatJobsByChatId.TryAdd(request.ChatThread.ChatId, jobId)) + { + state.CancellationTokenSource.Dispose(); + return this.TryGetChatSnapshot(request.ChatThread.ChatId); + } + + if (!this.jobs.TryAdd(jobId, state)) + { + this.activeChatJobsByChatId.TryRemove(request.ChatThread.ChatId, out _); + state.CancellationTokenSource.Dispose(); + return null; + } + + request.AIText.InitialRemoteWait = true; + request.AIText.IsStreaming = false; + await CheckpointChatAsync(state, force: true); + await this.NotifyChangedAsync(state); + + _ = Task.Factory.StartNew(async () => await this.RunChatGenerationAsync(state), TaskCreationOptions.LongRunning); + return state.Snapshot; + } + + public async Task CancelAsync(Guid jobId) + { + if (!this.jobs.TryGetValue(jobId, out var job)) + return; + + if (!job.CancellationTokenSource.IsCancellationRequested) + await job.CancellationTokenSource.CancelAsync(); + } + + public async Task CancelChatGenerationAsync(Guid chatId) + { + if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId)) + return; + + await this.CancelAsync(jobId); + } + + public async Task SetForegroundAsync(AIJobKind kind, Guid subjectId, bool isForeground) + { + var matchingJobs = this.jobs.Values + .Where(job => job.Snapshot.Kind == kind && job.Snapshot.SubjectId == subjectId && job.Snapshot.IsActive) + .ToList(); + + foreach (var job in matchingJobs) + { + lock (job.SyncRoot) + { + job.Snapshot = job.Snapshot with + { + IsForeground = isForeground, + UpdatedAt = DateTimeOffset.Now, + }; + } + + await this.NotifyChangedAsync(job); + } + } + + private async Task RunChatGenerationAsync(AIJobState state) + { + var request = state.ChatGenerationRequest; + var token = state.CancellationTokenSource.Token; + + try + { + var provider = request.ProviderSettings.CreateProvider(); + var chatThread = request.ChatThread; + var aiText = request.AIText; + + if (!chatThread.IsLLMProviderAllowed(provider)) + { + logger.LogError("The provider is not allowed for chat '{ChatId}' due to data security reasons. Skipping the AI process.", chatThread.ChatId); + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, TB("The selected provider is not allowed for this chat.")); + return; + } + + if (!await this.CheckSelectedModelAvailability(provider, request.ProviderSettings.Model, token)) + { + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, TB("The selected model is not available.")); + return; + } + + try + { + var rag = new AISrcSelWithRetCtxVal(); + if (request.LastUserPrompt is not null) + { + chatThread = await rag.ProcessAsync(provider, request.LastUserPrompt, chatThread, token); + request.ChatThread = chatThread; + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED); + return; + } + catch (Exception e) + { + logger.LogError(e, "Skipping the RAG process due to an error."); + } + + var lastStreamingEvent = DateTimeOffset.MinValue; + aiText.InitialRemoteWait = true; + + await this.NotifyChangedAsync(state); + await foreach (var contentStreamChunk in provider.StreamChatCompletion(request.ProviderSettings.Model, chatThread, settingsManager, token)) + { + if (token.IsCancellationRequested) + break; + + aiText.InitialRemoteWait = false; + aiText.IsStreaming = true; + aiText.Text += contentStreamChunk; + aiText.Sources.MergeSources(contentStreamChunk.Sources); + + UpdateStatus(state, AIJobStatus.RUNNING); + var now = DateTimeOffset.Now; + if (!settingsManager.ConfigurationData.App.IsSavingEnergy || now - lastStreamingEvent > STREAMING_EVENT_MIN_TIME) + { + lastStreamingEvent = now; + await this.NotifyChangedAsync(state); + } + + await CheckpointChatAsync(state); + } + + await this.CompleteChatGenerationAsync(state, token.IsCancellationRequested ? AIJobStatus.CANCELED : AIJobStatus.COMPLETED); + } + catch (OperationCanceledException) + { + await this.CompleteChatGenerationAsync(state, AIJobStatus.CANCELED); + } + catch (Exception e) + { + logger.LogError(e, "The chat generation job '{JobId}' failed.", state.Snapshot.JobId); + await this.CompleteChatGenerationAsync(state, AIJobStatus.FAILED, e.Message); + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Stream, string.Format(TB("The AI job failed. The message is: '{0}'"), e.Message))); + } + } + + private async Task CompleteChatGenerationAsync(AIJobState state, AIJobStatus status, string errorMessage = "") + { + var aiText = state.ChatGenerationRequest.AIText; + aiText.InitialRemoteWait = false; + aiText.IsStreaming = false; + aiText.Text = aiText.Text.RemoveThinkTags().Trim(); + + lock (state.SyncRoot) + { + state.Snapshot = state.Snapshot with + { + Status = status, + ErrorMessage = errorMessage, + UpdatedAt = DateTimeOffset.Now, + }; + } + + this.activeChatJobsByChatId.TryRemove(state.ChatGenerationRequest.ChatThread.ChatId, out _); + await CheckpointChatAsync(state, force: true); + await this.NotifyChangedAsync(state); + await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot); + state.CancellationTokenSource.Dispose(); + } + + private static void UpdateStatus(AIJobState state, AIJobStatus status) + { + lock (state.SyncRoot) + { + if (state.Snapshot.Status == status) + return; + + state.Snapshot = state.Snapshot with + { + Status = status, + UpdatedAt = DateTimeOffset.Now, + }; + } + } + + private async Task NotifyChangedAsync(AIJobState state) + { + lock (state.SyncRoot) + { + state.Snapshot = state.Snapshot with + { + Title = state.ChatGenerationRequest.ChatThread.Name, + UpdatedAt = DateTimeOffset.Now, + }; + } + + await messageBus.SendMessage(null, Event.AI_JOB_CHANGED, state.Snapshot); + } + + private static async Task CheckpointChatAsync(AIJobState state, bool force = false) + { + var now = DateTimeOffset.Now; + if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME) + return; + + state.LastCheckpoint = now; + await WorkspaceBehaviour.StoreChatAsync(state.ChatGenerationRequest.ChatThread); + } + + private static bool ModelsMatch(Model modelA, Model modelB) + { + var idA = modelA.Id.Trim(); + var idB = modelB.Id.Trim(); + return string.Equals(idA, idB, StringComparison.OrdinalIgnoreCase); + } + + private async Task CheckSelectedModelAvailability(IProvider provider, Model chatModel, CancellationToken token = default) + { + if (chatModel.IsSystemModel) + return true; + + if (string.IsNullOrWhiteSpace(chatModel.Id)) + { + logger.LogWarning("Skipping AI request because model ID is null or white space."); + return false; + } + + if (!provider.HasModelLoadingCapability) + return true; + + IReadOnlyList loadedModels; + try + { + var modelLoadResult = await provider.GetTextModels(token: token); + if (!modelLoadResult.Success) + { + var userMessage = modelLoadResult.FailureReason.ToUserMessage(provider.InstanceName); + if (!string.IsNullOrWhiteSpace(userMessage)) + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, userMessage)); + + logger.LogWarning("Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because loading the model list failed with reason {FailureReason}.", provider.InstanceName, provider.Provider, modelLoadResult.FailureReason); + return false; + } + + loadedModels = modelLoadResult.Models; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception e) + { + logger.LogWarning(e, "Skipping selected model availability check for '{ProviderInstanceName}' (provider={ProviderType}) because the model list could not be loaded.", provider.InstanceName, provider.Provider); + return true; + } + + var availableModels = loadedModels.Where(model => !string.IsNullOrWhiteSpace(model.Id)).ToList(); + if (availableModels.Count == 0) + { + var emptyModelsMessage = string.Format( + TB("We could load models from '{0}', but the provider did not return any usable text models."), + provider.InstanceName); + + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, emptyModelsMessage)); + logger.LogWarning("Skipping AI request because there are no models available from '{ProviderInstanceName}' (provider={ProviderType}).", provider.InstanceName, provider.Provider); + return false; + } + + if (availableModels.Any(model => ModelsMatch(model, chatModel))) + return true; + + var message = string.Format( + TB("The selected model '{0}' is no longer available from '{1}' (provider={2}). Please adapt your provider settings."), + chatModel.Id, + provider.InstanceName, + provider.Provider); + + await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.CloudOff, message)); + logger.LogWarning("Skipping AI request because model '{ModelId}' is not available from '{ProviderInstanceName}' (provider={ProviderType}).", chatModel.Id, provider.InstanceName, provider.Provider); + return false; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs new file mode 100644 index 00000000..c8037e87 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobSnapshot.cs @@ -0,0 +1,34 @@ +namespace AIStudio.Tools.AIJobs; + +public sealed record AIJobSnapshot +{ + public Guid JobId { get; init; } + + public AIJobKind Kind { get; init; } + + public Guid SubjectId { get; init; } + + public Guid? ParentJobId { get; init; } + + public Guid RootJobId { get; init; } + + public int Priority { get; init; } + + public bool IsForeground { get; init; } + + public AIJobSchedulingClass SchedulingClass { get; init; } + + public AIJobStatus Status { get; init; } + + public string Title { get; init; } = string.Empty; + + public string ProviderId { get; init; } = string.Empty; + + public string ModelId { get; init; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; init; } + + public string ErrorMessage { get; init; } = string.Empty; + + public bool IsActive => this.Status is AIJobStatus.QUEUED or AIJobStatus.WAITING_FOR_REMOTE or AIJobStatus.RUNNING; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs new file mode 100644 index 00000000..49657ed8 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobStatus.cs @@ -0,0 +1,12 @@ +namespace AIStudio.Tools.AIJobs; + +public enum AIJobStatus +{ + NONE, + QUEUED, + WAITING_FOR_REMOTE, + RUNNING, + COMPLETED, + CANCELED, + FAILED, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs b/app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs new file mode 100644 index 00000000..4d04d5d3 --- /dev/null +++ b/app/MindWork AI Studio/Tools/AIJobs/ChatGenerationRequest.cs @@ -0,0 +1,20 @@ +using AIStudio.Chat; + +namespace AIStudio.Tools.AIJobs; + +public sealed record ChatGenerationRequest +{ + public required ChatThread ChatThread { get; set; } + + public required ContentText AIText { get; init; } + + public IContent? LastUserPrompt { get; init; } + + public required AIStudio.Settings.Provider ProviderSettings { get; init; } + + public Guid? ParentJobId { get; init; } + + public int Priority { get; init; } + + public bool IsForeground { get; init; } = true; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index bbec441d..8d5f465a 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -1,63 +1,281 @@ namespace AIStudio.Tools; +/// +/// Defines message bus events used for communication between UI components and services. +/// public enum Event { + /// + /// Represents the absence of a message bus event. + /// NONE, + + + + + // // Common events: + // + + /// + /// Requests registered receivers to refresh state that depends on the current UI state. + /// STATE_HAS_CHANGED, + + /// + /// Notifies receivers that the application configuration was changed and should be reloaded or re-applied. + /// CONFIGURATION_CHANGED, + + /// + /// Notifies receivers that the active color theme changed. + /// COLOR_THEME_CHANGED, + + /// + /// Requests startup initialization of the plugin system. + /// STARTUP_PLUGIN_SYSTEM, + + /// + /// Notifies receivers that the startup initialization completed. + /// STARTUP_COMPLETED, + + /// + /// Carries an enterprise environment that should be processed during startup. + /// STARTUP_ENTERPRISE_ENVIRONMENT, + + /// + /// Notifies receivers that the known enterprise environments changed. + /// ENTERPRISE_ENVIRONMENTS_CHANGED, + + /// + /// Notifies receivers that plugins were reloaded. + /// PLUGINS_RELOADED, + + /// + /// Requests display of an error notification. + /// SHOW_ERROR, + + /// + /// Requests display of a warning notification. + /// SHOW_WARNING, + + /// + /// Requests display of a success notification. + /// SHOW_SUCCESS, + + /// + /// Carries an event received from the Tauri runtime. + /// TAURI_EVENT_RECEIVED, + + /// + /// Notifies receivers that the Rust service is unavailable or failed a health check. + /// RUST_SERVICE_UNAVAILABLE, + + /// + /// Notifies receivers that voice recording availability changed. + /// VOICE_RECORDING_AVAILABILITY_CHANGED, // Update events: + /// + /// Requests a user-triggered search for application updates. + /// USER_SEARCH_FOR_UPDATE, + + /// + /// Notifies receivers that an application update is available. + /// UPDATE_AVAILABLE, + + /// + /// Requests installation of the available application update. + /// INSTALL_UPDATE, + + + // // Chat events: + // + + /// + /// Queries whether the current chat has unsaved changes. + /// HAS_CHAT_UNSAVED_CHANGES, + + /// + /// Requests the current chat state to be reset. + /// RESET_CHAT_STATE, + + /// + /// Carries a chat that should be loaded by the chat component. + /// LOAD_CHAT, + + /// + /// Notifies receivers that chat response streaming has completed. + /// CHAT_STREAMING_DONE, + + /// + /// Notifies receivers that an AI job changed. + /// + AI_JOB_CHANGED, + + /// + /// Notifies receivers that an AI job finished. + /// + AI_JOB_FINISHED, + + /// + /// Notifies receivers that chat generation state changed. + /// + CHAT_GENERATION_CHANGED, // Workspace events: + /// + /// Notifies receivers that the chat loaded in the workspace changed. + /// WORKSPACE_LOADED_CHAT_CHANGED, + + /// + /// Requests the chat workspace overlay to be toggled. + /// WORKSPACE_TOGGLE_OVERLAY, + + + + + // // RAG events: + // + + /// + /// Carries data sources that were automatically selected for retrieval-augmented generation. + /// RAG_AUTO_DATA_SOURCES_SELECTED, + + + + + // // File attachment events: + // + + /// + /// Registers a file drop area for file attachment handling. + /// REGISTER_FILE_DROP_AREA, + + /// + /// Unregisters a file drop area from file attachment handling. + /// UNREGISTER_FILE_DROP_AREA, + + + + // // Send events: + // + + /// + /// Sends content to the grammar and spelling assistant. + /// SEND_TO_GRAMMAR_SPELLING_ASSISTANT, + + /// + /// Sends content to the icon finder assistant. + /// SEND_TO_ICON_FINDER_ASSISTANT, + + /// + /// Sends content to the rewrite assistant. + /// SEND_TO_REWRITE_ASSISTANT, + + /// + /// Sends content to the prompt optimizer assistant. + /// SEND_TO_PROMPT_OPTIMIZER_ASSISTANT, + + /// + /// Sends content to the translation assistant. + /// SEND_TO_TRANSLATION_ASSISTANT, + + /// + /// Sends content to the agenda assistant. + /// SEND_TO_AGENDA_ASSISTANT, + + /// + /// Sends content to the coding assistant. + /// SEND_TO_CODING_ASSISTANT, + + /// + /// Sends content to the text summarizer assistant. + /// SEND_TO_TEXT_SUMMARIZER_ASSISTANT, + + /// + /// Sends the result of the current assistant to the chat component. + /// SEND_TO_CHAT, + + /// + /// Sends text to the chat input field, aka the user prompt. + /// SEND_TO_CHAT_INPUT, + + /// + /// Sends content to the email assistant. + /// SEND_TO_EMAIL_ASSISTANT, + + /// + /// Sends content to the legal check assistant. + /// SEND_TO_LEGAL_CHECK_ASSISTANT, + + /// + /// Sends content to the synonym assistant. + /// SEND_TO_SYNONYMS_ASSISTANT, + + /// + /// Sends content to the "my tasks assistant". + /// SEND_TO_MY_TASKS_ASSISTANT, + + /// + /// Sends content to the job posting assistant. + /// SEND_TO_JOB_POSTING_ASSISTANT, + + /// + /// Sends content to the document analysis assistant. + /// SEND_TO_DOCUMENT_ANALYSIS_ASSISTANT, + + /// + /// Sends content to the slide builder assistant. + /// SEND_TO_SLIDE_BUILDER_ASSISTANT -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md index b5a65956..e9fd8ebc 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.5.5.md @@ -4,6 +4,7 @@ - Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. - Added an export option for ERI server data sources, so admins can create configuration plugin snippets without writing the Lua code manually. - Added an option to configure the timeout setting for all requests. This is useful when you have a slow network connection, or you have to work with slow AI servers. It is also possible to configure this timeout for an entire organization using configuration plugins. +- Added the ability to keep multiple chats running at the same time. For example, you can let a complex research chat continue in the background while you use some assistants to improve a text. - Added the username to the information page to make organization support easier when users share their screen. - Improved the app's security foundation with major modernization of the native runtime and its internal communication layer. This work is mostly invisible during everyday use, but it replaces older components that no longer received the security updates we require. We also continued updating security-sensitive dependencies so AI Studio stays on a healthier, better maintained base. - Improved the Pandoc management and detection process to make it more reliable. @@ -13,6 +14,7 @@ - Fixed an issue where legacy `.doc` files could be selected even though AI Studio could not process them. These files are now rejected with a clear error message. Thanks to Bernhard for reporting this issue. - Fixed an issue where attached documents were detached when editing a previous prompt. They now remain attached. - Fixed an issue where failed transcription requests could be shown as empty transcription results instead of a clear error message. +- Fixed an issue where an AI response in chat could be interrupted when you interacted with workspaces, such as opening, closing, or resizing the workspace panel. - Fixed missing translations for file type names in file selection dialogs. - Upgraded the native secret storage integration to `keyring-core`, keeping API keys in the secure credential store provided by the operating system. - Upgraded Rust to v1.95.0.