diff --git a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs index 514643b6..dbfb7486 100644 --- a/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs +++ b/app/MindWork AI Studio/Tools/Services/AssistantPluginInstallService.cs @@ -11,6 +11,8 @@ public sealed record AssistantPluginCheckResult(bool Success, Guid PluginId, str public sealed record AssistantPluginDeleteResult(bool Success, Guid PluginId, string PluginName, string PluginDirectory, string Issue); +public sealed record AssistantPluginUpdateResult(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)); @@ -30,6 +32,8 @@ public sealed class AssistantPluginInstallService private static AssistantPluginDeleteResult DeleteError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue); + private static AssistantPluginUpdateResult UpdateError(IPluginMetadata plugin, string pluginDirectory, string issue) => new(false, plugin.Id, plugin.Name, pluginDirectory, issue); + public AssistantPluginInstallService(ILogger logger, SettingsManager settingsManager) { this.logger = logger; @@ -239,6 +243,102 @@ public sealed class AssistantPluginInstallService } } + /// + /// Updates installed assistant plugin plugin.lua file. + /// The edited Lua code is validated from the provided string before it is written, + /// but validation uses existing plugin directory as loader context so + /// require(...) can resolve companion files such as icon.lua. + /// After successful validation, the current plugin.lua is backed up, + /// replaced atomically through a temporary file in the plugin directory, and + /// restored when the plugin reload fails. + /// + /// The installed local assistant plugin to update. + /// The edited plugin.lua content. + /// Cancellation token for Lua validation, file IO, and plugin reload. + /// + /// Update result that contains success state, updated plugin metadata, the plugin directory, + /// and a user-facing issue when the update failed. + /// + public async Task UpdateInstalledAssistantAsync(IAvailablePlugin plugin, string lua, CancellationToken token) + { + if (plugin.Type is not PluginType.ASSISTANT) + return UpdateError(plugin, plugin.LocalPath, TB("Only assistant plugins can be edited.")); + + if (plugin.IsInternal) + return UpdateError(plugin, plugin.LocalPath, TB("Internal assistant plugins cannot be edited.")); + + if (string.IsNullOrWhiteSpace(plugin.LocalPath)) + return UpdateError(plugin, string.Empty, TB("The assistant plugin has no local directory.")); + + if (!TryGetAssistantPluginsRoot(out var assistantPluginsRoot, out var rootIssue)) + return UpdateError(plugin, plugin.LocalPath, rootIssue); + + var pluginDirectory = plugin.LocalPath; + if (!IsPathInsideDirectory(assistantPluginsRoot, pluginDirectory) || IsSameDirectory(assistantPluginsRoot, pluginDirectory)) + return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory is outside the local assistant plugin directory.")); + + if (!Directory.Exists(pluginDirectory)) + return UpdateError(plugin, pluginDirectory, TB("The assistant plugin directory does not exist.")); + + var pluginFile = Path.Join(pluginDirectory, PLUGIN_FILE_NAME); + if (!IsPathInsideDirectory(pluginDirectory, pluginFile)) + return UpdateError(plugin, pluginDirectory, TB("The plugin file is outside the assistant plugin directory.")); + + await this.installSemaphore.WaitAsync(token); + var tempFile = string.Empty; + var backupFile = string.Empty; + + try + { + var validation = await this.ValidateInPluginDirectoryAsync(lua, pluginDirectory, token); + if (!validation.Success || validation.AssistantPlugin is null) + return UpdateError(plugin, pluginDirectory, validation.Issue); + + var assistantPlugin = validation.AssistantPlugin; + if (assistantPlugin.Id != plugin.Id) + return UpdateError(plugin, pluginDirectory, TB("The edited assistant plugin must keep the same plugin ID.")); + + var pluginCode = lua.Trim(); + tempFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.tmp-{Guid.NewGuid():N}"); + backupFile = Path.Join(pluginDirectory, $"{PLUGIN_FILE_NAME}.backup-{Guid.NewGuid():N}"); + + await File.WriteAllTextAsync(tempFile, pluginCode, Encoding.UTF8, token); + + if (File.Exists(pluginFile)) + File.Move(pluginFile, backupFile); + + File.Move(tempFile, pluginFile); + + try + { + await PluginFactory.LoadAll(token); + if (File.Exists(backupFile)) + File.Delete(backupFile); + + this.logger.LogInformation($"Updated assistant plugin '{assistantPlugin.Name}' ({assistantPlugin.Id}) at '{pluginFile}'."); + return new(true, assistantPlugin.Id, assistantPlugin.Name, pluginDirectory, string.Empty); + } + catch (Exception reloadException) + { + this.logger.LogError(reloadException, $"Failed to reload plugins after editing assistant plugin '{plugin.Name}' ({plugin.Id})."); + await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token); + return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), reloadException.Message)); + } + } + catch (Exception e) + { + this.logger.LogError(e, $"Failed to update assistant plugin '{plugin.Name}' ({plugin.Id}) at '{pluginDirectory}'."); + await this.TryRestoreEditedAssistantPluginAsync(pluginFile, backupFile, token); + return UpdateError(plugin, pluginDirectory, string.Format(TB("Unexpected error: {0}"), e.Message)); + } + finally + { + this.TryDeleteFile(tempFile, "assistant plugin edit temp file"); + + this.installSemaphore.Release(); + } + } + private async Task ValidateIntoStagingAsync(string lua, CancellationToken token) { if (string.IsNullOrWhiteSpace(lua)) @@ -256,26 +356,18 @@ public sealed class AssistantPluginInstallService 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.Failure(string.Format(TB("The generated plugin is not an assistant plugin. Issue: {0}"), string.Join("; ", plugin.Issues))); - } + var validation = await this.ValidateAssistantPluginCodeAsync( + stagingDirectory, + pluginCode, + TB("The generated plugin is not an assistant plugin. Issue: {0}"), + TB("The generated assistant plugin is invalid. Issue: {0}"), + TB("The generated assistant plugin uses the ID of an internal AI Studio plugin."), + token); - if (!assistantPlugin.IsValid) - { + if (!validation.Success || validation.AssistantPlugin is null) this.TryDeleteStagingDirectory(stagingDirectory); - 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(TB("The generated assistant plugin uses the ID of an internal AI Studio plugin.")); - } - - return new(true, stagingDirectory, assistantPlugin, string.Empty); + return validation with { StagingDirectory = stagingDirectory }; } catch (Exception e) { @@ -285,6 +377,52 @@ public sealed class AssistantPluginInstallService } } + private async Task ValidateInPluginDirectoryAsync(string lua, string pluginDirectory, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(lua)) + return AssistantPluginValidationResult.Failure(TB("No Lua plugin code was generated.")); + + if (!PluginFactory.IsInitialized) + return AssistantPluginValidationResult.Failure(TB("The plugin system is not initialized yet.")); + + try + { + return await this.ValidateAssistantPluginCodeAsync( + pluginDirectory, + lua.Trim(), + TB("The edited plugin is not an assistant plugin. Issue: {0}"), + TB("The edited assistant plugin is invalid. Issue: {0}"), + TB("The edited assistant plugin uses the ID of an internal AI Studio plugin."), + token); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to validate edited assistant plugin."); + return AssistantPluginValidationResult.Failure(string.Format(TB("Unexpected error: {0}"), e.Message)); + } + } + + private async Task ValidateAssistantPluginCodeAsync( + string pluginDirectory, + string pluginCode, + string notAssistantIssue, + string invalidAssistantIssue, + string internalPluginIdIssue, + CancellationToken token) + { + var plugin = await PluginFactory.Load(pluginDirectory, pluginCode, token); + if (plugin is not PluginAssistants assistantPlugin) + return AssistantPluginValidationResult.Failure(string.Format(notAssistantIssue, string.Join("; ", plugin.Issues))); + + if (!assistantPlugin.IsValid) + return AssistantPluginValidationResult.Failure(string.Format(invalidAssistantIssue, string.Join("; ", assistantPlugin.Issues))); + + if (PluginFactory.AvailablePlugins.Any(availablePlugin => availablePlugin.Type is PluginType.ASSISTANT && availablePlugin.Id == assistantPlugin.Id && availablePlugin.IsInternal)) + return AssistantPluginValidationResult.Failure(internalPluginIdIssue); + + return new(true, string.Empty, assistantPlugin, string.Empty); + } + private static bool TryGetAssistantPluginsRoot(out string assistantPluginsRoot, out string issue) { assistantPluginsRoot = string.Empty; @@ -407,6 +545,25 @@ public sealed class AssistantPluginInstallService } } + private async Task TryRestoreEditedAssistantPluginAsync(string pluginFile, string backupFile, CancellationToken token) + { + try + { + if (string.IsNullOrWhiteSpace(backupFile) || !File.Exists(backupFile)) + return; + + if (File.Exists(pluginFile)) + File.Delete(pluginFile); + + File.Move(backupFile, pluginFile); + await PluginFactory.LoadAll(token); + } + catch (Exception restoreException) + { + this.logger.LogError(restoreException, $"Failed to restore assistant plugin file '{pluginFile}' after a failed edit."); + } + } + private static void TryDeleteDirectory(string directory, string directoryDescription, ILogger logger) { if (!Directory.Exists(directory)) @@ -422,6 +579,21 @@ public sealed class AssistantPluginInstallService } } + private void TryDeleteFile(string filePath, string fileDescription) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return; + + try + { + File.Delete(filePath); + } + catch (Exception e) + { + this.logger.LogError(e, $"Failed to delete {fileDescription} '{filePath}'."); + } + } + private sealed record AssistantPluginValidationResult(bool Success, string StagingDirectory, PluginAssistants? AssistantPlugin, string Issue) { public static AssistantPluginValidationResult Failure(string issue) => new(false, string.Empty, null, issue);