From 46cd16266debc5bfd0f683d58ee4f6dbb4955054 Mon Sep 17 00:00:00 2001 From: j-erler Date: Sun, 20 Sep 2026 20:29:44 +0200 Subject: [PATCH] Added a way to duplicate a chat Co-Authored-By: Claude Opus 5 --- .../Assistants/I18N/allTexts.lua | 36 ++++++++ .../Components/ChatComponent.razor | 7 ++ .../Components/ChatComponent.razor.cs | 43 +++++++++ .../Components/Workspaces.razor | 4 + .../Components/Workspaces.razor.cs | 58 +++++++++++++ .../plugin.lua | 36 ++++++++ .../plugin.lua | 36 ++++++++ .../Tools/WorkspaceBehaviour.cs | 87 +++++++++++++++++++ .../wwwroot/changelog/v26.9.1.md | 1 + 9 files changed, 308 insertions(+) diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index d3ea7596c..b2f138ba6 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3589,6 +3589,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code" -- plus {0} image(s), which is more than the {1} this model accepts UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0} image(s), which is more than the {1} this model accepts" +-- Please enter a chat name. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2301651387"] = "Please enter a chat name." + -- Italic UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic" @@ -3601,6 +3604,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile u -- The selected provider is not allowed in this chat due to data security or confidence-level requirements. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2672162875"] = "The selected provider is not allowed in this chat due to data security or confidence-level requirements." +-- Duplicate Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2762849660"] = "Duplicate Chat" + +-- {0} (copy) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2930606011"] = "{0} (copy)" + -- Bulleted List UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2957125464"] = "Bulleted List" @@ -3613,12 +3622,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat -- {0} tokens UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} tokens" +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3323676840"] = "Please enter a name for the copy of your chat '{0}':" + -- plus {0} image(s), which cannot be counted UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0} image(s), which cannot be counted" -- Select a provider first UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first" +-- Chat Name +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3891063690"] = "Chat Name" + -- Start new chat in workspace '{0}' UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace '{0}'" @@ -3631,9 +3646,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Start tem -- Heading UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4231005109"] = "Heading" +-- Duplicate +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4231201590"] = "Duplicate" + -- Please select the workspace where you want to move the chat to. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T474393241"] = "Please select the workspace where you want to move the chat to." +-- Duplicate this chat & continue in the copy. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T568087588"] = "Duplicate this chat & continue in the copy." + -- Move the chat to a workspace, or to another if it is already in one. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Move the chat to a workspace, or to another if it is already in one." @@ -5338,6 +5359,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2446263209"] = "Workspace Na -- Move to workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2509305748"] = "Move to workspace" +-- Duplicate Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2762849660"] = "Duplicate Chat" + +-- {0} (copy) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2930606011"] = "{0} (copy)" + -- Are you sure you want to delete the temporary chat '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3043761007"] = "Are you sure you want to delete the temporary chat '{0}'?" @@ -5353,6 +5380,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "There is alr -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3323676840"] = "Please enter a name for the copy of your chat '{0}':" + -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" @@ -5380,6 +5410,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4048389951"] = "Workspaces" -- Disappearing Chats UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4201703117"] = "Disappearing Chats" +-- Are you sure you want to duplicate this chat? The copy is opened, so everything you wrote since you last saved this chat will be lost. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4222136922"] = "Are you sure you want to duplicate this chat? The copy is opened, so everything you wrote since you last saved this chat will be lost." + +-- Duplicate +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4231201590"] = "Duplicate" + -- Please select the workspace where you want to move the chat to. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T474393241"] = "Please select the workspace where you want to move the chat to." diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index addb3536d..58b0acc2b 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -89,6 +89,13 @@ + @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.DISABLE_WORKSPACES) + { + + + + } + @if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) { diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index a11730ba7..aaaa38443 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -1242,6 +1242,49 @@ private async Task StartNewChat(bool useSameWorkspace = false, bool deletePrevio await this.ChatThreadChanged.InvokeAsync(this.ChatThread); } + /// + /// Copies the chat which is open into a second one and continues in that one. + /// + /// + /// Copied is the chat as it stands on the screen, not the state which was stored last, so a + /// sentence somebody wrote a moment ago is part of the copy. That is also why nothing is asked + /// about unsaved changes here, unlike everywhere else which leaves a chat behind: none of them + /// are lost, they are what is being copied. + /// + private async Task DuplicateCurrentChat() + { + if (this.ChatThread is null || this.IsCurrentChatStreaming) + return; + + var dialogParameters = new DialogParameters + { + { x => x.Message, string.Format(T("Please enter a name for the copy of your chat '{0}':"), this.ChatThread.Name) }, + { x => x.InputHeaderText, T("Chat Name") }, + { x => x.UserInput, string.Format(T("{0} (copy)"), this.ChatThread.Name) }, + { x => x.ConfirmText, T("Duplicate") }, + { x => x.ConfirmColor, Color.Info }, + { x => x.AllowEmptyInput, false }, + { x => x.EmptyInputErrorMessage, T("Please enter a chat name.") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Duplicate Chat"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + + var copy = await WorkspaceBehaviour.DuplicateChatAsync(this.ChatThread, (dialogResult.Data as string)!); + if (copy is null) + return; + + this.ChatThread = copy; + this.hasUnsavedChanges = false; + + await this.SyncForegroundChatAsync(); + this.MarkCurrentChatAsLoadedParameter(); + await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + } + private async Task MoveChatToWorkspace() { if(this.ChatThread is null) diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index 74e52eddd..5b9295e6f 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -85,6 +85,10 @@ else + + + + diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index 05e9c3d92..eaf4724ac 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -745,6 +745,64 @@ public async Task DeleteChatAsync(string? chatPath, bool askForConfirmation = tr } } + /// + /// Copies a chat of the tree into a second one and opens it. + /// + /// + /// Two questions in a row, and only the second one is always asked. The first is the one every + /// other way out of a chat asks -- whether unsaved changes may be lost -- because the copy is + /// opened once it exists. The second asks what the copy is called, so that two chats of the + /// same name do not end up next to each other in the tree. + /// + /// The directory of the chat to copy. + private async Task DuplicateChatAsync(string? chatPath) + { + var chat = await this.LoadChatAsync(chatPath, false); + if (chat is null) + return; + + if (this.AIJobService.IsChatGenerationActive(chat.ChatId)) + return; + + if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) + { + var unsavedDialogParameters = new DialogParameters + { + { x => x.Message, T("Are you sure you want to duplicate this chat? The copy is opened, so everything you wrote since you last saved this chat will be lost.") }, + }; + + var unsavedDialogReference = await this.DialogService.ShowAsync(T("Duplicate Chat"), unsavedDialogParameters, DialogOptions.FULLSCREEN); + var unsavedDialogResult = await unsavedDialogReference.Result; + if (unsavedDialogResult is null || unsavedDialogResult.Canceled) + return; + } + + var dialogParameters = new DialogParameters + { + { x => x.Message, string.Format(T("Please enter a name for the copy of your chat '{0}':"), chat.Name) }, + { x => x.InputHeaderText, T("Chat Name") }, + { x => x.UserInput, string.Format(T("{0} (copy)"), chat.Name) }, + { x => x.ConfirmText, T("Duplicate") }, + { x => x.ConfirmColor, Color.Info }, + { x => x.AllowEmptyInput, false }, + { x => x.EmptyInputErrorMessage, T("Please enter a chat name.") }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Duplicate Chat"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + + var copy = await WorkspaceBehaviour.DuplicateChatAsync(chat, (dialogResult.Data as string)!); + if (copy is null) + return; + + await this.LoadTreeItemsAsync(startPrefetch: false); + + this.CurrentChatThread = copy; + await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); + } + private async Task RenameChatAsync(string? chatPath) { var chat = await this.LoadChatAsync(chatPath, false); 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 c0e2d70dc..b65542cd1 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 @@ -3591,6 +3591,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code" -- plus {0} image(s), which is more than the {1} this model accepts UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0} Bild(er), also mehr als die {1}, die dieses Modell akzeptiert" +-- Please enter a chat name. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2301651387"] = "Bitte geben Sie einen Namen für diesen Chat ein." + -- Italic UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Kursiv" @@ -3603,6 +3606,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Die Profi -- The selected provider is not allowed in this chat due to data security or confidence-level requirements. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2672162875"] = "Der ausgewählte Anbieter ist in diesem Chat aufgrund der Datensicherheit oder der Anforderungen an das Vertrauensniveau nicht zulässig." +-- Duplicate Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2762849660"] = "Chat duplizieren" + +-- {0} (copy) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2930606011"] = "{0} (Kopie)" + -- Bulleted List UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2957125464"] = "Aufzählungszeichen" @@ -3615,12 +3624,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Chat in d -- {0} tokens UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} Token" +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3323676840"] = "Bitte geben Sie einen Namen für die Kopie Ihres Chats „{0}“ ein:" + -- plus {0} image(s), which cannot be counted UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "zuzüglich {0} Bild(er), die nicht gezählt werden können" -- Select a provider first UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Wähle zuerst einen Anbieter aus" +-- Chat Name +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3891063690"] = "Name des Chat" + -- Start new chat in workspace "{0}" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Neuen Chat im Arbeitsbereich '{0}' starten" @@ -3633,9 +3648,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "Neuen sel -- Heading UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4231005109"] = "Überschrift" +-- Duplicate +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4231201590"] = "Duplizieren" + -- Please select the workspace where you want to move the chat to. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T474393241"] = "Bitte wählen Sie den Arbeitsbereich aus, in den Sie den Chat verschieben möchten." +-- Duplicate this chat & continue in the copy. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T568087588"] = "Diesen Chat duplizieren und in der Kopie weiterarbeiten." + -- Move the chat to a workspace, or to another if it is already in one. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Verschieben Sie den Chat in einen Arbeitsbereich oder in einen anderen, falls er sich bereits in einem befindet." @@ -5340,6 +5361,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2446263209"] = "Name des Arb -- Move to workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2509305748"] = "In einen Arbeitsbereich verschieben" +-- Duplicate Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2762849660"] = "Chat duplizieren" + +-- {0} (copy) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2930606011"] = "{0} (Kopie)" + -- Are you sure you want to delete the chat '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3043761007"] = "Sind Sie sicher, dass Sie den Chat „{0}“ löschen möchten?" @@ -5355,6 +5382,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "Es gibt bere -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Bitte geben Sie einen Namen für diesen Arbeitsbereich ein." +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3323676840"] = "Bitte geben Sie einen Namen für die Kopie Ihres Chats „{0}“ ein:" + -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen" @@ -5382,6 +5412,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4048389951"] = "Arbeitsberei -- Disappearing Chats UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4201703117"] = "Selbstlöschende Chats" +-- Are you sure you want to duplicate this chat? The copy is opened, so everything you wrote since you last saved this chat will be lost. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4222136922"] = "Möchten Sie diesen Chat wirklich duplizieren? Die Kopie wird geöffnet; alles, was Sie seit dem letzten Speichern geschrieben haben, geht dabei verloren." + +-- Duplicate +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4231201590"] = "Duplizieren" + -- Please select the workspace where you want to move the chat to. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T474393241"] = "Bitte wählen Sie den Arbeitsbereich aus, in den Sie den Chat verschieben möchten." 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 4725c5246..0a65dab3d 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 @@ -3591,6 +3591,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2036185364"] = "Code" -- plus {0} image(s), which is more than the {1} this model accepts UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0} image(s), which is more than the {1} this model accepts" +-- Please enter a chat name. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2301651387"] = "Please enter a chat name." + -- Italic UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic" @@ -3603,6 +3606,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile u -- The selected provider is not allowed in this chat due to data security or confidence-level requirements. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2672162875"] = "The selected provider is not allowed in this chat due to data security or confidence-level requirements." +-- Duplicate Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2762849660"] = "Duplicate Chat" + +-- {0} (copy) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2930606011"] = "{0} (copy)" + -- Bulleted List UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2957125464"] = "Bulleted List" @@ -3615,12 +3624,18 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3045856778"] = "Move Chat -- {0} tokens UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3244065777"] = "{0} tokens" +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3323676840"] = "Please enter a name for the copy of your chat '{0}':" + -- plus {0} image(s), which cannot be counted UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3619858297"] = "plus {0} image(s), which cannot be counted" -- Select a provider first UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3654197869"] = "Select a provider first" +-- Chat Name +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3891063690"] = "Chat Name" + -- Start new chat in workspace "{0}" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T3928697643"] = "Start new chat in workspace \"{0}\"" @@ -3633,9 +3648,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4113970938"] = "New disap -- Heading UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4231005109"] = "Heading" +-- Duplicate +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T4231201590"] = "Duplicate" + -- Please select the workspace where you want to move the chat to. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T474393241"] = "Please select the workspace where you want to move the chat to." +-- Duplicate this chat & continue in the copy. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T568087588"] = "Duplicate this chat & continue in the copy." + -- Move the chat to a workspace, or to another if it is already in one. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T636393754"] = "Move the chat to a workspace, or to another if it is already in one." @@ -5340,6 +5361,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2446263209"] = "Workspace Na -- Move to workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2509305748"] = "Move to workspace" +-- Duplicate Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2762849660"] = "Duplicate Chat" + +-- {0} (copy) +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T2930606011"] = "{0} (copy)" + -- Are you sure you want to delete the chat '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3043761007"] = "Are you sure you want to delete the chat '{0}'?" @@ -5355,6 +5382,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3249036008"] = "There is alr -- Please enter a workspace name. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3288132732"] = "Please enter a workspace name." +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3323676840"] = "Please enter a name for the copy of your chat '{0}':" + -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" @@ -5382,6 +5412,12 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4048389951"] = "Workspaces" -- Disappearing Chats UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4201703117"] = "Disappearing Chats" +-- Are you sure you want to duplicate this chat? The copy is opened, so everything you wrote since you last saved this chat will be lost. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4222136922"] = "Are you sure you want to duplicate this chat? The copy is opened, so everything you wrote since you last saved this chat will be lost." + +-- Duplicate +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4231201590"] = "Duplicate" + -- Please select the workspace where you want to move the chat to. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T474393241"] = "Please select the workspace where you want to move the chat to." diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index 068f369c7..091c6d106 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -902,6 +902,93 @@ private static async Task StoreMovedChatFilesAsync(ChatThread chat, string chatD } } + /// + /// Copies a chat into a second one of its own, in the workspace the first one is in. + /// + /// + /// The copy is made by writing the chat out and reading it back rather than by naming every + /// field it has. What survives a restart therefore survives being duplicated, and a field + /// somebody adds later comes along without anybody having to remember this method. + /// + /// The files a chat owns are copied with it. A chat keeps the transcripts of the media + /// somebody attached below its own directory, and a copy which pointed at the originals would + /// lose them the day the original chat is deleted. What the user attached from elsewhere is + /// not touched: those files stay where they are and both chats name the same path, which is + /// what a second chat about the same documents should do. + /// + /// The chat to copy. It is not changed. + /// The name the copy gets. + /// The copy, or null when the chat was busy or could not be read back. + public static async Task DuplicateChatAsync(ChatThread source, string name) + { + var sourceDirectory = GetChatDirectory(source.WorkspaceId, source.ChatId); + var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(source.WorkspaceId, source.ChatId, nameof(DuplicateChatAsync)); + if (!acquired) + return null; + + ChatThread copy; + string targetDirectory; + try + { + var readBack = JsonSerializer.Deserialize(JsonSerializer.Serialize(source, JSON_OPTIONS), JSON_OPTIONS); + if (readBack is null) + { + LOG.LogError("Could not read back chat '{ChatId}' while duplicating it.", source.ChatId); + return null; + } + + copy = readBack with { ChatId = Guid.NewGuid(), Name = name }; + targetDirectory = GetChatDirectory(copy.WorkspaceId, copy.ChatId); + CopyOwnedFiles(sourceDirectory, targetDirectory); + } + finally + { + semaphore.Release(); + } + + // + // Done after the source is released and before the copy is written: the paths of the + // files just copied still name the source directory, and storing the chat with those + // would tie the copy to a directory somebody may delete tomorrow. + // + UpdateAttachmentPathsAfterMove(copy, sourceDirectory, targetDirectory); + + await StoreChatAsync(copy); + return copy; + } + + /// + /// Copies the files a chat owns into another chat's directory. + /// + /// + /// Everything below a chat directory was written by AI Studio, which makes the directory the + /// definition of what a chat owns -- except the two files the chat storage writes itself, and + /// the half-written ones it leaves behind while saving. Those two are written for the copy + /// anyway, and a temporary file belongs to the save it came from. + /// + /// The directory of the chat being copied. + /// The directory of the copy. + private static void CopyOwnedFiles(string sourceDirectory, string targetDirectory) + { + if (!Directory.Exists(sourceDirectory)) + return; + + foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var fileName = Path.GetFileName(filePath); + if (fileName.StartsWith('.') && fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)) + continue; + + var relativePath = Path.GetRelativePath(sourceDirectory, filePath); + if (relativePath.Equals("thread.json", StringComparison.OrdinalIgnoreCase) || relativePath.Equals("name", StringComparison.OrdinalIgnoreCase)) + continue; + + var targetPath = Path.Combine(targetDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.Copy(filePath, targetPath, overwrite: false); + } + } + /// Rewrites absolute attachment paths after moving the complete chat directory. private static void UpdateAttachmentPathsAfterMove(ChatThread chat, string sourceDirectory, string targetDirectory) { diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index e0d15144d..e93a94f59 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -13,6 +13,7 @@ - Added the context window to what AI Studio knows about a model, wherever its metadata states one. - Added a live read of that context window at the providers which report it, among them Mistral, Groq, OpenRouter, and self-hosted vLLM servers. You then get the window your own server was started with, not the one the model card advertises. - Added a token count below the message field, so you always see how much of the conversation you have used. It counts everything that travels along: your messages, the files you attached, what your data sources contributed, and the tools you offered the AI. It can be an estimate when a provider does not give AI Studio everything it needs to count exactly. +- Added duplicating a chat, so you can try a second line of thought without losing the first one. You find it in the workspace tree next to renaming and deleting, and in the chat toolbar. AI Studio asks what the copy should be called and suggests the name of the original with "(copy)" behind it, then opens the copy for you to continue in while the original stays as it was. Everything comes along: the conversation, the settings of that chat, and the transcripts of any media you attached. Those transcripts are copied rather than shared, so deleting one of the two chats never empties the other. - Added a warning when your conversation holds more images than the model accepts, wherever we know that limit. The Visual Briefing assistant stops before anything is uploaded, instead of letting the provider refuse it afterward. - Added the context window and the image limits to the expert provider settings, next to the abilities you could already state there. Leave a field empty, and AI Studio keeps its own answer, which you see as the placeholder. IT departments can state the same numbers for the providers they roll out. - Added model plugins, so IT departments can describe the models their organization runs itself.