2026-05-22 15:46:03 +02:00
using System.Text ;
2025-05-24 12:27:00 +02:00
using AIStudio.Chat ;
using AIStudio.Tools.PluginSystem ;
2026-05-22 15:46:03 +02:00
using SharedTools ;
using LuaTable = Lua . LuaTable ;
2025-08-26 10:59:56 +02:00
2025-05-24 12:27:00 +02:00
namespace AIStudio.Settings ;
2025-08-26 10:59:56 +02:00
public record ChatTemplate (
uint Num ,
string Id ,
string Name ,
string SystemPrompt ,
string PredefinedUserPrompt ,
List < ContentBlock > ExampleConversation ,
2026-01-01 16:47:15 +01:00
List < FileAttachment > FileAttachments ,
2025-08-26 10:59:56 +02:00
bool AllowProfileUsage ,
bool IsEnterpriseConfiguration = false ,
Guid EnterpriseConfigurationPluginId = default ) : ConfigurationBaseObject
2025-05-24 12:27:00 +02:00
{
2026-05-22 15:46:03 +02:00
private const string ATTACHMENTS_DIRECTORY = "attachments" ;
2026-01-01 16:47:15 +01:00
public ChatTemplate () : this ( 0 , Guid . Empty . ToString (), string . Empty , string . Empty , string . Empty , [], [], false )
2025-08-18 20:40:52 +02:00
{
}
2025-05-24 12:27:00 +02:00
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( ChatTemplate ). Namespace , nameof ( ChatTemplate ));
2025-08-26 10:59:56 +02:00
private static readonly ILogger < ChatTemplate > LOGGER = Program . LOGGER_FACTORY . CreateLogger < ChatTemplate >();
2025-05-24 19:11:28 +02:00
public static readonly ChatTemplate NO_CHAT_TEMPLATE = new ()
2025-05-24 12:27:00 +02:00
{
2026-01-01 16:47:15 +01:00
Name = TB ( "Use no chat template" ), // Cannot be localized due to being a static readonly field
2025-05-24 12:27:00 +02:00
SystemPrompt = string . Empty ,
2025-07-11 09:57:46 +02:00
PredefinedUserPrompt = string . Empty ,
2025-05-24 12:27:00 +02:00
Id = Guid . Empty . ToString (),
Num = uint . MaxValue ,
ExampleConversation = [],
2026-01-01 16:47:15 +01:00
FileAttachments = [],
2025-05-24 12:27:00 +02:00
AllowProfileUsage = true ,
2025-08-18 20:40:52 +02:00
EnterpriseConfigurationPluginId = Guid . Empty ,
IsEnterpriseConfiguration = false ,
2025-05-24 12:27:00 +02:00
};
#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>
2025-12-30 20:29:19 +01:00
public override string ToString () => this . GetSafeName ();
2025-05-24 12:27:00 +02:00
#endregion
2025-12-30 20:29:19 +01:00
/// <summary>
/// Gets the name of this chat template. If it is the NO_CHAT_TEMPLATE, it returns a localized string.
/// </summary>
/// <remarks>
/// Why not using the Name property directly? Because the Name property of NO_CHAT_TEMPLATE cannot be
/// localized because it is a static readonly field. So we need this method to return a localized
/// string instead.
/// </remarks>
/// <returns>The name of this chat template.</returns>
public string GetSafeName ()
{
if ( this == NO_CHAT_TEMPLATE )
2026-01-01 16:47:15 +01:00
return TB ( "Use no chat template" );
2025-12-30 20:29:19 +01:00
return this . Name ;
}
2025-05-24 12:27:00 +02:00
public string ToSystemPrompt ()
{
if ( this . Num == uint . MaxValue )
return string . Empty ;
return this . SystemPrompt ;
}
2026-05-22 15:46:03 +02:00
public static bool TryParseChatTemplateTable ( int idx , LuaTable table , Guid configPluginId , string pluginPath , out ConfigurationBaseObject template )
2025-08-26 10:59:56 +02:00
{
template = NO_CHAT_TEMPLATE ;
if (! table . TryGetValue ( "Id" , out var idValue ) || ! idValue . TryRead < string >( out var idText ) || ! Guid . TryParse ( idText , out var id ))
{
LOGGER . LogWarning ( $"The configured chat template {idx} does not contain a valid ID. The ID must be a valid GUID." );
return false ;
}
if (! table . TryGetValue ( "Name" , out var nameValue ) || ! nameValue . TryRead < string >( out var name ))
{
LOGGER . LogWarning ( $"The configured chat template {idx} does not contain a valid name." );
return false ;
}
if (! table . TryGetValue ( "SystemPrompt" , out var sysPromptValue ) || ! sysPromptValue . TryRead < string >( out var systemPrompt ))
{
LOGGER . LogWarning ( $"The configured chat template {idx} does not contain a valid system prompt." );
return false ;
}
var predefinedUserPrompt = string . Empty ;
if ( table . TryGetValue ( "PredefinedUserPrompt" , out var preUserValue ) && preUserValue . TryRead < string >( out var preUser ))
predefinedUserPrompt = preUser ;
var allowProfileUsage = false ;
if ( table . TryGetValue ( "AllowProfileUsage" , out var allowProfileValue ) && allowProfileValue . TryRead < bool >( out var allow ))
allowProfileUsage = allow ;
2026-01-01 16:47:15 +01:00
2026-05-22 15:46:03 +02:00
var fileAttachments = ParseFileAttachments ( idx , table , pluginPath );
2026-01-01 16:47:15 +01:00
2025-08-26 10:59:56 +02:00
template = new ChatTemplate
{
2026-02-01 14:50:19 +01:00
Num = 0 , // will be set later by the PluginConfigurationObject
2025-08-26 10:59:56 +02:00
Id = id . ToString (),
Name = name ,
SystemPrompt = systemPrompt ,
PredefinedUserPrompt = predefinedUserPrompt ,
ExampleConversation = ParseExampleConversation ( idx , table ),
2026-01-01 16:47:15 +01:00
FileAttachments = fileAttachments ,
2025-08-26 10:59:56 +02:00
AllowProfileUsage = allowProfileUsage ,
IsEnterpriseConfiguration = true ,
EnterpriseConfigurationPluginId = configPluginId ,
};
return true ;
}
private static List < ContentBlock > ParseExampleConversation ( int idx , LuaTable table )
{
var exampleConversation = new List < ContentBlock >();
if (! table . TryGetValue ( "ExampleConversation" , out var exConvValue ) || ! exConvValue . TryRead < LuaTable >( out var exConvTable ))
return exampleConversation ;
var numBlocks = exConvTable . ArrayLength ;
for ( var j = 1 ; j <= numBlocks ; j ++)
{
var blockValue = exConvTable [ j ];
if (! blockValue . TryRead < LuaTable >( out var blockTable ))
{
LOGGER . LogWarning ( $"The ExampleConversation entry {j} in chat template {idx} is not a valid table." );
continue ;
}
if (! blockTable . TryGetValue ( "Role" , out var roleValue ) || ! roleValue . TryRead < string >( out var roleText ) || ! Enum . TryParse < ChatRole >( roleText , true , out var parsedRole ))
{
LOGGER . LogWarning ( $"The ExampleConversation entry {j} in chat template {idx} does not contain a valid role." );
continue ;
}
if (! blockTable . TryGetValue ( "Content" , out var contentValue ) || ! contentValue . TryRead < string >( out var content ))
{
LOGGER . LogWarning ( $"The ExampleConversation entry {j} in chat template {idx} does not contain a valid content message." );
continue ;
}
if ( string . IsNullOrWhiteSpace ( content ))
{
LOGGER . LogWarning ( $"The ExampleConversation entry {j} in chat template {idx} contains an empty content message." );
continue ;
}
exampleConversation . Add ( new ContentBlock
{
Time = DateTimeOffset . UtcNow ,
Role = parsedRole ,
Content = new ContentText { Text = content },
ContentType = ContentType . TEXT ,
HideFromUser = true ,
});
}
return exampleConversation ;
}
2026-01-01 16:47:15 +01:00
2026-05-22 15:46:03 +02:00
private static List < FileAttachment > ParseFileAttachments ( int idx , LuaTable table , string pluginPath )
2026-01-01 16:47:15 +01:00
{
var fileAttachments = new List < FileAttachment >();
if (! table . TryGetValue ( "FileAttachments" , out var fileAttValue ) || ! fileAttValue . TryRead < LuaTable >( out var fileAttTable ))
return fileAttachments ;
var numAttachments = fileAttTable . ArrayLength ;
for ( var attachmentNum = 1 ; attachmentNum <= numAttachments ; attachmentNum ++)
{
var attachmentValue = fileAttTable [ attachmentNum ];
if (! attachmentValue . TryRead < string >( out var filePath ))
{
LOGGER . LogWarning ( "The FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} is not a valid string." , attachmentNum , idx );
continue ;
}
2026-05-22 15:46:03 +02:00
if ( TryResolveFileAttachmentPath ( idx , attachmentNum , filePath , pluginPath , out var resolvedFilePath ))
fileAttachments . Add ( FileAttachment . FromPath ( resolvedFilePath ));
2026-01-01 16:47:15 +01:00
}
return fileAttachments ;
}
2026-05-22 15:46:03 +02:00
private static bool TryResolveFileAttachmentPath ( int idx , int attachmentNum , string filePath , string pluginPath , out string resolvedFilePath )
{
resolvedFilePath = filePath ;
if ( string . IsNullOrWhiteSpace ( filePath ))
{
LOGGER . LogWarning ( "The FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} is empty." , attachmentNum , idx );
return false ;
}
if ( Path . IsPathFullyQualified ( filePath ))
return true ;
if ( string . IsNullOrWhiteSpace ( pluginPath ))
{
LOGGER . LogWarning ( "The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} cannot be resolved because the plugin path is unknown." , attachmentNum , idx );
return false ;
}
var pluginRoot = Path . GetFullPath ( pluginPath );
var relativePath = filePath
. Replace ( '/' , Path . DirectorySeparatorChar )
. Replace ( '\\' , Path . DirectorySeparatorChar );
if ( relativePath . Split ( Path . DirectorySeparatorChar , StringSplitOptions . RemoveEmptyEntries ). Any ( segment => segment == ".." ))
{
LOGGER . LogWarning ( "The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} contains '..' path segments and will be ignored." , attachmentNum , idx );
return false ;
}
var combinedPath = Path . GetFullPath ( Path . Combine ( pluginRoot , relativePath ));
var pluginRootWithSeparator = pluginRoot . EndsWith ( Path . DirectorySeparatorChar )
? pluginRoot
: pluginRoot + Path . DirectorySeparatorChar ;
var comparison = OperatingSystem . IsWindows () ? StringComparison . OrdinalIgnoreCase : StringComparison . Ordinal ;
if (! combinedPath . StartsWith ( pluginRootWithSeparator , comparison ))
{
LOGGER . LogWarning ( "The relative FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} points outside of the plugin folder and will be ignored." , attachmentNum , idx );
return false ;
}
resolvedFilePath = combinedPath ;
return true ;
}
public bool TryExportAsConfigurationSection ( out string luaCode , out string issue ) => this . TryExportAsConfigurationSection ( null , Guid . NewGuid (). ToString (), out luaCode , out issue );
private bool TryExportAsConfigurationSection ( IReadOnlyList < string >? fileAttachmentPaths , string exportId , out string luaCode , out string issue )
{
luaCode = string . Empty ;
issue = string . Empty ;
if (! this . TryBuildExampleConversationLua ( out var exampleConversationLua , out issue ))
return false ;
return this . TryExportAsConfigurationSection ( fileAttachmentPaths , exportId , exampleConversationLua , out luaCode , out issue );
}
private bool TryExportAsConfigurationSection ( IReadOnlyList < string >? fileAttachmentPaths , string exportId , string exampleConversationLua , out string luaCode , out string issue )
{
issue = string . Empty ;
var fileAttachmentsLua = this . BuildFileAttachmentsLua ( fileAttachmentPaths );
luaCode = $ $"""
CONFIG[" CHAT_TEMPLATES "][#CONFIG[" CHAT_TEMPLATES "]+1] = {
["Id"] = "{{LuaTools.EscapeLuaString(exportId)}}" ,
["Name"] = {{ LuaTools . ToLuaStringLiteral ( this . Name )}},
["SystemPrompt"] = {{ LuaTools . ToLuaStringLiteral ( this . SystemPrompt )}},
["PredefinedUserPrompt"] = {{ LuaTools . ToLuaStringLiteral ( this . PredefinedUserPrompt )}},
["AllowProfileUsage"] = {{ this . AllowProfileUsage . ToString (). ToLowerInvariant ()}},
["FileAttachments"] = {{ fileAttachmentsLua }},
["ExampleConversation"] = {{ exampleConversationLua }},
}
""";
return true ;
}
public bool TryExportAsConfigurationSectionWithPackagedAttachments ( string pluginDirectory , out string luaCode , out string issue )
{
luaCode = string . Empty ;
issue = string . Empty ;
var exportId = Guid . NewGuid (). ToString ();
if (! this . TryBuildExampleConversationLua ( out var exampleConversationLua , out issue ))
return false ;
if ( this . FileAttachments . Count == 0 )
return this . TryExportAsConfigurationSection ( null , exportId , exampleConversationLua , out luaCode , out issue );
if ( string . IsNullOrWhiteSpace ( pluginDirectory ) || ! File . Exists ( Path . Combine ( pluginDirectory , "plugin.lua" )))
{
issue = TB ( "Please select a valid configuration plugin folder. The folder must contain a plugin.lua file." );
return false ;
}
var sourcePaths = new List < string >();
foreach ( var attachment in this . FileAttachments )
{
if ( string . IsNullOrWhiteSpace ( attachment . FilePath ) || ! File . Exists ( attachment . FilePath ))
{
issue = string . Format ( TB ( "Cannot package the attachment '{0}' because the file does not exist." ), attachment . FileName );
return false ;
}
sourcePaths . Add ( attachment . FilePath );
}
var targetDirectory = Path . Combine ( pluginDirectory , ATTACHMENTS_DIRECTORY , exportId );
var relativeAttachmentPaths = new List < string >();
var usedFileNames = new HashSet < string >( StringComparer . OrdinalIgnoreCase );
try
{
Directory . CreateDirectory ( targetDirectory );
foreach ( var sourcePath in sourcePaths )
{
var targetFileName = CreateUniqueAttachmentFileName ( sourcePath , usedFileNames );
var targetPath = Path . Combine ( targetDirectory , targetFileName );
File . Copy ( sourcePath , targetPath , overwrite : false );
relativeAttachmentPaths . Add ( $"{ATTACHMENTS_DIRECTORY}/{exportId}/{targetFileName}" );
}
}
catch ( Exception e )
{
try
{
if ( Directory . Exists ( targetDirectory ))
Directory . Delete ( targetDirectory , true );
}
catch
{
// Keep the original packaging error as the user-facing issue.
}
issue = string . Format ( TB ( "Cannot package the chat template attachments. The issue was: {0}" ), e . Message );
return false ;
}
return this . TryExportAsConfigurationSection ( relativeAttachmentPaths , exportId , exampleConversationLua , out luaCode , out issue );
}
private bool TryBuildExampleConversationLua ( out string luaTable , out string issue )
{
luaTable = "{}" ;
issue = string . Empty ;
if ( this . ExampleConversation . Count == 0 )
return true ;
var builder = new StringBuilder ();
builder . AppendLine ( "{" );
for ( var i = 0 ; i < this . ExampleConversation . Count ; i ++)
{
var block = this . ExampleConversation [ i ];
if ( block . Role is not ChatRole . USER and not ChatRole . AI )
{
issue = string . Format ( TB ( "Cannot export this chat template because example message {0} uses a role that is not supported by configuration plugins." ), i + 1 );
return false ;
}
if ( block . Content is not ContentText textContent )
{
issue = string . Format ( TB ( "Cannot export this chat template because example message {0} is not a text message." ), i + 1 );
return false ;
}
if ( string . IsNullOrWhiteSpace ( textContent . Text ))
{
issue = string . Format ( TB ( "Cannot export this chat template because example message {0} is empty." ), i + 1 );
return false ;
}
builder . AppendLine ( " {" );
builder . AppendLine ( $" [\" Role \ "] = \"{block.Role}\"," );
builder . AppendLine ( $" [\" Content \ "] = {LuaTools.ToLuaStringLiteral(textContent.Text)}," );
builder . AppendLine ( " }," );
}
builder . Append ( " }" );
luaTable = builder . ToString ();
return true ;
}
private string BuildFileAttachmentsLua ( IReadOnlyList < string >? fileAttachmentPaths )
{
var paths = fileAttachmentPaths ?? this . FileAttachments . Select ( attachment => attachment . FilePath ). ToList ();
if ( paths . Count == 0 )
return "{}" ;
var builder = new StringBuilder ();
builder . AppendLine ( "{" );
foreach ( var path in paths )
builder . AppendLine ( $" \" { LuaTools . EscapeLuaString ( path )} \ "," );
builder . Append ( " }" );
return builder . ToString ();
}
private static string CreateUniqueAttachmentFileName ( string sourcePath , HashSet < string > usedFileNames )
{
var fileName = SanitizeFileName ( Path . GetFileName ( sourcePath ));
if ( string . IsNullOrWhiteSpace ( fileName ))
fileName = "attachment" ;
var extension = Path . GetExtension ( fileName );
var nameWithoutExtension = Path . GetFileNameWithoutExtension ( fileName );
var candidate = fileName ;
var counter = 2 ;
while (! usedFileNames . Add ( candidate ))
candidate = $"{nameWithoutExtension}-{counter++}{extension}" ;
return candidate ;
}
private static string SanitizeFileName ( string fileName )
{
foreach ( var invalidChar in Path . GetInvalidFileNameChars ())
fileName = fileName . Replace ( invalidChar , '_' );
return fileName ;
}
2025-05-24 12:27:00 +02:00
}