Fixed an issue with switching between chat threads while multiple chats are running (#779)
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-25 20:48:26 +02:00 committed by GitHub
parent 3e6e3bdcbd
commit d05ff26e62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 246 additions and 80 deletions

View File

@ -37,7 +37,7 @@
<MudTextField
T="string"
@ref="@this.inputField"
@bind-Text="@this.userInput"
@bind-Text="@this.UserInput"
Variant="Variant.Outlined"
AutoGrow="@true"
Lines="3"
@ -96,28 +96,28 @@
@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">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)" OnClick="@(() => this.MoveChatToWorkspace())"/>
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)" OnClick="@this.MoveChatToWorkspace"/>
</MudTooltip>
}
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" @bind-DocumentPaths="@this.chatDocumentPaths" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider"/>
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" Provider="@this.Provider"/>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
<MudTooltip Text="@T("Bold")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.FormatBold" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_BOLD)" Disabled="@this.IsInputForbidden()"/>
<MudIconButton Icon="@Icons.Material.Filled.FormatBold" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_BOLD))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip>
<MudTooltip Text="@T("Italic")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.FormatItalic" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_ITALIC)" Disabled="@this.IsInputForbidden()"/>
<MudIconButton Icon="@Icons.Material.Filled.FormatItalic" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_ITALIC))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip>
<MudTooltip Text="@T("Heading")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.TextFields" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_HEADING)" Disabled="@this.IsInputForbidden()"/>
<MudIconButton Icon="@Icons.Material.Filled.TextFields" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_HEADING))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip>
<MudTooltip Text="@T("Bulleted List")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.FormatListBulleted" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_BULLET_LIST)" Disabled="@this.IsInputForbidden()"/>
<MudIconButton Icon="@Icons.Material.Filled.FormatListBulleted" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_BULLET_LIST))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip>
<MudTooltip Text="@T("Code")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Code" OnClick="() => this.ApplyMarkdownFormat(MARKDOWN_CODE)" Disabled="@this.IsInputForbidden()"/>
<MudIconButton Icon="@Icons.Material.Filled.Code" OnClick="@(() => this.ApplyMarkdownFormat(MARKDOWN_CODE))" Disabled="@this.IsInputForbidden()"/>
</MudTooltip>
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
@ -137,7 +137,7 @@
@if (this.IsCurrentChatStreaming)
{
<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"/>
</MudTooltip>
}

View File

@ -39,6 +39,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
[Parameter]
public Workspaces? Workspaces { get; set; }
[Parameter]
public ChatComposerState ComposerState { get; set; } = new();
[Inject]
private ILogger<ChatComponent> Logger { get; set; } = null!;
@ -62,7 +65,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool mustScrollToBottomAfterRender;
private InnerScrolling scrollingArea = null!;
private byte scrollRenderCountdown;
private string userInput = string.Empty;
private bool mustStoreChat;
private bool mustLoadChat;
private LoadChat loadChat;
@ -70,20 +72,36 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty;
private Guid currentChatThreadId = Guid.Empty;
private Guid loadedParameterChatId = Guid.Empty;
private Guid loadedParameterWorkspaceId = Guid.Empty;
private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion;
private HashSet<FileAttachment> chatDocumentPaths = [];
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
private MudTextField<string> inputField = null!;
/// <summary>
/// Represents the user's input in the chat interface.
/// </summary>
/// <remarks>
/// This property serves as a bridge between the chat component and the
/// underlying composer state, allowing user input to be dynamically updated
/// and managed. The setter also triggers state changes within the composer
/// to track whether the user has drafted any input.
/// </remarks>
private string UserInput
{
get => this.ComposerState.UserInput;
set => this.ComposerState.SetUserInput(value);
}
#region Overrides of ComponentBase
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, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]);
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, 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);
@ -94,15 +112,12 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Get the preselected chat template:
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
this.userInput = this.currentChatTemplate.PredefinedUserPrompt;
if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent)
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).FirstOrDefault();
if (!string.IsNullOrWhiteSpace(deferredInput))
this.userInput = deferredInput;
// Apply template's file attachments, if any:
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
this.ComposerState.SetUserInput(deferredInput);
//
// Check for deferred messages of the kind 'SEND_TO_CHAT',
@ -120,6 +135,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.ChatThread.IncludeDateTime = true;
this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now.");
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
// We know already that the chat thread is not null,
@ -246,6 +262,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(this.ChatThread is not null)
{
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully.");
@ -276,13 +293,35 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
protected override async Task OnParametersSetAsync()
{
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.ApplyLoadedChatParameterAsync();
await this.SyncForegroundChatAsync();
await base.OnParametersSetAsync();
}
#endregion
private async Task ApplyLoadedChatParameterAsync()
{
var chatId = this.ChatThread?.ChatId ?? Guid.Empty;
var workspaceId = this.ChatThread?.WorkspaceId ?? Guid.Empty;
if (this.loadedParameterChatId == chatId && this.loadedParameterWorkspaceId == workspaceId)
{
await this.SyncWorkspaceHeaderWithChatThreadAsync();
return;
}
this.loadedParameterChatId = chatId;
this.loadedParameterWorkspaceId = workspaceId;
await this.LoadedChatChanged(notifyParent: false);
}
private void MarkCurrentChatAsLoadedParameter()
{
this.loadedParameterChatId = this.ChatThread?.ChatId ?? Guid.Empty;
this.loadedParameterWorkspaceId = this.ChatThread?.WorkspaceId ?? Guid.Empty;
}
private async Task SyncWorkspaceHeaderWithChatThreadAsync()
{
var syncVersion = Interlocked.Increment(ref this.workspaceHeaderSyncVersion);
@ -424,12 +463,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
this.currentChatTemplate = chatTemplate;
if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt))
this.userInput = this.currentChatTemplate.PredefinedUserPrompt;
this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt);
// Apply template's file attachments (replaces existing):
this.chatDocumentPaths.Clear();
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
if(this.ChatThread is null)
return;
@ -489,6 +526,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.dataSourceSelectionComponent.Hide();
this.hasUnsavedChanges = true;
this.ComposerState.MarkUserDraft();
var key = keyEvent.Code.ToLowerInvariant();
// Was the enter key (either enter or numpad enter) pressed?
@ -520,7 +558,16 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(this.dataSourceSelectionComponent?.IsVisible ?? false)
this.dataSourceSelectionComponent.Hide();
this.userInput = await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType);
this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType));
this.hasUnsavedChanges = true;
}
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
{
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft();
this.hasUnsavedChanges = true;
}
@ -548,17 +595,18 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions,
Name = this.ExtractThreadName(this.userInput),
Name = this.ExtractThreadName(this.ComposerState.UserInput),
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
};
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
else
{
// Set the thread name if it is empty:
if (string.IsNullOrWhiteSpace(this.ChatThread.Name))
this.ChatThread.Name = this.ExtractThreadName(this.userInput);
this.ChatThread.Name = this.ExtractThreadName(this.ComposerState.UserInput);
// Update provider, profile and chat template:
this.ChatThread.SelectedProvider = this.Provider.Id;
@ -575,14 +623,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
IContent? lastUserPrompt;
if (!reuseLastUserPrompt)
{
var normalizedAttachments = this.chatDocumentPaths
var normalizedAttachments = this.ComposerState.FileAttachments
.Select(attachment => attachment.Normalize())
.Where(attachment => attachment.IsValid)
.ToList();
lastUserPrompt = new ContentText
{
Text = this.userInput,
Text = this.ComposerState.UserInput,
FileAttachments = normalizedAttachments,
};
@ -629,8 +677,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Clear the input field:
await this.inputField.FocusAsync();
this.userInput = string.Empty;
this.chatDocumentPaths.Clear();
this.ComposerState.Clear();
await this.inputField.BlurAsync();
@ -724,7 +771,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Reset our state:
//
this.hasUnsavedChanges = false;
this.userInput = string.Empty;
this.ComposerState.Clear();
//
// Reset the LLM provider considering the user's settings:
@ -781,18 +828,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
};
}
this.userInput = this.currentChatTemplate.PredefinedUserPrompt;
// Apply template's file attachments:
this.chatDocumentPaths.Clear();
foreach (var attachment in this.currentChatTemplate.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
// Now, we have to reset the data source options as well:
this.ApplyStandardDataSourceOptions();
// Notify the parent component about the change:
await this.SyncForegroundChatAsync();
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
@ -834,26 +877,33 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await WorkspaceBehaviour.DeleteChatAsync(this.DialogService, this.ChatThread!.WorkspaceId, this.ChatThread.ChatId, askForConfirmation: false);
this.ChatThread!.WorkspaceId = workspaceId;
this.MarkCurrentChatAsLoadedParameter();
await this.SaveThread();
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
private async Task LoadedChatChanged()
private async Task LoadedChatChanged(bool notifyParent = true)
{
this.hasUnsavedChanges = false;
this.userInput = string.Empty;
this.ComposerState.Clear();
if (this.ChatThread is not null)
{
this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread;
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.loadedParameterChatId = this.ChatThread.ChatId;
this.loadedParameterWorkspaceId = this.ChatThread.WorkspaceId;
if (notifyParent)
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SyncForegroundChatAsync();
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
}
else
{
this.loadedParameterChatId = Guid.Empty;
this.loadedParameterWorkspaceId = Guid.Empty;
this.ClearWorkspaceHeaderState();
await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions();
@ -872,10 +922,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private async Task ResetState()
{
this.hasUnsavedChanges = false;
this.userInput = string.Empty;
this.ComposerState.Clear();
this.ClearWorkspaceHeaderState();
this.ChatThread = null;
this.MarkCurrentChatAsLoadedParameter();
await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
@ -974,11 +1025,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private void RestoreComposerFromTextBlock(ContentText textBlock)
{
this.userInput = textBlock.Text;
this.chatDocumentPaths.Clear();
foreach (var attachment in textBlock.FileAttachments)
this.chatDocumentPaths.Add(attachment.Normalize());
this.ComposerState.RestoreFromTextBlock(textBlock);
}
#region Overrides of MSGComponentBase
@ -1000,10 +1047,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.SaveThread();
break;
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:
@ -1030,7 +1073,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.IsCurrentChatStreaming)
return Task.FromResult((TResult?) (object) false);
return Task.FromResult((TResult?)(object)this.hasUnsavedChanges);
return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft));
}
return Task.FromResult(default(TResult));
@ -1049,6 +1092,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
}
await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
this.Dispose();
}
#endregion

View File

@ -0,0 +1,65 @@
using AIStudio.Chat;
using AIStudio.Settings;
namespace AIStudio.Components;
public sealed class ChatComposerState
{
public string UserInput { get; private set; } = string.Empty;
public HashSet<FileAttachment> FileAttachments { get; } = [];
public bool HasUserDraft { get; private set; }
public bool HasComposerContent => !string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0;
public bool HasVisibleUserDraft => this.HasUserDraft && (!string.IsNullOrWhiteSpace(this.UserInput) || this.FileAttachments.Count > 0);
public void ApplyTemplate(ChatTemplate chatTemplate)
{
this.UserInput = chatTemplate.PredefinedUserPrompt;
this.FileAttachments.Clear();
foreach (var attachment in chatTemplate.FileAttachments)
this.FileAttachments.Add(attachment.Normalize());
this.HasUserDraft = false;
}
public void SetUserInput(string? userInput)
{
this.UserInput = userInput ?? string.Empty;
this.HasUserDraft = !string.IsNullOrWhiteSpace(userInput);
}
public void SetSystemInput(string? userInput)
{
this.UserInput = userInput ?? string.Empty;
this.HasUserDraft = false;
}
public void MarkUserDraft()
{
this.HasUserDraft = true;
}
public void ReplaceFileAttachments(IEnumerable<FileAttachment> fileAttachments)
{
this.FileAttachments.Clear();
foreach (var attachment in fileAttachments)
this.FileAttachments.Add(attachment.Normalize());
}
public void Clear()
{
this.UserInput = string.Empty;
this.FileAttachments.Clear();
this.HasUserDraft = false;
}
public void RestoreFromTextBlock(ContentText textBlock)
{
this.UserInput = textBlock.Text;
this.ReplaceFileAttachments(textBlock.FileAttachments);
this.HasUserDraft = true;
}
}

View File

@ -236,12 +236,13 @@ public partial class Workspaces : MSGComponentBase
private string GetChatTreeIcon(Guid chatId, string defaultIcon)
{
var snapshot = this.AIJobService.TryGetChatSnapshot(chatId);
return snapshot?.Status switch
if (snapshot is null || !snapshot.IsActive)
return defaultIcon;
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,
};
}
@ -390,7 +391,6 @@ public partial class Workspaces : MSGComponentBase
{
this.CurrentChatThread = chat;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
}
return chat;
@ -439,7 +439,6 @@ public partial class Workspaces : MSGComponentBase
{
this.CurrentChatThread = null;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
}
}
@ -473,7 +472,6 @@ public partial class Workspaces : MSGComponentBase
{
this.CurrentChatThread.Name = chat.Name;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
}
await WorkspaceBehaviour.StoreChatAsync(chat);
@ -596,7 +594,6 @@ public partial class Workspaces : MSGComponentBase
{
this.CurrentChatThread = chat;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
await MessageBus.INSTANCE.SendMessage<bool>(this, Event.WORKSPACE_LOADED_CHAT_CHANGED);
}
await WorkspaceBehaviour.StoreChatAsync(chat);

View File

@ -89,6 +89,7 @@
<ChatComponent
@bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings"
ComposerState="@this.composerState"
Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
</EndContent>
@ -115,6 +116,7 @@
<ChatComponent
@bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings"
ComposerState="@this.composerState"
Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
</MudStack>
@ -125,6 +127,7 @@
<ChatComponent
@bind-ChatThread="@this.chatThread"
@bind-Provider="@this.providerSettings"
ComposerState="@this.composerState"
Workspaces="@this.workspaces"
WorkspaceName="name => this.UpdateWorkspaceName(name)"/>
}

View File

@ -26,6 +26,7 @@ public partial class Chat : MSGComponentBase
private string currentWorkspaceName = string.Empty;
private Workspaces? workspaces;
private double splitterPosition = 30;
private readonly ChatComposerState composerState = new();
private readonly Timer splitterSaveTimer = new(TimeSpan.FromSeconds(1.6));

View File

@ -17,12 +17,16 @@ public sealed class AIJobService(
{
public required CancellationTokenSource CancellationTokenSource { get; init; }
public required CancellationToken CancellationToken { get; init; }
public required ChatGenerationRequest ChatGenerationRequest { get; init; }
public required AIJobSnapshot Snapshot { get; set; }
public DateTimeOffset LastCheckpoint { get; set; }
public bool IsCompletionStarted { get; set; }
public readonly Lock SyncRoot = new();
}
@ -96,9 +100,11 @@ public sealed class AIJobService(
UpdatedAt = DateTimeOffset.Now,
};
var cancellationTokenSource = new CancellationTokenSource();
var state = new AIJobState
{
CancellationTokenSource = new CancellationTokenSource(),
CancellationTokenSource = cancellationTokenSource,
CancellationToken = cancellationTokenSource.Token,
ChatGenerationRequest = request,
Snapshot = snapshot,
LastCheckpoint = DateTimeOffset.MinValue,
@ -131,8 +137,23 @@ public sealed class AIJobService(
if (!this.jobs.TryGetValue(jobId, out var job))
return;
if (!job.CancellationTokenSource.IsCancellationRequested)
await job.CancellationTokenSource.CancelAsync();
lock (job.SyncRoot)
{
if (job.IsCompletionStarted)
return;
}
try
{
if (!job.CancellationTokenSource.IsCancellationRequested)
await job.CancellationTokenSource.CancelAsync();
}
catch (ObjectDisposedException)
{
return;
}
await this.CompleteChatGenerationAsync(job, AIJobStatus.CANCELED);
}
public async Task CancelChatGenerationAsync(Guid chatId)
@ -167,13 +188,14 @@ public sealed class AIJobService(
private async Task RunChatGenerationAsync(AIJobState state)
{
var request = state.ChatGenerationRequest;
var token = state.CancellationTokenSource.Token;
var token = state.CancellationToken;
try
{
token.ThrowIfCancellationRequested();
var provider = request.ProviderSettings.CreateProvider();
var chatThread = request.ChatThread;
var aiText = request.AIText;
if (!chatThread.IsLLMProviderAllowed(provider))
{
@ -188,6 +210,8 @@ public sealed class AIJobService(
return;
}
token.ThrowIfCancellationRequested();
try
{
var rag = new AISrcSelWithRetCtxVal();
@ -207,21 +231,18 @@ public sealed class AIJobService(
logger.LogError(e, "Skipping the RAG process due to an error.");
}
token.ThrowIfCancellationRequested();
var lastStreamingEvent = DateTimeOffset.MinValue;
aiText.InitialRemoteWait = true;
if (!TrySetWaitingForRemote(state, token))
return;
await this.NotifyChangedAsync(state);
await foreach (var contentStreamChunk in provider.StreamChatCompletion(request.ProviderSettings.Model, chatThread, settingsManager, token))
{
if (token.IsCancellationRequested)
if (!TryApplyStreamChunk(state, contentStreamChunk, token))
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)
{
@ -255,11 +276,21 @@ public sealed class AIJobService(
private async Task CompleteChatGenerationAsync(AIJobState state, AIJobStatus status, string errorMessage = "")
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted)
return;
state.IsCompletionStarted = true;
}
var aiText = state.ChatGenerationRequest.AIText;
aiText.InitialRemoteWait = false;
aiText.IsStreaming = false;
aiText.Text = aiText.Text.RemoveThinkTags().Trim();
RemoveEmptyAIResponse(state);
lock (state.SyncRoot)
{
state.Snapshot = state.Snapshot with
@ -290,18 +321,41 @@ public sealed class AIJobService(
state.ChatGenerationRequest.ChatThread.Blocks.Remove(aiBlock);
}
private static void UpdateStatus(AIJobState state, AIJobStatus status)
private static bool TrySetWaitingForRemote(AIJobState state, CancellationToken token)
{
lock (state.SyncRoot)
{
if (state.Snapshot.Status == status)
return;
if (state.IsCompletionStarted || token.IsCancellationRequested)
return false;
state.Snapshot = state.Snapshot with
state.ChatGenerationRequest.AIText.InitialRemoteWait = true;
return true;
}
}
private static bool TryApplyStreamChunk(AIJobState state, ContentStreamChunk contentStreamChunk, CancellationToken token)
{
lock (state.SyncRoot)
{
if (state.IsCompletionStarted || token.IsCancellationRequested)
return false;
var aiText = state.ChatGenerationRequest.AIText;
aiText.InitialRemoteWait = false;
aiText.IsStreaming = true;
aiText.Text += contentStreamChunk;
aiText.Sources.MergeSources(contentStreamChunk.Sources);
if (state.Snapshot.Status is not AIJobStatus.RUNNING)
{
Status = status,
UpdatedAt = DateTimeOffset.Now,
};
state.Snapshot = state.Snapshot with
{
Status = AIJobStatus.RUNNING,
UpdatedAt = DateTimeOffset.Now,
};
}
return true;
}
}

View File

@ -64,11 +64,11 @@ public sealed class MessageBus
{
foreach (var (receiver, componentFilter) in this.componentFilters)
{
if (componentFilter.Length > 0 && sendingComponent is not null && !componentFilter.Contains(sendingComponent))
if (componentFilter.Length > 0 && message.SendingComponent is not null && !componentFilter.Contains(message.SendingComponent))
continue;
var eventFilter = this.componentEvents[receiver];
if (eventFilter.Length == 0 || eventFilter.Contains(triggeredEvent))
if (eventFilter.Length == 0 || eventFilter.Contains(message.TriggeredEvent))
// We don't await the task here because we don't want to block the message bus:
_ = receiver.ProcessMessage(message.SendingComponent, message.TriggeredEvent, message.Data);

View File

@ -15,8 +15,10 @@
- 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 an issue with switching between chat threads while multiple chats are running.
- Fixed error messages for provider requests so missing OpenAI API credits and too many requests are shown clearly in chats, assistants, transcription, and model loading.
- Fixed missing translations for file type names in file selection dialogs.
- Fixed the chat user prompt being cleared when toggling the workspace view.
- 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 .NET to v9.0.16.