Added chat templates (#474)

This commit is contained in:
Peer Schütt 2025-05-24 12:27:00 +02:00 committed by GitHub
parent 35eebc08a5
commit df062ae6e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 799 additions and 21 deletions

View File

@ -89,6 +89,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
protected MudForm? form;
protected bool inputIsValid;
protected Profile currentProfile = Profile.NO_PROFILE;
protected ChatTemplate currentChatTemplate = ChatTemplate.NO_CHATTEMPLATE;
protected ChatThread? chatThread;
protected IContent? lastUserPrompt;
protected CancellationTokenSource? cancellationTokenSource;
@ -115,6 +116,7 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
this.MightPreselectValues();
this.providerSettings = this.SettingsManager.GetPreselectedProvider(this.Component);
this.currentProfile = this.SettingsManager.GetPreselectedProfile(this.Component);
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(this.Component);
}
protected override async Task OnParametersSetAsync()

View File

@ -1,3 +1,5 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Chat;
/// <summary>
@ -19,6 +21,8 @@ public enum ChatRole
/// </summary>
public static class ExtensionsChatRole
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatRole).Namespace, nameof(ChatRole));
/// <summary>
/// Returns the name of the role.
/// </summary>
@ -26,11 +30,11 @@ public static class ExtensionsChatRole
/// <returns>The name of the role.</returns>
public static string ToName(this ChatRole role) => role switch
{
ChatRole.SYSTEM => "System",
ChatRole.USER => "You",
ChatRole.AI => "AI",
ChatRole.SYSTEM => TB("System"),
ChatRole.USER => TB("You"),
ChatRole.AI => TB("AI"),
_ => "Unknown",
_ => TB("Unknown"),
};
/// <summary>
@ -60,4 +64,18 @@ public static class ExtensionsChatRole
_ => Icons.Material.Filled.Help,
};
/// <summary>
/// Returns the specific name of the role for the chat template.
/// </summary>
/// <param name="role">The role.</param>
/// <returns>The name of the role.</returns>
public static string ToChatTemplateName(this ChatRole role) => role switch
{
ChatRole.SYSTEM => TB("System"),
ChatRole.USER => TB("User"),
ChatRole.AI => TB("Assistant"),
_ => TB("Unknown"),
};
}

View File

@ -0,0 +1,10 @@
namespace AIStudio.Chat;
public static class ChatRoles
{
public static IEnumerable<ChatRole> ChatTemplateRoles()
{
yield return ChatRole.USER;
yield return ChatRole.AI;
}
}

View File

@ -29,6 +29,11 @@ public sealed record ChatThread
/// Specifies the profile selected for the chat thread.
/// </summary>
public string SelectedProfile { get; set; } = string.Empty;
/// <summary>
/// Specifies the profile selected for the chat thread.
/// </summary>
public string SelectedChatTemplate { get; set; } = string.Empty;
/// <summary>
/// The data source options for this chat thread.
@ -69,6 +74,8 @@ public sealed record ChatThread
/// The content blocks of the chat thread.
/// </summary>
public List<ContentBlock> Blocks { get; init; } = [];
private bool allowProfile = true;
/// <summary>
/// Prepares the system prompt for the chat thread.
@ -84,16 +91,52 @@ public sealed record ChatThread
/// <returns>The prepared system prompt.</returns>
public string PrepareSystemPrompt(SettingsManager settingsManager, ChatThread chatThread, ILogger logger)
{
//
// Use the information from the chat template, if provided. Otherwise, use the default system prompt
//
string systemPromptTextWithChatTemplate;
var logMessage = $"Using no chat template for chat thread '{chatThread.Name}'.";
if (string.IsNullOrWhiteSpace(chatThread.SelectedChatTemplate))
systemPromptTextWithChatTemplate = chatThread.SystemPrompt;
else
{
if(!Guid.TryParse(chatThread.SelectedChatTemplate, out var chatTeamplateId))
systemPromptTextWithChatTemplate = chatThread.SystemPrompt;
else
{
if(chatThread.SelectedChatTemplate == ChatTemplate.NO_CHATTEMPLATE.Id || chatTeamplateId == Guid.Empty)
systemPromptTextWithChatTemplate = chatThread.SystemPrompt;
else
{
var chatTemplate = settingsManager.ConfigurationData.ChatTemplates.FirstOrDefault(x => x.Id == chatThread.SelectedChatTemplate);
if(chatTemplate == default)
systemPromptTextWithChatTemplate = chatThread.SystemPrompt;
else
{
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{chatThread.Name}'.";
this.allowProfile = chatTemplate.AllowProfileUsage;
systemPromptTextWithChatTemplate = $"""
{chatTemplate.ToSystemPrompt()}
""";
}
}
}
}
logger.LogInformation(logMessage);
//
// Add augmented data, if available:
//
var isAugmentedDataAvailable = !string.IsNullOrWhiteSpace(chatThread.AugmentedData);
var systemPromptWithAugmentedData = isAugmentedDataAvailable switch
{
true => $"""
{chatThread.SystemPrompt}
{systemPromptTextWithChatTemplate}
{chatThread.AugmentedData}
""",
false => chatThread.SystemPrompt,
false => systemPromptTextWithChatTemplate,
};
if(isAugmentedDataAvailable)
@ -101,12 +144,13 @@ public sealed record ChatThread
else
logger.LogInformation("No augmented data is available for the chat thread.");
//
// Prepare the system prompt:
// Add information from profile if available and allowed:
//
string systemPromptText;
var logMessage = $"Using no profile for chat thread '{chatThread.Name}'.";
if (string.IsNullOrWhiteSpace(chatThread.SelectedProfile))
logMessage = $"Using no profile for chat thread '{chatThread.Name}'.";
if ((string.IsNullOrWhiteSpace(chatThread.SelectedProfile)) || (this.allowProfile is false))
systemPromptText = systemPromptWithAugmentedData;
else
{

View File

@ -18,15 +18,27 @@ public class ContentBlock
/// <summary>
/// The content of the block.
/// </summary>
public IContent? Content { get; init; }
public IContent? Content { get; set; }
/// <summary>
/// The role of the content block in the chat thread, e.g., user, AI, etc.
/// </summary>
public ChatRole Role { get; init; } = ChatRole.NONE;
public ChatRole Role { get; set; } = ChatRole.NONE;
/// <summary>
/// Should the content block be hidden from the user?
/// </summary>
public bool HideFromUser { get; set; }
public ContentBlock DeepClone()
{
return new()
{
Time = this.Time,
ContentType = this.ContentType,
Content = this.Content?.DeepClone(),
Role = this.Role,
HideFromUser = this.HideFromUser,
};
}
}

View File

@ -32,6 +32,18 @@ public sealed class ContentImage : IContent, IImageSource
{
throw new NotImplementedException();
}
/// <inheritdoc />
public IContent DeepClone()
{
return new ContentImage
{
Source = this.Source,
InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming,
SourceType = this.SourceType,
};
}
#endregion

View File

@ -125,6 +125,17 @@ public sealed class ContentText : IContent
return chatThread;
}
/// <inheritdoc />
public IContent DeepClone()
{
return new ContentText
{
Text = this.Text,
InitialRemoteWait = this.InitialRemoteWait,
IsStreaming = this.IsStreaming,
};
}
#endregion
/// <summary>

View File

@ -43,6 +43,12 @@ public interface IContent
/// </summary>
public Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastPrompt, ChatThread? chatChatThread, CancellationToken token = default);
/// <summary>
/// Creates a deep copy
/// </summary>
/// <returns>The copy</returns>
public IContent DeepClone();
/// <summary>
/// Returns the corresponding ERI content type.
/// </summary>

View File

@ -80,6 +80,11 @@
<MudIconButton Icon="@Icons.Material.Filled.CommentBank" OnClick="() => this.StartNewChat(useSameWorkspace: true)"/>
</MudTooltip>
}
@if (this.SettingsManager.ConfigurationData.ChatTemplates.Count > 0)
{
<ChatTemplateSelection CurrentChatTemplate="@this.currentChatTemplate" CurrentChatTemplateChanged="@this.ChatTemplateWasChanged"/>
}
@if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{
@ -106,9 +111,9 @@
<MudIconButton Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="() => this.CancelStreaming()"/>
</MudTooltip>
}
<ProfileSelection CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged"/>
<ProfileSelection CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.currentChatTemplate.AllowProfileUsage)" DisabledText="@T("Profile usage is disabled according to your chat template settings.")"/>
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
<DataSourceSelection @ref="@this.dataSourceSelectionComponent" PopoverTriggerMode="PopoverTriggerMode.BUTTON" PopoverButtonClasses="ma-3" LLMProvider="@this.Provider" DataSourceOptions="@this.GetCurrentDataSourceOptions()" DataSourceOptionsChanged="@(async options => await this.SetCurrentDataSourceOptions(options))" DataSourcesAISelected="@this.GetAgentSelectedDataSources()"/>

View File

@ -46,6 +46,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private DataSourceSelection? dataSourceSelectionComponent;
private DataSourceOptions earlyDataSourceOptions = new();
private Profile currentProfile = Profile.NO_PROFILE;
private ChatTemplate currentChatTemplate = ChatTemplate.NO_CHATTEMPLATE;
private bool hasUnsavedChanges;
private bool mustScrollToBottomAfterRender;
private InnerScrolling scrollingArea = null!;
@ -59,7 +60,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private string currentWorkspaceName = string.Empty;
private Guid currentWorkspaceId = Guid.Empty;
private CancellationTokenSource? cancellationTokenSource;
// 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!;
@ -77,6 +78,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Get the preselected profile:
this.currentProfile = this.SettingsManager.GetPreselectedProfile(Tools.Components.CHAT);
// Get the preselected chat template:
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
//
// Check for deferred messages of the kind 'SEND_TO_CHAT',
// aka the user sends an assistant result to the chat:
@ -318,6 +322,15 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
}
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
{
this.currentChatTemplate = chatTemplate;
if(this.ChatThread is null)
return;
await this.StartNewChat(true, false);
}
private IReadOnlyList<DataSourceAgentSelected> GetAgentSelectedDataSources()
{
@ -413,13 +426,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
DataSourceOptions = this.earlyDataSourceOptions,
Name = this.ExtractThreadName(this.userInput),
Seed = this.RNG.Next(),
Blocks = [],
Blocks = this.currentChatTemplate == default ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
};
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
@ -430,9 +444,10 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (string.IsNullOrWhiteSpace(this.ChatThread.Name))
this.ChatThread.Name = this.ExtractThreadName(this.userInput);
// Update provider and profile:
// Update provider, profile and chat template:
this.ChatThread.SelectedProvider = this.Provider.Id;
this.ChatThread.SelectedProfile = this.currentProfile.Id;
this.ChatThread.SelectedChatTemplate = this.currentChatTemplate.Id;
}
var time = DateTimeOffset.Now;
@ -643,12 +658,13 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
Name = string.Empty,
Seed = this.RNG.Next(),
Blocks = [],
Blocks = this.currentChatTemplate == default ? [] : this.currentChatTemplate.ExampleConversation.Select(x => x.DeepClone()).ToList(),
};
}
@ -754,6 +770,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
var chatProvider = this.ChatThread?.SelectedProvider;
var chatProfile = this.ChatThread?.SelectedProfile;
var chatChatTemplate = this.ChatThread?.SelectedChatTemplate;
switch (this.SettingsManager.ConfigurationData.Chat.LoadingProviderBehavior)
{
@ -781,6 +798,14 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if(this.currentProfile == default)
this.currentProfile = Profile.NO_PROFILE;
}
// Try to select the chat template:
if (!string.IsNullOrWhiteSpace(chatChatTemplate))
{
this.currentChatTemplate = this.SettingsManager.ConfigurationData.ChatTemplates.FirstOrDefault(x => x.Id == chatChatTemplate);
if(this.currentChatTemplate == default)
this.currentChatTemplate = ChatTemplate.NO_CHATTEMPLATE;
}
}
private async Task ToggleWorkspaceOverlay()

View File

@ -0,0 +1,20 @@
@using AIStudio.Settings
@inherits MSGComponentBase
<MudTooltip Placement="Placement.Top" Text="@T("Start a new chat with a chat template.")">
<MudMenu AnchorOrigin="Origin.TopLeft" TransformOrigin="@Origin.BottomLeft" Class="@this.MarginClass">
<ActivatorContent>
<MudButton IconSize="Size.Large" StartIcon="@Icons.Material.Filled.RateReview">
@(this.CurrentChatTemplate != ChatTemplate.NO_CHATTEMPLATE ? this.CurrentChatTemplate.Name : "")
</MudButton>
</ActivatorContent>
<ChildContent>
@foreach (var chatTemplate in this.SettingsManager.ConfigurationData.ChatTemplates.GetAllChatTemplates())
{
<MudMenuItem OnClick="() => this.SelectionChanged(chatTemplate)">
@chatTemplate.Name
</MudMenuItem>
}
</ChildContent>
</MudMenu>
</MudTooltip>

View File

@ -0,0 +1,28 @@
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ChatTemplateSelection : MSGComponentBase
{
[Parameter]
public ChatTemplate CurrentChatTemplate { get; set; } = ChatTemplate.NO_CHATTEMPLATE;
[Parameter]
public EventCallback<ChatTemplate> CurrentChatTemplateChanged { get; set; }
[Parameter]
public string MarginLeft { get; set; } = "ml-1";
[Parameter]
public string MarginRight { get; set; } = string.Empty;
private string MarginClass => $"{this.MarginLeft} {this.MarginRight}";
private async Task SelectionChanged(ChatTemplate chatTemplate)
{
this.CurrentChatTemplate = chatTemplate;
await this.CurrentChatTemplateChanged.InvokeAsync(chatTemplate);
}
}

View File

@ -1,6 +1,6 @@
@inherits MSGComponentBase
<MudTooltip Text="@T("You can switch between your profiles here")" Placement="Placement.Top">
<MudMenu TransformOrigin="@Origin.BottomLeft" AnchorOrigin="Origin.TopLeft" StartIcon="@Icons.Material.Filled.Person4" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.CurrentProfile.Name" Variant="Variant.Filled" Color="Color.Default" Class="@this.MarginClass">
<MudTooltip Text="@this.ToolTipText" Placement="Placement.Top">
<MudMenu TransformOrigin="@Origin.BottomLeft" AnchorOrigin="Origin.TopLeft" StartIcon="@Icons.Material.Filled.Person4" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.CurrentProfile.Name" Variant="Variant.Filled" Color="Color.Default" Class="@this.MarginClass" Disabled="@this.Disabled">
@foreach (var profile in this.SettingsManager.ConfigurationData.Profiles.GetAllProfiles())
{
<MudMenuItem OnClick="() => this.SelectionChanged(profile)">

View File

@ -1,11 +1,13 @@
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ProfileSelection : MSGComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ConfigurationProviderSelection).Namespace, nameof(ConfigurationProviderSelection));
[Parameter]
public Profile CurrentProfile { get; set; } = Profile.NO_PROFILE;
@ -18,6 +20,16 @@ public partial class ProfileSelection : MSGComponentBase
[Parameter]
public string MarginRight { get; set; } = string.Empty;
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public string DisabledText { get; set; } = string.Empty;
private readonly string defaultToolTipText = TB("You can switch between your profiles here");
private string ToolTipText => this.Disabled ? this.DisabledText : this.defaultToolTipText;
private string MarginClass => $"{this.MarginLeft} {this.MarginRight}";
private async Task SelectionChanged(Profile profile)

View File

@ -0,0 +1,53 @@
@inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.RateReview" HeaderText="@T("Configure Chat Templates")">
<MudText Typo="Typo.h4" Class="mb-3">
@T("Your Chat Templates")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Customize your AI experience with our Chat Templates! Whether you want to experiment with prompt engineering, simply use a custom system prompt in the standard chat interface, or create a specialized assistant, our templates give you full control. Similar to OpenAI's playground, you can define your own system prompts and leverage assistant prompts for providers that support them.")
</MudJustifiedText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Note: This advanced feature is designed for users familiar with prompt engineering concepts. Furthermore, you have to make sure yourself that your chosen provider supports the use of assistant prompts.")
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.ConfigurationData.ChatTemplates" Hover="@true" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col/>
<col style="width: 16em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>@T("Chat Template Name")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Num</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Wrap="Wrap.Wrap">
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="() => this.EditChatTemplate(context)"/>
</MudTooltip>
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="() => this.DeleteChatTemplate(context)"/>
</MudTooltip>
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
@if(this.SettingsManager.ConfigurationData.ChatTemplates.Count == 0)
{
<MudText Typo="Typo.h6" Class="mt-3">
@T("No chat templates configured yet.")
</MudText>
}
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" Class="mt-3 mb-6" OnClick="@this.AddChatTemplate">
@T("Add Chat Template")
</MudButton>
</ExpansionPanel>

View File

@ -0,0 +1,73 @@
using AIStudio.Dialogs;
using AIStudio.Settings;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelChatTemplates : SettingsPanelBase
{
private async Task AddChatTemplate()
{
var dialogParameters = new DialogParameters<ChatTemplateDialog>
{
{ x => x.IsEditing, false },
};
var dialogReference = await this.DialogService.ShowAsync<ChatTemplateDialog>(T("Add Chat Template"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var addedChatTemplate = (ChatTemplate)dialogResult.Data!;
addedChatTemplate = addedChatTemplate with { Num = this.SettingsManager.ConfigurationData.NextChatTemplateNum++ };
this.SettingsManager.ConfigurationData.ChatTemplates.Add(addedChatTemplate);
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task EditChatTemplate(ChatTemplate chatTemplate)
{
var dialogParameters = new DialogParameters<ChatTemplateDialog>
{
{ x => x.DataNum, chatTemplate.Num },
{ x => x.DataId, chatTemplate.Id },
{ x => x.DataName, chatTemplate.Name },
{ x => x.DataSystemPrompt, chatTemplate.SystemPrompt },
{ x => x.IsEditing, true },
{x => x.ExampleConversation, chatTemplate.ExampleConversation},
{x => x.AllowProfileUsage, chatTemplate.AllowProfileUsage},
};
var dialogReference = await this.DialogService.ShowAsync<ChatTemplateDialog>(T("Edit Chat Template"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var editedChatTemplate = (ChatTemplate)dialogResult.Data!;
this.SettingsManager.ConfigurationData.ChatTemplates[this.SettingsManager.ConfigurationData.ChatTemplates.IndexOf(chatTemplate)] = editedChatTemplate;
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
private async Task DeleteChatTemplate(ChatTemplate chatTemplate)
{
var dialogParameters = new DialogParameters
{
{ "Message", string.Format(T("Are you sure you want to delete the chat template '{0}'?"), chatTemplate.Name) },
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete Chat Template"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
this.SettingsManager.ConfigurationData.ChatTemplates.Remove(chatTemplate);
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
}

View File

@ -0,0 +1,128 @@
@using AIStudio.Chat
@using MudBlazor.Extensions
@inherits MSGComponentBase
@inject ISnackbar Snackbar
<MudDialog>
<DialogContent>
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
@T("Create your custom chat template to tailor the LLM's behavior for specific tasks or domains. Define a custom system prompt and provide an example conversation to design an AI experience perfectly suited to your requirements.")
</MudJustifiedText>
<MudJustifiedText Class="mb-3" Typo="Typo.body1">
@T("The name of the chat template is mandatory. Each chat template must have a unique name.")
</MudJustifiedText>
<MudForm @ref="@this.form" @bind-IsValid="@this.dataIsValid" @bind-Errors="@this.dataIssues">
@* ReSharper disable once CSharpWarnings::CS8974 *@
<MudTextField
T="string"
@bind-Text="@this.DataName"
Label="@T("Chat Template Name")"
Class="mb-3"
Immediate="@true"
MaxLength="40"
Counter="40"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Badge"
AdornmentColor="Color.Info"
Validation="@this.ValidateName"
Variant="Variant.Outlined"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
/>
<MudTextField
T="string"
@bind-Text="@this.DataSystemPrompt"
Validation="@this.ValidateSystemPrompt"
AdornmentIcon="@Icons.Material.Filled.ListAlt"
Adornment="Adornment.Start"
Immediate="@true"
Label="@T("What system prompt do you want to use?")"
Variant="Variant.Outlined"
Lines="6"
AutoGrow="@true"
MaxLines="12"
Class="mb-3"
MaxLength="444"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
HelperText="@T("Tell the AI your system prompt.")"
/>
<MudTooltip Text="@T("Are you unsure which system prompt to use? You can simply start with the default system prompt that AI Studio uses for all chats.")">
<MudButton Class="mb-3" Color="Color.Primary" OnClick="@this.UseDefaultSystemPrompt" Size="Size.Small" StartIcon="@Icons.Material.Filled.ListAlt" Variant="Variant.Filled">@T("Use the default system prompt")</MudButton>
</MudTooltip>
<MudTooltip Text="@T("Using some chat templates in tandem with profiles might cause issues. Therefore, you can preliminarily block the usage of profiles here.")">
<MudSwitch @bind-Value="@this.AllowProfileUsage" Class="mb-3" Color="Color.Primary" Label="@T("Allow the use of profiles together with this chat template?")" ThumbIcon="@Icons.Material.Filled.Person4" ThumbIconColor="Color.Default" />
</MudTooltip>
<MudText Typo="Typo.h6" Class="mb-3">
@T("Example Conversation")
</MudText>
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("Add messages of an example conversation (user prompt followed by assistant prompt) to demonstrate the desired interaction pattern. These examples help the AI understand your expectations by showing it the correct format, style, and content of responses before it receives actual user inputs.")
</MudJustifiedText>
<MudTable CanCancelEdit="true" Class="mt-3 mb-6" CommitEditTooltip="@T("Commit Changes")" Elevation="10" FixedHeader="true" Items="@ExampleConversation" Outlined="true" RowEditCancel="@this.ResetItemToOriginalValues" RowEditPreview="@this.BackupItem">
<ColGroup>
<col style="width: 16em;" />
<col/>
<col style="width: 16em;" />
</ColGroup>
<HeaderContent>
<MudTh>@T("Role")</MudTh>
<MudTh>@T("Entry")</MudTh>
<MudTh Style="text-align:center">@T("Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="@T("Role")">@context.Role.ToChatTemplateName()</MudTd>
<MudTd DataLabel="@T("Message")">
@(context.Content is ContentText textContent ? textContent.Text : context.Content?.ToString())
</MudTd>
<MudTd style="text-align: center">
<MudIconButton Color="Color.Primary" Icon="@Icons.Material.Filled.Add" OnClick="@(() => AddNewMessageBelow(context))" />
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => RemoveMessage(context))" />
</MudTd>
</RowTemplate>
<RowEditingTemplate>
<MudTd DataLabel="Role">
<MudSelect Label="Role" @bind-Value="context.Role" Required>
@foreach (var role in availableRoles)
{
<MudSelectItem Value="@role">@role.ToChatTemplateName()</MudSelectItem>
}
</MudSelect>
</MudTd>
<MudTd DataLabel="Message">
<MudTextField AutoGrow="true" @bind-Value="context.Content.As<ContentText>()!.Text" Label="@T("Your message")" Required />
</MudTd>
</RowEditingTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="[10,20,50,100]" RowsPerPageString="@T("Messages per page")" />
</PagerContent>
</MudTable>
</MudForm>
<MudButton Class="mb-3" Color="Color.Primary" OnClick="@this.AddNewMessageToEnd" StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled">@T("Add additional message")</MudButton>
<Issues IssuesData="@this.dataIssues"/>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Variant="Variant.Filled">
@T("Cancel")
</MudButton>
<MudButton OnClick="@this.Store" Variant="Variant.Filled" Color="Color.Primary">
@if(this.IsEditing)
{
@T("Update")
}
else
{
@T("Add")
}
</MudButton>
</DialogActions>
</MudDialog>

View File

@ -0,0 +1,224 @@
using AIStudio.Chat;
using AIStudio.Components;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class ChatTemplateDialog : MSGComponentBase
{
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
/// The chat template's number in the list.
/// </summary>
[Parameter]
public uint DataNum { get; set; }
/// <summary>
/// The chat template's ID.
/// </summary>
[Parameter]
public string DataId { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// The chat template name chosen by the user.
/// </summary>
[Parameter]
public string DataName { get; set; } = string.Empty;
/// <summary>
/// What is the system prompt?
/// </summary>
[Parameter]
public string DataSystemPrompt { get; set; } = string.Empty;
/// <summary>
/// Should the dialog be in editing mode?
/// </summary>
[Parameter]
public bool IsEditing { get; init; }
[Parameter]
public List<ContentBlock> ExampleConversation { get; set; } = [];
[Parameter]
public bool AllowProfileUsage { get; set; } = true;
[Inject]
private ILogger<ProviderDialog> Logger { get; init; } = null!;
private static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
/// <summary>
/// The list of used chat template names. We need this to check for uniqueness.
/// </summary>
private List<string> UsedNames { get; set; } = [];
private bool dataIsValid;
private string[] dataIssues = [];
private string dataEditingPreviousName = string.Empty;
private ContentBlock messageEntryBeforeEdit;
private readonly IEnumerable<ChatRole> availableRoles = ChatRoles.ChatTemplateRoles().ToArray();
// We get the form reference from Blazor code to validate it manually:
private MudForm form = null!;
private ChatTemplate CreateChatTemplateSettings() => new()
{
Num = this.DataNum,
Id = this.DataId,
Name = this.DataName,
SystemPrompt = this.DataSystemPrompt,
ExampleConversation = this.ExampleConversation,
AllowProfileUsage = this.AllowProfileUsage,
};
private void RemoveMessage(ContentBlock item)
{
this.ExampleConversation.Remove(item);
}
private void AddNewMessageToEnd()
{
this.ExampleConversation ??= new List<ContentBlock>();
var newEntry = new ContentBlock
{
Role = ChatRole.USER, // Default to User
Content = new ContentText(),
ContentType = ContentType.TEXT,
HideFromUser = true,
Time = DateTimeOffset.Now,
};
this.ExampleConversation.Add(newEntry);
}
private void AddNewMessageBelow(ContentBlock currentItem)
{
// Create new entry with a valid role
var newEntry = new ContentBlock
{
Role = ChatRole.USER, // Default to User
Content = new ContentText(),
ContentType = ContentType.TEXT,
HideFromUser = true,
Time = DateTimeOffset.Now,
};
// Rest of the method remains the same
var index = this.ExampleConversation.IndexOf(currentItem);
if (index >= 0)
{
this.ExampleConversation.Insert(index + 1, newEntry);
}
else
{
this.ExampleConversation.Add(newEntry);
}
}
private void BackupItem(object element)
{
this.messageEntryBeforeEdit = new ContentBlock
{
Role = ((ContentBlock)element).Role,
Content = ((ContentBlock)element).Content,
};
}
private void ResetItemToOriginalValues(object element)
{
((ContentBlock)element).Role = this.messageEntryBeforeEdit.Role;
((ContentBlock)element).Content = this.messageEntryBeforeEdit.Content;
}
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the instance name input:
this.SettingsManager.InjectSpellchecking(SPELLCHECK_ATTRIBUTES);
// Load the used instance names:
this.UsedNames = this.SettingsManager.ConfigurationData.ChatTemplates.Select(x => x.Name.ToLowerInvariant()).ToList();
// When editing, we need to load the data:
if(this.IsEditing)
{
this.dataEditingPreviousName = this.DataName.ToLowerInvariant();
}
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Reset the validation when not editing and on the first render.
// We don't want to show validation errors when the user opens the dialog.
if(!this.IsEditing && firstRender)
this.form.ResetValidation();
await base.OnAfterRenderAsync(firstRender);
}
#endregion
private async Task Store()
{
await this.form.Validate();
// When the data is not valid, we don't store it:
if (!this.dataIsValid)
return;
// Use the data model to store the chat template.
// We just return this data to the parent component:
var addedChatTemplateSettings = this.CreateChatTemplateSettings();
if(this.IsEditing)
this.Logger.LogInformation($"Edited chat template '{addedChatTemplateSettings.Name}'.");
else
this.Logger.LogInformation($"Created chat template '{addedChatTemplateSettings.Name}'.");
this.MudDialog.Close(DialogResult.Ok(addedChatTemplateSettings));
}
private string? ValidateSystemPrompt(string text)
{
if(text.Length > 444)
return T("The text must not exceed 444 characters.");
return null;
}
private string? ValidateName(string name)
{
if (string.IsNullOrWhiteSpace(name))
return T("Please enter a name for the chat template.");
if (name.Length > 40)
return T("The chat template name must not exceed 40 characters.");
// The instance name must be unique:
var lowerName = name.ToLowerInvariant();
if (lowerName != this.dataEditingPreviousName && this.UsedNames.Contains(lowerName))
return T("The chat template name must be unique; the chosen name is already in use.");
return null;
}
private void UseDefaultSystemPrompt()
{
this.DataSystemPrompt = SystemPrompts.DEFAULT;
}
private void Cancel() => this.MudDialog.Cancel();
}

View File

@ -16,6 +16,7 @@
}
<SettingsPanelProfiles AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
<SettingsPanelChatTemplates AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
<SettingsPanelApp AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>
<SettingsPanelWorkspaces AvailableLLMProvidersFunc="() => this.availableLLMProviders"/>

View File

@ -0,0 +1,38 @@
using AIStudio.Chat;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Settings;
public readonly record struct ChatTemplate(uint Num, string Id, string Name, string SystemPrompt, List<ContentBlock> ExampleConversation, bool AllowProfileUsage)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatTemplate).Namespace, nameof(ChatTemplate));
public static readonly ChatTemplate NO_CHATTEMPLATE = new()
{
Name = TB("Use no chat template"),
SystemPrompt = string.Empty,
Id = Guid.Empty.ToString(),
Num = uint.MaxValue,
ExampleConversation = [],
AllowProfileUsage = true,
};
#region Overrides of ValueType
/// <summary>
/// Returns a string that represents the profile in a human-readable format.
/// </summary>
/// <returns>A string that represents the profile in a human-readable format.</returns>
public override string ToString() => this.Name;
#endregion
public string ToSystemPrompt()
{
if(this.Num == uint.MaxValue)
return string.Empty;
return this.SystemPrompt;
}
}

View File

@ -198,6 +198,12 @@ public static class ConfigurationSelectDataFactory
yield return new(profile.Name, profile.Id);
}
public static IEnumerable<ConfigurationSelectData<string>> GetChatTemplatesData(IEnumerable<ChatTemplate> chatTemplates)
{
foreach (var chatTemplate in chatTemplates.GetAllChatTemplates())
yield return new(chatTemplate.Name, chatTemplate.Id);
}
public static IEnumerable<ConfigurationSelectData<ConfidenceSchemes>> GetConfidenceSchemesData()
{
foreach (var scheme in Enum.GetValues<ConfidenceSchemes>())

View File

@ -35,6 +35,11 @@ public sealed class Data
/// List of configured profiles.
/// </summary>
public List<Profile> Profiles { get; init; } = [];
/// <summary>
/// List of configured chat templates.
/// </summary>
public List<ChatTemplate> ChatTemplates { get; init; } = [];
/// <summary>
/// List of enabled plugins.
@ -60,6 +65,11 @@ public sealed class Data
/// The next profile number to use.
/// </summary>
public uint NextProfileNum { get; set; } = 1;
/// <summary>
/// The next chat template number to use.
/// </summary>
public uint NextChatTemplateNum { get; set; } = 1;
public DataApp App { get; init; } = new();

View File

@ -57,4 +57,10 @@ public sealed class DataApp
/// Should we preselect a profile for the entire app?
/// </summary>
public string PreselectedProfile { get; set; } = string.Empty;
/// <summary>
/// Should we preselect a chat template for the entire app?
/// </summary>
public string PreselectedChatTemplate { get; set; } = string.Empty;
}

View File

@ -37,6 +37,11 @@ public sealed class DataChat
/// </summary>
public string PreselectedProfile { get; set; } = string.Empty;
/// <summary>
/// Preselect a chat template?
/// </summary>
public string PreselectedChatTemplate { get; set; } = string.Empty;
/// <summary>
/// Should we preselect data sources options for a created chat?
/// </summary>

View File

@ -265,6 +265,16 @@ public sealed class SettingsManager
preselection = this.ConfigurationData.Profiles.FirstOrDefault(x => x.Id == this.ConfigurationData.App.PreselectedProfile);
return preselection != default ? preselection : Profile.NO_PROFILE;
}
public ChatTemplate GetPreselectedChatTemplate(Tools.Components component)
{
var preselection = component.PreselectedChatTemplate(this);
if (preselection != default)
return preselection;
preselection = this.ConfigurationData.ChatTemplates.FirstOrDefault(x => x.Id == this.ConfigurationData.App.PreselectedChatTemplate);
return preselection != default ? preselection : ChatTemplate.NO_CHATTEMPLATE;
}
public ConfidenceLevel GetConfiguredConfidenceLevel(LLMProviders llmProvider)
{

View File

@ -0,0 +1,13 @@
using AIStudio.Settings;
namespace AIStudio.Tools;
public static class ChatTemplateExtensions
{
public static IEnumerable<ChatTemplate> GetAllChatTemplates(this IEnumerable<ChatTemplate> chatTemplates)
{
yield return ChatTemplate.NO_CHATTEMPLATE;
foreach (var chatTemplate in chatTemplates)
yield return chatTemplate;
}
}

View File

@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
@ -131,4 +130,11 @@ public static class ComponentsExtensions
_ => default,
};
public static ChatTemplate PreselectedChatTemplate(this Components component, SettingsManager settingsManager) => component switch
{
Components.CHAT => settingsManager.ConfigurationData.Chat.PreselectOptions ? settingsManager.ConfigurationData.ChatTemplates.FirstOrDefault(x => x.Id == settingsManager.ConfigurationData.Chat.PreselectedChatTemplate) : default,
_ => default,
};
}