Copy entire chats & Rollback chats to certain message (#992)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer Hogeterp 2026-09-23 10:44:56 +02:00 committed by GitHub
parent c4ba83f0be
commit e9ebc02a96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 733 additions and 8 deletions

View File

@ -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 <<EOF` or `echo >`
- **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

View File

@ -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"

View File

@ -350,6 +350,48 @@ public sealed record ChatThread
this.Blocks.Remove(block);
}
/// <summary>
/// Rolls this chat thread back, so that the given content becomes the last block of the conversation.
/// </summary>
/// <remarks>
/// 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.<br/><br/>
///
/// 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.<br/><br/>
///
/// 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.
/// </remarks>
/// <param name="content">The content to keep as the last block.</param>
/// <returns>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.</returns>
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)

View File

@ -70,6 +70,12 @@
<MudIconButton Icon="@Icons.Material.Filled.Recycling" Color="Color.Default" Disabled="@(!this.RegenerateEnabled())" OnClick="@this.RegenerateBlock"/>
</MudTooltip>
}
@if (!this.IsLastContentBlock && this.Role is ChatRole.AI && this.RollbackFunc is not null)
{
<MudTooltip Text="@T("Roll back to this response")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Restore" Color="Color.Default" Disabled="@(!this.RollbackEnabled())" OnClick="@this.RollbackBlock"/>
</MudTooltip>
}
@if (this.RemoveBlockFunc is not null)
{
<MudTooltip Text="@T("Removes this block")" Placement="Placement.Bottom">

View File

@ -77,6 +77,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func<IContent, Task>? RegenerateFunc { get; set; }
[Parameter]
public Func<IContent, Task>? RollbackFunc { get; set; }
[Parameter]
public Func<IContent, Task>? EditLastBlockFunc { get; set; }
@ -86,6 +89,9 @@ public partial class ContentBlockComponent : MSGComponentBase
[Parameter]
public Func<bool> RegenerateEnabled { get; set; } = () => false;
[Parameter]
public Func<bool> RollbackEnabled { get; set; } = () => false;
/// <summary>
/// What the export offers, used both as the label of the export button and as the title of
/// the save dialog.
@ -781,6 +787,21 @@ public partial class ContentBlockComponent : MSGComponentBase
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()
{
if (this.EditLastBlockFunc is null)

View File

@ -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 @@
<MudTooltip Text="@T("Move the chat to a workspace, or to another if it is already in one.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Disabled="@(!this.CanThreadBeSaved || this.IsCurrentChatStreaming)" OnClick="@this.MoveChatToWorkspace"/>
</MudTooltip>
<MudTooltip Text="@T("Copy this chat & continue in the copy.")" Placement="@TOOLBAR_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" Disabled="@(!this.CanThreadBeCopied)" OnClick="@this.CopyCurrentChat"/>
</MudTooltip>
}
<AttachDocuments Name="File Attachments" DocumentPaths="@this.ComposerState.FileAttachments" DocumentPathsChanged="@this.ComposerAttachmentsChanged" CatchAllDocuments="true" UseSmallForm="true" ShowMediaStatus="false" Provider="@this.Provider" OwnerChat="@this.ChatThread" EnsureOwnerChatAsync="@this.EnsureMediaImportChatAsync" Disabled="@this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner)"/>

View File

@ -650,6 +650,8 @@ 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;
@ -1345,6 +1347,47 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncWorkspaceHeaderWithChatThreadAsync();
}
/// <summary>
/// Copies the open chat and continues in the copy.
/// </summary>
/// <remarks>
/// 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.<br/><br/>
///
/// 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.
/// </remarks>
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;
@ -1458,6 +1501,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)
{
if(this.ChatThread is null)

View File

@ -81,6 +81,10 @@ else
<MudIconButton Icon="@Icons.Material.Filled.MoveToInbox" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.MoveChatAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Copy chat")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.CopyChatFromTreeAsync(treeItem.Path))"/>
</MudTooltip>
<MudTooltip Text="@T("Rename")" Placement="@WORKSPACE_ITEM_TOOLTIP_PLACEMENT">
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Disabled="@this.IsChatTreeItemBusy(treeItem)" OnClick="@(() => this.RenameChatAsync(treeItem.Path))"/>
</MudTooltip>

View File

@ -660,6 +660,24 @@ public partial class Workspaces : MSGComponentBase
await this.LoadTreeItemsAsync(startPrefetch: false);
}
/// <summary>
/// Copies the given chat, after asking the user for the name of the copy, and shows the copy in the tree.
/// </summary>
/// <param name="sourceChat">The chat to copy, as it stands in memory.</param>
/// <returns>The persisted copy. Null when the user canceled the question, in which case nothing was copied.</returns>
/// <remarks>
/// Neither asks about unsaved changes nor opens the copy: which of both a caller needs depends
/// on where the copy was asked for.
/// </remarks>
public async Task<ChatThread?> 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<ChatThread?> LoadChatAsync(string? chatPath, bool switchToChat)
{
if (string.IsNullOrWhiteSpace(chatPath))
@ -781,6 +799,62 @@ public partial class Workspaces : MSGComponentBase
await this.LoadTreeItemsAsync(startPrefetch: false);
}
/// <summary>
/// Copies the chat behind the copy button of a tree item and opens the copy.
/// </summary>
/// <param name="chatPath">The directory of the chat to copy.</param>
/// <remarks>
/// 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.
/// </remarks>
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<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{
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<ConfirmDialog>(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)

View File

@ -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"

View File

@ -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"

View File

@ -752,11 +752,16 @@ public static class WorkspaceBehaviour
return Directory.Exists(chatPath);
}
public static async Task StoreChatAsync(ChatThread chat)
/// <summary>
/// Stores a chat, unless another operation holds its lock for longer than the semaphore timeout.
/// </summary>
/// <param name="chat">The chat to store.</param>
/// <returns>True when the chat was written; false when the operation was skipped to avoid a race.</returns>
public static async Task<bool> 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
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="dialogService">Used to ask for the name.</param>
/// <param name="sourceChat">The chat to copy. Its own files and state stay untouched.</param>
/// <returns>The persisted copy. Null when the user canceled the question, in which case nothing was copied.</returns>
/// <remarks>
/// 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.<br/><br/>
///
/// 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.
/// </remarks>
public static async Task<ChatThread?> CopyChatAsync(IDialogService dialogService, ChatThread sourceChat)
{
var sourceName = string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Unnamed chat") : sourceChat.Name;
var dialogParameters = new DialogParameters<SingleInputDialog>
{
{ 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<SingleInputDialog>(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<ChatThread>(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<string, ManagedTranscriptAttachment>(pathComparer);
foreach (var content in chat.Blocks.Select(block => block.Content).OfType<ContentText>())
{
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<string, ManagedTranscriptAttachment> 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;
}
/// <summary>Creates a transcript atomically inside an already persisted chat.</summary>
/// <param name="chat">Persisted chat that owns the transcript counter.</param>
/// <param name="originalPath">Original media path.</param>

View File

@ -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.

View File

@ -0,0 +1,225 @@
using AIStudio.Agents;
using AIStudio.Chat;
using AIStudio.Components;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks what rolling a chat back to an earlier answer removes and what it keeps.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <summary>
/// A chat whose last retrieval is still in place, so that a rollback which should do nothing
/// shows when it resets it anyway.
/// </summary>
/// <param name="blocks">The blocks of the chat.</param>
/// <returns>The chat.</returns>
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,
};
/// <summary>
/// The texts of the chat in the order the chat shows them.
/// </summary>
/// <param name="thread">The chat.</param>
/// <returns>The texts.</returns>
private static string[] Texts(ChatThread thread) => thread.Blocks.OrderBy(x => x.Time).Select(x => ((ContentText)x.Content!).Text).ToArray();
}