mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-04 16:49:40 +00:00
Enhanced enterprise config support (#666)
This commit is contained in:
1 parent
6e33c361dc
commit
b445600a52
31 files changed
+733
-426
No files matched your search
@@ -0,0 +1,10 @@
|
||||
<div style="display: flex; align-items: center; gap: 8px; @this.Item.Style">
|
||||
<MudIcon Icon="@this.Item.Icon"/>
|
||||
<span>
|
||||
@this.Item.Text
|
||||
</span>
|
||||
@if (!string.IsNullOrWhiteSpace(this.Item.CopyValue))
|
||||
{
|
||||
<MudCopyClipboardButton TooltipMessage="@this.Item.CopyTooltip" StringContent="@this.Item.CopyValue"/>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class ConfigInfoRow : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public ConfigInfoRowItem Item { get; set; } = new(Icons.Material.Filled.ArrowRightAlt, string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public sealed record ConfigInfoRowItem(
|
||||
string Icon,
|
||||
string Text,
|
||||
string CopyValue,
|
||||
string CopyTooltip,
|
||||
string Style = ""
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
<MudPaper Outlined="true" Class="@this.Class">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<MudIcon Icon="@this.HeaderIcon" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle2">
|
||||
@this.HeaderText
|
||||
</MudText>
|
||||
</div>
|
||||
|
||||
@foreach (var item in this.Items)
|
||||
{
|
||||
<ConfigInfoRow Item="@item"/>
|
||||
}
|
||||
|
||||
@if (this.ShowWarning)
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Size="Size.Small" Color="Color.Warning"/>
|
||||
<MudText Typo="Typo.subtitle2">@this.WarningText</MudText>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class ConfigPluginInfoCard : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public string HeaderIcon { get; set; } = Icons.Material.Filled.Extension;
|
||||
|
||||
[Parameter]
|
||||
public string HeaderText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public IEnumerable<ConfigInfoRowItem> Items { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public bool ShowWarning { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string WarningText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Class { get; set; } = "pa-3 mt-2 mb-2";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<MudText Typo="Typo.body1" Class="@this.Class">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (this.IsConfigured)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@this.ConfiguredText</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@this.NotConfiguredText</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
public partial class EncryptionSecretInfo : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public bool IsConfigured { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string ConfiguredText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string NotConfiguredText { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Class { get; set; } = "mt-2 mb-2";
|
||||
}
|
||||
@@ -215,8 +215,28 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
|
||||
.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
|
||||
.Where(env => env != default)
|
||||
.ToList();
|
||||
|
||||
var failedDeferredConfigIds = new HashSet<Guid>();
|
||||
foreach (var env in enterpriseEnvironments)
|
||||
await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl);
|
||||
{
|
||||
var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(env.ConfigurationId, env.ConfigurationServerUrl);
|
||||
if (!wasDownloadSuccessful)
|
||||
{
|
||||
failedDeferredConfigIds.Add(env.ConfigurationId);
|
||||
this.Logger.LogWarning("Failed to download deferred enterprise configuration '{ConfigId}' during startup. Keeping managed plugins unchanged.", env.ConfigurationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (EnterpriseEnvironmentService.HasValidEnterpriseSnapshot)
|
||||
{
|
||||
var activeConfigIds = EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS
|
||||
.Select(env => env.ConfigurationId)
|
||||
.ToHashSet();
|
||||
|
||||
PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(activeConfigIds);
|
||||
if (failedDeferredConfigIds.Count > 0)
|
||||
this.Logger.LogWarning("Deferred startup updates failed for {FailedCount} enterprise configuration(s). Those configurations were kept unchanged.", failedDeferredConfigIds.Count);
|
||||
}
|
||||
|
||||
// Initialize the enterprise encryption service for decrypting API keys:
|
||||
await PluginFactory.InitializeEnterpriseEncryption(this.RustService);
|
||||
|
||||
@@ -64,33 +64,19 @@
|
||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||
@foreach (var plug in this.configPlugins)
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-3 mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Extension" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle2">@plug.Name</MudText>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration plugin ID:") @plug.Id</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@plug.Id.ToString()/>
|
||||
</div>
|
||||
</MudPaper>
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Extension"
|
||||
HeaderText="@plug.Name"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration plugin ID:")} {plug.Id}",
|
||||
plug.Id.ToString(),
|
||||
T("Copies the configuration plugin ID to the clipboard"))
|
||||
])"/>
|
||||
}
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is configured")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is not configured")</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
|
||||
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||
ConfiguredText="@T("Encryption secret: is configured")"
|
||||
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||
</MudCollapse>
|
||||
break;
|
||||
|
||||
@@ -101,97 +87,91 @@
|
||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-3 mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.HourglassBottom" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle2">@T("Waiting for the configuration plugin...")</MudText>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Enterprise configuration ID:") @env.ConfigurationId</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@env.ConfigurationId.ToString()/>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration server:") @env.ConfigurationServerUrl</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@env.ConfigurationServerUrl/>
|
||||
</div>
|
||||
</MudPaper>
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.HourglassBottom"
|
||||
HeaderText="@T("Waiting for the configuration plugin...")"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||
env.ConfigurationId.ToString(),
|
||||
T("Copies the config ID to the clipboard")),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||
env.ConfigurationServerUrl,
|
||||
T("Copies the server URL to the clipboard"),
|
||||
"margin-top: 4px;")
|
||||
])"/>
|
||||
}
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is configured")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is not configured")</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
|
||||
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||
ConfiguredText="@T("Encryption secret: is configured")"
|
||||
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||
</MudCollapse>
|
||||
break;
|
||||
|
||||
case true:
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.")
|
||||
</MudText>
|
||||
@if (this.HasAnyLoadedEnterpriseConfigurationPlugin)
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.")
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are not yet available.")
|
||||
</MudText>
|
||||
}
|
||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
||||
{
|
||||
var matchingPlugin = this.configPlugins.FirstOrDefault(p => p.Id == env.ConfigurationId);
|
||||
<MudPaper Outlined="true" Class="pa-3 mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
@if (matchingPlugin is not null)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Extension" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.subtitle2">@matchingPlugin.Name</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Size="Size.Small" Color="Color.Warning"/>
|
||||
<MudText Typo="Typo.subtitle2">@T("ID mismatch: the plugin ID differs from the enterprise configuration ID.")</MudText>
|
||||
}
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Enterprise configuration ID:") @env.ConfigurationId</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@env.ConfigurationId.ToString()/>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration server:") @env.ConfigurationServerUrl</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@env.ConfigurationServerUrl/>
|
||||
</div>
|
||||
@if (matchingPlugin is not null)
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
<span>@T("Configuration plugin ID:") @matchingPlugin.Id</span>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@matchingPlugin.Id.ToString()/>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
var matchingPlugin = this.FindManagedConfigurationPlugin(env.ConfigurationId);
|
||||
if (matchingPlugin is null)
|
||||
{
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.HourglassBottom"
|
||||
HeaderText="@T("Waiting for the configuration plugin...")"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||
env.ConfigurationId.ToString(),
|
||||
T("Copies the config ID to the clipboard")),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||
env.ConfigurationServerUrl,
|
||||
T("Copies the server URL to the clipboard"),
|
||||
"margin-top: 4px;")
|
||||
])"/>
|
||||
continue;
|
||||
}
|
||||
|
||||
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Extension"
|
||||
HeaderText="@matchingPlugin.Name"
|
||||
Items="@([
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||
env.ConfigurationId.ToString(),
|
||||
T("Copies the config ID to the clipboard")),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||
env.ConfigurationServerUrl,
|
||||
T("Copies the server URL to the clipboard"),
|
||||
"margin-top: 4px;"),
|
||||
|
||||
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||
$"{T("Configuration plugin ID:")} {matchingPlugin.Id}",
|
||||
matchingPlugin.Id.ToString(),
|
||||
T("Copies the configuration plugin ID to the clipboard"),
|
||||
"margin-top: 4px;")
|
||||
])"
|
||||
ShowWarning="@this.IsManagedConfigurationIdMismatch(matchingPlugin, env.ConfigurationId)"
|
||||
WarningText="@T("ID mismatch: the plugin ID differs from the enterprise configuration ID.")"/>
|
||||
}
|
||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is configured")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Cancel" Color="Color.Error" Size="Size.Small"/>
|
||||
<span>@T("Encryption secret: is not configured")</span>
|
||||
}
|
||||
</div>
|
||||
</MudText>
|
||||
|
||||
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||
ConfiguredText="@T("Encryption secret: is configured")"
|
||||
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||
</MudCollapse>
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -69,13 +69,20 @@ public partial class Information : MSGComponentBase
|
||||
|
||||
private bool showDatabaseDetails;
|
||||
|
||||
private List<IPluginMetadata> configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList();
|
||||
private List<IAvailablePlugin> configPlugins = PluginFactory.AvailablePlugins
|
||||
.Where(x => x.Type is PluginType.CONFIGURATION)
|
||||
.OfType<IAvailablePlugin>()
|
||||
.ToList();
|
||||
|
||||
private sealed record DatabaseDisplayInfo(string Label, string Value);
|
||||
|
||||
private readonly List<DatabaseDisplayInfo> databaseDisplayInfo = new();
|
||||
|
||||
private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive);
|
||||
|
||||
private bool HasAnyLoadedEnterpriseConfigurationPlugin => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS
|
||||
.Where(e => e.IsActive)
|
||||
.Any(env => this.FindManagedConfigurationPlugin(env.ConfigurationId) is not null);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the enterprise configuration has details that can be shown/hidden.
|
||||
@@ -130,7 +137,10 @@ public partial class Information : MSGComponentBase
|
||||
switch (triggeredEvent)
|
||||
{
|
||||
case Event.PLUGINS_RELOADED:
|
||||
this.configPlugins = PluginFactory.AvailablePlugins.Where(x => x.Type is PluginType.CONFIGURATION).ToList();
|
||||
this.configPlugins = PluginFactory.AvailablePlugins
|
||||
.Where(x => x.Type is PluginType.CONFIGURATION)
|
||||
.OfType<IAvailablePlugin>()
|
||||
.ToList();
|
||||
await this.InvokeAsync(this.StateHasChanged);
|
||||
break;
|
||||
}
|
||||
@@ -196,6 +206,18 @@ public partial class Information : MSGComponentBase
|
||||
this.showDatabaseDetails = !this.showDatabaseDetails;
|
||||
}
|
||||
|
||||
private IAvailablePlugin? FindManagedConfigurationPlugin(Guid configurationId)
|
||||
{
|
||||
return this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId == configurationId)
|
||||
// Backward compatibility for already downloaded plugins without ManagedConfigurationId.
|
||||
?? this.configPlugins.FirstOrDefault(plugin => plugin.ManagedConfigurationId is null && plugin.Id == configurationId);
|
||||
}
|
||||
|
||||
private bool IsManagedConfigurationIdMismatch(IAvailablePlugin plugin, Guid configurationId)
|
||||
{
|
||||
return plugin.ManagedConfigurationId == configurationId && plugin.Id != configurationId;
|
||||
}
|
||||
|
||||
private async Task CopyStartupLogPath()
|
||||
{
|
||||
await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath);
|
||||
|
||||
@@ -24,6 +24,9 @@ VERSION = "1.0.0"
|
||||
-- The type of the plugin:
|
||||
TYPE = "CONFIGURATION"
|
||||
|
||||
-- True when this plugin is deployed by an enterprise configuration server:
|
||||
DEPLOYED_USING_CONFIG_SERVER = false
|
||||
|
||||
-- The authors of the plugin:
|
||||
AUTHORS = {"<Company Name>"}
|
||||
|
||||
|
||||
@@ -56,43 +56,43 @@ public sealed record EmbeddingProvider(
|
||||
provider = NONE;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid ID. The ID must be a valid GUID.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {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))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadModelTable(idx, modelTable, out var model))
|
||||
if (!TryReadModelTable(idx, modelTable, configPluginId, out var model))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ public sealed record EmbeddingProvider(
|
||||
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||
{
|
||||
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
else
|
||||
{
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
@@ -128,31 +128,31 @@ public sealed record EmbeddingProvider(
|
||||
name,
|
||||
decryptedApiKey,
|
||||
SecretStoreType.EMBEDDING_PROVIDER));
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring.");
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for embedding provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect.");
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for embedding provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, out Model model)
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model)
|
||||
{
|
||||
model = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name.");
|
||||
LOGGER.LogWarning($"The configured embedding provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,31 +94,31 @@ public sealed record Provider(
|
||||
provider = NONE;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("InstanceName", out var instanceNameValue) || !instanceNameValue.TryRead<string>(out var instanceName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid instance name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -127,27 +127,27 @@ public sealed record Provider(
|
||||
{
|
||||
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid Hugging Face inference provider enum value. (Plugin ID: {configPluginId})");
|
||||
hfInferenceProvider = HFInferenceProvider.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadModelTable(idx, modelTable, out var model))
|
||||
if (!TryReadModelTable(idx, modelTable, configPluginId, out var model))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("AdditionalJsonApiParameters", out var additionalJsonApiParametersValue) || !additionalJsonApiParametersValue.TryRead<string>(out var additionalJsonApiParameters))
|
||||
{
|
||||
// In this case, no reason exists to reject this provider, though.
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain valid additional JSON API parameters. (Plugin ID: {configPluginId})");
|
||||
additionalJsonApiParameters = string.Empty;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ public sealed record Provider(
|
||||
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||
{
|
||||
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
else
|
||||
{
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
@@ -185,31 +185,31 @@ public sealed record Provider(
|
||||
instanceName,
|
||||
decryptedApiKey,
|
||||
SecretStoreType.LLM_PROVIDER));
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring.");
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect.");
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, out Model model)
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model)
|
||||
{
|
||||
model = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name.");
|
||||
LOGGER.LogWarning($"The configured provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,43 +56,43 @@ public sealed record TranscriptionProvider(
|
||||
provider = NONE;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid ID. The ID must be a valid GUID.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {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))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid LLM provider enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid host enum value. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model table. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadModelTable(idx, modelTable, out var model))
|
||||
if (!TryReadModelTable(idx, modelTable, configPluginId, out var model))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model configuration. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ public sealed record TranscriptionProvider(
|
||||
if (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||
{
|
||||
if (!EnterpriseEncryption.IsEncrypted(apiKeyText))
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains a plaintext API key. Only encrypted API keys (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})");
|
||||
else
|
||||
{
|
||||
var encryption = PluginFactory.EnterpriseEncryption;
|
||||
@@ -128,31 +128,31 @@ public sealed record TranscriptionProvider(
|
||||
name,
|
||||
decryptedApiKey,
|
||||
SecretStoreType.TRANSCRIPTION_PROVIDER));
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring.");
|
||||
LOGGER.LogDebug($"Successfully decrypted API key for transcription provider {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect.");
|
||||
LOGGER.LogWarning($"Failed to decrypt API key for transcription provider {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
else
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} contains an encrypted API key, but no encryption secret is configured. (Plugin ID: {configPluginId})");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, out Model model)
|
||||
private static bool TryReadModelTable(int idx, LuaTable table, Guid configPluginId, out Model model)
|
||||
{
|
||||
model = default;
|
||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model ID. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
||||
{
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name.");
|
||||
LOGGER.LogWarning($"The configured transcription provider {idx} does not contain a valid model display name. (Plugin ID: {configPluginId})");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,8 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
public interface IAvailablePlugin : IPluginMetadata
|
||||
{
|
||||
public string LocalPath { get; }
|
||||
|
||||
public bool IsManagedByConfigServer { get; }
|
||||
|
||||
public Guid? ManagedConfigurationId { get; }
|
||||
}
|
||||
@@ -17,6 +17,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// The list of configuration objects. Configuration objects are, e.g., providers or chat templates.
|
||||
/// </summary>
|
||||
public IEnumerable<PluginConfigurationObject> ConfigObjects => this.configObjects;
|
||||
|
||||
/// <summary>
|
||||
/// True/false when explicitly configured in the plugin, otherwise null.
|
||||
/// </summary>
|
||||
public bool? DeployedUsingConfigServer { get; } = ReadDeployedUsingConfigServer(state);
|
||||
|
||||
public async Task InitializeAsync(bool dryRun)
|
||||
{
|
||||
@@ -69,6 +74,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
||||
/// </summary>
|
||||
private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId;
|
||||
|
||||
private static bool? ReadDeployedUsingConfigServer(LuaState state)
|
||||
{
|
||||
if (state.Environment["DEPLOYED_USING_CONFIG_SERVER"].TryRead<bool>(out var deployedUsingConfigServer))
|
||||
return deployedUsingConfigServer;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to initialize the UI text content of the plugin.
|
||||
/// </summary>
|
||||
|
||||
@@ -79,13 +79,13 @@ public sealed record PluginConfigurationObject
|
||||
|
||||
if (luaTableName is null)
|
||||
{
|
||||
LOG.LogError($"The configuration object type '{configObjectType}' is not supported yet.");
|
||||
LOG.LogError("The configuration object type '{ConfigObjectType}' is not supported yet (config plugin id: {ConfigPluginId}).", configObjectType, configPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead<LuaTable>(out var luaTable))
|
||||
{
|
||||
LOG.LogWarning($"The {luaTableName} table does not exist or is not a valid table.");
|
||||
LOG.LogWarning("The table '{LuaTableName}' does not exist or is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, configPluginId);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed record PluginConfigurationObject
|
||||
var luaObjectTableValue = luaTable[i];
|
||||
if (!luaObjectTableValue.TryRead<LuaTable>(out var luaObjectTable))
|
||||
{
|
||||
LOG.LogWarning($"The {luaObjectTable} table at index {i} is not a valid table.");
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} is not a valid table (config plugin id: {ConfigPluginId}).", luaTableName, i, configPluginId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -151,12 +151,12 @@ public sealed record PluginConfigurationObject
|
||||
random ??= new ThreadSafeRandom();
|
||||
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
|
||||
storedObjects.Add((TClass)configObject);
|
||||
LOG.LogWarning($"The next number for the configuration object '{configObject.Name}' (id={configObject.Id}) could not be incremented. Using a random number instead.");
|
||||
LOG.LogWarning("The next number for the configuration object '{ConfigObjectName}' (id={ConfigObjectId}) could not be incremented. Using a random number instead (config plugin id: {ConfigPluginId}).", configObject.Name, configObject.Id, configPluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"The {luaObjectTable} table at index {i} does not contain a valid chat template configuration.");
|
||||
LOG.LogWarning("The table '{LuaTableName}' entry at index {Index} does not contain a valid configuration object (type={ConfigObjectType}, config plugin id: {ConfigPluginId}).", luaTableName, i, configObjectType, configPluginId);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
public static async Task<EntityTagHeaderValue?> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
public static async Task<(bool Success, EntityTagHeaderValue? ETag, string? Issue)> DetermineConfigPluginETagAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(configPlugId == Guid.Empty || string.IsNullOrWhiteSpace(configServerUrl))
|
||||
return null;
|
||||
return (false, null, "Configuration ID or server URL is missing.");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -18,18 +18,24 @@ public static partial class PluginFactory
|
||||
using var http = new HttpClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
return response.Headers.ETag;
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
LOG.LogError($"Failed to determine the ETag for configuration plugin '{configPlugId}'. HTTP Status: {response.StatusCode}");
|
||||
return (false, null, $"HTTP status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
return (true, response.Headers.ETag, null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin.");
|
||||
return null;
|
||||
return (false, null, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> TryDownloadingConfigPluginAsync(Guid configPlugId, string configServerUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
if(!IsInitialized)
|
||||
{
|
||||
LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin.");
|
||||
return false;
|
||||
@@ -40,36 +46,72 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})");
|
||||
var tempDownloadFile = Path.GetTempFileName();
|
||||
var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
|
||||
string? backupDirectory = null;
|
||||
var wasSuccessful = false;
|
||||
try
|
||||
{
|
||||
await LockHotReloadAsync();
|
||||
using var httpClient = new HttpClient();
|
||||
var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
await using(var tempFileStream = File.Create(tempDownloadFile))
|
||||
{
|
||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if(Directory.Exists(configDirectory))
|
||||
Directory.Delete(configDirectory, true);
|
||||
|
||||
Directory.CreateDirectory(configDirectory);
|
||||
ZipFile.ExtractToDirectory(tempDownloadFile, configDirectory);
|
||||
|
||||
LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'.");
|
||||
}
|
||||
else
|
||||
LOG.LogError($"Failed to download the enterprise configuration plugin. HTTP Status: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
await using(var tempFileStream = File.Create(tempDownloadFile))
|
||||
{
|
||||
await response.Content.CopyToAsync(tempFileStream, cancellationToken);
|
||||
}
|
||||
|
||||
ZipFile.ExtractToDirectory(tempDownloadFile, stagedDirectory);
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (Directory.Exists(configDirectory))
|
||||
{
|
||||
backupDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.backup-{Guid.NewGuid():N}");
|
||||
Directory.Move(configDirectory, backupDirectory);
|
||||
}
|
||||
|
||||
Directory.Move(stagedDirectory, configDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
|
||||
Directory.Delete(backupDirectory, true);
|
||||
|
||||
LOG.LogInformation($"Configuration plugin with ID='{configPlugId}' downloaded and extracted successfully to '{configDirectory}'.");
|
||||
wasSuccessful = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin.");
|
||||
|
||||
var configDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, configPlugId.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory) && !Directory.Exists(configDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Move(backupDirectory, configDirectory);
|
||||
}
|
||||
catch (Exception restoreException)
|
||||
{
|
||||
LOG.LogError(restoreException, "Failed to restore the previous configuration plugin after a failed update.");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(stagedDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(stagedDirectory, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, "Failed to delete the staged configuration plugin directory.");
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(tempDownloadFile))
|
||||
{
|
||||
try
|
||||
@@ -85,6 +127,6 @@ public static partial class PluginFactory
|
||||
UnlockHotReload();
|
||||
}
|
||||
|
||||
return true;
|
||||
return wasSuccessful;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ public static partial class PluginFactory
|
||||
|
||||
public static void SetUpHotReloading()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
|
||||
@@ -10,7 +10,7 @@ public static partial class PluginFactory
|
||||
{
|
||||
public static async Task EnsureInternalPlugins()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
|
||||
@@ -30,7 +30,7 @@ public static partial class PluginFactory
|
||||
/// </remarks>
|
||||
public static async Task LoadAll(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||
return;
|
||||
@@ -104,16 +104,40 @@ public static partial class PluginFactory
|
||||
|
||||
LOG.LogInformation($"Successfully loaded plugin: '{pluginMainFile}' (Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}', Authors='{string.Join(", ", plugin.Authors)}')");
|
||||
|
||||
// For configuration plugins, validate that the plugin ID matches the enterprise config ID
|
||||
// (the directory name under which the plugin was downloaded):
|
||||
if (plugin.Type is PluginType.CONFIGURATION && pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase))
|
||||
var isConfigurationPluginInConfigDirectory =
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var isManagedByConfigServer = false;
|
||||
Guid? managedConfigurationId = null;
|
||||
if (plugin is PluginConfiguration configPlugin)
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginPath);
|
||||
if (Guid.TryParse(directoryName, out var enterpriseConfigId) && enterpriseConfigId != plugin.Id)
|
||||
LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID.");
|
||||
if (configPlugin.DeployedUsingConfigServer.HasValue)
|
||||
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
|
||||
|
||||
else if (isConfigurationPluginInConfigDirectory)
|
||||
{
|
||||
isManagedByConfigServer = true;
|
||||
LOG.LogWarning($"The configuration plugin '{plugin.Id}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
}
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath));
|
||||
// For configuration plugins, validate that the plugin ID matches the enterprise config ID
|
||||
// (the directory name under which the plugin was downloaded):
|
||||
if (isConfigurationPluginInConfigDirectory && isManagedByConfigServer)
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginPath);
|
||||
if (Guid.TryParse(directoryName, out var enterpriseConfigId))
|
||||
{
|
||||
managedConfigurationId = enterpriseConfigId;
|
||||
if (enterpriseConfigId != plugin.Id)
|
||||
LOG.LogWarning($"The configuration plugin's ID ('{plugin.Id}') does not match the enterprise configuration ID ('{enterpriseConfigId}'). These IDs should be identical. Please update the plugin's ID field to match the enterprise configuration ID.");
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"Could not determine the managed configuration ID for configuration plugin '{plugin.Id}'. The plugin directory '{pluginPath}' does not end with a valid GUID.");
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Add(new PluginMetadata(plugin, pluginPath, isManagedByConfigServer, managedConfigurationId));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -1,54 +1,129 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public static partial class PluginFactory
|
||||
{
|
||||
public static void RemovePluginAsync(Guid pluginId)
|
||||
private const string REASON_NO_LONGER_REFERENCED = "no longer referenced by active enterprise environments";
|
||||
|
||||
public static void RemoveUnreferencedManagedConfigurationPlugins(ISet<Guid> activeConfigurationIds)
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning($"Try to remove plugin with ID: {pluginId}");
|
||||
var pluginIdsToRemove = new HashSet<Guid>();
|
||||
|
||||
// Case 1: Plugins are already loaded and metadata is available.
|
||||
foreach (var plugin in AVAILABLE_PLUGINS.Where(plugin =>
|
||||
plugin.Type is PluginType.CONFIGURATION &&
|
||||
plugin.IsManagedByConfigServer &&
|
||||
!activeConfigurationIds.Contains(plugin.Id)))
|
||||
pluginIdsToRemove.Add(plugin.Id);
|
||||
|
||||
// Case 2: Startup cleanup before the initial plugin load.
|
||||
// In this case, we inspect the .config directories directly.
|
||||
if (Directory.Exists(CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
foreach (var pluginDirectory in Directory.EnumerateDirectories(CONFIGURATION_PLUGINS_ROOT))
|
||||
{
|
||||
var directoryName = Path.GetFileName(pluginDirectory);
|
||||
if (!Guid.TryParse(directoryName, out var pluginId))
|
||||
continue;
|
||||
|
||||
if (activeConfigurationIds.Contains(pluginId))
|
||||
continue;
|
||||
|
||||
var deployFlag = ReadDeployFlagFromPluginFile(pluginDirectory);
|
||||
var isManagedByConfigServer = deployFlag ?? true;
|
||||
if (!deployFlag.HasValue)
|
||||
LOG.LogWarning($"Configuration plugin '{pluginId}' does not define 'DEPLOYED_USING_CONFIG_SERVER'. Falling back to the plugin path and treating it as managed because it is stored under '{CONFIGURATION_PLUGINS_ROOT}'.");
|
||||
|
||||
if (isManagedByConfigServer)
|
||||
pluginIdsToRemove.Add(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pluginId in pluginIdsToRemove)
|
||||
RemovePluginAsync(pluginId, REASON_NO_LONGER_REFERENCED);
|
||||
}
|
||||
|
||||
private static void RemovePluginAsync(Guid pluginId, string reason)
|
||||
{
|
||||
if (!IsInitialized)
|
||||
return;
|
||||
|
||||
LOG.LogWarning("Removing plugin with ID '{PluginId}'. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the available plugins list:
|
||||
//
|
||||
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (availablePluginToRemove == null)
|
||||
{
|
||||
LOG.LogWarning($"No plugin found with ID: {pluginId}");
|
||||
return;
|
||||
}
|
||||
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
if (availablePluginToRemove != null)
|
||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||
else
|
||||
LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
|
||||
//
|
||||
// Remove the plugin from the running plugins list:
|
||||
//
|
||||
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||
if (runningPluginToRemove == null)
|
||||
LOG.LogWarning($"No running plugin found with ID: {pluginId}");
|
||||
LOG.LogWarning("No running plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||
else
|
||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||
|
||||
//
|
||||
// Delete the plugin directory:
|
||||
//
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, availablePluginToRemove.Id.ToString());
|
||||
if (Directory.Exists(pluginDirectory))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
}
|
||||
}
|
||||
else
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
DeleteConfigurationPluginDirectory(pluginId);
|
||||
|
||||
LOG.LogInformation($"Plugin with ID: {pluginId} removed successfully.");
|
||||
LOG.LogInformation("Plugin with ID '{PluginId}' removed successfully. Reason: {Reason}.", pluginId, reason);
|
||||
}
|
||||
|
||||
private static bool? ReadDeployFlagFromPluginFile(string pluginDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginFile = Path.Join(pluginDirectory, "plugin.lua");
|
||||
if (!File.Exists(pluginFile))
|
||||
return null;
|
||||
|
||||
var pluginCode = File.ReadAllText(pluginFile);
|
||||
var match = DeployedByConfigServerRegex().Match(pluginCode);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return bool.TryParse(match.Groups[1].Value, out var deployFlag)
|
||||
? deployFlag
|
||||
: null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogWarning(ex, $"Failed to parse deployment flag from plugin directory '{pluginDirectory}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteConfigurationPluginDirectory(Guid pluginId)
|
||||
{
|
||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, pluginId.ToString());
|
||||
if (!Directory.Exists(pluginDirectory))
|
||||
{
|
||||
LOG.LogWarning($"Plugin directory '{pluginDirectory}' does not exist.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(pluginDirectory, true);
|
||||
LOG.LogInformation($"Plugin directory '{pluginDirectory}' deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LOG.LogError(ex, $"Failed to delete plugin directory '{pluginDirectory}'.");
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*DEPLOYED_USING_CONFIG_SERVER\s*=\s*(true|false)\s*(?:--.*)?$", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
|
||||
private static partial Regex DeployedByConfigServerRegex();
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public static partial class PluginFactory
|
||||
|
||||
if (startedBasePlugin is PluginLanguage languagePlugin)
|
||||
{
|
||||
BASE_LANGUAGE_PLUGIN = languagePlugin;
|
||||
BaseLanguage = languagePlugin;
|
||||
RUNNING_PLUGINS.Add(languagePlugin);
|
||||
LOG.LogInformation($"Successfully started the base language plugin: Id='{languagePlugin.Id}', Type='{languagePlugin.Type}', Name='{languagePlugin.Name}', Version='{languagePlugin.Version}'");
|
||||
}
|
||||
@@ -44,7 +44,7 @@ public static partial class PluginFactory
|
||||
catch (Exception e)
|
||||
{
|
||||
LOG.LogError(e, $"An error occurred while starting the base language plugin: Id='{baseLanguagePluginId}'.");
|
||||
BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
|
||||
BaseLanguage = NoPluginLanguage.INSTANCE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ public static partial class PluginFactory
|
||||
//
|
||||
// When this is a language plugin, we need to set the base language plugin.
|
||||
//
|
||||
if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE)
|
||||
languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN);
|
||||
if (plugin is PluginLanguage languagePlugin && BaseLanguage != NoPluginLanguage.INSTANCE)
|
||||
languagePlugin.SetBaseLanguage(BaseLanguage);
|
||||
|
||||
if(plugin is PluginConfiguration configPlugin)
|
||||
await configPlugin.InitializeAsync(false);
|
||||
|
||||
@@ -6,17 +6,17 @@ public static partial class PluginFactory
|
||||
{
|
||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
|
||||
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||
|
||||
private static bool IS_INITIALIZED;
|
||||
|
||||
private static string DATA_DIR = string.Empty;
|
||||
private static string PLUGINS_ROOT = string.Empty;
|
||||
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
|
||||
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
||||
private static ILanguagePlugin BASE_LANGUAGE_PLUGIN = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static ILanguagePlugin BaseLanguage => BASE_LANGUAGE_PLUGIN;
|
||||
public static ILanguagePlugin BaseLanguage { get; private set; } = NoPluginLanguage.INSTANCE;
|
||||
|
||||
public static bool IsInitialized { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
|
||||
@@ -47,7 +47,7 @@ public static partial class PluginFactory
|
||||
/// </summary>
|
||||
public static bool Setup()
|
||||
{
|
||||
if(IS_INITIALIZED)
|
||||
if(IsInitialized)
|
||||
return false;
|
||||
|
||||
LOG.LogInformation("Initializing plugin factory...");
|
||||
@@ -61,14 +61,14 @@ public static partial class PluginFactory
|
||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||
|
||||
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
||||
IS_INITIALIZED = true;
|
||||
IsInitialized = true;
|
||||
LOG.LogInformation("Plugin factory initialized successfully.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task LockHotReloadAsync()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized.");
|
||||
return;
|
||||
@@ -92,7 +92,7 @@ public static partial class PluginFactory
|
||||
|
||||
private static void UnlockHotReload()
|
||||
{
|
||||
if (!IS_INITIALIZED)
|
||||
if (!IsInitialized)
|
||||
{
|
||||
LOG.LogError("PluginFactory is not initialized.");
|
||||
return;
|
||||
@@ -113,7 +113,7 @@ public static partial class PluginFactory
|
||||
|
||||
public static void Dispose()
|
||||
{
|
||||
if(!IS_INITIALIZED)
|
||||
if(!IsInitialized)
|
||||
return;
|
||||
|
||||
HOT_RELOAD_WATCHER.Dispose();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace AIStudio.Tools.PluginSystem;
|
||||
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvailablePlugin
|
||||
public sealed class PluginMetadata(PluginBase plugin, string localPath, bool isManagedByConfigServer = false, Guid? managedConfigurationId = null) : IAvailablePlugin
|
||||
{
|
||||
#region Implementation of IPluginMetadata
|
||||
|
||||
@@ -51,6 +51,10 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvail
|
||||
#region Implementation of IAvailablePlugin
|
||||
|
||||
public string LocalPath { get; } = localPath;
|
||||
|
||||
public bool IsManagedByConfigServer { get; } = isManagedByConfigServer;
|
||||
|
||||
public Guid? ManagedConfigurationId { get; } = managedConfigurationId;
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace AIStudio.Tools.Services;
|
||||
public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentService> logger, RustService rustService) : BackgroundService
|
||||
{
|
||||
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];
|
||||
|
||||
public static bool HasValidEnterpriseSnapshot { get; private set; }
|
||||
|
||||
#if DEBUG
|
||||
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
|
||||
@@ -33,34 +35,10 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Start updating of the enterprise environment.");
|
||||
HasValidEnterpriseSnapshot = false;
|
||||
|
||||
//
|
||||
// Step 1: Handle deletions first.
|
||||
//
|
||||
List<Guid> deleteConfigIds;
|
||||
try
|
||||
{
|
||||
deleteConfigIds = await rustService.EnterpriseEnvDeleteConfigIds();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to fetch the enterprise delete configuration IDs from the Rust service.");
|
||||
await MessageBus.INSTANCE.SendMessage(null, Event.RUST_SERVICE_UNAVAILABLE, "EnterpriseEnvDeleteConfigIds failed");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var deleteId in deleteConfigIds)
|
||||
{
|
||||
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == deleteId);
|
||||
if (isPluginInUse)
|
||||
{
|
||||
logger.LogWarning("The enterprise environment configuration ID '{DeleteConfigId}' must be removed.", deleteId);
|
||||
PluginFactory.RemovePluginAsync(deleteId);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Step 2: Fetch all active configurations.
|
||||
// Step 1: Fetch all active configurations.
|
||||
//
|
||||
List<EnterpriseEnvironment> fetchedConfigs;
|
||||
try
|
||||
@@ -75,9 +53,20 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
}
|
||||
|
||||
//
|
||||
// Step 3: Determine ETags and build the next environment list.
|
||||
// Step 2: Determine ETags and build the list of reachable configurations.
|
||||
// IMPORTANT: when one config server fails, we continue with the others.
|
||||
//
|
||||
var nextEnvironments = new List<EnterpriseEnvironment>();
|
||||
var reachableEnvironments = new List<EnterpriseEnvironment>();
|
||||
var failedConfigIds = new HashSet<Guid>();
|
||||
var currentEnvironmentsById = CURRENT_ENVIRONMENTS
|
||||
.GroupBy(env => env.ConfigurationId)
|
||||
.ToDictionary(group => group.Key, group => group.Last());
|
||||
|
||||
var activeFetchedEnvironmentsById = fetchedConfigs
|
||||
.Where(config => config.IsActive)
|
||||
.GroupBy(config => config.ConfigurationId)
|
||||
.ToDictionary(group => group.Key, group => group.Last());
|
||||
|
||||
foreach (var config in fetchedConfigs)
|
||||
{
|
||||
if (!config.IsActive)
|
||||
@@ -86,72 +75,98 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
||||
continue;
|
||||
}
|
||||
|
||||
var etag = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
||||
nextEnvironments.Add(config with { ETag = etag });
|
||||
}
|
||||
|
||||
if (nextEnvironments.Count == 0)
|
||||
{
|
||||
if (CURRENT_ENVIRONMENTS.Count > 0)
|
||||
var etagResponse = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
||||
if (!etagResponse.Success)
|
||||
{
|
||||
logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs.");
|
||||
|
||||
// Remove plugins for configs that were previously active:
|
||||
foreach (var oldEnv in CURRENT_ENVIRONMENTS)
|
||||
{
|
||||
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId);
|
||||
if (isPluginInUse)
|
||||
PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId);
|
||||
}
|
||||
}
|
||||
else
|
||||
logger.LogInformation("AI Studio runs without any enterprise configurations.");
|
||||
|
||||
CURRENT_ENVIRONMENTS = [];
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Step 4: Compare with current environments and process changes.
|
||||
//
|
||||
var currentIds = CURRENT_ENVIRONMENTS.Select(e => e.ConfigurationId).ToHashSet();
|
||||
var nextIds = nextEnvironments.Select(e => e.ConfigurationId).ToHashSet();
|
||||
|
||||
// Remove plugins for configs that are no longer present:
|
||||
foreach (var oldEnv in CURRENT_ENVIRONMENTS)
|
||||
{
|
||||
if (!nextIds.Contains(oldEnv.ConfigurationId))
|
||||
{
|
||||
logger.LogInformation("Enterprise configuration '{ConfigId}' was removed.", oldEnv.ConfigurationId);
|
||||
var isPluginInUse = PluginFactory.AvailablePlugins.Any(plugin => plugin.Id == oldEnv.ConfigurationId);
|
||||
if (isPluginInUse)
|
||||
PluginFactory.RemovePluginAsync(oldEnv.ConfigurationId);
|
||||
}
|
||||
}
|
||||
|
||||
// Process new or changed configs:
|
||||
foreach (var nextEnv in nextEnvironments)
|
||||
{
|
||||
var currentEnv = CURRENT_ENVIRONMENTS.FirstOrDefault(e => e.ConfigurationId == nextEnv.ConfigurationId);
|
||||
if (currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag).
|
||||
{
|
||||
logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId);
|
||||
failedConfigIds.Add(config.ConfigurationId);
|
||||
logger.LogWarning("Failed to read enterprise config metadata for '{ConfigId}' from '{ServerUrl}': {Issue}. Keeping the current plugin state for this configuration.", config.ConfigurationId, config.ConfigurationServerUrl, etagResponse.Issue ?? "Unknown issue");
|
||||
continue;
|
||||
}
|
||||
|
||||
var isNew = !currentIds.Contains(nextEnv.ConfigurationId);
|
||||
if(isNew)
|
||||
reachableEnvironments.Add(config with { ETag = etagResponse.ETag });
|
||||
}
|
||||
|
||||
//
|
||||
// Step 3: Compare with current environments and process changes.
|
||||
// Download per configuration. A single failure must not block others.
|
||||
//
|
||||
var shouldDeferStartupDownloads = isFirstRun && !PluginFactory.IsInitialized;
|
||||
var effectiveEnvironmentsById = new Dictionary<Guid, EnterpriseEnvironment>();
|
||||
|
||||
// Process new or changed configs:
|
||||
foreach (var nextEnv in reachableEnvironments)
|
||||
{
|
||||
var hasCurrentEnvironment = currentEnvironmentsById.TryGetValue(nextEnv.ConfigurationId, out var currentEnv);
|
||||
if (hasCurrentEnvironment && currentEnv == nextEnv) // Hint: This relies on the record equality to check if anything relevant has changed (e.g. server URL or ETag).
|
||||
{
|
||||
logger.LogInformation("Enterprise configuration '{ConfigId}' has not changed. No update required.", nextEnv.ConfigurationId);
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||
continue;
|
||||
}
|
||||
|
||||
if(!hasCurrentEnvironment)
|
||||
logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
else
|
||||
logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId);
|
||||
|
||||
if (isFirstRun)
|
||||
if (shouldDeferStartupDownloads)
|
||||
{
|
||||
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv);
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||
}
|
||||
else
|
||||
await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
{
|
||||
var wasDownloadSuccessful = await PluginFactory.TryDownloadingConfigPluginAsync(nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||
if (!wasDownloadSuccessful)
|
||||
{
|
||||
failedConfigIds.Add(nextEnv.ConfigurationId);
|
||||
if (hasCurrentEnvironment)
|
||||
{
|
||||
logger.LogWarning("Failed to update enterprise configuration '{ConfigId}'. Keeping the previously active version.", nextEnv.ConfigurationId);
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = currentEnv;
|
||||
}
|
||||
else
|
||||
logger.LogWarning("Failed to download the new enterprise configuration '{ConfigId}'. Skipping activation for now.", nextEnv.ConfigurationId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||
}
|
||||
}
|
||||
|
||||
CURRENT_ENVIRONMENTS = nextEnvironments;
|
||||
// Retain configurations for all failed IDs. On cold start there might be no
|
||||
// previous in-memory snapshot yet, so we also keep the current fetched entry
|
||||
// to protect it from cleanup while the server is unreachable.
|
||||
foreach (var failedConfigId in failedConfigIds)
|
||||
{
|
||||
if (effectiveEnvironmentsById.ContainsKey(failedConfigId))
|
||||
continue;
|
||||
|
||||
if (!currentEnvironmentsById.TryGetValue(failedConfigId, out var retainedEnvironment))
|
||||
{
|
||||
if (!activeFetchedEnvironmentsById.TryGetValue(failedConfigId, out retainedEnvironment))
|
||||
continue;
|
||||
|
||||
logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Protecting it from cleanup until connectivity is restored.", failedConfigId);
|
||||
}
|
||||
else
|
||||
logger.LogWarning("Could not refresh enterprise configuration '{ConfigId}'. Keeping the previously active version.", failedConfigId);
|
||||
|
||||
effectiveEnvironmentsById[failedConfigId] = retainedEnvironment;
|
||||
}
|
||||
|
||||
var effectiveEnvironments = effectiveEnvironmentsById.Values.ToList();
|
||||
|
||||
// Cleanup is only allowed after a successful sync cycle:
|
||||
if (PluginFactory.IsInitialized && !shouldDeferStartupDownloads)
|
||||
PluginFactory.RemoveUnreferencedManagedConfigurationPlugins(effectiveEnvironmentsById.Keys.ToHashSet());
|
||||
|
||||
if (effectiveEnvironments.Count == 0)
|
||||
logger.LogInformation("AI Studio runs without any enterprise configurations.");
|
||||
|
||||
CURRENT_ENVIRONMENTS = effectiveEnvironments;
|
||||
HasValidEnterpriseSnapshot = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -36,13 +36,12 @@ public sealed partial class RustService
|
||||
var result = await this.http.GetAsync("/system/enterprise/configs");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
||||
return [];
|
||||
throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
||||
}
|
||||
|
||||
var configs = await result.Content.ReadFromJsonAsync<List<EnterpriseConfig>>(this.jsonRustSerializerOptions);
|
||||
if (configs is null)
|
||||
return [];
|
||||
throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust.");
|
||||
|
||||
var environments = new List<EnterpriseEnvironment>();
|
||||
foreach (var config in configs)
|
||||
@@ -55,35 +54,4 @@ public sealed partial class RustService
|
||||
|
||||
return environments;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads all enterprise configuration IDs that should be deleted.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Returns a list of GUIDs representing configuration IDs to remove.
|
||||
/// </returns>
|
||||
public async Task<List<Guid>> EnterpriseEnvDeleteConfigIds()
|
||||
{
|
||||
var result = await this.http.GetAsync("/system/enterprise/delete-configs");
|
||||
if (!result.IsSuccessStatusCode)
|
||||
{
|
||||
this.logger!.LogError($"Failed to query the enterprise delete configuration IDs: '{result.StatusCode}'");
|
||||
return [];
|
||||
}
|
||||
|
||||
var ids = await result.Content.ReadFromJsonAsync<List<string>>(this.jsonRustSerializerOptions);
|
||||
if (ids is null)
|
||||
return [];
|
||||
|
||||
var guids = new List<Guid>();
|
||||
foreach (var idStr in ids)
|
||||
{
|
||||
if (Guid.TryParse(idStr, out var id))
|
||||
guids.Add(id);
|
||||
else
|
||||
this.logger!.LogWarning($"Skipping invalid GUID in enterprise delete config IDs: '{idStr}'.");
|
||||
}
|
||||
|
||||
return guids;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@
|
||||
- Added an option to export all provider types (LLMs, embeddings, transcriptions) so you can use them in a configuration plugin. You'll be asked if you want to export the related API key too. API keys will be encrypted in the export. This feature only shows up when administration options are enabled.
|
||||
- Added an option in the app settings to create an encryption secret, which is required to encrypt values (for example, API keys) in configuration plugins. This feature only shows up when administration options are enabled.
|
||||
- Added support for using multiple enterprise configurations simultaneously. Enabled organizations to apply configurations based on employee affiliations, such as departments and working groups. See the enterprise configuration documentation for details.
|
||||
- Added the `DEPLOYED_USING_CONFIG_SERVER` field for configuration plugins so enterprise-managed plugins can be identified explicitly. Administrators should update their configuration plugins accordingly. See the enterprise configuration documentation for details.
|
||||
- Improved the enterprise configuration synchronization to be fail-safe on unstable or unavailable internet connections (for example, during business travel). If metadata checks or downloads fail, AI Studio keeps the current configuration plugins unchanged.
|
||||
- Improved the document analysis assistant (in beta) by hiding the export functionality by default. Enable the administration options in the app settings to show and use the export functionality. This streamlines the usage for regular users.
|
||||
- Improved the workspaces experience by using a different color for the delete button to avoid confusion.
|
||||
- Improved single-input dialogs (e.g., renaming chats) so pressing `Enter` confirmed immediately and the input field focused automatically when the dialog opened.
|
||||
- Improved the plugins page by adding an action to open the plugin source link. The action opens website URLs in an external browser, supports `mailto:` links for direct email composition.
|
||||
- Improved the system language detection for locale values such as `C` and variants like `de_DE.UTF-8`, enabling AI Studio to apply the matching UI language more reliably.
|
||||
- Fixed an issue where leftover enterprise configuration plugins could remain active after organizational assignment changes during longer absences (for example, vacation), which could lead to configuration conflicts.
|
||||
- Fixed an issue where manually saving chats in workspace manual-storage mode could appear unreliable during response streaming. The save button is now disabled while streaming to prevent partial saves.
|
||||
- Fixed a bug in the Responses API of our OpenAI provider implementation where streamed whitespace chunks were discarded. We thank Oliver Kunc `OliverKunc` for his first contribution in resolving this issue. We appreciate your help, Oliver.
|
||||
- Upgraded dependencies.
|
||||
Reference in new issue
Block a user