From e9ebc02a96aeb7d91f7a825a2f4208f5f928d2b7 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp Date: Wed, 23 Sep 2026 10:44:56 +0200 Subject: [PATCH 1/2] Copy entire chats & Rollback chats to certain message (#992) Co-authored-by: Thorsten Sommer --- AGENTS.md | 18 +- .../Assistants/I18N/allTexts.lua | 45 ++++ app/MindWork AI Studio/Chat/ChatThread.cs | 42 ++++ .../Chat/ContentBlockComponent.razor | 6 + .../Chat/ContentBlockComponent.razor.cs | 21 ++ .../Components/ChatComponent.razor | 6 + .../Components/ChatComponent.razor.cs | 65 ++++- .../Components/Workspaces.razor | 4 + .../Components/Workspaces.razor.cs | 74 ++++++ .../plugin.lua | 49 +++- .../plugin.lua | 45 ++++ .../Tools/WorkspaceBehaviour.cs | 137 ++++++++++- .../wwwroot/changelog/v26.9.1.md | 4 +- app/Tests/Chat/ChatThreadRollbackTests.cs | 225 ++++++++++++++++++ 14 files changed, 733 insertions(+), 8 deletions(-) create mode 100644 app/Tests/Chat/ChatThreadRollbackTests.cs diff --git a/AGENTS.md b/AGENTS.md index a143c86d..29a4bbab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,12 +308,28 @@ Multi-level confidence scheme allows users to control which providers see which 6. GitHub Actions builds release binaries for all platforms 7. Binaries uploaded to GitHub Releases +## Localization + +The app's texts are localized in two steps, and the developer always does the first one. + +1. The developer starts the app, which runs the I18N collector, and runs the localization assistant + in the app for German and US English. Agents never write these initial translations themselves: + they neither add nor regenerate entries in `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`, + `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`, + or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`. + When new or changed texts are waiting for translation, remind the developer to start the app and + run the localization. +2. Afterward, agents always review the German translation. Compare the new and changed values of the + de-de `plugin.lua` with `main`, check them against the wording already established there, and + correct or improve them directly in that file. `allTexts.lua` and the en-us `plugin.lua` stay as + the assistant wrote them. + ## Important Development Notes - **File changes require Write/Edit tools** - Never use bash commands like `cat <` - **End of file formatting** - Do not append an extra empty line at the end of files. - **No automated formatting for Rust or .NET files** - Never run automated formatters on Rust files (`.rs`) or .NET files (`.cs`, `.razor`, `.csproj`, etc.). Only make the minimal manual formatting changes required for the specific edit. -- **I18N resources are generated** - Do not manually edit `app/MindWork AI Studio/Assistants/I18N/allTexts.lua`, `app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua`, or `app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua`. These files are updated automatically by the I18N process. +- **I18N resources are generated** - The developer produces the translations by running the localization assistant in the app; agents only review and correct the German values afterward. See "Localization" above. - **Spaces in paths** - Always quote paths with spaces in bash commands - **Agent-run builds** - Never start `.NET` or Rust builds in the agent's own shell; it is sandboxed. Use the `rider` and `rustrover` MCP servers instead, which build in the IDE outside that sandbox. See "Running builds from an agent" above. - **Debug environment** - Reads `startup.env` file with IPC credentials diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index a59b4133..88c36251 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3268,6 +3268,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Result" -- Do you really want to remove this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Do you really want to remove this message?" +-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed. +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed." + -- Yes, remove the AI response and edit it UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Yes, remove the AI response and edit it" @@ -3319,12 +3322,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "Export -- Number of attachments UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3018847255"] = "Number of attachments" +-- Roll back to this response +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3132525321"] = "Roll back to this response" + -- Cannot render content of type {0} yet. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3175548294"] = "Cannot render content of type {0} yet." -- Edit UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3267849393"] = "Edit" +-- Roll Back Chat +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3304283125"] = "Roll Back Chat" + -- Unknown UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unknown" @@ -3337,6 +3346,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?" +-- Yes, roll back the chat +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Yes, roll back the chat" + -- Remove Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message" @@ -3610,6 +3622,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic" -- The media transcription was canceled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "The media transcription was canceled." +-- Copy this chat & continue in the copy. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2634509198"] = "Copy this chat & continue in the copy." + -- Profile usage is disabled according to your chat template settings. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile usage is disabled according to your chat template settings." @@ -5296,9 +5311,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "An error o -- The transcription result is empty. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transcription result is empty." +-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with." + -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat" +-- Copy Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1192756314"] = "Copy Chat" + -- Loading chats... UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..." @@ -5365,6 +5386,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." +-- Copy chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3337233722"] = "Copy chat" + -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" @@ -5380,6 +5404,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Load Chat" -- Add Workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspace" +-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost." + -- Chat Name UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name" @@ -12949,14 +12976,32 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] = -- Are you sure you want to delete the chat '{0}' in the workspace '{1}'? UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?" +-- Copy Chat +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1192756314"] = "Copy Chat" + -- Unnamed workspace UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed workspace" +-- Copy +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1703884388"] = "Copy" + -- Delete Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" +-- Please enter a chat name. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2301651387"] = "Please enter a chat name." + -- Are you sure you want to delete the temporary chat '{0}'? UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you sure you want to delete the temporary chat '{0}'?" -- Unnamed chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" + +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3323676840"] = "Please enter a name for the copy of your chat '{0}':" + +-- Copy of {0} +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}" + +-- Chat Name +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3891063690"] = "Chat Name" diff --git a/app/MindWork AI Studio/Chat/ChatThread.cs b/app/MindWork AI Studio/Chat/ChatThread.cs index d01e1afa..466fbe44 100644 --- a/app/MindWork AI Studio/Chat/ChatThread.cs +++ b/app/MindWork AI Studio/Chat/ChatThread.cs @@ -350,6 +350,48 @@ public sealed record ChatThread this.Blocks.Remove(block); } + /// + /// Rolls this chat thread back, so that the given content becomes the last block of the conversation. + /// + /// + /// Every later block is removed in conversation order, which is the order of the time stamps and + /// the order in which the chat shows the blocks. That includes the blocks hidden from the user, + /// such as the prompts an assistant sends into a chat: the user can neither see nor remove them, + /// so leaving them behind would continue the chat with messages nobody knows about. Hidden blocks + /// before the content stay, e.g. the example conversation of a chat template. The managed + /// transcripts of the removed blocks are deleted with them.

+ /// + /// The augmented data and the AI-selected data sources are reset, too. Both describe the last + /// retrieval, not a certain message, so after a rollback nobody knows whether they belong to a + /// kept or to a removed one. The next message with active data sources retrieves anew; without + /// active data sources, the chat continues without this context. The data source options stay, + /// because they are the user's choice rather than the result of a message.

+ /// + /// What stays as well is everything the thread ratchets for security reasons, namely the data + /// security and the required provider confidence. Both only ever tighten, because the data which + /// raised them was seen by this thread. Removing the message that brought it in does not unsee + /// it, so the chat keeps demanding the same of every provider which continues it. + ///
+ /// The content to keep as the last block. + /// True when one or more later blocks were removed. False when the content is unknown or already the last block; the thread stays unchanged then. + public bool RollBackTo(IContent content) + { + var sortedBlocks = this.Blocks.OrderBy(x => x.Time).ToList(); + var blockIndex = sortedBlocks.FindIndex(block => ReferenceEquals(block.Content, content)); + if (blockIndex < 0 || blockIndex == sortedBlocks.Count - 1) + return false; + + foreach (var block in sortedBlocks.Skip(blockIndex + 1)) + { + DeleteManagedAttachments(block); + this.Blocks.Remove(block); + } + + this.AugmentedData = string.Empty; + this.AISelectedDataSources = []; + return true; + } + private static void DeleteManagedAttachments(ContentBlock block) { if (block.Content is not ContentText textContent) diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index d1868ed2..71d5729d 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -70,6 +70,12 @@ } + @if (!this.IsLastContentBlock && this.Role is ChatRole.AI && this.RollbackFunc is not null) + { + + + + } @if (this.RemoveBlockFunc is not null) { diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 170d90f8..4dc68e92 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -76,6 +76,9 @@ public partial class ContentBlockComponent : MSGComponentBase [Parameter] public Func? RegenerateFunc { get; set; } + + [Parameter] + public Func? RollbackFunc { get; set; } [Parameter] public Func? EditLastBlockFunc { get; set; } @@ -86,6 +89,9 @@ public partial class ContentBlockComponent : MSGComponentBase [Parameter] public Func RegenerateEnabled { get; set; } = () => false; + [Parameter] + public Func RollbackEnabled { get; set; } = () => false; + /// /// What the export offers, used both as the label of the export button and as the title of /// the save dialog. @@ -780,6 +786,21 @@ public partial class ContentBlockComponent : MSGComponentBase if (regenerate.HasValue && regenerate.Value) await this.RegenerateFunc(this.Content); } + + private async Task RollbackBlock() + { + if (this.RollbackFunc is null || this.Role is not ChatRole.AI || !this.RollbackEnabled()) + return; + + var rollback = await this.DialogService.ShowMessageBox( + T("Roll Back Chat"), + T("Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed."), + T("Yes, roll back the chat"), + T("No, keep it")); + + if (rollback.HasValue && rollback.Value) + await this.RollbackFunc(this.Content); + } private async Task EditLastBlock() { diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index ca5566ef..c27291be 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -26,6 +26,8 @@ IsSecondToLastBlock="@isSecondLastBlock" RegenerateFunc="@this.RegenerateBlock" RegenerateEnabled="@(() => this.IsProviderSelected && this.ChatThread.IsLLMProviderAllowed(this.Provider))" + RollbackFunc="@this.RollbackBlock" + RollbackEnabled="@(() => !this.IsCurrentChatStreaming)" EditLastBlockFunc="@this.EditLastBlock" EditLastUserBlockFunc="@this.EditLastUserBlock"/> } @@ -101,6 +103,10 @@ + + + + } diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index be2ce136..f4c017aa 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -649,7 +649,9 @@ public partial class ChatComponent : MSGComponentBase } private bool CanThreadBeSaved => this.ChatThread is not null && this.ChatThread.Blocks.Any(b => !b.HideFromUser); - + + private bool CanThreadBeCopied => this.CanThreadBeSaved && !this.IsCurrentChatStreaming && !this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner); + private string TooltipAddChatToWorkspace => string.Format(T("Start new chat in workspace '{0}'"), this.currentWorkspaceName); private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty; @@ -1344,7 +1346,48 @@ public partial class ChatComponent : MSGComponentBase await this.SyncWorkspaceHeaderWithChatThreadAsync(); } - + + /// + /// Copies the open chat and continues in the copy. + /// + /// + /// Copied is the chat as it stands on the screen, not the state which was stored last. That is + /// why nothing is asked about unsaved changes here, unlike everywhere else a chat is left + /// behind: none of them are lost, they move into the copy, while the original keeps what was + /// stored.

+ /// + /// The same holds for the message being written. The copy is marked as loaded right away, + /// because the parameter update which follows would otherwise take it for another chat being + /// opened and clear the composer. Only the transcripts attached to that message are exchanged: + /// those of the original live in its directory, and the copy got copies of its own. + ///
+ private async Task CopyCurrentChat() + { + if (this.ChatThread is null || !this.CanThreadBeCopied) + return; + + var sourceChat = this.ChatThread; + var copy = this.Workspaces is null + ? await WorkspaceBehaviour.CopyChatAsync(this.DialogService, sourceChat) + : await this.Workspaces.CopyChatAsync(sourceChat); + + if (copy is null) + return; + + var transcriptsOfTheOriginal = sourceChat.PendingMediaTranscripts.Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal); + this.ComposerState.FileAttachments.RemoveWhere(attachment => transcriptsOfTheOriginal.Contains(attachment.FilePath)); + foreach (var transcript in copy.PendingMediaTranscripts) + this.ComposerState.FileAttachments.Add(transcript); + + this.ChatThread = copy; + this.hasUnsavedChanges = false; + + await this.SyncForegroundChatAsync(); + this.MarkCurrentChatAsLoadedParameter(); + await this.SyncWorkspaceHeaderWithChatThreadAsync(); + await this.ChatThreadChanged.InvokeAsync(this.ChatThread); + } + private async Task LoadedChatChanged(bool notifyParent = true) { this.hasUnsavedChanges = false; @@ -1457,6 +1500,24 @@ public partial class ChatComponent : MSGComponentBase await this.SendMessage(reuseLastUserPrompt: true); } + + private async Task RollbackBlock(IContent aiBlock) + { + if (this.ChatThread is null || this.IsCurrentChatStreaming) + return; + + // Which parts of the thread a rollback resets and which it keeps is documented at RollBackTo: + if (!this.ChatThread.RollBackTo(aiBlock)) + return; + + // The rollback reset the AI-selected data sources, which the data source selection still shows: + this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources); + this.hasUnsavedChanges = true; + await this.SaveThread(); + this.tokenTracker?.Nudge(); + this.StateHasChanged(); + await this.inputField.FocusAsync(); + } private Task EditLastUserBlock(IContent block) { diff --git a/app/MindWork AI Studio/Components/Workspaces.razor b/app/MindWork AI Studio/Components/Workspaces.razor index 74e52edd..d34e2fad 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor +++ b/app/MindWork AI Studio/Components/Workspaces.razor @@ -81,6 +81,10 @@ else + + + + diff --git a/app/MindWork AI Studio/Components/Workspaces.razor.cs b/app/MindWork AI Studio/Components/Workspaces.razor.cs index a3d8c901..98887cc6 100644 --- a/app/MindWork AI Studio/Components/Workspaces.razor.cs +++ b/app/MindWork AI Studio/Components/Workspaces.razor.cs @@ -660,6 +660,24 @@ public partial class Workspaces : MSGComponentBase await this.LoadTreeItemsAsync(startPrefetch: false); } + /// + /// Copies the given chat, after asking the user for the name of the copy, and shows the copy in the tree. + /// + /// The chat to copy, as it stands in memory. + /// The persisted copy. Null when the user canceled the question, in which case nothing was copied. + /// + /// Neither asks about unsaved changes nor opens the copy: which of both a caller needs depends + /// on where the copy was asked for. + /// + public async Task CopyChatAsync(ChatThread sourceChat) + { + var copy = await WorkspaceBehaviour.CopyChatAsync(this.DialogService, sourceChat); + if (copy is not null) + await this.LoadTreeItemsAsync(startPrefetch: false); + + return copy; + } + private async Task LoadChatAsync(string? chatPath, bool switchToChat) { if (string.IsNullOrWhiteSpace(chatPath)) @@ -781,6 +799,62 @@ public partial class Workspaces : MSGComponentBase await this.LoadTreeItemsAsync(startPrefetch: false); } + /// + /// Copies the chat behind the copy button of a tree item and opens the copy. + /// + /// The directory of the chat to copy. + /// + /// The copy itself is done by CopyChatAsync, which the chat toolbar uses as well. What this + /// handler adds is what only the tree needs: it finds the chat by its directory, and because it + /// opens the copy afterward, it asks first when the chat open right now has unsaved changes. + /// The chat toolbar asks nothing, since it copies the chat on the screen and keeps working in it. + /// + private async Task CopyChatFromTreeAsync(string? chatPath) + { + var chat = await this.LoadChatAsync(chatPath, false); + if (chat is null) + return; + + var mediaOwner = MediaImportOwner.ForChat(chat.ChatId); + if (this.AIJobService.IsChatGenerationActive(chat.ChatId) || this.MediaTranscriptionService.IsBusy(mediaOwner)) + return; + + // + // Copying the chat which is open right now takes its in-memory state, so whatever the user + // has not saved yet ends up in the copy while the original keeps the state it was stored + // with. Copying any other chat replaces the open one, so its unsaved changes are gone. + // Both outcomes are surprising enough to deserve their own wording. + // + var openChat = this.CurrentChatThread; + var isCopyOfOpenChat = openChat is not null && openChat.ChatId == chat.ChatId; + if (await MessageBus.INSTANCE.SendMessageUseFirstResult(this, Event.HAS_CHAT_UNSAVED_CHANGES)) + { + var dialogParameters = new DialogParameters + { + { + x => x.Message, isCopyOfOpenChat switch + { + true => T("Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with."), + false => T("Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost."), + } + }, + }; + + var dialogReference = await this.DialogService.ShowAsync(T("Copy Chat"), dialogParameters, DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return; + } + + var sourceChat = isCopyOfOpenChat ? openChat! : chat; + var copy = await this.CopyChatAsync(sourceChat); + if (copy is null) + return; + + this.CurrentChatThread = copy; + await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread); + } + private async Task RenameWorkspaceAsync(string? workspacePath) { if (workspacePath is null) 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 b479fbcc..7fb62ad5 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 @@ -3270,6 +3270,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Ergebni -- Do you really want to remove this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Möchten Sie diese Nachricht wirklich löschen?" +-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed. +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Möchten Sie diesen Chat wirklich auf diese KI-Antwort zurücksetzen? Alle späteren Nachrichten und deren Anhänge werden dauerhaft gelöscht." + -- Yes, remove the AI response and edit it UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Ja, entferne die KI-Antwort und bearbeite sie." @@ -3321,12 +3324,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "KI-Antw -- Number of attachments UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3018847255"] = "Anzahl der Anhänge" +-- Roll back to this response +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3132525321"] = "Zu dieser Antwort zurücksetzen" + -- Cannot render content of type {0} yet. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3175548294"] = "Der Inhaltstyp {0} kann noch nicht angezeigt werden." -- Edit UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3267849393"] = "Bearbeiten" +-- Roll Back Chat +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3304283125"] = "Chat zurücksetzen" + -- Unknown UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unbekannt" @@ -3339,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockie -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?" +-- Yes, roll back the chat +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Ja, Chat zurücksetzen" + -- Remove Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Nachricht entfernen" @@ -3612,8 +3624,11 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Kursiv" -- The media transcription was canceled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "Die Transkription der Mediendatei wurde abgebrochen." +-- Copy this chat & continue in the copy. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2634509198"] = "Diesen Chat kopieren & in der Kopie fortfahren." + -- Profile usage is disabled according to your chat template settings. -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Die Profilnutzung ist gemäß den Einstellungen ihrer Chat-Vorlage deaktiviert." +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Die Profilnutzung ist gemäß den Einstellungen Ihrer Chat-Vorlage deaktiviert." -- 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." @@ -5298,9 +5313,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "Während d -- The transcription result is empty. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "Das Ergebnis der Transkription ist leer." +-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Möchten Sie diesen Chat kopieren? Ihre ungespeicherten Änderungen werden in die Kopie übernommen; der ursprüngliche Chat behält den zuletzt gespeicherten Stand." + -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Chat verschieben" +-- Copy Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1192756314"] = "Chat kopieren" + -- Loading chats... UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Chats werden geladen..." @@ -5367,6 +5388,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." +-- Copy chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3337233722"] = "Chat kopieren" + -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Umbenennen" @@ -5382,8 +5406,11 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Chat laden" -- Add Workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Arbeitsbereich hinzufügen" +-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Möchten Sie diesen Chat kopieren? Die Kopie wird anschließend geöffnet, daher gehen alle ungespeicherten Änderungen des aktuell geöffneten Chats verloren." + -- Chat Name -UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Name des Chat" +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chatname" -- Empty chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T4019509364"] = "Leerer Chat" @@ -12951,14 +12978,32 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] = -- Are you sure you want to delete the chat '{0}' in the workspace '{1}'? UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Möchten Sie den Chat „{0}“ im Arbeitsbereich „{1}“ wirklich löschen?" +-- Copy Chat +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1192756314"] = "Chat kopieren" + -- Unnamed workspace UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unbenannter Arbeitsbereich" +-- Copy +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1703884388"] = "Kopieren" + -- Delete Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Chat löschen" +-- Please enter a chat name. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2301651387"] = "Bitte geben Sie einen Chatnamen ein." + -- Are you sure you want to delete the temporary chat '{0}'? UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Möchten Sie den temporären Chat „{0}“ wirklich löschen?" -- Unnamed chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat" + +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3323676840"] = "Bitte geben Sie einen Namen für die Kopie Ihres Chats „{0}“ ein:" + +-- Copy of {0} +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Kopie von {0}" + +-- Chat Name +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3891063690"] = "Chatname" 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 bc3e73d1..de64a8e9 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 @@ -3270,6 +3270,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347088452"] = "Result" -- Do you really want to remove this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347427447"] = "Do you really want to remove this message?" +-- Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed. +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1347725178"] = "Do you really want to roll back this chat to this AI response? All later messages and their attachments will be permanently removed." + -- Yes, remove the AI response and edit it UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1350385882"] = "Yes, remove the AI response and edit it" @@ -3321,12 +3324,18 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T2822776450"] = "Export -- Number of attachments UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3018847255"] = "Number of attachments" +-- Roll back to this response +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3132525321"] = "Roll back to this response" + -- Cannot render content of type {0} yet. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3175548294"] = "Cannot render content of type {0} yet." -- Edit UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3267849393"] = "Edit" +-- Roll Back Chat +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3304283125"] = "Roll Back Chat" + -- Unknown UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3424652889"] = "Unknown" @@ -3339,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?" +-- Yes, roll back the chat +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3951371697"] = "Yes, roll back the chat" + -- Remove Message UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T4070211974"] = "Remove Message" @@ -3612,6 +3624,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2377171085"] = "Italic" -- The media transcription was canceled. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T241403726"] = "The media transcription was canceled." +-- Copy this chat & continue in the copy. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2634509198"] = "Copy this chat & continue in the copy." + -- Profile usage is disabled according to your chat template settings. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2670286472"] = "Profile usage is disabled according to your chat template settings." @@ -5298,9 +5313,15 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T588743762"] = "An error o -- The transcription result is empty. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::VOICERECORDER::T974954792"] = "The transcription result is empty." +-- Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1047391993"] = "Do you want to copy this chat? Your unsaved changes move into the copy, and the original chat keeps the state it was last saved with." + -- Move chat UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1133040906"] = "Move chat" +-- Copy Chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1192756314"] = "Copy Chat" + -- Loading chats... UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1364857726"] = "Loading chats..." @@ -5367,6 +5388,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." +-- Copy chat +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3337233722"] = "Copy chat" + -- Rename UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3355849203"] = "Rename" @@ -5382,6 +5406,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3555709365"] = "Load Chat" -- Add Workspace UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3672981145"] = "Add Workspace" +-- Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3699436634"] = "Do you want to copy this chat? The copy is opened afterwards, so all unsaved changes of the chat you have open right now will be lost." + -- Chat Name UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T3891063690"] = "Chat Name" @@ -12951,14 +12978,32 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::VALIDATION::PROVIDERVALIDATION::T818893091"] = -- Are you sure you want to delete the chat '{0}' in the workspace '{1}'? UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1016188706"] = "Are you sure you want to delete the chat '{0}' in the workspace '{1}'?" +-- Copy Chat +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1192756314"] = "Copy Chat" + -- Unnamed workspace UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1307384014"] = "Unnamed workspace" +-- Copy +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T1703884388"] = "Copy" + -- Delete Chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2244038752"] = "Delete Chat" +-- Please enter a chat name. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T2301651387"] = "Please enter a chat name." + -- Are you sure you want to delete the temporary chat '{0}'? UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you sure you want to delete the temporary chat '{0}'?" -- Unnamed chat UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat" + +-- Please enter a name for the copy of your chat '{0}': +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3323676840"] = "Please enter a name for the copy of your chat '{0}':" + +-- Copy of {0} +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}" + +-- Chat Name +UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3891063690"] = "Chat Name" diff --git a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs index 5852a332..6a9c14da 100644 --- a/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs +++ b/app/MindWork AI Studio/Tools/WorkspaceBehaviour.cs @@ -752,11 +752,16 @@ public static class WorkspaceBehaviour return Directory.Exists(chatPath); } - public static async Task StoreChatAsync(ChatThread chat) + /// + /// Stores a chat, unless another operation holds its lock for longer than the semaphore timeout. + /// + /// The chat to store. + /// True when the chat was written; false when the operation was skipped to avoid a race. + public static async Task StoreChatAsync(ChatThread chat) { var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChatAsync)); if (!acquired) - return; + return false; try { @@ -775,6 +780,7 @@ public static class WorkspaceBehaviour var lastEditTime = File.GetLastWriteTimeUtc(chatPath); await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime); + return true; } finally { @@ -782,6 +788,133 @@ public static class WorkspaceBehaviour } } + /// + /// Copies a chat into a new chat of the same workspace, including its managed transcript files, + /// after asking the user for the name of the copy. + /// + /// Used to ask for the name. + /// The chat to copy. Its own files and state stay untouched. + /// The persisted copy. Null when the user canceled the question, in which case nothing was copied. + /// + /// This is the one place that asks for the name of a copy, so every way of copying a chat + /// suggests the same name and words the question the same way.

+ /// + /// The copy is written before it is returned, so the caller may open it right away. Runtime-only + /// state of the source is not part of the copy: it is rebuilt when the copy gets loaded. When + /// the copy cannot be written, nothing of it stays behind and the error reaches the caller. + ///
+ public static async Task CopyChatAsync(IDialogService dialogService, ChatThread sourceChat) + { + var sourceName = string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Unnamed chat") : sourceChat.Name; + var dialogParameters = new DialogParameters + { + { x => x.Message, string.Format(TB("Please enter a name for the copy of your chat '{0}':"), sourceName) }, + { x => x.InputHeaderText, TB("Chat Name") }, + { x => x.UserInput, string.Format(TB("Copy of {0}"), sourceName) }, + { x => x.ConfirmText, TB("Copy") }, + { x => x.ConfirmColor, Color.Info }, + { x => x.AllowEmptyInput, false }, + { x => x.EmptyInputErrorMessage, TB("Please enter a chat name.") }, + }; + + var dialogReference = await dialogService.ShowAsync(TB("Copy Chat"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN); + var dialogResult = await dialogReference.Result; + if (dialogResult is null || dialogResult.Canceled) + return null; + + var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS); + var copiedChat = JsonSerializer.Deserialize(serializedChat, JSON_OPTIONS) + ?? throw new InvalidOperationException("The chat could not be copied."); + copiedChat = copiedChat with + { + ChatId = Guid.NewGuid(), + Name = (dialogResult.Data as string)!, + }; + + var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId); + try + { + CopyManagedTranscriptAttachments(copiedChat, targetDirectory); + + // + // Storing is skipped instead of failing when the chat lock cannot be taken. For a copy + // that must not pass as success: the caller would open a chat which is not on disk, + // while the transcript files copied above would stay behind as orphans. + // + if (!await StoreChatAsync(copiedChat)) + throw new IOException($"The copied chat could not be stored: '{targetDirectory}'."); + + return copiedChat; + } + catch + { + if (Directory.Exists(targetDirectory)) + Directory.Delete(targetDirectory, true); + + InvalidateWorkspaceTreeCache(); + throw; + } + } + + private static void CopyManagedTranscriptAttachments(ChatThread chat, string targetChatDirectory) + { + var targetTranscriptDirectory = Path.Combine(targetChatDirectory, "attachments", "transcripts"); + var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var copiedPaths = new Dictionary(pathComparer); + + foreach (var content in chat.Blocks.Select(block => block.Content).OfType()) + { + for (var index = 0; index < content.FileAttachments.Count; index++) + { + if (content.FileAttachments[index] is ManagedTranscriptAttachment transcript) + content.FileAttachments[index] = CopyManagedTranscriptAttachment(chat, transcript, targetTranscriptDirectory, copiedPaths); + } + } + + for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++) + chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths); + } + + private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment( + ChatThread chat, + ManagedTranscriptAttachment source, + string targetTranscriptDirectory, + Dictionary copiedPaths) + { + // + // A thread which was edited outside the app may name a path which is not a path at all. + // Such an attachment keeps pointing at whatever the source named: it is already broken in + // the source chat, and letting it take the whole copy down would be worse. + // + string sourcePath; + try + { + sourcePath = Path.GetFullPath(source.FilePath); + } + catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException) + { + LOG.LogWarning(e, "Could not resolve the transcript path '{FilePath}' while copying chat '{ChatId}'. The attachment is kept as it is.", source.FilePath, chat.ChatId); + return source; + } + + if (copiedPaths.TryGetValue(sourcePath, out var existingCopy)) + return existingCopy; + + Directory.CreateDirectory(targetTranscriptDirectory); + var targetPath = NextTranscriptPath(chat, targetTranscriptDirectory, source.OriginalFileName); + if (File.Exists(sourcePath)) + File.Copy(sourcePath, targetPath); + + var copiedAttachment = new ManagedTranscriptAttachment( + Path.GetFileName(targetPath), + targetPath, + File.Exists(targetPath) ? new FileInfo(targetPath).Length : source.FileSizeBytes, + source.OriginalFileName, + false); + copiedPaths[sourcePath] = copiedAttachment; + return copiedAttachment; + } + /// Creates a transcript atomically inside an already persisted chat. /// Persisted chat that owns the transcript counter. /// Original media path. 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 61ddc6b8..82d42678 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -1,5 +1,7 @@ # v26.9.1, build 256 (2026-09-xx xx:xx UTC) -- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Schütt (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature. +- Added a way to copy an entire chat, either with the button in the chat toolbar or next to the chat in the chat list. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was. Many thanks to Peer Hogeterp (`peerschuett`) and Jens Erler (`j-erler`) for this feature. +- Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages. +- Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Hogeterp (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature. - Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end. - Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings. - Added tools to the assistants. Each assistant has its own tool settings: which tools it starts with and whether you get to change them while you work. The chat, the coding assistant, and the Slide Builder always show the selection; for every other assistant you switch it on where you want it. diff --git a/app/Tests/Chat/ChatThreadRollbackTests.cs b/app/Tests/Chat/ChatThreadRollbackTests.cs new file mode 100644 index 00000000..0f9e91f1 --- /dev/null +++ b/app/Tests/Chat/ChatThreadRollbackTests.cs @@ -0,0 +1,225 @@ +using AIStudio.Agents; +using AIStudio.Chat; +using AIStudio.Components; +using AIStudio.Provider; +using AIStudio.Settings.DataModel; + +namespace AIStudio.Tests.Chat; + +/// +/// Checks what rolling a chat back to an earlier answer removes and what it keeps. +/// +/// +/// A rollback has to remove more than the user sees. The prompts an assistant sends into a chat are +/// hidden, yet they are part of the conversation; a chat which kept them would continue with +/// messages the user can neither see nor remove. The example conversation of a chat template is +/// hidden as well, but it comes before everything else and has to keep working. +/// +/// What a rollback must not remove is anything which tells the providers what this chat has seen. +/// Removing the message which brought confidential data in does not unsee it, so the chat keeps +/// demanding a self-hosted provider, and the confidence which that data asked for. +/// +[TestFixture] +public sealed class ChatThreadRollbackTests +{ + private static readonly DateTimeOffset START = new(2026, 9, 23, 10, 0, 0, TimeSpan.Zero); + + [Test] + public void HiddenPromptsAfterTheAnswerGoAsWell() + { + var answer = Block(ChatRole.AI, "First answer", 2); + var thread = new ChatThread + { + Blocks = + [ + Block(ChatRole.USER, "First question", 1), + answer, + Block(ChatRole.USER, "Prompt of an assistant", 3, hidden: true), + Block(ChatRole.AI, "Answer to the assistant", 4), + ], + }; + + var rolledBack = thread.RollBackTo(answer.Content!); + + Assert.Multiple(() => + { + Assert.That(rolledBack, Is.True, "Two blocks came after the answer, so the rollback removed something."); + Assert.That(Texts(thread), Is.EqualTo(new[] { "First question", "First answer" }), "The hidden prompt goes with the answer to it, although the user never saw it."); + }); + } + + [Test] + public void TheExampleConversationOfAChatTemplateStays() + { + var answer = Block(ChatRole.AI, "First answer", 4); + var thread = new ChatThread + { + Blocks = + [ + Block(ChatRole.USER, "Example question", 1, hidden: true), + Block(ChatRole.AI, "Example answer", 2, hidden: true), + Block(ChatRole.USER, "First question", 3), + answer, + Block(ChatRole.USER, "Second question", 5), + Block(ChatRole.AI, "Second answer", 6), + ], + }; + + thread.RollBackTo(answer.Content!); + + Assert.That(Texts(thread), Is.EqualTo(new[] { "Example question", "Example answer", "First question", "First answer" }), "Hidden blocks before the answer belong to what the user rolls back to, so the chat template keeps its example conversation."); + } + + [Test] + public void TheOrderIsTheOneOfTheTimeStampsNotTheOneOfTheList() + { + var answer = Block(ChatRole.AI, "First answer", 2); + var thread = new ChatThread + { + Blocks = + [ + Block(ChatRole.USER, "Second question", 3), + Block(ChatRole.AI, "Second answer", 4), + answer, + Block(ChatRole.USER, "First question", 1), + ], + }; + + thread.RollBackTo(answer.Content!); + + Assert.That(Texts(thread), Is.EqualTo(new[] { "First question", "First answer" }), "The chat shows its blocks by time, so a rollback has to count by time as well, wherever a block sits in the list."); + } + + [Test] + public void TheLastRetrievalIsDroppedButTheChoiceOfDataSourcesStays() + { + var answer = Block(ChatRole.AI, "First answer", 2); + var options = new DataSourceOptions + { + DisableDataSources = false, + AutomaticDataSourceSelection = true, + PreselectedDataSourceIds = ["handbook"], + }; + + var thread = new ChatThread + { + DataSourceOptions = options, + AISelectedDataSources = [SelectedHandbook()], + AugmentedData = "A chunk from the handbook", + Blocks = + [ + Block(ChatRole.USER, "First question", 1), + answer, + Block(ChatRole.USER, "Second question", 3), + Block(ChatRole.AI, "Second answer", 4), + ], + }; + + thread.RollBackTo(answer.Content!); + + Assert.Multiple(() => + { + Assert.That(thread.AugmentedData, Is.Empty, "Nobody knows whether the retrieved data belongs to a kept or to a removed message, so it must not reach the next system prompt."); + Assert.That(thread.AISelectedDataSources, Is.Empty, "The data sources an agent picked belong to the same retrieval as the data."); + Assert.That(thread.DataSourceOptions, Is.SameAs(options), "The data source options are the user's choice, not the result of a message."); + Assert.That(options.DisableDataSources, Is.False); + Assert.That(options.AutomaticDataSourceSelection, Is.True); + Assert.That(options.PreselectedDataSourceIds, Is.EqualTo(new[] { "handbook" })); + }); + } + + [Test] + public void WhatTheChatHasSeenKeepsRestrictingTheProviders() + { + var answer = Block(ChatRole.AI, "First answer", 2); + var thread = new ChatThread + { + DataSecurity = DataSourceSecurity.SELF_HOSTED, + Blocks = + [ + Block(ChatRole.USER, "First question", 1), + answer, + Block(ChatRole.USER, "Question about the confidential handbook", 3), + Block(ChatRole.AI, "Answer from the confidential handbook", 4), + ], + }; + + thread.RequireProviderConfidence(ConfidenceLevel.HIGH); + + thread.RollBackTo(answer.Content!); + + Assert.Multiple(() => + { + Assert.That(thread.DataSecurity, Is.EqualTo(DataSourceSecurity.SELF_HOSTED), "The confidential data was seen by this chat, so no cloud provider may continue it."); + Assert.That(thread.RequiredProviderConfidence, Is.EqualTo(ConfidenceLevel.HIGH), "The confidence the data demanded stays, although the message which brought it in is gone."); + }); + } + + [Test] + public void RollingBackToTheLastAnswerChangesNothing() + { + var answer = Block(ChatRole.AI, "First answer", 2); + var thread = ThreadWithRetrieval(Block(ChatRole.USER, "First question", 1), answer); + + var rolledBack = thread.RollBackTo(answer.Content!); + + AssertUnchanged(thread, rolledBack, "First question", "First answer"); + } + + [Test] + public void RollingBackToUnknownContentChangesNothing() + { + var thread = ThreadWithRetrieval(Block(ChatRole.USER, "First question", 1), Block(ChatRole.AI, "First answer", 2)); + + var rolledBack = thread.RollBackTo(new ContentText { Text = "Answer of another chat" }); + + AssertUnchanged(thread, rolledBack, "First question", "First answer"); + } + + /// + /// A chat whose last retrieval is still in place, so that a rollback which should do nothing + /// shows when it resets it anyway. + /// + /// The blocks of the chat. + /// The chat. + private static ChatThread ThreadWithRetrieval(params ContentBlock[] blocks) => new() + { + AISelectedDataSources = [SelectedHandbook()], + AugmentedData = "A chunk from the handbook", + Blocks = [..blocks], + }; + + private static void AssertUnchanged(ChatThread thread, bool rolledBack, params string[] expectedTexts) + { + Assert.Multiple(() => + { + Assert.That(rolledBack, Is.False, "Nothing came after the content, so there was nothing to roll back."); + Assert.That(Texts(thread), Is.EqualTo(expectedTexts), "A rollback without anything to remove leaves the blocks alone."); + Assert.That(thread.AugmentedData, Is.EqualTo("A chunk from the handbook"), "A rollback without anything to remove keeps the last retrieval, because it still belongs to the last message."); + Assert.That(thread.AISelectedDataSources, Has.Count.EqualTo(1)); + }); + } + + private static ContentBlock Block(ChatRole role, string text, int minute, bool hidden = false) => new() + { + Time = START.AddMinutes(minute), + ContentType = ContentType.TEXT, + Content = new ContentText { Text = text }, + Role = role, + HideFromUser = hidden, + }; + + private static DataSourceAgentSelected SelectedHandbook() => new() + { + DataSource = new DataSourceLocalDirectory { Id = "handbook", Name = "Handbook" }, + AIDecision = new SelectedDataSource("handbook", "The question is about the handbook.", 0.9f), + Selected = true, + }; + + /// + /// The texts of the chat in the order the chat shows them. + /// + /// The chat. + /// The texts. + private static string[] Texts(ChatThread thread) => thread.Blocks.OrderBy(x => x.Time).Select(x => ((ContentText)x.Content!).Text).ToArray(); +} \ No newline at end of file From fda42ac24b45b5b71cd3bba5d8b13336c5698e44 Mon Sep 17 00:00:00 2001 From: Peer Hogeterp Date: Wed, 23 Sep 2026 15:02:33 +0200 Subject: [PATCH 2/2] Improved exports (#993) Co-authored-by: Thorsten Sommer --- .../Assistants/AssistantBase.razor | 4 +- .../Assistants/AssistantBase.razor.cs | 6 + .../DocumentAnalysisAssistant.razor.cs | 22 +- .../Assistants/I18N/allTexts.lua | 6 + .../Chat/ContentBlockComponent.razor | 6 +- .../Chat/ContentBlockComponent.razor.cs | 88 +++++--- .../Chat/IContentExtensions.cs | 67 ++++-- .../Components/ChatComponent.razor | 1 + .../plugin.lua | 6 + .../plugin.lua | 6 + .../Tools/FileExportFormatExtensions.cs | 100 +++++++++ app/MindWork AI Studio/Tools/MessageFile.cs | 14 ++ app/MindWork AI Studio/Tools/MessageTable.cs | 12 -- app/MindWork AI Studio/Tools/PandocExport.cs | 21 +- .../Tools/PlainFileExport.cs | 71 ++++--- .../wwwroot/changelog/v26.9.1.md | 3 + app/Tests/Chat/IContentExtensionsTests.cs | 86 +++++++- app/Tests/Tools/FileExportFormatTests.cs | 130 +++++++++++ app/Tests/Tools/Fixtures/standalone_page.html | 201 ++++++++++++++++++ app/Tests/Tools/PlainFileExportTests.cs | 134 ++++++++++++ 20 files changed, 883 insertions(+), 101 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/MessageFile.cs delete mode 100644 app/MindWork AI Studio/Tools/MessageTable.cs create mode 100644 app/Tests/Tools/Fixtures/standalone_page.html create mode 100644 app/Tests/Tools/PlainFileExportTests.cs diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index 3a866034..95e18ffe 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -79,7 +79,7 @@ @if (this.ShowResult && !this.ShowEntireChatThread && this.ResultingContentBlock?.Content != null) { - + } @if(this.ShowResult && this.ShowEntireChatThread && this.ChatThread is not null) @@ -88,7 +88,7 @@ { @if (block is { HideFromUser: false, Content: not null }) { - + } } } diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index f655ea77..77cb7f65 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -67,6 +67,12 @@ public abstract partial class AssistantBase : AssistantLowerBase wher _ => string.Empty, }; + /// + /// What an export of the result is named after, which the save dialog suggests as file name. + /// An assistant whose result is about something more specific than the assistant itself names that. + /// + protected virtual string ExportFileName => this.Title; + protected abstract void ResetForm(); protected abstract bool MightPreselectValues(); diff --git a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs index a6ef3bab..e69dbc12 100644 --- a/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/DocumentAnalysis/DocumentAnalysisAssistant.razor.cs @@ -36,7 +36,12 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore AssistantManagedToolIds => this.policyAllowedToolIds; protected override string Title => T("Document Analysis Assistant"); - + + /// + /// An analysis is named after its policy, which says far more than the name of the assistant. + /// + protected override string ExportFileName => string.IsNullOrWhiteSpace(this.analyzedPolicyName) ? this.Title : this.analyzedPolicyName; + protected override string Description => T("The document analysis assistant helps you to analyze and extract information from documents based on predefined policies. You can create, edit, and manage document analysis policies that define how documents should be processed and what information should be extracted. Some policies might be protected by your organization and cannot be modified or deleted."); protected override string SystemPrompt => @@ -368,6 +373,15 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore loadedDocumentPaths = []; + + /// + /// The name of the policy the result on screen was produced with. + /// + /// + /// Switching to another policy keeps the result, so the selected policy may no longer be the + /// one behind it. An export has to be named after the analysis it holds. + /// + private string analyzedPolicyName = string.Empty; private readonly List> availableLLMProviders = new(); private static readonly AssistantSessionStateKey SELECTED_POLICY_STATE_KEY = new(nameof(selectedPolicy)); private static readonly AssistantSessionStateKey POLICY_IS_PROTECTED_STATE_KEY = new(nameof(policyIsProtected)); @@ -381,6 +395,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore POLICY_PRESELECTED_PROVIDER_ID_STATE_KEY = new(nameof(policyPreselectedProviderId)); private static readonly AssistantSessionStateKey POLICY_PRESELECTED_PROFILE_STATE_KEY = new(nameof(policyPreselectedProfile)); private static readonly AssistantSessionStateKey> LOADED_DOCUMENT_PATHS_STATE_KEY = new(nameof(loadedDocumentPaths)); + private static readonly AssistantSessionStateKey ANALYZED_POLICY_NAME_STATE_KEY = new(nameof(analyzedPolicyName)); private static readonly AssistantSessionStateKey>> AVAILABLE_LLM_PROVIDERS_STATE_KEY = new(nameof(availableLLMProviders)); /// @@ -398,6 +413,7 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore this.policyPreselectedProviderId = value); state.Restore(POLICY_PRESELECTED_PROFILE_STATE_KEY, value => this.policyPreselectedProfile = value); state.RestoreHashSet(LOADED_DOCUMENT_PATHS_STATE_KEY, this.loadedDocumentPaths); + state.Restore(ANALYZED_POLICY_NAME_STATE_KEY, value => this.analyzedPolicyName = value); state.RestoreList(AVAILABLE_LLM_PROVIDERS_STATE_KEY, this.availableLLMProviders); } @@ -926,7 +943,8 @@ public partial class DocumentAnalysisAssistant : AssistantBaseCore } - @if (this.MessageTables.Count > 0) + @if (this.MessageFiles.Count > 0) { - @foreach (var messageTable in this.MessageTables) + @foreach (var messageFile in this.MessageFiles) { - + } } diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index 4dc68e92..86242dfd 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -104,6 +104,18 @@ public partial class ContentBlockComponent : MSGComponentBase /// [Parameter] public string? ExportTitle { get; set; } + + /// + /// What an export of this block is named after, which the save dialog suggests as file name. + /// + /// + /// In the chat that is the name of the chat, in an assistant whatever the assistant says its + /// result is about. Whoever renders this block knows which of the two it is. A table or a code + /// block with a heading above it is named after that heading instead. Null falls back to a + /// generic name. + /// + [Parameter] + public string? ExportFileName { get; set; } [Inject] private IDialogService DialogService { get; init; } = null!; @@ -125,8 +137,8 @@ public partial class ContentBlockComponent : MSGComponentBase private int lastRenderHash; private string cachedMarkdownRenderPlanInput = string.Empty; private MarkdownRenderPlan cachedMarkdownRenderPlan = MarkdownRenderPlan.EMPTY; - private string cachedMessageTablesInput = string.Empty; - private IReadOnlyList cachedMessageTables = []; + private string cachedMessageFilesInput = string.Empty; + private IReadOnlyList cachedMessageFiles = []; private char csvSeparator = ','; private ElementReference mathContentContainer; private SourcesList? sourcesList; @@ -147,54 +159,62 @@ public partial class ContentBlockComponent : MSGComponentBase private bool CanExport => this.Content is { InitialRemoteWait: false, IsStreaming: false } && this.Content.TryGetMarkdownText(out _); /// - /// The tables this block holds so that the export menu can offer each of them. + /// The files this block holds, tables and code blocks, so that the export menu can offer each of them. /// /// - /// Cached the same way the Markdown render plan is: reading the tables means parsing the whole + /// Cached the same way the Markdown render plan is: reading the files means parsing the whole /// message, and a block re-renders for reasons which have nothing to do with its text, such as /// switching the theme, which would parse every message of a long chat again. /// - private IReadOnlyList MessageTables + private IReadOnlyList MessageFiles { get { if (!this.Content.TryGetMarkdownText(out var markdown)) return []; - if (ReferenceEquals(this.cachedMessageTablesInput, markdown) || string.Equals(this.cachedMessageTablesInput, markdown, StringComparison.Ordinal)) - return this.cachedMessageTables; + if (ReferenceEquals(this.cachedMessageFilesInput, markdown) || string.Equals(this.cachedMessageFilesInput, markdown, StringComparison.Ordinal)) + return this.cachedMessageFiles; - this.cachedMessageTablesInput = markdown; - this.cachedMessageTables = PlainFileExport.ExtractTables(markdown, this.csvSeparator); - return this.cachedMessageTables; + this.cachedMessageFilesInput = markdown; + this.cachedMessageFiles = PlainFileExport.ExtractFiles(markdown, this.csvSeparator); + return this.cachedMessageFiles; } } /// - /// Names one table in the export menu. + /// Names one file in the export menu. /// /// - /// With a single table the format alone says everything. As soon as an answer holds more than - /// one, the user has to be able to tell them apart: the heading above a table does that, unless - /// it is missing or two tables share one, and then we count them. + /// Tables and code blocks are named apart, just as they are counted apart. With a single file of + /// its kind the format alone says everything. As soon as an answer holds more than one, the user + /// has to be able to tell them apart: the heading above a file does that, unless it is missing + /// or two files of the kind share one, and then we count them. A code block always says that it + /// is one, because the menu offers the entire answer as a web page or a LaTeX document right + /// below, and the two entries must not read alike. /// - private string ExportLabel(MessageTable table) + private string ExportLabel(MessageFile file) { - var tables = this.MessageTables; - if (tables.Count < 2) - return table.Format.ToName(); - - var captionIsTelling = !string.IsNullOrWhiteSpace(table.Caption) - && tables.Where(entry => entry.Ordinal != table.Ordinal).All(entry => !string.Equals(entry.Caption, table.Caption, StringComparison.Ordinal)); + var isTable = file.Format.IsTabular(); + var filesOfItsKind = this.MessageFiles.Where(entry => entry.Format.IsTabular() == isTable).ToList(); + var extension = file.Format.ToFileExtension(); // // The caption is the heading the model wrote, so it already carries the language of the // answer and needs no translation of ours. Only the fallback, where we have to count the - // tables ourselves, is our own wording. + // files ourselves, is our own wording. // - return captionIsTelling - ? $"{table.Caption} ({table.Format.ToFileExtension()})" - : string.Format(this.T("Table {0} ({1})"), table.Ordinal, table.Format.ToFileExtension()); + string name; + if (filesOfItsKind.Count < 2) + name = file.Format.ToName(); + else if (!string.IsNullOrWhiteSpace(file.Caption) && filesOfItsKind.Count(entry => string.Equals(entry.Caption, file.Caption, StringComparison.Ordinal)) is 1) + name = $"{file.Caption} ({extension})"; + else + return isTable + ? string.Format(T("Table {0} ({1})"), file.Ordinal, extension) + : string.Format(T("Code block {0} ({1})"), file.Ordinal, extension); + + return isTable ? name : string.Format(T("Code block: {0}"), name); } /// @@ -221,8 +241,8 @@ public partial class ContentBlockComponent : MSGComponentBase return; this.csvSeparator = separator; - this.cachedMessageTablesInput = string.Empty; - this.cachedMessageTables = []; + this.cachedMessageFilesInput = string.Empty; + this.cachedMessageFiles = []; await this.InvokeAsync(this.StateHasChanged); } @@ -738,9 +758,9 @@ public partial class ContentBlockComponent : MSGComponentBase // here which would fall out of sync with the one in FileExportFormatExtensions. // if (format.UsesPandoc()) - await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content); + await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content, this.ExportFileName); else if (this.Content.TryGetExportMarkdown(out var markdown)) - await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown); + await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown, this.ExportFileName); } catch (ArgumentOutOfRangeException e) { @@ -749,17 +769,19 @@ public partial class ContentBlockComponent : MSGComponentBase } /// - /// Exports one table out of the message, exactly as the menu offered it. + /// Exports one file out of the message, along with the sources the answer rests on wherever its + /// format has room for them. /// - private async Task ExportTable(MessageTable table) + private async Task ExportFile(MessageFile file) { try { - await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, table.Format, table.Content, table.Caption); + var fileName = string.IsNullOrWhiteSpace(file.Caption) ? this.ExportFileName : file.Caption; + await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, file.Format, this.Content.ToExportContent(file), fileName); } catch (ArgumentOutOfRangeException e) { - await this.ReportUnknownExportFormat(e, table.Format); + await this.ReportUnknownExportFormat(e, file.Format); } } diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs index 4d8f2346..d078c9d3 100644 --- a/app/MindWork AI Studio/Chat/IContentExtensions.cs +++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs @@ -67,26 +67,65 @@ public static class IContentExtensions return false; } - var answer = text.Text.Trim(); - var sources = text.Sources.ToExportMarkdown(keepPageAnchors); - if (sources.Length == 0) - { - markdown = answer; - return true; - } + markdown = AppendSources(text.Text.Trim(), text.Sources.ToExportMarkdown(keepPageAnchors)); + return true; + } - if (answer.Length == 0) - { - markdown = sources; - return true; - } + /// + /// Reads one file out of this content the way it leaves AI Studio, together with the sources + /// the answer rests on. + /// + /// + /// A code block saved on its own came out of the same answer, so it rests on the same sources + /// and takes them along. How depends on the format. Markdown is what the source list is written + /// in, so a Markdown text gets it just as the entire answer does. A web page or a LaTeX document + /// gets it as a comment at its end: anything visible would have to be woven into markup the + /// model wrote. A fragment has no body to put it in, a page may hide whatever lies outside its + /// layout, and one underscore in a title is enough to stop a LaTeX run. A comment breaks + /// neither, and whoever opens the file finds it. A table gets no sources at all, since it has + /// no column a link list would fit into. + /// + /// Apart from that, the file is what the model wrote, scripts of a web page included. Saving it + /// is what the user chose to do; the chat still never renders it. + /// + /// The content the file was found in. + /// The file, as PlainFileExport.ExtractFiles read it out of this content. + /// The content of the file to write. + public static string ToExportContent(this IContent content, MessageFile file) + { + if (file.Format.IsTabular()) + return file.Content; + + var sources = content.Sources.ToExportMarkdown(file.Format.FollowsPageAnchors()); + if (file.Format is FileExportFormat.MARKDOWN) + return AppendSources(file.Content, sources); + + if (sources.Length is 0 || !file.Format.TryToComment(sources, out var comment)) + return file.Content; + + return $"{file.Content}{Environment.NewLine}{Environment.NewLine}{comment}"; + } + + /// + /// Puts the source list below a Markdown text. + /// + /// The Markdown text. + /// The source list as SourceExtensions.ToExportMarkdown writes it, or an + /// empty string when there are no sources. + /// The text followed by its sources. + private static string AppendSources(string markdown, string sources) + { + if (sources.Length == 0) + return markdown; + + if (markdown.Length == 0) + return sources; // // The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so // that the heading of the source list stands on its own instead of being pulled into the // last block of the answer. // - markdown = $"{Markdown.CloseOpenCodeFence(answer)}{Environment.NewLine}{Environment.NewLine}{sources}"; - return true; + return $"{Markdown.CloseOpenCodeFence(markdown)}{Environment.NewLine}{Environment.NewLine}{sources}"; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor b/app/MindWork AI Studio/Components/ChatComponent.razor index c27291be..dcdcfc31 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor +++ b/app/MindWork AI Studio/Components/ChatComponent.razor @@ -21,6 +21,7 @@ Type="@block.ContentType" Time="@block.Time" Content="@block.Content" + ExportFileName="@this.ChatThread.Name" RemoveBlockFunc="@this.RemoveBlock" IsLastContentBlock="@isLastBlock" IsSecondToLastBlock="@isSecondLastBlock" 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 7fb62ad5..6d292049 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 @@ -3297,6 +3297,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1820166585"] = "Ja, ent -- Number of sources UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1848978959"] = "Anzahl der Quellen" +-- Code block {0} ({1}) +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1934297017"] = "Codeblock {0} ({1})" + -- Show {0} tool calls UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1981771421"] = "{0} Werkzeugaufrufe anzeigen" @@ -3345,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Neu gen -- Blocked UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blockiert" +-- Code block: {0} +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3840086915"] = "Codeblock: {0}" + -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Möchten Sie diese Nachricht wirklich neu generieren?" 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 de64a8e9..93d915e8 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 @@ -3297,6 +3297,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1820166585"] = "Yes, re -- Number of sources UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1848978959"] = "Number of sources" +-- Code block {0} ({1}) +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1934297017"] = "Code block {0} ({1})" + -- Show {0} tool calls UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T1981771421"] = "Show {0} tool calls" @@ -3345,6 +3348,9 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3587744975"] = "Regener -- Blocked UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3816336467"] = "Blocked" +-- Code block: {0} +UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3840086915"] = "Code block: {0}" + -- Do you really want to regenerate this message? UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTBLOCKCOMPONENT::T3878878761"] = "Do you really want to regenerate this message?" diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs index 2fde6b79..0e1b552e 100644 --- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs +++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs @@ -103,6 +103,57 @@ public static class FileExportFormatExtensions _ => string.Empty, }; + /// + /// Reads which format a model means when it names a language behind the opening fence of a + /// code block. + /// + /// + /// A model which answers with a finished file puts it into a code block and names its language, + /// as in ```html. Models do not agree on the spelling, so we accept the usual names of a format + /// in any case. Only formats which are plain text appear here: a code block holds text, never + /// a Word document. + /// + /// The language behind the opening fence, as Markdig reads it into + /// FencedCodeBlock.Info. + /// The format the language names, or NONE when it names none of ours. + /// True, when the language names a format AI Studio writes. + public static bool TryFromCodeFenceLanguage(string? language, out FileExportFormat format) + { + format = language?.Trim().ToLowerInvariant() switch + { + "html" => FileExportFormat.HTML, + "latex" or "tex" => FileExportFormat.LATEX, + "markdown" or "md" => FileExportFormat.MARKDOWN, + "csv" => FileExportFormat.CSV, + "tsv" => FileExportFormat.TSV, + + _ => FileExportFormat.NONE, + }; + + return format is not FileExportFormat.NONE; + } + + /// + /// Determines whether the format holds a table rather than a text. + /// + /// The format. + /// True for the formats a spreadsheet opens. + public static bool IsTabular(this FileExportFormat format) => format is FileExportFormat.CSV or FileExportFormat.TSV; + + /// + /// Determines whether a file of the format is plain text, which AI Studio writes as it is. + /// + /// + /// That holds for a web page and a LaTeX document as well, even though an entire answer needs + /// Pandoc to become one: the answer is Markdown, whereas a page the model wrote is a finished + /// file already. A Word or an OpenDocument file is an archive, and only Pandoc produces one. The + /// list is spelled out on purpose, so a format added later counts as plain text only once + /// somebody says so. + /// + /// The format. + /// True, when a text written as it is makes a valid file of the format. + public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV; + /// /// Returns the file name the save dialog starts with. /// @@ -204,6 +255,42 @@ public static class FileExportFormatExtensions _ => WITHOUT_BYTE_ORDER_MARK, }; + /// + /// Wraps a text into a comment of the format: whoever opens the file in an editor reads it, + /// while a browser or a LaTeX run skips it. + /// + /// + /// A comment in HTML, and so in Markdown, ends at the first --> it holds, and a browser takes + /// --!> for the same; the rest of the text would spill onto the page from there. The title of + /// a web page may hold either, so a space goes in before the bracket, which keeps the text + /// readable and ends nothing. Every other pair of dashes stays, because a web address may carry + /// one, as in the xn-- of a domain with an umlaut. A LaTeX comment has no end to watch for: it + /// runs to the end of its line, so every line starts one. + /// + /// The format. + /// The text to put into the comment. + /// The comment, or an empty string when the format has none. + /// True, when the format knows comments. + public static bool TryToComment(this FileExportFormat format, string text, out string comment) + { + var lines = text.TrimEnd().ReplaceLineEndings("\n").Split('\n'); + switch (format) + { + case FileExportFormat.HTML or FileExportFormat.MARKDOWN: + var commentText = string.Join(Environment.NewLine, lines).Replace("-->", "-- >").Replace("--!>", "--! >"); + comment = $""; + return true; + + case FileExportFormat.LATEX: + comment = string.Join(Environment.NewLine, lines.Select(line => line.Length is 0 ? "%" : $"% {line}")); + return true; + + default: + comment = string.Empty; + return false; + } + } + /// /// Determines whether a link into a local file may name the page it points at. /// @@ -224,6 +311,19 @@ public static class FileExportFormatExtensions _ => true, }; + /// + /// Determines whether Pandoc has to be told the title of a document in the format. + /// + /// + /// A web page shows its title in the browser tab. Without one, Pandoc names the page after its + /// input file, which is a temporary file of ours with a random name. Word and OpenDocument show + /// no such title, and handed one anyway, they keep it as a document property nobody asked for; + /// verified with Pandoc 3.8.3 on 2026-09-23. LaTeX ignores it. + /// + /// The format. + /// True, when a document of this format needs a title besides its content. + public static bool NeedsPageTitle(this FileExportFormat format) => format is FileExportFormat.HTML; + /// /// Returns the name Pandoc knows the format by. /// diff --git a/app/MindWork AI Studio/Tools/MessageFile.cs b/app/MindWork AI Studio/Tools/MessageFile.cs new file mode 100644 index 00000000..28151d7a --- /dev/null +++ b/app/MindWork AI Studio/Tools/MessageFile.cs @@ -0,0 +1,14 @@ +namespace AIStudio.Tools; + +/// +/// A file found in a message, ready to be written: a table the model wrote, or a code block the +/// model marked as a format we write. +/// +/// Which table or which code block of the message this is, counting from one +/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular. +/// This is what tells two files of one kind apart even when they carry the same heading. +/// What the file is about: the heading above it, or else the first column +/// heading of a table. Empty for a code block without a heading above it. +/// The format this content is written as. +/// The finished file content. +public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/MessageTable.cs b/app/MindWork AI Studio/Tools/MessageTable.cs deleted file mode 100644 index 7ea9a7be..00000000 --- a/app/MindWork AI Studio/Tools/MessageTable.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace AIStudio.Tools; - -/// -/// A table found in a message, ready to be written to a file. -/// -/// Which table of the message this is, counting from one. The same table -/// appears once per format we offer for it, so this is what tells two tables apart even when they -/// carry the same heading. -/// What the table is about, taken from its first column heading. -/// The format this content is written as. -/// The finished file content. -public sealed record MessageTable(int Ordinal, string Caption, FileExportFormat Format, string Content); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs index 1b63fa42..bdba4726 100644 --- a/app/MindWork AI Studio/Tools/PandocExport.cs +++ b/app/MindWork AI Studio/Tools/PandocExport.cs @@ -43,14 +43,23 @@ public static class PandocExport await File.WriteAllTextAsync(tempMarkdownFilePath, markdownText, new UTF8Encoding(false), token); // Call Pandoc to create the document: - var pandoc = await PandocProcessBuilder + var pandocBuilder = PandocProcessBuilder .Create() .UseStandaloneMode() .WithInputFormat("gfm+emoji+tex_math_dollars") .WithOutputFormat(format.ToPandocOutputFormat()) .WithOutputFile(targetFilePath) - .WithInputFile(tempMarkdownFilePath) - .BuildAsync(rustService); + .WithInputFile(tempMarkdownFilePath); + + // + // The document is named after the file it is written to. Set as metadata, the name + // reaches the page as a string which Pandoc escapes; only a file named true or false + // is read as a switch and keeps the temporary name. + // + if (format.NeedsPageTitle()) + pandocBuilder.AddArgument("-M").AddArgument($"pagetitle={Path.GetFileNameWithoutExtension(targetFilePath)}"); + + var pandoc = await pandocBuilder.BuildAsync(rustService); using var process = Process.Start(pandoc.StartInfo); if (process is null) @@ -108,8 +117,10 @@ public static class PandocExport /// looking at, a chat message or the result of an assistant, so the caller names it. /// The format to write. Must be a format which uses Pandoc. /// The content to export. + /// What the document is about, used to suggest a name in the save dialog. + /// Null falls back to a generic name. /// True, when the document was written. - public static async Task ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent) + public static async Task ToDocument(RustService rustService, PandocAvailabilityService pandocAvailability, string dialogTitle, FileExportFormat format, IContent markdownContent, string? fileName = null) { if (!format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter) throw new ArgumentOutOfRangeException(nameof(format), format, "Pandoc cannot write this format."); @@ -125,7 +136,7 @@ public static class PandocExport return false; } - var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName()); + var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName)); if (response.UserCancelled) { LOGGER.LogInformation("User cancelled the save dialog."); diff --git a/app/MindWork AI Studio/Tools/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs index d3e56cad..9ff96576 100644 --- a/app/MindWork AI Studio/Tools/PlainFileExport.cs +++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs @@ -16,18 +16,21 @@ public static class PlainFileExport private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport)); /// - /// Reads every table a message holds, in the order they appear in it. + /// Reads every file a message holds, in the order they appear in it. /// /// - /// Two kinds of tables end up in an answer. Almost always it is a Markdown table written with - /// pipes, which is what a model produces on its own; we turn its cells into a file. Rarely a - /// model answers with a fenced code block marked as csv or tsv, which already is the finished - /// file: we hand that through untouched rather than taking it apart and reassembling it. + /// Two kinds of files end up in an answer. Almost always it is a Markdown table written with + /// pipes, which is what a model produces on its own; we turn its cells into a file. Besides, + /// a model answers with a fenced code block marked as a format we write, such as html, latex, + /// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block + /// already is the finished file: we hand it through untouched rather than taking it apart and + /// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of + /// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for. /// /// The Markdown text of the message. /// The separator to write a Markdown table with, see CsvWriter.SeparatorFor. - /// The tables, or an empty list when the message holds none. - public static IReadOnlyList ExtractTables(string markdown, char separator) + /// The files, or an empty list when the message holds none. + public static IReadOnlyList ExtractFiles(string markdown, char separator) { if (string.IsNullOrWhiteSpace(markdown)) return []; @@ -40,9 +43,10 @@ public static class PlainFileExport var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE); // - // What a table is about stands above it, not in it: models introduce their tables with a - // heading. We remember every heading with its line so that each table can take the last - // one before it, and fall back to its own first column heading when there is none. + // What a file is about stands above it, not in it: models introduce their tables and code + // blocks with a heading. We remember every heading with its line so that each file can take + // the last one before it. A table falls back to its own first column heading when there is + // none; a code block has nothing comparable and stays without a caption. // var headings = document.Descendants() .Select(heading => (heading.Line, Text: ToPlainText(heading))) @@ -56,11 +60,18 @@ public static class PlainFileExport var codeBlocks = document.Descendants() .Select(block => (block.Line, Content: ToContent(block))); + // + // Tables and code blocks are counted apart. The menu falls back to that number when a + // heading cannot tell two files apart, and "Table 2" has to be the second table of the + // answer, not the second entry of the menu. + // + var numberOfTables = 0; + var numberOfCodeBlocks = 0; return tables.Concat(codeBlocks) .Where(entry => entry.Content is not null) .OrderBy(entry => entry.Line) - .Select((entry, index) => new MessageTable( - index + 1, + .Select(entry => new MessageFile( + entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks, Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback, entry.Content!.Value.Format, entry.Content.Value.Text)) @@ -90,22 +101,22 @@ public static class PlainFileExport } /// - /// Turns a fenced code block into a file, when the model marked it as tabular data. + /// Turns a fenced code block into a file, when the model marked it as a format we write. /// + /// + /// A block the model never closed is left out. That happens when an answer broke off, at the + /// output limit of the model for example, and the file would end wherever the answer did: half + /// a web page or half a table is nothing anybody wants to save. + /// private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block) { - var format = block.Info?.Trim() switch - { - "csv" => FileExportFormat.CSV, - "tsv" => FileExportFormat.TSV, - - _ => FileExportFormat.NONE, - }; - - if (format is FileExportFormat.NONE) + if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format)) return null; var content = block.Lines.ToString(); + if (!format.IsTabular()) + return (string.Empty, format, content); + var blockSeparator = format is FileExportFormat.TSV ? '\t' : ','; var firstLine = content.AsSpan(); var lineEnd = firstLine.IndexOf('\n'); @@ -167,20 +178,26 @@ public static class PlainFileExport } /// - /// Writes the given text to a plain text file and lets the user save it. + /// Writes the given text to a plain text file as it is and lets the user save it. /// + /// + /// Nothing is converted here, which is what sets this apart from PandocExport.ToDocument. A web + /// page or a LaTeX document the model wrote is a finished file already and comes through here; + /// an entire answer in one of these formats is Markdown and goes to Pandoc instead. + /// /// The Rust service, used for the save dialog. /// The title of the save dialog. The caller knows what the user is /// looking at, a chat message or the result of an assistant, so the caller names it. - /// The format to write. Must be a format which does not use Pandoc. - /// What to write. The caller decides whether that is the entire - /// message or one table out of it. + /// The format to write. Must be a plain text format, see + /// FileExportFormatExtensions.IsPlainText. + /// The finished file. The caller decides whether that is the entire + /// message or one file out of it. /// What the file is about, used to suggest a name in the save dialog. /// Null falls back to a generic name. /// True, when the file was written. public static async Task ToFile(RustService rustService, string dialogTitle, FileExportFormat format, string fileContent, string? fileName = null) { - if (format.UsesPandoc() || format.ToFileTypeFilter() is not { } fileTypeFilter) + if (!format.IsPlainText() || format.ToFileTypeFilter() is not { } fileTypeFilter) throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format itself."); var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName)); 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 82d42678..e7affb11 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -1,6 +1,7 @@ # v26.9.1, build 256 (2026-09-xx xx:xx UTC) - Added a way to copy an entire chat, either with the button in the chat toolbar or next to the chat in the chat list. The copy opens right away so you can continue in it, while the original conversation stays exactly as it was. Many thanks to Peer Hogeterp (`peerschuett`) and Jens Erler (`j-erler`) for this feature. - Added a way to roll a chat back to an earlier AI response. The response you pick stays, and every message after it is removed permanently, together with the attachments of those messages. +- Added a way to save a single code block of an answer. When an answer holds a web page, a LaTeX document, or a Markdown text, the export menu now offers that block as a file of its own. - Added tools that AI models can use on their own, starting with Web Search and Read Web Page. When you ask something a model cannot answer from what it knows, it now searches the web, reads the pages it found, and answers with the sources it used. You decide which tools a model may use, right below the message field, and you can watch it work: AI Studio shows which tool is running and, afterward, every call it made with its result. Whether tools are offered at all depends on the model because it has to support them. Read Web Page works right away; for Web Search you pick a search service in the app settings — Tavily or Staan with a free API key, or a SearXNG instance you run yourself. Set up more than one, and they can take turns when one of them finds nothing, or be asked all at once with their results combined. Many thanks to Peer Hogeterp (`peerschuett`) and Nils Kruthoff (`nilskruthoff`) for building this feature. - Added answers that appear word by word even while the AI uses its tools. You read along as the model writes, including the short note it puts down before it looks something up, and the answer that follows a tool call arrives the same way instead of all at once at the end. - Added safeguards around everything these tools bring back. Anything fetched from the web is treated as untrusted: AI Studio removes instructions hidden in a page before a model reads it and tells you when it did, exactly as it already does for the documents and web pages you load yourself. A model can never point a tool at your own network. Each tool states how much you have to trust a provider before it may be used with it, so your questions do not travel further than you allow. You can adjust that requirement per tool in the app settings. @@ -48,6 +49,7 @@ - Improved the question AI Studio asks before you delete an embedding provider. It now names the data sources depending on that provider, together with what they can still do without it. - Improved what the AI is told when it answers from your own documents (RAG): it now learns which page a passage came from, so it can name the page an answer rests on. - Improved what happens when you ask for web content to be cleaned up and no model is available for it. AI Studio loads the page and tells you it arrived uncleaned, instead of quietly handing you the raw page with its navigation and advertising still in it. +- Improved the file name AI Studio suggests when you export an answer. Instead of always proposing "export", it now suggests the name of your chat or of the assistant you are working in. In the Document Analysis assistant, it suggests the name of the policy. - Changed what a tile that opens a chat directly starts with: when it uses a chat template that brings its own tools or data sources, that template decides them. The Assistant Builder says so while you build such a tile. - Changed which model a security check of an assistant plugin may fall back on. When you have set none aside for these checks, AI Studio uses the model you were working with, but only when that model meets the trust your organization requires for a check. One ruled out for that purpose no longer gets to read a plugin's code. - Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet. @@ -71,6 +73,7 @@ - Fixed the Visual Briefing assistant (in preview) not scrolling, which put everything below the window edge out of reach and made the assistant unusable. The briefing preview is now shown at its intended size inside its frame, and switching between the desktop, tablet, and mobile view changes its width as it should. - Fixed exported answers losing their sources. When an answer is based on web pages a tool read or on documents of your own, the exported file now lists those sources in every format AI Studio writes. - Fixed the copy button leaving the sources behind. Copy an answer, and its sources come along. +- Fixed exported web pages showing a cryptic string of letters and digits as their title in the browser tab. They now carry the name of their file. - Fixed a tile that opens a chat directly always demanding a workspace. Leave the workspace empty in the Assistant Builder, and the tile opens a disappearing chat instead. - Fixed the same restriction for plugin authors: a direct-chat launcher can now open a chat without naming a workspace. The example assistant plugin shows both ways. - Fixed the security check of an assistant plugin being impossible when no model is set aside for such checks, and you have no app-wide default either. The dialog now lets you pick one, and that choice applies to this one check. Before, the button to start the check was greyed out with nothing saying why, so the plugin could not be enabled at all. diff --git a/app/Tests/Chat/IContentExtensionsTests.cs b/app/Tests/Chat/IContentExtensionsTests.cs index 873ecf53..aac364e6 100644 --- a/app/Tests/Chat/IContentExtensionsTests.cs +++ b/app/Tests/Chat/IContentExtensionsTests.cs @@ -162,16 +162,85 @@ public sealed class IContentExtensionsTests "|---|---|", "| Q1 | 100 |"), TOOL_SOURCE); - content.TryGetMarkdownText(out var markdown); - var tables = PlainFileExport.ExtractTables(markdown, ','); + var tables = FilesOf(content); Assert.Multiple(() => { Assert.That(tables, Has.Count.EqualTo(1), "One table in the message, one table offered for it."); - Assert.That(tables[0].Content, Does.Not.Contain("example.org"), "A data table has no column a link list would fit into."); + Assert.That(content.ToExportContent(tables[0]), Is.EqualTo(tables[0].Content), "A data table has no column a link list would fit into."); }); } + [Test] + public void AMarkdownBlockCarriesTheSourcesVisibly() + { + var content = TextWith(Lines( + "Here are your notes:", + string.Empty, + "```markdown", + "# Notes", + string.Empty, + "The notes.", + "```"), TOOL_SOURCE); + + var exported = content.ToExportContent(FilesOf(content).Single()); + + Assert.That(TopLevelBlocksOf(exported), Is.EqualTo(new[] { "h1", "ParagraphBlock", "h1", "h2", "ListBlock" }), "The notes without the text around them, followed by the source list just as the entire answer carries it."); + } + + [Test] + public void AWebPageCarriesTheSourcesInAComment() + { + var content = TextWith(Lines( + "```html", + "", + "

Hello

", + "```"), TOOL_SOURCE); + + var file = FilesOf(content).Single(); + var exported = content.ToExportContent(file); + var appended = exported[file.Content.Length..].Trim(); + + Assert.Multiple(() => + { + Assert.That(exported, Does.StartWith(file.Content), "The page stays as the model wrote it."); + Assert.That(appended, Does.StartWith(""), "Below the page stands one comment and nothing a browser would show."); + Assert.That(appended, Does.Contain(TOOL_SOURCE.URL)); + }); + } + + [Test] + public void ALatexBlockCarriesTheSourcesInComments() + { + var content = TextWith(Lines( + "```latex", + @"\section{Results}", + "```"), TOOL_SOURCE); + + var file = FilesOf(content).Single(); + var exported = content.ToExportContent(file); + var appended = exported[file.Content.Length..].Trim(); + + Assert.Multiple(() => + { + Assert.That(exported, Does.StartWith(file.Content), "The document stays as the model wrote it."); + Assert.That(appended.Split(Environment.NewLine), Has.All.StartWith("%"), "A line LaTeX would read could stop the whole run."); + Assert.That(appended, Does.Contain(TOOL_SOURCE.URL)); + }); + } + + [TestCase("markdown")] + [TestCase("html")] + [TestCase("latex")] + public void WithoutSourcesACodeBlockStaysAsTheModelWroteIt(string language) + { + var content = TextWith(Lines($"```{language}", "The content.", "```")); + + var file = FilesOf(content).Single(); + + Assert.That(content.ToExportContent(file), Is.EqualTo(file.Content), "No comment, no heading, no empty line."); + } + /// /// A text message with the given sources hanging on it. /// @@ -184,6 +253,17 @@ public sealed class IContentExtensionsTests Sources = [..sources], }; + /// + /// Reads the files of a message the way the export menu does. + /// + /// The content to read. + /// The files the export menu offers for it. + private static IReadOnlyList FilesOf(IContent content) + { + content.TryGetMarkdownText(out var markdown); + return PlainFileExport.ExtractFiles(markdown, ','); + } + /// /// Names the blocks a Markdown text is made of, headings by their level. /// diff --git a/app/Tests/Tools/FileExportFormatTests.cs b/app/Tests/Tools/FileExportFormatTests.cs index 46e8cb9f..301dd2f8 100644 --- a/app/Tests/Tools/FileExportFormatTests.cs +++ b/app/Tests/Tools/FileExportFormatTests.cs @@ -36,4 +36,134 @@ public sealed class FileExportFormatTests FileExportFormat.HTML, }), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here."); } + + [TestCase("Q3: Umsatz/Planung?", FileExportFormat.HTML, "Q3 Umsatz Planung.html", Description = "A chat is named after the first words of its question, and those may hold anything.")] + [TestCase("Notes for the meeting.", FileExportFormat.MARKDOWN, "Notes for the meeting.md", Description = "Windows drops a trailing dot anyway.")] + [TestCase(null, FileExportFormat.MICROSOFT_WORD, "export.docx")] + [TestCase(" ", FileExportFormat.LATEX, "export.tex", Description = "A name made of nothing is no name.")] + public void TheSaveDialogSuggestsAUsableFileName(string? name, FileExportFormat format, string expectedFileName) + { + Assert.That(format.ToSuggestedFileName(name), Is.EqualTo(expectedFileName)); + } + + [Test] + public void AnOverlongFileNameIsShortened() + { + var fileName = FileExportFormat.HTML.ToSuggestedFileName(new string('a', 100)); + + Assert.That(fileName, Is.EqualTo($"{new string('a', 60)}.html"), "The first ten words of a question easily outgrow what a dialog shows."); + } + + [Test] + public void OnlyAWebPageNeedsAPageTitle() + { + Assert.Multiple(() => + { + Assert.That(FileExportFormat.HTML.NeedsPageTitle(), Is.True, "Without one, the browser tab shows the random name of the temporary file Pandoc read."); + Assert.That(FileExportFormat.MICROSOFT_WORD.NeedsPageTitle(), Is.False, "Word would keep it as a document property nobody asked for."); + Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.NeedsPageTitle(), Is.False, "The same goes for an OpenDocument file."); + Assert.That(FileExportFormat.LATEX.NeedsPageTitle(), Is.False); + }); + } + + [TestCase("html", FileExportFormat.HTML)] + [TestCase("latex", FileExportFormat.LATEX)] + [TestCase("tex", FileExportFormat.LATEX)] + [TestCase("markdown", FileExportFormat.MARKDOWN)] + [TestCase("md", FileExportFormat.MARKDOWN)] + [TestCase("csv", FileExportFormat.CSV)] + [TestCase("tsv", FileExportFormat.TSV)] + [TestCase("HTML", FileExportFormat.HTML, Description = "Models do not agree on the case.")] + [TestCase("LaTeX", FileExportFormat.LATEX)] + [TestCase("CSV", FileExportFormat.CSV)] + [TestCase(" md ", FileExportFormat.MARKDOWN, Description = "Space around the name is no part of it.")] + public void AFenceLanguageNamesItsFormat(string language, FileExportFormat expectedFormat) + { + Assert.Multiple(() => + { + Assert.That(FileExportFormatExtensions.TryFromCodeFenceLanguage(language, out var format), Is.True); + Assert.That(format, Is.EqualTo(expectedFormat)); + Assert.That(format.IsPlainText(), Is.True, "A code block holds text, so the export writes it as it is."); + }); + } + + [Test] + public void OnlyTheTwoOfficeFormatsAreNoPlainText() + { + Assert.Multiple(() => + { + Assert.That(FileExportFormat.MICROSOFT_WORD.IsPlainText(), Is.False, "A Word file is an archive, and writing text into one breaks it."); + Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.IsPlainText(), Is.False); + Assert.That(FileExportFormat.NONE.IsPlainText(), Is.False, "No format means no file."); + Assert.That(FileExportFormat.UNKNOWN.IsPlainText(), Is.False); + Assert.That(FileExportFormat.HTML.IsPlainText(), Is.True, "A page the model wrote is a finished file, even though an entire answer needs Pandoc to become one."); + Assert.That(FileExportFormat.LATEX.IsPlainText(), Is.True); + Assert.That(FileExportFormat.MARKDOWN.IsPlainText(), Is.True); + Assert.That(FileExportFormat.CSV.IsPlainText(), Is.True); + Assert.That(FileExportFormat.TSV.IsPlainText(), Is.True); + }); + } + + [TestCase("css", TestName = "A language AI Studio writes no file for")] + [TestCase("docx", TestName = "A format no code block can hold")] + [TestCase("", TestName = "A fence without a language")] + [TestCase(null, TestName = "A fence Markdig read no language for")] + public void AnyOtherFenceLanguageNamesNoFormat(string? language) + { + Assert.Multiple(() => + { + Assert.That(FileExportFormatExtensions.TryFromCodeFenceLanguage(language, out var format), Is.False); + Assert.That(format, Is.EqualTo(FileExportFormat.NONE)); + }); + } + + [TestCase(FileExportFormat.HTML)] + [TestCase(FileExportFormat.MARKDOWN)] + public void AnHtmlCommentEndsWhereItShouldAndNowhereElse(FileExportFormat format) + { + var found = format.TryToComment("A page titled --> Start, and one titled --!> Next", out var comment); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(comment, Does.StartWith("", StringComparison.Ordinal), Is.EqualTo(comment.Length - 3), "Only the end of the comment may end it; the title would spill onto the page otherwise."); + Assert.That(comment, Does.Not.Contain("--!>"), "A browser ends a comment there as well."); + Assert.That(comment, Does.Contain("Start").And.Contain("Next"), "The title stays readable."); + }); + } + + [Test] + public void AnHtmlCommentKeepsTheDashesOfAnAddress() + { + FileExportFormat.HTML.TryToComment("https://xn--mnchen-3ya.de/", out var comment); + + Assert.That(comment, Does.Contain("https://xn--mnchen-3ya.de/"), "A domain with an umlaut is written with two dashes, and the link has to keep working."); + } + + [Test] + public void EveryLineOfALatexCommentIsOne() + { + var found = FileExportFormat.LATEX.TryToComment(Lines("# Sources", string.Empty, "- [1] A title with 100 % and a_b"), out var comment); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(comment.Split(Environment.NewLine), Is.EqualTo(new[] { "% # Sources", "%", "% - [1] A title with 100 % and a_b" }), "LaTeX has no end of a comment, only the end of a line."); + }); + } + + [TestCase(FileExportFormat.CSV)] + [TestCase(FileExportFormat.TSV)] + [TestCase(FileExportFormat.MICROSOFT_WORD)] + public void AFormatWithoutCommentsSaysSo(FileExportFormat format) + { + Assert.Multiple(() => + { + Assert.That(format.TryToComment("A text.", out var comment), Is.False); + Assert.That(comment, Is.Empty); + }); + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); } \ No newline at end of file diff --git a/app/Tests/Tools/Fixtures/standalone_page.html b/app/Tests/Tools/Fixtures/standalone_page.html new file mode 100644 index 00000000..72b1cc4c --- /dev/null +++ b/app/Tests/Tools/Fixtures/standalone_page.html @@ -0,0 +1,201 @@ + + + + + +Hello World + + + + + + +
+ + + + + + +
+

+
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/app/Tests/Tools/PlainFileExportTests.cs b/app/Tests/Tools/PlainFileExportTests.cs new file mode 100644 index 00000000..8a41bebd --- /dev/null +++ b/app/Tests/Tools/PlainFileExportTests.cs @@ -0,0 +1,134 @@ +using System.Runtime.CompilerServices; + +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks which files the export menu finds in an answer. +/// +/// +/// Asked for a web page, a model answers with a code block marked as html, and the same goes for a +/// LaTeX document or a Markdown text. That block already is the file the user wants. Converted along +/// with the rest of the answer, Pandoc shows it as a listing of source code instead, which is what +/// PR #993 reported. The fixture is the page attached to that PR, as the model wrote it. +/// +[TestFixture] +public sealed class PlainFileExportTests +{ + private static readonly string PAGE = ReadFixture("standalone_page.html"); + + [Test] + public void AnAnswerMadeOfOneWebPageOffersThatPage() + { + var files = PlainFileExport.ExtractFiles(Lines("```html", PAGE, "```"), ','); + + Assert.Multiple(() => + { + Assert.That(files, Has.Count.EqualTo(1)); + Assert.That(files[0].Format, Is.EqualTo(FileExportFormat.HTML)); + Assert.That(files[0].Content, Is.EqualTo(PAGE), "The page leaves the answer exactly as the model wrote it, without the fence around it."); + Assert.That(files[0].Caption, Is.Empty, "Without a heading above it, a code block has nothing to be named after."); + }); + } + + [Test] + public void AWebPageAmidExplanationsIsOfferedAsWell() + { + var answer = Lines("Here is your page:", string.Empty, "```html", PAGE, "```", string.Empty, "Save it and open it in your browser."); + + var files = PlainFileExport.ExtractFiles(answer, ','); + + Assert.Multiple(() => + { + Assert.That(files, Has.Count.EqualTo(1), "Models rarely answer with the block alone, so the text around it must not hide it."); + Assert.That(files[0].Content, Is.EqualTo(PAGE)); + }); + } + + [TestCase("HTML", FileExportFormat.HTML)] + [TestCase("tex", FileExportFormat.LATEX)] + [TestCase("markdown", FileExportFormat.MARKDOWN)] + [TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")] + public void ACodeBlockIsOfferedInTheFormatItsLanguageNames(string infoString, FileExportFormat expectedFormat) + { + var files = PlainFileExport.ExtractFiles(Lines($"```{infoString}", "The content.", "```"), ','); + + Assert.Multiple(() => + { + Assert.That(files, Has.Count.EqualTo(1)); + Assert.That(files[0].Format, Is.EqualTo(expectedFormat)); + Assert.That(files[0].Content, Is.EqualTo("The content.")); + }); + } + + [Test] + public void ATildeFenceIsOfferedAsWell() + { + var files = PlainFileExport.ExtractFiles(Lines("~~~latex", @"\section{Results}", "~~~"), ','); + + Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX })); + } + + [TestCase("```css", TestName = "A language AI Studio writes no file for")] + [TestCase("```", TestName = "A fence without a language")] + public void AnyOtherCodeBlockIsNotOffered(string openingFence) + { + var files = PlainFileExport.ExtractFiles(Lines(openingFence, "body { margin: 0; }", "```"), ','); + + Assert.That(files, Is.Empty); + } + + [TestCase("```html", "

The answer broke off here", TestName = "Half a web page")] + [TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")] + public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content) + { + var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ','); + + Assert.That(files, Is.Empty, "The file would end wherever the answer broke off."); + } + + [Test] + public void TablesAndCodeBlocksAreCountedApart() + { + var answer = Lines( + "# Revenue", + string.Empty, + "| Quarter | Revenue |", + "|---|---|", + "| Q1 | 100 |", + string.Empty, + "# Landing page", + string.Empty, + "```html", + "

First block

", + "```", + string.Empty, + "```latex", + @"\section{Second block}", + "```"); + + var files = PlainFileExport.ExtractFiles(answer, ','); + + Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[] + { + (1, "Revenue", FileExportFormat.CSV), + (1, "Landing page", FileExportFormat.HTML), + (2, "Landing page", FileExportFormat.LATEX), + }), "The first code block is code block 1, even though a table stands before it."); + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); + + /// + /// Reads a file from the fixtures next to this test. + /// + /// + /// Read from the source tree, the way the capability snapshot is, so the fixture needs no entry in + /// the project file. A checkout on Windows may have turned its line ends into CRLF, which the + /// model never wrote. + /// + private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File + .ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName)) + .Replace("\r\n", "\n"); +} \ No newline at end of file