mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-02-20 11:01:37 +00:00
Enhanced enterprise config support (#666)
This commit is contained in:
parent
6e33c361dc
commit
b445600a52
10
app/MindWork AI Studio/Components/ConfigInfoRow.razor
Normal file
10
app/MindWork AI Studio/Components/ConfigInfoRow.razor
Normal file
@ -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>
|
||||||
9
app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs
Normal file
9
app/MindWork AI Studio/Components/ConfigInfoRow.razor.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
9
app/MindWork AI Studio/Components/ConfigInfoRowItem.cs
Normal file
9
app/MindWork AI Studio/Components/ConfigInfoRowItem.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace AIStudio.Components;
|
||||||
|
|
||||||
|
public sealed record ConfigInfoRowItem(
|
||||||
|
string Icon,
|
||||||
|
string Text,
|
||||||
|
string CopyValue,
|
||||||
|
string CopyTooltip,
|
||||||
|
string Style = ""
|
||||||
|
);
|
||||||
21
app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor
Normal file
21
app/MindWork AI Studio/Components/ConfigPluginInfoCard.razor
Normal file
@ -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";
|
||||||
|
}
|
||||||
15
app/MindWork AI Studio/Components/EncryptionSecretInfo.razor
Normal file
15
app/MindWork AI Studio/Components/EncryptionSecretInfo.razor
Normal file
@ -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)
|
.CheckDeferredMessages<EnterpriseEnvironment>(Event.STARTUP_ENTERPRISE_ENVIRONMENT)
|
||||||
.Where(env => env != default)
|
.Where(env => env != default)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
var failedDeferredConfigIds = new HashSet<Guid>();
|
||||||
foreach (var env in enterpriseEnvironments)
|
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:
|
// Initialize the enterprise encryption service for decrypting API keys:
|
||||||
await PluginFactory.InitializeEnterpriseEncryption(this.RustService);
|
await PluginFactory.InitializeEnterpriseEncryption(this.RustService);
|
||||||
|
|||||||
@ -64,33 +64,19 @@
|
|||||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||||
@foreach (var plug in this.configPlugins)
|
@foreach (var plug in this.configPlugins)
|
||||||
{
|
{
|
||||||
<MudPaper Outlined="true" Class="pa-3 mt-2 mb-2">
|
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Extension"
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
HeaderText="@plug.Name"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Extension" Size="Size.Small"/>
|
Items="@([
|
||||||
<MudText Typo="Typo.subtitle2">@plug.Name</MudText>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
</div>
|
$"{T("Configuration plugin ID:")} {plug.Id}",
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
plug.Id.ToString(),
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
T("Copies the configuration plugin ID to the clipboard"))
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
ConfiguredText="@T("Encryption secret: is configured")"
|
||||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||||
{
|
|
||||||
<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>
|
|
||||||
</MudCollapse>
|
</MudCollapse>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -101,97 +87,91 @@
|
|||||||
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||||
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
||||||
{
|
{
|
||||||
<MudPaper Outlined="true" Class="pa-3 mt-2 mb-2">
|
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.HourglassBottom"
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
HeaderText="@T("Waiting for the configuration plugin...")"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.HourglassBottom" Size="Size.Small"/>
|
Items="@([
|
||||||
<MudText Typo="Typo.subtitle2">@T("Waiting for the configuration plugin...")</MudText>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
</div>
|
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
env.ConfigurationId.ToString(),
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
T("Copies the config ID to the clipboard")),
|
||||||
<span>@T("Enterprise configuration ID:") @env.ConfigurationId</span>
|
|
||||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@env.ConfigurationId.ToString()/>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
</div>
|
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
env.ConfigurationServerUrl,
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
T("Copies the server URL to the clipboard"),
|
||||||
<span>@T("Configuration server:") @env.ConfigurationServerUrl</span>
|
"margin-top: 4px;")
|
||||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@env.ConfigurationServerUrl/>
|
])"/>
|
||||||
</div>
|
|
||||||
</MudPaper>
|
|
||||||
}
|
}
|
||||||
<MudText Typo="Typo.body1" Class="mt-2 mb-2">
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
ConfiguredText="@T("Encryption secret: is configured")"
|
||||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||||
{
|
|
||||||
<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>
|
|
||||||
</MudCollapse>
|
</MudCollapse>
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case true:
|
case true:
|
||||||
<MudText Typo="Typo.body1">
|
@if (this.HasAnyLoadedEnterpriseConfigurationPlugin)
|
||||||
@T("AI Studio runs with an enterprise configuration and configuration servers. The configuration plugins are active.")
|
{
|
||||||
</MudText>
|
<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">
|
<MudCollapse Expanded="@this.showEnterpriseConfigDetails">
|
||||||
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
@foreach (var env in EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Where(e => e.IsActive))
|
||||||
{
|
{
|
||||||
var matchingPlugin = this.configPlugins.FirstOrDefault(p => p.Id == env.ConfigurationId);
|
var matchingPlugin = this.FindManagedConfigurationPlugin(env.ConfigurationId);
|
||||||
<MudPaper Outlined="true" Class="pa-3 mt-2 mb-2">
|
if (matchingPlugin is null)
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
{
|
||||||
@if (matchingPlugin is not null)
|
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.HourglassBottom"
|
||||||
{
|
HeaderText="@T("Waiting for the configuration plugin...")"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.Extension" Size="Size.Small"/>
|
Items="@([
|
||||||
<MudText Typo="Typo.subtitle2">@matchingPlugin.Name</MudText>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
}
|
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||||
else
|
env.ConfigurationId.ToString(),
|
||||||
{
|
T("Copies the config ID to the clipboard")),
|
||||||
<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>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
}
|
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||||
</div>
|
env.ConfigurationServerUrl,
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
T("Copies the server URL to the clipboard"),
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
"margin-top: 4px;")
|
||||||
<span>@T("Enterprise configuration ID:") @env.ConfigurationId</span>
|
])"/>
|
||||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the config ID to the clipboard")" StringContent=@env.ConfigurationId.ToString()/>
|
continue;
|
||||||
</div>
|
}
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
<ConfigPluginInfoCard HeaderIcon="@Icons.Material.Filled.Extension"
|
||||||
<span>@T("Configuration server:") @env.ConfigurationServerUrl</span>
|
HeaderText="@matchingPlugin.Name"
|
||||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the server URL to the clipboard")" StringContent=@env.ConfigurationServerUrl/>
|
Items="@([
|
||||||
</div>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
@if (matchingPlugin is not null)
|
$"{T("Enterprise configuration ID:")} {env.ConfigurationId}",
|
||||||
{
|
env.ConfigurationId.ToString(),
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-top: 4px;">
|
T("Copies the config ID to the clipboard")),
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
|
||||||
<span>@T("Configuration plugin ID:") @matchingPlugin.Id</span>
|
new ConfigInfoRowItem(Icons.Material.Filled.ArrowRightAlt,
|
||||||
<MudCopyClipboardButton TooltipMessage="@T("Copies the configuration plugin ID to the clipboard")" StringContent=@matchingPlugin.Id.ToString()/>
|
$"{T("Configuration server:")} {env.ConfigurationServerUrl}",
|
||||||
</div>
|
env.ConfigurationServerUrl,
|
||||||
}
|
T("Copies the server URL to the clipboard"),
|
||||||
</MudPaper>
|
"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;">
|
<EncryptionSecretInfo IsConfigured="@(PluginFactory.EnterpriseEncryption?.IsAvailable is true)"
|
||||||
<MudIcon Icon="@Icons.Material.Filled.ArrowRightAlt"/>
|
ConfiguredText="@T("Encryption secret: is configured")"
|
||||||
@if (PluginFactory.EnterpriseEncryption?.IsAvailable is true)
|
NotConfiguredText="@T("Encryption secret: is not configured")"/>
|
||||||
{
|
|
||||||
<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>
|
|
||||||
</MudCollapse>
|
</MudCollapse>
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -69,7 +69,10 @@ public partial class Information : MSGComponentBase
|
|||||||
|
|
||||||
private bool showDatabaseDetails;
|
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 sealed record DatabaseDisplayInfo(string Label, string Value);
|
||||||
|
|
||||||
@ -77,6 +80,10 @@ public partial class Information : MSGComponentBase
|
|||||||
|
|
||||||
private static bool HasAnyActiveEnvironment => EnterpriseEnvironmentService.CURRENT_ENVIRONMENTS.Any(e => e.IsActive);
|
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>
|
/// <summary>
|
||||||
/// Determines whether the enterprise configuration has details that can be shown/hidden.
|
/// Determines whether the enterprise configuration has details that can be shown/hidden.
|
||||||
/// Returns true if there are details available, false otherwise.
|
/// Returns true if there are details available, false otherwise.
|
||||||
@ -130,7 +137,10 @@ public partial class Information : MSGComponentBase
|
|||||||
switch (triggeredEvent)
|
switch (triggeredEvent)
|
||||||
{
|
{
|
||||||
case Event.PLUGINS_RELOADED:
|
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);
|
await this.InvokeAsync(this.StateHasChanged);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -196,6 +206,18 @@ public partial class Information : MSGComponentBase
|
|||||||
this.showDatabaseDetails = !this.showDatabaseDetails;
|
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()
|
private async Task CopyStartupLogPath()
|
||||||
{
|
{
|
||||||
await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath);
|
await this.RustService.CopyText2Clipboard(this.Snackbar, this.logPaths.LogStartupPath);
|
||||||
|
|||||||
@ -24,6 +24,9 @@ VERSION = "1.0.0"
|
|||||||
-- The type of the plugin:
|
-- The type of the plugin:
|
||||||
TYPE = "CONFIGURATION"
|
TYPE = "CONFIGURATION"
|
||||||
|
|
||||||
|
-- True when this plugin is deployed by an enterprise configuration server:
|
||||||
|
DEPLOYED_USING_CONFIG_SERVER = false
|
||||||
|
|
||||||
-- The authors of the plugin:
|
-- The authors of the plugin:
|
||||||
AUTHORS = {"<Company Name>"}
|
AUTHORS = {"<Company Name>"}
|
||||||
|
|
||||||
|
|||||||
@ -56,43 +56,43 @@ public sealed record EmbeddingProvider(
|
|||||||
provider = NONE;
|
provider = NONE;
|
||||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead<string>(out var name))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
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;
|
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;
|
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 (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||||
{
|
{
|
||||||
if (!EnterpriseEncryption.IsEncrypted(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
|
else
|
||||||
{
|
{
|
||||||
var encryption = PluginFactory.EnterpriseEncryption;
|
var encryption = PluginFactory.EnterpriseEncryption;
|
||||||
@ -128,31 +128,31 @@ public sealed record EmbeddingProvider(
|
|||||||
name,
|
name,
|
||||||
decryptedApiKey,
|
decryptedApiKey,
|
||||||
SecretStoreType.EMBEDDING_PROVIDER));
|
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
|
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
|
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;
|
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;
|
model = default;
|
||||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -94,31 +94,31 @@ public sealed record Provider(
|
|||||||
provider = NONE;
|
provider = NONE;
|
||||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("InstanceName", out var instanceNameValue) || !instanceNameValue.TryRead<string>(out var instanceName))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,27 +127,27 @@ public sealed record Provider(
|
|||||||
{
|
{
|
||||||
if (!Enum.TryParse(hfInferenceProviderText, true, out hfInferenceProvider))
|
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;
|
hfInferenceProvider = HFInferenceProvider.NONE;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
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;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("AdditionalJsonApiParameters", out var additionalJsonApiParametersValue) || !additionalJsonApiParametersValue.TryRead<string>(out var additionalJsonApiParameters))
|
if (!table.TryGetValue("AdditionalJsonApiParameters", out var additionalJsonApiParametersValue) || !additionalJsonApiParametersValue.TryRead<string>(out var additionalJsonApiParameters))
|
||||||
{
|
{
|
||||||
// In this case, no reason exists to reject this provider, though.
|
// 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;
|
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 (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||||
{
|
{
|
||||||
if (!EnterpriseEncryption.IsEncrypted(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
|
else
|
||||||
{
|
{
|
||||||
var encryption = PluginFactory.EnterpriseEncryption;
|
var encryption = PluginFactory.EnterpriseEncryption;
|
||||||
@ -185,31 +185,31 @@ public sealed record Provider(
|
|||||||
instanceName,
|
instanceName,
|
||||||
decryptedApiKey,
|
decryptedApiKey,
|
||||||
SecretStoreType.LLM_PROVIDER));
|
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
|
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
|
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;
|
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;
|
model = default;
|
||||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,43 +56,43 @@ public sealed record TranscriptionProvider(
|
|||||||
provider = NONE;
|
provider = NONE;
|
||||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var idText) || !Guid.TryParse(idText, out var id))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Name", out var nameValue) || !nameValue.TryRead<string>(out var name))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("UsedLLMProvider", out var usedLLMProviderValue) || !usedLLMProviderValue.TryRead<string>(out var usedLLMProviderText) || !Enum.TryParse<LLMProviders>(usedLLMProviderText, true, out var usedLLMProvider))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Host", out var hostValue) || !hostValue.TryRead<string>(out var hostText) || !Enum.TryParse<Host>(hostText, true, out var host))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Hostname", out var hostnameValue) || !hostnameValue.TryRead<string>(out var hostname))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("Model", out var modelValue) || !modelValue.TryRead<LuaTable>(out var modelTable))
|
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;
|
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;
|
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 (table.TryGetValue("APIKey", out var apiKeyValue) && apiKeyValue.TryRead<string>(out var apiKeyText) && !string.IsNullOrWhiteSpace(apiKeyText))
|
||||||
{
|
{
|
||||||
if (!EnterpriseEncryption.IsEncrypted(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
|
else
|
||||||
{
|
{
|
||||||
var encryption = PluginFactory.EnterpriseEncryption;
|
var encryption = PluginFactory.EnterpriseEncryption;
|
||||||
@ -128,31 +128,31 @@ public sealed record TranscriptionProvider(
|
|||||||
name,
|
name,
|
||||||
decryptedApiKey,
|
decryptedApiKey,
|
||||||
SecretStoreType.TRANSCRIPTION_PROVIDER));
|
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
|
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
|
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;
|
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;
|
model = default;
|
||||||
if (!table.TryGetValue("Id", out var idValue) || !idValue.TryRead<string>(out var id))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!table.TryGetValue("DisplayName", out var displayNameValue) || !displayNameValue.TryRead<string>(out var displayName))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,4 +3,8 @@ namespace AIStudio.Tools.PluginSystem;
|
|||||||
public interface IAvailablePlugin : IPluginMetadata
|
public interface IAvailablePlugin : IPluginMetadata
|
||||||
{
|
{
|
||||||
public string LocalPath { get; }
|
public string LocalPath { get; }
|
||||||
|
|
||||||
|
public bool IsManagedByConfigServer { get; }
|
||||||
|
|
||||||
|
public Guid? ManagedConfigurationId { get; }
|
||||||
}
|
}
|
||||||
@ -18,6 +18,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<PluginConfigurationObject> ConfigObjects => this.configObjects;
|
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)
|
public async Task InitializeAsync(bool dryRun)
|
||||||
{
|
{
|
||||||
if(!this.TryProcessConfiguration(dryRun, out var issue))
|
if(!this.TryProcessConfiguration(dryRun, out var issue))
|
||||||
@ -69,6 +74,14 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record TemporarySecretId(string SecretId, string SecretName) : ISecretId;
|
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>
|
/// <summary>
|
||||||
/// Tries to initialize the UI text content of the plugin.
|
/// Tries to initialize the UI text content of the plugin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -79,13 +79,13 @@ public sealed record PluginConfigurationObject
|
|||||||
|
|
||||||
if (luaTableName is null)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mainTable.TryGetValue(luaTableName, out var luaValue) || !luaValue.TryRead<LuaTable>(out var luaTable))
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,7 +97,7 @@ public sealed record PluginConfigurationObject
|
|||||||
var luaObjectTableValue = luaTable[i];
|
var luaObjectTableValue = luaTable[i];
|
||||||
if (!luaObjectTableValue.TryRead<LuaTable>(out var luaObjectTable))
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,12 +151,12 @@ public sealed record PluginConfigurationObject
|
|||||||
random ??= new ThreadSafeRandom();
|
random ??= new ThreadSafeRandom();
|
||||||
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
|
configObject = configObject with { Num = (uint)random.Next(500_000, 1_000_000) };
|
||||||
storedObjects.Add((TClass)configObject);
|
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
|
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;
|
return true;
|
||||||
|
|||||||
@ -5,10 +5,10 @@ namespace AIStudio.Tools.PluginSystem;
|
|||||||
|
|
||||||
public static partial class PluginFactory
|
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))
|
if(configPlugId == Guid.Empty || string.IsNullOrWhiteSpace(configServerUrl))
|
||||||
return null;
|
return (false, null, "Configuration ID or server URL is missing.");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -18,18 +18,24 @@ public static partial class PluginFactory
|
|||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||||
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
LOG.LogError(e, "An error occurred while determining the ETag for the configuration plugin.");
|
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)
|
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.");
|
LOG.LogWarning("Plugin factory is not yet initialized. Cannot download configuration plugin.");
|
||||||
return false;
|
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})");
|
LOG.LogInformation($"Try to download configuration plugin with ID='{configPlugId}' from server='{configServerUrl}' (GET {downloadUrl})");
|
||||||
var tempDownloadFile = Path.GetTempFileName();
|
var tempDownloadFile = Path.GetTempFileName();
|
||||||
|
var stagedDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, $"{configPlugId}.staging-{Guid.NewGuid():N}");
|
||||||
|
string? backupDirectory = null;
|
||||||
|
var wasSuccessful = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await LockHotReloadAsync();
|
await LockHotReloadAsync();
|
||||||
using var httpClient = new HttpClient();
|
using var httpClient = new HttpClient();
|
||||||
var response = await httpClient.GetAsync(downloadUrl, cancellationToken);
|
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}");
|
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
LOG.LogError(e, "An error occurred while downloading or extracting the enterprise configuration plugin.");
|
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
|
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))
|
if (File.Exists(tempDownloadFile))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -85,6 +127,6 @@ public static partial class PluginFactory
|
|||||||
UnlockHotReload();
|
UnlockHotReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return wasSuccessful;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6,7 +6,7 @@ public static partial class PluginFactory
|
|||||||
|
|
||||||
public static void SetUpHotReloading()
|
public static void SetUpHotReloading()
|
||||||
{
|
{
|
||||||
if (!IS_INITIALIZED)
|
if (!IsInitialized)
|
||||||
{
|
{
|
||||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -10,7 +10,7 @@ public static partial class PluginFactory
|
|||||||
{
|
{
|
||||||
public static async Task EnsureInternalPlugins()
|
public static async Task EnsureInternalPlugins()
|
||||||
{
|
{
|
||||||
if (!IS_INITIALIZED)
|
if (!IsInitialized)
|
||||||
{
|
{
|
||||||
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -30,7 +30,7 @@ public static partial class PluginFactory
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static async Task LoadAll(CancellationToken cancellationToken = default)
|
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.");
|
LOG.LogError("PluginFactory is not initialized. Please call Setup() before using it.");
|
||||||
return;
|
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)}')");
|
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
|
var isConfigurationPluginInConfigDirectory =
|
||||||
// (the directory name under which the plugin was downloaded):
|
plugin.Type is PluginType.CONFIGURATION &&
|
||||||
if (plugin.Type is PluginType.CONFIGURATION && pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase))
|
pluginPath.StartsWith(CONFIGURATION_PLUGINS_ROOT, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var isManagedByConfigServer = false;
|
||||||
|
Guid? managedConfigurationId = null;
|
||||||
|
if (plugin is PluginConfiguration configPlugin)
|
||||||
{
|
{
|
||||||
var directoryName = Path.GetFileName(pluginPath);
|
if (configPlugin.DeployedUsingConfigServer.HasValue)
|
||||||
if (Guid.TryParse(directoryName, out var enterpriseConfigId) && enterpriseConfigId != plugin.Id)
|
isManagedByConfigServer = configPlugin.DeployedUsingConfigServer.Value;
|
||||||
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 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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,54 +1,129 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AIStudio.Tools.PluginSystem;
|
namespace AIStudio.Tools.PluginSystem;
|
||||||
|
|
||||||
public static partial class PluginFactory
|
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;
|
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:
|
// Remove the plugin from the available plugins list:
|
||||||
//
|
//
|
||||||
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
var availablePluginToRemove = AVAILABLE_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||||
if (availablePluginToRemove == null)
|
if (availablePluginToRemove != null)
|
||||||
{
|
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
||||||
LOG.LogWarning($"No plugin found with ID: {pluginId}");
|
else
|
||||||
return;
|
LOG.LogWarning("No available plugin found with ID '{PluginId}' while removing plugin. Reason: {Reason}.", pluginId, reason);
|
||||||
}
|
|
||||||
|
|
||||||
AVAILABLE_PLUGINS.Remove(availablePluginToRemove);
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Remove the plugin from the running plugins list:
|
// Remove the plugin from the running plugins list:
|
||||||
//
|
//
|
||||||
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
var runningPluginToRemove = RUNNING_PLUGINS.FirstOrDefault(p => p.Id == pluginId);
|
||||||
if (runningPluginToRemove == null)
|
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
|
else
|
||||||
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
RUNNING_PLUGINS.Remove(runningPluginToRemove);
|
||||||
|
|
||||||
//
|
//
|
||||||
// Delete the plugin directory:
|
// Delete the plugin directory:
|
||||||
//
|
//
|
||||||
var pluginDirectory = Path.Join(CONFIGURATION_PLUGINS_ROOT, availablePluginToRemove.Id.ToString());
|
DeleteConfigurationPluginDirectory(pluginId);
|
||||||
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.");
|
|
||||||
|
|
||||||
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)
|
if (startedBasePlugin is PluginLanguage languagePlugin)
|
||||||
{
|
{
|
||||||
BASE_LANGUAGE_PLUGIN = languagePlugin;
|
BaseLanguage = languagePlugin;
|
||||||
RUNNING_PLUGINS.Add(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}'");
|
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
LOG.LogError(e, $"An error occurred while starting the base language plugin: Id='{baseLanguagePluginId}'.");
|
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.
|
// When this is a language plugin, we need to set the base language plugin.
|
||||||
//
|
//
|
||||||
if (plugin is PluginLanguage languagePlugin && BASE_LANGUAGE_PLUGIN != NoPluginLanguage.INSTANCE)
|
if (plugin is PluginLanguage languagePlugin && BaseLanguage != NoPluginLanguage.INSTANCE)
|
||||||
languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN);
|
languagePlugin.SetBaseLanguage(BaseLanguage);
|
||||||
|
|
||||||
if(plugin is PluginConfiguration configPlugin)
|
if(plugin is PluginConfiguration configPlugin)
|
||||||
await configPlugin.InitializeAsync(false);
|
await configPlugin.InitializeAsync(false);
|
||||||
|
|||||||
@ -7,16 +7,16 @@ public static partial class PluginFactory
|
|||||||
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(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 readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
|
||||||
|
|
||||||
private static bool IS_INITIALIZED;
|
|
||||||
private static string DATA_DIR = string.Empty;
|
private static string DATA_DIR = string.Empty;
|
||||||
private static string PLUGINS_ROOT = string.Empty;
|
private static string PLUGINS_ROOT = string.Empty;
|
||||||
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
|
private static string INTERNAL_PLUGINS_ROOT = string.Empty;
|
||||||
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
private static string CONFIGURATION_PLUGINS_ROOT = string.Empty;
|
||||||
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
private static string HOT_RELOAD_LOCK_FILE = string.Empty;
|
||||||
private static FileSystemWatcher HOT_RELOAD_WATCHER = null!;
|
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>
|
/// <summary>
|
||||||
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
|
/// Gets the enterprise encryption instance for decrypting API keys in configuration plugins.
|
||||||
@ -47,7 +47,7 @@ public static partial class PluginFactory
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool Setup()
|
public static bool Setup()
|
||||||
{
|
{
|
||||||
if(IS_INITIALIZED)
|
if(IsInitialized)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
LOG.LogInformation("Initializing plugin factory...");
|
LOG.LogInformation("Initializing plugin factory...");
|
||||||
@ -61,14 +61,14 @@ public static partial class PluginFactory
|
|||||||
Directory.CreateDirectory(PLUGINS_ROOT);
|
Directory.CreateDirectory(PLUGINS_ROOT);
|
||||||
|
|
||||||
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
HOT_RELOAD_WATCHER = new(PLUGINS_ROOT);
|
||||||
IS_INITIALIZED = true;
|
IsInitialized = true;
|
||||||
LOG.LogInformation("Plugin factory initialized successfully.");
|
LOG.LogInformation("Plugin factory initialized successfully.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task LockHotReloadAsync()
|
private static async Task LockHotReloadAsync()
|
||||||
{
|
{
|
||||||
if (!IS_INITIALIZED)
|
if (!IsInitialized)
|
||||||
{
|
{
|
||||||
LOG.LogError("PluginFactory is not initialized.");
|
LOG.LogError("PluginFactory is not initialized.");
|
||||||
return;
|
return;
|
||||||
@ -92,7 +92,7 @@ public static partial class PluginFactory
|
|||||||
|
|
||||||
private static void UnlockHotReload()
|
private static void UnlockHotReload()
|
||||||
{
|
{
|
||||||
if (!IS_INITIALIZED)
|
if (!IsInitialized)
|
||||||
{
|
{
|
||||||
LOG.LogError("PluginFactory is not initialized.");
|
LOG.LogError("PluginFactory is not initialized.");
|
||||||
return;
|
return;
|
||||||
@ -113,7 +113,7 @@ public static partial class PluginFactory
|
|||||||
|
|
||||||
public static void Dispose()
|
public static void Dispose()
|
||||||
{
|
{
|
||||||
if(!IS_INITIALIZED)
|
if(!IsInitialized)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
HOT_RELOAD_WATCHER.Dispose();
|
HOT_RELOAD_WATCHER.Dispose();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
namespace AIStudio.Tools.PluginSystem;
|
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
|
#region Implementation of IPluginMetadata
|
||||||
|
|
||||||
@ -52,5 +52,9 @@ public sealed class PluginMetadata(PluginBase plugin, string localPath) : IAvail
|
|||||||
|
|
||||||
public string LocalPath { get; } = localPath;
|
public string LocalPath { get; } = localPath;
|
||||||
|
|
||||||
|
public bool IsManagedByConfigServer { get; } = isManagedByConfigServer;
|
||||||
|
|
||||||
|
public Guid? ManagedConfigurationId { get; } = managedConfigurationId;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
@ -6,6 +6,8 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
|||||||
{
|
{
|
||||||
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];
|
public static List<EnterpriseEnvironment> CURRENT_ENVIRONMENTS = [];
|
||||||
|
|
||||||
|
public static bool HasValidEnterpriseSnapshot { get; private set; }
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
|
private static readonly TimeSpan CHECK_INTERVAL = TimeSpan.FromMinutes(6);
|
||||||
#else
|
#else
|
||||||
@ -33,34 +35,10 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logger.LogInformation("Start updating of the enterprise environment.");
|
logger.LogInformation("Start updating of the enterprise environment.");
|
||||||
|
HasValidEnterpriseSnapshot = false;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Step 1: Handle deletions first.
|
// Step 1: Fetch all active configurations.
|
||||||
//
|
|
||||||
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.
|
|
||||||
//
|
//
|
||||||
List<EnterpriseEnvironment> fetchedConfigs;
|
List<EnterpriseEnvironment> fetchedConfigs;
|
||||||
try
|
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)
|
foreach (var config in fetchedConfigs)
|
||||||
{
|
{
|
||||||
if (!config.IsActive)
|
if (!config.IsActive)
|
||||||
@ -86,72 +75,98 @@ public sealed class EnterpriseEnvironmentService(ILogger<EnterpriseEnvironmentSe
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var etag = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
var etagResponse = await PluginFactory.DetermineConfigPluginETagAsync(config.ConfigurationId, config.ConfigurationServerUrl);
|
||||||
nextEnvironments.Add(config with { ETag = etag });
|
if (!etagResponse.Success)
|
||||||
}
|
|
||||||
|
|
||||||
if (nextEnvironments.Count == 0)
|
|
||||||
{
|
|
||||||
if (CURRENT_ENVIRONMENTS.Count > 0)
|
|
||||||
{
|
{
|
||||||
logger.LogWarning("AI Studio no longer has any enterprise configurations. Removing previously active configs.");
|
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");
|
||||||
// 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);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var isNew = !currentIds.Contains(nextEnv.ConfigurationId);
|
reachableEnvironments.Add(config with { ETag = etagResponse.ETag });
|
||||||
if(isNew)
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// 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);
|
logger.LogInformation("Detected new enterprise configuration with ID '{ConfigId}' and server URL '{ServerUrl}'.", nextEnv.ConfigurationId, nextEnv.ConfigurationServerUrl);
|
||||||
else
|
else
|
||||||
logger.LogInformation("Detected change in enterprise configuration with ID '{ConfigId}'. Server URL or ETag has changed.", nextEnv.ConfigurationId);
|
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);
|
MessageBus.INSTANCE.DeferMessage(null, Event.STARTUP_ENTERPRISE_ENVIRONMENT, nextEnv);
|
||||||
|
effectiveEnvironmentsById[nextEnv.ConfigurationId] = nextEnv;
|
||||||
|
}
|
||||||
else
|
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -36,13 +36,12 @@ public sealed partial class RustService
|
|||||||
var result = await this.http.GetAsync("/system/enterprise/configs");
|
var result = await this.http.GetAsync("/system/enterprise/configs");
|
||||||
if (!result.IsSuccessStatusCode)
|
if (!result.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
this.logger!.LogError($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'");
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var configs = await result.Content.ReadFromJsonAsync<List<EnterpriseConfig>>(this.jsonRustSerializerOptions);
|
var configs = await result.Content.ReadFromJsonAsync<List<EnterpriseConfig>>(this.jsonRustSerializerOptions);
|
||||||
if (configs is null)
|
if (configs is null)
|
||||||
return [];
|
throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust.");
|
||||||
|
|
||||||
var environments = new List<EnterpriseEnvironment>();
|
var environments = new List<EnterpriseEnvironment>();
|
||||||
foreach (var config in configs)
|
foreach (var config in configs)
|
||||||
@ -55,35 +54,4 @@ public sealed partial class RustService
|
|||||||
|
|
||||||
return environments;
|
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 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 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 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 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 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 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 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.
|
- 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 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.
|
- 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.
|
- Upgraded dependencies.
|
||||||
@ -25,8 +25,6 @@ AI Studio supports loading multiple enterprise configurations simultaneously. Th
|
|||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier).
|
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `configs` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS`: A combined format containing one or more configuration entries. Each entry consists of a configuration ID and a server URL separated by `@`. Multiple entries are separated by `;`. The format is: `id1@url1;id2@url2;id3@url3`. The configuration ID must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier).
|
||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_ids` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS`: One or more configuration IDs that should be removed, separated by `;`. The format is: `id1;id2;id3`. This is helpful if an employee moves to a different department or leaves the organization.
|
|
||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret.
|
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration. All configurations share the same encryption secret.
|
||||||
|
|
||||||
**Example:** To configure two enterprise configurations (one for the organization and one for a department):
|
**Example:** To configure two enterprise configurations (one for the organization and one for a department):
|
||||||
@ -37,14 +35,100 @@ MINDWORK_AI_STUDIO_ENTERPRISE_CONFIGS=9072b77d-ca81-40da-be6a-861da525ef7b@https
|
|||||||
|
|
||||||
**Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority.
|
**Priority:** When multiple configurations define the same setting (e.g., a provider with the same ID), the first definition wins. The order of entries in the variable determines priority. Place the organization-wide configuration first, followed by department-specific configurations if the organization should have higher priority.
|
||||||
|
|
||||||
|
### Windows GPO / PowerShell example for `configs`
|
||||||
|
|
||||||
|
If you distribute multiple GPOs, each GPO should read and write the same registry value (`configs`) and only update its own `id@url` entry. Other entries must stay untouched.
|
||||||
|
|
||||||
|
The following PowerShell example provides helper functions for appending and removing entries safely:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RegistryPath = "HKCU:\Software\github\MindWork AI Studio\Enterprise IT"
|
||||||
|
$ConfigsValueName = "configs"
|
||||||
|
|
||||||
|
function Get-ConfigEntries {
|
||||||
|
param([string]$RawValue)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($RawValue)) { return @() }
|
||||||
|
|
||||||
|
$entries = @()
|
||||||
|
foreach ($part in $RawValue.Split(';')) {
|
||||||
|
$trimmed = $part.Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($trimmed)) { continue }
|
||||||
|
|
||||||
|
$pair = $trimmed.Split('@', 2)
|
||||||
|
if ($pair.Count -ne 2) { continue }
|
||||||
|
|
||||||
|
$id = $pair[0].Trim().ToLowerInvariant()
|
||||||
|
$url = $pair[1].Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($id) -or [string]::IsNullOrWhiteSpace($url)) { continue }
|
||||||
|
|
||||||
|
$entries += [PSCustomObject]@{
|
||||||
|
Id = $id
|
||||||
|
Url = $url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-ConfigValue {
|
||||||
|
param([array]$Entries)
|
||||||
|
|
||||||
|
return ($Entries | ForEach-Object { "$($_.Id)@$($_.Url)" }) -join ';'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-EnterpriseConfigEntry {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][Guid]$ConfigId,
|
||||||
|
[Parameter(Mandatory=$true)][string]$ServerUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $RegistryPath)) {
|
||||||
|
New-Item -Path $RegistryPath -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName
|
||||||
|
$entries = Get-ConfigEntries -RawValue $raw
|
||||||
|
$normalizedId = $ConfigId.ToString().ToLowerInvariant()
|
||||||
|
$normalizedUrl = $ServerUrl.Trim()
|
||||||
|
|
||||||
|
# Replace only this one ID, keep all other entries unchanged.
|
||||||
|
$entries = @($entries | Where-Object { $_.Id -ne $normalizedId })
|
||||||
|
$entries += [PSCustomObject]@{
|
||||||
|
Id = $normalizedId
|
||||||
|
Url = $normalizedUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Remove-EnterpriseConfigEntry {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][Guid]$ConfigId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $RegistryPath)) { return }
|
||||||
|
|
||||||
|
$raw = (Get-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -ErrorAction SilentlyContinue).$ConfigsValueName
|
||||||
|
$entries = Get-ConfigEntries -RawValue $raw
|
||||||
|
$normalizedId = $ConfigId.ToString().ToLowerInvariant()
|
||||||
|
|
||||||
|
# Remove only this one ID, keep all other entries unchanged.
|
||||||
|
$updated = @($entries | Where-Object { $_.Id -ne $normalizedId })
|
||||||
|
Set-ItemProperty -Path $RegistryPath -Name $ConfigsValueName -Type String -Value (ConvertTo-ConfigValue -Entries $updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Example usage:
|
||||||
|
# Add-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b" -ServerUrl "https://intranet.example.org:30100/ai-studio/configuration"
|
||||||
|
# Remove-EnterpriseConfigEntry -ConfigId "9072b77d-ca81-40da-be6a-861da525ef7b"
|
||||||
|
```
|
||||||
|
|
||||||
### Single configuration (legacy)
|
### Single configuration (legacy)
|
||||||
|
|
||||||
The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations:
|
The following single-configuration keys and variables are still supported for backwards compatibility. AI Studio always reads both the multi-config and legacy variables and merges all found configurations into one list. If a configuration ID appears in both, the entry from the multi-config format takes priority (first occurrence wins). This means you can migrate to the new format incrementally without losing existing configurations:
|
||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person.
|
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ID`: This must be a valid [GUID](https://en.wikipedia.org/wiki/Universally_unique_identifier#Globally_unique_identifier). It uniquely identifies the configuration. You can use an ID per department, institute, or even per person.
|
||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `delete_config_id` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID`: This is a configuration ID that should be removed. This is helpful if an employee moves to a different department or leaves the organization.
|
|
||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file.
|
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_server_url` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_SERVER_URL`: An HTTP or HTTPS address using an IP address or DNS name. This is the web server from which AI Studio attempts to load the specified configuration as a ZIP file.
|
||||||
|
|
||||||
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration.
|
- Key `HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT`, value `config_encryption_secret` or variable `MINDWORK_AI_STUDIO_ENTERPRISE_CONFIG_ENCRYPTION_SECRET`: A base64-encoded 32-byte encryption key for decrypting API keys in configuration plugins. This is optional and only needed if you want to include encrypted API keys in your configuration.
|
||||||
@ -107,6 +191,16 @@ For example, if your enterprise configuration ID is `9072b77d-ca81-40da-be6a-861
|
|||||||
ID = "9072b77d-ca81-40da-be6a-861da525ef7b"
|
ID = "9072b77d-ca81-40da-be6a-861da525ef7b"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Important: Mark enterprise-managed plugins explicitly
|
||||||
|
|
||||||
|
Configuration plugins deployed by your configuration server should define:
|
||||||
|
|
||||||
|
```lua
|
||||||
|
DEPLOYED_USING_CONFIG_SERVER = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Local, manually managed configuration plugins should set this to `false`. If the field is missing, AI Studio falls back to the plugin path (`.config`) to determine whether the plugin is managed and logs a warning.
|
||||||
|
|
||||||
## Example AI Studio configuration
|
## Example AI Studio configuration
|
||||||
The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files:
|
The latest example of an AI Studio configuration via configuration plugin can always be found in the repository in the `app/MindWork AI Studio/Plugins/configuration` folder. Here are the links to the files:
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use log::{debug, info, warn};
|
use log::{debug, info, warn};
|
||||||
use rocket::{delete, get};
|
use rocket::get;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sys_locale::get_locale;
|
use sys_locale::get_locale;
|
||||||
@ -178,30 +178,6 @@ pub fn read_enterprise_env_config_id(_token: APIToken) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[delete("/system/enterprise/config/id")]
|
|
||||||
pub fn delete_enterprise_env_config_id(_token: APIToken) -> String {
|
|
||||||
//
|
|
||||||
// When we are on a Windows machine, we try to read the enterprise config from
|
|
||||||
// the Windows registry. In case we can't find the registry key, or we are on a
|
|
||||||
// macOS or Linux machine, we try to read the enterprise config from the
|
|
||||||
// environment variables.
|
|
||||||
//
|
|
||||||
// The registry key is:
|
|
||||||
// HKEY_CURRENT_USER\Software\github\MindWork AI Studio\Enterprise IT
|
|
||||||
//
|
|
||||||
// In this registry key, we expect the following values:
|
|
||||||
// - delete_config_id
|
|
||||||
//
|
|
||||||
// The environment variable is:
|
|
||||||
// MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID
|
|
||||||
//
|
|
||||||
debug!("Trying to read the enterprise environment for some config ID, which should be deleted.");
|
|
||||||
get_enterprise_configuration(
|
|
||||||
"delete_config_id",
|
|
||||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/system/enterprise/config/server")]
|
#[get("/system/enterprise/config/server")]
|
||||||
pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
|
pub fn read_enterprise_env_config_server_url(_token: APIToken) -> String {
|
||||||
//
|
//
|
||||||
@ -314,46 +290,6 @@ pub fn read_enterprise_configs(_token: APIToken) -> Json<Vec<EnterpriseConfig>>
|
|||||||
Json(configs)
|
Json(configs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns all enterprise configuration IDs that should be deleted. Supports the new
|
|
||||||
/// multi-delete format (`id1;id2;id3`) as well as the legacy single-delete variable.
|
|
||||||
#[get("/system/enterprise/delete-configs")]
|
|
||||||
pub fn read_enterprise_delete_config_ids(_token: APIToken) -> Json<Vec<String>> {
|
|
||||||
info!("Trying to read the enterprise environment for configuration IDs to delete.");
|
|
||||||
|
|
||||||
let mut ids: Vec<String> = Vec::new();
|
|
||||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
|
|
||||||
// Read the new combined format:
|
|
||||||
let combined = get_enterprise_configuration(
|
|
||||||
"delete_config_ids",
|
|
||||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_IDS",
|
|
||||||
);
|
|
||||||
|
|
||||||
if !combined.is_empty() {
|
|
||||||
for id in combined.split(';') {
|
|
||||||
let id = id.trim().to_lowercase();
|
|
||||||
if !id.is_empty() && seen.insert(id.clone()) {
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also read the legacy single-delete variable:
|
|
||||||
let delete_id = get_enterprise_configuration(
|
|
||||||
"delete_config_id",
|
|
||||||
"MINDWORK_AI_STUDIO_ENTERPRISE_DELETE_CONFIG_ID",
|
|
||||||
);
|
|
||||||
|
|
||||||
if !delete_id.is_empty() {
|
|
||||||
let id = delete_id.trim().to_lowercase();
|
|
||||||
if seen.insert(id.clone()) {
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Json(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
|
fn get_enterprise_configuration(_reg_value: &str, env_name: &str) -> String {
|
||||||
cfg_if::cfg_if! {
|
cfg_if::cfg_if! {
|
||||||
if #[cfg(target_os = "windows")] {
|
if #[cfg(target_os = "windows")] {
|
||||||
|
|||||||
@ -83,11 +83,9 @@ pub fn start_runtime_api() {
|
|||||||
crate::environment::get_config_directory,
|
crate::environment::get_config_directory,
|
||||||
crate::environment::read_user_language,
|
crate::environment::read_user_language,
|
||||||
crate::environment::read_enterprise_env_config_id,
|
crate::environment::read_enterprise_env_config_id,
|
||||||
crate::environment::delete_enterprise_env_config_id,
|
|
||||||
crate::environment::read_enterprise_env_config_server_url,
|
crate::environment::read_enterprise_env_config_server_url,
|
||||||
crate::environment::read_enterprise_env_config_encryption_secret,
|
crate::environment::read_enterprise_env_config_encryption_secret,
|
||||||
crate::environment::read_enterprise_configs,
|
crate::environment::read_enterprise_configs,
|
||||||
crate::environment::read_enterprise_delete_config_ids,
|
|
||||||
crate::file_data::extract_data,
|
crate::file_data::extract_data,
|
||||||
crate::log::get_log_paths,
|
crate::log::get_log_paths,
|
||||||
crate::log::log_event,
|
crate::log::log_event,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user