mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-24 10:53:37 +00:00
680 lines
30 KiB
C#
680 lines
30 KiB
C#
using System.Text;
|
|
|
|
using AIStudio.Chat;
|
|
using AIStudio.Settings.DataModel;
|
|
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<ContentBlock> ExampleConversation,
|
|
List<FileAttachment> 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)
|
|
{
|
|
}
|
|
|
|
/// <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; }
|
|
|
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(ChatTemplate).Namespace, nameof(ChatTemplate));
|
|
|
|
private static readonly ILogger<ChatTemplate> LOGGER = Program.LOGGER_FACTORY.CreateLogger<ChatTemplate>();
|
|
|
|
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,
|
|
ToolIds = null,
|
|
DataSourceOptions = null,
|
|
EnterpriseConfigurationPluginId = Guid.Empty,
|
|
IsEnterpriseConfiguration = false,
|
|
};
|
|
|
|
#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.GetSafeName();
|
|
|
|
#endregion
|
|
|
|
/// <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)
|
|
return TB("Use no chat template");
|
|
|
|
return this.Name;
|
|
}
|
|
|
|
public string ToSystemPrompt()
|
|
{
|
|
if(this.Num == uint.MaxValue)
|
|
return string.Empty;
|
|
|
|
return this.SystemPrompt;
|
|
}
|
|
|
|
/// <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();
|
|
}
|
|
|
|
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<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;
|
|
|
|
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,
|
|
ToolIds = ParseToolIds(idx, table),
|
|
DataSourceOptions = ParseDataSourceOptions(idx, table),
|
|
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;
|
|
}
|
|
|
|
/// <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,
|
|
PreselectedDataSourceIds = ParsePreselectedDataSourceIds(idx, optionsTable),
|
|
};
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
private static List<FileAttachment> ParseFileAttachments(int idx, LuaTable table, string pluginPath)
|
|
{
|
|
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;
|
|
}
|
|
|
|
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<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);
|
|
|
|
//
|
|
// 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();
|
|
|
|
luaCode = $$"""
|
|
{{this.BuildDataSourceIdNote()}}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}},{{toolIdsLua}}{{dataSourceOptionsLua}}
|
|
}
|
|
""";
|
|
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;
|
|
}
|
|
|
|
/// <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()},""");
|
|
|
|
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.
|
|
|
|
""";
|
|
}
|
|
|
|
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;
|
|
}
|
|
} |