Merge branch 'main' into improve_chat_message_export

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-23 12:21:19 +02:00
commit e6db7aea47
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)