added a stepper to interactively show all the processes from validating and installing to automatic security audits

This commit is contained in:
nilsk 2026-07-02 00:13:16 +02:00
parent 52a1657c0a
commit 386ac8f02a
No known key found for this signature in database
GPG Key ID: A5C0151B4DDB172C
3 changed files with 566 additions and 88 deletions

View File

@ -1,4 +1,5 @@
@attribute [Route(Routes.ASSISTANT_META_ASSISTANT)] @attribute [Route(Routes.ASSISTANT_META_ASSISTANT)]
@using AIStudio.Agents.AssistantAudit
@using AIStudio.Tools.PluginSystem.Assistants.DataModel @using AIStudio.Tools.PluginSystem.Assistants.DataModel
@inherits AssistantBaseCore<AIStudio.Dialogs.Settings.NoSettingsPanel> @inherits AssistantBaseCore<AIStudio.Dialogs.Settings.NoSettingsPanel>
@ -38,7 +39,7 @@
</MudExpansionPanels> </MudExpansionPanels>
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/> <ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
<MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.highPerformanceLLMInfo</MudAlert> <MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.HighPerformanceLLMInfo</MudAlert>
} }
else else
{ {
@ -71,11 +72,188 @@ else
</MudStack> </MudStack>
<ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/> <ProviderSelection @bind-ProviderSettings="@this.ProviderSettings" ValidateProvider="@this.ValidatingProvider"/>
<MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.highPerformanceLLMInfo</MudAlert> <MudAlert Severity="Severity.Info" Square="true" Class="mb-2 mt-n1" Style="width: max-content">@this.HighPerformanceLLMInfo</MudAlert>
} }
@code { @code {
private protected override RenderFragment? AfterSubmitContent => @<MudCard> private protected override RenderFragment? BelowSubmitContent => this.step is BuilderStep.DONE && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant)
? @<MudStack Spacing="3" Class="mb-3">
<MudExpansionPanels Dense="@true" Elevation="0" Class="rounded">
<MudExpansionPanel Dense="@true" Class="border-solid border rounded pt-n4" Style="border-color: #BDBDBD">
<TitleContent>
<div class="d-flex align-center">
<MudIcon Icon="@Icons.Material.Filled.Code" Class="mr-3" Color="Color.Primary"/>
<MudText Typo="Typo.button">
@T("Generated Lua plugin")
</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.generatedLuaAssistant" ReadOnly="true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
<MudStepper @bind-ActiveIndex="@this.stepperIndex" CompletedStepColor="Color.Primary" CurrentStepColor="Color.Primary" ErrorStepColor="Color.Error" NonLinear="@false" ShowResetButton="@false" Class="mb-3">
<ChildContent>
<MudStep Title="@T("Validate plugin")" Completed="@this.PluginCheckCompleted" HasError="@this.IsInstallStepFailed(BuilderInstallStep.CHECK_PLUGIN)">
<MudStack Spacing="2" Class="mt-2">
@if (this.isCheckingPlugin)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="@true"/>
<MudText Typo="Typo.body2">@T("Validating the generated assistant...")</MudText>
}
else if (this.IsInstallStepFailed(BuilderInstallStep.CHECK_PLUGIN))
{
<MudAlert Severity="Severity.Error" Dense="@true">
@T("The generated assistant could not be checked.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
else if (this.PluginCheckCompleted)
{
<MudAlert Severity="Severity.Info" Dense="@true" Icon="@Icons.Material.Filled.CheckCircle">
@string.Format(T("The generated assistant \"{0}\" is valid and runnable."), this.pluginCheckResult?.PluginName ?? T("Unknown assistant"))
</MudAlert>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" Disabled="@(!this.CanRunPluginCheck)" OnClick="@(async () => await this.CheckGeneratedAssistantAsync())">
@T("Validate generated assistant")
</MudButton>
}
</MudStack>
</MudStep>
<MudStep Title="@T("Install assistant")" Completed="@this.PluginInstallCompleted" HasError="@this.IsInstallStepFailed(BuilderInstallStep.INSTALL_ASSISTANT)">
<MudStack Spacing="2" Class="mt-2">
@if (this.isInstallingPlugin)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="@true"/>
<MudText Typo="Typo.body2">@T("Installing the assistant...")</MudText>
}
else if (this.IsInstallStepFailed(BuilderInstallStep.INSTALL_ASSISTANT))
{
<MudAlert Severity="Severity.Error" Dense="@true">
@T("The assistant could not be installed.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
else if (this.PluginInstallCompleted)
{
<MudAlert Severity="Severity.Info" Dense="@true" Icon="@Icons.Material.Filled.Extension">
@(this.pluginInstallResult?.ReplacedExisting is true
? string.Format(T("The assistant \"{0}\" was updated."), this.pluginInstallResult?.PluginName ?? T("Unknown assistant"))
: string.Format(T("The assistant \"{0}\" was installed."), this.pluginInstallResult?.PluginName ?? T("Unknown assistant")))
</MudAlert>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Extension" Disabled="@(!this.CanInstallPlugin)" OnClick="@(async () => await this.InstallGeneratedAssistantAsync())">
@T("Install assistant")
</MudButton>
}
</MudStack>
</MudStep>
<MudStep Title="@T("Security audit")" Completed="@(!this.AuditRequiredForActivation || this.AuditCompleted)" HasError="@this.IsInstallStepFailed(BuilderInstallStep.SECURITY_CHECK)">
<MudStack Spacing="2" Class="mt-2">
@if (this.isAuditingPlugin)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="@true"/>
<MudText Typo="Typo.body2">@T("Auditing assistants safety...")</MudText>
}
else if (this.IsInstallStepFailed(BuilderInstallStep.SECURITY_CHECK))
{
<MudAlert Severity="Severity.Error" Dense="@true">
@T("The security audit could not be completed.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
else if (this.AuditCompleted)
{
<MudAlert Severity="@this.AuditSeverity" Dense="@true" Icon="@this.pluginAudit!.Level.GetIcon()">
<strong>@this.pluginAudit.Level.GetName()</strong><span>: @this.pluginAudit.Summary</span>
</MudAlert>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Security" Disabled="@(!this.CanRunAudit)" OnClick="@(async () => await this.RunSecurityCheckAsync())">
@T("Start security audit")
</MudButton>
}
</MudStack>
</MudStep>
<MudStep Title="@T("Enable assistant")" Completed="@this.EnableCompleted" HasError="@this.IsInstallStepFailed(BuilderInstallStep.ENABLE_ASSISTANT)">
<MudStack Spacing="2" Class="mt-2">
@if (this.isEnablingPlugin)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="@true"/>
<MudText Typo="Typo.body2">@T("Enabling the assistant...")</MudText>
}
else if (this.IsInstallStepFailed(BuilderInstallStep.ENABLE_ASSISTANT))
{
<MudAlert Severity="Severity.Error" Dense="@true">
@T("The assistant cannot be enabled.")
@if (!string.IsNullOrWhiteSpace(this.installFlowIssue))
{
<span> @string.Format(T("Issue: {0}"), this.installFlowIssue)</span>
}
</MudAlert>
}
else if (this.EnableCompleted)
{
<MudAlert Severity="Severity.Info" Dense="@true" Icon="@Icons.Material.Filled.ToggleOn">
@T("The assistant is enabled.")
</MudAlert>
}
else
{
@if (this.RequiresActivationConfirmation)
{
<MudAlert Severity="Severity.Warning" Dense="@true" Icon="@Icons.Material.Filled.WarningAmber">
@T("The security check is below your required level. Your settings allow activation after confirmation.")
</MudAlert>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.ToggleOn" Disabled="@(!this.CanEnableAssistant)" OnClick="@(async () => await this.EnableInstalledAssistantAsync())">
@T("Enable assistant")
</MudButton>
}
</MudStack>
</MudStep>
<MudStep Title="@T("Open assistant")">
<MudStack Spacing="2" Class="mt-2">
@if (this.CanOpenAssistant)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.OpenInNew" OnClick="@this.OpenInstalledAssistant">
@T("Open assistant")
</MudButton>
}
else
{
<MudText Typo="Typo.body2">@T("Enable the assistant before opening it.")</MudText>
}
</MudStack>
</MudStep>
</ChildContent>
<ActionContent Context="_">
</ActionContent>
</MudStepper>
</MudStack>
: null;
private protected override RenderFragment AfterSubmitContent => @<MudCard>
<MudCardContent> <MudCardContent>
<MudSkeleton /> <MudSkeleton />
<MudSkeleton Animation="Animation.False" /> <MudSkeleton Animation="Animation.False" />

View File

@ -4,9 +4,11 @@ using System.Reflection;
// ReSharper restore RedundantUsingDirective // ReSharper restore RedundantUsingDirective
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using AIStudio.Chat; using AIStudio.Agents.AssistantAudit;
using AIStudio.Dialogs; using AIStudio.Dialogs;
using AIStudio.Dialogs.Settings; using AIStudio.Dialogs.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.PluginSystem.Assistants.DataModel; using AIStudio.Tools.PluginSystem.Assistants.DataModel;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@ -22,6 +24,9 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
[Inject] [Inject]
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!; private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
[Inject]
private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!;
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(AssistantBuilder)); private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(AssistantBuilder));
private static readonly JsonSerializerOptions UNTRUSTED_PROMPT_JSON_OPTIONS = new() private static readonly JsonSerializerOptions UNTRUSTED_PROMPT_JSON_OPTIONS = new()
{ {
@ -63,15 +68,12 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
BuilderStep.DONE => this.GenerateLuaAssistant, BuilderStep.DONE => this.GenerateLuaAssistant,
_ => this.GenerateAssistantSpec, _ => this.GenerateAssistantSpec,
}; };
protected override bool SubmitDisabled => this.isAgentRunning || this.isInstallingPlugin; protected override bool SubmitDisabled => this.isAgentRunning || this.IsInstallFlowRunning;
protected override bool ShowResult => this.step is BuilderStep.DONE; protected override bool ShowResult => false;
protected override bool ShowEntireChatThread => this.step is BuilderStep.DONE; protected override bool ShowEntireChatThread => false;
protected override bool AllowProfiles => false; protected override bool AllowProfiles => false;
protected override bool ShowProfileSelection => false; protected override bool ShowProfileSelection => false;
protected override bool ShowCopyResult => this.step is BuilderStep.DONE; protected override bool ShowCopyResult => this.step is BuilderStep.DONE;
protected override IReadOnlyList<IButtonData> FooterButtons => this.step is BuilderStep.DONE
? [new ButtonData(T("Install assistant"), Icons.Material.Filled.Extension, Color.Primary, T("Install this generated assistant as a plugin."), this.InstallPluginAsync, () => this.isAgentRunning || this.isInstallingPlugin || string.IsNullOrWhiteSpace(this.generatedLuaAssistant))]
: [];
protected override bool HasSettingsPanel => false; protected override bool HasSettingsPanel => false;
protected override Func<string> Result2Copy => () => !string.IsNullOrWhiteSpace(this.generatedLuaAssistant) protected override Func<string> Result2Copy => () => !string.IsNullOrWhiteSpace(this.generatedLuaAssistant)
@ -80,7 +82,10 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
private BuilderStep step = BuilderStep.DESCRIBE; private BuilderStep step = BuilderStep.DESCRIBE;
private bool isAgentRunning; private bool isAgentRunning;
private bool isCheckingPlugin;
private bool isInstallingPlugin; private bool isInstallingPlugin;
private bool isAuditingPlugin;
private bool isEnablingPlugin;
private string assistantDescription = string.Empty; private string assistantDescription = string.Empty;
private AssistantCategory selectedCategory; private AssistantCategory selectedCategory;
private string customCategory = string.Empty; private string customCategory = string.Empty;
@ -97,8 +102,14 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
private string reviewNotes = string.Empty; private string reviewNotes = string.Empty;
private string generatedLuaAssistant = string.Empty; private string generatedLuaAssistant = string.Empty;
private Guid pluginId = Guid.NewGuid(); private Guid pluginId = Guid.NewGuid();
private string HighPerformanceLLMInfo => T("It is recommended to a powerful LLM.");
private string highPerformanceLLMInfo => T("It is recommended to a powerful LLM."); private int stepperIndex;
private AssistantPluginCheckResult? pluginCheckResult;
private AssistantPluginInstallResult? pluginInstallResult;
private PluginAssistantAudit? pluginAudit;
private PluginAssistants? installedAssistantPlugin;
private BuilderInstallStep? failedInstallStep;
private string installFlowIssue = string.Empty;
private static readonly AssistantContextFile[] ASSISTANT_CONTEXT_FILES = private static readonly AssistantContextFile[] ASSISTANT_CONTEXT_FILES =
[ [
new("Assistant plugin schema", "Plugins/assistants/README.md", IsRequired: true), new("Assistant plugin schema", "Plugins/assistants/README.md", IsRequired: true),
@ -113,6 +124,56 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
REVIEW_SPEC, REVIEW_SPEC,
DONE, DONE,
} }
private enum BuilderInstallStep
{
CHECK_PLUGIN = 0,
INSTALL_ASSISTANT = 1,
SECURITY_CHECK = 2,
ENABLE_ASSISTANT = 3,
OPEN_ASSISTANT = 4,
}
private bool IsInstallFlowRunning => this.isCheckingPlugin || this.isInstallingPlugin || this.isAuditingPlugin || this.isEnablingPlugin;
private bool PluginCheckCompleted => this.pluginCheckResult?.Success is true;
private bool PluginInstallCompleted => this.pluginInstallResult?.Success is true;
private bool AuditCompleted => this.pluginAudit is not null && this.pluginAudit.Level is not AssistantAuditLevel.UNKNOWN;
private bool AuditRequiredForActivation => this.SettingsManager.ConfigurationData.AssistantPluginAudit.RequireAuditBeforeActivation;
private bool EnableCompleted => this.pluginInstallResult is not null && this.SettingsManager.ConfigurationData.EnabledPlugins.Contains(this.pluginInstallResult.PluginId);
private bool CanRunPluginCheck => !this.IsInstallFlowRunning && !string.IsNullOrWhiteSpace(this.generatedLuaAssistant);
private bool CanInstallPlugin => !this.IsInstallFlowRunning && this.PluginCheckCompleted;
private bool CanRunAudit => !this.IsInstallFlowRunning && this.PluginInstallCompleted && this.installedAssistantPlugin is not null;
private bool CanEnableAssistant => !this.IsInstallFlowRunning && this.PluginInstallCompleted && !this.IsActivationBlockedBySettings;
private bool CanOpenAssistant => this.EnableCompleted && this.pluginInstallResult is not null;
private bool IsAuditBelowMinimum => this.pluginAudit is not null && this.pluginAudit.Level < this.SettingsManager.ConfigurationData.AssistantPluginAudit.MinimumLevel;
private bool IsActivationBlockedBySettings => this.AuditRequiredForActivation &&
(!this.AuditCompleted ||
this.IsAuditBelowMinimum && this.SettingsManager.ConfigurationData.AssistantPluginAudit.BlockActivationBelowMinimum);
private bool RequiresActivationConfirmation => this.AuditCompleted &&
this.IsAuditBelowMinimum &&
!this.IsActivationBlockedBySettings;
private Severity AuditSeverity => this.pluginAudit?.Level switch
{
AssistantAuditLevel.DANGEROUS => Severity.Error,
AssistantAuditLevel.CAUTION => Severity.Warning,
AssistantAuditLevel.SAFE => Severity.Info,
_ => Severity.Normal,
};
private static readonly AssistantComponentType[] ASSISTANT_COMPONENT_OPTIONS = private static readonly AssistantComponentType[] ASSISTANT_COMPONENT_OPTIONS =
[ [
AssistantComponentType.TEXT_AREA, AssistantComponentType.TEXT_AREA,
@ -145,6 +206,7 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
this.generatedAssistantSpec = string.Empty; this.generatedAssistantSpec = string.Empty;
this.reviewNotes = string.Empty; this.reviewNotes = string.Empty;
this.generatedLuaAssistant = string.Empty; this.generatedLuaAssistant = string.Empty;
this.ResetInstallFlow();
} }
protected override bool MightPreselectValues() => false; protected override bool MightPreselectValues() => false;
@ -240,8 +302,8 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
return; return;
} }
this.ResetInstallFlow();
this.generatedLuaAssistant = parsedResponse.FullLua.Trim(); this.generatedLuaAssistant = parsedResponse.FullLua.Trim();
this.AddGeneratedLuaPreviewResult();
this.step = BuilderStep.DONE; this.step = BuilderStep.DONE;
} }
finally finally
@ -254,12 +316,14 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
{ {
this.step = BuilderStep.DESCRIBE; this.step = BuilderStep.DESCRIBE;
this.generatedLuaAssistant = string.Empty; this.generatedLuaAssistant = string.Empty;
this.ResetInstallFlow();
} }
private void BackToSpecReview() private void BackToSpecReview()
{ {
this.step = BuilderStep.REVIEW_SPEC; this.step = BuilderStep.REVIEW_SPEC;
this.generatedLuaAssistant = string.Empty; this.generatedLuaAssistant = string.Empty;
this.ResetInstallFlow();
} }
private async Task EditDraftAndDiscardPluginPreview() private async Task EditDraftAndDiscardPluginPreview()
@ -291,6 +355,7 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
return; return;
this.generatedLuaAssistant = string.Empty; this.generatedLuaAssistant = string.Empty;
this.ResetInstallFlow();
this.step = BuilderStep.REVIEW_SPEC; this.step = BuilderStep.REVIEW_SPEC;
} }
@ -353,7 +418,8 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
- The future Lua plugin must be loadable by AI Studio. - The future Lua plugin must be loadable by AI Studio.
- Include assumptions instead of asking follow-up questions. - Include assumptions instead of asking follow-up questions.
- Treat filled optional guidance as explicit user intent. - Treat filled optional guidance as explicit user intent.
- Keep technical identifiers untranslated, such as TEXT_AREA, DROPDOWN, PROVIDER_SELECTION, PROFILE_SELECTION, BuildPrompt, and plugin.lua. - Do not mention the PROVIDER_SELECTION or the submit button in the ## {{T("UI Components")}} section as they are mandatory anyway.
- Keep technical identifiers untranslated, such as TEXT_AREA, DROPDOWN, PROFILE_SELECTION, BuildPrompt, and plugin.lua.
- Exception: Do not use technical identifiers in the "{{T("Inputs")}}" section, it should be easy comprehensible what the usual user input will be - Exception: Do not use technical identifiers in the "{{T("Inputs")}}" section, it should be easy comprehensible what the usual user input will be
"""; """;
@ -504,28 +570,71 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
return string.Empty; return string.Empty;
} }
private async Task InstallPluginAsync() private async Task CheckGeneratedAssistantAsync()
{ {
if (string.IsNullOrWhiteSpace(this.generatedLuaAssistant)) if (string.IsNullOrWhiteSpace(this.generatedLuaAssistant))
{ {
this.Snackbar.Add(T("No assistant plugin was generated yet."), Severity.Warning); await this.MessageBus.SendError(new(Icons.Material.Filled.Extension, T("No assistant plugin was generated yet.")));
return; return;
} }
this.ResetInstallFlow();
this.stepperIndex = (int)BuilderInstallStep.CHECK_PLUGIN;
this.isCheckingPlugin = true;
try
{
var result = await this.AssistantPluginInstallService.CheckInstallabilityAsync(this.generatedLuaAssistant, CancellationToken.None);
this.pluginCheckResult = result;
if (!result.Success)
{
this.FailInstallStep(BuilderInstallStep.CHECK_PLUGIN, result.Issue);
await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, T("The generated assistant could not be checked.")));
return;
}
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.CheckCircle, T("The generated assistant can be installed.")));
this.stepperIndex = (int)BuilderInstallStep.INSTALL_ASSISTANT;
}
finally
{
this.isCheckingPlugin = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
private async Task InstallGeneratedAssistantAsync()
{
if (!this.PluginCheckCompleted)
return;
this.ClearInstallStepIssue();
this.stepperIndex = (int)BuilderInstallStep.INSTALL_ASSISTANT;
this.isInstallingPlugin = true; this.isInstallingPlugin = true;
try try
{ {
var result = await this.AssistantPluginInstallService.InstallAsync(this.generatedLuaAssistant, CancellationToken.None); var result = await this.AssistantPluginInstallService.InstallAsync(this.generatedLuaAssistant, CancellationToken.None);
this.pluginInstallResult = result;
if (!result.Success) if (!result.Success)
{ {
this.Snackbar.Add(result.Issue, Severity.Error); this.FailInstallStep(BuilderInstallStep.INSTALL_ASSISTANT, result.Issue);
await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, T("The assistant could not be installed.")));
return; return;
} }
var message = result.ReplacedExisting this.installedAssistantPlugin = ResolveAssistantPlugin(result.PluginId);
? string.Format(T("The assistant plugin \"{0}\" was updated."), result.PluginName) if (this.installedAssistantPlugin is null)
: string.Format(T("The assistant plugin \"{0}\" was installed."), result.PluginName); {
this.Snackbar.Add(message, Severity.Success); this.FailInstallStep(BuilderInstallStep.INSTALL_ASSISTANT, T("The installed assistant could not be loaded."));
await this.MessageBus.SendError(new(Icons.Material.Filled.ReportProblem, T("The installed assistant could not be loaded.")));
return;
}
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Extension, result.ReplacedExisting ? T("Assistant updated.") : T("Assistant installed.")));
this.stepperIndex = this.AuditRequiredForActivation
? (int)BuilderInstallStep.SECURITY_CHECK
: this.EnableCompleted
? (int)BuilderInstallStep.OPEN_ASSISTANT
: (int)BuilderInstallStep.ENABLE_ASSISTANT;
} }
finally finally
{ {
@ -534,49 +643,146 @@ public partial class AssistantBuilder : AssistantBaseCore<NoSettingsPanel>
} }
} }
private static string CreateLuaCodeFence(string lua) private async Task RunSecurityCheckAsync()
{ {
var fenceLength = Math.Max(3, GetLongestBacktickRun(lua) + 1); if (this.installedAssistantPlugin is null)
var fence = new string('`', fenceLength); return;
return $"""
{fence}lua
{lua.Trim()}
{fence}
""";
}
private static int GetLongestBacktickRun(string text) this.ClearInstallStepIssue();
{ this.stepperIndex = (int)BuilderInstallStep.SECURITY_CHECK;
var longestRun = 0; this.isAuditingPlugin = true;
var currentRun = 0; try
foreach (var character in text)
{ {
if (character is '`') this.pluginAudit = await this.AssistantPluginAuditService.RunAuditAsync(this.installedAssistantPlugin);
if (this.pluginAudit.Level is AssistantAuditLevel.UNKNOWN)
{ {
currentRun++; this.FailInstallStep(BuilderInstallStep.SECURITY_CHECK, T("The security check could not determine a result."));
longestRun = Math.Max(longestRun, currentRun); await this.MessageBus.SendError(new(Icons.Material.Filled.GppMaybe, T("The security check could not be completed.")));
continue; return;
} }
currentRun = 0; this.UpsertAudit(this.pluginAudit);
} await this.SettingsManager.StoreSettings();
await this.MessageBus.SendSuccess(new(
this.pluginAudit.Level.GetIcon(),
this.pluginAudit.Findings.Count == 0
? T("Security check completed. No security issues were found.")
: T("Security check completed with findings.")));
return longestRun; if (this.IsActivationBlockedBySettings)
{
this.stepperIndex = (int)BuilderInstallStep.ENABLE_ASSISTANT;
this.FailInstallStep(BuilderInstallStep.ENABLE_ASSISTANT, T("This assistant cannot be enabled because the security check is below your required level."));
await this.MessageBus.SendError(new(Icons.Material.Filled.Block, T("The assistant cannot be enabled because it is below your required security level.")));
return;
}
this.stepperIndex = this.EnableCompleted
? (int)BuilderInstallStep.OPEN_ASSISTANT
: (int)BuilderInstallStep.ENABLE_ASSISTANT;
}
finally
{
this.isAuditingPlugin = false;
await this.InvokeAsync(this.StateHasChanged);
}
} }
private void AddGeneratedLuaPreviewResult() private async Task EnableInstalledAssistantAsync()
{ {
this.ChatThread?.Blocks.Add(new() if (this.pluginInstallResult is null || this.IsActivationBlockedBySettings)
return;
if (this.RequiresActivationConfirmation && !await this.ConfirmActivationBelowMinimumAsync())
return;
this.ClearInstallStepIssue();
this.stepperIndex = (int)BuilderInstallStep.ENABLE_ASSISTANT;
this.isEnablingPlugin = true;
try
{
if (!this.SettingsManager.ConfigurationData.EnabledPlugins.Contains(this.pluginInstallResult.PluginId))
this.SettingsManager.ConfigurationData.EnabledPlugins.Add(this.pluginInstallResult.PluginId);
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.ToggleOn, T("Assistant enabled.")));
this.stepperIndex = (int)BuilderInstallStep.OPEN_ASSISTANT;
}
finally
{
this.isEnablingPlugin = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
private async Task<bool> ConfirmActivationBelowMinimumAsync()
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{ {
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.AI,
Content = new ContentText
{ {
Text = CreateLuaCodeFence(this.generatedLuaAssistant), x => x.Message,
string.Format(
T("The assistant \"{0}\" was checked with the level \"{1}\", which is below your required level \"{2}\". Your settings allow activation anyway, but this may be unsafe. Do you want to enable this assistant?"),
this.pluginInstallResult?.PluginName ?? T("Unknown assistant"),
this.pluginAudit?.Level.GetName() ?? T("Unknown"),
this.SettingsManager.ConfigurationData.AssistantPluginAudit.MinimumLevel.GetName())
}, },
}); };
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Potentially Unsafe Assistant"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
return dialogResult is not null && !dialogResult.Canceled;
}
private void OpenInstalledAssistant()
{
if (this.pluginInstallResult is null)
return;
this.NavigationManager.NavigateTo($"{Routes.ASSISTANT_DYNAMIC}?assistantId={this.pluginInstallResult.PluginId}");
}
private static PluginAssistants? ResolveAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(plugin => plugin.Id == pluginId);
private void UpsertAudit(PluginAssistantAudit audit)
{
var audits = this.SettingsManager.ConfigurationData.AssistantPluginAudits;
var existingIndex = audits.FindIndex(x => x.PluginId == audit.PluginId);
if (existingIndex >= 0)
audits[existingIndex] = audit;
else
audits.Add(audit);
}
private void FailInstallStep(BuilderInstallStep installStep, string issue)
{
this.failedInstallStep = installStep;
this.installFlowIssue = issue;
this.stepperIndex = (int)installStep;
}
private void ClearInstallStepIssue()
{
this.failedInstallStep = null;
this.installFlowIssue = string.Empty;
}
private bool IsInstallStepFailed(BuilderInstallStep installStep) => this.failedInstallStep == installStep;
private void ResetInstallFlow()
{
this.stepperIndex = (int)BuilderInstallStep.CHECK_PLUGIN;
this.isCheckingPlugin = false;
this.isInstallingPlugin = false;
this.isAuditingPlugin = false;
this.isEnablingPlugin = false;
this.pluginCheckResult = null;
this.pluginInstallResult = null;
this.pluginAudit = null;
this.installedAssistantPlugin = null;
this.failedInstallStep = null;
this.installFlowIssue = string.Empty;
} }
private async Task<string> LoadAssistantBuilderContextAsync() private async Task<string> LoadAssistantBuilderContextAsync()

View File

@ -6,6 +6,7 @@ using AIStudio.Tools.PluginSystem.Assistants;
namespace AIStudio.Tools.Services; namespace AIStudio.Tools.Services;
public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue); public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, bool ReplacedExisting, string Issue);
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);
public sealed class AssistantPluginInstallService public sealed class AssistantPluginInstallService
{ {
@ -17,6 +18,7 @@ public sealed class AssistantPluginInstallService
private readonly SemaphoreSlim installSemaphore = new(1, 1); private readonly SemaphoreSlim installSemaphore = new(1, 1);
private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue); private static AssistantPluginInstallResult Error(string issue) => new(false, Guid.Empty, string.Empty, string.Empty, false, issue);
private static AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> logger) public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> logger)
{ {
@ -24,6 +26,43 @@ public sealed class AssistantPluginInstallService
this.logger.LogInformation("The assistant plugin install service has been initialized."); this.logger.LogInformation("The assistant plugin install service has been initialized.");
} }
/// <summary>
/// Checks whether generated Lua assistant plugin code can be loaded and installed.
/// The plugin is written to a temporary staging directory and validated through the
/// normal plugin loader, but it is not moved into the user plugin directory.
/// </summary>
/// <param name="lua">The full generated <c>plugin.lua</c> content.</param>
/// <param name="token">A cancellation token for file IO and Lua validation.</param>
/// <returns>
/// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed.
/// </returns>
public async Task<AssistantPluginCheckResult> CheckInstallabilityAsync(string lua, CancellationToken token)
{
if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
return CheckError(rootIssue);
await this.installSemaphore.WaitAsync(token);
var stagingDirectory = string.Empty;
try
{
var validation = await this.ValidateIntoStagingAsync(lua, token);
if (!validation.Success || validation.AssistantPlugin is null)
return CheckError(validation.Issue);
stagingDirectory = validation.StagingDirectory;
var finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, validation.AssistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
return CheckError("The resolved plugin directory is outside the assistant plugin directory.");
return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty);
}
finally
{
this.TryDeleteStagingDirectory(stagingDirectory);
this.installSemaphore.Release();
}
}
/// <summary> /// <summary>
/// Installs generated Lua assistant plugin code into the user plugin directory. /// Installs generated Lua assistant plugin code into the user plugin directory.
/// Writes the plugin into a temporary staging directory first, validates it through the /// Writes the plugin into a temporary staging directory first, validates it through the
@ -39,44 +78,27 @@ public sealed class AssistantPluginInstallService
/// </returns> /// </returns>
public async Task<AssistantPluginInstallResult> InstallAsync(string lua, CancellationToken token) public async Task<AssistantPluginInstallResult> InstallAsync(string lua, CancellationToken token)
{ {
if (string.IsNullOrWhiteSpace(lua)) if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
return Error("No Lua plugin code was generated."); return Error(rootIssue);
var pluginCode = lua.Trim();
if (!PluginFactory.IsInitialized)
return Error("The plugin system is not initialized yet.");
var dataDirectory = SettingsManager.DataDirectory;
if (string.IsNullOrWhiteSpace(dataDirectory))
return Error("The AI Studio data directory is not initialized yet.");
await this.installSemaphore.WaitAsync(token); await this.installSemaphore.WaitAsync(token);
AssistantPluginValidationResult validation;
try try
{ {
var assistantPluginsRoot = Path.Join(dataDirectory, "plugins", PluginType.ASSISTANT.GetDirectory()); validation = await this.ValidateIntoStagingAsync(lua, token);
if (!validation.Success || validation.AssistantPlugin is null)
return Error(validation.Issue);
Directory.CreateDirectory(assistantPluginsRoot); Directory.CreateDirectory(assistantPluginsRoot);
var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}"); var stagingDirectory = validation.StagingDirectory;
var assistantPlugin = validation.AssistantPlugin;
string? backupDirectory = null; string? backupDirectory = null;
string? finalDirectory = null; string? finalDirectory = null;
var replacedExisting = false; var replacedExisting = false;
try try
{ {
Directory.CreateDirectory(stagingDirectory);
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
var plugin = await PluginFactory.Load(stagingDirectory, pluginCode, token);
if (plugin is not PluginAssistants assistantPlugin)
return Error($"The generated plugin is not an assistant plugin. Issue: {string.Join("; ", plugin.Issues)}");
if (!assistantPlugin.IsValid)
return Error($"The generated assistant plugin is invalid. Issue: {string.Join("; ", assistantPlugin.Issues)}");
if (PluginFactory.AvailablePlugins.Any(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && plugin.IsInternal))
return Error("The generated assistant plugin uses the ID of an internal AI Studio plugin.");
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin); finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory)) if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
return Error("The resolved plugin directory is outside the assistant plugin directory."); return Error("The resolved plugin directory is outside the assistant plugin directory.");
@ -125,17 +147,7 @@ public sealed class AssistantPluginInstallService
} }
finally finally
{ {
if (Directory.Exists(stagingDirectory)) this.TryDeleteStagingDirectory(stagingDirectory);
{
try
{
Directory.Delete(stagingDirectory, true);
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to delete assistant plugin staging directory '{StagingDirectory}'.", stagingDirectory);
}
}
} }
} }
finally finally
@ -143,6 +155,83 @@ public sealed class AssistantPluginInstallService
this.installSemaphore.Release(); this.installSemaphore.Release();
} }
} }
private async Task<AssistantPluginValidationResult> ValidateIntoStagingAsync(string lua, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(lua))
return AssistantPluginValidationResult.Error("No Lua plugin code was generated.");
if (!PluginFactory.IsInitialized)
return AssistantPluginValidationResult.Error("The plugin system is not initialized yet.");
var pluginCode = lua.Trim();
var stagingDirectory = Path.Join(Path.GetTempPath(), $"{ASSISTANT_BUILDER_DIRECTORY_PREFIX}.staging-{Guid.NewGuid():N}");
try
{
Directory.CreateDirectory(stagingDirectory);
var stagedPluginFile = Path.Join(stagingDirectory, PLUGIN_FILE_NAME);
await File.WriteAllTextAsync(stagedPluginFile, pluginCode, Encoding.UTF8, token);
var plugin = await PluginFactory.Load(stagingDirectory, pluginCode, token);
if (plugin is not PluginAssistants assistantPlugin)
{
this.TryDeleteStagingDirectory(stagingDirectory);
return AssistantPluginValidationResult.Error($"The generated plugin is not an assistant plugin. Issue: {string.Join("; ", plugin.Issues)}");
}
if (!assistantPlugin.IsValid)
{
this.TryDeleteStagingDirectory(stagingDirectory);
return AssistantPluginValidationResult.Error($"The generated assistant plugin is invalid. Issue: {string.Join("; ", assistantPlugin.Issues)}");
}
if (PluginFactory.AvailablePlugins.Any(plugin => plugin.Type is PluginType.ASSISTANT && plugin.Id == assistantPlugin.Id && plugin.IsInternal))
{
this.TryDeleteStagingDirectory(stagingDirectory);
return AssistantPluginValidationResult.Error("The generated assistant plugin uses the ID of an internal AI Studio plugin.");
}
return new(true, stagingDirectory, assistantPlugin, string.Empty);
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to validate generated assistant plugin.");
this.TryDeleteStagingDirectory(stagingDirectory);
return AssistantPluginValidationResult.Error(e.Message);
}
}
private static bool TryGetAssistantPluginsRoot(out string assistantPluginsRoot, out string issue)
{
assistantPluginsRoot = string.Empty;
issue = string.Empty;
var dataDirectory = SettingsManager.DataDirectory;
if (string.IsNullOrWhiteSpace(dataDirectory))
{
issue = "The AI Studio data directory is not initialized yet.";
return false;
}
assistantPluginsRoot = Path.Join(dataDirectory, "plugins", PluginType.ASSISTANT.GetDirectory());
return true;
}
private void TryDeleteStagingDirectory(string stagingDirectory)
{
if (!Directory.Exists(stagingDirectory))
return;
try
{
Directory.Delete(stagingDirectory, true);
}
catch (Exception e)
{
this.logger.LogError(e, "Failed to delete assistant plugin staging directory '{StagingDirectory}'.", stagingDirectory);
}
}
private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin) private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin)
{ {
@ -206,4 +295,9 @@ public sealed class AssistantPluginInstallService
var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var childPath = Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase); return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
} }
private sealed record AssistantPluginValidationResult(bool Success, string StagingDirectory, PluginAssistants? AssistantPlugin, string Issue)
{
public static AssistantPluginValidationResult Error(string issue) => new(false, string.Empty, null, issue);
}
} }