mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-03 00:47:21 +00:00
1611 lines
70 KiB
C#
1611 lines
70 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
using AIStudio.Chat;
|
|
using AIStudio.Dialogs;
|
|
using AIStudio.Settings;
|
|
using AIStudio.Tools.PluginSystem;
|
|
|
|
namespace AIStudio.Tools;
|
|
|
|
public static partial class WorkspaceBehaviour
|
|
{
|
|
public readonly record struct TryCreateWorkspaceResult(bool Success, WorkspaceTreeWorkspace Workspace);
|
|
|
|
private sealed class WorkspaceChatCacheEntry
|
|
{
|
|
public Guid WorkspaceId { get; init; }
|
|
|
|
public Guid ChatId { get; init; }
|
|
|
|
public string ChatPath { get; init; } = string.Empty;
|
|
|
|
public string ChatName { get; set; } = string.Empty;
|
|
|
|
public DateTimeOffset LastEditTime { get; set; }
|
|
|
|
public bool IsTemporary { get; init; }
|
|
}
|
|
|
|
private sealed class WorkspaceCacheEntry
|
|
{
|
|
public Guid WorkspaceId { get; init; }
|
|
|
|
public string WorkspacePath { get; init; } = string.Empty;
|
|
|
|
public string WorkspaceName { get; set; } = string.Empty;
|
|
|
|
public bool ChatsLoaded { get; set; }
|
|
|
|
public List<WorkspaceChatCacheEntry> Chats { get; set; } = [];
|
|
}
|
|
|
|
private sealed class WorkspaceTreeCacheState
|
|
{
|
|
public Dictionary<Guid, WorkspaceCacheEntry> Workspaces { get; } = [];
|
|
|
|
public List<Guid> WorkspaceOrder { get; } = [];
|
|
|
|
public List<WorkspaceChatCacheEntry> TemporaryChats { get; set; } = [];
|
|
|
|
public bool IsShellLoaded { get; set; }
|
|
}
|
|
|
|
private static readonly ILogger LOG = Program.LOGGER_FACTORY.CreateLogger(nameof(WorkspaceBehaviour));
|
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> CHAT_STORAGE_SEMAPHORES = new();
|
|
private static readonly SemaphoreSlim WORKSPACE_TREE_CACHE_SEMAPHORE = new(1, 1);
|
|
private static readonly WorkspaceTreeCacheState WORKSPACE_TREE_CACHE = new();
|
|
|
|
private static readonly TimeSpan SEMAPHORE_TIMEOUT = TimeSpan.FromSeconds(6);
|
|
private static volatile bool WORKSPACE_TREE_CACHE_INVALIDATED = true;
|
|
|
|
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour));
|
|
|
|
public static readonly JsonSerializerOptions JSON_OPTIONS = new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
AllowTrailingCommas = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
|
DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
Converters =
|
|
{
|
|
new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseUpper),
|
|
},
|
|
TypeInfoResolver = ChatJsonContext.Default,
|
|
}.AsReadOnly();
|
|
|
|
private static readonly TimeSpan PREFETCH_DELAY_DURATION = TimeSpan.FromMilliseconds(45);
|
|
|
|
private static readonly string WORKSPACE_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "workspaces");
|
|
|
|
private static readonly string TEMPORARY_CHATS_ROOT_DIRECTORY = Path.Join(SettingsManager.DataDirectory, "tempChats");
|
|
|
|
/// <summary>
|
|
/// Only for tests: the data directory which the roots of the workspaces and of the temporary chats are taken from.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The app sets its data directory once at startup, and both roots stay fixed from then on. The
|
|
/// tests give every test a data directory of its own, though, while the fixed roots keep the one
|
|
/// of whichever test touched this class first. Null, the default and the only value the app ever
|
|
/// sees, keeps the fixed roots.
|
|
/// </remarks>
|
|
internal static string? TestDataDirectory { get; set; }
|
|
|
|
private static string WorkspaceRootDirectory => TestDataDirectory is null ? WORKSPACE_ROOT_DIRECTORY : Path.Join(TestDataDirectory, "workspaces");
|
|
|
|
private static string TemporaryChatsRootDirectory => TestDataDirectory is null ? TEMPORARY_CHATS_ROOT_DIRECTORY : Path.Join(TestDataDirectory, "tempChats");
|
|
|
|
private static string ChatSemaphoreKey(Guid workspaceId, Guid chatId) => $"{workspaceId}_{chatId}";
|
|
|
|
private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId) =>
|
|
CHAT_STORAGE_SEMAPHORES.GetOrAdd(ChatSemaphoreKey(workspaceId, chatId), _ => new SemaphoreSlim(1, 1));
|
|
|
|
/// <summary>
|
|
/// Drops the storage semaphore of a chat which does not exist anymore.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Deleting the chat is the one moment where we know that nobody will ask for this semaphore
|
|
/// again; without this, the dictionary would keep one entry per chat the app ever touched. We
|
|
/// do not dispose the semaphore, though: another operation might still be waiting on it, and
|
|
/// disposing it under their feet would turn a deleted chat into an exception somewhere else.
|
|
/// The garbage collector takes care of it once the last waiter is gone.
|
|
/// </remarks>
|
|
private static void ForgetChatSemaphore(Guid workspaceId, Guid chatId) =>
|
|
CHAT_STORAGE_SEMAPHORES.TryRemove(ChatSemaphoreKey(workspaceId, chatId), out _);
|
|
|
|
private static async Task<(bool Acquired, SemaphoreSlim Semaphore)> TryAcquireChatSemaphoreAsync(Guid workspaceId, Guid chatId, string callerName)
|
|
{
|
|
var semaphore = GetChatSemaphore(workspaceId, chatId);
|
|
var acquired = await semaphore.WaitAsync(SEMAPHORE_TIMEOUT);
|
|
|
|
if (!acquired)
|
|
LOG.LogWarning("Failed to acquire chat storage semaphore within {Timeout} seconds for workspace '{WorkspaceId}', chat '{ChatId}' in method '{CallerName}'. Skipping operation to prevent potential race conditions or deadlocks.",
|
|
SEMAPHORE_TIMEOUT.TotalSeconds,
|
|
workspaceId,
|
|
chatId,
|
|
callerName);
|
|
|
|
return (acquired, semaphore);
|
|
}
|
|
|
|
private static WorkspaceTreeChat ToPublicChat(WorkspaceChatCacheEntry chat) => new(chat.WorkspaceId, chat.ChatId, chat.ChatPath, chat.ChatName, chat.LastEditTime, chat.IsTemporary);
|
|
|
|
private static WorkspaceTreeWorkspace ToPublicWorkspace(WorkspaceCacheEntry workspace) => new(workspace.WorkspaceId,
|
|
workspace.WorkspacePath,
|
|
workspace.WorkspaceName,
|
|
workspace.ChatsLoaded,
|
|
workspace.Chats.Select(ToPublicChat).ToList());
|
|
|
|
private static async Task<string> ReadNameOrDefaultAsync(string nameFilePath, string fallbackName)
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(nameFilePath))
|
|
return fallbackName;
|
|
|
|
var name = await File.ReadAllTextAsync(nameFilePath, Encoding.UTF8);
|
|
return string.IsNullOrWhiteSpace(name) ? fallbackName : name;
|
|
}
|
|
catch
|
|
{
|
|
return fallbackName;
|
|
}
|
|
}
|
|
|
|
private static async Task<List<WorkspaceChatCacheEntry>> ReadWorkspaceChatsCoreAsync(Guid workspaceId, string workspacePath)
|
|
{
|
|
var chats = new List<WorkspaceChatCacheEntry>();
|
|
if (!Directory.Exists(workspacePath))
|
|
return chats;
|
|
|
|
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
|
|
{
|
|
if (!Guid.TryParse(Path.GetFileName(chatPath), out var chatId))
|
|
continue;
|
|
|
|
var chatName = await ReadNameOrDefaultAsync(Path.Join(chatPath, "name"), TB("Unnamed chat"));
|
|
var chatThreadPath = Path.Join(chatPath, "thread.json");
|
|
var lastEditTime = File.Exists(chatThreadPath) ? File.GetLastWriteTimeUtc(chatThreadPath) : DateTimeOffset.MinValue;
|
|
chats.Add(new WorkspaceChatCacheEntry
|
|
{
|
|
WorkspaceId = workspaceId,
|
|
ChatId = chatId,
|
|
ChatPath = chatPath,
|
|
ChatName = chatName,
|
|
LastEditTime = lastEditTime,
|
|
IsTemporary = false,
|
|
});
|
|
}
|
|
|
|
return chats.OrderByDescending(x => x.LastEditTime).ToList();
|
|
}
|
|
|
|
private static async Task<List<WorkspaceChatCacheEntry>> ReadTemporaryChatsCoreAsync()
|
|
{
|
|
var chats = new List<WorkspaceChatCacheEntry>();
|
|
Directory.CreateDirectory(TemporaryChatsRootDirectory);
|
|
|
|
foreach (var tempChatPath in Directory.EnumerateDirectories(TemporaryChatsRootDirectory))
|
|
{
|
|
if (!Guid.TryParse(Path.GetFileName(tempChatPath), out var chatId))
|
|
continue;
|
|
|
|
var chatName = await ReadNameOrDefaultAsync(Path.Join(tempChatPath, "name"), TB("Unnamed chat"));
|
|
var chatThreadPath = Path.Join(tempChatPath, "thread.json");
|
|
var lastEditTime = File.Exists(chatThreadPath) ? File.GetLastWriteTimeUtc(chatThreadPath) : DateTimeOffset.MinValue;
|
|
chats.Add(new WorkspaceChatCacheEntry
|
|
{
|
|
WorkspaceId = Guid.Empty,
|
|
ChatId = chatId,
|
|
ChatPath = tempChatPath,
|
|
ChatName = chatName,
|
|
LastEditTime = lastEditTime,
|
|
IsTemporary = true,
|
|
});
|
|
}
|
|
|
|
return chats.OrderByDescending(x => x.LastEditTime).ToList();
|
|
}
|
|
|
|
private static async Task EnsureTreeShellLoadedCoreAsync()
|
|
{
|
|
if (!WORKSPACE_TREE_CACHE_INVALIDATED && WORKSPACE_TREE_CACHE.IsShellLoaded)
|
|
return;
|
|
|
|
WORKSPACE_TREE_CACHE.Workspaces.Clear();
|
|
WORKSPACE_TREE_CACHE.WorkspaceOrder.Clear();
|
|
|
|
Directory.CreateDirectory(WorkspaceRootDirectory);
|
|
foreach (var workspacePath in Directory.EnumerateDirectories(WorkspaceRootDirectory))
|
|
{
|
|
if (!Guid.TryParse(Path.GetFileName(workspacePath), out var workspaceId))
|
|
continue;
|
|
|
|
var workspaceName = await ReadNameOrDefaultAsync(Path.Join(workspacePath, "name"), TB("Unnamed workspace"));
|
|
WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = new WorkspaceCacheEntry
|
|
{
|
|
WorkspaceId = workspaceId,
|
|
WorkspacePath = workspacePath,
|
|
WorkspaceName = workspaceName,
|
|
ChatsLoaded = false,
|
|
Chats = [],
|
|
};
|
|
|
|
WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId);
|
|
}
|
|
|
|
WORKSPACE_TREE_CACHE.TemporaryChats = await ReadTemporaryChatsCoreAsync();
|
|
WORKSPACE_TREE_CACHE.IsShellLoaded = true;
|
|
WORKSPACE_TREE_CACHE_INVALIDATED = false;
|
|
}
|
|
|
|
private static void UpsertChatInCache(List<WorkspaceChatCacheEntry> chats, WorkspaceChatCacheEntry chat)
|
|
{
|
|
var existingIndex = chats.FindIndex(existing => existing.ChatId == chat.ChatId);
|
|
if (existingIndex >= 0)
|
|
chats[existingIndex] = chat;
|
|
else
|
|
chats.Add(chat);
|
|
|
|
chats.Sort((a, b) => b.LastEditTime.CompareTo(a.LastEditTime));
|
|
}
|
|
|
|
private static void DeleteChatFromCache(List<WorkspaceChatCacheEntry> chats, Guid chatId)
|
|
{
|
|
var existingIndex = chats.FindIndex(existing => existing.ChatId == chatId);
|
|
if (existingIndex >= 0)
|
|
chats.RemoveAt(existingIndex);
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseSearchTerms(string searchText) => searchText
|
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Where(term => !string.IsNullOrWhiteSpace(term))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
private static IReadOnlyList<string> GetMissingTerms(string text, IReadOnlyList<string> terms) => terms
|
|
.Where(term => text.IndexOf(term, StringComparison.OrdinalIgnoreCase) < 0)
|
|
.ToList();
|
|
|
|
private static bool ChatThreadContainsTerms(ChatThread thread, IReadOnlyList<string> terms)
|
|
{
|
|
var matchedTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var block in thread.Blocks)
|
|
{
|
|
if (block.HideFromUser || block.Content is not ContentText textContent || string.IsNullOrWhiteSpace(textContent.Text))
|
|
continue;
|
|
|
|
foreach (var term in terms)
|
|
if (textContent.Text.Contains(term, StringComparison.OrdinalIgnoreCase))
|
|
matchedTerms.Add(term);
|
|
|
|
if (matchedTerms.Count == terms.Count)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool WorkspaceNameExistsCore(string workspaceName, Guid excludedWorkspaceId = default)
|
|
{
|
|
return WORKSPACE_TREE_CACHE.Workspaces.Values.Any(workspace =>
|
|
workspace.WorkspaceId != excludedWorkspaceId &&
|
|
string.Equals(workspace.WorkspaceName.Trim(), workspaceName, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static async Task<bool> ThreadContainsTermsAsync(WorkspaceTreeChat chat, IReadOnlyList<string> terms, CancellationToken token)
|
|
{
|
|
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(ThreadContainsTermsAsync));
|
|
if (!acquired)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
var threadPath = Path.Join(chat.ChatPath, "thread.json");
|
|
if (!File.Exists(threadPath))
|
|
return false;
|
|
|
|
var chatData = await File.ReadAllTextAsync(threadPath, Encoding.UTF8, token);
|
|
token.ThrowIfCancellationRequested();
|
|
var thread = JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS);
|
|
return thread is not null && ChatThreadContainsTerms(thread, terms);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LOG.LogWarning(ex, "Failed to search chat thread for workspace '{WorkspaceId}', chat '{ChatId}'.", chat.WorkspaceId, chat.ChatId);
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
}
|
|
}
|
|
|
|
private static async Task<List<WorkspaceSearchResult>> SearchChatsAsync(IReadOnlyList<WorkspaceTreeChat> chats, IReadOnlyList<string> terms, bool includeThreadContents, CancellationToken token)
|
|
{
|
|
var results = new List<WorkspaceSearchResult>();
|
|
foreach (var chat in chats)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
var missingTerms = GetMissingTerms(chat.Name, terms);
|
|
if (missingTerms.Count == 0)
|
|
{
|
|
results.Add(new(chat, NameMatched: true, ThreadMatched: false));
|
|
continue;
|
|
}
|
|
|
|
if (!includeThreadContents)
|
|
continue;
|
|
|
|
var threadMatched = await ThreadContainsTermsAsync(chat, missingTerms, token);
|
|
if (threadMatched)
|
|
results.Add(new(chat, NameMatched: false, ThreadMatched: true));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private static async Task UpdateCacheAfterChatStored(Guid workspaceId, Guid chatId, string chatDirectory, string chatName, DateTimeOffset lastEditTime)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
|
|
return;
|
|
|
|
UpdateCacheAfterChatStoredCore(workspaceId, chatId, chatDirectory, chatName, lastEditTime);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Puts a stored chat into the cache. The caller holds the cache semaphore.</summary>
|
|
private static void UpdateCacheAfterChatStoredCore(Guid workspaceId, Guid chatId, string chatDirectory, string chatName, DateTimeOffset lastEditTime)
|
|
{
|
|
var chatCacheEntry = new WorkspaceChatCacheEntry
|
|
{
|
|
WorkspaceId = workspaceId,
|
|
ChatId = chatId,
|
|
ChatPath = chatDirectory,
|
|
ChatName = string.IsNullOrWhiteSpace(chatName) ? TB("Unnamed chat") : chatName,
|
|
LastEditTime = lastEditTime,
|
|
IsTemporary = workspaceId == Guid.Empty,
|
|
};
|
|
|
|
if (workspaceId == Guid.Empty)
|
|
{
|
|
UpsertChatInCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatCacheEntry);
|
|
return;
|
|
}
|
|
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded)
|
|
UpsertChatInCache(workspace.Chats, chatCacheEntry);
|
|
}
|
|
|
|
private static async Task UpdateCacheAfterChatDeleted(Guid workspaceId, Guid chatId)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
|
|
return;
|
|
|
|
UpdateCacheAfterChatDeletedCore(workspaceId, chatId);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Takes a deleted chat out of the cache. The caller holds the cache semaphore.</summary>
|
|
private static void UpdateCacheAfterChatDeletedCore(Guid workspaceId, Guid chatId)
|
|
{
|
|
if (workspaceId == Guid.Empty)
|
|
{
|
|
DeleteChatFromCache(WORKSPACE_TREE_CACHE.TemporaryChats, chatId);
|
|
return;
|
|
}
|
|
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && workspace.ChatsLoaded)
|
|
DeleteChatFromCache(workspace.Chats, chatId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the cache entry of a chat from the workspace it came from to the one it lives in now.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Both happen within one hold of the cache semaphore, so no snapshot shows the chat twice or
|
|
/// not at all. Throwing the whole cache away would get this one chat right as well, but it also
|
|
/// forgets the chats of every workspace loaded so far: the workspace list then shows all of them
|
|
/// empty until the user clicks each one.<br/><br/>
|
|
///
|
|
/// A target workspace the cache does not know yet is the exception. It appears only when the
|
|
/// workspaces are read from the disk again, which is why the cache is thrown away in that case.
|
|
/// </remarks>
|
|
/// <param name="sourceWorkspaceId">The workspace the chat came from, or Guid.Empty for a temporary chat.</param>
|
|
/// <param name="targetWorkspaceId">The workspace the chat lives in now, or Guid.Empty for a temporary chat.</param>
|
|
/// <param name="chatId">The chat which was moved.</param>
|
|
/// <param name="targetDirectory">The directory of the chat after the move.</param>
|
|
/// <param name="chatName">The name of the chat.</param>
|
|
/// <param name="lastEditTime">When the chat was written last, which orders the chats in the list.</param>
|
|
private static async Task UpdateCacheAfterChatMoved(Guid sourceWorkspaceId, Guid targetWorkspaceId, Guid chatId, string targetDirectory, string chatName, DateTimeOffset lastEditTime)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
|
|
return;
|
|
|
|
if (targetWorkspaceId != Guid.Empty && !WORKSPACE_TREE_CACHE.Workspaces.ContainsKey(targetWorkspaceId))
|
|
{
|
|
WORKSPACE_TREE_CACHE_INVALIDATED = true;
|
|
return;
|
|
}
|
|
|
|
UpdateCacheAfterChatDeletedCore(sourceWorkspaceId, chatId);
|
|
UpdateCacheAfterChatStoredCore(targetWorkspaceId, chatId, targetDirectory, chatName, lastEditTime);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static void InvalidateWorkspaceTreeCache()
|
|
{
|
|
WORKSPACE_TREE_CACHE_INVALIDATED = true;
|
|
}
|
|
|
|
public static async Task ForceReloadWorkspaceTreeAsync()
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
WORKSPACE_TREE_CACHE_INVALIDATED = false;
|
|
WORKSPACE_TREE_CACHE.IsShellLoaded = false;
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<WorkspaceTreeCacheSnapshot> GetOrLoadWorkspaceTreeShellAsync()
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
var workspaces = WORKSPACE_TREE_CACHE.WorkspaceOrder
|
|
.Where(workspaceId => WORKSPACE_TREE_CACHE.Workspaces.ContainsKey(workspaceId))
|
|
.Select(workspaceId => ToPublicWorkspace(WORKSPACE_TREE_CACHE.Workspaces[workspaceId]))
|
|
.ToList();
|
|
var temporaryChats = WORKSPACE_TREE_CACHE.TemporaryChats.Select(ToPublicChat).ToList();
|
|
return new WorkspaceTreeCacheSnapshot(workspaces, temporaryChats);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<IReadOnlyList<WorkspaceTreeChat>> GetWorkspaceChatsAsync(Guid workspaceId, bool forceRefresh = false)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
|
|
return [];
|
|
|
|
if (forceRefresh || !workspace.ChatsLoaded)
|
|
{
|
|
workspace.Chats = await ReadWorkspaceChatsCoreAsync(workspaceId, workspace.WorkspacePath);
|
|
workspace.ChatsLoaded = true;
|
|
}
|
|
|
|
return workspace.Chats.Select(ToPublicChat).ToList();
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<WorkspaceSearchSnapshot> SearchWorkspaceChatsAsync(string searchText, bool includeThreadContents, CancellationToken token = default)
|
|
{
|
|
var terms = ParseSearchTerms(searchText);
|
|
if (terms.Count == 0)
|
|
return new([], []);
|
|
|
|
List<WorkspaceTreeWorkspace> workspaces;
|
|
List<WorkspaceTreeChat> temporaryChats;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(token);
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
workspaces = [];
|
|
foreach (var workspaceId in WORKSPACE_TREE_CACHE.WorkspaceOrder)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
|
|
continue;
|
|
|
|
if (!workspace.ChatsLoaded)
|
|
{
|
|
workspace.Chats = await ReadWorkspaceChatsCoreAsync(workspaceId, workspace.WorkspacePath);
|
|
workspace.ChatsLoaded = true;
|
|
}
|
|
|
|
workspaces.Add(ToPublicWorkspace(workspace));
|
|
}
|
|
|
|
temporaryChats = WORKSPACE_TREE_CACHE.TemporaryChats.Select(ToPublicChat).ToList();
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
|
|
var matchingWorkspaces = new List<WorkspaceSearchWorkspace>();
|
|
foreach (var workspace in workspaces)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var matchingChats = await SearchChatsAsync(workspace.Chats, terms, includeThreadContents, token);
|
|
if (matchingChats.Count > 0)
|
|
matchingWorkspaces.Add(new(workspace.WorkspaceId, workspace.WorkspacePath, workspace.Name, matchingChats));
|
|
}
|
|
|
|
var matchingTemporaryChats = await SearchChatsAsync(temporaryChats, terms, includeThreadContents, token);
|
|
return new(matchingWorkspaces, matchingTemporaryChats);
|
|
}
|
|
|
|
public static async Task TryPrefetchRemainingChatsAsync(Func<Guid, Task>? onWorkspaceUpdated = null, CancellationToken token = default)
|
|
{
|
|
while (true)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
Guid? workspaceToPrefetch = null;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync(token);
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
foreach (var workspaceId in WORKSPACE_TREE_CACHE.WorkspaceOrder)
|
|
{
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace) && !workspace.ChatsLoaded)
|
|
{
|
|
workspaceToPrefetch = workspaceId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
|
|
if (workspaceToPrefetch is null)
|
|
return;
|
|
|
|
await GetWorkspaceChatsAsync(workspaceToPrefetch.Value);
|
|
if (onWorkspaceUpdated is not null)
|
|
{
|
|
try
|
|
{
|
|
await onWorkspaceUpdated(workspaceToPrefetch.Value);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LOG.LogWarning(ex, "Failed to process callback after prefetching workspace '{WorkspaceId}'.", workspaceToPrefetch.Value);
|
|
}
|
|
}
|
|
|
|
await Task.Delay(PREFETCH_DELAY_DURATION, token);
|
|
}
|
|
}
|
|
|
|
public static async Task AddWorkspaceToCacheAsync(Guid workspaceId, string workspacePath, string workspaceName)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
|
|
{
|
|
workspace.WorkspaceName = workspaceName;
|
|
return;
|
|
}
|
|
|
|
WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = new WorkspaceCacheEntry
|
|
{
|
|
WorkspaceId = workspaceId,
|
|
WorkspacePath = workspacePath,
|
|
WorkspaceName = workspaceName,
|
|
Chats = [],
|
|
ChatsLoaded = false,
|
|
};
|
|
WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task UpdateWorkspaceNameInCacheAsync(Guid workspaceId, string workspaceName)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
|
|
workspace.WorkspaceName = workspaceName;
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
private static async Task RemoveWorkspaceFromCacheAsync(Guid workspaceId)
|
|
{
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
if (!WORKSPACE_TREE_CACHE.IsShellLoaded || WORKSPACE_TREE_CACHE_INVALIDATED)
|
|
return;
|
|
|
|
WORKSPACE_TREE_CACHE.Workspaces.Remove(workspaceId);
|
|
WORKSPACE_TREE_CACHE.WorkspaceOrder.Remove(workspaceId);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static string NormalizeWorkspaceName(string workspaceName) => workspaceName.Trim();
|
|
|
|
public static async Task<bool> IsWorkspaceNameExistingAsync(string workspaceName, Guid excludedWorkspaceId = default)
|
|
{
|
|
var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName);
|
|
if (string.IsNullOrWhiteSpace(normalizedWorkspaceName))
|
|
return false;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
return WorkspaceNameExistsCore(normalizedWorkspaceName, excludedWorkspaceId);
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<Guid> ResolveOrCreateWorkspaceIdByNameAsync(string workspaceName)
|
|
{
|
|
var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName);
|
|
if (string.IsNullOrWhiteSpace(normalizedWorkspaceName))
|
|
return Guid.Empty;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
|
|
var existingWorkspace = WORKSPACE_TREE_CACHE.Workspaces.Values.FirstOrDefault(workspace =>
|
|
string.Equals(workspace.WorkspaceName.Trim(), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase));
|
|
if (existingWorkspace is not null)
|
|
return existingWorkspace.WorkspaceId;
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
|
|
var result = await TryCreateWorkspaceAsync(normalizedWorkspaceName);
|
|
if (result.Success)
|
|
return result.Workspace.WorkspaceId;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
|
|
var existingWorkspace = WORKSPACE_TREE_CACHE.Workspaces.Values.FirstOrDefault(workspace =>
|
|
string.Equals(workspace.WorkspaceName.Trim(), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase));
|
|
return existingWorkspace?.WorkspaceId ?? Guid.Empty;
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens or creates the workspace a chat template keeps its chats in, by its name.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Both the chat page and assistant chat launchers come here, so that a chat finds the workspace
|
|
/// of its template the same way wherever it starts. Unlike a workspace a launcher names itself,
|
|
/// a failure stops nothing: like everything else a chat template names, a workspace which cannot
|
|
/// be used leaves the chat to start as it would without the template, i.e., without a workspace.
|
|
/// The failure is logged here, once, so the callers need no handling of their own.
|
|
/// </remarks>
|
|
/// <param name="workspaceName">The name of the workspace, as the chat template names it.</param>
|
|
/// <param name="chatTemplateName">The name of the chat template, for the log.</param>
|
|
/// <returns>The ID of the workspace, or Guid.Empty when it can be neither opened nor created.</returns>
|
|
public static async Task<Guid> ResolveOrCreateChatTemplateWorkspaceAsync(string workspaceName, string chatTemplateName)
|
|
{
|
|
try
|
|
{
|
|
var workspaceId = await ResolveOrCreateWorkspaceIdByNameAsync(workspaceName);
|
|
if (workspaceId == Guid.Empty)
|
|
LOG.LogWarning("The workspace '{WorkspaceName}' of the chat template '{ChatTemplateName}' could not be opened or created. The chat starts without a workspace.", workspaceName, chatTemplateName);
|
|
|
|
return workspaceId;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
LOG.LogError(exception, "The workspace '{WorkspaceName}' of the chat template '{ChatTemplateName}' could not be opened or created. The chat starts without a workspace.", workspaceName, chatTemplateName);
|
|
return Guid.Empty;
|
|
}
|
|
}
|
|
|
|
public static async Task<TryCreateWorkspaceResult> TryCreateWorkspaceAsync(string workspaceName)
|
|
{
|
|
var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName);
|
|
if (string.IsNullOrWhiteSpace(normalizedWorkspaceName))
|
|
return new(false, default);
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
if (WorkspaceNameExistsCore(normalizedWorkspaceName))
|
|
return new(false, default);
|
|
|
|
var workspaceId = Guid.NewGuid();
|
|
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
|
|
Directory.CreateDirectory(workspacePath);
|
|
|
|
var workspaceNamePath = Path.Join(workspacePath, "name");
|
|
await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8);
|
|
|
|
//
|
|
// The workspace directory was created just now, so there are no chats to read from it.
|
|
// Counting them as loaded lets a chat moved into the new workspace show up there right
|
|
// away, instead of only after the user clicked the workspace:
|
|
//
|
|
var workspace = new WorkspaceCacheEntry
|
|
{
|
|
WorkspaceId = workspaceId,
|
|
WorkspacePath = workspacePath,
|
|
WorkspaceName = normalizedWorkspaceName,
|
|
Chats = [],
|
|
ChatsLoaded = true,
|
|
};
|
|
WORKSPACE_TREE_CACHE.Workspaces[workspaceId] = workspace;
|
|
WORKSPACE_TREE_CACHE.WorkspaceOrder.Add(workspaceId);
|
|
|
|
return new(true, ToPublicWorkspace(workspace));
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<bool> RenameWorkspaceAsync(Guid workspaceId, string workspaceName)
|
|
{
|
|
var normalizedWorkspaceName = NormalizeWorkspaceName(workspaceName);
|
|
if (string.IsNullOrWhiteSpace(normalizedWorkspaceName))
|
|
return false;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
if (!WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
|
|
return false;
|
|
|
|
var workspaceNamePath = Path.Join(workspace.WorkspacePath, "name");
|
|
if (string.Equals(workspace.WorkspaceName.Trim(), normalizedWorkspaceName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8);
|
|
workspace.WorkspaceName = normalizedWorkspaceName;
|
|
return true;
|
|
}
|
|
|
|
if (WorkspaceNameExistsCore(normalizedWorkspaceName, workspaceId))
|
|
return false;
|
|
|
|
await File.WriteAllTextAsync(workspaceNamePath, normalizedWorkspaceName, Encoding.UTF8);
|
|
workspace.WorkspaceName = normalizedWorkspaceName;
|
|
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the given workspace exists on the disk.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The temporary chats belong to no workspace, so for them, the empty ID, this is always true.
|
|
/// </remarks>
|
|
/// <param name="workspaceId">The workspace to look for.</param>
|
|
public static bool IsWorkspaceExisting(Guid workspaceId) => workspaceId == Guid.Empty || Directory.Exists(Path.Join(WorkspaceRootDirectory, workspaceId.ToString()));
|
|
|
|
/// <summary>
|
|
/// Reads where every chat on the disk lies: in which workspace, or among the temporary chats.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Only the directories are read, no thread, so this stays fast with many chats.
|
|
/// </remarks>
|
|
/// <returns>The workspace of every chat by its ID, Guid.Empty for a temporary chat.</returns>
|
|
public static IReadOnlyDictionary<Guid, Guid> ReadAllChatLocations()
|
|
{
|
|
var chatLocations = new Dictionary<Guid, Guid>();
|
|
if (Directory.Exists(WorkspaceRootDirectory))
|
|
{
|
|
foreach (var workspacePath in Directory.EnumerateDirectories(WorkspaceRootDirectory))
|
|
{
|
|
if (!Guid.TryParse(Path.GetFileName(workspacePath), out var workspaceId))
|
|
continue;
|
|
|
|
foreach (var chatId in ReadChatIdsOfWorkspace(workspaceId))
|
|
chatLocations[chatId] = workspaceId;
|
|
}
|
|
}
|
|
|
|
if (Directory.Exists(TemporaryChatsRootDirectory))
|
|
{
|
|
foreach (var chatPath in Directory.EnumerateDirectories(TemporaryChatsRootDirectory))
|
|
if (Guid.TryParse(Path.GetFileName(chatPath), out var chatId))
|
|
chatLocations[chatId] = Guid.Empty;
|
|
}
|
|
|
|
return chatLocations;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the IDs of the chats the given workspace holds on the disk.
|
|
/// </summary>
|
|
/// <param name="workspaceId">The workspace to read.</param>
|
|
/// <returns>The IDs of its chats; none when the workspace does not exist.</returns>
|
|
public static IReadOnlyList<Guid> ReadChatIdsOfWorkspace(Guid workspaceId)
|
|
{
|
|
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
|
|
if (!Directory.Exists(workspacePath))
|
|
return [];
|
|
|
|
var chatIds = new List<Guid>();
|
|
foreach (var chatPath in Directory.EnumerateDirectories(workspacePath))
|
|
if (Guid.TryParse(Path.GetFileName(chatPath), out var chatId))
|
|
chatIds.Add(chatId);
|
|
|
|
return chatIds;
|
|
}
|
|
|
|
public static bool IsChatExisting(LoadChat loadChat)
|
|
{
|
|
var chatPath = loadChat.WorkspaceId == Guid.Empty
|
|
? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString())
|
|
: Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString());
|
|
|
|
return Directory.Exists(chatPath);
|
|
}
|
|
|
|
/// <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 false;
|
|
|
|
try
|
|
{
|
|
var chatDirectory = chat.WorkspaceId == Guid.Empty
|
|
? Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString())
|
|
: Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
|
|
|
|
Directory.CreateDirectory(chatDirectory);
|
|
|
|
FinalizeStagedTranscripts(chat, chatDirectory);
|
|
var chatNamePath = Path.Join(chatDirectory, "name");
|
|
await File.WriteAllTextAsync(chatNamePath, chat.Name);
|
|
|
|
var chatPath = Path.Join(chatDirectory, "thread.json");
|
|
await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8);
|
|
|
|
var lastEditTime = File.GetLastWriteTimeUtc(chatPath);
|
|
await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime);
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
|
|
return await CopyChatAsync(sourceChat, (dialogResult.Data as string)!);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies a chat into a new chat of the same workspace, including its managed transcript files.
|
|
/// </summary>
|
|
/// <param name="sourceChat">The chat to copy. Its own files and state stay untouched.</param>
|
|
/// <param name="name">The name of the copy.</param>
|
|
/// <returns>The persisted copy.</returns>
|
|
internal static async Task<ChatThread> CopyChatAsync(ChatThread sourceChat, string name)
|
|
{
|
|
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 = name,
|
|
};
|
|
|
|
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}'.");
|
|
|
|
// The copy refers to the same entries as its original, and gets entries of its own, so that deleting either chat leaves the other whole:
|
|
await ChatProvenance.CopyChatAsync(sourceChat.ChatId, copiedChat.ChatId);
|
|
return copiedChat;
|
|
}
|
|
catch
|
|
{
|
|
if (Directory.Exists(targetDirectory))
|
|
Directory.Delete(targetDirectory, true);
|
|
|
|
InvalidateWorkspaceTreeCache();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static void CopyManagedTranscriptAttachments(ChatThread chat, string targetChatDirectory)
|
|
{
|
|
var targetTranscriptDirectory = ManagedTranscriptAttachment.GetTranscriptDirectory(targetChatDirectory);
|
|
var copiedPaths = new Dictionary<string, ManagedTranscriptAttachment>(PathTools.COMPARER);
|
|
|
|
chat.ReplaceAttachments(attachment => attachment is ManagedTranscriptAttachment transcript
|
|
? CopyManagedTranscriptAttachment(chat, transcript, targetTranscriptDirectory, copiedPaths)
|
|
: attachment);
|
|
}
|
|
|
|
internal 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);
|
|
|
|
// The copy of the file holds the same transcript, so it keeps the entry of the text it was sent with:
|
|
var copiedAttachment = new ManagedTranscriptAttachment(
|
|
Path.GetFileName(targetPath),
|
|
targetPath,
|
|
File.Exists(targetPath) ? new FileInfo(targetPath).Length : source.FileSizeBytes,
|
|
source.OriginalFileName,
|
|
false)
|
|
{
|
|
ProvenanceId = source.ProvenanceId,
|
|
};
|
|
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>
|
|
/// <param name="transcript">Provider transcript.</param>
|
|
/// <returns>The chat-owned managed attachment.</returns>
|
|
public static async Task<ManagedTranscriptAttachment> CreateManagedTranscriptAsync(ChatThread chat, string originalPath, string transcript)
|
|
{
|
|
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(CreateManagedTranscriptAsync));
|
|
if (!acquired)
|
|
throw new IOException("The chat transcript directory is busy.");
|
|
|
|
try
|
|
{
|
|
var chatDirectory = GetChatDirectory(chat.WorkspaceId, chat.ChatId);
|
|
if (!Directory.Exists(chatDirectory))
|
|
throw new DirectoryNotFoundException($"The owning chat directory does not exist: '{chatDirectory}'.");
|
|
|
|
var transcriptDirectory = ManagedTranscriptAttachment.GetTranscriptDirectory(chatDirectory);
|
|
Directory.CreateDirectory(transcriptDirectory);
|
|
ReconcileTranscriptCounter(chat, transcriptDirectory);
|
|
|
|
var targetPath = NextTranscriptPath(chat, transcriptDirectory, Path.GetFileName(originalPath));
|
|
return await ManagedTranscriptAttachment.CreateAtomicAsync(targetPath, Path.GetFileName(originalPath), transcript);
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
|
|
var sourceWorkspaceId = chat.WorkspaceId;
|
|
var sourceDirectory = GetChatDirectory(sourceWorkspaceId, chat.ChatId);
|
|
var targetDirectory = GetChatDirectory(targetWorkspaceId, chat.ChatId);
|
|
var sourceSemaphore = GetChatSemaphore(sourceWorkspaceId, chat.ChatId);
|
|
var targetSemaphore = GetChatSemaphore(targetWorkspaceId, chat.ChatId);
|
|
// Always acquire both workspace/chat locks in canonical workspace-ID order. This prevents
|
|
// opposing moves of the same chat from waiting on one another with reversed lock order.
|
|
var orderedSemaphores = string.CompareOrdinal(sourceWorkspaceId.ToString("N"), targetWorkspaceId.ToString("N")) <= 0
|
|
? new[] { sourceSemaphore, targetSemaphore }
|
|
: new[] { targetSemaphore, sourceSemaphore };
|
|
|
|
await orderedSemaphores[0].WaitAsync();
|
|
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))
|
|
throw new DirectoryNotFoundException($"The source chat directory does not exist: '{sourceDirectory}'.");
|
|
|
|
// Only the workspace parent is created here. Directory.Move requires the chat target
|
|
// directory itself not to exist so an existing destination is never merged silently.
|
|
var targetWorkspaceDirectory = Path.GetDirectoryName(targetDirectory)!;
|
|
Directory.CreateDirectory(targetWorkspaceDirectory);
|
|
if (Directory.Exists(targetDirectory))
|
|
throw new IOException($"The target chat directory already exists: '{targetDirectory}'.");
|
|
|
|
Directory.Move(sourceDirectory, targetDirectory);
|
|
moved = true;
|
|
|
|
movedChat = storeUnsavedChanges ? chat : await ReadMovedChatAsync(targetDirectory);
|
|
UpdateAttachmentPathsAfterMove(movedChat, sourceDirectory, targetDirectory);
|
|
movedChat.WorkspaceId = targetWorkspaceId;
|
|
|
|
FinalizeStagedTranscripts(movedChat, targetDirectory);
|
|
await StoreMovedChatFilesAsync(movedChat, targetDirectory);
|
|
|
|
var lastEditTime = File.GetLastWriteTimeUtc(Path.Join(targetDirectory, "thread.json"));
|
|
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
|
|
{
|
|
if (moved)
|
|
{
|
|
try
|
|
{
|
|
if (movedChat is not null)
|
|
{
|
|
UpdateAttachmentPathsAfterMove(movedChat, targetDirectory, sourceDirectory);
|
|
movedChat.WorkspaceId = sourceWorkspaceId;
|
|
}
|
|
|
|
if (Directory.Exists(targetDirectory) && !Directory.Exists(sourceDirectory))
|
|
Directory.Move(targetDirectory, sourceDirectory);
|
|
|
|
if (movedChat is not null && Directory.Exists(sourceDirectory))
|
|
await StoreMovedChatFilesAsync(movedChat, sourceDirectory);
|
|
}
|
|
catch (Exception rollbackError)
|
|
{
|
|
LOG.LogError(rollbackError, "Could not roll back moving chat '{ChatId}' to workspace '{WorkspaceId}'.", chat.ChatId, targetWorkspaceId);
|
|
}
|
|
}
|
|
|
|
// After a failed move, it is unclear where the chat ended up, so the cache reads it from the disk again:
|
|
InvalidateWorkspaceTreeCache();
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
orderedSemaphores[1].Release();
|
|
orderedSemaphores[0].Release();
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
await File.WriteAllTextAsync(Path.Join(chatDirectory, "name"), chat.Name);
|
|
var chatPath = Path.Join(chatDirectory, "thread.json");
|
|
var temporaryPath = Path.Join(chatDirectory, $".thread-{Guid.NewGuid():N}.tmp");
|
|
|
|
try
|
|
{
|
|
await File.WriteAllTextAsync(temporaryPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8);
|
|
File.Move(temporaryPath, chatPath, overwrite: true);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(temporaryPath))
|
|
File.Delete(temporaryPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>Rewrites absolute file paths after moving the complete chat directory.</summary>
|
|
/// <remarks>
|
|
/// The transcripts of the message not sent yet live in the chat directory as well. Left with
|
|
/// their old paths, the draft points at a directory which is gone after the move, and the
|
|
/// transcripts would never reach the message. ChatThread.RewriteFilePaths takes the draft along.
|
|
/// </remarks>
|
|
private static void UpdateAttachmentPathsAfterMove(ChatThread chat, string sourceDirectory, string targetDirectory) =>
|
|
chat.RewriteFilePaths(path => RelocatePath(path, sourceDirectory, targetDirectory));
|
|
|
|
/// <summary>
|
|
/// Gives an attachment the path it has after its chat moved into another workspace.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Moving a chat moves its whole directory, and with it every file inside, such as the
|
|
/// transcripts of audio and video files. Whatever still refers to such a file needs its new path:
|
|
/// the messages and the draft of the chat, which the move takes care of itself, and the composer
|
|
/// of the open chat, which is what this is for. An attachment outside the chat directory stays as
|
|
/// it is.
|
|
/// </remarks>
|
|
/// <param name="attachment">The attachment as it was before the move.</param>
|
|
/// <param name="chatId">The chat which was moved.</param>
|
|
/// <param name="sourceWorkspaceId">The workspace the chat came from, or Guid.Empty for a temporary chat.</param>
|
|
/// <param name="targetWorkspaceId">The workspace the chat lives in now, or Guid.Empty for a temporary chat.</param>
|
|
/// <returns>The attachment with its new path, or the attachment itself when the move did not touch its file.</returns>
|
|
public static FileAttachment RelocateAttachmentAfterMove(FileAttachment attachment, Guid chatId, Guid sourceWorkspaceId, Guid targetWorkspaceId) =>
|
|
RelocateAttachment(attachment, GetChatDirectory(sourceWorkspaceId, chatId), GetChatDirectory(targetWorkspaceId, chatId));
|
|
|
|
/// <summary>Gives an attachment inside a moved directory its path in the target directory.</summary>
|
|
private static FileAttachment RelocateAttachment(FileAttachment attachment, string sourceDirectory, string targetDirectory)
|
|
{
|
|
var movedPath = RelocatePath(attachment.FilePath, sourceDirectory, targetDirectory);
|
|
return movedPath == attachment.FilePath ? attachment : attachment.WithFilePath(movedPath);
|
|
}
|
|
|
|
/// <summary>Gives a path inside a moved directory its place in the target directory, and leaves every other path as it is.</summary>
|
|
private static string RelocatePath(string path, string sourceDirectory, string targetDirectory)
|
|
{
|
|
if (!PathTools.IsInsideDirectory(sourceDirectory, path))
|
|
return path;
|
|
|
|
return Path.Combine(targetDirectory, Path.GetRelativePath(sourceDirectory, path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves the transcripts of a chat which still wait in the staging area into the chat directory.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A transcript waits there when its chat was not stored yet when the transcript was made. The draft
|
|
/// counts as well: leaving a new chat stores it along with the message the user started in it, and
|
|
/// the next start of AI Studio empties the staging area. A transcript of the draft left behind there
|
|
/// would be gone, while the stored draft still pointed at it.
|
|
/// </remarks>
|
|
private static void FinalizeStagedTranscripts(ChatThread chat, string chatDirectory)
|
|
{
|
|
var transcriptDirectory = ManagedTranscriptAttachment.GetTranscriptDirectory(chatDirectory);
|
|
ReconcileTranscriptCounter(chat, transcriptDirectory);
|
|
chat.ReplaceAttachments(attachment => attachment is ManagedTranscriptAttachment { IsStaged: true } staged && File.Exists(staged.FilePath)
|
|
? FinalizeStagedTranscript(chat, staged, transcriptDirectory)
|
|
: attachment);
|
|
}
|
|
|
|
/// <summary>Moves one staged transcript into the transcript directory of its chat.</summary>
|
|
private static ManagedTranscriptAttachment FinalizeStagedTranscript(ChatThread chat, ManagedTranscriptAttachment staged, string transcriptDirectory)
|
|
{
|
|
Directory.CreateDirectory(transcriptDirectory);
|
|
var targetPath = NextTranscriptPath(chat, transcriptDirectory, staged.OriginalFileName);
|
|
|
|
File.Move(staged.FilePath, targetPath);
|
|
var sourceDirectory = Path.GetDirectoryName(staged.FilePath);
|
|
if (sourceDirectory is not null && Directory.Exists(sourceDirectory) && !Directory.EnumerateFileSystemEntries(sourceDirectory).Any())
|
|
Directory.Delete(sourceDirectory);
|
|
|
|
return new ManagedTranscriptAttachment(
|
|
Path.GetFileName(targetPath),
|
|
targetPath,
|
|
new FileInfo(targetPath).Length,
|
|
staged.OriginalFileName,
|
|
false);
|
|
}
|
|
|
|
/// <summary>Raises the persisted counter to the highest transcript suffix found chat-wide.</summary>
|
|
private static void ReconcileTranscriptCounter(ChatThread chat, string transcriptDirectory)
|
|
{
|
|
if (!Directory.Exists(transcriptDirectory))
|
|
return;
|
|
|
|
ulong highest = 0;
|
|
foreach (var path in Directory.EnumerateFiles(transcriptDirectory, "*-transcript-*.md", SearchOption.TopDirectoryOnly))
|
|
{
|
|
var name = Path.GetFileNameWithoutExtension(path);
|
|
var marker = name.LastIndexOf("-transcript-", StringComparison.Ordinal);
|
|
|
|
if (marker >= 0 && ulong.TryParse(name[(marker + "-transcript-".Length)..], out var number))
|
|
highest = Math.Max(highest, number);
|
|
}
|
|
|
|
chat.LastMediaTranscriptNumber = Math.Max(chat.LastMediaTranscriptNumber, highest);
|
|
}
|
|
|
|
/// <summary>Allocates the next globally monotonic transcript path for one chat.</summary>
|
|
private static string NextTranscriptPath(ChatThread chat, string transcriptDirectory, string originalFileName)
|
|
{
|
|
string targetPath;
|
|
do
|
|
{
|
|
chat.LastMediaTranscriptNumber++;
|
|
var stem = ManagedTranscriptAttachment.NormalizeOriginalStem(originalFileName);
|
|
targetPath = Path.Combine(transcriptDirectory, $"{stem}-transcript-{chat.LastMediaTranscriptNumber:D4}.md");
|
|
} while (File.Exists(targetPath));
|
|
|
|
return targetPath;
|
|
}
|
|
|
|
/// <summary>Returns the canonical storage directory for a chat identity.</summary>
|
|
public static string GetChatDirectory(Guid workspaceId, Guid chatId) => workspaceId == Guid.Empty
|
|
? Path.Join(SettingsManager.DataDirectory, "tempChats", chatId.ToString())
|
|
: Path.Join(SettingsManager.DataDirectory, "workspaces", workspaceId.ToString(), chatId.ToString());
|
|
|
|
/// <summary>
|
|
/// Reads when a chat was last written to the disk.
|
|
/// </summary>
|
|
/// <returns>The moment, or null when the chat has no file.</returns>
|
|
public static DateTimeOffset? GetChatWrittenAt(Guid workspaceId, Guid chatId)
|
|
{
|
|
var chatPath = Path.Join(GetChatDirectory(workspaceId, chatId), "thread.json");
|
|
return File.Exists(chatPath) ? new DateTimeOffset(File.GetLastWriteTimeUtc(chatPath)) : null;
|
|
}
|
|
|
|
public static async Task<ChatThread?> LoadChatAsync(LoadChat loadChat)
|
|
{
|
|
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(loadChat.WorkspaceId, loadChat.ChatId, nameof(LoadChatAsync));
|
|
if (!acquired)
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var chatPath = loadChat.WorkspaceId == Guid.Empty
|
|
? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString())
|
|
: Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString());
|
|
|
|
if (!Directory.Exists(chatPath))
|
|
return null;
|
|
|
|
var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8);
|
|
return JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<string> LoadWorkspaceNameAsync(Guid workspaceId)
|
|
{
|
|
if (workspaceId == Guid.Empty)
|
|
return string.Empty;
|
|
|
|
await WORKSPACE_TREE_CACHE_SEMAPHORE.WaitAsync();
|
|
try
|
|
{
|
|
await EnsureTreeShellLoadedCoreAsync();
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var cachedWorkspace) && !string.IsNullOrWhiteSpace(cachedWorkspace.WorkspaceName))
|
|
return cachedWorkspace.WorkspaceName;
|
|
|
|
// Not in cache — read from disk and update cache in the same semaphore scope
|
|
// to avoid a second semaphore acquisition via UpdateWorkspaceNameInCacheAsync:
|
|
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
|
|
var workspaceNamePath = Path.Join(workspacePath, "name");
|
|
string workspaceName;
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(workspaceNamePath))
|
|
{
|
|
workspaceName = TB("Unnamed workspace");
|
|
Directory.CreateDirectory(workspacePath);
|
|
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
|
|
}
|
|
else
|
|
{
|
|
workspaceName = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
|
|
if (string.IsNullOrWhiteSpace(workspaceName))
|
|
{
|
|
workspaceName = TB("Unnamed workspace");
|
|
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
workspaceName = TB("Unnamed workspace");
|
|
}
|
|
|
|
// Update the cache directly (we already hold the semaphore):
|
|
if (WORKSPACE_TREE_CACHE.Workspaces.TryGetValue(workspaceId, out var workspace))
|
|
workspace.WorkspaceName = workspaceName;
|
|
|
|
return workspaceName;
|
|
}
|
|
finally
|
|
{
|
|
WORKSPACE_TREE_CACHE_SEMAPHORE.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Deletes the given chat, asking the user to confirm that beforehand.</summary>
|
|
/// <param name="dialogService">Used to show the confirmation.</param>
|
|
/// <param name="workspaceId">Workspace that owns the chat; an empty id means a temporary chat.</param>
|
|
/// <param name="chatId">Chat to delete.</param>
|
|
/// <param name="askForConfirmation">False skips the question. Only for callers who already asked.</param>
|
|
/// <returns>True when the chat is gone, which includes it never having been there. False when it is still there.</returns>
|
|
/// <remarks>
|
|
/// This is the one place that asks whether a chat may be deleted, because a deleted chat cannot
|
|
/// be restored: there is no trash. Callers who do more than deleting have to honor the return
|
|
/// value, or a declined question would still take the rest of their work with it.
|
|
/// </remarks>
|
|
public static async Task<bool> DeleteChatAsync(IDialogService dialogService, Guid workspaceId, Guid chatId, bool askForConfirmation = true)
|
|
{
|
|
var chat = await LoadChatAsync(new(workspaceId, chatId));
|
|
|
|
// There is nothing left to delete, so the caller may go on:
|
|
if (chat is null)
|
|
return true;
|
|
|
|
if (askForConfirmation)
|
|
{
|
|
var workspaceName = await LoadWorkspaceNameAsync(chat.WorkspaceId);
|
|
var dialogParameters = new DialogParameters<ConfirmDialog>
|
|
{
|
|
{
|
|
x => x.Message, (chat.WorkspaceId == Guid.Empty) switch
|
|
{
|
|
true => string.Format(TB("Are you sure you want to delete the temporary chat '{0}'?"), chat.Name),
|
|
false => string.Format(TB("Are you sure you want to delete the chat '{0}' in the workspace '{1}'?"), chat.Name, workspaceName),
|
|
}
|
|
},
|
|
};
|
|
|
|
var dialogReference = await dialogService.ShowAsync<ConfirmDialog>(TB("Delete Chat"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN);
|
|
var dialogResult = await dialogReference.Result;
|
|
if (dialogResult is null || dialogResult.Canceled)
|
|
return false;
|
|
}
|
|
|
|
return await DeleteChatWithoutAskingAsync(workspaceId, chatId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the given chat without asking, together with what it received.
|
|
/// </summary>
|
|
/// <param name="workspaceId">Workspace that owns the chat; an empty id means a temporary chat.</param>
|
|
/// <param name="chatId">Chat to delete.</param>
|
|
/// <returns>True when the chat is gone, which includes it never having been there. False when another operation holds it.</returns>
|
|
/// <remarks>
|
|
/// The way every chat leaves the disk: the one the user deletes, see DeleteChatAsync, and the
|
|
/// temporary one which the maintenance deletes, see TemporaryChatService. What a chat received,
|
|
/// e.g., the documents and web pages in the provenance store, goes along right away, so that it
|
|
/// does not outlive the chat until the next start. A chat which is still open and gets stored
|
|
/// again afterward comes back without it.
|
|
/// </remarks>
|
|
internal static async Task<bool> DeleteChatWithoutAskingAsync(Guid workspaceId, Guid chatId)
|
|
{
|
|
var chatDirectory = GetChatDirectory(workspaceId, chatId);
|
|
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChatWithoutAskingAsync));
|
|
|
|
// Another operation holds the chat, so it stays where it is:
|
|
if (!acquired)
|
|
return false;
|
|
|
|
try
|
|
{
|
|
if (Directory.Exists(chatDirectory))
|
|
Directory.Delete(chatDirectory, true);
|
|
|
|
await UpdateCacheAfterChatDeleted(workspaceId, chatId);
|
|
}
|
|
finally
|
|
{
|
|
semaphore.Release();
|
|
ForgetChatSemaphore(workspaceId, chatId);
|
|
}
|
|
|
|
// What the chat received goes with it:
|
|
await ChatProvenance.ForgetChatsAsync([chatId]);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the given workspace together with all of its chats, unless one of them is busy.
|
|
/// </summary>
|
|
/// <param name="workspaceId">The workspace to delete.</param>
|
|
/// <param name="isChatBusy">Whether an answer or a transcript is being made for the chat with the given ID.</param>
|
|
/// <returns>True when the workspace is gone, which includes it never having been there. False when a busy chat kept it.</returns>
|
|
/// <remarks>
|
|
/// The work on a busy chat stores the chat once more when it is done. That would create the
|
|
/// directory of the chat anew and the one of the workspace with it, which then comes back as an
|
|
/// unnamed workspace. Deleting all chats but the busy one would be no better: the user asked for
|
|
/// the workspace to go, not for some of its chats. Hence, a busy chat keeps the whole workspace.<br/><br/>
|
|
///
|
|
/// Asking the user beforehand is up to the caller, and so is taking an open chat of the workspace
|
|
/// off the screen.
|
|
/// </remarks>
|
|
public static async Task<bool> DeleteWorkspaceAsync(Guid workspaceId, Func<Guid, bool> isChatBusy)
|
|
{
|
|
var chatIds = ReadChatIdsOfWorkspace(workspaceId);
|
|
if (chatIds.Any(isChatBusy))
|
|
return false;
|
|
|
|
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
|
|
if (Directory.Exists(workspacePath))
|
|
Directory.Delete(workspacePath, true);
|
|
|
|
await RemoveWorkspaceFromCacheAsync(workspaceId);
|
|
foreach (var chatId in chatIds)
|
|
ForgetChatSemaphore(workspaceId, chatId);
|
|
|
|
// What its chats received goes with them:
|
|
await ChatProvenance.ForgetChatsAsync(chatIds);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures that a workspace with the given identity exists on disk and in the cache.
|
|
/// An existing workspace keeps its current name.
|
|
/// </summary>
|
|
public static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
|
|
{
|
|
var workspacePath = Path.Join(WorkspaceRootDirectory, workspaceId.ToString());
|
|
var workspaceNamePath = Path.Join(workspacePath, "name");
|
|
|
|
if (!Path.Exists(workspacePath))
|
|
Directory.CreateDirectory(workspacePath);
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(workspaceNamePath))
|
|
{
|
|
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
|
|
}
|
|
else
|
|
{
|
|
var existing = await File.ReadAllTextAsync(workspaceNamePath, Encoding.UTF8);
|
|
if (string.IsNullOrWhiteSpace(existing))
|
|
await File.WriteAllTextAsync(workspaceNamePath, workspaceName, Encoding.UTF8);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore IO issues to avoid interrupting background initialization.
|
|
}
|
|
|
|
await AddWorkspaceToCacheAsync(workspaceId, workspacePath, workspaceName);
|
|
}
|
|
|
|
public static async Task EnsureBiasWorkspace() => await EnsureWorkspace(KnownWorkspaces.BIAS_WORKSPACE_ID, "Bias of the Day");
|
|
|
|
public static async Task EnsureERIServerWorkspace() => await EnsureWorkspace(KnownWorkspaces.ERI_SERVER_WORKSPACE_ID, "ERI Servers");
|
|
}
|