mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 06:57:59 +00:00
Merge branch 'main' into chunk-data
This commit is contained in:
commit
00b6c98f3b
167 files changed
+8153
-3577
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,11 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
|
||||
/// </summary>
|
||||
public string ShortcutVoiceRecording { get; set; } = ManagedConfiguration.Register(configSelection, n => n.ShortcutVoiceRecording, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// The HTTP timeout in seconds for external HTTP clients.
|
||||
/// </summary>
|
||||
public int HttpClientTimeoutSeconds { get; set; } = ManagedConfiguration.Register(configSelection, n => n.HttpClientTimeoutSeconds, ExternalHttpClientTimeout.DEFAULT_HTTP_CLIENT_TIMEOUT_SECONDS);
|
||||
|
||||
/// <summary>
|
||||
/// Should the user be allowed to add providers?
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AIStudio.Settings.DataModel;
|
||||
|
||||
public enum DataSourceERIUsernamePasswordMode
|
||||
{
|
||||
/// <summary>
|
||||
/// The user manages the username and password locally.
|
||||
/// </summary>
|
||||
USER_MANAGED,
|
||||
|
||||
/// <summary>
|
||||
/// The username and password are shared by all users and provided by configuration.
|
||||
/// </summary>
|
||||
SHARED_USERNAME_AND_PASSWORD,
|
||||
|
||||
/// <summary>
|
||||
/// The username is read from the operating system, and the password is shared by all users.
|
||||
/// </summary>
|
||||
OS_USERNAME_SHARED_PASSWORD,
|
||||
}
|
||||
@@ -4,11 +4,15 @@ using AIStudio.Assistants.ERI;
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Tools.ERIClient;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.RAG;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using SharedTools;
|
||||
|
||||
using ChatThread = AIStudio.Chat.ChatThread;
|
||||
using ContentType = AIStudio.Tools.ERIClient.DataModel.ContentType;
|
||||
using LuaTable = Lua.LuaTable;
|
||||
|
||||
namespace AIStudio.Settings.DataModel;
|
||||
|
||||
@@ -17,6 +21,8 @@ namespace AIStudio.Settings.DataModel;
|
||||
/// </summary>
|
||||
public readonly record struct DataSourceERI_V1 : IERIDataSource
|
||||
{
|
||||
private static readonly ILogger<DataSourceERI_V1> LOGGER = Program.LOGGER_FACTORY.CreateLogger<DataSourceERI_V1>();
|
||||
|
||||
public DataSourceERI_V1()
|
||||
{
|
||||
}
|
||||
@@ -45,8 +51,17 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
|
||||
/// <inheritdoc />
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; } = DataSourceERIUsernamePasswordMode.USER_MANAGED;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnterpriseConfiguration { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ERIVersion Version { get; init; } = ERIVersion.V1;
|
||||
@@ -82,7 +97,7 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
|
||||
|
||||
Thread = await thread.ToERIChatThread(token),
|
||||
MaxMatches = this.MaxMatches,
|
||||
RetrievalProcessId = string.IsNullOrWhiteSpace(this.SelectedRetrievalId) ? null : this.SelectedRetrievalId,
|
||||
RetrievalProcessId = this.SelectedRetrievalId,
|
||||
Parameters = null, // The ERI server selects useful default parameters
|
||||
};
|
||||
|
||||
@@ -139,4 +154,240 @@ public readonly record struct DataSourceERI_V1 : IERIDataSource
|
||||
logger.LogWarning($"Was not able to authenticate with the ERI data source '{this.Name}'. Message: {authResponse.Message}");
|
||||
return [];
|
||||
}
|
||||
|
||||
public static bool TryParseConfiguration(int idx, LuaTable table, Guid configPluginId, out DataSourceERI_V1 dataSource)
|
||||
{
|
||||
dataSource = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead<string>(out var name) || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Type", out var typeValue) || !typeValue.TryRead<string>(out var typeText) || !Enum.TryParse<DataSourceType>(typeText, true, out var type) || type is not DataSourceType.ERI_V1)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a supported data source type. Only ERI_V1 is supported. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname) || string.IsNullOrWhiteSpace(hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Port", out var portValue) || !portValue.TryRead<int>(out var port) || port is < 1 or > 65535)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid port. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("AuthMethod", out var authMethodValue) || !authMethodValue.TryRead<string>(out var authMethodText) || !Enum.TryParse<AuthMethod>(authMethodText, true, out var authMethod))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid auth method. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("SecurityPolicy", out var securityPolicyValue) || !securityPolicyValue.TryRead<string>(out var securityPolicyText) || !Enum.TryParse<DataSourceSecurity>(securityPolicyText, true, out var securityPolicy))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid security policy. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (securityPolicy is DataSourceSecurity.NOT_SPECIFIED)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} must specify a security policy. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("SelectedRetrievalId", out var selectedRetrievalIdValue) || !selectedRetrievalIdValue.TryRead<string>(out var selectedRetrievalId) || string.IsNullOrWhiteSpace(selectedRetrievalId))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} must specify a selected retrieval ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("MaxMatches", out var maxMatchesValue) || !maxMatchesValue.TryRead<int>(out var maxMatches) || maxMatches is < 1 or > ushort.MaxValue)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid maximum number of matches. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
var username = string.Empty;
|
||||
var usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED;
|
||||
if (table.TryGetValue("UsernamePasswordMode", out var usernamePasswordModeValue) && usernamePasswordModeValue.TryRead<string>(out var usernamePasswordModeText))
|
||||
{
|
||||
if (!Enum.TryParse(usernamePasswordModeText, true, out usernamePasswordMode))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid username/password mode. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} uses the user-managed username/password mode. This mode is not allowed in configuration plugins. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (authMethod is AuthMethod.USERNAME_PASSWORD)
|
||||
{
|
||||
if (!table.TryGetValue("UsernamePasswordMode", out _) || usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} must specify an organization-managed username/password mode. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD &&
|
||||
(!table.TryGetValue("Username", out var usernameValue) || !usernameValue.TryRead<string>(out username) || string.IsNullOrWhiteSpace(username)))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} must specify a username. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
dataSource = new DataSourceERI_V1
|
||||
{
|
||||
Num = 0,
|
||||
Id = id.ToString(),
|
||||
Name = name,
|
||||
Type = DataSourceType.ERI_V1,
|
||||
Hostname = CleanHostname(hostname),
|
||||
Port = port,
|
||||
AuthMethod = authMethod,
|
||||
Username = username,
|
||||
UsernamePasswordMode = usernamePasswordMode,
|
||||
SecurityPolicy = securityPolicy,
|
||||
Version = ERIVersion.V1,
|
||||
SelectedRetrievalId = selectedRetrievalId,
|
||||
MaxMatches = (ushort)maxMatches,
|
||||
IsEnterpriseConfiguration = true,
|
||||
EnterpriseConfigurationPluginId = configPluginId,
|
||||
};
|
||||
|
||||
return TryQueueEnterpriseSecret(idx, table, configPluginId, dataSource);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the ERI v1 data source configuration as a Lua configuration section.
|
||||
/// </summary>
|
||||
/// <param name="encryptedSecret">Optional encrypted token or password to include in the export.</param>
|
||||
/// <param name="usernamePasswordMode">The organization-managed username/password mode to export.</param>
|
||||
/// <returns>A Lua configuration section string.</returns>
|
||||
public string ExportAsConfigurationSection(string? encryptedSecret = null, DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode.USER_MANAGED)
|
||||
{
|
||||
var secretLine = string.Empty;
|
||||
var usernamePasswordModeLine = string.Empty;
|
||||
var usernameLine = string.Empty;
|
||||
|
||||
switch (this.AuthMethod)
|
||||
{
|
||||
case AuthMethod.TOKEN:
|
||||
secretLine = CreateSecretLine("Token", encryptedSecret);
|
||||
break;
|
||||
|
||||
case AuthMethod.USERNAME_PASSWORD:
|
||||
if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.USER_MANAGED)
|
||||
usernamePasswordMode = DataSourceERIUsernamePasswordMode.OS_USERNAME_SHARED_PASSWORD;
|
||||
|
||||
usernamePasswordModeLine = $"""
|
||||
["UsernamePasswordMode"] = "{usernamePasswordMode}",
|
||||
""";
|
||||
|
||||
if (usernamePasswordMode is DataSourceERIUsernamePasswordMode.SHARED_USERNAME_AND_PASSWORD)
|
||||
{
|
||||
var username = string.IsNullOrWhiteSpace(this.Username) ? "<shared username>" : this.Username;
|
||||
usernameLine = $"""
|
||||
["Username"] = "{LuaTools.EscapeLuaString(username)}",
|
||||
""";
|
||||
}
|
||||
|
||||
secretLine = CreateSecretLine("Password", encryptedSecret);
|
||||
break;
|
||||
}
|
||||
|
||||
return $$"""
|
||||
CONFIG["DATA_SOURCES"][#CONFIG["DATA_SOURCES"]+1] = {
|
||||
["Id"] = "{{Guid.NewGuid().ToString()}}",
|
||||
["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}",
|
||||
["Type"] = "ERI_V1",
|
||||
["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}",
|
||||
["Port"] = {{this.Port}},
|
||||
["AuthMethod"] = "{{this.AuthMethod}}",
|
||||
{{usernamePasswordModeLine}}
|
||||
{{usernameLine}}
|
||||
{{secretLine}}
|
||||
["SecurityPolicy"] = "{{this.SecurityPolicy}}",
|
||||
["SelectedRetrievalId"] = "{{LuaTools.EscapeLuaString(this.SelectedRetrievalId)}}",
|
||||
["MaxMatches"] = {{this.MaxMatches}},
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static bool TryQueueEnterpriseSecret(int idx, LuaTable table, Guid configPluginId, DataSourceERI_V1 dataSource)
|
||||
{
|
||||
var secretFieldName = dataSource.AuthMethod switch
|
||||
{
|
||||
AuthMethod.TOKEN => "Token",
|
||||
AuthMethod.USERNAME_PASSWORD => "Password",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secretFieldName))
|
||||
return true;
|
||||
|
||||
if (!table.TryGetValue(secretFieldName, out var secretValue) || !secretValue.TryRead<string>(out var encryptedSecret) || string.IsNullOrWhiteSpace(encryptedSecret))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} does not contain a valid encrypted {secretFieldName}. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!EnterpriseEncryption.IsEncrypted(encryptedSecret))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
if (encryption?.IsAvailable != true)
|
||||
{
|
||||
LOGGER.LogWarning($"The configured data source {idx} contains an encrypted {secretFieldName}, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!encryption.TryDecrypt(encryptedSecret, out var decryptedSecret))
|
||||
{
|
||||
LOGGER.LogWarning($"Failed to decrypt the {secretFieldName} for data source {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
PendingEnterpriseSecrets.Add(new(
|
||||
$"{ISecretId.ENTERPRISE_KEY_PREFIX}::{dataSource.Id}",
|
||||
dataSource.Name,
|
||||
decryptedSecret,
|
||||
SecretStoreType.DATA_SOURCE));
|
||||
LOGGER.LogDebug($"Successfully decrypted the {secretFieldName} for data source {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string CreateSecretLine(string fieldName, string? encryptedSecret)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(encryptedSecret))
|
||||
return string.Empty;
|
||||
|
||||
return $"""
|
||||
["{fieldName}"] = "{LuaTools.EscapeLuaString(encryptedSecret)}",
|
||||
""";
|
||||
}
|
||||
|
||||
private static string CleanHostname(string hostname)
|
||||
{
|
||||
var cleanedHostname = hostname.Trim();
|
||||
return cleanedHostname.EndsWith('/') ? cleanedHostname[..^1] : cleanedHostname;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalDirectory : IInternalDataSource
|
||||
|
||||
/// <inheritdoc />
|
||||
public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnterpriseConfiguration { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ushort MaxMatches { get; init; } = 10;
|
||||
|
||||
@@ -35,6 +35,12 @@ public readonly record struct DataSourceLocalFile : IInternalDataSource
|
||||
|
||||
/// <inheritdoc />
|
||||
public DataSourceSecurity SecurityPolicy { get; init; } = DataSourceSecurity.NOT_SPECIFIED;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnterpriseConfiguration { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid EnterpriseConfigurationPluginId { get; init; } = Guid.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ushort MaxMatches { get; init; } = 10;
|
||||
|
||||
@@ -14,7 +14,7 @@ public static class PreviewFeaturesExtensions
|
||||
PreviewFeatures.PRE_PLUGINS_2025 => TB("Plugins: Preview of our plugin system where you can extend the functionality of the app"),
|
||||
PreviewFeatures.PRE_READ_PDF_2025 => TB("Read PDF: Preview of our PDF reading system where you can read and extract text from PDF files"),
|
||||
PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025 => TB("Document Analysis: Preview of our document analysis system where you can analyze and extract information from documents"),
|
||||
PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => TB("Transcription: Preview of our speech to text system where you can transcribe recordings and audio files into text"),
|
||||
PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => TB("Transcription: Convert recordings and audio files into text"),
|
||||
|
||||
_ => TB("Unknown preview feature")
|
||||
};
|
||||
@@ -33,6 +33,7 @@ public static class PreviewFeaturesExtensions
|
||||
PreviewFeatures.PRE_READ_PDF_2025 => true,
|
||||
PreviewFeatures.PRE_PLUGINS_2025 => true,
|
||||
PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025 => true,
|
||||
PreviewFeatures.PRE_SPEECH_TO_TEXT_2026 => true,
|
||||
|
||||
_ => false
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ public static class PreviewVisibilityExtensions
|
||||
if (visibility >= PreviewVisibility.BETA)
|
||||
{
|
||||
features.Add(PreviewFeatures.PRE_DOCUMENT_ANALYSIS_2025);
|
||||
features.Add(PreviewFeatures.PRE_SPEECH_TO_TEXT_2026);
|
||||
}
|
||||
|
||||
if (visibility >= PreviewVisibility.ALPHA)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
using AIStudio.Tools.RAG;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
@@ -13,23 +14,8 @@ namespace AIStudio.Settings;
|
||||
[JsonDerivedType(typeof(DataSourceLocalDirectory), nameof(DataSourceType.LOCAL_DIRECTORY))]
|
||||
[JsonDerivedType(typeof(DataSourceLocalFile), nameof(DataSourceType.LOCAL_FILE))]
|
||||
[JsonDerivedType(typeof(DataSourceERI_V1), nameof(DataSourceType.ERI_V1))]
|
||||
public interface IDataSource
|
||||
public interface IDataSource : IConfigurationObject
|
||||
{
|
||||
/// <summary>
|
||||
/// The number of the data source.
|
||||
/// </summary>
|
||||
public uint Num { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The unique identifier of the data source.
|
||||
/// </summary>
|
||||
public string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The name of the data source.
|
||||
/// </summary>
|
||||
public string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Which type of data source is this?
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AIStudio.Assistants.ERI;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ERIClient.DataModel;
|
||||
|
||||
namespace AIStudio.Settings;
|
||||
@@ -24,6 +25,11 @@ public interface IERIDataSource : IExternalDataSource
|
||||
/// The username to use for authentication, when the auth. method is USERNAME_PASSWORD.
|
||||
/// </summary>
|
||||
public string Username { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How username/password authentication should obtain the username.
|
||||
/// </summary>
|
||||
public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The ERI specification to use.
|
||||
|
||||
@@ -7,7 +7,7 @@ public interface IExternalDataSource : IDataSource, ISecretId
|
||||
#region Implementation of ISecretId
|
||||
|
||||
[JsonIgnore]
|
||||
string ISecretId.SecretId => this.Id;
|
||||
string ISecretId.SecretId => this.IsEnterpriseConfiguration ? $"{ENTERPRISE_KEY_PREFIX}::{this.Id}" : this.Id;
|
||||
|
||||
[JsonIgnore]
|
||||
string ISecretId.SecretName => this.Name;
|
||||
|
||||
@@ -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)}},
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user