Added support for parallel AI job processing (#774)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer 2026-05-24 13:50:42 +02:00 committed by GitHub
parent 8853ea0cfe
commit a0753488b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 883 additions and 69 deletions

View File

@ -6817,6 +6817,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] =
-- Use no profile -- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "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) -- SSO (Kerberos)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)"

View File

@ -68,7 +68,7 @@
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY) @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY)
{ {
<MudTooltip Text="@T("Save chat")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Save chat")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@(() => this.SaveThread())" Disabled="@(!this.CanThreadBeSaved || this.isStreaming)"/> <MudIconButton Icon="@Icons.Material.Filled.Save" OnClick="@(() => this.SaveThread())" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)"/>
</MudTooltip> </MudTooltip>
} }
@ -89,14 +89,14 @@
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{ {
<MudTooltip Text="@T("Delete this chat & start a new one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Delete this chat & start a new one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="@(() => this.StartNewChat(useSameWorkspace: true, deletePreviousChat: true))" Disabled="@(!this.CanThreadBeSaved)"/> <MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="@(() => this.StartNewChat(useSameWorkspace: true, deletePreviousChat: true))" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)"/>
</MudTooltip> </MudTooltip>
} }
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES)
{ {
<MudTooltip Text="@T("Move the chat to a workspace, or to another if it is already in one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Move the chat to a workspace, or to another if it is already in one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved)" OnClick="@(() => this.MoveChatToWorkspace())"/> <MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)" OnClick="@(() => this.MoveChatToWorkspace())"/>
</MudTooltip> </MudTooltip>
} }
@ -134,7 +134,7 @@
<ConfidenceInfo Mode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider.UsedLLMProvider"/> <ConfidenceInfo Mode="PopoverTriggerMode.ICON" LLMProvider="@this.Provider.UsedLLMProvider"/>
} }
@if (this.isStreaming && this.cancellationTokenSource is not null) @if (this.IsCurrentChatStreaming)
{ {
<MudTooltip Text="@T("Stop generation")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Stop generation")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(() => this.CancelStreaming())"/> <MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(() => this.CancelStreaming())"/>

View File

@ -3,6 +3,7 @@ using AIStudio.Dialogs;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.AIJobs;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
@ -47,6 +48,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
[Inject] [Inject]
private IJSRuntime JsRuntime { get; init; } = null!; private IJSRuntime JsRuntime { get; init; } = null!;
[Inject]
private AIJobService AIJobService { get; init; } = null!;
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top; private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new(); private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
@ -58,7 +62,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool mustScrollToBottomAfterRender; private bool mustScrollToBottomAfterRender;
private InnerScrolling scrollingArea = null!; private InnerScrolling scrollingArea = null!;
private byte scrollRenderCountdown; private byte scrollRenderCountdown;
private bool isStreaming;
private string userInput = string.Empty; private string userInput = string.Empty;
private bool mustStoreChat; private bool mustStoreChat;
private bool mustLoadChat; private bool mustLoadChat;
@ -67,8 +70,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private string currentWorkspaceName = string.Empty; private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty; private Guid currentWorkspaceId = Guid.Empty;
private Guid currentChatThreadId = Guid.Empty; private Guid currentChatThreadId = Guid.Empty;
private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion; private int workspaceHeaderSyncVersion;
private CancellationTokenSource? cancellationTokenSource;
private HashSet<FileAttachment> chatDocumentPaths = []; private HashSet<FileAttachment> chatDocumentPaths = [];
// Unfortunately, we need the input field reference to blur the focus away. Without // 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() protected override async Task OnInitializedAsync()
{ {
// Apply the filters for the message bus: // 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: // Configure the spellchecking for the user input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
@ -217,6 +220,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Select the correct provider: // Select the correct provider:
await this.SelectProviderWhenLoadingChat(); await this.SelectProviderWhenLoadingChat();
await this.SyncForegroundChatAsync();
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
@ -273,6 +277,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
await this.SyncWorkspaceHeaderWithChatThreadAsync(); await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SyncForegroundChatAsync();
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
} }
@ -333,7 +338,23 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.WorkspaceName(this.currentWorkspaceName); 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 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"); 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) if (!this.IsProviderSelected)
return true; return true;
if(this.isStreaming) if(this.IsCurrentChatStreaming)
return true; return true;
if(!this.ChatThread.IsLLMProviderAllowed(this.Provider)) if(!this.ChatThread.IsLLMProviderAllowed(this.Provider))
@ -614,7 +635,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.inputField.BlurAsync(); await this.inputField.BlurAsync();
// Enable the stream state for the chat component: // Enable the stream state for the chat component:
this.isStreaming = true;
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
if (this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading) 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}'."); this.Logger.LogDebug($"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'.");
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
using (this.cancellationTokenSource = new())
{ {
this.StateHasChanged(); ChatThread = this.ChatThread!,
AIText = aiText,
// Use the selected provider to get the AI response. LastUserPrompt = lastUserPrompt,
// By awaiting this line, we wait for the entire ProviderSettings = this.Provider,
// content to be streamed. IsForeground = true,
this.ChatThread = await aiText.CreateFromProviderAsync(this.Provider.CreateProvider(), this.Provider.Model, lastUserPrompt, this.ChatThread, this.cancellationTokenSource.Token); });
}
this.cancellationTokenSource = null;
// Save the chat: await this.SyncForegroundChatAsync();
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:
this.StateHasChanged(); this.StateHasChanged();
} }
private async Task CancelStreaming() private async Task CancelStreaming()
{ {
if (this.cancellationTokenSource is not null) if (this.ChatThread is not null)
if(!this.cancellationTokenSource.IsCancellationRequested) await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId);
await this.cancellationTokenSource.CancelAsync();
} }
private async Task SaveThread() 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 // Want the user to manage the chat storage manually? In that case, we have to ask the user
// about possible data loss: // 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<ConfirmDialog> var dialogParameters = new DialogParameters<ConfirmDialog>
{ {
@ -718,7 +723,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// //
// Reset our state: // Reset our state:
// //
this.isStreaming = false;
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.userInput = string.Empty;
@ -788,6 +792,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.ApplyStandardDataSourceOptions(); this.ApplyStandardDataSourceOptions();
// Notify the parent component about the change: // Notify the parent component about the change:
await this.SyncForegroundChatAsync();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
@ -796,7 +801,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(this.ChatThread is null) if(this.ChatThread is null)
return; 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<ConfirmDialog> var confirmationDialogParameters = new DialogParameters<ConfirmDialog>
{ {
@ -836,18 +841,21 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private async Task LoadedChatChanged() private async Task LoadedChatChanged()
{ {
this.isStreaming = false;
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.userInput = string.Empty;
if (this.ChatThread is not null) 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.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SyncForegroundChatAsync();
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
} }
else else
{ {
this.ClearWorkspaceHeaderState(); this.ClearWorkspaceHeaderState();
await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions(); this.ApplyStandardDataSourceOptions();
} }
@ -863,12 +871,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private async Task ResetState() private async Task ResetState()
{ {
this.isStreaming = false;
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
this.userInput = string.Empty; this.userInput = string.Empty;
this.ClearWorkspaceHeaderState(); this.ClearWorkspaceHeaderState();
this.ChatThread = null; this.ChatThread = null;
await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions(); this.ApplyStandardDataSourceOptions();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
@ -995,6 +1003,19 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
case Event.WORKSPACE_LOADED_CHAT_CHANGED: case Event.WORKSPACE_LOADED_CHAT_CHANGED:
await this.LoadedChatChanged(); await this.LoadedChatChanged();
break; 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: case Event.HAS_CHAT_UNSAVED_CHANGES:
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return Task.FromResult((TResult?) (object) false); return Task.FromResult((TResult?) (object) false);
if (this.IsCurrentChatStreaming)
return Task.FromResult((TResult?) (object) false);
return Task.FromResult((TResult?)(object)this.hasUnsavedChanges); return Task.FromResult((TResult?)(object)this.hasUnsavedChanges);
} }
@ -1024,21 +1048,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.hasUnsavedChanges = false; this.hasUnsavedChanges = false;
} }
if (this.cancellationTokenSource is not null) await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
{
try
{
if(!this.cancellationTokenSource.IsCancellationRequested)
await this.cancellationTokenSource.CancelAsync();
this.cancellationTokenSource.Dispose();
}
catch
{
// ignored
}
}
} }
#endregion #endregion
} }

View File

@ -12,10 +12,16 @@ public class TreeItemData : ITreeItem
public string Icon { get; init; } = string.Empty; public string Icon { get; init; } = string.Empty;
public string DefaultIcon { get; init; } = string.Empty;
public TreeItemType Type { get; init; } public TreeItemType Type { get; init; }
public string Path { get; init; } = string.Empty; public string Path { get; init; } = string.Empty;
public Guid ChatId { get; init; }
public Guid WorkspaceId { get; init; }
public bool Expandable { get; init; } = true; public bool Expandable { get; init; } = true;
public DateTimeOffset LastEditTime { get; init; } public DateTimeOffset LastEditTime { get; init; }

View File

@ -24,7 +24,7 @@ else
case TreeItemData treeItem: case TreeItemData treeItem:
@if (treeItem.Type is TreeItemType.LOADING) @if (treeItem.Type is TreeItemType.LOADING)
{ {
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@false" Items="@(treeItem.Children!)"> <MudTreeViewItem T="ITreeItem" Icon="@this.GetTreeItemIcon(treeItem)" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@false" Items="@(treeItem.Children!)">
<BodyContent> <BodyContent>
<MudSkeleton Width="85%" Height="22px"/> <MudSkeleton Width="85%" Height="22px"/>
</BodyContent> </BodyContent>
@ -32,7 +32,7 @@ else
} }
else if (treeItem.Type is TreeItemType.CHAT) else if (treeItem.Type is TreeItemType.CHAT)
{ {
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@(treeItem.Children!)" OnClick="@(() => this.LoadChatAsync(treeItem.Path, true))"> <MudTreeViewItem T="ITreeItem" Icon="@this.GetTreeItemIcon(treeItem)" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@(treeItem.Children!)" OnClick="@(() => this.LoadChatAsync(treeItem.Path, true))">
<BodyContent> <BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%"> <div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;"> <MudText Style="justify-self: start;">
@ -48,15 +48,15 @@ else
<div style="justify-self: end;"> <div style="justify-self: end;">
<MudTooltip Text="@T("Move to workspace")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Move to workspace")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.MoveChatAsync(treeItem.Path))"/> <MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.MoveChatAsync(treeItem.Path))"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" OnClick="@(() => this.RenameChatAsync(treeItem.Path))"/> <MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.RenameChatAsync(treeItem.Path))"/>
</MudTooltip> </MudTooltip>
<MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT"> <MudTooltip Text="@T("Delete")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" OnClick="@(() => this.DeleteChatAsync(treeItem.Path))"/> <MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Error" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.DeleteChatAsync(treeItem.Path))"/>
</MudTooltip> </MudTooltip>
</div> </div>
</div> </div>
@ -65,7 +65,7 @@ else
} }
else if (treeItem.Type is TreeItemType.WORKSPACE) else if (treeItem.Type is TreeItemType.WORKSPACE)
{ {
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@(treeItem.Children!)" OnClick="@(() => this.OnWorkspaceClicked(treeItem))"> <MudTreeViewItem T="ITreeItem" Icon="@this.GetTreeItemIcon(treeItem)" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@(treeItem.Children!)" OnClick="@(() => this.OnWorkspaceClicked(treeItem))">
<BodyContent> <BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%"> <div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;"> <MudText Style="justify-self: start;">
@ -86,7 +86,7 @@ else
} }
else else
{ {
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@(treeItem.Children!)"> <MudTreeViewItem T="ITreeItem" Icon="@this.GetTreeItemIcon(treeItem)" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@(treeItem.Children!)">
<BodyContent> <BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%"> <div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;"> <MudText Style="justify-self: start;">

View File

@ -4,6 +4,7 @@ using System.Text.Json;
using AIStudio.Chat; using AIStudio.Chat;
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.AIJobs;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@ -18,6 +19,9 @@ public partial class Workspaces : MSGComponentBase
[Inject] [Inject]
private ILogger<Workspaces> Logger { get; init; } = null!; private ILogger<Workspaces> Logger { get; init; } = null!;
[Inject]
private AIJobService AIJobService { get; init; } = null!;
[Parameter] [Parameter]
public ChatThread? CurrentChatThread { get; set; } public ChatThread? CurrentChatThread { get; set; }
@ -42,6 +46,7 @@ public partial class Workspaces : MSGComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await base.OnInitializedAsync(); await base.OnInitializedAsync();
this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]);
_ = this.LoadTreeItemsAsync(startPrefetch: true); _ = this.LoadTreeItemsAsync(startPrefetch: true);
} }
@ -111,7 +116,7 @@ public partial class Workspaces : MSGComponentBase
var temporaryChatsChildren = new List<TreeItemData<ITreeItem>>(); var temporaryChatsChildren = new List<TreeItemData<ITreeItem>>();
foreach (var temporaryChat in snapshot.TemporaryChats.OrderByDescending(x => x.LastEditTime)) 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<ITreeItem> this.treeItems.Add(new TreeItemData<ITreeItem>
{ {
@ -136,7 +141,7 @@ public partial class Workspaces : MSGComponentBase
if (workspace.ChatsLoaded) if (workspace.ChatsLoaded)
{ {
foreach (var workspaceChat in workspace.Chats.OrderByDescending(x => x.LastEditTime)) 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)) else if (this.loadingWorkspaceChatLists.Contains(workspace.WorkspaceId))
children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath)); children.AddRange(this.CreateLoadingRows(workspace.WorkspacePath));
@ -192,7 +197,7 @@ public partial class Workspaces : MSGComponentBase
}; };
} }
private static TreeItemData<ITreeItem> CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon) private TreeItemData<ITreeItem> CreateChatTreeItem(WorkspaceTreeChat chat, WorkspaceBranch branch, int depth, string icon)
{ {
return new TreeItemData<ITreeItem> return new TreeItemData<ITreeItem>
{ {
@ -204,13 +209,43 @@ public partial class Workspaces : MSGComponentBase
Branch = branch, Branch = branch,
Text = chat.Name, Text = chat.Name,
Icon = icon, Icon = icon,
DefaultIcon = icon,
Expandable = false, Expandable = false,
Path = chat.ChatPath, Path = chat.ChatPath,
ChatId = chat.ChatId,
WorkspaceId = chat.WorkspaceId,
LastEditTime = chat.LastEditTime, 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() private async Task SafeStateHasChanged()
{ {
if (this.isDisposed) 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 chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8);
var chat = JsonSerializer.Deserialize<ChatThread>(chatData, WorkspaceBehaviour.JSON_OPTIONS); var chat = JsonSerializer.Deserialize<ChatThread>(chatData, WorkspaceBehaviour.JSON_OPTIONS);
if (chat is not null)
chat = this.AIJobService.TryGetLiveChatThread(chat.ChatId) ?? chat;
if (switchToChat) if (switchToChat)
{ {
this.CurrentChatThread = chat; this.CurrentChatThread = chat;
@ -371,6 +409,9 @@ public partial class Workspaces : MSGComponentBase
if (chat is null) if (chat is null)
return; return;
if (this.AIJobService.IsChatGenerationActive(chat.ChatId))
return;
if (askForConfirmation) if (askForConfirmation)
{ {
var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId); var workspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(chat.WorkspaceId);
@ -407,6 +448,9 @@ public partial class Workspaces : MSGComponentBase
var chat = await this.LoadChatAsync(chatPath, false); var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null) if (chat is null)
return; return;
if (this.AIJobService.IsChatGenerationActive(chat.ChatId))
return;
var dialogParameters = new DialogParameters<SingleInputDialog> var dialogParameters = new DialogParameters<SingleInputDialog>
{ {
@ -525,6 +569,9 @@ public partial class Workspaces : MSGComponentBase
var chat = await this.LoadChatAsync(chatPath, false); var chat = await this.LoadChatAsync(chatPath, false);
if (chat is null) if (chat is null)
return; return;
if (this.AIJobService.IsChatGenerationActive(chat.ChatId))
return;
var dialogParameters = new DialogParameters<WorkspaceSelectionDialog> var dialogParameters = new DialogParameters<WorkspaceSelectionDialog>
{ {
@ -597,6 +644,12 @@ public partial class Workspaces : MSGComponentBase
case Event.PLUGINS_RELOADED: case Event.PLUGINS_RELOADED:
await this.ForceRefreshFromDiskAsync(); await this.ForceRefreshFromDiskAsync();
break; break;
case Event.AI_JOB_CHANGED:
case Event.AI_JOB_FINISHED:
case Event.CHAT_GENERATION_CHANGED:
await this.SafeStateHasChanged();
break;
} }
} }

View File

@ -17,7 +17,7 @@
<MudNavMenu> <MudNavMenu>
@foreach (var navBarItem in this.navItems) @foreach (var navBarItem in this.navItems)
{ {
<MudNavLink Href="@navBarItem.Path" Match="@(navBarItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@navBarItem.Icon" Style="@navBarItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color"> <MudNavLink Href="@navBarItem.Path" Match="@(navBarItem.MatchAll ? NavLinkMatch.All : NavLinkMatch.Prefix)" Icon="@navBarItem.Icon" Style="@navBarItem.SetColorStyle(this.SettingsManager)" Class="custom-icon-color">
@navBarItem.Name @navBarItem.Name
</MudNavLink> </MudNavLink>
} }

View File

@ -1,6 +1,7 @@
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Settings.DataModel; using AIStudio.Settings.DataModel;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@ -26,6 +27,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
[Inject] [Inject]
private RustService RustService { get; init; } = null!; private RustService RustService { get; init; } = null!;
[Inject]
private AIJobService AIJobService { get; init; } = null!;
[Inject] [Inject]
private ISnackbar Snackbar { get; init; } = null!; 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.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.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: // Set the snackbar for the update service:
@ -186,6 +191,13 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
this.StateHasChanged(); this.StateHasChanged();
break; 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: case Event.SHOW_SUCCESS:
if (data is DataSuccessMessage success) if (data is DataSuccessMessage success)
success.Show(this.Snackbar); success.Show(this.Snackbar);
@ -296,7 +308,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
var palette = this.ColorTheme.GetCurrentPalette(this.SettingsManager); 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("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); 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)) if (PreviewFeatures.PRE_WRITER_MODE_2024.IsEnabled(this.SettingsManager))

View File

@ -6819,6 +6819,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] =
-- Use no profile -- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "Kein Profil verwenden" 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) -- SSO (Kerberos)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)" UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "SSO (Kerberos)"

View File

@ -6819,6 +6819,21 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::DATAMODEL::THEMESEXTENSIONS::T534715610"] =
-- Use no profile -- Use no profile
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::PROFILE::T2205839602"] = "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) -- SSO (Kerberos)
UI_TEXT_CONTENT["AISTUDIO::TOOLS::AUTHMETHODSV1EXTENSIONS::T268552140"] = "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" UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat"
-- Unnamed chat -- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"

View File

@ -2,6 +2,7 @@ using AIStudio.Agents;
using AIStudio.Agents.AssistantAudit; using AIStudio.Agents.AssistantAudit;
using AIStudio.Settings; using AIStudio.Settings;
using AIStudio.Tools.Databases; using AIStudio.Tools.Databases;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants; using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@ -128,6 +129,7 @@ internal sealed class Program
builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>(); builder.Services.AddMudMarkdownClipboardService<MarkdownClipboardService>();
builder.Services.AddSingleton<SettingsManager>(); builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<ThreadSafeRandom>(); builder.Services.AddSingleton<ThreadSafeRandom>();
builder.Services.AddSingleton<AIJobService>();
builder.Services.AddSingleton<VoiceRecordingAvailabilityService>(); builder.Services.AddSingleton<VoiceRecordingAvailabilityService>();
builder.Services.AddSingleton<DataSourceService>(); builder.Services.AddSingleton<DataSourceService>();
builder.Services.AddScoped<PandocAvailabilityService>(); builder.Services.AddScoped<PandocAvailabilityService>();

View File

@ -0,0 +1,7 @@
namespace AIStudio.Tools.AIJobs;
public enum AIJobKind
{
NONE,
CHAT_GENERATION,
}

View File

@ -0,0 +1,8 @@
namespace AIStudio.Tools.AIJobs;
public enum AIJobSchedulingClass
{
NONE,
TOP_LEVEL_USER_JOB,
INTERNAL_DEPENDENCY,
}

View File

@ -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<AIJobService> 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<Guid, AIJobState> jobs = new();
private readonly ConcurrentDictionary<Guid, Guid> activeChatJobsByChatId = new();
public IReadOnlyCollection<AIJobSnapshot> 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<AIJobSnapshot?> 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<bool> 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<Model> 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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,12 @@
namespace AIStudio.Tools.AIJobs;
public enum AIJobStatus
{
NONE,
QUEUED,
WAITING_FOR_REMOTE,
RUNNING,
COMPLETED,
CANCELED,
FAILED,
}

View File

@ -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;
}

View File

@ -1,63 +1,281 @@
namespace AIStudio.Tools; namespace AIStudio.Tools;
/// <summary>
/// Defines message bus events used for communication between UI components and services.
/// </summary>
public enum Event public enum Event
{ {
/// <summary>
/// Represents the absence of a message bus event.
/// </summary>
NONE, NONE,
//
// Common events: // Common events:
//
/// <summary>
/// Requests registered receivers to refresh state that depends on the current UI state.
/// </summary>
STATE_HAS_CHANGED, STATE_HAS_CHANGED,
/// <summary>
/// Notifies receivers that the application configuration was changed and should be reloaded or re-applied.
/// </summary>
CONFIGURATION_CHANGED, CONFIGURATION_CHANGED,
/// <summary>
/// Notifies receivers that the active color theme changed.
/// </summary>
COLOR_THEME_CHANGED, COLOR_THEME_CHANGED,
/// <summary>
/// Requests startup initialization of the plugin system.
/// </summary>
STARTUP_PLUGIN_SYSTEM, STARTUP_PLUGIN_SYSTEM,
/// <summary>
/// Notifies receivers that the startup initialization completed.
/// </summary>
STARTUP_COMPLETED, STARTUP_COMPLETED,
/// <summary>
/// Carries an enterprise environment that should be processed during startup.
/// </summary>
STARTUP_ENTERPRISE_ENVIRONMENT, STARTUP_ENTERPRISE_ENVIRONMENT,
/// <summary>
/// Notifies receivers that the known enterprise environments changed.
/// </summary>
ENTERPRISE_ENVIRONMENTS_CHANGED, ENTERPRISE_ENVIRONMENTS_CHANGED,
/// <summary>
/// Notifies receivers that plugins were reloaded.
/// </summary>
PLUGINS_RELOADED, PLUGINS_RELOADED,
/// <summary>
/// Requests display of an error notification.
/// </summary>
SHOW_ERROR, SHOW_ERROR,
/// <summary>
/// Requests display of a warning notification.
/// </summary>
SHOW_WARNING, SHOW_WARNING,
/// <summary>
/// Requests display of a success notification.
/// </summary>
SHOW_SUCCESS, SHOW_SUCCESS,
/// <summary>
/// Carries an event received from the Tauri runtime.
/// </summary>
TAURI_EVENT_RECEIVED, TAURI_EVENT_RECEIVED,
/// <summary>
/// Notifies receivers that the Rust service is unavailable or failed a health check.
/// </summary>
RUST_SERVICE_UNAVAILABLE, RUST_SERVICE_UNAVAILABLE,
/// <summary>
/// Notifies receivers that voice recording availability changed.
/// </summary>
VOICE_RECORDING_AVAILABILITY_CHANGED, VOICE_RECORDING_AVAILABILITY_CHANGED,
// Update events: // Update events:
/// <summary>
/// Requests a user-triggered search for application updates.
/// </summary>
USER_SEARCH_FOR_UPDATE, USER_SEARCH_FOR_UPDATE,
/// <summary>
/// Notifies receivers that an application update is available.
/// </summary>
UPDATE_AVAILABLE, UPDATE_AVAILABLE,
/// <summary>
/// Requests installation of the available application update.
/// </summary>
INSTALL_UPDATE, INSTALL_UPDATE,
//
// Chat events: // Chat events:
//
/// <summary>
/// Queries whether the current chat has unsaved changes.
/// </summary>
HAS_CHAT_UNSAVED_CHANGES, HAS_CHAT_UNSAVED_CHANGES,
/// <summary>
/// Requests the current chat state to be reset.
/// </summary>
RESET_CHAT_STATE, RESET_CHAT_STATE,
/// <summary>
/// Carries a chat that should be loaded by the chat component.
/// </summary>
LOAD_CHAT, LOAD_CHAT,
/// <summary>
/// Notifies receivers that chat response streaming has completed.
/// </summary>
CHAT_STREAMING_DONE, CHAT_STREAMING_DONE,
/// <summary>
/// Notifies receivers that an AI job changed.
/// </summary>
AI_JOB_CHANGED,
/// <summary>
/// Notifies receivers that an AI job finished.
/// </summary>
AI_JOB_FINISHED,
/// <summary>
/// Notifies receivers that chat generation state changed.
/// </summary>
CHAT_GENERATION_CHANGED,
// Workspace events: // Workspace events:
/// <summary>
/// Notifies receivers that the chat loaded in the workspace changed.
/// </summary>
WORKSPACE_LOADED_CHAT_CHANGED, WORKSPACE_LOADED_CHAT_CHANGED,
/// <summary>
/// Requests the chat workspace overlay to be toggled.
/// </summary>
WORKSPACE_TOGGLE_OVERLAY, WORKSPACE_TOGGLE_OVERLAY,
//
// RAG events: // RAG events:
//
/// <summary>
/// Carries data sources that were automatically selected for retrieval-augmented generation.
/// </summary>
RAG_AUTO_DATA_SOURCES_SELECTED, RAG_AUTO_DATA_SOURCES_SELECTED,
//
// File attachment events: // File attachment events:
//
/// <summary>
/// Registers a file drop area for file attachment handling.
/// </summary>
REGISTER_FILE_DROP_AREA, REGISTER_FILE_DROP_AREA,
/// <summary>
/// Unregisters a file drop area from file attachment handling.
/// </summary>
UNREGISTER_FILE_DROP_AREA, UNREGISTER_FILE_DROP_AREA,
//
// Send events: // Send events:
//
/// <summary>
/// Sends content to the grammar and spelling assistant.
/// </summary>
SEND_TO_GRAMMAR_SPELLING_ASSISTANT, SEND_TO_GRAMMAR_SPELLING_ASSISTANT,
/// <summary>
/// Sends content to the icon finder assistant.
/// </summary>
SEND_TO_ICON_FINDER_ASSISTANT, SEND_TO_ICON_FINDER_ASSISTANT,
/// <summary>
/// Sends content to the rewrite assistant.
/// </summary>
SEND_TO_REWRITE_ASSISTANT, SEND_TO_REWRITE_ASSISTANT,
/// <summary>
/// Sends content to the prompt optimizer assistant.
/// </summary>
SEND_TO_PROMPT_OPTIMIZER_ASSISTANT, SEND_TO_PROMPT_OPTIMIZER_ASSISTANT,
/// <summary>
/// Sends content to the translation assistant.
/// </summary>
SEND_TO_TRANSLATION_ASSISTANT, SEND_TO_TRANSLATION_ASSISTANT,
/// <summary>
/// Sends content to the agenda assistant.
/// </summary>
SEND_TO_AGENDA_ASSISTANT, SEND_TO_AGENDA_ASSISTANT,
/// <summary>
/// Sends content to the coding assistant.
/// </summary>
SEND_TO_CODING_ASSISTANT, SEND_TO_CODING_ASSISTANT,
/// <summary>
/// Sends content to the text summarizer assistant.
/// </summary>
SEND_TO_TEXT_SUMMARIZER_ASSISTANT, SEND_TO_TEXT_SUMMARIZER_ASSISTANT,
/// <summary>
/// Sends the result of the current assistant to the chat component.
/// </summary>
SEND_TO_CHAT, SEND_TO_CHAT,
/// <summary>
/// Sends text to the chat input field, aka the user prompt.
/// </summary>
SEND_TO_CHAT_INPUT, SEND_TO_CHAT_INPUT,
/// <summary>
/// Sends content to the email assistant.
/// </summary>
SEND_TO_EMAIL_ASSISTANT, SEND_TO_EMAIL_ASSISTANT,
/// <summary>
/// Sends content to the legal check assistant.
/// </summary>
SEND_TO_LEGAL_CHECK_ASSISTANT, SEND_TO_LEGAL_CHECK_ASSISTANT,
/// <summary>
/// Sends content to the synonym assistant.
/// </summary>
SEND_TO_SYNONYMS_ASSISTANT, SEND_TO_SYNONYMS_ASSISTANT,
/// <summary>
/// Sends content to the "my tasks assistant".
/// </summary>
SEND_TO_MY_TASKS_ASSISTANT, SEND_TO_MY_TASKS_ASSISTANT,
/// <summary>
/// Sends content to the job posting assistant.
/// </summary>
SEND_TO_JOB_POSTING_ASSISTANT, SEND_TO_JOB_POSTING_ASSISTANT,
/// <summary>
/// Sends content to the document analysis assistant.
/// </summary>
SEND_TO_DOCUMENT_ANALYSIS_ASSISTANT, SEND_TO_DOCUMENT_ANALYSIS_ASSISTANT,
/// <summary>
/// Sends content to the slide builder assistant.
/// </summary>
SEND_TO_SLIDE_BUILDER_ASSISTANT SEND_TO_SLIDE_BUILDER_ASSISTANT
} }

View File

@ -4,6 +4,7 @@
- Added support for organization-managed ERI servers in configuration plugins, so admins can preconfigure external data sources for users. - 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 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 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. - 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 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. - 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 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 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 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. - 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 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. - Upgraded Rust to v1.95.0.