using System.Text; using AIStudio.Chat; using AIStudio.Tools.PluginSystem; using SharedTools; using LuaTable = Lua.LuaTable; namespace AIStudio.Settings; public record ChatTemplate( uint Num, string Id, string Name, string SystemPrompt, string PredefinedUserPrompt, List ExampleConversation, List FileAttachments, bool AllowProfileUsage, bool IsEnterpriseConfiguration = false, Guid EnterpriseConfigurationPluginId = default) : ConfigurationBaseObject { private const string ATTACHMENTS_DIRECTORY = "attachments"; public ChatTemplate() : this(0, Guid.Empty.ToString(), string.Empty, string.Empty, string.Empty, [], [], false) { } private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatTemplate).Namespace, nameof(ChatTemplate)); private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); public static readonly ChatTemplate NO_CHAT_TEMPLATE = new() { Name = TB("Use no chat template"), // Cannot be localized due to being a static readonly field SystemPrompt = string.Empty, PredefinedUserPrompt = string.Empty, Id = Guid.Empty.ToString(), Num = uint.MaxValue, ExampleConversation = [], FileAttachments = [], AllowProfileUsage = true, EnterpriseConfigurationPluginId = Guid.Empty, IsEnterpriseConfiguration = false, }; #region Overrides of ValueType /// /// Returns a string that represents the profile in a human-readable format. /// /// A string that represents the profile in a human-readable format. public override string ToString() => this.GetSafeName(); #endregion /// /// Gets the name of this chat template. If it is the NO_CHAT_TEMPLATE, it returns a localized string. /// /// /// 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. /// /// The name of this chat template. public string GetSafeName() { if(this == NO_CHAT_TEMPLATE) return TB("Use no chat template"); return this.Name; } public string ToSystemPrompt() { if(this.Num == uint.MaxValue) return string.Empty; return this.SystemPrompt; } public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject template) { template = NO_CHAT_TEMPLATE; if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead(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(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(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(out var preUser)) predefinedUserPrompt = preUser; var allowProfileUsage = false; if (table.TryGetValue("AllowProfileUsage", out var allowProfileValue) && allowProfileValue.TryRead(out var allow)) allowProfileUsage = allow; var fileAttachments = ParseFileAttachments(idx, table, pluginPath); template = new ChatTemplate { Num = 0, // will be set later by the PluginConfigurationObject Id = id.ToString(), Name = name, SystemPrompt = systemPrompt, PredefinedUserPrompt = predefinedUserPrompt, ExampleConversation = ParseExampleConversation(idx, table), FileAttachments = fileAttachments, AllowProfileUsage = allowProfileUsage, IsEnterpriseConfiguration = true, EnterpriseConfigurationPluginId = configPluginId, }; return true; } private static List ParseExampleConversation(int idx, LuaTable table) { var exampleConversation = new List(); if (!table.TryGetValue("ExampleConversation", out var exConvValue) || !exConvValue.TryRead(out var exConvTable)) return exampleConversation; var numBlocks = exConvTable.ArrayLength; for (var j = 1; j <= numBlocks; j++) { var blockValue = exConvTable[j]; if (!blockValue.TryRead(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(out var roleText) || !Enum.TryParse(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(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; } private static List ParseFileAttachments(int idx, LuaTable table, string pluginPath) { var fileAttachments = new List(); if (!table.TryGetValue("FileAttachments", out var fileAttValue) || !fileAttValue.TryRead(out var fileAttTable)) return fileAttachments; var numAttachments = fileAttTable.ArrayLength; for (var attachmentNum = 1; attachmentNum <= numAttachments; attachmentNum++) { var attachmentValue = fileAttTable[attachmentNum]; if (!attachmentValue.TryRead(out var filePath)) { LOGGER.LogWarning("The FileAttachments entry {AttachmentNum} in chat template {IdxChatTemplate} is not a valid string.", attachmentNum, idx); continue; } if (TryResolveFileAttachmentPath(idx, attachmentNum, filePath, pluginPath, out var resolvedFilePath)) fileAttachments.Add(FileAttachment.FromPath(resolvedFilePath)); } return fileAttachments; } 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? 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? 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(); 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(); var usedFileNames = new HashSet(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? 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 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; } }