diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor
index 26167b11..fc561089 100644
--- a/app/MindWork AI Studio/Pages/Plugins.razor
+++ b/app/MindWork AI Studio/Pages/Plugins.razor
@@ -64,7 +64,7 @@
-
+
@if (context.Type is PluginType.ASSISTANT)
{
var assistantPlugin = PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == context.Id);
@@ -96,6 +96,13 @@
}
}
+
+ @if (context is IAvailablePlugin availablePlugin && CanDeleteAssistantPlugin(availablePlugin))
+ {
+
+
+
+ }
diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs
index 914a13b7..8a616ad4 100644
--- a/app/MindWork AI Studio/Pages/Plugins.razor.cs
+++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs
@@ -4,6 +4,7 @@ using AIStudio.Dialogs;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.PluginSystem;
+using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
@@ -27,6 +28,11 @@ public partial class Plugins : MSGComponentBase
[Inject]
private AssistantPluginAuditService AssistantPluginAuditService { get; init; } = null!;
+ [Inject]
+ private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
+
+ private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(Plugins));
+
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
@@ -135,7 +141,7 @@ public partial class Plugins : MSGComponentBase
{
x => x.Message,
string.Format(
- this.T("The assistant plugin \"{0}\" was audited with the level \"{1}\", which is below the required minimum level \"{2}\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"),
+ this.T("The assistant plugin '{0}' was audited with the level '{1}', which is below the required minimum level \"{2}\". Your current settings allow activation anyway, but this may be potentially dangerous. Do you really want to enable this plugin?"),
pluginName,
actualLevel.GetName(),
this.AssistantPluginAuditSettings.MinimumLevel.GetName())
@@ -182,6 +188,35 @@ public partial class Plugins : MSGComponentBase
: this.T("Enable plugin");
}
+ private static bool CanDeleteAssistantPlugin(IAvailablePlugin plugin) => plugin is { IsInternal: false, Type: PluginType.ASSISTANT } && !string.IsNullOrWhiteSpace(plugin.LocalPath);
+
+ private async Task DeleteAssistantPluginAsync(IAvailablePlugin plugin)
+ {
+ var dialogParameters = new DialogParameters
+ {
+ {
+ x => x.Message,
+ string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), plugin.Name)
+ },
+ };
+
+ var dialogReference = await this.DialogService.ShowAsync(this.T("Delete Assistant Plugin"), dialogParameters, DialogOptions.FULLSCREEN);
+ var dialogResult = await dialogReference.Result;
+ if (dialogResult is null || dialogResult.Canceled)
+ return;
+
+ var result = await this.AssistantPluginInstallService.DeleteInstalledAssistantAsync(plugin, CancellationToken.None);
+ if (!result.Success)
+ {
+ LOG.LogError($"Failed to delete assistant plugin '{result.PluginName}' ({result.PluginId}) from '{result.PluginDirectory}' with issue '{result.Issue}'.");
+ await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The assistant plugin '{0}' could not be deleted: {1}"), plugin.Name, result.Issue)));
+ return;
+ }
+
+ await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The '{0}' assistant plugin has been successfully removed."), result.PluginName)));
+ await this.InvokeAsync(this.StateHasChanged);
+ }
+
private static bool IsSendingMail(string sourceUrl) => sourceUrl.TrimStart().StartsWith("mailto:", StringComparison.OrdinalIgnoreCase);
private PluginAssistants? TryGetAssistantPlugin(Guid pluginId) => PluginFactory.RunningPlugins.OfType().FirstOrDefault(x => x.Id == pluginId);
diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs
index 5e9879c7..514643b6 100644
--- a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs
+++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs
@@ -9,22 +9,31 @@ public sealed record AssistantPluginInstallResult(bool Success, Guid PluginId, s
public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, string PluginName, string Issue);
+public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue);
+
public sealed class AssistantPluginInstallService
{
+ private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(AssistantPluginInstallService).Namespace, nameof(AssistantPluginInstallService));
+
private const string PLUGIN_FILE_NAME = "plugin.lua";
private const string ASSISTANT_BUILDER_DIRECTORY_PREFIX = "assistant-builder";
+ private const string DELETE_BACKUP_DIRECTORY = ".plugin-delete-backups";
private const int DIRECTORY_PREFIX_MAX_LEN = 80;
private readonly ILogger logger;
+ private readonly SettingsManager settingsManager;
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 AssistantPluginCheckResult CheckError(string issue) => new(false, Guid.Empty, string.Empty, issue);
+
+ private static AssistantPluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue);
- public AssistantPluginInstallService(ILogger logger)
+ public AssistantPluginInstallService(ILogger logger, SettingsManager settingsManager)
{
this.logger = logger;
+ this.settingsManager = settingsManager;
this.logger.LogInformation("The assistant plugin install service has been initialized.");
}
@@ -54,7 +63,7 @@ public sealed class AssistantPluginInstallService
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 CheckError(TB("The resolved plugin directory is outside the assistant plugin directory."));
return new(true, validation.AssistantPlugin.Id, validation.AssistantPlugin.Name, string.Empty);
}
@@ -103,7 +112,7 @@ public sealed class AssistantPluginInstallService
{
finalDirectory = DetermineFinalDirectory(assistantPluginsRoot, assistantPlugin);
if (!IsPathInsideDirectory(assistantPluginsRoot, finalDirectory))
- return Error("The resolved plugin directory is outside the assistant plugin directory.");
+ return Error(TB("The resolved plugin directory is outside the assistant plugin directory."));
if (Directory.Exists(finalDirectory))
{
@@ -121,12 +130,12 @@ public sealed class AssistantPluginInstallService
}
catch (Exception e)
{
- this.logger.LogError(e, "Failed to delete assistant plugin backup directory '{BackupDirectory}'.", backupDirectory);
+ this.logger.LogError(e, $"Failed to delete assistant plugin backup directory '{backupDirectory}'.");
}
}
await PluginFactory.LoadAll(token);
- this.logger.LogInformation("Installed assistant plugin '{PluginName}' ({PluginId}) to '{PluginDirectory}'.", assistantPlugin.Name, assistantPlugin.Id, finalDirectory);
+ this.logger.LogInformation($"Installed assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) to '{finalDirectory}'.");
return new(true, assistantPlugin.Id, assistantPlugin.Name, finalDirectory, replacedExisting, string.Empty);
}
catch (Exception e)
@@ -145,7 +154,7 @@ public sealed class AssistantPluginInstallService
}
}
- return Error(e.Message);
+ return Error(string.Format(TB("Unexpected error: {0}"), e.Message));
}
finally
{
@@ -158,13 +167,85 @@ public sealed class AssistantPluginInstallService
}
}
+ ///
+ /// Deletes installed local assistant plugin directories.
+ /// The directory gets moved to a backup dir outside the plugin root so the
+ /// plugin loader cannot discover it during reload. On failure, the directory
+ /// and related assistant settings are restored.
+ ///
+ /// Assistant plugin metadata
+ /// Cancellation token for settings storage and plugin reload
+ ///
+ /// Delete result that contains success state, deleted plugin metadata, the original plugin directory,
+ /// and a user-facing issue when deletion failed.
+ ///
+ public async Task DeleteInstalledAssistantAsync(IAvailablePlugin plugin, CancellationToken token)
+ {
+ if (plugin.Type is not PluginType.ASSISTANT)
+ return DeleteError(plugin, plugin.LocalPath, TB("Only assistant plugins can be deleted."));
+
+ if (plugin.IsInternal)
+ return DeleteError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be deleted."));
+
+ if (string.IsNullOrWhiteSpace(plugin.LocalPath))
+ return DeleteError(plugin, string.Empty, TB("The assistant plugin has no local directory."));
+
+ if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue))
+ return DeleteError(plugin, plugin.LocalPath, rootIssue);
+
+ var pluginDirectory = plugin.LocalPath;
+ if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory))
+ return DeleteError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory."));
+
+ if (!Directory.Exists(pluginDirectory))
+ return DeleteError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist."));
+
+ await this.installSemaphore.WaitAsync(token);
+ var backupDirectory = string.Empty;
+ var wasEnabled = false;
+ var removedAudits = new List();
+
+ try
+ {
+ backupDirectory = CreateDeleteBackupDirectory(plugin);
+ Directory.CreateDirectory(Path.GetDirectoryName(backupDirectory)!);
+ Directory.Move(pluginDirectory, backupDirectory);
+
+ wasEnabled = this.settingsManager.ConfigurationData.EnabledPlugins.Remove(plugin.Id);
+ removedAudits = this.settingsManager.ConfigurationData.AssistantPluginAudits
+ .Where(audit => audit.PluginId == plugin.Id)
+ .ToList();
+
+ if (removedAudits.Count > 0)
+ this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
+
+ await this.settingsManager.StoreSettings();
+ await PluginFactory.LoadAll(token);
+
+ TryDeleteDirectory(backupDirectory, "assistant plugin delete backup", this.logger);
+ this.logger.LogInformation($"Deleted assistant plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'.");
+ return new(true, plugin.Id, plugin.Name, pluginDirectory, string.Empty);
+ }
+ catch (Exception e)
+ {
+ this.logger.LogError(e, $"Failed to delete assistant plugin '{plugin.Name}' ({plugin.Id}) from '{pluginDirectory}'.");
+
+ await this.TryRestoreDeletedAssistantPluginAsync(plugin, pluginDirectory, backupDirectory, wasEnabled, removedAudits, token);
+ return DeleteError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message));
+ }
+ finally
+ {
+ this.installSemaphore.Release();
+ }
+ }
+
private async Task ValidateIntoStagingAsync(string lua, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(lua))
- return AssistantPluginValidationResult.Failure("No Lua plugin code was generated.");
+ return AssistantPluginValidationResult.Failure(TB("No Lua plugin code was generated."));
if (!PluginFactory.IsInitialized)
- return AssistantPluginValidationResult.Failure("The plugin system is not initialized yet.");
+ return AssistantPluginValidationResult.Failure(TB("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}");
@@ -179,19 +260,19 @@ public sealed class AssistantPluginInstallService
if (plugin is not PluginAssistants assistantPlugin)
{
this.TryDeleteStagingDirectory(stagingDirectory);
- return AssistantPluginValidationResult.Failure($"The generated plugin is not an assistant plugin. Issue: {string.Join("; ", plugin.Issues)}");
+ return AssistantPluginValidationResult.Failure(string.Format(TB("The generated plugin is not an assistant plugin. Issue: {0}"), string.Join("; ", plugin.Issues)));
}
if (!assistantPlugin.IsValid)
{
this.TryDeleteStagingDirectory(stagingDirectory);
- return AssistantPluginValidationResult.Failure($"The generated assistant plugin is invalid. Issue: {string.Join("; ", assistantPlugin.Issues)}");
+ return AssistantPluginValidationResult.Failure(string.Format(TB("The generated assistant plugin is invalid. Issue: {0}"), string.Join("; ", assistantPlugin.Issues)));
}
if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Type is PluginType.ASSISTANT && availablePlugin.Id == assistantPlugin.Id && availablePlugin.IsInternal))
{
this.TryDeleteStagingDirectory(stagingDirectory);
- return AssistantPluginValidationResult.Failure("The generated assistant plugin uses the ID of an internal AI Studio plugin.");
+ return AssistantPluginValidationResult.Failure(TB("The generated assistant plugin uses the ID of an internal AI Studio plugin."));
}
return new(true, stagingDirectory, assistantPlugin, string.Empty);
@@ -200,7 +281,7 @@ public sealed class AssistantPluginInstallService
{
this.logger.LogError(e, "Failed to validate generated assistant plugin.");
this.TryDeleteStagingDirectory(stagingDirectory);
- return AssistantPluginValidationResult.Failure(e.Message);
+ return AssistantPluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message));
}
}
@@ -212,7 +293,7 @@ public sealed class AssistantPluginInstallService
var dataDirectory = SettingsManager.DataDirectory;
if (string.IsNullOrWhiteSpace(dataDirectory))
{
- issue = "The AI Studio data directory is not initialized yet.";
+ issue = TB("The AI Studio data directory is not initialized yet.");
return false;
}
@@ -222,17 +303,7 @@ public sealed class AssistantPluginInstallService
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);
- }
+ TryDeleteDirectory(stagingDirectory, "assistant plugin staging", this.logger);
}
private static string DetermineFinalDirectory(string assistantPluginsRoot, PluginAssistants assistantPlugin)
@@ -298,8 +369,61 @@ public sealed class AssistantPluginInstallService
return childPath.StartsWith(parentPath, StringComparison.OrdinalIgnoreCase);
}
+ private static bool IsSameDirectory(string firstDirectory, string secondDirectory)
+ {
+ var firstPath = Path.GetFullPath(firstDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var secondPath = Path.GetFullPath(secondDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ return string.Equals(firstPath, secondPath, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string CreateDeleteBackupDirectory(IAvailablePlugin plugin)
+ {
+ var backupRoot = Path.Join(SettingsManager.DataDirectory, DELETE_BACKUP_DIRECTORY);
+ return Path.Join(backupRoot, $"assistant-{plugin.Id:N}-{Guid.NewGuid():N}");
+ }
+
+ private async Task TryRestoreDeletedAssistantPluginAsync(IAvailablePlugin plugin, string pluginDirectory, string backupDirectory, bool wasEnabled, List removedAudits, CancellationToken token)
+ {
+ try
+ {
+ if (!Directory.Exists(pluginDirectory) && Directory.Exists(backupDirectory))
+ Directory.Move(backupDirectory, pluginDirectory);
+
+ if (wasEnabled && !this.settingsManager.ConfigurationData.EnabledPlugins.Contains(plugin.Id))
+ this.settingsManager.ConfigurationData.EnabledPlugins.Add(plugin.Id);
+
+ if (removedAudits.Count > 0)
+ {
+ this.settingsManager.ConfigurationData.AssistantPluginAudits.RemoveAll(audit => audit.PluginId == plugin.Id);
+ this.settingsManager.ConfigurationData.AssistantPluginAudits.AddRange(removedAudits);
+ }
+
+ await this.settingsManager.StoreSettings();
+ await PluginFactory.LoadAll(token);
+ }
+ catch (Exception restoreException)
+ {
+ this.logger.LogError(restoreException, $"Failed to restore assistant plugin '{plugin.Name}' ({plugin.Id}) after a failed delete.");
+ }
+ }
+
+ private static void TryDeleteDirectory(string directory, string directoryDescription, ILogger logger)
+ {
+ if (!Directory.Exists(directory))
+ return;
+
+ try
+ {
+ Directory.Delete(directory, true);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, $"Failed to delete {directoryDescription} directory '{directory}'.");
+ }
+ }
+
private sealed record AssistantPluginValidationResult(bool Success, string StagingDirectory, PluginAssistants? AssistantPlugin, string Issue)
{
public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);
}
-}
\ No newline at end of file
+}