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

@@ -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)
{