Adding providers can now be disabled using config plugins (#522)
Some checks are pending
Build and Release / Publish release (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Read metadata (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis updater) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage deb updater) (push) Blocked by required conditions

Co-authored-by: Thorsten Sommer <mail@tsommer.org>
This commit is contained in:
Peer Schütt 2025-08-09 19:29:43 +02:00 committed by GitHub
parent 6116c03f7c
commit 035412f7ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 663 additions and 219 deletions

View File

@ -6,10 +6,10 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2 mr-3" StretchItems="StretchItems.Start">
<MudText Typo="Typo.h3">
@(this.Title)
@this.Title
</MudText>
<MudIconButton Variant="Variant.Text" Icon="@Icons.Material.Filled.Settings" OnClick="() => this.OpenSettingsDialog()"/>
<MudIconButton Variant="Variant.Text" Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettingsDialog())"/>
</MudStack>
<InnerScrolling>
@ -26,13 +26,13 @@
</CascadingValue>
<MudStack Row="true" AlignItems="AlignItems.Center" StretchItems="StretchItems.Start" Class="mb-3">
<MudButton Disabled="@this.SubmitDisabled" Variant="Variant.Filled" OnClick="async () => await this.Start()" Style="@this.SubmitButtonStyle">
<MudButton Disabled="@this.SubmitDisabled" Variant="Variant.Filled" OnClick="@(async () => await this.Start())" Style="@this.SubmitButtonStyle">
@this.SubmitText
</MudButton>
@if (this.isProcessing && this.cancellationTokenSource is not null)
{
<MudTooltip Text="@TB("Stop generation")">
<MudIconButton Variant="Variant.Filled" Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="() => this.CancelStreaming()"/>
<MudIconButton Variant="Variant.Filled" Icon="@Icons.Material.Filled.Stop" Color="Color.Error" OnClick="@(async () => await this.CancelStreaming())"/>
</MudTooltip>
}
</MudStack>
@ -80,7 +80,7 @@
<MudMenu AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" StartIcon="@Icons.Material.Filled.Apps" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@TB("Send to ...")" Variant="Variant.Filled" Style="@this.GetSendToColor()" Class="rounded">
@foreach (var assistant in Enum.GetValues<Components>().Where(n => n.AllowSendTo()).OrderBy(n => n.Name().Length))
{
<MudMenuItem OnClick="() => this.SendToAssistant(assistant, new())">
<MudMenuItem OnClick="@(async () => await this.SendToAssistant(assistant, new()))">
@assistant.Name()
</MudMenuItem>
}
@ -94,14 +94,14 @@
{
case ButtonData buttonData when !string.IsNullOrWhiteSpace(buttonData.Tooltip):
<MudTooltip Text="@buttonData.Tooltip">
<MudButton Variant="Variant.Filled" Color="@buttonData.Color" Disabled="@buttonData.DisabledAction()" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="async () => await buttonData.AsyncAction()">
<MudButton Variant="Variant.Filled" Color="@buttonData.Color" Disabled="@buttonData.DisabledAction()" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="@(async () => await buttonData.AsyncAction())">
@buttonData.Text
</MudButton>
</MudTooltip>
break;
case ButtonData buttonData:
<MudButton Variant="Variant.Filled" Color="@buttonData.Color" Disabled="@buttonData.DisabledAction()" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="async () => await buttonData.AsyncAction()">
<MudButton Variant="Variant.Filled" Color="@buttonData.Color" Disabled="@buttonData.DisabledAction()" StartIcon="@GetButtonIcon(buttonData.Icon)" OnClick="@(async () => await buttonData.AsyncAction())">
@buttonData.Text
</MudButton>
break;
@ -110,7 +110,7 @@
<MudMenu AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" StartIcon="@Icons.Material.Filled.Apps" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@TB("Send to ...")" Variant="Variant.Filled" Style="@this.GetSendToColor()" Class="rounded">
@foreach (var assistant in Enum.GetValues<Components>().Where(n => n.AllowSendTo()).OrderBy(n => n.Name().Length))
{
<MudMenuItem OnClick="() => this.SendToAssistant(assistant, sendToButton)">
<MudMenuItem OnClick="@(async () => await this.SendToAssistant(assistant, sendToButton))">
@assistant.Name()
</MudMenuItem>
}
@ -121,14 +121,14 @@
@if (this.ShowCopyResult)
{
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.ContentCopy" OnClick="() => this.CopyToClipboard()">
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.ContentCopy" OnClick="@(async () => await this.CopyToClipboard())">
@TB("Copy result")
</MudButton>
}
@if (this.ShowReset)
{
<MudButton Variant="Variant.Filled" Style="@this.GetResetColor()" StartIcon="@Icons.Material.Filled.Refresh" OnClick="() => this.InnerResetForm()">
<MudButton Variant="Variant.Filled" Style="@this.GetResetColor()" StartIcon="@Icons.Material.Filled.Refresh" OnClick="@(async () => await this.InnerResetForm())">
@TB("Reset")
</MudButton>
}

View File

@ -1450,6 +1450,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T3243388657"] = "Confiden
-- Shows and hides the confidence card with information about the selected LLM provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T847071819"] = "Shows and hides the confidence card with information about the selected LLM provider."
-- This feature is managed by your organization and has therefore been disabled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONBASE::T1416426626"] = "This feature is managed by your organization and has therefore been disabled."
-- Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONMINCONFIDENCESELECTION::T2526727283"] = "Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level."

View File

@ -1 +1,23 @@
@inherits MSGComponentBase
@if (this.Body is not null)
{
@if (!this.Disabled() && this.IsLocked())
{
<MudField Label="@this.Label" Variant="@this.Variant" Underline="false" HelperText="@this.OptionHelp" Class="@this.Classes" InnerPadding="false">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.FlexStart" Wrap="Wrap.NoWrap" StretchItems="this.StretchItems">
@* MudTooltip.RootStyle is set as a workaround for issue -> https://github.com/MudBlazor/MudBlazor/issues/10882 *@
<MudTooltip Text="@TB("This feature is managed by your organization and has therefore been disabled.")" Arrow="true" Placement="Placement.Right" RootStyle="display:inline-flex;">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" Size="Size.Small" Class="mr-1"/>
</MudTooltip>
@this.Body
</MudStack>
</MudField>
}
else
{
<MudField Label="@this.Label" Variant="@this.Variant" Underline="false" HelperText="@this.OptionHelp" Class="@this.Classes" InnerPadding="false">
@this.Body
</MudField>
}
}

View File

@ -5,7 +5,7 @@ namespace AIStudio.Components;
/// <summary>
/// A base class for configuration options.
/// </summary>
public partial class ConfigurationBase : MSGComponentBase
public abstract partial class ConfigurationBase : MSGComponentBase
{
/// <summary>
/// The description of the option, i.e., the name. Should be
@ -26,7 +26,42 @@ public partial class ConfigurationBase : MSGComponentBase
[Parameter]
public Func<bool> Disabled { get; set; } = () => false;
protected const string MARGIN_CLASS = "mb-6";
/// <summary>
/// Is the option locked by a configuration plugin?
/// </summary>
[Parameter]
public Func<bool> IsLocked { get; set; } = () => false;
/// <summary>
/// Should the option be stretched to fill the available space?
/// </summary>
protected abstract bool Stretch { get; }
/// <summary>
/// The CSS class to apply to the component.
/// </summary>
protected virtual string GetClassForBase => string.Empty;
/// <summary>
/// The visual variant of the option.
/// </summary>
protected virtual Variant Variant => Variant.Text;
/// <summary>
/// The label to display for the option.
/// </summary>
protected virtual string Label => string.Empty;
private StretchItems StretchItems => this.Stretch ? StretchItems.End : StretchItems.None;
protected bool IsDisabled => this.Disabled() || this.IsLocked();
private string Classes => $"{this.GetClassForBase} {MARGIN_CLASS}";
private protected virtual RenderFragment? Body => null;
private const string MARGIN_CLASS = "mb-6";
protected static readonly Dictionary<string, object?> SPELLCHECK_ATTRIBUTES = new();
#region Overrides of ComponentBase
@ -40,6 +75,8 @@ public partial class ConfigurationBase : MSGComponentBase
#endregion
private string TB(string fallbackEN) => this.T(fallbackEN, typeof(ConfigurationBase).Namespace, nameof(ConfigurationBase));
protected async Task InformAboutChange() => await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
#region Overrides of MSGComponentBase

View File

@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components;
public abstract class ConfigurationBaseCore : ConfigurationBase
{
private protected sealed override RenderFragment Body => this.BuildRenderTree;
// Allow content to be provided by a .razor file but without
// overriding the content of the base class
protected new virtual void BuildRenderTree(RenderTreeBuilder builder)
{
}
}

View File

@ -1,3 +1,3 @@
@using AIStudio.Settings
@inherits MSGComponentBase
<ConfigurationSelect Disabled="@this.Disabled" OptionDescription="@T("Select a minimum confidence level")" SelectedValue="@this.FilteredSelectedValue" Data="@ConfigurationSelectDataFactory.GetConfidenceLevelsData(this.SettingsManager, this.RestrictToGlobalMinimumConfidence)" SelectionUpdate="@this.SelectionUpdate" OptionHelp="@T("Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.")"/>
<ConfigurationSelect IsLocked="this.IsLocked" Disabled="this.Disabled" OptionDescription="@T("Select a minimum confidence level")" SelectedValue="@this.FilteredSelectedValue" Data="@ConfigurationSelectDataFactory.GetConfidenceLevelsData(this.SettingsManager, this.RestrictToGlobalMinimumConfidence)" SelectionUpdate="@this.SelectionUpdate" OptionHelp="@T("Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.")"/>

View File

@ -18,18 +18,18 @@ public partial class ConfigurationMinConfidenceSelection : MSGComponentBase
[Parameter]
public Action<ConfidenceLevel> SelectionUpdate { get; set; } = _ => { };
/// <summary>
/// Is the selection component disabled?
/// </summary>
[Parameter]
public Func<bool> Disabled { get; set; } = () => false;
/// <summary>
/// Boolean value indicating whether the selection is restricted to a global minimum confidence level.
/// </summary>
[Parameter]
public bool RestrictToGlobalMinimumConfidence { get; set; }
[Parameter]
public Func<bool> Disabled { get; set; } = () => false;
[Parameter]
public Func<bool> IsLocked { get; set; } = () => false;
private ConfidenceLevel FilteredSelectedValue()
{
if (this.SelectedValue() is ConfidenceLevel.NONE)

View File

@ -1,4 +1,4 @@
@inherits ConfigurationBase
@inherits ConfigurationBaseCore
@typeparam TData
<MudSelectExtended
@ -7,12 +7,10 @@
MultiSelectionTextFunc="@this.GetMultiSelectionText"
SelectedValues="@this.SelectedValues()"
Strict="@true"
Disabled="@this.Disabled()"
Disabled="@this.IsDisabled"
Margin="Margin.Dense"
Label="@this.OptionDescription"
Class="@GetClass"
Variant="Variant.Outlined"
HelperText="@this.OptionHelp"
Class="rounded-lg"
Underline="false"
SelectedValuesChanged="@this.OptionChanged">
@foreach (var data in this.Data)
{

View File

@ -8,7 +8,7 @@ namespace AIStudio.Components;
/// Configuration component for selecting many values from a list.
/// </summary>
/// <typeparam name="TData">The type of the value to select.</typeparam>
public partial class ConfigurationMultiSelect<TData> : ConfigurationBase
public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
{
/// <summary>
/// The data to select from.
@ -28,6 +28,17 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBase
[Parameter]
public Action<HashSet<TData>> SelectionUpdate { get; set; } = _ => { };
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => true;
protected override Variant Variant => Variant.Outlined;
protected override string Label => this.OptionDescription;
#endregion
private async Task OptionChanged(IEnumerable<TData?>? updatedValues)
{
if(updatedValues is null)
@ -39,8 +50,6 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBase
await this.InformAboutChange();
}
private static string GetClass => $"{MARGIN_CLASS} rounded-lg";
private string GetMultiSelectionText(List<TData?>? selectedValues)
{
if(selectedValues is null || selectedValues.Count == 0)

View File

@ -1,7 +1,5 @@
@inherits ConfigurationBase
@inherits ConfigurationBaseCore
<MudField Disabled="@this.Disabled()" Label="@this.OptionDescription" Variant="Variant.Outlined" HelperText="@this.OptionHelp" Class="@MARGIN_CLASS">
<MudSwitch T="bool" Disabled="@this.Disabled()" Value="@this.State()" ValueChanged="@this.OptionChanged" Color="Color.Primary">
@(this.State() ? this.LabelOn : this.LabelOff)
</MudSwitch>
</MudField>
<MudSwitch T="bool" Disabled="@this.IsDisabled" Value="@this.State()" ValueChanged="@this.OptionChanged" Color="Color.Primary">
@(this.State() ? this.LabelOn : this.LabelOff)
</MudSwitch>

View File

@ -5,7 +5,7 @@ namespace AIStudio.Components;
/// <summary>
/// Configuration component for any boolean option.
/// </summary>
public partial class ConfigurationOption : ConfigurationBase
public partial class ConfigurationOption : ConfigurationBaseCore
{
/// <summary>
/// Text to display when the option is true.
@ -31,6 +31,19 @@ public partial class ConfigurationOption : ConfigurationBase
[Parameter]
public Action<bool> StateUpdate { get; set; } = _ => { };
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => true;
/// <inheritdoc />
protected override Variant Variant => Variant.Outlined;
/// <inheritdoc />
protected override string Label => this.OptionDescription;
#endregion
private async Task OptionChanged(bool updatedState)
{
this.StateUpdate(updatedState);

View File

@ -1,2 +1,2 @@
@inherits MSGComponentBase
<ConfigurationSelect OptionDescription="@T("Preselected provider")" Disabled="@this.Disabled" OptionHelp="@this.HelpText()" Data="@this.FilteredData()" SelectedValue="@this.SelectedValue" SelectionUpdate="@this.SelectionUpdate"/>
<ConfigurationSelect IsLocked="@this.IsLocked" OptionDescription="@T("Preselected provider")" Disabled="@(() => this.Disabled())" OptionHelp="@this.HelpText()" Data="@this.FilteredData()" SelectedValue="@this.SelectedValue" SelectionUpdate="@this.SelectionUpdate"/>

View File

@ -20,27 +20,17 @@ public partial class ConfigurationProviderSelection : MSGComponentBase
[Parameter]
public IEnumerable<ConfigurationSelectData<string>> Data { get; set; } = new List<ConfigurationSelectData<string>>();
/// <summary>
/// Is the selection component disabled?
/// </summary>
[Parameter]
public Func<bool> Disabled { get; set; } = () => false;
[Parameter]
public Func<string> HelpText { get; set; } = () => TB("Select a provider that is preselected.");
[Parameter]
public Tools.Components Component { get; set; } = Tools.Components.NONE;
#region Overrides of ComponentBase
[Parameter]
public Func<bool> Disabled { get; set; } = () => false;
protected override async Task OnParametersSetAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnParametersSetAsync();
}
#endregion
[Parameter]
public Func<bool> IsLocked { get; set; } = () => false;
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
private IEnumerable<ConfigurationSelectData<string>> FilteredData()

View File

@ -1,7 +1,7 @@
@inherits ConfigurationBase
@typeparam T
@inherits ConfigurationBaseCore
@typeparam TConfig
<MudSelect T="T" Value="@this.SelectedValue()" Strict="@true" ShrinkLabel="@true" Disabled="@this.Disabled()" Margin="Margin.Dense" Label="@this.OptionDescription" Class="@GetClass" Variant="Variant.Outlined" HelperText="@this.OptionHelp" ValueChanged="@this.OptionChanged">
<MudSelect T="TConfig" Value="@this.SelectedValue()" Strict="@true" ShrinkLabel="@true" Disabled="@this.IsDisabled" Margin="Margin.Dense" Class="rounded-lg mb-0" Underline="false" ValueChanged="@this.OptionChanged">
@foreach (var data in this.Data)
{
<MudSelectItem Value="@data.Value">

View File

@ -7,33 +7,43 @@ namespace AIStudio.Components;
/// <summary>
/// Configuration component for selecting a value from a list.
/// </summary>
/// <typeparam name="T">The type of the value to select.</typeparam>
public partial class ConfigurationSelect<T> : ConfigurationBase
/// <typeparam name="TConfig">The type of the value to select.</typeparam>
public partial class ConfigurationSelect<TConfig> : ConfigurationBaseCore
{
/// <summary>
/// The data to select from.
/// </summary>
[Parameter]
public IEnumerable<ConfigurationSelectData<T>> Data { get; set; } = [];
public IEnumerable<ConfigurationSelectData<TConfig>> Data { get; set; } = [];
/// <summary>
/// The selected value.
/// </summary>
[Parameter]
public Func<T> SelectedValue { get; set; } = () => default!;
public Func<TConfig> SelectedValue { get; set; } = () => default!;
/// <summary>
/// An action that is called when the selection changes.
/// </summary>
[Parameter]
public Action<T> SelectionUpdate { get; set; } = _ => { };
public Action<TConfig> SelectionUpdate { get; set; } = _ => { };
private async Task OptionChanged(T updatedValue)
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => true;
/// <inheritdoc />
protected override string Label => this.OptionDescription;
protected override Variant Variant => Variant.Outlined;
#endregion
private async Task OptionChanged(TConfig updatedValue)
{
this.SelectionUpdate(updatedValue);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
private static string GetClass => $"{MARGIN_CLASS} rounded-lg";
}

View File

@ -1,8 +1,6 @@
@typeparam T
@inherits ConfigurationBase
@inherits ConfigurationBaseCore
<MudField Label="@this.OptionDescription" Variant="Variant.Outlined" Class="mb-3" Disabled="@this.Disabled()">
<MudSlider T="@T" Size="Size.Medium" Value="@this.Value()" ValueChanged="@this.OptionChanged" Min="@this.Min" Max="@this.Max" Step="@this.Step" Immediate="@true" Disabled="@this.Disabled()">
@this.Value() @this.Unit
</MudSlider>
</MudField>
<MudSlider T="@T" Size="Size.Medium" Value="@this.Value()" ValueChanged="@this.OptionChanged" Min="@this.Min" Max="@this.Max" Step="@this.Step" Immediate="@true" Disabled="@this.IsDisabled" Class="mb-1">
@this.Value() @this.Unit
</MudSlider>

View File

@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ConfigurationSlider<T> : ConfigurationBase where T : struct, INumber<T>
public partial class ConfigurationSlider<T> : ConfigurationBaseCore where T : struct, INumber<T>
{
/// <summary>
/// The minimum value for the slider.
@ -42,6 +42,18 @@ public partial class ConfigurationSlider<T> : ConfigurationBase where T : struct
[Parameter]
public Action<T> ValueUpdate { get; set; } = _ => { };
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => true;
/// <inheritdoc />
protected override Variant Variant => Variant.Outlined;
protected override string Label => this.OptionDescription;
#endregion
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()

View File

@ -1,12 +1,10 @@
@inherits ConfigurationBase
@inherits ConfigurationBaseCore
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Label="@this.OptionDescription"
Disabled="@this.Disabled()"
Class="@MARGIN_CLASS"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
@ -15,5 +13,5 @@
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Immediate="@true"
Variant="Variant.Outlined"
Underline="false"
/>

View File

@ -4,7 +4,7 @@ using Timer = System.Timers.Timer;
namespace AIStudio.Components;
public partial class ConfigurationText : ConfigurationBase
public partial class ConfigurationText : ConfigurationBaseCore
{
/// <summary>
/// The text used for the textfield.
@ -43,21 +43,30 @@ public partial class ConfigurationText : ConfigurationBase
public int MaxLines { get; set; } = 12;
private string internalText = string.Empty;
private Timer timer = new(TimeSpan.FromMilliseconds(500))
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
{
AutoReset = false
};
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => true;
protected override Variant Variant => Variant.Outlined;
protected override string Label => this.OptionDescription;
#endregion
#region Overrides of ConfigurationBase
protected override async Task OnInitializedAsync()
{
this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText));
await base.OnInitializedAsync();
}
#region Overrides of ComponentBase
protected override async Task OnParametersSetAsync()
{
this.internalText = this.Text();
@ -66,8 +75,6 @@ public partial class ConfigurationText : ConfigurationBase
#endregion
#endregion
private bool AutoGrow => this.NumLines > 1;
private int GetMaxLines => this.AutoGrow ? this.MaxLines : 1;

View File

@ -0,0 +1,5 @@
@inherits ConfigurationBaseCore
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@this.Icon" Disabled="@this.IsDisabled" OnClick="@(async () => await this.ClickAsync())">
@this.Text
</MudButton>

View File

@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class LockableButton : ConfigurationBaseCore
{
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Info;
[Parameter]
public Func<Task> OnClickAsync { get; set; } = () => Task.CompletedTask;
[Parameter]
public Action OnClick { get; set; } = () => { };
[Parameter]
public string Text { get; set; } = string.Empty;
[Parameter]
public string Class { get; set; } = string.Empty;
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => false;
protected override string GetClassForBase => this.Class;
#endregion
private async Task ClickAsync()
{
if (this.IsLocked() || this.Disabled())
return;
await this.OnClickAsync();
this.OnClick();
}
}

View File

@ -13,7 +13,7 @@
<ConfigurationSelect OptionDescription="@T("Color theme")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="@T("Choose the color theme that best suits for you.")"/>
<ConfigurationOption OptionDescription="@T("Save energy?")" LabelOn="@T("Energy saving is enabled")" LabelOff="@T("Energy saving is disabled")" State="@(() => this.SettingsManager.ConfigurationData.App.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.IsSavingEnergy = updatedState)" OptionHelp="@T("When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available.")"/>
<ConfigurationOption OptionDescription="@T("Enable spellchecking?")" LabelOn="@T("Spellchecking is enabled")" LabelOff="@T("Spellchecking is disabled")" State="@(() => this.SettingsManager.ConfigurationData.App.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.EnableSpellchecking = updatedState)" OptionHelp="@T("When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections.")"/>
<ConfigurationSelect OptionDescription="@T("Check for updates")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UpdateBehavior)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UpdateBehavior = selectedValue)" OptionHelp="@T("How often should we check for app updates?")" Disabled="() => this.SettingsLocker.IsLocked<DataApp>(x => x.UpdateBehavior)"/>
<ConfigurationSelect OptionDescription="@T("Check for updates")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UpdateBehavior)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UpdateBehavior = selectedValue)" OptionHelp="@T("How often should we check for app updates?")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UpdateBehavior, out var meta) && meta.IsLocked"/>
<ConfigurationSelect OptionDescription="@T("Navigation bar behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.NavigationBehavior)" Data="@ConfigurationSelectDataFactory.GetNavBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.NavigationBehavior = selectedValue)" OptionHelp="@T("Select the desired behavior for the navigation bar.")"/>
<ConfigurationSelect OptionDescription="@T("Preview feature visibility")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreviewVisibility)" Data="@ConfigurationSelectDataFactory.GetPreviewVisibility()" SelectionUpdate="@this.UpdatePreviewFeatures" OptionHelp="@T("Do you want to show preview features in the app?")"/>

View File

@ -15,7 +15,4 @@ public abstract class SettingsPanelBase : MSGComponentBase
[Inject]
protected RustService RustService { get; init; } = null!;
[Inject]
protected SettingsLocker SettingsLocker { get; init; } = null!;
}

View File

@ -75,9 +75,7 @@
</MudText>
}
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@Icons.Material.Filled.AddRoad" Class="mt-3 mb-6" OnClick="@this.AddLLMProvider">
@T("Add Provider")
</MudButton>
<LockableButton Text="@T("Add Provider")" IsLocked="@(() => !this.SettingsManager.ConfigurationData.App.AllowUserToAddProvider)" Icon="@Icons.Material.Filled.AddRoad" OnClickAsync="@this.AddLLMProvider" Class="mt-3" />
<MudText Typo="Typo.h4" Class="mb-3">
@T("LLM Provider Confidence")

View File

@ -215,7 +215,11 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
await PluginFactory.TryDownloadingConfigPluginAsync(enterpriseEnvironment.ConfigurationId, enterpriseEnvironment.ConfigurationServerUrl);
// Load (but not start) all plugins without waiting for them:
#if DEBUG
var pluginLoadingTimeout = new CancellationTokenSource();
#else
var pluginLoadingTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
#endif
await PluginFactory.LoadAll(pluginLoadingTimeout.Token);
// Set up hot reloading for plugins:

View File

@ -65,3 +65,7 @@ CONFIG["SETTINGS"] = {}
-- Configure the update behavior:
-- Allowed values are: NO_CHECK, ONCE_STARTUP, HOURLY, DAILY, WEEKLY
-- CONFIG["SETTINGS"]["DataApp.UpdateBehavior"] = "NO_CHECK"
-- Configure the user permission to add providers:
-- Allowed values are: true, false
-- CONFIG["SETTINGS"]["DataApp.AllowUserToAddProvider"] = false

View File

@ -1452,6 +1452,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T3243388657"] = "Vertraue
-- Shows and hides the confidence card with information about the selected LLM provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T847071819"] = "Zeigt oder verbirgt die Vertrauenskarte mit Informationen über den ausgewählten LLM-Anbieter."
-- This feature is managed by your organization and has therefore been disabled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONBASE::T1416426626"] = "Diese Funktion wird von Ihrer Organisation verwaltet und wurde daher deaktiviert."
-- Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONMINCONFIDENCESELECTION::T2526727283"] = "Wählen Sie das minimale Vertrauensniveau, das alle LLM-Anbieter erfüllen müssen. So stellen Sie sicher, dass nur vertrauenswürdige Anbieter verwendet werden. Anbieter, die dieses Niveau unterschreiten, können nicht verwendet werden."

View File

@ -1452,6 +1452,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T3243388657"] = "Confiden
-- Shows and hides the confidence card with information about the selected LLM provider.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T847071819"] = "Shows and hides the confidence card with information about the selected LLM provider."
-- This feature is managed by your organization and has therefore been disabled.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONBASE::T1416426626"] = "This feature is managed by your organization and has therefore been disabled."
-- Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONMINCONFIDENCESELECTION::T2526727283"] = "Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level."

View File

@ -126,7 +126,6 @@ internal sealed class Program
builder.Services.AddSingleton<SettingsManager>();
builder.Services.AddSingleton<ThreadSafeRandom>();
builder.Services.AddSingleton<DataSourceService>();
builder.Services.AddSingleton<SettingsLocker>();
builder.Services.AddTransient<HTMLParser>();
builder.Services.AddTransient<AgentDataSourceSelection>();
builder.Services.AddTransient<AgentRetrievalContextValidation>();

View File

@ -0,0 +1,88 @@
using System.Linq.Expressions;
using AIStudio.Settings.DataModel;
namespace AIStudio.Settings;
/// <summary>
/// Represents configuration metadata for a specific class and property.
/// </summary>
/// <typeparam name="TClass">The class type that contains the configuration property.</typeparam>
/// <typeparam name="TValue">The type of the configuration property value.</typeparam>
public record ConfigMeta<TClass, TValue> : ConfigMetaBase
{
public ConfigMeta(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression)
{
this.ConfigSelection = configSelection;
this.PropertyExpression = propertyExpression;
}
/// <summary>
/// The expression to select the configuration class from the settings data.
/// </summary>
private Expression<Func<Data, TClass>> ConfigSelection { get; }
/// <summary>
/// The expression to select the property within the configuration class.
/// </summary>
private Expression<Func<TClass, TValue>> PropertyExpression { get; }
/// <summary>
/// Indicates whether the configuration is managed by a plugin and is therefore locked.
/// </summary>
public bool IsLocked { get; private set; }
/// <summary>
/// The ID of the plugin that manages this configuration. This is set when the configuration is locked.
/// </summary>
public Guid MangedByConfigPluginId { get; private set; }
/// <summary>
/// The default value for the configuration property. This is used when resetting the property to its default state.
/// </summary>
public required TValue Default { get; init; }
/// <summary>
/// Locks the configuration state, indicating that it is managed by a specific plugin.
/// </summary>
/// <param name="pluginId">The ID of the plugin that is managing this configuration.</param>
public void LockManagedState(Guid pluginId)
{
this.IsLocked = true;
this.MangedByConfigPluginId = pluginId;
}
/// <summary>
/// Resets the managed state of the configuration, allowing it to be modified again.
/// This will also reset the property to its default value.
/// </summary>
public void ResetManagedState()
{
this.IsLocked = false;
this.MangedByConfigPluginId = Guid.Empty;
this.Reset();
}
/// <summary>
/// Resets the configuration property to its default value.
/// </summary>
public void Reset()
{
var configInstance = this.ConfigSelection.Compile().Invoke(SETTINGS_MANAGER.ConfigurationData);
var memberExpression = this.PropertyExpression.GetMemberExpression();
if (memberExpression.Member is System.Reflection.PropertyInfo propertyInfo)
propertyInfo.SetValue(configInstance, this.Default);
}
/// <summary>
/// Sets the value of the configuration property to the specified value.
/// </summary>
/// <param name="value">The value to set for the configuration property.</param>
public void SetValue(TValue value)
{
var configInstance = this.ConfigSelection.Compile().Invoke(SETTINGS_MANAGER.ConfigurationData);
var memberExpression = this.PropertyExpression.GetMemberExpression();
if (memberExpression.Member is System.Reflection.PropertyInfo propertyInfo)
propertyInfo.SetValue(configInstance, value);
}
}

View File

@ -0,0 +1,6 @@
namespace AIStudio.Settings;
public abstract record ConfigMetaBase : IConfig
{
protected static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
}

View File

@ -71,7 +71,7 @@ public sealed class Data
/// </summary>
public uint NextChatTemplateNum { get; set; } = 1;
public DataApp App { get; init; } = new();
public DataApp App { get; init; } = new(x => x.App);
public DataChat Chat { get; init; } = new();

View File

@ -1,7 +1,16 @@
using System.Linq.Expressions;
namespace AIStudio.Settings.DataModel;
public sealed class DataApp
public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = null)
{
/// <summary>
/// The default constructor for the JSON deserializer.
/// </summary>
public DataApp() : this(null)
{
}
/// <summary>
/// The language behavior.
/// </summary>
@ -21,7 +30,7 @@ public sealed class DataApp
/// Should we save energy? When true, we will update content streamed
/// from the server, i.e., AI, less frequently.
/// </summary>
public bool IsSavingEnergy { get; set; }
public bool IsSavingEnergy { get; set; } = ManagedConfiguration.Register(configSelection, n => n.IsSavingEnergy, false);
/// <summary>
/// Should we enable spellchecking for all input fields?
@ -31,7 +40,7 @@ public sealed class DataApp
/// <summary>
/// If and when we should look for updates.
/// </summary>
public UpdateBehavior UpdateBehavior { get; set; } = UpdateBehavior.HOURLY;
public UpdateBehavior UpdateBehavior { get; set; } = ManagedConfiguration.Register(configSelection, n => n.UpdateBehavior, UpdateBehavior.HOURLY);
/// <summary>
/// The navigation behavior.
@ -41,7 +50,7 @@ public sealed class DataApp
/// <summary>
/// The visibility setting for previews features.
/// </summary>
public PreviewVisibility PreviewVisibility { get; set; } = PreviewVisibility.NONE;
public PreviewVisibility PreviewVisibility { get; set; } = ManagedConfiguration.Register(configSelection, n => n.PreviewVisibility, PreviewVisibility.NONE);
/// <summary>
/// The enabled preview features.
@ -58,9 +67,13 @@ public sealed class DataApp
/// </summary>
public string PreselectedProfile { get; set; } = string.Empty;
/// <summary>
/// Should we preselect a chat template for the entire app?
/// </summary>
public string PreselectedChatTemplate { get; set; } = string.Empty;
/// <summary>
/// Should the user be allowed to add providers?
/// </summary>
public bool AllowUserToAddProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddProvider, true);
}

View File

@ -33,7 +33,7 @@ public sealed class DataV4
/// </summary>
public uint NextProfileNum { get; set; } = 1;
public DataApp App { get; init; } = new();
public DataApp App { get; init; } = new(x => x.App);
public DataChat Chat { get; init; } = new();

View File

@ -0,0 +1,26 @@
using System.Linq.Expressions;
namespace AIStudio.Settings;
public static class ExpressionExtensions
{
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(typeof(ExpressionExtensions));
public static MemberExpression GetMemberExpression<TIn, TOut>(this Expression<Func<TIn, TOut>> expression)
{
switch (expression.Body)
{
// Case for value types, which are wrapped in UnaryExpression:
case UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression:
return (MemberExpression)unaryExpression.Operand;
// Case for reference types, which are directly MemberExpressions:
case MemberExpression memberExpression:
return memberExpression;
default:
LOGGER.LogError($"Expression '{expression}' is not a valid property expression.");
throw new ArgumentException($"Expression '{expression}' is not a valid property expression.", nameof(expression));
}
}
}

View File

@ -0,0 +1,3 @@
namespace AIStudio.Settings;
public interface IConfig;

View File

@ -0,0 +1,198 @@
using System.Collections.Concurrent;
using System.Linq.Expressions;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using Lua;
namespace AIStudio.Settings;
public static class ManagedConfiguration
{
private static readonly ConcurrentDictionary<string, IConfig> METADATA = new();
/// <summary>
/// Registers a configuration setting with a default value.
/// </summary>
/// <remarks>
/// When called from the JSON deserializer, the configSelection parameter will be null.
/// In this case, the method will return the default value without registering the setting.
/// </remarks>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="defaultValue">The default value to use when the setting is not configured.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>The default value.</returns>
public static TValue Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, TValue>> propertyExpression, TValue defaultValue)
{
// When called from the JSON deserializer by using the standard constructor,
// we ignore the register call and return the default value:
if(configSelection is null)
return defaultValue;
var configPath = Path(configSelection, propertyExpression);
// If the metadata already exists for this configuration path, we return the default value:
if (METADATA.ContainsKey(configPath))
return defaultValue;
METADATA[configPath] = new ConfigMeta<TClass, TValue>(configSelection, propertyExpression)
{
Default = defaultValue,
};
return defaultValue;
}
/// <summary>
/// Attempts to retrieve the configuration metadata for a given configuration selection and property expression.
/// </summary>
/// <remarks>
/// When no configuration metadata is found, it returns a NoConfig instance with the default value set to default(TValue).
/// This allows the caller to handle the absence of configuration gracefully. In such cases, the return value of the method will be false.
/// </remarks>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="configMeta">The output parameter that will hold the configuration metadata if found.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, out ConfigMeta<TClass, TValue> configMeta)
{
var configPath = Path(configSelection, propertyExpression);
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, TValue> meta)
{
configMeta = meta;
return true;
}
configMeta = new NoConfig<TClass, TValue>(configSelection, propertyExpression)
{
Default = default!,
};
return false;
}
/// <summary>
/// Attempts to process the configuration settings from a Lua table.
/// </summary>
/// <remarks>
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
/// Furthermore, it locks the managed state of the configuration metadata to the provided configuration plugin ID.
/// The setting's value is set to the configured value.
/// </remarks>
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
/// <param name="settings">The Lua table containing the settings to process.</param>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
public static bool TryProcessConfiguration<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, Guid configPluginId, LuaTable settings, bool dryRun)
{
if(!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
var (configuredValue, successful) = configMeta.Default switch
{
Enum => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredEnumValue) && configuredEnumValue.TryRead<string>(out var configuredEnumText) && Enum.TryParse(typeof(TValue), configuredEnumText, true, out var configuredEnum) ? ((TValue)configuredEnum, true) : (configMeta.Default, false),
Guid => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredGuidValue) && configuredGuidValue.TryRead<string>(out var configuredGuidText) && Guid.TryParse(configuredGuidText, out var configuredGuid) ? ((TValue)(object)configuredGuid, true) : (configMeta.Default, false),
string => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredTextValue) && configuredTextValue.TryRead<string>(out var configuredText) ? ((TValue)(object)configuredText, true) : (configMeta.Default, false),
bool => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredBoolValue) && configuredBoolValue.TryRead<bool>(out var configuredState) ? ((TValue)(object)configuredState, true) : (configMeta.Default, false),
int => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredIntValue) && configuredIntValue.TryRead<int>(out var configuredInt) ? ((TValue)(object)configuredInt, true) : (configMeta.Default, false),
double => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredDoubleValue) && configuredDoubleValue.TryRead<double>(out var configuredDouble) ? ((TValue)(object)configuredDouble, true) : (configMeta.Default, false),
float => settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredFloatValue) && configuredFloatValue.TryRead<float>(out var configuredFloat) ? ((TValue)(object)configuredFloat, true) : (configMeta.Default, false),
_ => (configMeta.Default, false),
};
if(dryRun)
return successful;
switch (successful)
{
case true:
//
// Case: the setting was configured, and we could read the value successfully.
//
configMeta.SetValue(configuredValue);
configMeta.LockManagedState(configPluginId);
break;
case false when configMeta.IsLocked && configMeta.MangedByConfigPluginId == configPluginId:
//
// Case: the setting was configured previously, but we could not read the value successfully.
// This happens when the setting was removed from the configuration plugin. We handle that
// case only when the setting was locked and managed by the same configuration plugin.
//
// The other case, when the setting was locked and managed by a different configuration plugin,
// is handled by the IsConfigurationLeftOver method, which checks if the configuration plugin
// is still available. If it is not available, it resets the managed state of the
// configuration setting, allowing it to be reconfigured by a different plugin or left unchanged.
//
configMeta.ResetManagedState();
break;
case false:
//
// Case: the setting was not configured, or we could not read the value successfully.
// We do not change the setting, and it remains at whatever value it had before.
//
break;
}
return successful;
}
/// <summary>
/// Checks if a configuration setting is left over from a configuration plugin that is no longer available.
/// If the configuration setting is locked and managed by a configuration plugin that is not available,
/// it resets the managed state of the configuration setting and returns true.
/// Otherwise, it returns false.
/// </summary>
/// <param name="configSelection">The expression to select the configuration class.</param>
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
/// <param name="availablePlugins">The collection of available plugins to check against.</param>
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
/// <typeparam name="TValue">The type of the property within the configuration class.</typeparam>
/// <returns>True if the configuration setting is left over and was reset, otherwise false.</returns>
public static bool IsConfigurationLeftOver<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression, IEnumerable<IAvailablePlugin> availablePlugins)
{
if (!TryGet(configSelection, propertyExpression, out var configMeta))
return false;
if(configMeta.MangedByConfigPluginId == Guid.Empty || !configMeta.IsLocked)
return false;
// Check if the configuration plugin ID is valid against the available plugin IDs:
var plugin = availablePlugins.FirstOrDefault(x => x.Id == configMeta.MangedByConfigPluginId);
if (plugin is null)
{
// Remove the locked state:
configMeta.ResetManagedState();
return true;
}
return false;
}
private static string Path<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression)
{
var className = typeof(TClass).Name;
var memberExpressionConfig = configSelection.GetMemberExpression();
var configName = memberExpressionConfig.Member.Name;
var memberExpressionProperty = propertyExpression.GetMemberExpression();
var propertyName = memberExpressionProperty.Member.Name;
var configPath = $"{configName}.{className}.{propertyName}";
return configPath;
}
}

View File

@ -0,0 +1,12 @@
using System.Linq.Expressions;
using AIStudio.Settings.DataModel;
namespace AIStudio.Settings;
public sealed record NoConfig<TClass, TValue> : ConfigMeta<TClass, TValue>
{
public NoConfig(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, TValue>> propertyExpression) : base(configSelection, propertyExpression)
{
}
}

View File

@ -1,78 +0,0 @@
using System.Linq.Expressions;
namespace AIStudio.Settings;
public sealed class SettingsLocker
{
private static readonly ILogger<SettingsLocker> LOGGER = Program.LOGGER_FACTORY.CreateLogger<SettingsLocker>();
private readonly Dictionary<string, Dictionary<string, Guid>> lockedProperties = new();
public void Register<T>(Expression<Func<T, object>> propertyExpression, Guid configurationPluginId)
{
var memberExpression = GetMemberExpression(propertyExpression);
var className = typeof(T).Name;
var propertyName = memberExpression.Member.Name;
if (!this.lockedProperties.ContainsKey(className))
this.lockedProperties[className] = [];
this.lockedProperties[className].TryAdd(propertyName, configurationPluginId);
}
public void Remove<T>(Expression<Func<T, object>> propertyExpression)
{
var memberExpression = GetMemberExpression(propertyExpression);
var className = typeof(T).Name;
var propertyName = memberExpression.Member.Name;
if (this.lockedProperties.TryGetValue(className, out var props))
{
if (props.Remove(propertyName))
{
// If the property was removed, check if the class has no more locked properties:
if (props.Count == 0)
this.lockedProperties.Remove(className);
}
}
}
public Guid GetConfigurationPluginId<T>(Expression<Func<T, object>> propertyExpression)
{
var memberExpression = GetMemberExpression(propertyExpression);
var className = typeof(T).Name;
var propertyName = memberExpression.Member.Name;
if (this.lockedProperties.TryGetValue(className, out var props) && props.TryGetValue(propertyName, out var configurationPluginId))
return configurationPluginId;
// No configuration plugin ID found for this property:
return Guid.Empty;
}
public bool IsLocked<T>(Expression<Func<T, object>> propertyExpression)
{
var memberExpression = GetMemberExpression(propertyExpression);
var className = typeof(T).Name;
var propertyName = memberExpression.Member.Name;
return this.lockedProperties.TryGetValue(className, out var props) && props.ContainsKey(propertyName);
}
private static MemberExpression GetMemberExpression<T>(Expression<Func<T, object>> expression)
{
switch (expression.Body)
{
// Case for value types, which are wrapped in UnaryExpression:
case UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression:
return (MemberExpression)unaryExpression.Operand;
// Case for reference types, which are directly MemberExpressions:
case MemberExpression memberExpression:
return memberExpression;
default:
LOGGER.LogError($"Expression '{expression}' is not a valid property expression.");
throw new ArgumentException($"Expression '{expression}' is not a valid property expression.", nameof(expression));
}
}
}

View File

@ -349,7 +349,7 @@ public sealed class SettingsManager
}
}
public static string ToSettingName<T>(Expression<Func<T, object>> propertyExpression)
public static string ToSettingName<TIn, TOut>(Expression<Func<TIn, TOut>> propertyExpression)
{
MemberExpression? memberExpr;
@ -363,6 +363,6 @@ public sealed class SettingsManager
throw new ArgumentException("Expression must be a property access", nameof(propertyExpression));
// Return the full name of the property, including the class name:
return $"{typeof(T).Name}.{memberExpr.Member.Name}";
return $"{typeof(TIn).Name}.{memberExpr.Member.Name}";
}
}

View File

@ -137,7 +137,7 @@ public static class SettingsMigrations
Providers = previousConfig.Providers,
NextProviderNum = previousConfig.NextProviderNum,
App = new()
App = new(x => x.App)
{
EnableSpellchecking = previousConfig.EnableSpellchecking,
IsSavingEnergy = previousConfig.IsSavingEnergy,

View File

@ -1,6 +1,5 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Lua;
@ -13,24 +12,27 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(PluginConfiguration).Namespace, nameof(PluginConfiguration));
private static readonly ILogger<PluginConfiguration> LOGGER = Program.LOGGER_FACTORY.CreateLogger<PluginConfiguration>();
private static readonly SettingsLocker SETTINGS_LOCKER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsLocker>();
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
public async Task InitializeAsync()
public async Task InitializeAsync(bool dryRun)
{
if(!this.TryProcessConfiguration(out var issue))
if(!this.TryProcessConfiguration(dryRun, out var issue))
this.pluginIssues.Add(issue);
await SETTINGS_MANAGER.StoreSettings();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
if (!dryRun)
{
await SETTINGS_MANAGER.StoreSettings();
await MessageBus.INSTANCE.SendMessage<bool>(null, Event.CONFIGURATION_CHANGED);
}
}
/// <summary>
/// Tries to initialize the UI text content of the plugin.
/// </summary>
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
/// <param name="message">The error message, when the UI text content could not be read.</param>
/// <returns>True, when the UI text content could be read successfully.</returns>
private bool TryProcessConfiguration(out string message)
private bool TryProcessConfiguration(bool dryRun, out string message)
{
// Ensure that the main CONFIG table exists and is a valid Lua table:
if (!this.state.Environment["CONFIG"].TryRead<LuaTable>(out var mainTable))
@ -40,7 +42,9 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
}
//
// ===========================================
// Configured settings
// ===========================================
//
if (!mainTable.TryGetValue("SETTINGS", out var settingsValue) || !settingsValue.TryRead<LuaTable>(out var settingsTable))
{
@ -48,11 +52,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
return false;
}
if (settingsTable.TryGetValue(SettingsManager.ToSettingName<DataApp>(x => x.UpdateBehavior), out var updateBehaviorValue) && updateBehaviorValue.TryRead<string>(out var updateBehaviorText) && Enum.TryParse<UpdateBehavior>(updateBehaviorText, true, out var updateBehavior))
{
SETTINGS_LOCKER.Register<DataApp>(x => x.UpdateBehavior, this.Id);
SETTINGS_MANAGER.ConfigurationData.App.UpdateBehavior = updateBehavior;
}
// Check for updates, and if so, how often?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.UpdateBehavior, this.Id, settingsTable, dryRun);
// Allow the user to add providers?
ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun);
//
// Configured providers

View File

@ -16,10 +16,21 @@ public static partial class PluginFactory
try
{
HOT_RELOAD_WATCHER.IncludeSubdirectories = true;
HOT_RELOAD_WATCHER.NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName;
HOT_RELOAD_WATCHER.Filter = "*.lua";
HOT_RELOAD_WATCHER.NotifyFilter = NotifyFilters.CreationTime
| NotifyFilters.DirectoryName
| NotifyFilters.FileName
| NotifyFilters.LastAccess
| NotifyFilters.LastWrite
| NotifyFilters.Size;
HOT_RELOAD_WATCHER.Changed += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Deleted += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Created += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Renamed += HotReloadEventHandler;
HOT_RELOAD_WATCHER.Error += (_, args) =>
{
LOG.LogError(args.GetException(), "Error in hot reload watcher.");
};
HOT_RELOAD_WATCHER.EnableRaisingEvents = true;
}
catch (Exception e)
@ -39,13 +50,13 @@ public static partial class PluginFactory
var changeType = args.ChangeType.ToString().ToLowerInvariant();
if (!await HOT_RELOAD_SEMAPHORE.WaitAsync(0))
{
LOG.LogInformation($"File changed ({changeType}): {args.FullPath}. Already processing another change.");
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Already processing another change.");
return;
}
try
{
LOG.LogInformation($"File changed ({changeType}): {args.FullPath}. Reloading plugins...");
LOG.LogInformation($"File changed '{args.FullPath}' (event={changeType}). Reloading plugins...");
if (File.Exists(HOT_RELOAD_LOCK_FILE))
{
LOG.LogInformation("Hot reload lock file exists. Waiting for it to be released before proceeding with the reload.");

View File

@ -1,5 +1,6 @@
using System.Text;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using Lua;
@ -120,8 +121,10 @@ public static partial class PluginFactory
}
//
// =========================================================
// Next, we have to clean up our settings. It is possible that a configuration plugin was removed.
// We have to remove the related settings as well:
// =========================================================
//
var wasConfigurationChanged = false;
@ -150,23 +153,18 @@ public static partial class PluginFactory
#pragma warning restore MWAIS0001
//
// ==========================================================
// Check all possible settings:
// ==========================================================
//
if (SETTINGS_LOCKER.GetConfigurationPluginId<DataApp>(x => x.UpdateBehavior) is var updateBehaviorPluginId && updateBehaviorPluginId != Guid.Empty)
{
var sourcePlugin = AVAILABLE_PLUGINS.FirstOrDefault(plugin => plugin.Id == updateBehaviorPluginId);
if (sourcePlugin is null)
{
// Remove the locked state:
SETTINGS_LOCKER.Remove<DataApp>(x => x.UpdateBehavior);
// Reset the setting to the default value:
SETTINGS_MANAGER.ConfigurationData.App.UpdateBehavior = UpdateBehavior.HOURLY;
// Check for updates, and if so, how often?
if(ManagedConfiguration.IsConfigurationLeftOver<DataApp, UpdateBehavior>(x => x.App, x => x.UpdateBehavior, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
LOG.LogWarning($"The configured update behavior is based on a plugin that is not available anymore. Resetting the setting to the default value: {SETTINGS_MANAGER.ConfigurationData.App.UpdateBehavior}.");
wasConfigurationChanged = true;
}
}
// Allow the user to add providers?
if(ManagedConfiguration.IsConfigurationLeftOver<DataApp, bool>(x => x.App, x => x.AllowUserToAddProvider, AVAILABLE_PLUGINS))
wasConfigurationChanged = true;
if (wasConfigurationChanged)
{
@ -225,7 +223,7 @@ public static partial class PluginFactory
case PluginType.CONFIGURATION:
var configPlug = new PluginConfiguration(isInternal, state, type);
await configPlug.InitializeAsync();
await configPlug.InitializeAsync(true);
return configPlug;
default:

View File

@ -103,7 +103,7 @@ public static partial class PluginFactory
languagePlugin.SetBaseLanguage(BASE_LANGUAGE_PLUGIN);
if(plugin is PluginConfiguration configPlugin)
await configPlugin.InitializeAsync();
await configPlugin.InitializeAsync(false);
LOG.LogInformation($"Successfully started plugin: Id='{plugin.Id}', Type='{plugin.Type}', Name='{plugin.Name}', Version='{plugin.Version}'");
return plugin;

View File

@ -6,7 +6,6 @@ public static partial class PluginFactory
{
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(PluginFactory));
private static readonly SettingsManager SETTINGS_MANAGER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsManager>();
private static readonly SettingsLocker SETTINGS_LOCKER = Program.SERVICE_PROVIDER.GetRequiredService<SettingsLocker>();
private static bool IS_INITIALIZED;
private static string DATA_DIR = string.Empty;

View File

@ -1,3 +1,5 @@
# v0.9.50, build 225 (2025-07-xx xx:xx UTC)
- Added an option for chat templates to predefine a user input.
- Added the ability to create chat templates from existing chats.
- Added an enterprise IT configuration option to prevent manual addition of LLM providers in managed environments.
- Improved hot reloading on Unix-like systems when entire plugins were added or removed.