mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-12 20:12:12 +00:00
introduced a safe delete service for non-internal assistant plugins
This commit is contained in:
parent
00d732678c
commit
508ab1abab
@ -64,7 +64,7 @@
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
|
||||
<MudStack Row="true" Spacing="0" AlignItems="AlignItems.Center">
|
||||
@if (context.Type is PluginType.ASSISTANT)
|
||||
{
|
||||
var assistantPlugin = PluginFactory.RunningPlugins.OfType<PluginAssistants>().FirstOrDefault(x => x.Id == context.Id);
|
||||
@ -96,6 +96,13 @@
|
||||
</MudTooltip>
|
||||
}
|
||||
}
|
||||
|
||||
@if (context is IAvailablePlugin availablePlugin && CanDeleteAssistantPlugin(availablePlugin))
|
||||
{
|
||||
<MudTooltip Text="@T("Delete assistant plugin")">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline" Size="Size.Medium" OnClick="@(() => this.DeleteAssistantPluginAsync(availablePlugin))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
|
||||
@ -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<ConfirmDialog>
|
||||
{
|
||||
{
|
||||
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<ConfirmDialog>(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<PluginAssistants>().FirstOrDefault(x => x.Id == pluginId);
|
||||
|
||||
@ -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<AssistantPluginInstallService> 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<AssistantPluginInstallService> logger)
|
||||
public AssistantPluginInstallService(ILogger<AssistantPluginInstallService> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="plugin">Assistant plugin metadata</param>
|
||||
/// <param name="token">Cancellation token for settings storage and plugin reload</param>
|
||||
/// <returns>
|
||||
/// Delete result that contains success state, deleted plugin metadata, the original plugin directory,
|
||||
/// and a user-facing issue when deletion failed.
|
||||
/// </returns>
|
||||
public async Task<AssistantPluginDeleteResult> 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<PluginAssistantAudit>();
|
||||
|
||||
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<AssistantPluginValidationResult> 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<PluginAssistantAudit> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user