Added provider icons (#934)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-08-25 12:46:10 +02:00
1 parent afdcc0455d
commit e78a0e61b9
74 files changed
+1018 -74

No files matched your search

@@ -21,7 +21,8 @@ public sealed record EmbeddingProvider(
Guid EnterpriseConfigurationPluginId = default,
string Hostname = "http://localhost:1234",
Host Host = Host.NONE,
bool AllowUserProvidedAPIKey = false) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<EmbeddingProvider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<EmbeddingProvider>();
@@ -53,7 +54,7 @@ public sealed record EmbeddingProvider(
#endregion
public static bool TryParseEmbeddingProviderTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject provider)
public static bool TryParseEmbeddingProviderTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject provider)
{
provider = NONE;
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
@@ -102,6 +103,15 @@ public sealed record EmbeddingProvider(
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
if (!iconPathValue.TryRead<string>(out var iconPath))
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid icon path. Falling back to the built-in provider icon. (Plugin ID: {configPluginId})");
else if (!PluginIconFile.TryLoadDataUrl(iconPath, pluginPath, out customIconDataUrl, out var iconIssue))
LOGGER.LogWarning($"The configured embedding provider {idx} contains an invalid icon path. Falling back to the built-in provider icon. Issue: {iconIssue} (Plugin ID: {configPluginId})");
}
provider = new EmbeddingProvider
{
Num = 0, // will be set later by the PluginConfigurationObject
@@ -115,6 +125,7 @@ public sealed record EmbeddingProvider(
Hostname = hostname,
Host = host,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
};
// Handle an encrypted API key if present. When the user manages their own key for this
@@ -0,0 +1,125 @@
namespace AIStudio.Settings;
/// <summary>
/// Loads an icon file a configuration plugin points to, such as a custom provider logo.
/// </summary>
/// <remarks>
/// The checks here are about the path, not about the markup: they keep a plugin from turning an
/// arbitrary file somewhere on the system into a data URL. Whether the file is a usable SVG, and
/// how it becomes a data URL, is up to SvgIcon.
/// </remarks>
internal static class PluginIconFile
{
public static bool TryLoadDataUrl(string iconPath, string pluginPath, out string dataUrl, out string issue)
{
dataUrl = string.Empty;
issue = string.Empty;
if (string.IsNullOrWhiteSpace(iconPath))
{
issue = "The icon path is empty.";
return false;
}
if (Path.IsPathFullyQualified(iconPath))
{
issue = "The icon path must be relative to the configuration plugin directory.";
return false;
}
if (string.IsNullOrWhiteSpace(pluginPath))
{
issue = "The icon path cannot be resolved because the configuration plugin directory is unknown.";
return false;
}
var relativePath = iconPath
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
if (relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
{
issue = "The icon path must not contain '..' path segments.";
return false;
}
string pluginRoot;
string resolvedPath;
try
{
pluginRoot = Path.GetFullPath(pluginPath);
resolvedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath));
}
catch (Exception e)
{
issue = $"The icon path is invalid: {e.Message}";
return false;
}
if (!IsInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path points outside of the configuration plugin directory.";
return false;
}
if (!string.Equals(Path.GetExtension(resolvedPath), ".svg", StringComparison.OrdinalIgnoreCase))
{
issue = "The icon file must use the .svg extension.";
return false;
}
if (!File.Exists(resolvedPath))
{
issue = "The icon file does not exist.";
return false;
}
try
{
// Check the size before reading, so an oversized file never reaches memory:
var fileInfo = new FileInfo(resolvedPath);
if (fileInfo.Length is <= 0 or > SvgIcon.MAX_ICON_SIZE_BYTES)
{
issue = $"The icon file must be between 1 byte and {SvgIcon.MAX_ICON_SIZE_BYTES / 1024} KiB.";
return false;
}
if (!LinksStayInsideDirectory(pluginRoot, resolvedPath))
{
issue = "The icon path contains a link that points outside of the configuration plugin directory.";
return false;
}
return SvgIcon.TryCreateDataUrl(File.ReadAllBytes(resolvedPath), out dataUrl, out issue);
}
catch (Exception e)
{
issue = $"The icon file could not be read: {e.Message}";
return false;
}
}
private static bool LinksStayInsideDirectory(string rootDirectory, string filePath)
{
var relativePath = Path.GetRelativePath(rootDirectory, filePath);
var currentPath = Path.GetFullPath(rootDirectory);
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
{
currentPath = Path.Combine(currentPath, segment);
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
var finalTarget = pathInfo.ResolveLinkTarget(true);
if (finalTarget is not null && !IsInsideDirectory(rootDirectory, finalTarget.FullName))
return false;
}
return true;
}
private static bool IsInsideDirectory(string rootDirectory, string path)
{
var root = Path.GetFullPath(rootDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var target = Path.GetFullPath(path);
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return target.StartsWith(root, comparison);
}
}
+14 -2
View File
@@ -22,6 +22,7 @@ namespace AIStudio.Settings;
/// <param name="Hostname">The hostname of the provider. Useful for self-hosted providers.</param>
/// <param name="Model">The LLM model to use for chat.</param>
/// <param name="AllowUserProvidedAPIKey">When set by a configuration plugin, the user may set their own API key for this otherwise locked, enterprise-managed provider.</param>
/// <param name="CustomIconDataUrl">The validated custom SVG icon supplied by a configuration plugin.</param>
public sealed record Provider(
uint Num,
string Id,
@@ -36,7 +37,8 @@ public sealed record Provider(
HFInferenceProvider HFInferenceProvider = HFInferenceProvider.NONE,
string AdditionalJsonApiParameters = "",
ProviderCapabilityOverrides? CapabilityOverrides = null,
bool AllowUserProvidedAPIKey = false) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<Provider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<Provider>();
@@ -93,7 +95,7 @@ public sealed record Provider(
#endregion
public static bool TryParseProviderTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject provider)
public static bool TryParseProviderTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject provider)
{
provider = NONE;
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
@@ -161,6 +163,15 @@ public sealed record Provider(
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
if (!iconPathValue.TryRead<string>(out var iconPath))
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid icon path. Falling back to the built-in provider icon. (Plugin ID: {configPluginId})");
else if (!PluginIconFile.TryLoadDataUrl(iconPath, pluginPath, out customIconDataUrl, out var iconIssue))
LOGGER.LogWarning($"The configured provider {idx} contains an invalid icon path. Falling back to the built-in provider icon. Issue: {iconIssue} (Plugin ID: {configPluginId})");
}
provider = new Provider
{
Num = 0, // will be set later by the PluginConfigurationObject
@@ -177,6 +188,7 @@ public sealed record Provider(
AdditionalJsonApiParameters = additionalJsonApiParameters,
CapabilityOverrides = capabilityOverrides,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
};
// Handle an encrypted API key if present. When the user manages their own key for this
@@ -660,6 +660,29 @@ public sealed class SettingsManager
.ThenBy(x => x.Num)
.ToList();
/// <summary>
/// Returns the embedding provider with the given id, without applying any confidence filtering.
/// </summary>
/// <remarks>
/// This method resolves a stored embedding provider reference by its id. It applies neither the
/// global minimum confidence level nor any component-specific minimum, so it returns the
/// requested embedding provider even when the user enforces a higher global minimum. Callers
/// that intend to send data to the returned embedding provider must check it themselves, for
/// example through IsTrustedForDataSourceSecurityChecks.
/// </remarks>
/// <param name="embeddingProviderId">The id of the embedding provider to look up.</param>
/// <returns>The embedding provider, or EmbeddingProvider.NONE when no embedding provider with that id exists.</returns>
public EmbeddingProvider GetEmbeddingProviderById(string? embeddingProviderId)
{
if (string.IsNullOrWhiteSpace(embeddingProviderId))
return EmbeddingProvider.NONE;
if (string.Equals(embeddingProviderId, EmbeddingProvider.NONE.Id, StringComparison.OrdinalIgnoreCase))
return EmbeddingProvider.NONE;
return this.ConfigurationData.EmbeddingProviders.FirstOrDefault(x => x.Id.Equals(embeddingProviderId, StringComparison.OrdinalIgnoreCase)) ?? EmbeddingProvider.NONE;
}
/// <summary>
/// Returns all configured transcription providers.
/// </summary>
@@ -675,6 +698,29 @@ public sealed class SettingsManager
.ThenBy(x => x.Num)
.ToList();
/// <summary>
/// Returns the transcription provider with the given id, without applying any confidence filtering.
/// </summary>
/// <remarks>
/// This method resolves a stored transcription provider reference by its id. It applies neither
/// the global minimum confidence level nor any component-specific minimum, so it returns the
/// requested transcription provider even when the user enforces a higher global minimum. Callers
/// that intend to send audio to the returned transcription provider must check its confidence
/// level themselves, the way GetFilteredTranscriptionProviders does for the app settings.
/// </remarks>
/// <param name="transcriptionProviderId">The id of the transcription provider to look up.</param>
/// <returns>The transcription provider, or TranscriptionProvider.NONE when no transcription provider with that id exists.</returns>
public TranscriptionProvider GetTranscriptionProviderById(string? transcriptionProviderId)
{
if (string.IsNullOrWhiteSpace(transcriptionProviderId))
return TranscriptionProvider.NONE;
if (string.Equals(transcriptionProviderId, TranscriptionProvider.NONE.Id, StringComparison.OrdinalIgnoreCase))
return TranscriptionProvider.NONE;
return this.ConfigurationData.TranscriptionProviders.FirstOrDefault(x => x.Id.Equals(transcriptionProviderId, StringComparison.OrdinalIgnoreCase)) ?? TranscriptionProvider.NONE;
}
public Profile GetPreselectedProfile(Tools.Components component)
{
var preselection = component.GetProfilePreselection(this);
@@ -21,7 +21,8 @@ public sealed record TranscriptionProvider(
Guid EnterpriseConfigurationPluginId = default,
string Hostname = "http://localhost:1234",
Host Host = Host.NONE,
bool AllowUserProvidedAPIKey = false) : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
bool AllowUserProvidedAPIKey = false,
string CustomIconDataUrl = "") : ConfigurationBaseObject, ISecretId, IUserProvidedAPIKey
{
private static readonly ILogger<TranscriptionProvider> LOGGER = Program.LOGGER_FACTORY.CreateLogger<TranscriptionProvider>();
@@ -53,7 +54,7 @@ public sealed record TranscriptionProvider(
#endregion
public static bool TryParseTranscriptionProviderTable(int idx, LuaTable table, Guid configPluginId, out ConfigurationBaseObject provider)
public static bool TryParseTranscriptionProviderTable(int idx, LuaTable table, Guid configPluginId, string pluginPath, out ConfigurationBaseObject provider)
{
provider = NONE;
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
@@ -102,6 +103,15 @@ public sealed record TranscriptionProvider(
if (table.TryGetValue("AllowUserProvidedAPIKey", out var allowUserProvidedApiKeyValue) && allowUserProvidedApiKeyValue.TryRead<bool>(out var allowUserProvidedApiKeyBool))
allowUserProvidedApiKey = allowUserProvidedApiKeyBool;
var customIconDataUrl = string.Empty;
if (table.TryGetValue("IconPath", out var iconPathValue))
{
if (!iconPathValue.TryRead<string>(out var iconPath))
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid icon path. Falling back to the built-in provider icon. (Plugin ID: {configPluginId})");
else if (!PluginIconFile.TryLoadDataUrl(iconPath, pluginPath, out customIconDataUrl, out var iconIssue))
LOGGER.LogWarning($"The configured transcription provider {idx} contains an invalid icon path. Falling back to the built-in provider icon. Issue: {iconIssue} (Plugin ID: {configPluginId})");
}
provider = new TranscriptionProvider
{
Num = 0, // will be set later by the PluginConfigurationObject
@@ -115,6 +125,7 @@ public sealed record TranscriptionProvider(
Hostname = hostname,
Host = host,
AllowUserProvidedAPIKey = allowUserProvidedApiKey,
CustomIconDataUrl = customIconDataUrl,
};
// Handle an encrypted API key if present. When the user manages their own key for this