Fixed chats being stored when chats are stored manually or workspaces are disabled (#1145)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-31 18:54:20 +01:00
1 parent 9532664a25
commit 64dc621a7d
12 files changed
+415 -71

No files matched your search

@@ -4024,9 +4024,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Move chat"
-- Are you sure you want to move this chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1142475422"] = "Are you sure you want to move this chat? All unsaved changes will be lost."
-- Bold
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1165397398"] = "Bold"
@@ -4063,9 +4060,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0}
-- Are you sure you want to start a new chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2111282488"] = "Are you sure you want to start a new chat? All unsaved changes will be lost."
-- Unsaved Changes
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2123670756"] = "Unsaved Changes"
-- Start New Chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2310454789"] = "Start New Chat"
@@ -674,6 +674,9 @@ public partial class ChatComponent : MSGComponentBase
}
var leftChatHadNoThread = !isFirstChat && this.loadedParameterChatId == Guid.Empty;
if (!isFirstChat)
await this.StopAnswerOfLeftChatAsync(this.loadedParameterChatId);
this.loadedParameterChatId = chatId;
this.loadedParameterWorkspaceId = workspaceId;
@@ -1580,9 +1583,15 @@ public partial class ChatComponent : MSGComponentBase
}
/// <summary>
/// Creates and stores the chat the composer belongs to, when there is none yet, e.g., for what an
/// attachment brings along before the first message is sent.
/// Creates the chat the composer belongs to, when there is none yet, e.g., for what an attachment
/// brings along before the first message is sent.
/// </summary>
/// <remarks>
/// Only while chats are stored automatically, the new chat reaches the disk right away. Otherwise, it
/// counts as unsaved, like a message typed into a new chat: a transcript made for it waits in the
/// staging area until the user saves the chat, see WorkspaceBehaviour.StoreChatAsync, and the next
/// start removes it when they never do.
/// </remarks>
/// <param name="nameWithoutInput">The name of the chat while the user typed nothing yet.</param>
/// <returns>The chat of the composer.</returns>
private async Task<ChatThread?> EnsureChatAsync(string nameWithoutInput)
@@ -1615,7 +1624,11 @@ public partial class ChatComponent : MSGComponentBase
// lists the new chat right away, together with the progress of the transcription, instead
// of only after the first message was sent:
//
await this.StoreAndListChatAsync(chat);
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
await this.StoreAndListChatAsync(chat);
else
this.hasUnsavedChanges = true;
await this.SyncForegroundChatAsync();
return chat;
}
@@ -1766,7 +1779,7 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds, this.ChatThread.RequiredOutboundDataRestriction.Restriction);
// From here on, the AI job stores the chat, in every storage mode:
// From here on, the AI job stores the chat, while chats are stored automatically:
this.unstoredChat = null;
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
@@ -1786,6 +1799,28 @@ public partial class ChatComponent : MSGComponentBase
await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId);
}
/// <summary>
/// Stops the answer which is still being written in a chat the user leaves, unless chats are stored automatically.
/// </summary>
/// <remarks>
/// Only while chats are stored automatically, the AI job stores the answer, so that the user finds
/// it when coming back. Otherwise, the answer would end up nowhere, and writing it would only cost
/// the user. The user was asked before, since an answer being written counts as an unsaved change
/// then.<br/><br/>
///
/// Called only where the user leaves a chat: opening another one, starting a new one, or leaving the
/// chat page. Never when this component goes away, since the page creates it anew, e.g., whenever the
/// workspace sidebar is shown or hidden, while the chat stays.
/// </remarks>
/// <param name="chatId">The chat being left.</param>
private async Task StopAnswerOfLeftChatAsync(Guid chatId)
{
if (chatId == Guid.Empty || this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
await this.AIJobService.CancelChatGenerationAsync(chatId);
}
/// <summary>
/// Takes over the tool selection of the chat that was just loaded or handed to this component.
/// </summary>
@@ -1825,7 +1860,21 @@ public partial class ChatComponent : MSGComponentBase
if (!this.CanThreadBeSaved)
return;
var stagedTranscripts = this.ChatThread.Draft.MediaTranscripts.Where(transcript => transcript.IsStaged).Select(transcript => transcript.FilePath).ToHashSet(StringComparer.Ordinal);
await this.StoreAndListChatAsync(this.ChatThread);
//
// Storing moved the staged transcripts of the message being written into the chat directory,
// see WorkspaceBehaviour.StoreChatAsync. The composer still knows them by where they were, so
// sending the message would lose them:
//
if (stagedTranscripts.Count > 0)
{
this.ComposerState.FileAttachments.RemoveWhere(attachment => stagedTranscripts.Contains(attachment.FilePath));
foreach (var transcript in this.ChatThread.Draft.MediaTranscripts)
this.ComposerState.FileAttachments.Add(transcript);
}
this.hasUnsavedChanges = false;
// A new chat is on the disk now, like any other:
@@ -1876,9 +1925,11 @@ public partial class ChatComponent : MSGComponentBase
//
// Want the user to manage the chat storage manually? In that case, we have to ask the user
// about possible data loss:
// about possible data loss. An answer still being written is lost as well, whenever chats are
// not stored automatically, since leaving stops it, see StopAnswerOfLeftChatAsync:
//
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming)
var storageBehavior = this.SettingsManager.ConfigurationData.Workspace.StorageBehavior;
if ((storageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges) || (storageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY && this.IsCurrentChatStreaming))
{
var dialogParameters = new DialogParameters<ConfirmDialog>
{
@@ -1930,6 +1981,9 @@ public partial class ChatComponent : MSGComponentBase
return false;
}
if (this.ChatThread is not null)
await this.StopAnswerOfLeftChatAsync(this.ChatThread.ChatId);
//
// Reset our state:
//
@@ -2034,19 +2088,6 @@ public partial class ChatComponent : MSGComponentBase
if(this.ChatThread is null)
return;
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_MANUALLY && this.hasUnsavedChanges && !this.IsCurrentChatStreaming)
{
var confirmationDialogParameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, T("Are you sure you want to move this chat? All unsaved changes will be lost.") },
};
var confirmationDialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Unsaved Changes"), confirmationDialogParameters, DialogOptions.FULLSCREEN);
var confirmationDialogResult = await confirmationDialogReference.Result;
if (confirmationDialogResult is null || confirmationDialogResult.Canceled)
return;
}
var dialogParameters = new DialogParameters<WorkspaceSelectionDialog>
{
{ x => x.Message, T("Please select the workspace where you want to move the chat to.") },
@@ -2065,13 +2106,15 @@ public partial class ChatComponent : MSGComponentBase
//
// Like saving and copying, the move goes through the workspace component whenever there is
// one, so its tree shows the chat in the new place right away:
// one, so its tree shows the chat in the new place right away. Nothing the user did not save
// gets lost, so nothing is asked: it reaches the new place only while chats are stored
// automatically, and otherwise stays unsaved, see WorkspaceBehaviour.MoveChatAsync:
//
var previousWorkspaceId = this.ChatThread!.WorkspaceId;
if (this.Workspaces is not null)
await this.Workspaces.MoveChatAsync(this.ChatThread!, workspaceId);
else
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId);
await WorkspaceBehaviour.MoveChatAsync(this.ChatThread!, workspaceId, this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
await this.FollowMovedChatAsync(previousWorkspaceId);
}
@@ -2204,6 +2247,9 @@ public partial class ChatComponent : MSGComponentBase
{
await this.FlushDraftAsync();
await this.PreserveNewChatDraftAsync(this.ChatThread is null);
if (this.ChatThread is not null)
await this.StopAnswerOfLeftChatAsync(this.ChatThread.ChatId);
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
this.ClearWorkspaceHeaderState();
@@ -2283,8 +2329,11 @@ public partial class ChatComponent : MSGComponentBase
if (!this.blockActions.TryRemove(this.ChatThread, block))
return;
// Like every other change, stored right away only while chats are stored automatically:
this.hasUnsavedChanges = true;
await this.SaveThread();
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
await this.SaveThread();
this.StateHasChanged();
}
@@ -2310,8 +2359,12 @@ public partial class ChatComponent : MSGComponentBase
// The rollback reset the AI-selected data sources, which the data source selection still shows:
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
// Like every other change, stored right away only while chats are stored automatically:
this.hasUnsavedChanges = true;
await this.SaveThread();
if (this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
await this.SaveThread();
this.tokenTracker?.Nudge();
this.StateHasChanged();
await this.inputField.FocusAsync();
@@ -2568,7 +2621,8 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread = this.AIJobService.TryGetLiveChatThread(snapshot.SubjectId) ?? this.ChatThread;
if (!snapshot.IsActive)
{
this.hasUnsavedChanges = false;
// The job stored the answer only while chats are stored automatically, see AIJobService.CheckpointChatAsync:
this.hasUnsavedChanges = this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY;
this.previousInputForbidden = true;
}
@@ -2587,8 +2641,9 @@ public partial class ChatComponent : MSGComponentBase
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return Task.FromResult(new MessageBusAnswer<TResult>(true, (TResult) (object) false));
// Nothing stores an answer being written now, and leaving stops it, see StopAnswerOfLeftChatAsync:
if (this.IsCurrentChatStreaming)
return Task.FromResult(new MessageBusAnswer<TResult>(true, (TResult) (object) false));
return Task.FromResult(new MessageBusAnswer<TResult>(true, (TResult) (object) true));
//
// A message the user started counts as unsaved on its own only while there is no chat
@@ -3,6 +3,7 @@ using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Dialogs;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
@@ -1009,11 +1010,14 @@ public partial class Workspaces : MSGComponentBase
/// Neither asks for the target workspace nor follows the chat in the chat view: both depend on
/// where the move was asked for. Whoever moves a chat while this component exists goes through
/// here, though, since the tree draws what it has built before and would otherwise keep showing
/// the chat in its old place.
/// the chat in its old place.<br/><br/>
///
/// What the user did not save reaches the new place only while chats are stored automatically,
/// see WorkspaceBehaviour.MoveChatAsync.
/// </remarks>
public async Task MoveChatAsync(ChatThread chat, Guid targetWorkspaceId)
{
await WorkspaceBehaviour.MoveChatAsync(chat, targetWorkspaceId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetWorkspaceId, this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
await this.LoadTreeItemsAsync(startPrefetch: false);
}
@@ -1024,9 +1028,10 @@ public partial class Workspaces : MSGComponentBase
return;
//
// The open chat moves as it stands on the screen, just like with the button in the chat
// itself. The copy from the disk lacks whatever is not stored yet, such as a message being
// edited, and the chat would continue with that copy afterward:
// The open chat is the one which moves, just like with the button in the chat itself. The
// copy from the disk lacks whatever is not stored yet, such as a message being edited, and
// the chat would continue with that copy afterward. Whether that reaches the disk in the new
// place depends on how chats are stored, see MoveChatAsync:
//
var chat = this.CurrentChatThread is { } openChat && openChat.ChatId == storedChat.ChatId ? openChat : storedChat;
if (this.AIJobService.IsChatGenerationActive(chat.ChatId))
@@ -4026,9 +4026,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Chat verschieben"
-- Are you sure you want to move this chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1142475422"] = "Sind Sie sicher, dass Sie diesen Chat verschieben möchten? Alle ungespeicherten Änderungen gehen verloren."
-- Bold
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1165397398"] = "Fett"
@@ -4065,9 +4062,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0}
-- Are you sure you want to start a new chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2111282488"] = "Möchten Sie wirklich einen neuen Chat starten? Alle nicht gespeicherten Änderungen gehen verloren."
-- Unsaved Changes
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2123670756"] = "Nicht gespeicherte Änderungen"
-- Start New Chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2310454789"] = "Neuen Chat starten"
@@ -4026,9 +4026,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1028969501"] = "{0}, plus
-- Move chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1133040906"] = "Move chat"
-- Are you sure you want to move this chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1142475422"] = "Are you sure you want to move this chat? All unsaved changes will be lost."
-- Bold
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T1165397398"] = "Bold"
@@ -4065,9 +4062,6 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2059172343"] = "plus {0}
-- Are you sure you want to start a new chat? All unsaved changes will be lost.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2111282488"] = "Are you sure you want to start a new chat? All unsaved changes will be lost."
-- Unsaved Changes
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2123670756"] = "Unsaved Changes"
-- Start New Chat
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATCOMPONENT::T2310454789"] = "Start New Chat"
@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG.RAGProcesses;
@@ -175,7 +176,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
request.AIText.InitialRemoteWait = true;
request.AIText.IsStreaming = false;
await CheckpointChatAsync(state, force: true);
await this.CheckpointChatAsync(state, force: true);
await this.NotifyChangedAsync(state);
//
@@ -329,7 +330,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
await this.NotifyChangedAsync(state);
}
await CheckpointChatAsync(state);
await this.CheckpointChatAsync(state);
}
await this.CompleteChatGenerationAsync(state, token.IsCancellationRequested ? AIJobStatus.CANCELED : AIJobStatus.COMPLETED);
@@ -393,7 +394,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
}
this.activeChatJobsByChatId.TryRemove(request.ChatThread.ChatId, out _);
await CheckpointChatAsync(state, force: true);
await this.CheckpointChatAsync(state, force: true);
await this.NotifyChangedAsync(state);
await messageBus.SendMessage(null, Event.AI_JOB_FINISHED, state.Snapshot);
state.CancellationTokenSource.Dispose();
@@ -505,8 +506,26 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
await messageBus.SendMessage(null, Event.AI_JOB_CHANGED, state.Snapshot);
}
private static async Task CheckpointChatAsync(AIJobState state, bool force = false)
/// <summary>
/// Stores the chat of a job, at most once per <see cref="CHECKPOINT_MIN_TIME"/> unless forced.
/// </summary>
/// <remarks>
/// Only while chats are stored automatically. Whoever stores chats manually decides when a chat
/// reaches the disk, with its save button, and whoever disabled the workspaces wants no chat on the
/// disk at all. Storing it here anyway would put every message on the disk the moment it is sent,
/// and overwrite what the user saved by hand with what they did not save. The chat marks the answer
/// as unsaved instead, once the job is done.<br/><br/>
///
/// The storage mode is read every time rather than once for the job: whoever changes it while an
/// answer is being written gets what the chat reads when the answer is done.
/// </remarks>
/// <param name="state">The job whose chat to store.</param>
/// <param name="force">True stores the chat even when the last checkpoint was just now.</param>
private async Task CheckpointChatAsync(AIJobState state, bool force = false)
{
if (settingsManager.ConfigurationData.Workspace.StorageBehavior is not WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
return;
var now = DateTimeOffset.Now;
if (!force && now - state.LastCheckpoint < CHECKPOINT_MIN_TIME)
return;
@@ -314,9 +314,15 @@ public sealed class MediaTranscriptionService(
continue;
}
//
// Only while chats are stored automatically, the transcript goes right into a stored chat,
// which is stored along with it. Otherwise, storing would put whatever the user did not save
// on the disk, so the transcript waits in the staging area until the user saves the chat:
//
var persistentStorage = transcriptStorages.FirstOrDefault(storage => storage.CanStore(target.Owner));
var isPersistedChat = persistentStorage is null &&
ownerChat is not null &&
settingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY &&
WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId));
var attachment = persistentStorage is not null
@@ -1116,7 +1116,19 @@ public static partial class WorkspaceBehaviour
}
}
public static async Task MoveChatAsync(ChatThread chat, Guid targetWorkspaceId)
/// <summary>
/// Moves a chat, together with its directory, into another workspace.
/// </summary>
/// <param name="chat">The chat to move, as it stands in memory. It gets the new workspace and the new paths of its attachments.</param>
/// <param name="targetWorkspaceId">The workspace to move the chat into.</param>
/// <param name="storeUnsavedChanges">True stores the chat as it stands in memory in its new place, the way chats are stored automatically. False moves the chat as it was stored last, and the chat in memory only follows.</param>
/// <remarks>
/// Whoever stores chats manually decides with the save button what reaches the disk. Moving such a
/// chat therefore must neither store what the user did not save nor throw it away: the stored chat
/// moves, and what the user did not save stays in memory, unsaved as before, until they save it in
/// its new place.
/// </remarks>
public static async Task MoveChatAsync(ChatThread chat, Guid targetWorkspaceId, bool storeUnsavedChanges)
{
if (chat.WorkspaceId == targetWorkspaceId)
return;
@@ -1136,6 +1148,9 @@ public static partial class WorkspaceBehaviour
await orderedSemaphores[1].WaitAsync();
var moved = false;
// What is written in the new place: the chat in memory, or the one stored last.
ChatThread? movedChat = null;
try
{
if (!Directory.Exists(sourceDirectory))
@@ -1151,14 +1166,22 @@ public static partial class WorkspaceBehaviour
Directory.Move(sourceDirectory, targetDirectory);
moved = true;
UpdateAttachmentPathsAfterMove(chat, sourceDirectory, targetDirectory);
chat.WorkspaceId = targetWorkspaceId;
movedChat = storeUnsavedChanges ? chat : await ReadMovedChatAsync(targetDirectory);
UpdateAttachmentPathsAfterMove(movedChat, sourceDirectory, targetDirectory);
movedChat.WorkspaceId = targetWorkspaceId;
FinalizeStagedTranscripts(chat, targetDirectory);
await StoreMovedChatFilesAsync(chat, targetDirectory);
FinalizeStagedTranscripts(movedChat, targetDirectory);
await StoreMovedChatFilesAsync(movedChat, targetDirectory);
var lastEditTime = File.GetLastWriteTimeUtc(Path.Join(targetDirectory, "thread.json"));
await UpdateCacheAfterChatMoved(sourceWorkspaceId, targetWorkspaceId, chat.ChatId, targetDirectory, chat.Name, lastEditTime);
await UpdateCacheAfterChatMoved(sourceWorkspaceId, targetWorkspaceId, chat.ChatId, targetDirectory, movedChat.Name, lastEditTime);
// The chat in memory follows, with whatever the user did not save, which stays unsaved:
if (!ReferenceEquals(movedChat, chat))
{
UpdateAttachmentPathsAfterMove(chat, sourceDirectory, targetDirectory);
chat.WorkspaceId = targetWorkspaceId;
}
}
catch
{
@@ -1166,14 +1189,17 @@ public static partial class WorkspaceBehaviour
{
try
{
UpdateAttachmentPathsAfterMove(chat, targetDirectory, sourceDirectory);
chat.WorkspaceId = sourceWorkspaceId;
if (movedChat is not null)
{
UpdateAttachmentPathsAfterMove(movedChat, targetDirectory, sourceDirectory);
movedChat.WorkspaceId = sourceWorkspaceId;
}
if (Directory.Exists(targetDirectory) && !Directory.Exists(sourceDirectory))
Directory.Move(targetDirectory, sourceDirectory);
if (Directory.Exists(sourceDirectory))
await StoreMovedChatFilesAsync(chat, sourceDirectory);
if (movedChat is not null && Directory.Exists(sourceDirectory))
await StoreMovedChatFilesAsync(movedChat, sourceDirectory);
}
catch (Exception rollbackError)
{
@@ -1192,6 +1218,14 @@ public static partial class WorkspaceBehaviour
}
}
/// <summary>Reads the chat as it was stored last, from the directory it was just moved to.</summary>
/// <remarks>Its paths still point to where the chat was before the move.</remarks>
private static async Task<ChatThread> ReadMovedChatAsync(string chatDirectory)
{
var chatData = await File.ReadAllTextAsync(Path.Join(chatDirectory, "thread.json"), Encoding.UTF8);
return JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS) ?? throw new InvalidDataException($"The stored chat could not be read: '{chatDirectory}'.");
}
/// <summary>Atomically stores the name and thread after a directory move.</summary>
private static async Task StoreMovedChatFilesAsync(ChatThread chat, string chatDirectory)
{
@@ -11,6 +11,7 @@
- Fixed the ERI Server assistant not writing some generated files to disk, such as docker-compose.yml. It now tells you about every file it could not write.
- Fixed configuration plugins being unable to lock the preselected provider, the preselected profile, or the transcription provider to none. An empty value now does this, as the example configuration plugin describes. Older versions of AI Studio ignore the empty value and leave the choice to your users.
- Fixed AI Studio loading none of your organization's configurations when one place, such as the Windows registry, named a configuration with an invalid ID. AI Studio now skips that entry and uses the configurations from the other places.
- Fixed chats being stored although you chose "Store chats manually" or "Disable workspaces". AI Studio now stores a chat only when you click "Save chat", or not at all while workspaces are disabled. In both modes, leaving a chat whose answer is still being written asks you first and stops that answer. Chats outside of workspaces that earlier versions stored this way appear among your disappearing chats once you use workspaces again.
- Upgraded several libraries to improve security.
- Upgraded Tauri to v2.12.5.
- Upgraded MudBlazor to v9.12.0.
@@ -0,0 +1,202 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools;
using AIStudio.Tools.AIJobs;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.AIJobs;
/// <summary>
/// Checks when the AI job of a chat stores that chat.
/// </summary>
/// <remarks>
/// The job stores the chat while the answer is written, so that a chat being answered survives when the
/// user goes elsewhere. That is what storing chats automatically means. Whoever stores chats manually
/// decides with the save button when a chat reaches the disk, and whoever disabled the workspaces wants
/// no chat on the disk at all. The job of such a chat must leave the disk alone: neither a new chat may
/// appear there, nor may a saved one take on what the user did not save.<br/><br/>
///
/// The jobs here run without a provider, so they end right away, the way a failed request ends. That
/// is enough: every job ends through the same last checkpoint, and the first one is taken before the
/// provider is asked anything.
/// </remarks>
[TestFixture]
[NonParallelizable]
public sealed class AIJobServiceChatStorageTests
{
private static readonly TimeSpan WAIT_LIMIT = TimeSpan.FromSeconds(10);
private string? previousDataDirectory;
private string testDirectory = string.Empty;
private readonly List<IMessageBusReceiver> registeredReceivers = [];
[SetUp]
public async Task PrepareDataDirectory()
{
//
// The data directory, the workspace cache, and the message bus are static state of the whole
// application, which is why this fixture does not run alongside others. See WorkspaceChatStoreTests
// for why the workspace behaviour gets told about the data directory separately.
//
this.previousDataDirectory = SettingsManager.DataDirectory;
this.testDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-ai-job-storage-{Guid.NewGuid():N}");
Directory.CreateDirectory(this.testDirectory);
SettingsManager.DataDirectory = this.testDirectory;
WorkspaceBehaviour.TestDataDirectory = this.testDirectory;
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
}
[TearDown]
public void RemoveDataDirectory()
{
foreach (var receiver in this.registeredReceivers)
MessageBus.INSTANCE.Unregister(receiver);
this.registeredReceivers.Clear();
SettingsManager.DataDirectory = this.previousDataDirectory;
WorkspaceBehaviour.TestDataDirectory = null;
// Whoever reads the cache next must not get the chats of this test:
WorkspaceBehaviour.InvalidateWorkspaceTreeCache();
try
{
Directory.Delete(this.testDirectory, true);
}
catch (IOException)
{
// A temporary directory we could not remove says nothing about the code under test.
}
}
[TestCase(WorkspaceStorageBehavior.DISABLE_WORKSPACES)]
[TestCase(WorkspaceStorageBehavior.STORE_CHATS_MANUALLY)]
public async Task ANewChatStaysOffTheDiskWhileChatsAreNotStoredAutomatically(WorkspaceStorageBehavior storageBehavior)
{
var chat = NewChat("What is the capital of France?");
await this.AnswerAsync(chat, storageBehavior);
// Reading the workspace tree creates the directory of the temporary chats, so only what it holds tells:
var temporaryChats = Path.Combine(this.testDirectory, "tempChats");
var temporaryChatEntries = Directory.Exists(temporaryChats) ? Directory.EnumerateFileSystemEntries(temporaryChats).ToList() : [];
Assert.Multiple(() =>
{
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(chat.WorkspaceId, chat.ChatId)), Is.False, "Sending a message is no reason to store a chat the user did not save, or which they want kept nowhere.");
Assert.That(temporaryChatEntries, Is.Empty, "A chat outside the workspaces would end up among the temporary chats, where nobody sees it while the workspaces are disabled.");
});
}
[Test]
public async Task AChatSavedByHandKeepsItsSavedStateUntilTheNextSave()
{
var chat = NewChat("What is the capital of France?");
Assert.That(await WorkspaceBehaviour.StoreChatAsync(chat), Is.True, "The chat could not be saved.");
// A message sent after saving, which the user did not save yet:
AddUserMessage(chat, "And the one of Italy?");
await this.AnswerAsync(chat, WorkspaceStorageBehavior.STORE_CHATS_MANUALLY);
var stored = await ReadStoredChatAsync(chat);
Assert.That(stored.Blocks, Has.Count.EqualTo(1), "Only saving puts the new message on the disk; until then, the chat on the disk is the one the user saved.");
}
[Test]
public async Task AnAnsweredChatIsStoredWhileChatsAreStoredAutomatically()
{
var chat = NewChat("What is the capital of France?");
await this.AnswerAsync(chat, WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY);
Assert.That(WorkspaceBehaviour.IsChatExisting(new LoadChat(chat.WorkspaceId, chat.ChatId)), Is.True, "Storing chats automatically means the chat reaches the disk with its first message.");
var stored = await ReadStoredChatAsync(chat);
Assert.That(stored.Blocks.Select(block => block.Content).OfType<ContentText>().Select(text => text.Text), Does.Contain("What is the capital of France?"), "The stored chat has to hold the message which was sent.");
}
/// <summary>
/// Lets the AI job answer the given chat, like sending a message does, and waits until the job is done.
/// </summary>
/// <param name="chat">The chat, with the message to answer as its last block.</param>
/// <param name="storageBehavior">How the user wants their chats stored.</param>
private async Task AnswerAsync(ChatThread chat, WorkspaceStorageBehavior storageBehavior)
{
var settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
settingsManager.ConfigurationData.Workspace.StorageBehavior = storageBehavior;
var aiText = new ContentText { InitialRemoteWait = true };
chat.Blocks.Add(new ContentBlock
{
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.AI,
Content = aiText,
});
// The bus announces the end of a job only after its last checkpoint, so nothing is stored afterward:
var finishedJob = new FinishedJobReceiver(chat.ChatId);
MessageBus.INSTANCE.RegisterComponent(finishedJob);
MessageBus.INSTANCE.ApplyFilters(finishedJob, [], [Event.AI_JOB_FINISHED]);
this.registeredReceivers.Add(finishedJob);
var aiJobService = new AIJobService(settingsManager, MessageBus.INSTANCE, NullLogger<AIJobService>.Instance);
var snapshot = await aiJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
ChatThread = chat,
AIText = aiText,
LastUserPrompt = chat.Blocks.Last(block => block.Role is ChatRole.USER).Content,
ProviderSettings = AIStudio.Settings.Provider.NONE,
});
Assert.That(snapshot, Is.Not.Null, "The AI job did not start.");
await finishedJob.Finished.WaitAsync(WAIT_LIMIT);
}
private static ChatThread NewChat(string message)
{
var chat = new ChatThread { WorkspaceId = Guid.Empty, ChatId = Guid.NewGuid(), Name = "Capitals" };
AddUserMessage(chat, message);
return chat;
}
private static void AddUserMessage(ChatThread chat, string message) => chat.Blocks.Add(new ContentBlock
{
Time = DateTimeOffset.Now,
ContentType = ContentType.TEXT,
Role = ChatRole.USER,
Content = new ContentText { Text = message },
});
private static async Task<ChatThread> ReadStoredChatAsync(ChatThread chat)
{
var chatPath = Path.Combine(WorkspaceBehaviour.GetChatDirectory(chat.WorkspaceId, chat.ChatId), "thread.json");
return JsonSerializer.Deserialize<ChatThread>(await File.ReadAllTextAsync(chatPath), WorkspaceBehaviour.JSON_OPTIONS)!;
}
/// <summary>
/// Learns from the message bus when the job of one chat is done.
/// </summary>
/// <param name="chatId">The chat whose job to wait for.</param>
private sealed class FinishedJobReceiver(Guid chatId) : IMessageBusReceiver
{
private readonly TaskCompletionSource finished = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task Finished => this.finished.Task;
public Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
{
if (triggeredEvent is Event.AI_JOB_FINISHED && data is AIJobSnapshot snapshot && snapshot.SubjectId == chatId)
this.finished.TrySetResult();
return Task.CompletedTask;
}
public Task<MessageBusAnswer<TResult>> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(MessageBusAnswer<TResult>.NONE);
}
}
+43 -3
View File
@@ -76,7 +76,7 @@ public sealed class WorkspaceChatMoveTests
};
chat.Draft.AddAttachments([draft]);
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId, storeUnsavedChanges: true);
var targetDirectory = Path.Combine(this.testDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
var movedDraft = chat.Draft.MediaTranscripts.Single();
@@ -103,7 +103,7 @@ public sealed class WorkspaceChatMoveTests
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
chat.Draft.AddAttachments([document]);
await WorkspaceBehaviour.MoveChatAsync(chat, Guid.NewGuid());
await WorkspaceBehaviour.MoveChatAsync(chat, Guid.NewGuid(), storeUnsavedChanges: true);
Assert.That(chat.Draft.FileAttachments.Single(), Is.SameAs(document), "The move takes only the chat directory along, so a path elsewhere is still right.");
}
@@ -118,7 +118,7 @@ public sealed class WorkspaceChatMoveTests
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes" };
chat.Draft.AddAttachments([transcript]);
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId);
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId, storeUnsavedChanges: true);
// What the composer of the open chat holds: the transcript of the draft, and a document from elsewhere:
var relocatedTranscript = WorkspaceBehaviour.RelocateAttachmentAfterMove(transcript, chatId, Guid.Empty, workspaceId);
@@ -133,6 +133,46 @@ public sealed class WorkspaceChatMoveTests
});
}
[Test]
public async Task AChatStoredByHandMovesAsItWasSavedLast()
{
var chatId = Guid.NewGuid();
var workspaceId = Guid.NewGuid();
var sent = this.CreateTranscript(chatId, "kickoff", 1);
// What the user saved last:
var saved = new ChatThread { ChatId = chatId, Name = "Meeting notes", Blocks = [UserMessage("Summarize the kickoff", sent)] };
await File.WriteAllTextAsync(Path.Combine(this.testDirectory, "tempChats", chatId.ToString(), "thread.json"), JsonSerializer.Serialize(saved, WorkspaceBehaviour.JSON_OPTIONS));
// The open chat, with a message the user sent since, but did not save:
var chat = new ChatThread { ChatId = chatId, Name = "Meeting notes", Blocks = [UserMessage("Summarize the kickoff", sent), UserMessage("And list the open questions")] };
await WorkspaceBehaviour.MoveChatAsync(chat, workspaceId, storeUnsavedChanges: false);
var targetDirectory = Path.Combine(this.testDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
var stored = JsonSerializer.Deserialize<ChatThread>(await File.ReadAllTextAsync(Path.Combine(targetDirectory, "thread.json")), WorkspaceBehaviour.JSON_OPTIONS)!;
var storedTranscript = ((ContentText)stored.Blocks.Single().Content!).FileAttachments.Single();
var openTranscript = ((ContentText)chat.Blocks.First().Content!).FileAttachments.Single();
Assert.Multiple(() =>
{
Assert.That(stored.Blocks, Has.Count.EqualTo(1), "Whoever stores chats manually decides with the save button what reaches the disk, and moving a chat is no saving.");
Assert.That(stored.WorkspaceId, Is.EqualTo(workspaceId), "Opening the chat again has to find it in the workspace it was moved to.");
Assert.That(storedTranscript.FilePath, Does.StartWith(targetDirectory), "The stored chat has to point at its transcripts in the new place.");
Assert.That(chat.Blocks, Has.Count.EqualTo(2), "Nothing the user did not save may get lost by moving the chat; it stays unsaved in the open chat.");
Assert.That(chat.WorkspaceId, Is.EqualTo(workspaceId), "The open chat follows the move, so saving it later stores it in the new place.");
Assert.That(File.Exists(openTranscript.FilePath), Is.True, "The open chat has to point at its transcripts in the new place as well.");
});
}
private static ContentBlock UserMessage(string text, params FileAttachment[] attachments) => new()
{
Time = START,
ContentType = ContentType.TEXT,
Content = new ContentText { Text = text, FileAttachments = [..attachments] },
Role = ChatRole.USER,
};
/// <summary>
/// Writes a transcript where a temporary chat keeps its transcripts.
/// </summary>
+6 -6
View File
@@ -64,7 +64,7 @@ public sealed class WorkspaceTreeCacheTests
var targetId = await CreateLoadedWorkspaceAsync("Projects");
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetId, storeUnsavedChanges: true);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var target = snapshot.Workspaces.Single(workspace => workspace.WorkspaceId == targetId);
@@ -86,7 +86,7 @@ public sealed class WorkspaceTreeCacheTests
var targetId = await CreateLoadedWorkspaceAsync("Projects");
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetId, storeUnsavedChanges: true);
var other = await GetWorkspaceAsync(otherId);
Assert.Multiple(() =>
@@ -103,7 +103,7 @@ public sealed class WorkspaceTreeCacheTests
var targetId = await CreateLoadedWorkspaceAsync("Projects");
var chat = await StoreChatAsync(sourceId, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetId, storeUnsavedChanges: true);
var source = await GetWorkspaceAsync(sourceId);
var target = await GetWorkspaceAsync(targetId);
@@ -124,7 +124,7 @@ public sealed class WorkspaceTreeCacheTests
await WorkspaceBehaviour.ForceReloadWorkspaceTreeAsync();
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetId, storeUnsavedChanges: true);
var chats = await WorkspaceBehaviour.GetWorkspaceChatsAsync(targetId);
Assert.That(chats.Select(entry => entry.ChatId), Does.Contain(chat.ChatId), "Loading the chats of the workspace later reads them from the disk, the moved one included.");
@@ -137,7 +137,7 @@ public sealed class WorkspaceTreeCacheTests
// As in the dialog for moving a chat, which can create the workspace it moves the chat into:
var targetResult = await WorkspaceBehaviour.TryCreateWorkspaceAsync("Projects");
await WorkspaceBehaviour.MoveChatAsync(chat, targetResult.Workspace.WorkspaceId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetResult.Workspace.WorkspaceId, storeUnsavedChanges: true);
var target = await GetWorkspaceAsync(targetResult.Workspace.WorkspaceId);
Assert.Multiple(() =>
@@ -153,7 +153,7 @@ public sealed class WorkspaceTreeCacheTests
var chat = await StoreChatAsync(Guid.Empty, "Meeting notes");
var targetId = Guid.NewGuid();
await WorkspaceBehaviour.MoveChatAsync(chat, targetId);
await WorkspaceBehaviour.MoveChatAsync(chat, targetId, storeUnsavedChanges: true);
var snapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
var chats = await WorkspaceBehaviour.GetWorkspaceChatsAsync(targetId);