diff --git a/app/MindWork AI Studio/Dialogs/AssistantPluginRevisionDialog.razor b/app/MindWork AI Studio/Dialogs/AssistantPluginRevisionDialog.razor new file mode 100644 index 00000000..8778a264 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/AssistantPluginRevisionDialog.razor @@ -0,0 +1,109 @@ +@using AIStudio.Components +@using AIStudio.Tools +@inherits MSGComponentBase + + + + + @if (!string.IsNullOrWhiteSpace(this.issue)) + { + + @this.issue + + } + + @if (this.isLoading) + { + + } + else if (this.assistantPlugin is not null) + { + @this.assistantPlugin.AssistantTitle + @T("Describe what should change after trying the assistant. AI Studio will revise the installed plugin while keeping the same assistant ID.") + + + + + + + + + @if (this.isGenerating) + { + @T("Creating revision...") + } + else + { + @T("Create revision") + } + + + @if (this.isGenerating) + { + + } + + @if (this.revisionCheckResult?.Success is true) + { + + @string.Format(T("The revised assistant '{0}' is valid and ready to update."), string.IsNullOrWhiteSpace(this.revisedPluginName) ? this.revisionCheckResult.PluginName : this.revisedPluginName) + + } + + @if (!string.IsNullOrWhiteSpace(this.revisedLua)) + { + + + +
+ + + @T("Revised Lua plugin") + +
+
+ + + +
+
+ } + + @if (this.isApplying || this.isAuditing) + { + + + @(this.isAuditing ? T("Running security audit...") : T("Updating assistant...")) + + } + } +
+
+ + + @T("Cancel") + + + @T("Update assistant") + + +
diff --git a/app/MindWork AI Studio/Dialogs/AssistantPluginRevisionDialog.razor.cs b/app/MindWork AI Studio/Dialogs/AssistantPluginRevisionDialog.razor.cs new file mode 100644 index 00000000..571b4093 --- /dev/null +++ b/app/MindWork AI Studio/Dialogs/AssistantPluginRevisionDialog.razor.cs @@ -0,0 +1,255 @@ +using System.Text; +using AIStudio.Agents.AssistantAudit; +using AIStudio.Components; +using AIStudio.Provider; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.PluginSystem.Assistants; +using AIStudio.Tools.Services; +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Dialogs; + +public sealed record AssistantPluginRevisionDialogResult(Guid PluginId, string PluginName, PluginAssistantAudit? Audit); + +public partial class AssistantPluginRevisionDialog : MSGComponentBase +{ + private const string PLUGIN_FILE_NAME = "plugin.lua"; + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(nameof(AssistantPluginRevisionDialog)); + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Inject] + private AssistantPluginGenerationService AssistantPluginGenerationService { get; init; } = null!; + + [Inject] + private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!; + + [Inject] + private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!; + + [Parameter] + public Guid PluginId { get; set; } + + [Parameter] + public string PluginLocalPath { get; set; } = string.Empty; + + [Parameter] + public string TestContext { get; set; } = string.Empty; + + private IAvailablePlugin? availablePlugin; + private PluginAssistants? assistantPlugin; + private AIStudio.Settings.Provider providerSettings = AIStudio.Settings.Provider.NONE; + private string pluginFile = string.Empty; + private string currentLua = string.Empty; + private string changeRequest = string.Empty; + private string revisedLua = string.Empty; + private string revisedPluginName = string.Empty; + private string issue = string.Empty; + private AssistantPluginCheckResult? revisionCheckResult; + private bool isLoading = true; + private bool isGenerating; + private bool isApplying; + private bool isAuditing; + + private bool CanGenerate => this.assistantPlugin is not null && + !this.isLoading && + !this.isGenerating && + !this.isApplying && + !string.IsNullOrWhiteSpace(this.changeRequest); + + private bool CanApply => this.availablePlugin is not null && + this.assistantPlugin is not null && + !this.isGenerating && + !this.isApplying && + !this.isAuditing && + this.revisionCheckResult?.Success is true && + !string.IsNullOrWhiteSpace(this.revisedLua); + + protected override async Task OnInitializedAsync() + { + try + { + this.providerSettings = this.SettingsManager.GetPreselectedProvider(Tools.Components.META_ASSISTANT); + this.availablePlugin = PluginFactory.AvailablePlugins + .OfType() + .FirstOrDefault(x => x.Id == this.PluginId && AreSamePath(x.LocalPath, this.PluginLocalPath)); + + this.assistantPlugin = PluginFactory.RunningPlugins + .OfType() + .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 (!CanReviseAssistantPlugin(this.availablePlugin, this.assistantPlugin)) + { + this.issue = T("Only local assistants generated by the Assistant Builder can be revised with AI."); + return; + } + + this.pluginFile = Path.Join(this.availablePlugin.LocalPath, PLUGIN_FILE_NAME); + if (!File.Exists(this.pluginFile)) + { + this.issue = T("The plugin.lua file could not be found."); + return; + } + + this.currentLua = await File.ReadAllTextAsync(this.pluginFile, Encoding.UTF8); + } + 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(); + } + + private async Task GenerateRevisionAsync() + { + if (!this.CanGenerate || this.assistantPlugin is null) + return; + + this.isGenerating = true; + this.issue = string.Empty; + this.revisedLua = string.Empty; + this.revisionCheckResult = null; + await this.InvokeAsync(this.StateHasChanged); + + try + { + var draft = await this.AssistantPluginGenerationService.GenerateRevisionAsync( + this.assistantPlugin, + this.currentLua, + this.changeRequest, + this.providerSettings, + this.TestContext, + CancellationToken.None); + + if (!draft.Success) + { + this.issue = draft.Issue; + return; + } + + this.revisedLua = draft.Lua; + this.revisedPluginName = draft.PluginName; + if (this.availablePlugin is null) + return; + + this.revisionCheckResult = await this.AssistantPluginInstallService.CheckInstalledAssistantUpdateAsync(this.availablePlugin, this.revisedLua, CancellationToken.None); + if (this.revisionCheckResult.Success) + return; + + this.issue = this.revisionCheckResult.Issue; + } + finally + { + this.isGenerating = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + + private async Task ApplyRevisionAsync() + { + if (!this.CanApply || this.availablePlugin is null) + return; + + this.isApplying = true; + this.issue = string.Empty; + await this.InvokeAsync(this.StateHasChanged); + + try + { + var result = await this.AssistantPluginInstallService.UpdateInstalledAssistantAsync(this.availablePlugin, this.revisedLua, CancellationToken.None); + if (!result.Success) + { + LOGGER.LogError("Failed to revise assistant plugin '{PluginName}' ({PluginId}) in '{PluginDirectory}' with issue '{Issue}'.", result.PluginName, result.PluginId, result.PluginDirectory, result.Issue); + this.issue = result.Issue; + return; + } + + PluginAssistantAudit? audit = null; + if (this.SettingsManager.ConfigurationData.AssistantPluginAudit.AutomaticallyAuditAssistants) + audit = await this.TryRunAuditAsync(result.PluginId); + + this.MudDialog.Close(DialogResult.Ok(new AssistantPluginRevisionDialogResult(result.PluginId, result.PluginName, audit))); + } + finally + { + this.isApplying = false; + if (!string.IsNullOrWhiteSpace(this.issue)) + await this.InvokeAsync(this.StateHasChanged); + } + } + + private async Task TryRunAuditAsync(Guid pluginId) + { + var updatedPlugin = PluginFactory.RunningPlugins.OfType().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? ValidatingProvider(AIStudio.Settings.Provider provider) + { + if (provider.UsedLLMProvider == LLMProviders.NONE) + return T("Please select a provider."); + + return null; + } + + private void Cancel() => this.MudDialog.Cancel(); + + private static bool CanReviseAssistantPlugin(IAvailablePlugin availablePlugin, PluginAssistants assistantPlugin) => + availablePlugin is { IsInternal: false, Type: PluginType.ASSISTANT } && + !string.IsNullOrWhiteSpace(availablePlugin.LocalPath) && + assistantPlugin.IsAssistantBuilderGenerated; + + private static void UpsertAudit(IList 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); + } +} diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs index 11c2cf1a..3473f7f1 100644 --- a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs +++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs @@ -171,6 +171,53 @@ public sealed class AssistantPluginInstallService } } + /// + /// Checks whether edited assistant plugin code can replace an installed local assistant plugin + /// without writing the file. + /// + /// The installed local assistant plugin to validate against. + /// The edited plugin.lua content. + /// Cancellation token for Lua validation. + /// Check result that contains success state, plugin metadata, and a user-facing issue when validation failed. + public async Task CheckInstalledAssistantUpdateAsync(IAvailablePlugin plugin, string lua, CancellationToken token) + { + if (plugin.Type is not PluginType.ASSISTANT) + return CheckError(TB("Only assistant plugins can be edited.")); + + if (plugin.IsInternal) + return CheckError(TB("Internal assistant plugins cannot be edited.")); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return CheckError(TB("The assistant plugin has no local directory.")); + + if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue)) + return CheckError(rootIssue); + + var pluginDirectory = plugin.LocalPath; + if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) + return CheckError(TB("The assistant plugin directory is outside the local assistant plugin directory.")); + + if (!Directory.Exists(pluginDirectory)) + return CheckError(TB("The assistant plugin directory does not exist.")); + + await this.installSemaphore.WaitAsync(token); + try + { + var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token); + if (!validation.Success || validation.AssistantPlugin is null) + return CheckError(validation.Issue); + + var assistantPlugin = validation.AssistantPlugin; + return assistantPlugin.Id != plugin.Id + ? CheckError(TB("The edited assistant plugin must keep the same plugin ID.")) + : new(true, assistantPlugin.Id, assistantPlugin.Name, string.Empty); + } + finally + { + this.installSemaphore.Release(); + } + } + /// /// Deletes installed local assistant plugin directories. /// The directory gets moved to a backup dir outside the plugin root so the