Added the ability to roll back to certain chat messages and to copy an entire chat

This commit is contained in:
Peer Hogeterp 2026-09-22 14:22:38 +02:00
parent c95cc5bacc
commit 601fe727ba
12 changed files with 251 additions and 2 deletions

View File

@ -3262,6 +3262,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"
@ -3313,12 +3316,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"
@ -3331,6 +3340,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"
@ -5281,6 +5293,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure
-- 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..."
@ -5353,6 +5368,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"
@ -12891,3 +12909,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you s
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"
-- Copy of {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}"
-- Empty chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Empty chat"

View File

@ -350,6 +350,27 @@ public sealed record ChatThread
this.Blocks.Remove(block);
}
/// <summary>
/// Removes every content block after the selected content in conversation order.
/// </summary>
/// <param name="content">The content to keep as the last block.</param>
/// <returns>True when one or more later blocks were removed.</returns>
public bool RemoveBlocksAfter(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);
}
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

@ -76,6 +76,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 bool RollbackEnabled { get; set; }
/// <summary>
/// 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()
{
@ -852,4 +873,4 @@ public partial class ContentBlockComponent : MSGComponentBase
await this.DisposeMathContainerIfNeededAsync();
}
}
}

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"/>
}

View File

@ -1394,6 +1394,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;
if (!this.ChatThread.RemoveBlocksAfter(aiBlock))
return;
this.ChatThread.AugmentedData = string.Empty;
this.ChatThread.AISelectedDataSources = [];
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)
{

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.CopyChatAsync(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>
@ -148,4 +152,4 @@ else
}
</ItemTemplate>
</MudTreeView>
}
}

View File

@ -781,6 +781,37 @@ public partial class Workspaces : MSGComponentBase
await this.LoadTreeItemsAsync(startPrefetch: false);
}
private async Task CopyChatAsync(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;
if (await MessageBus.INSTANCE.SendMessageUseFirstResult<bool, bool>(this, Event.HAS_CHAT_UNSAVED_CHANGES))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, T("Are you sure you want to load another chat? All unsaved changes 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 = this.CurrentChatThread is { } currentChat && currentChat.ChatId == chat.ChatId ? currentChat : chat;
var copy = await WorkspaceBehaviour.CopyChatAsync(sourceChat);
await this.LoadTreeItemsAsync(startPrefetch: false);
this.CurrentChatThread = copy;
await this.CurrentChatThreadChanged.InvokeAsync(this.CurrentChatThread);
}
private async Task RenameWorkspaceAsync(string? workspacePath)
{
if (workspacePath is null)

View File

@ -3264,6 +3264,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öchtest du 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."
@ -3315,12 +3318,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"
@ -3333,6 +3342,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"
@ -5283,6 +5295,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Möchten Sie
-- 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..."
@ -5355,6 +5370,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"
@ -12893,3 +12911,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Möchten
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unbenannter Chat"
-- Copy of {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Kopie von {0}"
-- Empty chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Leerer Chat"

View File

@ -3264,6 +3264,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"
@ -3315,12 +3318,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"
@ -3333,6 +3342,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"
@ -5283,6 +5295,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::WORKSPACES::T1016188706"] = "Are you sure
-- 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..."
@ -5355,6 +5370,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"
@ -12893,3 +12911,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3043761007"] = "Are you s
-- Unnamed chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3310482275"] = "Unnamed chat"
-- Copy of {0}
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T3365678931"] = "Copy of {0}"
-- Empty chat
UI_TEXT_CONTENT["AISTUDIO::TOOLS::WORKSPACEBEHAVIOUR::T4019509364"] = "Empty chat"

View File

@ -782,6 +782,78 @@ public static class WorkspaceBehaviour
}
}
public static async Task<ChatThread> CopyChatAsync(ChatThread sourceChat)
{
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 = string.Format(TB("Copy of {0}"), string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Empty chat") : sourceChat.Name),
};
var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId);
try
{
CopyManagedTranscriptAttachments(copiedChat, targetDirectory);
await StoreChatAsync(copiedChat);
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)
{
var sourcePath = Path.GetFullPath(source.FilePath);
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,4 +1,6 @@
# v26.9.1, build 256 (2026-09-xx xx:xx UTC)
- Added a way to copy an entire chat and continue the copy independently while the original conversation stays unchanged.
- Added a way to roll a chat back to an earlier AI response. The response you choose stays, everything after it is permanently removed, and your current draft and attachments remain ready so you can continue from there.
- 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 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.