Added direct-chat launchers for assistant plugins and a dialog to reconfigure them (#935)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-08-27 16:19:35 +02:00
1 parent f03c2d1c88
commit a4c35fc2f2
44 files changed
+2615 -219

No files matched your search

@@ -0,0 +1,80 @@
@inherits MSGComponentBase
<MudDialog DefaultFocus="DefaultFocus.None">
<DialogContent>
<MudStack Spacing="3">
@if (!string.IsNullOrWhiteSpace(this.issue))
{
<MudAlert Severity="Severity.Error" Dense="@true">
@this.issue
</MudAlert>
}
@if (this.isLoading)
{
<MudProgressLinear Indeterminate="@true" Color="Color.Primary"/>
}
else if (this.assistantPlugin is not null && this.canEdit)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
@T("This tile opens a chat directly, so there is nothing to prompt for: pick what the chat should start with. AI Studio rewrites the plugin itself, without asking a model.")
</MudText>
<MudForm @ref="@this.form">
@* The dashed frame shows that these fields belong together: they describe one
chat the launcher tile opens. *@
<MudPaper Class="pa-3 border-dashed border rounded-lg">
<MudTextField T="string" @bind-Text="@this.pluginName" Validation="@this.ValidatePluginName" AdornmentIcon="@Icons.Material.Filled.Extension" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Plugin name")" HelperText="@T("The name shown on the plugins page.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" Disabled="@this.IsBusy"/>
<MudTextField T="string" @bind-Text="@this.title" Validation="@this.ValidateTitle" AdornmentIcon="@Icons.Material.Filled.Assistant" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Tile title")" HelperText="@T("The title shown on the tile.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" Disabled="@this.IsBusy"/>
<MudTextField T="string" @bind-Text="@this.description" Validation="@this.ValidateDescription" AdornmentIcon="@Icons.Material.Filled.Notes" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Description")" HelperText="@T("Shown on the tile and on the plugins page.")" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="2" AutoGrow="@true" MaxLines="6" Class="mb-3" Disabled="@this.IsBusy"/>
<DirectChatLauncherForm @bind-WorkspaceName="@this.workspaceName"
@bind-ProviderId="@this.providerId"
@bind-ProfileId="@this.profileId"
@bind-ChatTemplateId="@this.chatTemplateId"
@bind-DataSourceIds="@this.dataSourceIds"
ValidateWorkspaceName="@this.ValidateWorkspaceName"/>
</MudPaper>
</MudForm>
@* The panel content is only built while it is open, so the plugin is written just
for users who want to look at it. *@
<MudExpansionPanels Dense="@true" Elevation="0">
<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("Resulting Lua plugin")
</MudText>
</div>
</TitleContent>
<ChildContent>
<MudTextField T="string" Text="@this.BuildLua()" ReadOnly="@true" Variant="Variant.Outlined" Lines="18" Class="mt-2" Style="font-family: monospace"/>
</ChildContent>
</MudExpansionPanel>
</MudExpansionPanels>
@if (this.IsBusy)
{
<MudProgressLinear Indeterminate="@true" Color="Color.Primary"/>
<MudText Typo="Typo.body2">
@(this.isAuditing ? T("Running security audit...") : T("Saving the tile..."))
</MudText>
}
}
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Cancel" Disabled="@this.IsBusy" Size="Size.Small">
@T("Cancel")
</MudButton>
<MudButton OnClick="@(async () => await this.SaveAsync())"
Disabled="@(!this.CanSave)"
Color="Color.Primary"
Variant="Variant.Filled"
StartIcon="@Icons.Material.Filled.Save"
Size="Size.Small">
@T("Save tile")
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,279 @@
using System.Text;
using AIStudio.Agents.AssistantAudit;
using AIStudio.Components;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
public sealed record DirectChatLauncherSettingsDialogResult(Guid PluginId, string PluginName, PluginAssistantAudit? Audit);
/// <summary>
/// Changes the settings of an installed direct chat launcher without asking a model.
/// </summary>
/// <remarks>
/// A launcher has no prompt and no form, so every change a user can make here is a different pick
/// from a drop-down. The dialog therefore writes the plugin itself through
/// DirectChatLauncherLuaWriter and reuses the regular assistant update path for validating,
/// writing, and rolling back.
/// </remarks>
public partial class DirectChatLauncherSettingsDialog : MSGComponentBase
{
private const string PLUGIN_FILE_NAME = "plugin.lua";
private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(DirectChatLauncherSettingsDialog));
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
[Inject]
private PluginInstallService PluginInstallService { get; init; } = null!;
[Inject]
private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!;
[Parameter]
public Guid PluginId { get; set; }
[Parameter]
public string PluginLocalPath { get; set; } = string.Empty;
private IAvailablePlugin? availablePlugin;
private PluginAssistants? assistantPlugin;
private MudForm? form;
private string pluginName = string.Empty;
private string title = string.Empty;
private string description = string.Empty;
private string workspaceName = string.Empty;
private string providerId = string.Empty;
private string profileId = string.Empty;
private string chatTemplateId = string.Empty;
private IEnumerable<string> dataSourceIds = [];
private string issue = string.Empty;
private bool canEdit;
private bool isLoading = true;
private bool isSaving;
private bool isAuditing;
private bool IsBusy => this.isSaving || this.isAuditing;
private bool CanSave => this.canEdit && this.assistantPlugin is not null && this.availablePlugin is not null && !this.isLoading && !this.IsBusy;
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
try
{
this.availablePlugin = PluginFactory.AvailablePlugins
.OfType<IAvailablePlugin>()
.FirstOrDefault(x => x.Id == this.PluginId && AreSamePath(x.LocalPath, this.PluginLocalPath));
this.assistantPlugin = PluginFactory.RunningPlugins
.OfType<PluginAssistants>()
.FirstOrDefault(x => x.Id == this.PluginId && AreSamePath(x.PluginPath, this.PluginLocalPath));
if (this.availablePlugin is null || this.assistantPlugin is null)
{
this.issue = T("The assistant plugin could not be resolved.");
return;
}
if (!DirectChatLauncherLuaWriter.CanRewrite(this.assistantPlugin) || this.assistantPlugin.ChatLaunchConfiguration is not { } launch)
{
this.issue = T("Only locally managed direct chat launchers can be edited here.");
return;
}
//
// Saving replaces the whole plugin.lua. Anything the file carries beyond the canonical
// launcher shape would be lost, so those plugins keep the code editor and the AI
// revision instead of this dialog:
//
var pluginFile = Path.Join(this.availablePlugin.LocalPath, PLUGIN_FILE_NAME);
if (!File.Exists(pluginFile))
{
this.issue = T("The plugin.lua file could not be found.");
return;
}
var currentLua = await File.ReadAllTextAsync(pluginFile, Encoding.UTF8);
if (DirectChatLauncherLuaWriter.HasCompanionLuaFiles(this.assistantPlugin) || !DirectChatLauncherLuaWriter.IsCanonicalSource(currentLua))
{
this.issue = T("This launcher contains its own icon or additional Lua code. Please edit it with the plugin code editor, so nothing of it gets lost.");
return;
}
this.pluginName = this.assistantPlugin.Name;
this.title = this.assistantPlugin.AssistantTitle;
this.description = string.IsNullOrWhiteSpace(this.assistantPlugin.Description)
? this.assistantPlugin.AssistantDescription
: this.assistantPlugin.Description;
this.workspaceName = launch.WorkspaceName;
this.providerId = launch.ProviderId?.ToString() ?? string.Empty;
this.profileId = launch.ProfileId?.ToString() ?? string.Empty;
this.chatTemplateId = launch.ChatTemplateId?.ToString() ?? string.Empty;
this.dataSourceIds = launch.DataSourceIds?.Select(id => id.ToString()).ToArray() ?? [];
this.canEdit = true;
}
catch (Exception e)
{
this.issue = string.Format(T("The assistant plugin could not be loaded: {0}"), e.Message);
}
finally
{
this.isLoading = false;
}
await base.OnInitializedAsync();
}
#endregion
private string BuildLua() => this.assistantPlugin is null
? string.Empty
: DirectChatLauncherLuaWriter.Write(this.assistantPlugin, this.BuildDefinition());
private DirectChatLauncherDefinition BuildDefinition() => new(
this.pluginName.Trim(),
this.title.Trim(),
this.description.Trim(),
this.BuildLaunchConfiguration());
private AssistantChatLaunchConfiguration BuildLaunchConfiguration()
{
var selectedDataSourceIds = this.dataSourceIds
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.ToArray();
//
// An empty selection means "use the chat defaults" and is left out of the plugin, whereas
// the empty GUID explicitly selects no profile or no chat template:
//
return new(
this.workspaceName.Trim(),
ParseOptionalGuid(this.providerId),
ParseOptionalGuid(this.profileId),
ParseOptionalGuid(this.chatTemplateId),
selectedDataSourceIds.Length == 0 ? null : selectedDataSourceIds);
}
private async Task SaveAsync()
{
if (!this.CanSave || this.assistantPlugin is null || this.availablePlugin is null || this.form is null)
return;
await this.form.Validate();
if (!this.form.IsValid)
return;
this.isSaving = true;
this.issue = string.Empty;
await this.InvokeAsync(this.StateHasChanged);
try
{
var lua = DirectChatLauncherLuaWriter.Write(this.assistantPlugin, this.BuildDefinition());
//
// The writer produces the plugin deterministically, but the update path is still the
// authority: it validates the Lua, writes it atomically with a backup, and restores the
// previous file when the reload fails.
//
var checkResult = await this.PluginInstallService.CheckInstalledAssistantUpdateAsync(this.availablePlugin, lua, CancellationToken.None);
if (!checkResult.Success)
{
LOGGER.LogError($"The rewritten chat launcher '{this.pluginName}' ({this.PluginId}) is not valid. Issue: {checkResult.Issue}");
this.issue = checkResult.Issue;
return;
}
var updateResult = await this.PluginInstallService.UpdateInstalledAssistantAsync(this.availablePlugin, lua, CancellationToken.None);
if (!updateResult.Success)
{
LOGGER.LogError($"Failed to save the chat launcher '{updateResult.PluginName}' ({updateResult.PluginId}) in '{updateResult.PluginDirectory}'. Issue: {updateResult.Issue}");
this.issue = updateResult.Issue;
return;
}
//
// Writing the file changes the audit hash, so a stored audit no longer applies:
//
PluginAssistantAudit? audit = null;
if (this.SettingsManager.ConfigurationData.AssistantPluginAudit.AutomaticallyAuditAssistants)
audit = await this.TryRunAuditAsync(updateResult.PluginId);
this.MudDialog.Close(DialogResult.Ok(new DirectChatLauncherSettingsDialogResult(updateResult.PluginId, updateResult.PluginName, audit)));
}
finally
{
this.isSaving = false;
if (!string.IsNullOrWhiteSpace(this.issue))
await this.InvokeAsync(this.StateHasChanged);
}
}
private async Task<PluginAssistantAudit?> TryRunAuditAsync(Guid pluginId)
{
var updatedPlugin = PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(x => x.Id == pluginId);
if (updatedPlugin is null)
return null;
this.isAuditing = true;
await this.InvokeAsync(this.StateHasChanged);
try
{
var audit = await this.AssistantPluginAuditService.RunAuditAsync(updatedPlugin);
if (audit.Level is AssistantAuditLevel.UNKNOWN)
return audit;
UpsertAudit(this.SettingsManager.ConfigurationData.AssistantPluginAudits, audit);
await this.SettingsManager.StoreSettings();
return audit;
}
finally
{
this.isAuditing = false;
}
}
private string? ValidatePluginName(string value) => string.IsNullOrWhiteSpace(value) ? T("Please provide a name for this plugin.") : null;
private string? ValidateTitle(string value) => string.IsNullOrWhiteSpace(value) ? T("Please provide a title for this tile.") : null;
private string? ValidateDescription(string value) => string.IsNullOrWhiteSpace(value) ? T("Please provide a description for this tile.") : null;
private string? ValidateWorkspaceName(string value) => string.IsNullOrWhiteSpace(value) ? T("Please select or enter a workspace name for this tile.") : null;
private void Cancel() => this.MudDialog.Cancel();
private static Guid? ParseOptionalGuid(string value) => Guid.TryParse(value, out var parsed) ? parsed : null;
private static void UpsertAudit(IList<PluginAssistantAudit> audits, PluginAssistantAudit audit)
{
var existingIndex = audits.ToList().FindIndex(x => x.PluginId == audit.PluginId);
if (existingIndex >= 0)
audits[existingIndex] = audit;
else
audits.Add(audit);
}
private static bool AreSamePath(string left, string right)
{
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
return false;
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
return string.Equals(
Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
comparison);
}
}