Kept the unsent message with its chat (#1049)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-11 15:52:09 +02:00
1 parent 1c13300988
commit 468d28aa94
18 files changed
+1333 -240

No files matched your search

@@ -178,25 +178,13 @@ public partial class AttachDocuments : MSGComponentBase
var completed = this.EffectiveImportOwner.Kind.PersistsOwnSources()
? Array.Empty<FileAttachment>()
: delivery?.Attachments ?? [];
var pending = this.OwnerChat?.PendingMediaTranscripts ?? [];
var pending = this.OwnerChat?.Draft.MediaTranscripts ?? [];
var changed = false;
var ownerPendingChanged = false;
foreach (var attachment in completed.Concat(pending))
changed |= this.DocumentPaths.Add(attachment);
if (this.OwnerChat is not null)
{
foreach (var attachment in completed.OfType<ManagedTranscriptAttachment>())
{
if (this.OwnerChat.PendingMediaTranscripts.All(existing => existing.FilePath != attachment.FilePath))
{
this.OwnerChat.PendingMediaTranscripts.Add(attachment);
ownerPendingChanged = true;
}
}
}
var ownerPendingChanged = this.OwnerChat?.Draft.AddAttachments(completed.OfType<ManagedTranscriptAttachment>()) ?? false;
if (changed || ownerPendingChanged)
{
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
@@ -276,10 +264,11 @@ public partial class AttachDocuments : MSGComponentBase
var previousAttachments = this.DocumentPaths.ToHashSet();
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths, this.AttachDroppedPathsAsync, () => this.IsUnavailable);
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
var removedAttachments = previousAttachments.Except(this.DocumentPaths).ToList();
foreach (var removedAttachment in removedAttachments)
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
this.ReconcileOwnerPendingTranscripts();
this.RemoveFromOwnerDraft(removedAttachments);
//
// Said out loud, like every other path in this file. Removing a file in the dialog changed
@@ -295,11 +284,12 @@ public partial class AttachDocuments : MSGComponentBase
if (this.IsUnavailable)
return;
foreach (var attachment in this.DocumentPaths)
var removedAttachments = this.DocumentPaths.ToList();
foreach (var attachment in removedAttachments)
ManagedTranscriptAttachment.TryDeleteOwnedFile(attachment);
this.DocumentPaths.Clear();
this.ReconcileOwnerPendingTranscripts();
this.RemoveFromOwnerDraft(removedAttachments);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
}
@@ -311,21 +301,22 @@ public partial class AttachDocuments : MSGComponentBase
this.DocumentPaths.Remove(fileAttachment);
ManagedTranscriptAttachment.TryDeleteOwnedFile(fileAttachment);
this.ReconcileOwnerPendingTranscripts();
this.RemoveFromOwnerDraft([fileAttachment]);
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
}
/// <summary>Keeps persisted chat-draft transcript references aligned with the composer.</summary>
private void ReconcileOwnerPendingTranscripts()
{
if (this.OwnerChat is null)
return;
var retainedPaths = this.DocumentPaths.Select(attachment => attachment.FilePath).ToHashSet(StringComparer.Ordinal);
this.OwnerChat.PendingMediaTranscripts.RemoveAll(attachment => !retainedPaths.Contains(attachment.FilePath));
}
/// <summary>
/// Removes the attachments the user took out of the composer from the draft of the owner chat.
/// </summary>
/// <remarks>
/// Only what the user removed leaves the draft. The media transcription service hands a finished
/// transcript to the draft before the composer shows it, so aligning the draft with the composer
/// would drop such a transcript, and its file would stay behind without an owner.
/// </remarks>
/// <param name="removedAttachments">The attachments the user removed.</param>
private void RemoveFromOwnerDraft(IEnumerable<FileAttachment> removedAttachments) => this.OwnerChat?.Draft.RemoveAttachments(removedAttachments);
/// <summary>
/// Validates the given paths and attaches every file which passes.
@@ -38,7 +38,7 @@
<FooterContent>
<MediaTranscriptionStatus Owner="@this.CurrentMediaImportOwner"/>
<MudElement Style="flex: 0 0 auto;">
<UserPromptComponent
<MudTextField
T="string"
@ref="@this.inputField"
@bind-Text="@this.UserInput"
@@ -56,7 +56,6 @@
OnKeyUp="@this.InputKeyEvent"
UserAttributes="@USER_INPUT_ATTRIBUTES"
Class="@this.UserInputClass"
DebounceTime="TimeSpan.FromSeconds(1)"
HelperText="@this.TokenCountMessage"
Style="@this.UserInputStyle"/>
</MudElement>
@@ -11,6 +11,7 @@ using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
@@ -69,6 +70,9 @@ public partial class ChatComponent : MSGComponentBase
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
[Inject]
private NavigationManager NavigationManager { get; init; } = null!;
private const Placement TOOLBAR_TOOLTIP_PLACEMENT = Placement.Top;
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
@@ -94,6 +98,7 @@ public partial class ChatComponent : MSGComponentBase
private Guid currentChatThreadId = Guid.Empty;
private Guid loadedParameterChatId = Guid.Empty;
private Guid loadedParameterWorkspaceId = Guid.Empty;
private bool hasAppliedChatParameter;
private Guid foregroundChatId = Guid.Empty;
private int workspaceHeaderSyncVersion;
private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
@@ -139,6 +144,35 @@ public partial class ChatComponent : MSGComponentBase
/// </summary>
private ConversationTokenTracker? tokenTracker;
/// <summary>
/// How long the user has to pause before the open chat is stored with the message being written.
/// </summary>
private static readonly TimeSpan DRAFT_AUTOSAVE_DELAY = TimeSpan.FromSeconds(2);
/// <summary>
/// Stores the open chat with the message being written, while chats are stored automatically.
/// </summary>
private ChatDraftAutosave? draftAutosave;
/// <summary>
/// A new chat which has not reached the disk yet, or null.
/// </summary>
/// <remarks>
/// Starting a new chat in a workspace creates its thread right away, so that it keeps the
/// workspace, but stores it only together with its first message. Until then, leaving the chat
/// stores it when the user started a message in it, see PreserveNewChatDraftAsync. Which chat
/// that is has to be noted here: once the chat is left, the component may show the next one
/// already. Nor does the disk tell, since a deleted chat is missing there as well, and storing
/// that one would bring it back. Hence, this is always the chat on the screen, and it is
/// forgotten as soon as the chat was stored or left.
/// </remarks>
private ChatThread? unstoredChat;
/// <summary>
/// Lets this component store a new chat before the user leaves the chat page.
/// </summary>
private IDisposable? locationChangingRegistration;
/// <summary>
/// How long to leave the token count alone after it ran.
/// </summary>
@@ -228,7 +262,7 @@ public partial class ChatComponent : MSGComponentBase
// Unfortunately, we need the input field reference to blur the focus away. Without
// this, we cannot clear the input field.
private UserPromptComponent<string> inputField = null!;
private MudTextField<string> inputField = null!;
/// <summary>
/// Represents the user's input in the chat interface.
@@ -237,12 +271,20 @@ public partial class ChatComponent : MSGComponentBase
/// 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.
/// to track whether the user has drafted any input, and hands the input to
/// the draft of the open chat.
/// </remarks>
private string UserInput
{
get => this.ComposerState.UserInput;
set => this.ComposerState.SetUserInput(value);
set
{
this.ComposerState.SetUserInput(value);
this.KeepComposerInDraft();
// Also for text which arrives without a key, e.g., pasted with the mouse:
this.hasUnsavedChanges = true;
}
}
#region Overrides of ComponentBase
@@ -250,6 +292,12 @@ public partial class ChatComponent : MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
//
// Leaving the chat page takes the composer along. Registered before anything is awaited, so
// that a component which goes away while it starts can never leave its handler behind:
//
this.locationChangingRegistration = this.NavigationManager.RegisterLocationChangingHandler(this.OnLocationChangingAsync);
await this.RefreshCulture();
//
@@ -260,6 +308,13 @@ public partial class ChatComponent : MSGComponentBase
this.tokenTracker = new(this.RecountTokensAsync, this.TokenCountQuietTime, TOKEN_COUNT_HEARTBEAT);
this.tokenTracker.Start();
this.draftAutosave = new(
DRAFT_AUTOSAVE_DELAY,
chat => WorkspaceBehaviour.IsChatExisting(new LoadChat(chat.WorkspaceId, chat.ChatId)),
chat => this.AIJobService.IsChatGenerationActive(chat.ChatId),
WorkspaceBehaviour.StoreChatAsync,
this.Logger);
// Apply the filters for the message bus:
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, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@@ -293,8 +348,13 @@ public partial class ChatComponent : MSGComponentBase
// Yes, the user sent an assistant result to the chat.
//
// Use chat thread sent by the user:
this.ChatThread = deferredRequest.ChatThread;
//
// Use chat thread sent by the user, with a draft of its own. The thread is a copy of
// the assistant's one, made with a with expression, which shares the draft. An assistant
// has no composer, so its draft is always empty, but the next chat it starts would
// otherwise begin with whatever the user writes in this one.
//
this.ChatThread = deferredRequest.ChatThread with { Draft = new() };
this.ChatThread.IncludeDateTime = true;
this.ApplyToolSelectionOfLoadedChat();
@@ -305,7 +365,11 @@ public partial class ChatComponent : MSGComponentBase
//
if (deferredRequest.ApplySelectedChatTemplateToComposer && !this.ComposerState.HasUserDraft)
this.ComposerState.ApplyTemplate(this.SettingsManager.GetChatTemplateById(this.ChatThread.SelectedChatTemplate));
// That draft is the message of the incoming chat now:
if (this.ComposerState.HasUserDraft)
this.MirrorComposerIntoDraft(this.ChatThread);
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);
@@ -459,12 +523,7 @@ public partial class ChatComponent : MSGComponentBase
if (firstRender && this.ChatThread is not null && this.mustStoreChat)
{
this.mustStoreChat = false;
if(this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
await this.StoreAndListChatAsync(this.ChatThread);
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
@@ -543,6 +602,14 @@ public partial class ChatComponent : MSGComponentBase
var chatId = this.ChatThread?.ChatId ?? Guid.Empty;
var workspaceId = this.ChatThread?.WorkspaceId ?? Guid.Empty;
//
// The first chat of this component is no chat being left. The component appears anew, e.g.,
// whenever the workspace sidebar is shown or hidden, while the page keeps the chat and the
// composer:
//
var isFirstChat = !this.hasAppliedChatParameter;
this.hasAppliedChatParameter = true;
if (this.loadedParameterChatId == chatId && this.loadedParameterWorkspaceId == workspaceId)
{
await this.SyncWorkspaceHeaderWithChatThreadAsync();
@@ -559,8 +626,13 @@ public partial class ChatComponent : MSGComponentBase
return;
}
var leftChatHadNoThread = !isFirstChat && this.loadedParameterChatId == Guid.Empty;
this.loadedParameterChatId = chatId;
this.loadedParameterWorkspaceId = workspaceId;
// The chat being left keeps the message the user started in it:
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(leftChatHadNoThread);
await this.LoadedChatChanged(notifyParent: false);
}
@@ -857,6 +929,11 @@ public partial class ChatComponent : MSGComponentBase
SelectedProfile = this.currentProfile.Id,
};
// The copy shares the draft, but storing the former thread would bring the former profile back:
if (this.unstoredChat?.ChatId == this.ChatThread.ChatId)
this.unstoredChat = this.ChatThread;
this.draftAutosave?.Follow(this.ChatThread);
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
}
@@ -1007,6 +1084,7 @@ public partial class ChatComponent : MSGComponentBase
this.dataSourceSelectionComponent.Hide();
this.ComposerState.SetUserInput(await this.JsRuntime.InvokeAsync<string>("formatChatInputMarkdown", CHAT_INPUT_ID, formatType));
this.KeepComposerInDraft();
this.hasUnsavedChanges = true;
}
@@ -1016,9 +1094,141 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft();
this.KeepComposerInDraft();
this.hasUnsavedChanges = true;
}
/// <summary>
/// Hands what the user changed in the composer to the draft of the open chat, and stores the chat
/// once the user paused.
/// </summary>
/// <remarks>
/// Only for changes of the user: a chat template fills the composer as well, but that is no message
/// anybody started, so it never reaches the draft on its own. The draft follows the composer in
/// every storage mode, so storing the chat by hand keeps the message, too. Storing it without being
/// asked happens only while chats are stored automatically, though, because it stores every other
/// change of the chat along with it.
/// </remarks>
private void KeepComposerInDraft()
{
if (this.ChatThread is null)
return;
this.MirrorComposerIntoDraft(this.ChatThread);
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
this.draftAutosave?.Schedule(this.ChatThread);
}
/// <summary>
/// Puts the text and the attachments of the composer into the draft of the given chat.
/// </summary>
/// <param name="chat">The chat the message in the composer belongs to.</param>
private void MirrorComposerIntoDraft(ChatThread chat)
{
chat.Draft.SetText(this.ComposerState.UserInput);
chat.Draft.MirrorAttachments(this.ComposerState.FileAttachments);
}
/// <summary>
/// Stores the chat being left with the message the user started in it, if that is still pending.
/// </summary>
private async Task FlushDraftAsync()
{
if (this.draftAutosave is not null)
await this.draftAutosave.FlushAsync();
}
/// <summary>
/// Stores a new chat which is being left, when the user started a message in it.
/// </summary>
/// <remarks>
/// A new chat reaches the disk together with its first message. Whoever leaves it before would
/// lose the message, so the chat is stored now, with the message as its draft. That happens only
/// while chats are stored automatically, and only for a message of the user: the text of a chat
/// template alone is nobody's message, and storing it would fill the list with chats nobody
/// started.<br/><br/>
///
/// A new chat comes in two kinds. Usually, it has no thread before its first message, and the
/// message lives in the composer only. It becomes a disappearing chat of its own then. Starting a
/// new chat in a workspace creates the thread right away instead, see unstoredChat. That one is
/// stored with whatever its draft holds, and also while a transcript is still being made for it,
/// since the transcript would otherwise arrive in a chat which is nowhere.<br/><br/>
///
/// The composer is cleared before the chat is stored, since the message has moved into its chat.
/// Leaving twice in a row, e.g., with two quick clicks, therefore stores it only once.
/// </remarks>
/// <param name="hadNoThread">Whether the chat being left had no thread, so its message is in the composer only.</param>
private async Task PreserveNewChatDraftAsync(bool hadNoThread)
{
// Whatever happens below, the new chat is left now:
var unstoredChatBeingLeft = this.unstoredChat;
this.unstoredChat = null;
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
ChatThread chat;
if (hadNoThread)
{
if (!this.ComposerState.HasVisibleUserDraft)
return;
this.RefreshCurrentProfileAndChatTemplate();
chat = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput));
this.MirrorComposerIntoDraft(chat);
this.ComposerState.Clear();
}
else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId))))
chat = unstoredChatBeingLeft;
else
return;
try
{
await this.StoreAndListChatAsync(chat);
}
catch (Exception e)
{
this.Logger.LogError(e, $"The new chat '{chat.ChatId}' could not be stored with the message the user started in it.");
}
}
/// <summary>
/// Stores the given chat, through the workspace component whenever there is one.
/// </summary>
/// <remarks>
/// The workspace component updates its tree along with storing, so it lists a new chat right
/// away, and a changed one with its new name and time.
/// </remarks>
/// <param name="chat">The chat to store.</param>
private Task StoreAndListChatAsync(ChatThread chat) => this.Workspaces is not null ? this.Workspaces.StoreChatAsync(chat) : WorkspaceBehaviour.StoreChatAsync(chat);
/// <summary>
/// Stores a new chat before the user leaves the chat page, when the user started a message in it.
/// </summary>
/// <remarks>
/// The page takes the composer along when it goes, and with it the message of a chat which has
/// no thread yet. This is a handler of its own rather than a message of the bus: the navigation
/// waits for its handlers, while the bus hands a message over without waiting, so the page could
/// be gone before the chat is stored.<br/><br/>
///
/// Only while chats are stored automatically. Otherwise, nothing is preserved anyway, and the
/// navigation may still be canceled after the user was asked about unsaved changes, so the new
/// chat has to stay as it is.
/// </remarks>
/// <param name="context">The navigation which is about to happen.</param>
private async ValueTask OnLocationChangingAsync(LocationChangingContext context)
{
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
// Opening the chat page again keeps the page, and the composer with it:
if (string.Equals(this.NavigationManager.ToAbsoluteUri(context.TargetLocation).AbsolutePath, Routes.CHAT, StringComparison.OrdinalIgnoreCase))
return;
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
}
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
private async Task<ChatThread?> EnsureMediaImportChatAsync(string firstMediaPath)
{
@@ -1031,21 +1241,18 @@ public partial class ChatComponent : MSGComponentBase
? string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath))
: promptName;
this.ChatThread = this.NewChatThread(threadName) with
{
DataSourceOptions = this.earlyDataSourceOptions,
};
this.ChatThread = this.NewChatThread(threadName);
// A message the user started belongs to the new chat, so it is stored with it:
if (this.ComposerState.HasUserDraft)
this.MirrorComposerIntoDraft(this.ChatThread);
//
// Like saving, this goes through the workspace component whenever there is one, so its tree
// lists the new chat right away, together with the progress of the transcription, instead
// of only after the first message was sent:
//
if (this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
await this.StoreAndListChatAsync(this.ChatThread);
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.SyncForegroundChatAsync();
@@ -1073,11 +1280,7 @@ public partial class ChatComponent : MSGComponentBase
// Create a new chat thread if necessary:
if (this.ChatThread is null)
{
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput)) with
{
DataSourceOptions = this.earlyDataSourceOptions,
};
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput));
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
@@ -1112,7 +1315,7 @@ public partial class ChatComponent : MSGComponentBase
Text = this.ComposerState.UserInput,
FileAttachments = normalizedAttachments,
};
this.ChatThread.PendingMediaTranscripts.Clear();
this.ChatThread.Draft.Clear();
//
// Add the user message to the thread:
@@ -1163,12 +1366,16 @@ public partial class ChatComponent : MSGComponentBase
Content = aiText,
});
// Clear the input field:
await this.inputField.FocusAsync();
this.ComposerState.Clear();
await this.inputField.BlurAsync();
//
// Clear the input field, unless the answer is only generated again. The message the user
// has started to write in the meantime is no part of that, so it stays where it is:
//
if (!reuseLastUserPrompt)
{
await this.inputField.FocusAsync();
this.ComposerState.Clear();
await this.inputField.BlurAsync();
}
// Enable the stream state for the chat component:
this.hasUnsavedChanges = true;
@@ -1185,6 +1392,9 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.RuntimeAssistantName = string.Empty;
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds, this.ChatThread.RequiredOutboundDataRestriction.Restriction);
// From here on, the AI job stores the chat, in every storage mode:
this.unstoredChat = null;
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
ChatThread = this.ChatThread,
@@ -1242,17 +1452,11 @@ public partial class ChatComponent : MSGComponentBase
if (!this.CanThreadBeSaved)
return;
//
// When the workspace component is visible, we store the chat
// through the workspace component. The advantage of this is that
// the workspace gets updated automatically when the chat is saved.
//
if (this.Workspaces is not null)
await this.Workspaces.StoreChatAsync(this.ChatThread);
else
await WorkspaceBehaviour.StoreChatAsync(this.ChatThread);
await this.StoreAndListChatAsync(this.ChatThread);
this.hasUnsavedChanges = false;
// A new chat is on the disk now, like any other:
this.unstoredChat = null;
}
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false)
@@ -1274,6 +1478,17 @@ public partial class ChatComponent : MSGComponentBase
return;
}
//
// The chat being left keeps the message the user started in it. This comes before deleting
// the chat, so that storing it can never bring it back afterward. A new chat which never
// reached the disk is simply dropped when the user wants it deleted:
//
if (deletePreviousChat)
this.unstoredChat = null;
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
//
// Delete the previous chat when desired and necessary:
//
@@ -1348,9 +1563,10 @@ public partial class ChatComponent : MSGComponentBase
//
// When the user wants to start a new chat in the same workspace, we have to
// reset the chat thread only. The workspace id and the workspace name remain
// the same:
// the same. The new chat reaches the disk only once there is something to keep:
//
this.ChatThread = this.NewChatThread(string.Empty);
this.unstoredChat = this.ChatThread;
}
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
@@ -1467,14 +1683,17 @@ public partial class ChatComponent : MSGComponentBase
if (copy is null)
return;
var transcriptsOfTheOriginal = sourceChat.PendingMediaTranscripts.Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
var transcriptsOfTheOriginal = sourceChat.Draft.MediaTranscripts.Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
this.ComposerState.FileAttachments.RemoveWhere(attachment => transcriptsOfTheOriginal.Contains(attachment.FilePath));
foreach (var transcript in copy.PendingMediaTranscripts)
foreach (var transcript in copy.Draft.MediaTranscripts)
this.ComposerState.FileAttachments.Add(transcript);
this.ChatThread = copy;
this.hasUnsavedChanges = false;
// The copy carries the message along, so a new chat which never reached the disk stays that way:
this.unstoredChat = null;
await this.SyncForegroundChatAsync();
this.MarkCurrentChatAsLoadedParameter();
await this.SyncWorkspaceHeaderWithChatThreadAsync();
@@ -1489,6 +1708,9 @@ public partial class ChatComponent : MSGComponentBase
if (this.ChatThread is not null)
{
this.ChatThread = this.AIJobService.TryGetLiveChatThread(this.ChatThread.ChatId) ?? this.ChatThread;
// The message the user had started in this chat comes back:
this.ComposerState.RestoreFromDraft(this.ChatThread.Draft);
this.loadedParameterChatId = this.ChatThread.ChatId;
this.loadedParameterWorkspaceId = this.ChatThread.WorkspaceId;
if (notifyParent)
@@ -1533,6 +1755,8 @@ public partial class ChatComponent : MSGComponentBase
private async Task ResetState()
{
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
this.ClearWorkspaceHeaderState();
@@ -1617,7 +1841,7 @@ public partial class ChatComponent : MSGComponentBase
if(this.ChatThread is null)
return Task.CompletedTask;
if (block is not ContentText textBlock)
if (block is not ContentText)
return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last();
@@ -1625,9 +1849,9 @@ public partial class ChatComponent : MSGComponentBase
if(lastBlockContent is null)
return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.RemoveForEditing(block);
this.ChatThread.Remove(lastBlockContent);
this.RestoreComposerForEditing();
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
@@ -1638,19 +1862,28 @@ public partial class ChatComponent : MSGComponentBase
if(this.ChatThread is null)
return Task.CompletedTask;
if (block is not ContentText textBlock)
if (block is not ContentText)
return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.RemoveForEditing(block);
this.RestoreComposerForEditing();
this.hasUnsavedChanges = true;
this.StateHasChanged();
return Task.CompletedTask;
}
private void RestoreComposerFromTextBlock(ContentText textBlock)
/// <summary>
/// Puts the message being edited into the composer, after the chat made it its draft again.
/// </summary>
/// <remarks>
/// Taken from the draft rather than from the message, so the composer also shows the transcripts
/// the draft held before. The chat is then stored like after any other change of the draft: who
/// edits a message and goes elsewhere finds it in the composer when coming back.
/// </remarks>
private void RestoreComposerForEditing()
{
this.ComposerState.RestoreFromTextBlock(textBlock);
this.ComposerState.RestoreFromDraft(this.ChatThread!.Draft);
this.KeepComposerInDraft();
}
/// <summary>
@@ -1802,13 +2035,14 @@ public partial class ChatComponent : MSGComponentBase
/// the one which is then sent would be wrong in exactly the moment a person looks at it: before
/// they send their first message.
///
/// The data source options are left out on purpose: two of the three callers set them and the
/// third replaces them right afterwards, so this stays the part they agree on.
/// The data source options are those chosen before the chat had a thread. Starting a new chat in
/// a workspace replaces them right afterward with those its chat template asks for.
/// </remarks>
/// <param name="name">The name of the thread.</param>
/// <returns>The new thread.</returns>
private ChatThread NewChatThread(string name) => new()
{
DataSourceOptions = this.earlyDataSourceOptions,
IncludeDateTime = true,
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
@@ -1882,8 +2116,13 @@ public partial class ChatComponent : MSGComponentBase
if (this.IsCurrentChatStreaming)
return Task.FromResult((TResult?) (object) false);
return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || this.ComposerState.HasVisibleUserDraft));
//
// A message the user started counts as unsaved on its own only while there is no chat
// to keep it. Otherwise, it is part of the chat: changing it counts as a change of the
// chat, and a message which came back from the disk with the chat is saved already.
//
return Task.FromResult((TResult?)(object)(this.hasUnsavedChanges || (this.ChatThread is null && this.ComposerState.HasVisibleUserDraft)));
}
return Task.FromResult(default(TResult));
@@ -1896,10 +2135,23 @@ public partial class ChatComponent : MSGComponentBase
protected override async ValueTask DisposeResourcesAsync()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
this.locationChangingRegistration?.Dispose();
if (this.tokenTracker is not null)
await this.tokenTracker.DisposeAsync();
// Stores what is still pending, before the chat is left:
if (this.draftAutosave is not null)
await this.draftAutosave.DisposeAsync();
//
// So does a new chat started in a workspace, since the next component of the page, e.g.,
// after the workspace sidebar was shown or hidden, cannot tell it from a deleted one. A chat
// without a thread stays in the composer, though: the page keeps that, and its next
// component goes on with it. Leaving the page stores it, see OnLocationChangingAsync.
//
await this.PreserveNewChatDraftAsync(hadNoThread: false);
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{
await this.SaveThread();
@@ -56,10 +56,18 @@ public sealed class ChatComposerState
this.HasUserDraft = false;
}
public void RestoreFromTextBlock(ContentText textBlock)
/// <summary>
/// Brings the message the user had not sent yet back into the composer.
/// </summary>
/// <remarks>
/// The draft holds only what the user wrote or attached, so whatever it holds counts as their
/// message. An empty draft leaves the composer free for a chat template.
/// </remarks>
/// <param name="draft">The draft of the chat.</param>
public void RestoreFromDraft(ChatDraft draft)
{
this.UserInput = textBlock.Text;
this.ReplaceFileAttachments(textBlock.FileAttachments);
this.HasUserDraft = true;
this.UserInput = draft.Text;
this.ReplaceFileAttachments(draft.FileAttachments);
this.HasUserDraft = !draft.IsEmpty;
}
}
@@ -1,108 +0,0 @@
using Microsoft.AspNetCore.Components;
using Timer = System.Timers.Timer;
namespace AIStudio.Components;
/// <summary>
/// Debounced multi-line text input built on <see cref="MudTextField{T}"/>.
/// Keeps the base API while adding a debounce timer.
/// Callers can override any property as usual.
/// </summary>
public class UserPromptComponent<T> : MudTextField<T>, IDisposable
{
[Parameter]
public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(800);
[Parameter]
public Func<string, Task> WhenTextChangedAsync { get; set; } = _ => Task.CompletedTask;
private readonly Timer debounceTimer = new();
private string text = string.Empty;
private string lastParameterText = string.Empty;
private string lastNotifiedText = string.Empty;
private bool isInitialized;
private bool isDisposed;
protected override async Task OnInitializedAsync()
{
this.text = this.Text ?? string.Empty;
this.lastParameterText = this.text;
this.lastNotifiedText = this.text;
this.debounceTimer.AutoReset = false;
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
this.debounceTimer.Elapsed += this.WhenDebounceElapsed;
this.isInitialized = true;
await base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
// Ensure the timer uses the latest debouncing interval:
if (!this.isInitialized || this.isDisposed)
{
await base.OnParametersSetAsync();
return;
}
if(Math.Abs(this.debounceTimer.Interval - this.DebounceTime.TotalMilliseconds) > 1)
this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds;
// Only sync when the parent's parameter actually changed since the last change:
if (this.Text != this.lastParameterText)
{
this.text = this.Text ?? string.Empty;
this.lastParameterText = this.text;
}
this.debounceTimer.Stop();
this.debounceTimer.Start();
await base.OnParametersSetAsync();
}
private void WhenDebounceElapsed(object? sender, System.Timers.ElapsedEventArgs args)
{
this.debounceTimer.Stop();
//
// The timer runs on its own thread and may still fire while this component is being torn
// down. Notifying a renderer which is already gone would throw on that thread, where no
// caller is left to handle it.
//
if (this.isDisposed || this.text == this.lastNotifiedText)
return;
this.lastNotifiedText = this.text;
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: notifying about changed text");
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(UserPromptComponent<T>)}: handling changed text asynchronously");
}
#region IDisposable
public void Dispose()
{
if (this.isDisposed)
return;
//
// Set before stopping the timer: the handler might be running on the timer thread right
// now, and this is what tells it to leave the gone renderer alone.
//
this.isDisposed = true;
try
{
this.debounceTimer.Elapsed -= this.WhenDebounceElapsed;
this.debounceTimer.Stop();
this.debounceTimer.Dispose();
}
catch
{
// ignore
}
GC.SuppressFinalize(this);
}
#endregion
}