From 102b344557d827ba642c294e2018976ef3b5e408 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 6 Jun 2026 10:06:41 +0200 Subject: [PATCH] Improved the dialog for moving chats into workspaces (#796) --- .../Assistants/I18N/allTexts.lua | 18 ++ .../Components/ChatComponent.razor.cs | 32 +++- .../Components/Workspaces.razor.cs | 49 ++++-- .../Dialogs/DialogOptions.cs | 6 + .../Dialogs/SingleInputDialog.razor.cs | 7 +- .../Dialogs/WorkspaceSelectionDialog.razor | 55 +++++- .../Dialogs/WorkspaceSelectionDialog.razor.cs | 163 +++++++++++++++++- .../plugin.lua | 18 ++ .../plugin.lua | 18 ++ app/MindWork AI Studio/Tools/Event.cs | 10 ++ .../Tools/WorkspaceBehaviour.cs | 121 ++++++++++++- app/MindWork AI Studio/wwwroot/app.js | 27 +++ .../wwwroot/changelog/v26.6.1.md | 2 + 13 files changed, 489 insertions(+), 37 deletions(-) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 81324295..6d2a30e1 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3253,6 +3253,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3045856778"] = "Move Chat to -- Please enter a new or edit the name for your workspace '{0}': UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Please enter a new or edit the name for your workspace '{0}':" +-- There is already a workspace with this name. Please choose a different name. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "There is already a workspace with this name. Please choose a different name." + -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." @@ -5794,6 +5797,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::UPDATEDIALOG::T25417398"] = "Update from v{0 -- Install later UI_TEXT_CONTENT["AISTUDIO::DIALOGS::UPDATEDIALOG::T2936430090"] = "Install later" +-- Create new workspace +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T1541251414"] = "Create new workspace" + +-- Add workspace +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T1586005241"] = "Add workspace" + +-- Workspace name +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T295876489"] = "Workspace name" + +-- There is already a workspace with this name. Please choose a different name. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T3249036008"] = "There is already a workspace with this name. Please choose a different name." + +-- Please enter a workspace name. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T3288132732"] = "Please enter a workspace name." + -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T900713019"] = "Cancel" diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index ded3427f..62caf008 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -101,7 +101,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable protected override async Task OnInitializedAsync() { // Apply the filters for the message bus: - this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); + this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED ]); // Configure the spellchecking for the user input: this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES); @@ -377,6 +377,29 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable this.WorkspaceName(this.currentWorkspaceName); } + private async Task RefreshRenamedWorkspaceHeaderAsync(Guid workspaceId) + { + var currentChatThread = this.ChatThread; + if (currentChatThread is null || currentChatThread.WorkspaceId != workspaceId) + return; + + var syncVersion = Interlocked.Increment(ref this.workspaceHeaderSyncVersion); + var chatThreadId = currentChatThread.ChatId; + var loadedWorkspaceName = await WorkspaceBehaviour.LoadWorkspaceNameAsync(workspaceId); + + if (syncVersion != this.workspaceHeaderSyncVersion) + return; + + if (this.ChatThread is null + || this.ChatThread.ChatId != chatThreadId + || this.ChatThread.WorkspaceId != workspaceId) + return; + + this.currentChatThreadId = chatThreadId; + this.currentWorkspaceId = workspaceId; + this.PublishWorkspaceNameIfChanged(loadedWorkspaceName); + } + private async Task SyncForegroundChatAsync() { var nextForegroundChatId = this.ChatThread?.ChatId ?? Guid.Empty; @@ -864,7 +887,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable { x => x.ConfirmText, T("Move chat") }, }; - var dialogReference = await this.DialogService.ShowAsync(T("Move Chat to Workspace"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Move Chat to Workspace"), dialogParameters, DialogOptions.FULLSCREEN_MANUAL_ESCAPE); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -1046,6 +1069,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable if(this.autoSaveEnabled) await this.SaveThread(); break; + + case Event.WORKSPACE_RENAMED: + if (data is Guid workspaceId) + await this.RefreshRenamedWorkspaceHeaderAsync(workspaceId); + break; case Event.AI_JOB_CHANGED: case Event.AI_JOB_FINISHED: diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index e370b699..46f44a96 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -3,7 +3,6 @@ using System.Text.Json; using AIStudio.Chat; using AIStudio.Dialogs; -using AIStudio.Settings; using AIStudio.Tools.AIJobs; using Microsoft.AspNetCore.Components; @@ -57,7 +56,7 @@ public partial class Workspaces : MSGComponentBase protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED ]); + this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]); _ = this.LoadTreeItemsAsync(startPrefetch: true); } @@ -101,6 +100,27 @@ public partial class Workspaces : MSGComponentBase private string GetAddChatToWorkspaceTooltip(string workspaceName) => string.Format(T("Start a new chat in workspace '{0}'"), workspaceName); + private async Task> CreateWorkspaceNameValidationAsync(Guid excludedWorkspaceId = default, string? originalWorkspaceName = null) + { + var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync(); + return workspaceName => + { + var normalizedWorkspaceName = WorkspaceBehaviour.NormalizeWorkspaceName(workspaceName ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalizedWorkspaceName)) + return null; + + if (!string.IsNullOrWhiteSpace(originalWorkspaceName) && + string.Equals(WorkspaceBehaviour.NormalizeWorkspaceName(originalWorkspaceName), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase)) + return null; + + var nameExists = snapshot.Workspaces.Any(workspace => + workspace.WorkspaceId != excludedWorkspaceId && + string.Equals(WorkspaceBehaviour.NormalizeWorkspaceName(workspace.Name), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase)); + + return nameExists ? T("There is already a workspace with this name. Please choose a different name.") : null; + }; + } + private void BuildTreeItems(WorkspaceTreeCacheSnapshot snapshot) { this.treeItems.Clear(); @@ -720,6 +740,7 @@ public partial class Workspaces : MSGComponentBase { x => x.ConfirmColor, Color.Info }, { x => x.AllowEmptyInput, false }, { x => x.EmptyInputErrorMessage, T("Please enter a workspace name.") }, + { x => x.AdditionalValidation, await this.CreateWorkspaceNameValidationAsync(workspaceId, workspaceName) }, }; var dialogReference = await this.DialogService.ShowAsync(T("Rename Workspace"), dialogParameters, DialogOptions.FULLSCREEN); @@ -728,9 +749,10 @@ public partial class Workspaces : MSGComponentBase return; var alteredWorkspaceName = (dialogResult.Data as string)!; - var workspaceNamePath = Path.Join(workspacePath, "name"); - await File.WriteAllTextAsync(workspaceNamePath, alteredWorkspaceName, Encoding.UTF8); - await WorkspaceBehaviour.UpdateWorkspaceNameInCacheAsync(workspaceId, alteredWorkspaceName); + if (!await WorkspaceBehaviour.RenameWorkspaceAsync(workspaceId, alteredWorkspaceName)) + return; + + await this.SendMessage(Event.WORKSPACE_RENAMED, workspaceId); await this.LoadTreeItemsAsync(startPrefetch: false); } @@ -745,6 +767,7 @@ public partial class Workspaces : MSGComponentBase { x => x.ConfirmColor, Color.Info }, { x => x.AllowEmptyInput, false }, { x => x.EmptyInputErrorMessage, T("Please enter a workspace name.") }, + { x => x.AdditionalValidation, await this.CreateWorkspaceNameValidationAsync() }, }; var dialogReference = await this.DialogService.ShowAsync(T("Add Workspace"), dialogParameters, DialogOptions.FULLSCREEN); @@ -752,14 +775,10 @@ public partial class Workspaces : MSGComponentBase if (dialogResult is null || dialogResult.Canceled) return; - var workspaceId = Guid.NewGuid(); - var workspacePath = Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString()); - Directory.CreateDirectory(workspacePath); - var workspaceName = (dialogResult.Data as string)!; - var workspaceNamePath = Path.Join(workspacePath, "name"); - await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8); - await WorkspaceBehaviour.AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName); + var result = await WorkspaceBehaviour.TryCreateWorkspaceAsync(workspaceName); + if (!result.Success) + return; await this.LoadTreeItemsAsync(startPrefetch: false); } @@ -804,7 +823,7 @@ public partial class Workspaces : MSGComponentBase { x => x.ConfirmText, T("Move chat") }, }; - var dialogReference = await this.DialogService.ShowAsync(T("Move Chat to Workspace"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogReference = await this.DialogService.ShowAsync(T("Move Chat to Workspace"), dialogParameters, DialogOptions.FULLSCREEN_MANUAL_ESCAPE); var dialogResult = await dialogReference.Result; if (dialogResult is null || dialogResult.Canceled) return; @@ -868,6 +887,10 @@ public partial class Workspaces : MSGComponentBase await this.ForceRefreshFromDiskAsync(); break; + case Event.WORKSPACE_CREATED: + await this.LoadTreeItemsAsync(startPrefetch: false); + break; + case Event.AI_JOB_CHANGED: case Event.AI_JOB_FINISHED: case Event.CHAT_GENERATION_CHANGED: diff --git a/app/MindWork AI Studio/Dialogs/DialogOptions.cs b/app/MindWork AI Studio/Dialogs/DialogOptions.cs index e2373824..ddb1d090 100644 --- a/app/MindWork AI Studio/Dialogs/DialogOptions.cs +++ b/app/MindWork AI Studio/Dialogs/DialogOptions.cs @@ -7,6 +7,12 @@ public static class DialogOptions CloseOnEscapeKey = true, FullWidth = true, MaxWidth = MaxWidth.Medium, }; + + public static readonly MudBlazor.DialogOptions FULLSCREEN_MANUAL_ESCAPE = new() + { + CloseOnEscapeKey = false, + FullWidth = true, MaxWidth = MaxWidth.Medium, + }; public static readonly MudBlazor.DialogOptions FULLSCREEN_NO_HEADER = new() { diff --git a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs index c858b38c..301bf937 100644 --- a/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/SingleInputDialog.razor.cs @@ -31,6 +31,9 @@ public partial class SingleInputDialog : MSGComponentBase [Parameter] public string EmptyInputErrorMessage { get; set; } = string.Empty; + [Parameter] + public Func? AdditionalValidation { get; set; } + private static readonly Dictionary USER_INPUT_ATTRIBUTES = new(); private MudForm form = null!; @@ -52,8 +55,8 @@ public partial class SingleInputDialog : MSGComponentBase { if (!this.AllowEmptyInput && string.IsNullOrWhiteSpace(value)) return string.IsNullOrWhiteSpace(this.EmptyInputErrorMessage) ? T("Please enter a value.") : this.EmptyInputErrorMessage; - - return null; + + return this.AdditionalValidation?.Invoke(value); } private void Cancel() => this.MudDialog.Cancel(); diff --git a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor index 05493cff..460b1bd6 100644 --- a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor +++ b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor @@ -5,18 +5,57 @@ @this.Message - @foreach (var (workspaceName, workspaceId) in this.workspaces) + @foreach (var workspace in this.workspaces) { - + } - - @T("Cancel") - - - @this.ConfirmText - + + + + @if (this.showCreateWorkspaceForm) + { + + + + } + else + { + + @T("Create new workspace") + + } + + + + @T("Cancel") + + @if (this.showCreateWorkspaceForm) + { + + @T("Add workspace") + + } + else + { + + @this.ConfirmText + + } + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs index ca4b625e..46cf6ea6 100644 --- a/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs +++ b/app/MindWork AI Studio/Dialogs/WorkspaceSelectionDialog.razor.cs @@ -1,14 +1,20 @@ using AIStudio.Components; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; namespace AIStudio.Dialogs; public partial class WorkspaceSelectionDialog : MSGComponentBase { + private readonly record struct WorkspaceSelectionItem(Guid WorkspaceId, string Name); + [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; + [Inject] + private IJSRuntime JsRuntime { get; init; } = null!; + [Parameter] public string Message { get; set; } = string.Empty; @@ -18,8 +24,18 @@ public partial class WorkspaceSelectionDialog : MSGComponentBase [Parameter] public string ConfirmText { get; set; } = "OK"; - private readonly Dictionary workspaces = new(); + private readonly List workspaces = []; + private readonly string escapeHandlerId = $"workspace-selection-dialog-{Guid.NewGuid():N}"; + private MudForm? createWorkspaceForm; + private MudTextField? newWorkspaceNameField; + private DotNetObjectReference? dotNetReference; private Guid selectedWorkspace; + private string newWorkspaceName = string.Empty; + private bool isCreatingWorkspace; + private bool showCreateWorkspaceForm; + private bool shouldFocusNewWorkspaceName; + private string? createWorkspaceError; + private string? createWorkspaceErrorName; #region Overrides of ComponentBase @@ -29,15 +45,156 @@ public partial class WorkspaceSelectionDialog : MSGComponentBase var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync(); foreach (var workspace in snapshot.Workspaces) - this.workspaces[workspace.Name] = workspace.WorkspaceId; + this.workspaces.Add(new(workspace.WorkspaceId, workspace.Name)); this.StateHasChanged(); await base.OnInitializedAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + this.dotNetReference = DotNetObjectReference.Create(this); + await this.JsRuntime.InvokeVoidAsync("registerEscapeHandler", this.escapeHandlerId, this.dotNetReference); + } + + if (this.shouldFocusNewWorkspaceName && this.newWorkspaceNameField is not null) + { + this.shouldFocusNewWorkspaceName = false; + await this.newWorkspaceNameField.FocusAsync(); + } + + await base.OnAfterRenderAsync(firstRender); + } + #endregion - private void Cancel() => this.MudDialog.Cancel(); + private string? ValidateNewWorkspaceName(string? workspaceName) + { + var normalizedWorkspaceName = WorkspaceBehaviour.NormalizeWorkspaceName(workspaceName ?? string.Empty); + if (string.IsNullOrWhiteSpace(normalizedWorkspaceName)) + return T("Please enter a workspace name."); + + if (this.IsWorkspaceNameExisting(normalizedWorkspaceName)) + return T("There is already a workspace with this name. Please choose a different name."); + + if (this.createWorkspaceError is not null && string.Equals(this.createWorkspaceErrorName, normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase)) + return this.createWorkspaceError; + + return null; + } + + private bool IsWorkspaceNameExisting(string normalizedWorkspaceName) + { + return this.workspaces.Any(workspace => + string.Equals(WorkspaceBehaviour.NormalizeWorkspaceName(workspace.Name), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase)); + } + + private async Task HandleNewWorkspaceNameKeyDown(KeyboardEventArgs keyEvent) + { + var key = keyEvent.Key.ToLowerInvariant(); + var code = keyEvent.Code.ToLowerInvariant(); + if (key is not "enter" && code is not "enter" and not "numpadenter") + return; + + if (keyEvent is { AltKey: true } or { CtrlKey: true } or { MetaKey: true }) + return; + + await this.CreateWorkspaceAsync(); + } + + private void ShowCreateWorkspaceForm() + { + this.createWorkspaceError = null; + this.createWorkspaceErrorName = null; + this.newWorkspaceName = string.Empty; + this.showCreateWorkspaceForm = true; + this.shouldFocusNewWorkspaceName = true; + } + + private async Task CreateWorkspaceAsync() + { + if (this.createWorkspaceForm is null) + return; + + this.createWorkspaceError = null; + this.createWorkspaceErrorName = null; + await this.createWorkspaceForm.Validate(); + if (!this.createWorkspaceForm.IsValid) + return; + + this.isCreatingWorkspace = true; + try + { + var result = await WorkspaceBehaviour.TryCreateWorkspaceAsync(this.newWorkspaceName); + if (!result.Success) + { + this.createWorkspaceError = T("There is already a workspace with this name. Please choose a different name."); + this.createWorkspaceErrorName = WorkspaceBehaviour.NormalizeWorkspaceName(this.newWorkspaceName); + await this.createWorkspaceForm.Validate(); + return; + } + + this.workspaces.Add(new(result.Workspace.WorkspaceId, result.Workspace.Name)); + this.selectedWorkspace = result.Workspace.WorkspaceId; + this.newWorkspaceName = string.Empty; + this.createWorkspaceForm?.ResetValidation(); + this.showCreateWorkspaceForm = false; + await this.SendMessage(Event.WORKSPACE_CREATED, result.Workspace.WorkspaceId); + } + finally + { + this.isCreatingWorkspace = false; + } + } + + private void Cancel() + { + if (!this.showCreateWorkspaceForm) + { + this.MudDialog.Cancel(); + return; + } + + this.createWorkspaceError = null; + this.createWorkspaceErrorName = null; + this.newWorkspaceName = string.Empty; + this.createWorkspaceForm?.ResetValidation(); + this.showCreateWorkspaceForm = false; + this.shouldFocusNewWorkspaceName = false; + } + + [JSInvokable] + public async Task HandleEscapeKeyAsync() + { + await this.InvokeAsync(() => + { + this.Cancel(); + this.StateHasChanged(); + }); + } private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.selectedWorkspace)); + + #region Overrides of MSGComponentBase + + protected override void DisposeResources() + { + try + { + _ = this.JsRuntime.InvokeVoidAsync("unregisterEscapeHandler", this.escapeHandlerId).AsTask(); + } + catch + { + // Ignore JS cleanup errors while the dialog is being disposed. + } + + this.dotNetReference?.Dispose(); + this.dotNetReference = null; + + base.DisposeResources(); + } + + #endregion } \ No newline at end of file diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 198926dc..8367e109 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3255,6 +3255,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3045856778"] = "Chat in den -- Please enter a new or edit the name for your workspace '{0}': UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Bitte geben Sie einen neuen Namen für ihren Arbeitsbereich „{0}“ ein oder bearbeiten Sie ihn:" +-- There is already a workspace with this name. Please choose a different name. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "Es gibt bereits einen Arbeitsbereich mit diesem Namen. Bitte wählen Sie einen anderen Namen." + -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein." @@ -5796,6 +5799,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::UPDATEDIALOG::T25417398"] = "Aktualisieren v -- Install later UI_TEXT_CONTENT["AISTUDIO::DIALOGS::UPDATEDIALOG::T2936430090"] = "Später installieren" +-- Create new workspace +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T1541251414"] = "Neuen Arbeitsbereich erstellen" + +-- Add workspace +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T1586005241"] = "Arbeitsbereich hinzufügen" + +-- Workspace name +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T295876489"] = "Name des Arbeitsbereichs" + +-- There is already a workspace with this name. Please choose a different name. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T3249036008"] = "Es gibt bereits einen Arbeitsbereich mit diesem Namen. Bitte wählen Sie einen anderen Namen." + +-- Please enter a workspace name. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein." + -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T900713019"] = "Abbrechen" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 557713e1..c1104280 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3255,6 +3255,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3045856778"] = "Move Chat to -- Please enter a new or edit the name for your workspace '{0}': UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T323280982"] = "Please enter a new or edit the name for your workspace '{0}':" +-- There is already a workspace with this name. Please choose a different name. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "There is already a workspace with this name. Please choose a different name." + -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." @@ -5796,6 +5799,21 @@ UI_TEXT_CONTENT["AISTUDIO::DIALOGS::UPDATEDIALOG::T25417398"] = "Update from v{0 -- Install later UI_TEXT_CONTENT["AISTUDIO::DIALOGS::UPDATEDIALOG::T2936430090"] = "Install later" +-- Create new workspace +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T1541251414"] = "Create new workspace" + +-- Add workspace +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T1586005241"] = "Add workspace" + +-- Workspace name +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T295876489"] = "Workspace name" + +-- There is already a workspace with this name. Please choose a different name. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T3249036008"] = "There is already a workspace with this name. Please choose a different name." + +-- Please enter a workspace name. +UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T3288132732"] = "Please enter a workspace name." + -- Cancel UI_TEXT_CONTENT["AISTUDIO::DIALOGS::WORKSPACESELECTIONDIALOG::T900713019"] = "Cancel" diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index 8d5f465a..15ee6183 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -155,6 +155,16 @@ public enum Event /// Requests the chat workspace overlay to be toggled. /// WORKSPACE_TOGGLE_OVERLAY, + + /// + /// Notifies receivers that a workspace was renamed. + /// + WORKSPACE_RENAMED, + + /// + /// Notifies receivers that a workspace was created. + /// + WORKSPACE_CREATED, diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index 9397db85..aaa5bb04 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -12,6 +12,8 @@ namespace AIStudio.Tools; public static class WorkspaceBehaviour { + public readonly record struct TryCreateWorkspaceResult(bool Success, WorkspaceTreeWorkspace Workspace); + private sealed class WorkspaceChatCacheEntry { public Guid WorkspaceId { get; init; } @@ -76,9 +78,9 @@ public static class WorkspaceBehaviour private static readonly TimeSpan PREFETCH_DELAY_DURATION = TimeSpan.FromMilliseconds(45); - private static string WorkspaceRootDirectory => Path.Join(SettingsManager.DataDirectory, "workspaces"); + private static readonly string WORKSPACE_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "workspaces"); - private static string TemporaryChatsRootDirectory => Path.Join(SettingsManager.DataDirectory, "tempChats"); + private static readonly string TEMPORARY_CHATS_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "tempChats"); private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) { @@ -156,9 +158,9 @@ public static class WorkspaceBehaviour private static async Task> ReadTemporaryChatsCoreAsync() { var chats = new List(); - Directory.CreateDirectory(TemporaryChatsRootDirectory); + Directory.CreateDirectory(TEMPORARY_CHATS_ROOT_DIRECTORY); - foreach (var tempChatPath in Directory.EnumerateDirectories(TemporaryChatsRootDirectory)) + foreach (var tempChatPath in Directory.EnumerateDirectories(TEMPORARY_CHATS_ROOT_DIRECTORY)) { if (!Guid.TryParse(Path.GetFileName(tempChatPath), out var chatId)) continue; @@ -188,8 +190,8 @@ public static class WorkspaceBehaviour WORKSPACE_TREE_CACHE.Workspaces.Clear(); WORKSPACE_TREE_CACHE.WorkspaceOrder.Clear(); - Directory.CreateDirectory(WorkspaceRootDirectory); - foreach (var workspacePath in Directory.EnumerateDirectories(WorkspaceRootDirectory)) + Directory.CreateDirectory(WORKSPACE_ROOT_DIRECTORY); + foreach (var workspacePath in Directory.EnumerateDirectories(WORKSPACE_ROOT_DIRECTORY)) { if (!Guid.TryParse(Path.GetFileName(workspacePath), out var workspaceId)) continue; @@ -259,6 +261,13 @@ public static class WorkspaceBehaviour return false; } + private static bool WorkspaceNameExistsCore(string workspaceName, Guid excludedWorkspaceId = default) + { + return WORKSPACE_TREE_CACHE.Workspaces.Values.Any(workspace => + workspace.WorkspaceId != excludedWorkspaceId && + string.Equals(workspace.WorkspaceName.Trim(), workspaceName, StringComparison.OrdinalIgnoreCase)); + } + private static async Task ThreadContainsTermsAsync(WorkspaceTreeChat chat, IReadOnlyList terms, CancellationToken token) { var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(ThreadContainsTermsAsync)); @@ -587,6 +596,100 @@ public static class WorkspaceBehaviour WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); } } + + public static string NormalizeWorkspaceName(string workspaceName) => workspaceName.Trim(); + + public static async Task IsWorkspaceNameExistingAsync(string workspaceName, Guid excludedWorkspaceId = default) + { + var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName); + if (string.IsNullOrWhiteSpace(normalizedWorkspaceName)) + return false; + + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + return WorkspaceNameExistsCore(normalizedWorkspaceName, excludedWorkspaceId); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task TryCreateWorkspaceAsync(string workspaceName) + { + var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName); + if (string.IsNullOrWhiteSpace(normalizedWorkspaceName)) + return new(false, default); + + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + if (WorkspaceNameExistsCore(normalizedWorkspaceName)) + return new(false, default); + + var workspaceId = Guid.NewGuid(); + var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString()); + Directory.CreateDirectory(workspacePath); + + var workspaceNamePath = Path.Join(workspacePath, "name"); + await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8); + + var workspace = new WorkspaceCacheEntry + { + WorkspaceId = workspaceId, + WorkspacePath = workspacePath, + WorkspaceName = normalizedWorkspaceName, + Chats = [], + ChatsLoaded = false, + }; + WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = workspace; + WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId); + + return new(true, ToPublicWorkspace(workspace)); + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } + + public static async Task RenameWorkspaceAsync(Guid workspaceId, string workspaceName) + { + var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName); + if (string.IsNullOrWhiteSpace(normalizedWorkspaceName)) + return false; + + await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(); + try + { + await EnsureTreeShellLoadedCoreAsync(); + if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace)) + return false; + + var workspaceNamePath = Path.Join(workspace.WorkspacePath, "name"); + if (string.Equals(workspace.WorkspaceName.Trim(), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase)) + { + await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8); + workspace.WorkspaceName = normalizedWorkspaceName; + return true; + } + + if (WorkspaceNameExistsCore(normalizedWorkspaceName, workspaceId)) + return false; + + await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8); + workspace.WorkspaceName = normalizedWorkspaceName; + + return true; + } + finally + { + WORKSPACE_TREE_CACHE_SEMAPHORE.Release(); + } + } public static bool IsChatExisting(LoadChat loadChat) { @@ -668,7 +771,7 @@ public static class WorkspaceBehaviour // Not in cache — read from disk and update cache in the same semaphore scope // to avoid a second semaphore acquisition via UpdateWorkspaceNameInCacheAsync: - var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString()); + var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString()); var workspaceNamePath = Path.Join(workspacePath, "name"); string workspaceName; @@ -756,7 +859,7 @@ public static class WorkspaceBehaviour private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) { - var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString()); + var workspacePath = Path.Join(WORKSPACE_ROOT_DIRECTORY, workspaceId.ToString()); var workspaceNamePath = Path.Join(workspacePath, "name"); if (!Path.Exists(workspacePath)) @@ -786,4 +889,4 @@ public static class WorkspaceBehaviour public static async Task EnsureBiasWorkspace() => await EnsureWorkspace(KnownWorkspaces.BIAS_WORKSPACE_ID, "Bias of the Day"); public static async Task EnsureERIServerWorkspace() => await EnsureWorkspace(KnownWorkspaces.ERI_SERVER_WORKSPACE_ID, "ERI Servers"); -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/app.js b/app/MindWork AI Studio/wwwroot/app.js index a2f8f967..c2845f76 100644 --- a/app/MindWork AI Studio/wwwroot/app.js +++ b/app/MindWork AI Studio/wwwroot/app.js @@ -131,3 +131,30 @@ window.formatChatInputMarkdown = function (inputId, formatType) { return nextValue } + +const escapeHandlers = new Map() + +window.registerEscapeHandler = function (id, dotNetReference) { + window.unregisterEscapeHandler(id) + + const handler = function (event) { + if (event.key !== 'Escape') + return + + event.preventDefault() + event.stopPropagation() + dotNetReference.invokeMethodAsync('HandleEscapeKeyAsync').catch(() => {}) + } + + document.addEventListener('keydown', handler, true) + escapeHandlers.set(id, handler) +} + +window.unregisterEscapeHandler = function (id) { + const handler = escapeHandlers.get(id) + if (!handler) + return + + document.removeEventListener('keydown', handler, true) + escapeHandlers.delete(id) +} \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md index 8cf1b5c9..d4642138 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.6.1.md @@ -6,5 +6,7 @@ - Added startup path and Linux package type details to the information page to make support easier. - Added the option to search for chats in all workspaces. - Improved workspaces by adding a shortcut to start a new chat directly from each workspace row. +- Improved workspaces by allowing new workspaces to be created while moving a chat. - Improved the enterprise configuration details on the information page by showing where each configuration comes from and which configuration slot was used. +- Fixed workspace creation and renaming to prevent new workspaces from using an existing name. - Upgraded dependencies. \ No newline at end of file