Added support for exporting chat templates & profiles (#772)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-05-22 15:46:03 +02:00
1 parent 277309cd19
commit c08f9e2ea1
24 files changed
+543 -49

No files matched your search

+230 -6
View File
@@ -1,7 +1,11 @@
using System.Text;
using AIStudio.Chat;
using AIStudio.Tools.PluginSystem;
using Lua;
using SharedTools;
using LuaTable = Lua.LuaTable;
namespace AIStudio.Settings;
@@ -17,6 +21,8 @@ public record ChatTemplate(
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)
{
}
@@ -73,8 +79,8 @@ public record ChatTemplate(
return this.SystemPrompt;
}
public static bool TryParseChatTemplateTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject template)
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))
@@ -103,7 +109,7 @@ public record ChatTemplate(
if (table.TryGetValue("AllowProfileUsage", out var allowProfileValue) && allowProfileValue.TryRead<bool>(out var allow))
allowProfileUsage = allow;
var fileAttachments = ParseFileAttachments(idx, table);
var fileAttachments = ParseFileAttachments(idx, table, pluginPath);
template = new ChatTemplate
{
@@ -169,7 +175,7 @@ public record ChatTemplate(
return exampleConversation;
}
private static List<FileAttachment> ParseFileAttachments(int idx, LuaTable table)
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))
@@ -185,9 +191,227 @@ public record ChatTemplate(
continue;
}
fileAttachments.Add(FileAttachment.FromPath(filePath));
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);
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;
}
}
@@ -8,10 +8,11 @@ using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using AIStudio.Tools.Services;
using Lua;
using SharedTools;
using ChatThread = AIStudio.Chat.ChatThread;
using ContentType = AIStudio.Tools.ERIClient.DataModel.ContentType;
using LuaTable = Lua.LuaTable;
namespace AIStudio.Settings.DataModel;
@@ -3,9 +3,10 @@ using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
using Lua;
using SharedTools;
using Host = AIStudio.Provider.SelfHosted.Host;
using LuaTable = Lua.LuaTable;
namespace AIStudio.Settings;
+20 -1
View File
@@ -1,5 +1,8 @@
using AIStudio.Tools.PluginSystem;
using Lua;
using SharedTools;
using LuaTable = Lua.LuaTable;
namespace AIStudio.Settings;
@@ -132,4 +135,20 @@ public record Profile(
return true;
}
/// <summary>
/// Exports the profile configuration as a Lua configuration section.
/// </summary>
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection()
{
return $$"""
CONFIG["PROFILES"][#CONFIG["PROFILES"]+1] = {
["Id"] = "{{Guid.NewGuid().ToString()}}",
["Name"] = {{LuaTools.ToLuaStringLiteral(this.Name)}},
["NeedToKnow"] = {{LuaTools.ToLuaStringLiteral(this.NeedToKnow)}},
["Actions"] = {{LuaTools.ToLuaStringLiteral(this.Actions)}},
}
""";
}
}
+2 -1
View File
@@ -4,9 +4,10 @@ using AIStudio.Provider;
using AIStudio.Provider.HuggingFace;
using AIStudio.Tools.PluginSystem;
using Lua;
using SharedTools;
using Host = AIStudio.Provider.SelfHosted.Host;
using LuaTable = Lua.LuaTable;
namespace AIStudio.Settings;
@@ -3,9 +3,10 @@ using System.Text.Json.Serialization;
using AIStudio.Provider;
using AIStudio.Tools.PluginSystem;
using Lua;
using SharedTools;
using Host = AIStudio.Provider.SelfHosted.Host;
using LuaTable = Lua.LuaTable;
namespace AIStudio.Settings;