Added assistant plugins (#659)
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,updater, dmg) (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, nsis) (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, appimage,deb) (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, dmg) (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, nsis) (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, appimage,deb) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
nilskruthoff authored and GitHub committed 2026-04-09 10:01:24 +02:00
1 parent a573409a24
commit f6a128f2e4
100 files changed
+10146 -85

No files matched your search

@@ -0,0 +1,311 @@
@using AIStudio.Agents.AssistantAudit
@inherits MSGComponentBase
<MudDialog DefaultFocus="DefaultFocus.FirstChild">
<DialogContent>
@if (this.plugin is null)
{
<MudAlert Severity="Severity.Error" Dense="true">
@T("The assistant plugin could not be resolved for auditing.")
</MudAlert>
}
else
{
<MudStack Spacing="2">
<MudAlert Severity="Severity.Info" Dense="true">
@T("This security check uses a sample prompt preview. Empty or placeholder values in the preview are expected.")
</MudAlert>
<MudPaper Class="pa-3 border-dashed border rounded-lg">
<MudText Typo="Typo.h6">@this.plugin.Name</MudText>
<MudText Typo="Typo.body2" Class="mb-2">@this.plugin.Description</MudText>
<MudText Typo="Typo.body2">
@T("Audit provider"): <strong>@this.ProviderLabel</strong>
</MudText>
<MudText Typo="Typo.body2">
@T("Minimum required safety level"): <strong>@this.MinimumLevelLabel</strong>
</MudText>
</MudPaper>
<MudExpansionPanels MultiExpansion="true">
<MudExpansionPanel Expanded="true">
<TitleContent>
<div class="d-flex">
<MudIcon Icon="@Icons.Material.Filled.EditNote" class="mr-3" Color="Color.Primary"></MudIcon>
<MudText>@T("System Prompt")</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.plugin.RawSystemPrompt" ReadOnly="true" Variant="Variant.Outlined" Lines="8" Class="mt-2"/>
</ChildContent>
</MudExpansionPanel>
<MudExpansionPanel Expanded="false">
<TitleContent>
<div class="d-flex">
<MudIcon Icon="@Icons.Material.Filled.Preview" class="mr-3" Color="Color.Primary"></MudIcon>
<MudText>@T("User Prompt Preview")</MudText>
</div>
</TitleContent>
<ChildContent>
@{
var promptBuilder = this.plugin.HasCustomPromptBuilder;
var sortDirection = promptBuilder ? SortDirection.Ascending : SortDirection.Descending;
var badgeColor = promptBuilder ? Color.Success : Color.Error;
var fallbackBadgeColor = !promptBuilder ? Color.Success : Color.Error;
var fallbackText = promptBuilder ? T("Fallback Prompt") : T("User Prompt");
<MudTabs Centered="true" SortDirection="@sortDirection" Rounded="true" ApplyEffectsToContainer="true">
<MudTabPanel SortKey="A" Text="@T("Advanced Prompt Building")" Icon="@Icons.Material.Filled.Build" BadgeDot="@true" BadgeColor="@badgeColor" Disabled="@(!promptBuilder)">
<MudTextField T="string" Text="@this.promptPreview" ReadOnly="true" Variant="Variant.Outlined" Lines="10"/>
</MudTabPanel>
<MudTabPanel SortKey="B" Text="@fallbackText" Icon="@Icons.Material.Filled.EditNote" BadgeDot="@true" BadgeColor="@fallbackBadgeColor">
<MudTextField T="string" Text="@this.promptFallbackPreview" ReadOnly="true" Variant="Variant.Outlined" Lines="10"/>
</MudTabPanel>
</MudTabs>
}
</ChildContent>
</MudExpansionPanel>
<MudExpansionPanel KeepContentAlive="false">
<TitleContent>
<div class="d-flex">
<MudIcon Icon="@Icons.Material.Filled.AccountTree" class="mr-3" Color="Color.Primary"></MudIcon>
<MudText>@T("Components")</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTreeView T="ITreeItem" Items="@this.componentTreeItems" ReadOnly="true" Hover="true" Dense="true" Disabled="false" ExpandOnClick="true" Class="mt-3">
<ItemTemplate Context="item">
@if (item.Value is AssistantAuditTreeItem treeItem)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@item.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
@if (!string.IsNullOrWhiteSpace(treeItem.Caption))
{
if (treeItem.IsComponent)
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="justify-self: end;">
@treeItem.Caption
</MudText>
}
else
{
<MudText Typo="Typo.overline" Color="Color.Primary" Style="justify-self: end;">
@treeItem.Caption
</MudText>
}
}
</div>
</BodyContent>
</MudTreeViewItem>
}
</ItemTemplate>
</MudTreeView>
</ChildContent>
</MudExpansionPanel>
<MudExpansionPanel KeepContentAlive="false">
<TitleContent>
<div class="d-flex">
<MudIcon Icon="@Icons.Material.Filled.FolderZip" class="mr-3" Color="Color.Primary"></MudIcon>
<MudText>@T("Plugin Structure")</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTreeView T="ITreeItem" Items="@this.fileSystemTreeItems" ReadOnly="true" Hover="true" Dense="true" Disabled="false" ExpandOnClick="true" Class="mt-3">
<ItemTemplate Context="item">
@if (item.Value is AssistantAuditTreeItem treeItem)
{
<MudTreeViewItem T="ITreeItem" Icon="@treeItem.Icon" Value="@item.Value" Expanded="@item.Expanded" CanExpand="@treeItem.Expandable" Items="@item.Children">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudText Style="justify-self: start;">
@treeItem.Text
</MudText>
@if (!string.IsNullOrWhiteSpace(treeItem.Caption))
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="justify-self: end;">
@treeItem.Caption
</MudText>
}
</div>
</BodyContent>
</MudTreeViewItem>
}
</ItemTemplate>
</MudTreeView>
</ChildContent>
</MudExpansionPanel>
<MudExpansionPanel KeepContentAlive="false">
<TitleContent>
<div class="d-flex">
<MudIcon Icon="@Icons.Material.Filled.Code" class="mr-3" Color="Color.Primary"></MudIcon>
<MudText>@T("Lua Manifest")</MudText>
</div>
</TitleContent>
<ChildContent>
<MudExpansionPanels Elevation="0" Dense="true">
@foreach (var file in this.luaFiles)
{
var fileInfo = new FileInfo(Path.Combine(this.plugin.PluginPath, file.Key));
<MudExpansionPanel Expanded="false" Icon="@Icons.Material.Outlined.ArrowDropDown">
<TitleContent>
<div class="d-flex align-center justify-start">
<MudTooltip Placement="Placement.Left" Arrow="true">
<ChildContent>
<MudIconButton Icon="@Icons.Material.Outlined.Info" Size="Size.Small" Color="Color.Info" Class="mr-1"/>
</ChildContent>
<TooltipContent>
<MudPaper Class="pa-3" >
<MudStack Spacing="1">
<MudText Typo="Typo.subtitle2">@file.Key</MudText>
<MudDivider/>
<MudText Typo="Typo.body2">@T("Size"): @this.FormatFileSize(fileInfo.Length)</MudText>
<MudText Typo="Typo.body2">@T("Created"): @this.FormatFileTimestamp(fileInfo.CreationTime)</MudText>
<MudText Typo="Typo.body2">@T("Last accessed"): @this.FormatFileTimestamp(fileInfo.LastAccessTime)</MudText>
<MudText Typo="Typo.body2">@T("Last modified"): @this.FormatFileTimestamp(fileInfo.LastWriteTime)</MudText>
</MudStack>
</MudPaper>
</TooltipContent>
</MudTooltip>
<MudText Class="">@file.Key</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@file.Value" ReadOnly="true" Variant="Variant.Outlined" Lines="25" Class="mt-2" Style="font-family: monospace"/>
</ChildContent>
</MudExpansionPanel>
}
</MudExpansionPanels>
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
@if (this.audit is not null)
{
<MudStack Spacing="2" Class="mt-4">
<MudText Typo="Typo.h6">@T("Audit Result")</MudText>
@if (this.audit.Findings.Count == 0 && this.audit.Level is not AssistantAuditLevel.UNKNOWN)
{
<MudAlert Severity="Severity.Success" Variant="Variant.Filled" Dense="true" Icon="@Icons.Material.Filled.VerifiedUser">
<strong>@T("Safe")</strong><span>: @T("No security issues were found during this check.")</span>
</MudAlert>
}
else
{
<MudAlert Severity="@this.GetAuditResultSeverity()" Variant="Variant.Filled" Dense="true">
<strong>@this.audit.Level.GetName()</strong><span>: @this.audit.Summary</span>
</MudAlert>
@if (this.IsActivationBlockedBySettings)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Text" Dense="true" Icon="@Icons.Material.Filled.Block">
@T("This plugin cannot be activated because its audit result is below the required safety level and your settings block activation in this case.")
</MudAlert>
}
else if (this.RequiresActivationConfirmation)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Text" Dense="true" Icon="@Icons.Material.Filled.WarningAmber">
@T("This plugin is below the required safety level. Your settings still allow activation, but enabling it requires an extra confirmation because it may be unsafe.")
</MudAlert>
}
<MudText Typo="Typo.subtitle2">@T("Findings")</MudText>
<MudStack Spacing="2">
@foreach (var finding in this.audit.Findings)
{
var severityUi = finding.Severity switch
{
AssistantAuditLevel.UNKNOWN => (
AlertStyling: "color: rgb(12,128,223); background-color: rgba(33,150,243,0.06);",
AlertIcon: Icons.Material.Filled.QuestionMark,
ChipColor: Color.Info
),
AssistantAuditLevel.DANGEROUS => (
AlertStyling: "color: rgb(242,28,13); background-color: rgba(244,67,54,0.06);",
AlertIcon: Icons.Material.Filled.Dangerous,
ChipColor: Color.Error
),
AssistantAuditLevel.CAUTION => (
AlertStyling: "color: rgb(214,129,0); background-color: rgba(255,152,0,0.06);",
AlertIcon: Icons.Material.Filled.Warning,
ChipColor: Color.Warning
),
AssistantAuditLevel.SAFE => (
AlertStyling: "color: rgb(0,163,68); background-color: rgba(0,200,83,0.06);",
AlertIcon: Icons.Material.Filled.Verified,
ChipColor: Color.Success
),
_ => (
AlertStyling: "color: rgb(12,128,223); background-color: rgba(33,150,243,0.06);",
AlertIcon: Icons.Material.Filled.QuestionMark,
ChipColor: Color.Info
)
};
<MudCard Class="pa-1 mud-alert mud-alert-text-error" Elevation="3" Style="@severityUi.AlertStyling">
<MudCardContent>
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.FlexStart">
<MudElement HtmlTag="div" Class="mt-1 me-1">
<MudIcon Icon="@severityUi.AlertIcon" Title="@finding.SeverityText" />
</MudElement>
<MudStack Spacing="1" Style="width: 100%">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle2">@finding.Category</MudText>
<MudChip T="string" Variant="Variant.Text" Class="pt-n2" Size="Size.Small" Color="@severityUi.ChipColor">@finding.Severity.GetName()</MudChip>
</MudStack>
<MudText Typo="Typo.caption" Class="mt-n3 mb-3">@finding.Location</MudText>
<MudText Typo="Typo.body2" Class="mt-n2 mb-2">@finding.Description</MudText>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
}
</MudStack>
}
</MudStack>
}
</MudStack>
}
@if (this.isAuditing)
{
<MudCard Class="pa-1 mt-4" Elevation="3" Style="width: 100%">
<MudCardContent>
<MudStack Row="true" AlignItems="AlignItems.Start" Justify="Justify.FlexStart">
<MudElement HtmlTag="div" Class="mt-1 me-1">
<MudSkeleton SkeletonType="SkeletonType.Circle" Width="25px" Height="25px"/>
</MudElement>
<MudStack Spacing="1" Style="width: 100%">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
<MudSkeleton SkeletonType="SkeletonType.Text" Width="50%"/>
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="15%" Height="25px" Class="pt-n2" Style="border-radius: 15px"/>
</MudStack>
<MudSkeleton SkeletonType="SkeletonType.Text" Width="25%" Class="mt-n2 mb-2"/>
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Width="100%" Height="30px"/>
</MudStack>
</MudStack>
</MudCardContent>
</MudCard>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.CloseWithoutActivation" Variant="Variant.Filled">
@(this.audit is null ? T("Cancel") : T("Close"))
</MudButton>
<MudButton OnClick="@this.RunAudit" Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!this.CanRunAudit || this.justAudited)">
@T("Start Security Check")
</MudButton>
@if (this.CanEnablePlugin)
{
<MudButton OnClick="@this.EnablePlugin" Variant="Variant.Filled" Color="@this.EnableButtonColor">
@T("Enable Assistant Plugin")
</MudButton>
}
</DialogActions>
</MudDialog>
@@ -0,0 +1,478 @@
using System.Collections;
using System.Collections.Immutable;
using System.Globalization;
using System.Reflection;
using AIStudio.Agents.AssistantAudit;
using AIStudio.Components;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.PluginSystem.Assistants.DataModel;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public partial class AssistantPluginAuditDialog : MSGComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginAuditDialog).Namespace, nameof(AssistantPluginAuditDialog));
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Inject]
private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Parameter] public Guid PluginId { get; set; }
private PluginAssistants? plugin;
private PluginAssistantAudit? audit;
private string promptPreview = string.Empty;
private string promptFallbackPreview = string.Empty;
private ImmutableDictionary<string, string> luaFiles = ImmutableDictionary.Create<string, string>();
private IReadOnlyCollection<TreeItemData<ITreeItem>> componentTreeItems = [];
private IReadOnlyCollection<TreeItemData<ITreeItem>> fileSystemTreeItems = [];
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
private bool isAuditing;
private AIStudio.Settings.Provider CurrentProvider => this.SettingsManager.GetPreselectedProvider(Tools.Components.AGENT_ASSISTANT_PLUGIN_AUDIT, null, true);
private string ProviderLabel => this.CurrentProvider == AIStudio.Settings.Provider.NONE
? this.T("No provider configured")
: $"{this.CurrentProvider.InstanceName} ({this.CurrentProvider.UsedLLMProvider.ToName()})";
private DataAssistantPluginAudit AuditSettings => this.SettingsManager.ConfigurationData.AssistantPluginAudit;
private AssistantAuditLevel MinimumLevel => this.SettingsManager.ConfigurationData.AssistantPluginAudit.MinimumLevel;
private string MinimumLevelLabel => this.MinimumLevel.GetName();
private bool CanRunAudit => this.plugin is not null && this.CurrentProvider != AIStudio.Settings.Provider.NONE && !this.isAuditing;
private bool IsAuditBelowMinimum => this.audit is not null && this.audit.Level < this.MinimumLevel;
private bool IsActivationBlockedBySettings => this.audit is null || this.IsAuditBelowMinimum && this.AuditSettings.BlockActivationBelowMinimum;
private bool RequiresActivationConfirmation => this.audit is not null && this.IsAuditBelowMinimum && !this.AuditSettings.BlockActivationBelowMinimum;
private bool CanEnablePlugin => this.audit is not null && !this.isAuditing && !this.IsActivationBlockedBySettings;
private Color EnableButtonColor => this.RequiresActivationConfirmation ? Color.Warning : Color.Success;
private bool justAudited;
private const ushort BYTES_PER_KILOBYTE = 1024;
protected override async Task OnInitializedAsync()
{
var activeLanguagePlugin = await this.SettingsManager.GetActiveLanguagePlugin();
this.currentCultureInfo = CommonTools.DeriveActiveCultureOrInvariant(activeLanguagePlugin.IETFTag);
this.plugin = PluginFactory.RunningPlugins.OfType<PluginAssistants>()
.FirstOrDefault(x => x.Id == this.PluginId);
if (this.plugin is not null)
{
this.promptPreview = await this.plugin.BuildAuditPromptPreviewAsync();
this.promptFallbackPreview = this.plugin.BuildAuditPromptFallbackPreview();
this.plugin.CreateAuditComponentSummary();
this.componentTreeItems = this.CreateAuditTreeItems(this.plugin.RootComponent);
this.fileSystemTreeItems = this.CreatePluginFileSystemTreeItems(this.plugin.PluginPath);
this.luaFiles = this.plugin.ReadAllLuaFiles();
}
await base.OnInitializedAsync();
}
private async Task RunAudit()
{
if (this.plugin is null || this.isAuditing)
return;
this.isAuditing = true;
await this.InvokeAsync(this.StateHasChanged);
try
{
this.audit = await this.AssistantPluginAuditService.RunAuditAsync(this.plugin);
}
finally
{
this.isAuditing = false;
this.justAudited = true;
await this.InvokeAsync(this.StateHasChanged);
}
}
private void CloseWithoutActivation()
{
if (this.audit is null)
{
this.MudDialog.Cancel();
return;
}
this.MudDialog.Close(DialogResult.Ok(new AssistantPluginAuditDialogResult(this.audit, false)));
}
private async Task EnablePlugin()
{
if (this.audit is null)
return;
if (this.IsActivationBlockedBySettings)
return;
if (this.RequiresActivationConfirmation && !await this.ConfirmActivationBelowMinimumAsync())
return;
this.MudDialog.Close(DialogResult.Ok(new AssistantPluginAuditDialogResult(this.audit, true)));
}
private async Task<bool> ConfirmActivationBelowMinimumAsync()
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{
x => x.Message,
string.Format(
T("The assistant plugin \"{0}\" was audited with the level \"{1}\", which is below the required safety level \"{2}\". Your current settings still allow activation, but this may be unsafe. Do you really want to enable this plugin?"),
this.plugin?.Name ?? T("Unknown plugin"),
this.audit?.Level.GetName() ?? T("Unknown"),
this.MinimumLevelLabel)
},
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Potentially Dangerous Plugin"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
return dialogResult is not null && !dialogResult.Canceled;
}
private Severity GetAuditResultSeverity() => this.audit?.Level switch
{
AssistantAuditLevel.DANGEROUS => Severity.Error,
AssistantAuditLevel.CAUTION => Severity.Warning,
AssistantAuditLevel.SAFE => Severity.Success,
_ => Severity.Normal,
};
/// <summary>
/// Creates the full audit tree for the assistant component hierarchy.
/// The dialog owns this mapping because it is pure presentation logic for the audit UI.
/// </summary>
private IReadOnlyCollection<TreeItemData<ITreeItem>> CreateAuditTreeItems(IAssistantComponent? rootComponent)
{
if (rootComponent is null)
return [];
return [this.CreateComponentTreeItem(rootComponent, index: 0, depth: 0)];
}
/// <summary>
/// Maps one assistant component into a tree node and recursively appends its value, props and child components.
/// </summary>
private TreeItemData<ITreeItem> CreateComponentTreeItem(IAssistantComponent component, int index, int depth)
{
var children = new List<TreeItemData<ITreeItem>>();
if (component.Props.TryGetValue("Value", out var value))
children.Add(this.CreateValueTreeItem(TB("Value"), value, depth + 1));
if (component.Props.Count > 0)
children.Add(this.CreatePropsTreeItem(component.Props, depth + 1));
children.AddRange(component.Children.Select((child, childIndex) =>
this.CreateComponentTreeItem(child, childIndex, depth + 1)));
return new TreeItemData<ITreeItem>
{
Expanded = depth < 2,
Expandable = children.Count > 0,
Value = new AssistantAuditTreeItem
{
Text = this.GetComponentTreeItemText(component),
Caption = this.GetComponentTreeItemCaption(component, index),
Icon = component.Type.GetIcon(),
Expandable = children.Count > 0,
},
Children = children,
};
}
/// <summary>
/// Groups all props of a component under a single "Props" branch to keep the component nodes compact.
/// </summary>
private TreeItemData<ITreeItem> CreatePropsTreeItem(IReadOnlyDictionary<string, object> props, int depth)
{
var children = props
.OrderBy(prop => prop.Key, StringComparer.Ordinal)
.Select(prop => this.CreateValueTreeItem(prop.Key, prop.Value, depth + 1))
.ToList();
return new TreeItemData<ITreeItem>
{
Expanded = depth < 2,
Expandable = children.Count > 0,
Value = new AssistantAuditTreeItem
{
Text = TB("Properties"),
Caption = string.Format(TB("Count: {0}"), props.Count),
Icon = Icons.Material.Filled.Code,
Expandable = children.Count > 0,
IsComponent = false,
},
Children = children,
};
}
/// <summary>
/// Converts a scalar or structured prop value into a tree node.
/// Scalars stay on one line, while structured values recursively expose their children.
/// </summary>
private TreeItemData<ITreeItem> CreateValueTreeItem(string label, object? value, int depth)
{
var children = this.CreateValueChildren(value, depth + 1);
return new TreeItemData<ITreeItem>
{
Expanded = depth < 2,
Expandable = children.Count > 0,
Value = new AssistantAuditTreeItem
{
Text = label,
Caption = children.Count == 0 ? this.FormatScalarValue(value) : this.GetStructuredValueCaption(value),
Icon = this.GetValueIcon(value),
Expandable = children.Count > 0,
IsComponent = false,
},
Children = children,
};
}
/// <summary>
/// Recursively expands structured values for the tree.
/// Lists, dictionaries and known DTO-style assistant values become nested tree branches.
/// </summary>
private List<TreeItemData<ITreeItem>> CreateValueChildren(object? value, int depth)
{
if (value is null || IsScalarValue(value))
return [];
if (value is IDictionary dictionary)
return this.CreateDictionaryChildren(dictionary, depth);
if (value is IEnumerable enumerable and not string)
return this.CreateEnumerableChildren(enumerable, depth);
return this.CreateObjectChildren(value, depth);
}
private List<TreeItemData<ITreeItem>> CreateDictionaryChildren(IDictionary dictionary, int depth)
{
var children = new List<TreeItemData<ITreeItem>>();
foreach (DictionaryEntry entry in dictionary)
{
var keyText = entry.Key.ToString() ?? TB("Unknown key");
children.Add(this.CreateValueTreeItem(keyText, entry.Value, depth));
}
return children;
}
/// <summary>
/// Creates a tree for the plugin directory so the audit can show unexpected folders and files, while excluding irrelevant dependency folders.
/// </summary>
private IReadOnlyCollection<TreeItemData<ITreeItem>> CreatePluginFileSystemTreeItems(string pluginPath)
{
if (string.IsNullOrWhiteSpace(pluginPath) || !Directory.Exists(pluginPath))
return [];
return [this.CreateDirectoryTreeItem(pluginPath, pluginPath, depth: 0)];
}
private TreeItemData<ITreeItem> CreateDirectoryTreeItem(string directoryPath, string rootPath, int depth)
{
var childDirectories = Directory.EnumerateDirectories(directoryPath)
.OrderBy(path => path, StringComparer.Ordinal)
.Select(path => this.CreateDirectoryTreeItem(path, rootPath, depth + 1))
.ToList();
var childFiles = Directory.EnumerateFiles(directoryPath)
.OrderBy(path => path, StringComparer.Ordinal)
.Select(path => this.CreateFileTreeItem(path, depth + 1))
.ToList();
var children = new List<TreeItemData<ITreeItem>>(childDirectories.Count + childFiles.Count);
children.AddRange(childDirectories);
children.AddRange(childFiles);
var relativePath = Path.GetRelativePath(rootPath, directoryPath);
var displayName = depth == 0
? Path.GetFileName(directoryPath)
: relativePath.Split(Path.DirectorySeparatorChar).Last();
return new TreeItemData<ITreeItem>
{
Expanded = depth < 2,
Expandable = children.Count > 0,
Value = new AssistantAuditTreeItem
{
Text = string.IsNullOrWhiteSpace(displayName) ? directoryPath : displayName,
Caption = depth == 0 ? TB("Plugin root") : string.Format(TB("Items: {0}"), children.Count),
Icon = children.Count > 0 ? Icons.Material.Filled.FolderCopy : Icons.Material.Filled.Folder,
Expandable = children.Count > 0,
IsComponent = false,
},
Children = children,
};
}
private TreeItemData<ITreeItem> CreateFileTreeItem(string filePath, int depth) => new()
{
Expanded = depth < 2,
Expandable = false,
Value = new AssistantAuditTreeItem
{
Text = Path.GetFileName(filePath),
Caption = string.Empty,
Icon = GetFileIcon(filePath),
Expandable = false,
IsComponent = false,
},
};
private static string GetFileIcon(string filePath)
{
var extension = Path.GetExtension(filePath);
return extension.ToLowerInvariant() switch
{
".lua" => Icons.Material.Filled.Code,
".md" => Icons.Material.Filled.Article,
".json" => Icons.Material.Filled.DataObject,
".png" or ".jpg" or ".jpeg" or ".svg" or ".webp" => Icons.Material.Filled.Image,
_ => Icons.Material.Filled.InsertDriveFile,
};
}
private List<TreeItemData<ITreeItem>> CreateEnumerableChildren(IEnumerable enumerable, int depth)
{
var children = new List<TreeItemData<ITreeItem>>();
var index = 0;
foreach (var item in enumerable)
{
children.Add(this.CreateValueTreeItem($"[{index}]", item, depth));
index++;
}
return children;
}
/// <summary>
/// Falls back to public instance properties for simple DTO-style values such as dropdown items.
/// Getter failures are treated defensively, so the audit dialog never crashes because of a problematic property.
/// </summary>
private List<TreeItemData<ITreeItem>> CreateObjectChildren(object value, int depth)
{
var children = new List<TreeItemData<ITreeItem>>();
foreach (var property in value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!property.CanRead || property.GetIndexParameters().Length != 0)
continue;
object? propertyValue;
try
{
propertyValue = property.GetValue(value);
}
catch (Exception)
{
propertyValue = TB("Unavailable");
}
children.Add(this.CreateValueTreeItem(property.Name, propertyValue, depth));
}
return children;
}
private string GetComponentTreeItemText(IAssistantComponent component)
{
var type = component.Type.GetDisplayName();
if (component is INamedAssistantComponent named && !string.IsNullOrWhiteSpace(named.Name))
return $"{type}: {named.Name}";
return type;
}
private string GetComponentTreeItemCaption(IAssistantComponent component, int index)
{
var details = new List<string> { $"#{index + 1}" };
if (component is IStatefulAssistantComponent stateful)
details.Add(string.IsNullOrWhiteSpace(stateful.UserPrompt) ? TB("Prompt: empty") : TB("Prompt: set"));
if (component.Children.Count > 0)
details.Add(string.Format(TB("Children: {0}"), component.Children.Count));
return string.Join(" | ", details);
}
private static bool IsScalarValue(object value)
{
return value is string or bool or char or Enum
or byte or sbyte or short or ushort or int or uint or long or ulong
or float or double or decimal
or DateTime or DateTimeOffset or TimeSpan or Guid;
}
private string FormatScalarValue(object? value) => value switch
{
null => TB("null"),
string stringValue when string.IsNullOrWhiteSpace(stringValue) => TB("empty"),
string stringValue => stringValue,
bool boolValue => boolValue ? "true" : "false",
_ => Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty,
};
private string GetStructuredValueCaption(object? value) => value switch
{
null => TB("null"),
IDictionary dictionary => string.Format(TB("Entries: {0}"), dictionary.Count),
IEnumerable enumerable when value is not string => string.Format(TB("Items: {0}"),
enumerable.Cast<object?>().Count()),
_ => value.GetType().Name,
};
private string GetValueIcon(object? value) => value switch
{
null => Icons.Material.Filled.Block,
bool => Icons.Material.Outlined.ToggleOn,
string => Icons.Material.Outlined.Abc,
int => Icons.Material.Filled.Numbers,
Enum => Icons.Material.Filled.Label,
IDictionary => Icons.Material.Filled.DataObject,
IEnumerable when value is not string => Icons.Material.Filled.FormatListBulleted,
_ => Icons.Material.Filled.DataArray,
};
private string FormatFileTimestamp(DateTime timestamp) => CommonTools.FormatTimestampToGeneral(timestamp, this.currentCultureInfo);
private string FormatFileSize(long bytes)
{
if (bytes < BYTES_PER_KILOBYTE)
return string.Format(this.currentCultureInfo, TB("{0} B"), bytes);
var kilobyte = bytes / (double)BYTES_PER_KILOBYTE;
if (kilobyte < BYTES_PER_KILOBYTE)
return string.Format(this.currentCultureInfo, TB("{0:0.##} KB"), kilobyte);
var megabyte = kilobyte / BYTES_PER_KILOBYTE;
if (megabyte < BYTES_PER_KILOBYTE)
return string.Format(this.currentCultureInfo, TB("{0:0.##} MB"), megabyte);
var gigabyte = megabyte / BYTES_PER_KILOBYTE;
return string.Format(this.currentCultureInfo, TB("{0:0.##} GB"), gigabyte);
}
}
@@ -0,0 +1,5 @@
using AIStudio.Tools.PluginSystem.Assistants;
namespace AIStudio.Dialogs;
public sealed record AssistantPluginAuditDialogResult(PluginAssistantAudit? Audit, bool ActivatePlugin);