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(),