mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 07:13:48 +00:00
Kept the unsent message with its chat (#1049)
This commit is contained in:
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
|
||||
}
|
||||
Reference in new issue
Block a user