diff --git a/app/MindWork AI Studio/Components/AssistantPluginDeleteAction.razor.cs b/app/MindWork AI Studio/Components/AssistantPluginDeleteAction.razor.cs deleted file mode 100644 index b1ce0f7e..00000000 --- a/app/MindWork AI Studio/Components/AssistantPluginDeleteAction.razor.cs +++ /dev/null @@ -1,92 +0,0 @@ -using AIStudio.Dialogs; -using AIStudio.Tools.Media; -using AIStudio.Tools.PluginSystem; -using AIStudio.Tools.Services; -using Microsoft.AspNetCore.Components; -using DialogOptions = AIStudio.Dialogs.DialogOptions; - -namespace AIStudio.Components; - -public partial class AssistantPluginDeleteAction : MSGComponentBase -{ - [Parameter, EditorRequired] - public IAvailablePlugin Plugin { get; set; } = null!; - - [Inject] - private IDialogService DialogService { get; init; } = null!; - - [Inject] - private PluginInstallService PluginInstallService { get; init; } = null!; - - [Inject] - private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!; - - [Inject] - private ILogger Logger { get; init; } = null!; - - // The type check keeps this action apart from the LocalPluginDeleteAction next to it, which - // covers every other deletable type. Both are merged into one component in a follow-up: - private bool CanDelete => this.Plugin.Type is PluginType.ASSISTANT && PluginInstallService.CanDeletePlugin(this.Plugin); - - private bool IsBlockedByActiveWork => this.PluginInstallService.HasActiveAssistantWork(this.Plugin.Id); - - private string Tooltip => this.IsBlockedByActiveWork - ? this.T("The assistant cannot be deleted while background work is still running.") - : this.T("Delete assistant plugin"); - - protected override async Task OnInitializedAsync() - { - this.ApplyFilters([], [ Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED ]); - this.MediaTranscriptionService.StateChanged += this.OnMediaTranscriptionStateChanged; - await base.OnInitializedAsync(); - } - - private async Task DeleteAssistantPluginAsync() - { - if (!this.CanDelete || this.IsBlockedByActiveWork) - return; - - 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."), this.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.PluginInstallService.DeletePluginAsync(this.Plugin, CancellationToken.None); - if (!result.Success) - { - this.Logger.LogError("Failed to delete assistant plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", result.PluginName, result.PluginId, result.PluginDirectory, result.Issue); - await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The assistant plugin '{0}' could not be deleted: {1}"), this.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))); - } - - private void OnMediaTranscriptionStateChanged(MediaImportOwner owner) - { - if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal)) - _ = this.InvokeAsync(this.StateHasChanged); - } - - protected override Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default - { - if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED) - this.StateHasChanged(); - - return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); - } - - protected override void DisposeResources() - { - this.MediaTranscriptionService.StateChanged -= this.OnMediaTranscriptionStateChanged; - base.DisposeResources(); - } -} diff --git a/app/MindWork AI Studio/Components/LocalPluginDeleteAction.razor b/app/MindWork AI Studio/Components/LocalPluginDeleteAction.razor deleted file mode 100644 index f8c839d1..00000000 --- a/app/MindWork AI Studio/Components/LocalPluginDeleteAction.razor +++ /dev/null @@ -1,13 +0,0 @@ -@inherits MSGComponentBase - -@if (this.CanDelete) -{ - - - -} diff --git a/app/MindWork AI Studio/Components/LocalPluginDeleteAction.razor.cs b/app/MindWork AI Studio/Components/LocalPluginDeleteAction.razor.cs deleted file mode 100644 index 4863f4dd..00000000 --- a/app/MindWork AI Studio/Components/LocalPluginDeleteAction.razor.cs +++ /dev/null @@ -1,104 +0,0 @@ -using AIStudio.Dialogs; -using AIStudio.Tools.PluginSystem; -using AIStudio.Tools.Services; - -using Microsoft.AspNetCore.Components; - -using DialogOptions = AIStudio.Dialogs.DialogOptions; - -namespace AIStudio.Components; - -/// -/// Lets users remove a configuration or language plugin they installed or placed themselves. -/// -/// -/// Without this action, such a plugin could only be removed from the data directory by hand. That is -/// especially painful for configuration plugins, which have no activation switch at all. Plugins -/// shipped with AI Studio and plugins deployed by an organization stay untouched: the action does -/// not appear for them. -/// -public partial class LocalPluginDeleteAction : MSGComponentBase -{ - [Parameter, EditorRequired] - public IAvailablePlugin Plugin { get; set; } = null!; - - [Inject] - private IDialogService DialogService { get; init; } = null!; - - [Inject] - private PluginInstallService PluginInstallService { get; init; } = null!; - - [Inject] - private ILogger Logger { get; init; } = null!; - - private bool isDeleting; - - // The type check keeps this action apart from the AssistantPluginDeleteAction next to it, which - // covers assistants. Both are merged into one component in a follow-up: - private bool CanDelete => this.Plugin.Type is not PluginType.ASSISTANT && PluginInstallService.CanDeletePlugin(this.Plugin); - - private string Tooltip => this.Plugin.Type is PluginType.CONFIGURATION - ? this.T("Delete configuration plugin") - : this.T("Delete language plugin"); - - private async Task DeleteLocalPluginAsync() - { - if (!this.CanDelete || this.isDeleting) - return; - - if (!await this.ConfirmDeletionAsync()) - return; - - this.isDeleting = true; - await this.InvokeAsync(this.StateHasChanged); - - try - { - var result = await this.PluginInstallService.DeletePluginAsync(this.Plugin, CancellationToken.None); - if (!result.Success) - { - this.Logger.LogError("Failed to delete {PluginType} plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", this.Plugin.Type, result.PluginName, result.PluginId, result.PluginDirectory, result.Issue); - await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The plugin '{0}' could not be deleted: {1}"), this.Plugin.Name, result.Issue))); - return; - } - - await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The plugin '{0}' has been successfully removed."), result.PluginName))); - } - finally - { - this.isDeleting = false; - await this.InvokeAsync(this.StateHasChanged); - } - } - - /// - /// Asks the user before the deletion. A configuration gets the dialog listing its consequences, - /// because removing it also removes the providers and settings it brought. A language plugin - /// only owns its own files, so a plain confirmation is enough. - /// - private async Task ConfirmDeletionAsync() - { - if (this.Plugin.Type is PluginType.CONFIGURATION) - { - var configurationParameters = new DialogParameters - { - { x => x.PluginName, this.Plugin.Name }, - { x => x.Summary, this.PluginInstallService.BuildConfigurationDeleteSummary(this.Plugin) }, - }; - - var configurationDialog = await this.DialogService.ShowAsync(this.T("Delete Configuration Plugin"), configurationParameters, DialogOptions.FULLSCREEN); - return await configurationDialog.Result is { Canceled: false }; - } - - var parameters = new DialogParameters - { - { - x => x.Message, - string.Format(this.T("Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically."), this.Plugin.Name) - }, - }; - - var dialog = await this.DialogService.ShowAsync(this.T("Delete Language Plugin"), parameters, DialogOptions.FULLSCREEN); - return await dialog.Result is { Canceled: false }; - } -} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/AssistantPluginDeleteAction.razor b/app/MindWork AI Studio/Components/PluginDeleteAction.razor similarity index 67% rename from app/MindWork AI Studio/Components/AssistantPluginDeleteAction.razor rename to app/MindWork AI Studio/Components/PluginDeleteAction.razor index 777b94d5..8001dcef 100644 --- a/app/MindWork AI Studio/Components/AssistantPluginDeleteAction.razor +++ b/app/MindWork AI Studio/Components/PluginDeleteAction.razor @@ -7,7 +7,7 @@ Color="Color.Error" Variant="Variant.Text" Size="Size.Medium" - Disabled="@this.IsBlockedByActiveWork" - OnClick="@this.DeleteAssistantPluginAsync" /> + Disabled="@(this.isDeleting || this.IsBlockedByActiveWork)" + OnClick="@this.DeletePluginAsync" /> } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs b/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs new file mode 100644 index 00000000..e90ea1cf --- /dev/null +++ b/app/MindWork AI Studio/Components/PluginDeleteAction.razor.cs @@ -0,0 +1,169 @@ +using AIStudio.Dialogs; +using AIStudio.Tools.Media; +using AIStudio.Tools.PluginSystem; +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; + +using DialogOptions = AIStudio.Dialogs.DialogOptions; + +namespace AIStudio.Components; + +/// +/// Lets users remove a plugin they installed or placed themselves. +/// +/// +/// Without this action, such a plugin could only be removed from the data directory by hand. That is +/// especially painful for configuration plugins, which have no activation switch at all. Plugins +/// shipped with AI Studio and plugins deployed by an organization stay untouched: the action does +/// not appear for them. +/// +public partial class PluginDeleteAction : MSGComponentBase +{ + [Parameter, EditorRequired] + public IAvailablePlugin Plugin { get; set; } = null!; + + [Inject] + private IDialogService DialogService { get; init; } = null!; + + [Inject] + private PluginInstallService PluginInstallService { get; init; } = null!; + + [Inject] + private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + private bool isDeleting; + + private bool IsAssistant => this.Plugin.Type is PluginType.ASSISTANT; + + private bool CanDelete => PluginInstallService.CanDeletePlugin(this.Plugin); + + /// + /// True while an assistant still owns background work. We keep the action visible and block it + /// instead of hiding it, so that the tooltip can explain why it does nothing right now. + /// + private bool IsBlockedByActiveWork => this.IsAssistant && this.PluginInstallService.HasActiveAssistantWork(this.Plugin.Id); + + private string Tooltip + { + get + { + if (this.IsBlockedByActiveWork) + return this.T("The assistant cannot be deleted while background work is still running."); + + return this.Plugin.Type switch + { + PluginType.ASSISTANT => this.T("Delete assistant plugin"), + PluginType.CONFIGURATION => this.T("Delete configuration plugin"), + + _ => this.T("Delete language plugin"), + }; + } + } + + #region Overrides of MSGComponentBase + + protected override async Task OnInitializedAsync() + { + // Only an assistant can be busy. We watch its sessions and transcriptions, so the action + // reflects the current state without the user reloading the page: + this.ApplyFilters([], this.IsAssistant ? [Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED] : []); + if (this.IsAssistant) + this.MediaTranscriptionService.StateChanged += this.OnMediaTranscriptionStateChanged; + + await base.OnInitializedAsync(); + } + + protected override Task ProcessIncomingMessage(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default + { + if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED) + this.StateHasChanged(); + + return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data); + } + + protected override void DisposeResources() + { + if (this.IsAssistant) + this.MediaTranscriptionService.StateChanged -= this.OnMediaTranscriptionStateChanged; + + base.DisposeResources(); + } + + #endregion + + private async Task DeletePluginAsync() + { + if (!this.CanDelete || this.isDeleting || this.IsBlockedByActiveWork) + return; + + if (!await this.ConfirmDeletionAsync()) + return; + + this.isDeleting = true; + await this.InvokeAsync(this.StateHasChanged); + + try + { + var result = await this.PluginInstallService.DeletePluginAsync(this.Plugin, CancellationToken.None); + if (!result.Success) + { + this.Logger.LogError("Failed to delete {PluginType} plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", this.Plugin.Type, result.PluginName, result.PluginId, result.PluginDirectory, result.Issue); + await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The plugin '{0}' could not be deleted: {1}"), this.Plugin.Name, result.Issue))); + return; + } + + await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The plugin '{0}' has been successfully removed."), result.PluginName))); + } + finally + { + this.isDeleting = false; + await this.InvokeAsync(this.StateHasChanged); + } + } + + /// + /// Asks the user before the deletion. A configuration gets the dialog listing its consequences, + /// because removing it also removes the providers and settings it brought. Assistants and + /// language plugins only own their own files, so a plain confirmation is enough. + /// + private async Task ConfirmDeletionAsync() + { + if (this.Plugin.Type is PluginType.CONFIGURATION) + { + var configurationParameters = new DialogParameters + { + { x => x.PluginName, this.Plugin.Name }, + { x => x.Summary, this.PluginInstallService.BuildConfigurationDeleteSummary(this.Plugin) }, + }; + + var configurationDialog = await this.DialogService.ShowAsync(this.T("Delete Configuration Plugin"), configurationParameters, DialogOptions.FULLSCREEN); + return await configurationDialog.Result is { Canceled: false }; + } + + var title = this.IsAssistant + ? this.T("Delete Assistant Plugin") + : this.T("Delete Language Plugin"); + + var message = this.IsAssistant + ? string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), this.Plugin.Name) + : string.Format(this.T("Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically."), this.Plugin.Name); + + var parameters = new DialogParameters + { + { x => x.Message, message }, + }; + + var dialog = await this.DialogService.ShowAsync(title, parameters, DialogOptions.FULLSCREEN); + return await dialog.Result is { Canceled: false }; + } + + private void OnMediaTranscriptionStateChanged(MediaImportOwner owner) + { + if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal)) + _ = this.InvokeAsync(this.StateHasChanged); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Pages/Assistants.razor b/app/MindWork AI Studio/Pages/Assistants.razor index 3718d9d5..e8b622c9 100644 --- a/app/MindWork AI Studio/Pages/Assistants.razor +++ b/app/MindWork AI Studio/Pages/Assistants.razor @@ -58,7 +58,7 @@ @if (availablePlugin is not null) { - + } diff --git a/app/MindWork AI Studio/Pages/Plugins.razor b/app/MindWork AI Studio/Pages/Plugins.razor index c443f9e5..4ceaa044 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor +++ b/app/MindWork AI Studio/Pages/Plugins.razor @@ -138,8 +138,7 @@ @if (context is IAvailablePlugin availablePlugin) { - - + }