2026-05-22 15:46:03 +02:00
using System.Text ;
2025-05-24 12:27:00 +02:00
using AIStudio.Chat ;
2026-09-22 16:39:17 +02:00
using AIStudio.Settings.DataModel ;
2025-05-24 12:27:00 +02:00
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
{
}
2026-09-22 16:39:17 +02:00
/// <summary>
/// The tools this template preselects for a chat started with it.
/// </summary>
/// <remarks>
/// Null means the template says nothing about tools, so the chat starts with the tools chosen
/// as its default in the app settings. An empty set is the opposite statement: this template
/// wants no tools at all, whatever that default says.<br/><br/>
/// A preselection, not a limit: the user changes the selection in the chat as usual, and a
/// tool still has to meet the confidence requirements of the provider in use.
/// </remarks>
public HashSet < string >? ToolIds { get ; init ; }
/// <summary>
/// The data source options a chat started with this template begins with.
/// </summary>
/// <remarks>
/// Null means the template says nothing, so the chat starts with the data source defaults from
/// the app settings. Anything else is the template's own answer, and it carries more than a
/// list of sources: whether data sources are used at all, whether an agent picks them, and
/// whether the retrieved data is validated.
/// </remarks>
public DataSourceOptions ? DataSourceOptions { get ; init ; }
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 ,
2026-09-22 16:39:17 +02:00
ToolIds = null ,
DataSourceOptions = null ,
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 ;
2026-09-22 16:39:17 +02:00
2025-05-24 12:27:00 +02:00
return this . SystemPrompt ;
}
2026-05-22 15:46:03 +02:00
2026-09-22 16:39:17 +02:00
/// <summary>
/// Decides whose tools a chat started by a launcher begins with.
/// </summary>
/// <remarks>
/// A launcher may name tools itself and may choose a chat template which names tools as well.
/// When both do, the template wins as a whole — the same rule as for the data sources, so that
/// nobody has to remember two of them.
/// </remarks>
/// <param name="chatTemplate">The chat template the launcher opens its chat with.</param>
/// <param name="launcherToolIds">The tools the launcher names itself, or null when it names none.</param>
/// <returns>The tools to start with — null when neither says anything, which leaves the chat default in place — and whether the launcher's own choice was dropped for it.</returns>
public static ( IReadOnlyCollection < string >? ToolIds , bool LauncherChoiceDropped ) ChooseToolIds ( ChatTemplate chatTemplate , IReadOnlyCollection < string >? launcherToolIds )
{
if ( chatTemplate . ToolIds is not { } templateToolIds )
return ( launcherToolIds , false );
return ( templateToolIds , launcherToolIds is not null );
}
/// <summary>
/// Decides whose data source options a chat started by a launcher begins with.
/// </summary>
/// <remarks>
/// The two sides are not equally expressive: a launcher can only ever say "these sources, picked
/// by hand", while a chat template carries the whole options and can also say "let an agent pick
/// them for each message". Mixing them field by field would produce something neither of them
/// asked for, so the template wins as a whole.
/// </remarks>
/// <param name="chatTemplate">The chat template the launcher opens its chat with.</param>
/// <param name="launcherOptions">The options built from the data sources the launcher names, or null when it names none.</param>
/// <returns>The options to start with — null when neither says anything, which leaves the chat default in place — and whether the launcher's own choice was dropped for them.</returns>
public static ( DataSourceOptions ? Options , bool LauncherChoiceDropped ) ChooseDataSourceOptions ( ChatTemplate chatTemplate , DataSourceOptions ? launcherOptions )
{
if ( chatTemplate . DataSourceOptions is not { } templateOptions )
return ( launcherOptions , false );
return ( templateOptions . CreateCopy (), launcherOptions is not null );
}
/// <summary>
/// Names the preselected data sources which exist on this machine only.
/// </summary>
/// <remarks>
/// Such a source is a sensible choice inside a chat and a dead end in an export: its ID travels
/// into the plugin unchanged, and on the machine which reads that plugin it points at nothing.
/// Only ERI sources describe something the whole organization can reach, which is why they are
/// also the only ones the app offers an export for.<br/><br/>
/// IDs which match no configured source at all are left out. Those are covered by the note the
/// export writes above the data source IDs anyway, and the name to warn about is missing.
/// </remarks>
/// <param name="chatTemplate">The chat template about to be exported.</param>
/// <param name="configuredDataSources">The data sources configured on this machine.</param>
/// <returns>The names of the preselected local data sources, in the order they are configured in.</returns>
public static IReadOnlyList < string > GetPreselectedLocalDataSourceNames ( ChatTemplate chatTemplate , IEnumerable < IDataSource > configuredDataSources )
{
if ( chatTemplate . DataSourceOptions is not { PreselectedDataSourceIds . Count : > 0 } options )
return [];
var preselectedIds = options . PreselectedDataSourceIds . ToHashSet ( StringComparer . OrdinalIgnoreCase );
return configuredDataSources
. Where ( source => source is IInternalDataSource && preselectedIds . Contains ( source . Id ))
. Select ( source => source . Name )
. ToList ();
}
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 ,
2026-09-22 16:39:17 +02:00
ToolIds = ParseToolIds ( idx , table ),
DataSourceOptions = ParseDataSourceOptions ( idx , table ),
2025-08-26 10:59:56 +02:00
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-09-22 16:39:17 +02:00
/// <remarks>
/// A missing list and an empty one mean different things here, so an empty one must not fall
/// back to null: the template then states that it wants no tools. The assistant plugins reject
/// an empty list instead, because there it carries no meaning at all.
/// </remarks>
private static HashSet < string >? ParseToolIds ( int idx , LuaTable table )
{
if (! table . TryGetValue ( "ToolIds" , out var toolIdsValue ) || ! toolIdsValue . TryRead < LuaTable >( out var toolIdsTable ))
return null ;
var toolIds = new HashSet < string >( StringComparer . Ordinal );
var numToolIds = toolIdsTable . ArrayLength ;
for ( var toolNum = 1 ; toolNum <= numToolIds ; toolNum ++)
{
if (! toolIdsTable [ toolNum ]. TryRead < string >( out var toolId ) || string . IsNullOrWhiteSpace ( toolId ))
{
LOGGER . LogWarning ( "The ToolIds entry {ToolNum} in chat template {IdxChatTemplate} is not a valid tool ID and will be ignored." , toolNum , idx );
continue ;
}
toolIds . Add ( toolId . Trim ());
}
return toolIds ;
}
private static DataSourceOptions ? ParseDataSourceOptions ( int idx , LuaTable table )
{
if (! table . TryGetValue ( "DataSourceOptions" , out var optionsValue ) || ! optionsValue . TryRead < LuaTable >( out var optionsTable ))
return null ;
//
// Writing this table at all is already the statement that the template wants data sources,
// hence the switch starts enabled here. Everywhere else in the app, data sources start
// switched off.
//
var disableDataSources = false ;
if ( optionsTable . TryGetValue ( "DisableDataSources" , out var disableValue ) && disableValue . TryRead < bool >( out var disable ))
disableDataSources = disable ;
var automaticSelection = false ;
if ( optionsTable . TryGetValue ( "AutomaticDataSourceSelection" , out var automaticSelectionValue ) && automaticSelectionValue . TryRead < bool >( out var automaticSelectionFlag ))
automaticSelection = automaticSelectionFlag ;
var automaticValidation = false ;
if ( optionsTable . TryGetValue ( "AutomaticValidation" , out var automaticValidationValue ) && automaticValidationValue . TryRead < bool >( out var automaticValidationFlag ))
automaticValidation = automaticValidationFlag ;
return new DataSourceOptions
{
DisableDataSources = disableDataSources ,
AutomaticDataSourceSelection = automaticSelection ,
AutomaticValidation = automaticValidation ,
2026-09-27 16:26:52 +02:00
RetrievalMode = ParseRetrievalMode ( idx , optionsTable ),
2026-09-22 16:39:17 +02:00
PreselectedDataSourceIds = ParsePreselectedDataSourceIds ( idx , optionsTable ),
};
}
2026-09-27 16:26:52 +02:00
/// <remarks>
/// Like the other options, a template which leaves this out does not take it from the chat
/// defaults: it gets semantic search, the default everywhere. Only a name counts, see EnumNames.
/// </remarks>
private static DataSourceRetrievalMode ParseRetrievalMode ( int idx , LuaTable optionsTable )
{
if (! optionsTable . TryGetValue ( "RetrievalMode" , out var retrievalModeValue ))
return DataSourceRetrievalMode . SEMANTIC_SEARCH ;
if ( retrievalModeValue . TryRead < string >( out var retrievalModeText ) && EnumNames . TryParse < DataSourceRetrievalMode >( retrievalModeText , out var retrievalMode ))
return retrievalMode ;
LOGGER . LogWarning ( "The RetrievalMode of chat template {IdxChatTemplate} is not one of {RetrievalModes}. The template uses semantic search instead." , idx , string . Join ( ", " , Enum . GetNames < DataSourceRetrievalMode >()));
return DataSourceRetrievalMode . SEMANTIC_SEARCH ;
}
2026-09-22 16:39:17 +02:00
/// <remarks>
/// The IDs stay strings instead of being parsed as GUIDs: a data source of another
/// configuration may carry an ID which is none, and rejecting it here would make it
/// unreferenceable for no gain.
/// </remarks>
private static List < string > ParsePreselectedDataSourceIds ( int idx , LuaTable optionsTable )
{
var dataSourceIds = new List < string >();
if (! optionsTable . TryGetValue ( "PreselectedDataSourceIds" , out var idsValue ) || ! idsValue . TryRead < LuaTable >( out var idsTable ))
return dataSourceIds ;
var numIds = idsTable . ArrayLength ;
for ( var idNum = 1 ; idNum <= numIds ; idNum ++)
{
if (! idsTable [ idNum ]. TryRead < string >( out var dataSourceId ) || string . IsNullOrWhiteSpace ( dataSourceId ))
{
LOGGER . LogWarning ( "The PreselectedDataSourceIds entry {IdNum} in chat template {IdxChatTemplate} is not a valid data source ID and will be ignored." , idNum , idx );
continue ;
}
dataSourceIds . Add ( dataSourceId . Trim ());
}
return dataSourceIds ;
}
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 );
2026-09-22 16:39:17 +02:00
//
// Both of these may be absent entirely, because saying nothing about tools or data sources
// is a statement of its own. They therefore bring their own line break and indentation
// instead of sitting on a line of the template:
//
var toolIdsLua = this . BuildToolIdsLua ();
var dataSourceOptionsLua = this . BuildDataSourceOptionsLua ();
2026-05-22 15:46:03 +02:00
luaCode = $ $"""
2026-09-22 16:39:17 +02:00
{{this.BuildDataSourceIdNote()}}CONFIG[" CHAT_TEMPLATES "][#CONFIG[" CHAT_TEMPLATES "]+1] = {
2026-05-22 15:46:03 +02:00
["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 }},
2026-09-22 16:39:17 +02:00
["ExampleConversation"] = {{ exampleConversationLua }},{{ toolIdsLua }}{{ dataSourceOptionsLua }}
2026-05-22 15:46:03 +02:00
}
""";
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 ;
}
2026-09-22 16:39:17 +02:00
/// <remarks>
/// An empty set is written out as an empty table rather than being left out: the two say
/// different things, and dropping the line would turn "no tools at all" into "whatever the
/// chat default is" on the machine which reads this back.
/// </remarks>
private string BuildToolIdsLua ()
{
if ( this . ToolIds is null )
return string . Empty ;
var builder = new StringBuilder ();
builder . AppendLine ();
if ( this . ToolIds . Count == 0 )
{
builder . Append ( """ [" ToolIds "] = {},""" );
return builder . ToString ();
}
builder . AppendLine ( """ [" ToolIds "] = {""" );
//
// A set has no order of its own, so exporting the same template twice would otherwise
// produce two different files. Sorting keeps the plugin diffs readable:
//
foreach ( var toolId in this . ToolIds . Order ( StringComparer . Ordinal ))
builder . AppendLine ( $" {LuaTools.ToLuaStringLiteral(toolId)}," );
builder . Append ( " }," );
return builder . ToString ();
}
private string BuildDataSourceOptionsLua ()
{
if ( this . DataSourceOptions is not { } options )
return string . Empty ;
var builder = new StringBuilder ();
builder . AppendLine ();
builder . AppendLine ( """ [" DataSourceOptions "] = {""" );
builder . AppendLine ( $""" [" DisableDataSources "] = {options.DisableDataSources.ToString().ToLowerInvariant()},""" );
builder . AppendLine ( $""" [" AutomaticDataSourceSelection "] = {options.AutomaticDataSourceSelection.ToString().ToLowerInvariant()},""" );
builder . AppendLine ( $""" [" AutomaticValidation "] = {options.AutomaticValidation.ToString().ToLowerInvariant()},""" );
2026-09-27 16:26:52 +02:00
builder . AppendLine ( $""" [" RetrievalMode "] = " { options . RetrievalMode } ",""" );
2026-09-22 16:39:17 +02:00
if ( options . PreselectedDataSourceIds . Count == 0 )
builder . AppendLine ( """ [" PreselectedDataSourceIds "] = {},""" );
else
{
builder . AppendLine ( """ [" PreselectedDataSourceIds "] = {""" );
foreach ( var dataSourceId in options . PreselectedDataSourceIds )
builder . AppendLine ( $" {LuaTools.ToLuaStringLiteral(dataSourceId)}," );
builder . AppendLine ( " }," );
}
builder . Append ( " }," );
return builder . ToString ();
}
/// <remarks>
/// The template itself gets a fresh ID on export, but the data source IDs must not: they point
/// at the sources of the organization and only work when both sides agree on them. Nobody can
/// see that from the exported code alone, hence this note.
/// </remarks>
private string BuildDataSourceIdNote ()
{
if ( this . DataSourceOptions is not { PreselectedDataSourceIds . Count : > 0 })
return string . Empty ;
// The empty line before the closing delimiter is what ends the last comment line. Without
// it, the assignment would continue that comment and the whole export would be one comment:
return """
-- The data source IDs below are the ones of the machine this was exported from .
-- Please check them against your CONFIG [ "DATA_SOURCES" ]: an ID which resolves to
-- nothing is ignored , and a chat with this template then starts without that source .
""";
}
2026-05-22 15:46:03 +02:00
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
}