Merge branch 'main' into rollback_chat_copy_chat

This commit is contained in:
Thorsten Sommer committed 2026-09-23 09:29:23 +02:00
commit 7695863201
31 files changed
+1773 -171

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);
}
}
@@ -1192,11 +1192,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)
{
@@ -1215,7 +1228,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
@@ -1223,8 +1236,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
{
@@ -1238,6 +1253,8 @@ public static class WorkspaceBehaviour
semaphore.Release();
ForgetChatSemaphore(workspaceId, chatId);
}
return true;
}
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)