Added profile, provider, and workspace preselection to chat templates (#1099)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-10-22 14:15:10 +02:00
1 parent ab9df99cf0
commit 24c4c31035
34 files changed
+2084 -142

No files matched your search

@@ -901,6 +901,84 @@ public partial class ChatComponent : MSGComponentBase
this.selectedToolIds = await this.ToolRegistry.FilterSelectableToolIdsAsync(Tools.Components.CHAT, templateToolIds);
}
/// <summary>
/// Takes over the profile and provider which the chat template preselects, wherever it preselects one.
/// </summary>
/// <remarks>
/// A preselection, not a limit — the user changes both in the chat as usual. A template which
/// says nothing, or names a provider which is gone or may not be used for chats, leaves the
/// current choice in place, so the chat starts as it would without the template. A template
/// whose profile is gone starts with the default profile of the chat settings instead. What the
/// previous template preselected is not carried over, see SettingsManager.ChooseChatTemplateProfile.
/// The caller tells the parent about a new provider.
/// </remarks>
/// <param name="previousChatTemplate">The chat template the current profile and provider were chosen with; the current one when the template does not change.</param>
/// <returns>True when the provider changed.</returns>
private bool ApplyChatTemplateProfileAndProvider(ChatTemplate previousChatTemplate)
{
this.currentProfile = this.SettingsManager.ChooseChatTemplateProfile(this.currentChatTemplate, previousChatTemplate, this.currentProfile);
var provider = this.SettingsManager.ChooseChatTemplateProvider(this.currentChatTemplate, previousChatTemplate, this.Provider);
if (provider == this.Provider)
return false;
this.Provider = provider;
return true;
}
/// <summary>
/// The workspace a chat which has no thread yet gets with its first content.
/// </summary>
/// <remarks>
/// The first content is the first message, a media import, or a draft kept when the user leaves
/// the page. A chat without a thread belongs to no workspace, so the workspace of its chat
/// template fills in, opened or created by its name. That happens only here, when the chat gets
/// something to keep: merely opening the chat page or trying out a template creates no
/// workspace, and a workspace the user deleted stays gone until a chat with that template
/// actually starts. Without a usable workspace of the template, the chat stays without one.
/// </remarks>
/// <returns>The ID of the workspace, or Guid.Empty for none.</returns>
private async Task<Guid> ResolveWorkspaceOfFirstThreadAsync()
{
if (this.currentWorkspaceId != Guid.Empty)
return this.currentWorkspaceId;
if (this.SettingsManager.GetChatTemplateWorkspaceName(this.currentChatTemplate) is not { } workspaceName)
return Guid.Empty;
return await WorkspaceBehaviour.ResolveOrCreateChatTemplateWorkspaceAsync(workspaceName, this.currentChatTemplate.GetSafeName());
}
/// <summary>
/// Hands a thread which this component just created for a chat without one to the parent.
/// </summary>
/// <remarks>
/// The thread is a parameter of this component, and the parent hands over its own whenever it
/// renders. So the parent hears about the new thread before anything else may render it, see
/// StartNewChat. Only afterward, the header shows the workspace of the thread, since showing a
/// workspace name renders the parent as well.
/// </remarks>
private async Task PublishNewChatThreadAsync()
{
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
// The header shows the workspace of the new thread from now on, e.g., the one of its chat template:
if (this.ChatThread is not null && this.ChatThread.WorkspaceId != Guid.Empty)
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
/// <summary>
/// Whether the open chat is in the workspace which the given chat template names.
/// </summary>
/// <remarks>
/// Such a workspace was the choice of the template rather than of the user, so a chat started
/// with another template does not have to stay there.
/// </remarks>
private bool IsInWorkspaceOfChatTemplate(ChatTemplate chatTemplate) => this.currentWorkspaceId != Guid.Empty
&& this.SettingsManager.GetChatTemplateWorkspaceName(chatTemplate) is { } workspaceName
&& string.Equals(WorkspaceBehaviour.NormalizeWorkspaceName(this.currentWorkspaceName), workspaceName, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The data source options a chat starts with: those of its chat template, or the chat defaults.
/// </summary>
@@ -1006,24 +1084,36 @@ public partial class ChatComponent : MSGComponentBase
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
{
this.currentChatTemplate = this.SettingsManager.GetChatTemplateById(chatTemplate.Id);
var nextChatTemplate = this.SettingsManager.GetChatTemplateById(chatTemplate.Id);
if (this.ChatThread is not null)
{
//
// Starting the new chat is what hands the new template to it, with its text, attachments,
// and selections. Nothing changes here before, since the user may still decline to leave
// the open chat; it then stays as it was, with its template. The new chat stays in the
// workspace of the open one, unless that workspace is merely the one of the current
// template: then the new template decides where its chat goes.
//
await this.StartNewChat(useSameWorkspace: !this.IsInWorkspaceOfChatTemplate(this.currentChatTemplate), chatTemplate: nextChatTemplate);
return;
}
var previousChatTemplate = this.currentChatTemplate;
this.currentChatTemplate = nextChatTemplate;
if(!string.IsNullOrWhiteSpace(this.currentChatTemplate.PredefinedUserPrompt))
this.ComposerState.SetSystemInput(this.currentChatTemplate.PredefinedUserPrompt);
// Apply template's file attachments (replaces existing):
this.ComposerState.ReplaceFileAttachments(this.currentChatTemplate.FileAttachments);
if (this.ChatThread is not null)
{
// Starting the new chat is what hands the selection of the new template to it:
await this.StartNewChat(true);
return;
}
//
// Without a thread there is nothing to start anew, so the selection of the new template is
// applied right here. It travels into the thread which the first message creates.
// applied right here. It travels into the thread which the first message creates, and that
// message also decides the workspace, see ResolveWorkspaceOfFirstThreadAsync.
//
if (this.ApplyChatTemplateProfileAndProvider(previousChatTemplate))
await this.ProviderChanged.InvokeAsync(this.Provider);
await this.ApplyChatTemplateToolSelectionAsync();
this.ApplyAutomaticDataSourceOptions();
}
@@ -1047,6 +1137,19 @@ public partial class ChatComponent : MSGComponentBase
{
this.currentProfile = this.SettingsManager.GetPreselectedProfile(Tools.Components.CHAT);
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
//
// A chat which has not started yet keeps up with its chat template. The provider follows
// only when the template now preselects another one, so that a provider the user picked
// by hand survives a change of some unrelated setting:
//
if (previousChatTemplate.Id != this.currentChatTemplate.Id || previousChatTemplate.ProviderId != this.currentChatTemplate.ProviderId)
{
if (this.ApplyChatTemplateProfileAndProvider(previousChatTemplate))
await this.ProviderChanged.InvokeAsync(this.Provider);
}
else if (this.SettingsManager.GetChatTemplateStartProfile(this.currentChatTemplate) is { } templateProfile)
this.currentProfile = templateProfile;
}
else
{
@@ -1256,10 +1359,16 @@ public partial class ChatComponent : MSGComponentBase
if (!this.ComposerState.HasVisibleUserDraft)
return;
//
// The message moves out of the composer before anything below waits, so that leaving a
// second time meanwhile finds nothing left to keep. Only the workspace follows afterward,
// since finding or creating the one of the chat template may take a moment:
//
this.RefreshCurrentProfileAndChatTemplate();
chat = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput));
chat = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput), Guid.Empty);
this.MirrorComposerIntoDraft(chat);
this.ComposerState.Clear();
chat.WorkspaceId = await this.ResolveWorkspaceOfFirstThreadAsync();
}
else if (unstoredChatBeingLeft is not null && (!unstoredChatBeingLeft.Draft.IsEmpty || this.MediaTranscriptionService.IsBusy(MediaImportOwner.ForChat(unstoredChatBeingLeft.ChatId))))
{
@@ -1330,22 +1439,28 @@ public partial class ChatComponent : MSGComponentBase
? string.Format(T("Transcription: {0}"), Path.GetFileName(firstMediaPath))
: promptName;
this.ChatThread = this.NewChatThread(threadName);
var chat = this.NewChatThread(threadName, await this.ResolveWorkspaceOfFirstThreadAsync());
// A message the user started belongs to the new chat, so it is stored with it:
if (this.ComposerState.HasUserDraft)
this.MirrorComposerIntoDraft(this.ChatThread);
this.MirrorComposerIntoDraft(chat);
//
// The parent hears about the new thread before storing it waits, as with the first message.
// A render of the parent in between would otherwise hand back no thread at all, and the
// transcript would arrive in a chat which is nowhere:
//
this.ChatThread = chat;
await this.PublishNewChatThreadAsync();
//
// 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:
//
await this.StoreAndListChatAsync(this.ChatThread);
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.StoreAndListChatAsync(chat);
await this.SyncForegroundChatAsync();
return this.ChatThread;
return chat;
}
private async Task SendMessage(bool reuseLastUserPrompt = false)
@@ -1377,9 +1492,8 @@ 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));
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.ChatThread = this.NewChatThread(this.ExtractThreadName(this.ComposerState.UserInput), await this.ResolveWorkspaceOfFirstThreadAsync());
await this.PublishNewChatThreadAsync();
}
else
{
@@ -1576,12 +1690,17 @@ public partial class ChatComponent : MSGComponentBase
/// Every way to start a new chat leads here: the buttons of the toolbar, a change of the chat
/// template, and the workspace tree. So every new chat gets its chat template with the data
/// sources, tools, and example conversation, its profile, and its provider in the same way, and
/// the user is asked about unsaved changes in the same way.
/// the user is asked about unsaved changes in the same way.<br/><br/>
///
/// A new chat template takes over only once the user agreed to leave the open chat and that
/// chat is stored or deleted. Declining leaves the open chat as it was, with its template, and
/// the template it runs with stays the one whose preselections the next chat must not carry over.
/// </remarks>
/// <param name="useSameWorkspace">True starts the new chat with a thread right away, in the workspace of the open chat. False starts it outside any workspace, and its thread comes with the first message.</param>
/// <param name="useSameWorkspace">True starts the new chat with a thread right away, in the workspace of the open chat. False starts it outside any workspace, and its thread comes with the first message. A chat which would belong to no workspace either way, while its chat template names one, also gets its thread only with the first message, in the workspace of the template.</param>
/// <param name="deletePreviousChat">Whether to delete the open chat first.</param>
/// <param name="targetWorkspaceId">The workspace for the new chat, when the workspace tree asks for one. When given, the new chat gets a thread in that workspace, whatever useSameWorkspace says.</param>
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false, Guid? targetWorkspaceId = null)
/// <param name="chatTemplate">The chat template of the new chat, when the user picked another one. Without one, the new chat keeps the template of the open chat.</param>
private async Task StartNewChat(bool useSameWorkspace = false, bool deletePreviousChat = false, Guid? targetWorkspaceId = null, ChatTemplate? chatTemplate = null)
{
//
// Where the new chat goes is decided right away, when it is asked for. Deleting the open chat
@@ -1589,6 +1708,9 @@ public partial class ChatComponent : MSGComponentBase
//
var workspaceOfNewChat = targetWorkspaceId ?? this.currentWorkspaceId;
// The template the profile and the provider of the open chat were chosen with:
var previousChatTemplate = this.currentChatTemplate;
//
// Want the user to manage the chat storage manually? In that case, we have to ask the user
// about possible data loss:
@@ -1650,8 +1772,13 @@ public partial class ChatComponent : MSGComponentBase
//
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
// The open chat is left now, so the new template takes over:
if (chatTemplate is not null)
this.currentChatTemplate = chatTemplate;
this.RefreshCurrentProfileAndChatTemplate();
//
// Reset the LLM provider considering the user's settings:
//
@@ -1673,11 +1800,26 @@ public partial class ChatComponent : MSGComponentBase
break;
}
// What the chat template preselects comes last, so that it wins over the behavior above:
if (this.ApplyChatTemplateProfileAndProvider(previousChatTemplate))
await this.ProviderChanged.InvokeAsync(this.Provider);
//
// Reset the chat thread or create a new one. A target workspace always means a new chat in
// that workspace, no matter what useSameWorkspace says:
//
var startsOutsideWorkspaces = !useSameWorkspace && targetWorkspaceId is null;
//
// The workspace of the chat template fills in only where the new chat would belong to no
// workspace otherwise. A workspace asked for on purpose wins: the one of the workspace list,
// and the open one for the button that starts a new chat in it. Such a chat gets no thread
// here: its first message creates one in that workspace, so that the workspace is created
// only once there is something to keep, see ResolveWorkspaceOfFirstThreadAsync.
//
if (targetWorkspaceId is null && workspaceOfNewChat == Guid.Empty && this.SettingsManager.GetChatTemplateWorkspaceName(this.currentChatTemplate) is not null)
startsOutsideWorkspaces = true;
if (startsOutsideWorkspaces)
{
//
@@ -1916,7 +2058,11 @@ public partial class ChatComponent : MSGComponentBase
var chatChatTemplate = this.ChatThread?.SelectedChatTemplate;
this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(chatProvider);
// A chat without a thread has not started yet, so it starts with what its chat template preselects:
if (this.ChatThread is null)
this.ApplyChatTemplateProfileAndProvider(this.currentChatTemplate);
await this.ProviderChanged.InvokeAsync(this.Provider);
// Try to select the profile:
@@ -2054,7 +2200,7 @@ public partial class ChatComponent : MSGComponentBase
// that, and it may even bring an example conversation along -- reporting nothing for all
// of it would tell a person their window is empty while their first message is not.
//
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
var thread = this.ChatThread ?? this.NewChatThread(string.Empty, this.currentWorkspaceId);
var toolDefinitions = this.GetRunnableToolDefinitions(offersSemanticSearch);
provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
@@ -2153,18 +2299,22 @@ public partial class ChatComponent : MSGComponentBase
/// The thread a new chat starts with, as the selections made so far decide it.
/// </summary>
/// <remarks>
/// In one place because three code paths used to write it out, and because the token count has
/// to measure the same thing they build. A count against a thread assembled differently from
/// 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 only place which builds a new thread: every way to start a chat comes here, and so does
/// the token count, which has to measure the same thing they build. A count against a thread
/// assembled differently from 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 workspace is no default of its own but always named by the caller, since that is the one
/// part the ways to start a chat decide differently: StartNewChat decides it right away, and a
/// chat which gets its thread with its first content asks ResolveWorkspaceOfFirstThreadAsync.
///
/// 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>
/// <param name="workspaceId">The workspace of the thread. Without one, the thread goes to the workspace of the open chat.</param>
/// <param name="workspaceId">The workspace of the thread, or Guid.Empty for none.</param>
/// <returns>The new thread.</returns>
private ChatThread NewChatThread(string name, Guid? workspaceId = null) => new()
private ChatThread NewChatThread(string name, Guid workspaceId) => new()
{
DataSourceOptions = this.earlyDataSourceOptions,
IncludeDateTime = true,
@@ -2173,7 +2323,7 @@ public partial class ChatComponent : MSGComponentBase
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = workspaceId ?? this.currentWorkspaceId,
WorkspaceId = workspaceId,
ChatId = Guid.NewGuid(),
Name = name,
Blocks = this.currentChatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : this.currentChatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
@@ -1,16 +1,7 @@
@using AIStudio.Settings.DataModel
@inherits MSGComponentBase
@if (this.availableWorkspaces.Count > 0)
{
<MudSelect T="string" Value="@this.WorkspaceName" ValueChanged="@this.SelectExistingWorkspace" Strict="@false" Label="@T("Existing workspace (Optional)")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Folder">
@foreach (var workspace in this.availableWorkspaces)
{
<MudSelectItem T="string" Value="@workspace.Name">@workspace.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" AdornmentIcon="@(this.OpensTemporaryChat ? Icons.Material.Filled.Timer : Icons.Material.Filled.CreateNewFolder)" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Workspace name (Optional)")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-1" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<WorkspaceNameField WorkspaceName="@this.WorkspaceName" WorkspaceNameChanged="@this.SetWorkspaceName" SelectLabel="@T("Existing workspace (Optional)")" Label="@T("Workspace name (Optional)")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" AdornmentIcon="@(this.OpensTemporaryChat ? Icons.Material.Filled.Timer : Icons.Material.Filled.CreateNewFolder)" AdornmentIconSize="Size.Small" TextFieldClass="mb-1"/>
@* The tile behaves differently with and without a workspace, so the form says which of the two is
chosen right now instead of only explaining that both are possible. *@
@@ -19,6 +10,12 @@
? T("Without a workspace, the tile opens a disappearing chat: it belongs to no workspace and is deleted according to your workspace maintenance settings.")
: T("The tile opens its chat in this workspace and creates the workspace when it does not exist yet."))
</MudText>
@if (this.OpensTemporaryChat && this.SettingsManager.GetChatTemplateWorkspaceName(this.SelectedChatTemplate) is { } templateWorkspaceName)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@string.Format(T("The chosen chat template keeps its chats in the workspace '{0}', so the tile opens its chat there instead. A workspace entered here wins over the one of the template."), templateWorkspaceName)
</MudText>
}
<MudSelect T="string" Value="@this.ProviderId" ValueChanged="@this.SetProviderId" Label="@T("Chat provider")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.SmartToy">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
@foreach (var provider in this.SettingsManager.GetConfidentProviders(Components.CHAT))
@@ -28,6 +25,12 @@
</MudSelectItem>
}
</MudSelect>
@if (this.SelectedChatTemplate.ProviderId is not null)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template preselects a provider of its own, and it wins over a selection made here as long as that provider can be used for chats.")
</MudText>
}
<MudSelect T="string" Value="@this.ProfileId" ValueChanged="@this.SetProfileId" Label="@T("Chat profile")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Person">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no profile")</MudSelectItem>
@@ -36,6 +39,12 @@
<MudSelectItem T="string" Value="@profile.Id">@profile.GetSafeName()</MudSelectItem>
}
</MudSelect>
@if (this.SelectedChatTemplate is { AllowProfileUsage: true, ProfileId: not null })
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template preselects a profile of its own, and it wins over a selection made here as long as that profile exists.")
</MudText>
}
<MudSelect T="string" Value="@this.ChatTemplateId" ValueChanged="@this.SetChatTemplateId" Label="@T("Chat template")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Article">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no chat template")</MudSelectItem>
@@ -44,20 +53,25 @@
<MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@AppIcons.DATABASE">
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
{
<MudSelectItem T="string" Value="@dataSource.Id">@dataSource.Name</MudSelectItem>
}
</MudSelect>
@* A chat template wins over what is chosen here, so the form says so before somebody picks
something which would never take effect: *@
@if (this.SelectedChatTemplate.DataSourceOptions is not null)
@* Without the RAG preview, the chat uses no data sources, so there is nothing to choose here. A
selection made before stays with the launcher and counts once RAG is switched on again: *@
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template brings data sources of its own, and those win over a selection made here. Only a template can also leave the choice of sources to the AI, which is why it decides this on its own.")
</MudText>
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@AppIcons.DATABASE">
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
{
<MudSelectItem T="string" Value="@dataSource.Id">@dataSource.Name</MudSelectItem>
}
</MudSelect>
@* A chat template wins over what is chosen here, so the form says so before somebody picks
something which would never take effect: *@
@if (this.SelectedChatTemplate.DataSourceOptions is not null)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-3">
@T("The chosen chat template brings data sources of its own, and those win over a selection made here. Only a template can also leave the choice of sources to the AI, which is why it decides this on its own.")
</MudText>
}
}
<ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/>
@@ -96,37 +96,6 @@ public partial class DirectChatLauncherForm : MSGComponentBase
? this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT)
: this.SettingsManager.GetChatTemplateById(this.ChatTemplateId);
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the workspace name input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
await base.OnInitializedAsync();
var workspaceSnapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.availableWorkspaces = workspaceSnapshot.Workspaces;
}
#endregion
//
// Picking an existing workspace fills the name field. Clearing the select must not wipe a name
// the user typed, though, so an empty selection is ignored:
//
private async Task SelectExistingWorkspace(string workspaceName)
{
if (string.IsNullOrWhiteSpace(workspaceName))
return;
await this.SetWorkspaceName(workspaceName);
}
private async Task SetWorkspaceName(string workspaceName)
{
this.WorkspaceName = workspaceName;
@@ -0,0 +1,13 @@
@inherits MSGComponentBase
@if (this.availableWorkspaces.Count > 0 && !this.ReadOnly)
{
<MudSelect T="string" Value="@this.WorkspaceName" ValueChanged="@this.SelectExistingWorkspace" Strict="@false" Label="@this.SelectLabel" Variant="Variant.Outlined" Margin="Margin.Dense" Class="@this.SelectClass" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Folder">
@foreach (var workspace in this.availableWorkspaces)
{
<MudSelectItem T="string" Value="@workspace.Name">@workspace.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" Label="@this.Label" HelperText="@this.HelperText" Validation="@this.Validation" Immediate="@this.Immediate" ReadOnly="@this.ReadOnly" Variant="Variant.Outlined" Margin="Margin.Dense" Class="@this.TextFieldClass" Adornment="Adornment.Start" AdornmentIcon="@this.AdornmentIcon" IconSize="@this.AdornmentIconSize" UserAttributes="@SPELLCHECK_ATTRIBUTES"/>
@@ -0,0 +1,107 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Names a workspace by its name: an existing one to pick from, or a new name to type.
/// </summary>
/// <remarks>
/// Assistant chat launchers and chat templates both name their workspace this way, because a
/// workspace has an ID of its own on every computer, and both create it when it does not exist
/// yet. The field offers the existing workspaces only as a shortcut; the name it hands out is
/// whatever the text field says. All texts come from the form around it, which knows what the
/// name is used for.
/// </remarks>
public partial class WorkspaceNameField : MSGComponentBase
{
/// <summary>
/// The name of the workspace. An empty name is up to the form around the field, e.g., a
/// disappearing chat for an assistant chat launcher.
/// </summary>
[Parameter]
public string WorkspaceName { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> WorkspaceNameChanged { get; set; }
/// <summary>
/// The label of the selection of existing workspaces.
/// </summary>
[Parameter]
public string SelectLabel { get; set; } = string.Empty;
/// <summary>
/// The label of the text field for the name.
/// </summary>
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string HelperText { get; set; } = string.Empty;
[Parameter]
public string AdornmentIcon { get; set; } = Icons.Material.Filled.CreateNewFolder;
[Parameter]
public Size AdornmentIconSize { get; set; } = Size.Medium;
/// <summary>
/// The validation of the name, or null for none.
/// </summary>
[Parameter]
public Func<string, string?>? Validation { get; set; }
/// <summary>
/// Whether every keystroke counts as a change, e.g., so that a validation follows the typing.
/// </summary>
[Parameter]
public bool Immediate { get; set; }
/// <summary>
/// Shows the name without letting the user change it. The selection of existing workspaces
/// is left out then, since picking from it would change the name.
/// </summary>
[Parameter]
public bool ReadOnly { get; set; }
[Parameter]
public string SelectClass { get; set; } = "mb-3 rounded-lg";
[Parameter]
public string TextFieldClass { get; set; } = "mb-3";
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
await base.OnInitializedAsync();
var workspaceSnapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.availableWorkspaces = workspaceSnapshot.Workspaces;
}
#endregion
//
// Picking an existing workspace fills the name field. Clearing the select must not wipe a name
// the user typed, though, so an empty selection is ignored:
//
private async Task SelectExistingWorkspace(string workspaceName)
{
if (string.IsNullOrWhiteSpace(workspaceName))
return;
await this.SetWorkspaceName(workspaceName);
}
private async Task SetWorkspaceName(string workspaceName)
{
this.WorkspaceName = workspaceName;
await this.WorkspaceNameChanged.InvokeAsync(workspaceName);
}
}