Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions app/MindWork AI Studio/Components/ChatComponent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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;"/>
Expand All @@ -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>
}

Expand Down
128 changes: 86 additions & 42 deletions app/MindWork AI Studio/Components/ChatComponent.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,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!;
Expand All @@ -62,28 +65,43 @@ 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;
private bool autoSaveEnabled;
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);
Expand All @@ -94,15 +112,12 @@ protected override async Task OnInitializedAsync()

// 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',
Expand All @@ -120,6 +135,7 @@ protected override async Task OnInitializedAsync()
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,
Expand Down Expand Up @@ -246,6 +262,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)

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.");

Expand Down Expand Up @@ -276,13 +293,35 @@ protected override async Task OnAfterRenderAsync(bool firstRender)

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);
Expand Down Expand Up @@ -424,12 +463,10 @@ private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
{
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;
Expand Down Expand Up @@ -489,6 +526,7 @@ private async Task InputKeyEvent(KeyboardEventArgs keyEvent)
this.dataSourceSelectionComponent.Hide();

this.hasUnsavedChanges = true;
this.ComposerState.MarkUserDraft();
var key = keyEvent.Code.ToLowerInvariant();

// Was the enter key (either enter or numpad enter) pressed?
Expand Down Expand Up @@ -520,7 +558,16 @@ private async Task ApplyMarkdownFormat(string formatType)
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;
}

Expand Down Expand Up @@ -548,17 +595,18 @@ private async Task SendMessage(bool reuseLastUserPrompt = false)
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;
Expand All @@ -575,14 +623,14 @@ private async Task SendMessage(bool reuseLastUserPrompt = false)
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,
};

Expand Down Expand Up @@ -629,8 +677,7 @@ private async Task SendMessage(bool reuseLastUserPrompt = false)
// Clear the input field:
await this.inputField.FocusAsync();

this.userInput = string.Empty;
this.chatDocumentPaths.Clear();
this.ComposerState.Clear();

await this.inputField.BlurAsync();

Expand Down Expand Up @@ -724,7 +771,7 @@ private async Task StartNewChat(bool useSameWorkspace = false, bool deletePrevio
// Reset our state:
//
this.hasUnsavedChanges = false;
this.userInput = string.Empty;
this.ComposerState.Clear();

//
// Reset the LLM provider considering the user's settings:
Expand Down Expand Up @@ -781,18 +828,14 @@ private async Task StartNewChat(bool useSameWorkspace = false, bool deletePrevio
};
}

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

Expand Down Expand Up @@ -834,26 +877,33 @@ private async Task MoveChatToWorkspace()
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();
Expand All @@ -872,10 +922,11 @@ private async Task LoadedChatChanged()
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);
Expand Down Expand Up @@ -974,11 +1025,7 @@ private Task EditLastBlock(IContent block)

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
Expand All @@ -1000,10 +1047,6 @@ private void RestoreComposerFromTextBlock(ContentText textBlock)
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:
Expand All @@ -1030,7 +1073,7 @@ private void RestoreComposerFromTextBlock(ContentText textBlock)
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));
Expand All @@ -1049,6 +1092,7 @@ public async ValueTask DisposeAsync()
}

await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
this.Dispose();
}

#endregion
Expand Down
Loading