mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-13 12:33:48 +00:00
Merge branch 'main' into improve_chat_message_export
This commit is contained in:
37 files changed
+2503
-176
No files matched your search
@@ -28,6 +28,18 @@ public sealed class CircuitStateService
|
||||
/// </summary>
|
||||
public string CircuitId { get; private set; } = "n/a";
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the browser connection returned after it was lost.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It does not occur for the first connection of a circuit, only for the ones which follow a loss. Use it
|
||||
/// to fetch again what the browser reports on its own: Blazor drops such reports while the connection is
|
||||
/// down, and nothing sends them a second time. The event is raised while Blazor is still completing the
|
||||
/// reconnection, though. A handler must not wait for JavaScript interop, because the browser's answer can
|
||||
/// only be processed once the reconnection has finished. Start such work without awaiting it instead.
|
||||
/// </remarks>
|
||||
public event Action? ConnectionRestored;
|
||||
|
||||
/// <summary>
|
||||
/// Called by the circuit handler when the circuit was opened.
|
||||
/// </summary>
|
||||
@@ -37,7 +49,17 @@ public sealed class CircuitStateService
|
||||
/// <summary>
|
||||
/// Called by the circuit handler when the browser connection was established or restored.
|
||||
/// </summary>
|
||||
public void MarkAsConnected() => this.isConnected = true;
|
||||
/// <remarks>
|
||||
/// A restored connection raises ConnectionRestored. Blazor never runs the handler's events of one circuit
|
||||
/// concurrently, so reading and writing the state in two steps is safe here.
|
||||
/// </remarks>
|
||||
public void MarkAsConnected()
|
||||
{
|
||||
var wasConnected = this.isConnected;
|
||||
this.isConnected = true;
|
||||
if (!wasConnected)
|
||||
this.ConnectionRestored?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the circuit handler when the browser connection was lost or the circuit ended.
|
||||
|
||||
@@ -45,7 +45,7 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
|
||||
profile = Profile.NO_PROFILE;
|
||||
}
|
||||
|
||||
var dataSourceOptionsResult = await this.ResolveDataSourceOptionsAsync(providerResult.Provider, launchConfiguration.DataSourceIds);
|
||||
var dataSourceOptionsResult = await this.ResolveDataSourceOptionsAsync(assistantPlugin, providerResult.Provider, chatTemplate, launchConfiguration.DataSourceIds);
|
||||
var dataSourceOptions = dataSourceOptionsResult.Options;
|
||||
if (dataSourceOptions is null)
|
||||
return new(null, dataSourceOptionsResult.ErrorMessage);
|
||||
@@ -75,15 +75,21 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
|
||||
}
|
||||
}
|
||||
|
||||
var toolChoice = ChatTemplate.ChooseToolIds(chatTemplate, launchConfiguration.ToolIds);
|
||||
if (toolChoice.LauncherChoiceDropped)
|
||||
logger.LogWarning(
|
||||
"Assistant plugin '{PluginName}' selects the tools '{LauncherToolIds}', but its chat template '{ChatTemplateName}' names tools of its own. The chat starts with the tools of that template.",
|
||||
assistantPlugin.Name, string.Join(", ", launchConfiguration.ToolIds!), chatTemplate.GetSafeName());
|
||||
|
||||
//
|
||||
// Only the tools the user could have switched on themselves. A launcher may name one whose
|
||||
// Only the tools the user could have switched on themselves. Either side may name one whose
|
||||
// settings are incomplete — an unconfigured web search, say — and starting the chat with it
|
||||
// enabled would show a state the user cannot produce by hand and cannot fix from the chat.
|
||||
// Null keeps the chat's own defaults, which is what a launcher without tools wants.
|
||||
//
|
||||
var selectedToolIds = launchConfiguration.ToolIds is null
|
||||
var selectedToolIds = toolChoice.ToolIds is null
|
||||
? null
|
||||
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, launchConfiguration.ToolIds);
|
||||
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, toolChoice.ToolIds);
|
||||
|
||||
var chatThread = new ChatThread
|
||||
{
|
||||
@@ -101,7 +107,12 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
|
||||
Blocks = chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : chatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
|
||||
};
|
||||
|
||||
return new(new(chatThread, ApplySelectedChatTemplateToComposer: true, PreserveDataSourceOptions: launchConfiguration.DataSourceIds is not null), string.Empty);
|
||||
//
|
||||
// Whoever decided these options — the chat template or the launcher — decided them for this
|
||||
// chat. Without saying so, the chat page would replace them with the chat defaults again:
|
||||
//
|
||||
var dataSourcesWereChosen = chatTemplate.DataSourceOptions is not null || launchConfiguration.DataSourceIds is not null;
|
||||
return new(new(chatThread, ApplySelectedChatTemplateToComposer: true, PreserveDataSourceOptions: dataSourcesWereChosen), string.Empty);
|
||||
}
|
||||
|
||||
private (ProviderSettings Provider, bool IsExplicit, string ErrorMessage) ResolveProvider(Guid? providerId)
|
||||
@@ -167,64 +178,119 @@ public sealed class DirectChatService(SettingsManager settingsManager, DataSourc
|
||||
: new(chatTemplate, string.Empty);
|
||||
}
|
||||
|
||||
private async Task<(DataSourceOptions? Options, string ErrorMessage)> ResolveDataSourceOptionsAsync(ProviderSettings provider, IReadOnlyList<Guid>? dataSourceIds)
|
||||
private async Task<(DataSourceOptions? Options, string ErrorMessage)> ResolveDataSourceOptionsAsync(PluginAssistants assistantPlugin, ProviderSettings provider, ChatTemplate chatTemplate, IReadOnlyList<Guid>? launcherDataSourceIds)
|
||||
{
|
||||
if (dataSourceIds is null)
|
||||
//
|
||||
// The launcher names data sources as plain IDs, and the options around them are always the
|
||||
// same ones. Building them here turns its choice into the same kind of thing the chat
|
||||
// template carries, which is what lets one rule decide between the two.
|
||||
//
|
||||
DataSourceOptions? launcherOptions = null;
|
||||
if (launcherDataSourceIds is not null)
|
||||
{
|
||||
var standardOptions = settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions;
|
||||
launcherOptions = new DataSourceOptions
|
||||
{
|
||||
DisableDataSources = false,
|
||||
AutomaticDataSourceSelection = false,
|
||||
AutomaticValidation = standardOptions.AutomaticValidation,
|
||||
PreselectedDataSourceIds = launcherDataSourceIds.Select(dataSourceId => dataSourceId.ToString()).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
var optionsChoice = ChatTemplate.ChooseDataSourceOptions(chatTemplate, launcherOptions);
|
||||
if (optionsChoice.LauncherChoiceDropped)
|
||||
logger.LogWarning(
|
||||
"Assistant plugin '{PluginName}' selects the data sources '{LauncherDataSourceIds}', but its chat template '{ChatTemplateName}' brings data source options of its own. The chat starts with the data sources of that template.",
|
||||
assistantPlugin.Name, string.Join(", ", launcherDataSourceIds!), chatTemplate.GetSafeName());
|
||||
|
||||
// Neither side says anything, so the chat starts the way it would start on its own:
|
||||
if (optionsChoice.Options is not { } chosenOptions)
|
||||
return new(settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(), string.Empty);
|
||||
|
||||
return await this.CheckChosenDataSourcesAsync(provider, chosenOptions, chatTemplate.DataSourceOptions is null ? null : chatTemplate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the chosen data sources exist and may be used with the provider of the chat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Opening a launcher is one click, so a source which is gone or not permitted has to be said
|
||||
/// out loud instead of being dropped quietly: nobody would see what the chat is missing. Which
|
||||
/// of the two sides chose the sources changes nothing but the wording — and that wording is the
|
||||
/// only place where the user learns which of them to go and fix.
|
||||
/// </remarks>
|
||||
/// <param name="provider">The provider the launched chat runs with.</param>
|
||||
/// <param name="chosenOptions">The options the chat is about to start with.</param>
|
||||
/// <param name="originChatTemplate">The chat template the options came from, or null when the launcher named the sources itself.</param>
|
||||
/// <returns>The checked options, or null and a message saying why no chat was created.</returns>
|
||||
private async Task<(DataSourceOptions? Options, string ErrorMessage)> CheckChosenDataSourcesAsync(ProviderSettings provider, DataSourceOptions chosenOptions, ChatTemplate? originChatTemplate)
|
||||
{
|
||||
//
|
||||
// There is nothing to check when data sources are switched off, and nothing to check either
|
||||
// when an agent picks them: that choice is made per message in the chat, exactly as it is
|
||||
// for a chat template the user picks by hand.
|
||||
//
|
||||
if (chosenOptions.DisableDataSources || chosenOptions.AutomaticDataSourceSelection || chosenOptions.PreselectedDataSourceIds.Count == 0)
|
||||
return new(chosenOptions, string.Empty);
|
||||
|
||||
//
|
||||
// Deciding which data sources are permitted needs an effective provider. Without one,
|
||||
// the check below would report every requested source as unavailable, which would hide
|
||||
// the actual cause from the user:
|
||||
//
|
||||
if (provider == ProviderSettings.NONE)
|
||||
return new(null, TB("The assistant chat launcher selects data sources, but no provider is available for chats. Please choose a default provider for chats first. No chat was created."));
|
||||
return new(null, originChatTemplate is null
|
||||
? TB("The assistant chat launcher selects data sources, but no provider is available for chats. Please choose a default provider for chats first. No chat was created.")
|
||||
: string.Format(TB("The chat template '{0}' selects data sources, but no provider is available for chats. Please choose a default provider for chats first. No chat was created."), originChatTemplate.GetSafeName()));
|
||||
|
||||
var requestedDataSources = new List<IDataSource>(dataSourceIds.Count);
|
||||
foreach (var dataSourceId in dataSourceIds)
|
||||
var requestedDataSources = new List<IDataSource>(chosenOptions.PreselectedDataSourceIds.Count);
|
||||
foreach (var dataSourceId in chosenOptions.PreselectedDataSourceIds)
|
||||
{
|
||||
// Data sources have no lookup helper in the settings manager, so we match their ids
|
||||
// the same way the rest of the app does:
|
||||
var dataSourceIdText = dataSourceId.ToString();
|
||||
var dataSource = settingsManager.ConfigurationData.DataSources.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Id, dataSourceIdText, StringComparison.OrdinalIgnoreCase));
|
||||
string.Equals(candidate.Id, dataSourceId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (dataSource is null)
|
||||
return new(null, string.Format(TB("The assistant chat launcher references data source '{0}', but that data source does not exist."), dataSourceId));
|
||||
return new(null, originChatTemplate is null
|
||||
? string.Format(TB("The assistant chat launcher references data source '{0}', but that data source does not exist."), dataSourceId)
|
||||
: string.Format(TB("The chat template '{0}' references data source '{1}', but that data source does not exist."), originChatTemplate.GetSafeName(), dataSourceId));
|
||||
|
||||
requestedDataSources.Add(dataSource);
|
||||
}
|
||||
|
||||
//
|
||||
// The options the launched chat will run under. We build them here already, because the
|
||||
// data-source check depends on them: they decide which agent providers take part, and an
|
||||
// agent with too little confidence makes a data source unavailable.
|
||||
// The IDs are written back from the sources they resolved to: one of them may be spelled in
|
||||
// another case than the source itself, and the chat matches its preselection literally.
|
||||
//
|
||||
var standardOptions = settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions;
|
||||
var launchedDataSourceOptions = new DataSourceOptions
|
||||
{
|
||||
DisableDataSources = false,
|
||||
AutomaticDataSourceSelection = false,
|
||||
AutomaticValidation = standardOptions.AutomaticValidation,
|
||||
PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList(),
|
||||
};
|
||||
chosenOptions.PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList();
|
||||
|
||||
IReadOnlyList<IDataSource> availableDataSources;
|
||||
try
|
||||
{
|
||||
availableDataSources = await dataSourceService.GetAllowedDataSources(provider, launchedDataSourceOptions, requestedDataSources);
|
||||
//
|
||||
// The options the launched chat will run under are what this check runs against: they
|
||||
// decide which agent providers take part, and an agent with too little confidence makes
|
||||
// a data source unavailable.
|
||||
//
|
||||
availableDataSources = await dataSourceService.GetAllowedDataSources(provider, chosenOptions, requestedDataSources);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "The data sources configured by an assistant chat launcher could not be checked.");
|
||||
return new(null, TB("The data sources selected by the assistant chat launcher could not be checked. No chat was created."));
|
||||
logger.LogError(exception, "The data sources an assistant chat launcher would start its chat with could not be checked.");
|
||||
return new(null, originChatTemplate is null
|
||||
? TB("The data sources selected by the assistant chat launcher could not be checked. No chat was created.")
|
||||
: string.Format(TB("The data sources selected by the chat template '{0}' could not be checked. No chat was created."), originChatTemplate.GetSafeName()));
|
||||
}
|
||||
|
||||
var availableSelectedIds = availableDataSources.Select(source => source.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var unavailableDataSources = requestedDataSources.Where(source => !availableSelectedIds.Contains(source.Id)).Select(source => source.Name).ToList();
|
||||
if (unavailableDataSources.Count > 0)
|
||||
return new(null, string.Format(TB("The following data sources selected by the assistant chat launcher are currently unavailable or not permitted for the selected provider: {0}"), string.Join(", ", unavailableDataSources)));
|
||||
return new(null, originChatTemplate is null
|
||||
? string.Format(TB("The following data sources selected by the assistant chat launcher are currently unavailable or not permitted for the selected provider: {0}"), string.Join(", ", unavailableDataSources))
|
||||
: string.Format(TB("The following data sources selected by the chat template '{0}' are currently unavailable or not permitted for the selected provider: {1}"), originChatTemplate.GetSafeName(), string.Join(", ", unavailableDataSources)));
|
||||
|
||||
return new(launchedDataSourceOptions, string.Empty);
|
||||
return new(chosenOptions, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -752,11 +752,16 @@ public static class WorkspaceBehaviour
|
||||
return Directory.Exists(chatPath);
|
||||
}
|
||||
|
||||
public static async Task StoreChatAsync(ChatThread chat)
|
||||
/// <summary>
|
||||
/// Stores a chat, unless another operation holds its lock for longer than the semaphore timeout.
|
||||
/// </summary>
|
||||
/// <param name="chat">The chat to store.</param>
|
||||
/// <returns>True when the chat was written; false when the operation was skipped to avoid a race.</returns>
|
||||
public static async Task<bool> StoreChatAsync(ChatThread chat)
|
||||
{
|
||||
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(chat.WorkspaceId, chat.ChatId, nameof(StoreChatAsync));
|
||||
if (!acquired)
|
||||
return;
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -775,6 +780,7 @@ public static class WorkspaceBehaviour
|
||||
|
||||
var lastEditTime = File.GetLastWriteTimeUtc(chatPath);
|
||||
await UpdateCacheAfterChatStored(chat.WorkspaceId, chat.ChatId, chatDirectory, chat.Name, lastEditTime);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -782,6 +788,133 @@ public static class WorkspaceBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a chat into a new chat of the same workspace, including its managed transcript files,
|
||||
/// after asking the user for the name of the copy.
|
||||
/// </summary>
|
||||
/// <param name="dialogService">Used to ask for the name.</param>
|
||||
/// <param name="sourceChat">The chat to copy. Its own files and state stay untouched.</param>
|
||||
/// <returns>The persisted copy. Null when the user canceled the question, in which case nothing was copied.</returns>
|
||||
/// <remarks>
|
||||
/// This is the one place that asks for the name of a copy, so every way of copying a chat
|
||||
/// suggests the same name and words the question the same way.<br/><br/>
|
||||
///
|
||||
/// The copy is written before it is returned, so the caller may open it right away. Runtime-only
|
||||
/// state of the source is not part of the copy: it is rebuilt when the copy gets loaded. When
|
||||
/// the copy cannot be written, nothing of it stays behind and the error reaches the caller.
|
||||
/// </remarks>
|
||||
public static async Task<ChatThread?> CopyChatAsync(IDialogService dialogService, ChatThread sourceChat)
|
||||
{
|
||||
var sourceName = string.IsNullOrWhiteSpace(sourceChat.Name) ? TB("Unnamed chat") : sourceChat.Name;
|
||||
var dialogParameters = new DialogParameters<SingleInputDialog>
|
||||
{
|
||||
{ x => x.Message, string.Format(TB("Please enter a name for the copy of your chat '{0}':"), sourceName) },
|
||||
{ x => x.InputHeaderText, TB("Chat Name") },
|
||||
{ x => x.UserInput, string.Format(TB("Copy of {0}"), sourceName) },
|
||||
{ x => x.ConfirmText, TB("Copy") },
|
||||
{ x => x.ConfirmColor, Color.Info },
|
||||
{ x => x.AllowEmptyInput, false },
|
||||
{ x => x.EmptyInputErrorMessage, TB("Please enter a chat name.") },
|
||||
};
|
||||
|
||||
var dialogReference = await dialogService.ShowAsync<SingleInputDialog>(TB("Copy Chat"), dialogParameters, Dialogs.DialogOptions.FULLSCREEN);
|
||||
var dialogResult = await dialogReference.Result;
|
||||
if (dialogResult is null || dialogResult.Canceled)
|
||||
return null;
|
||||
|
||||
var serializedChat = JsonSerializer.Serialize(sourceChat, JSON_OPTIONS);
|
||||
var copiedChat = JsonSerializer.Deserialize<ChatThread>(serializedChat, JSON_OPTIONS)
|
||||
?? throw new InvalidOperationException("The chat could not be copied.");
|
||||
copiedChat = copiedChat with
|
||||
{
|
||||
ChatId = Guid.NewGuid(),
|
||||
Name = (dialogResult.Data as string)!,
|
||||
};
|
||||
|
||||
var targetDirectory = GetChatDirectory(copiedChat.WorkspaceId, copiedChat.ChatId);
|
||||
try
|
||||
{
|
||||
CopyManagedTranscriptAttachments(copiedChat, targetDirectory);
|
||||
|
||||
//
|
||||
// Storing is skipped instead of failing when the chat lock cannot be taken. For a copy
|
||||
// that must not pass as success: the caller would open a chat which is not on disk,
|
||||
// while the transcript files copied above would stay behind as orphans.
|
||||
//
|
||||
if (!await StoreChatAsync(copiedChat))
|
||||
throw new IOException($"The copied chat could not be stored: '{targetDirectory}'.");
|
||||
|
||||
return copiedChat;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (Directory.Exists(targetDirectory))
|
||||
Directory.Delete(targetDirectory, true);
|
||||
|
||||
InvalidateWorkspaceTreeCache();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyManagedTranscriptAttachments(ChatThread chat, string targetChatDirectory)
|
||||
{
|
||||
var targetTranscriptDirectory = Path.Combine(targetChatDirectory, "attachments", "transcripts");
|
||||
var pathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
|
||||
var copiedPaths = new Dictionary<string, ManagedTranscriptAttachment>(pathComparer);
|
||||
|
||||
foreach (var content in chat.Blocks.Select(block => block.Content).OfType<ContentText>())
|
||||
{
|
||||
for (var index = 0; index < content.FileAttachments.Count; index++)
|
||||
{
|
||||
if (content.FileAttachments[index] is ManagedTranscriptAttachment transcript)
|
||||
content.FileAttachments[index] = CopyManagedTranscriptAttachment(chat, transcript, targetTranscriptDirectory, copiedPaths);
|
||||
}
|
||||
}
|
||||
|
||||
for (var index = 0; index < chat.PendingMediaTranscripts.Count; index++)
|
||||
chat.PendingMediaTranscripts[index] = CopyManagedTranscriptAttachment(chat, chat.PendingMediaTranscripts[index], targetTranscriptDirectory, copiedPaths);
|
||||
}
|
||||
|
||||
private static ManagedTranscriptAttachment CopyManagedTranscriptAttachment(
|
||||
ChatThread chat,
|
||||
ManagedTranscriptAttachment source,
|
||||
string targetTranscriptDirectory,
|
||||
Dictionary<string, ManagedTranscriptAttachment> copiedPaths)
|
||||
{
|
||||
//
|
||||
// A thread which was edited outside the app may name a path which is not a path at all.
|
||||
// Such an attachment keeps pointing at whatever the source named: it is already broken in
|
||||
// the source chat, and letting it take the whole copy down would be worse.
|
||||
//
|
||||
string sourcePath;
|
||||
try
|
||||
{
|
||||
sourcePath = Path.GetFullPath(source.FilePath);
|
||||
}
|
||||
catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
LOG.LogWarning(e, "Could not resolve the transcript path '{FilePath}' while copying chat '{ChatId}'. The attachment is kept as it is.", source.FilePath, chat.ChatId);
|
||||
return source;
|
||||
}
|
||||
|
||||
if (copiedPaths.TryGetValue(sourcePath, out var existingCopy))
|
||||
return existingCopy;
|
||||
|
||||
Directory.CreateDirectory(targetTranscriptDirectory);
|
||||
var targetPath = NextTranscriptPath(chat, targetTranscriptDirectory, source.OriginalFileName);
|
||||
if (File.Exists(sourcePath))
|
||||
File.Copy(sourcePath, targetPath);
|
||||
|
||||
var copiedAttachment = new ManagedTranscriptAttachment(
|
||||
Path.GetFileName(targetPath),
|
||||
targetPath,
|
||||
File.Exists(targetPath) ? new FileInfo(targetPath).Length : source.FileSizeBytes,
|
||||
source.OriginalFileName,
|
||||
false);
|
||||
copiedPaths[sourcePath] = copiedAttachment;
|
||||
return copiedAttachment;
|
||||
}
|
||||
|
||||
/// <summary>Creates a transcript atomically inside an already persisted chat.</summary>
|
||||
/// <param name="chat">Persisted chat that owns the transcript counter.</param>
|
||||
/// <param name="originalPath">Original media path.</param>
|
||||
@@ -1082,11 +1215,24 @@ public static class WorkspaceBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task DeleteChatAsync(IDialogService dialogService, Guid workspaceId, Guid chatId, bool askForConfirmation = true)
|
||||
/// <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;
|
||||
return true;
|
||||
|
||||
if (askForConfirmation)
|
||||
{
|
||||
@@ -1105,7 +1251,7 @@ public static class WorkspaceBehaviour
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
||||
var chatDirectory = chat.WorkspaceId == Guid.Empty
|
||||
@@ -1113,8 +1259,10 @@ public static class WorkspaceBehaviour
|
||||
: Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
|
||||
|
||||
var (acquired, semaphore) = await TryAcquireChatSemaphoreAsync(workspaceId, chatId, nameof(DeleteChatAsync));
|
||||
|
||||
// Another operation holds the chat, so it stays where it is:
|
||||
if (!acquired)
|
||||
return;
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1128,6 +1276,8 @@ public static class WorkspaceBehaviour
|
||||
semaphore.Release();
|
||||
ForgetChatSemaphore(workspaceId, chatId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)
|
||||
|
||||
Reference in new issue
Block a user