AI-Studio/app/MindWork AI Studio/Tools/Services/DirectChatService.cs

296 lines
17 KiB
C#

using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using AIStudio.Tools.ToolCallingSystem;
using ProviderSettings = AIStudio.Settings.Provider;
namespace AIStudio.Tools.Services;
public sealed class DirectChatService(SettingsManager settingsManager, DataSourceService dataSourceService, ToolRegistry toolRegistry, ILogger<DirectChatService> logger)
{
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(DirectChatService).Namespace, nameof(DirectChatService));
public async Task<DirectChatStartResult> TryCreateAssistantChatAsync(PluginAssistants assistantPlugin)
{
if (assistantPlugin.ChatLaunchConfiguration is not { } launchConfiguration)
return new(null, TB("The assistant plugin does not contain a valid chat launch configuration."));
var providerResult = this.ResolveProvider(launchConfiguration.ProviderId);
if (providerResult.IsExplicit && providerResult.Provider == ProviderSettings.NONE)
return new(null, providerResult.ErrorMessage);
var profileResult = this.ResolveProfile(launchConfiguration.ProfileId);
var profile = profileResult.Profile;
if (profile is null)
return new(null, profileResult.ErrorMessage);
var chatTemplateResult = this.ResolveChatTemplate(launchConfiguration.ChatTemplateId);
var chatTemplate = chatTemplateResult.ChatTemplate;
if (chatTemplate is null)
return new(null, chatTemplateResult.ErrorMessage);
//
// A chat template that forbids profiles wins over a configured profile: the chat disables
// its profile selection for such templates, so keeping the profile would pin one that the
// user can neither see nor change. We drop it instead of failing the whole launch.
//
if (!chatTemplate.AllowProfileUsage && profile != Profile.NO_PROFILE)
{
logger.LogWarning(
"Assistant plugin '{PluginName}' selects the profile '{ProfileName}', but its chat template '{ChatTemplateName}' does not allow profiles. The chat starts without a profile.",
assistantPlugin.Name, profile.GetSafeName(), chatTemplate.GetSafeName());
profile = Profile.NO_PROFILE;
}
var dataSourceOptionsResult = await this.ResolveDataSourceOptionsAsync(assistantPlugin, providerResult.Provider, chatTemplate, launchConfiguration.DataSourceIds);
var dataSourceOptions = dataSourceOptionsResult.Options;
if (dataSourceOptions is null)
return new(null, dataSourceOptionsResult.ErrorMessage);
//
// A launcher that names no workspace wants the same chat the chat page starts on its own:
// one that belongs nowhere, is kept among the temporary chats, and disappears with them. The
// empty workspace ID is what says so, here as everywhere else in the app.
//
var workspaceId = Guid.Empty;
if (!launchConfiguration.OpensTemporaryChat)
{
try
{
workspaceId = await WorkspaceBehaviour.ResolveOrCreateWorkspaceIdByNameAsync(launchConfiguration.WorkspaceName);
}
catch (Exception exception)
{
logger.LogError(exception, "Assistant plugin '{PluginName}' could not resolve or create workspace '{WorkspaceName}'.", assistantPlugin.Name, launchConfiguration.WorkspaceName);
return new(null, string.Format(TB("The workspace '{0}' could not be opened or created."), launchConfiguration.WorkspaceName));
}
if (workspaceId == Guid.Empty)
{
logger.LogWarning("Assistant plugin '{PluginName}' could not resolve or create workspace '{WorkspaceName}'.", assistantPlugin.Name, launchConfiguration.WorkspaceName);
return new(null, string.Format(TB("The workspace '{0}' could not be opened or created."), launchConfiguration.WorkspaceName));
}
}
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. 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 = toolChoice.ToolIds is null
? null
: await toolRegistry.FilterSelectableToolIdsAsync(Components.CHAT, toolChoice.ToolIds);
var chatThread = new ChatThread
{
IncludeDateTime = true,
SelectedProvider = providerResult.Provider == ProviderSettings.NONE ? string.Empty : providerResult.Provider.Id,
SelectedProfile = profile.Id,
SelectedChatTemplate = chatTemplate.Id,
// The provider confidence is checked later, when the chat sends a message:
SelectedToolIds = selectedToolIds,
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = workspaceId,
ChatId = Guid.NewGuid(),
Name = assistantPlugin.AssistantTitle,
DataSourceOptions = dataSourceOptions,
Blocks = chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE ? [] : chatTemplate.ExampleConversation.Select(block => block.DeepClone()).ToList(),
};
//
// 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)
{
//
// The launcher does not name a provider, so it wants the chat defaults. We resolve them
// exactly like the chat does when it loads a chat without a provider. When no default can
// be determined, we do not fail: the chat opens with an empty provider selection and the
// user picks a provider there, just like for any other new chat.
//
if (providerId is null)
return new(settingsManager.GetChatProviderForLoadedChat(), false, string.Empty);
//
// GetProviderById does not apply any confidence filtering, so we check the provider
// ourselves afterwards, exactly as its documentation demands:
//
var provider = settingsManager.GetProviderById(providerId.Value.ToString());
if (provider == ProviderSettings.NONE)
return new(ProviderSettings.NONE, true, string.Format(TB("The assistant chat launcher references provider '{0}', but that provider does not exist."), providerId));
if (!settingsManager.IsProviderConfident(provider, Components.CHAT))
return new(ProviderSettings.NONE, true, string.Format(TB("The provider '{0}' selected by the assistant chat launcher is not permitted for chats at the required confidence level."), provider.InstanceName));
return new(provider, true, string.Empty);
}
private (Profile? Profile, string ErrorMessage) ResolveProfile(Guid? profileId)
{
if (profileId is null)
return new(settingsManager.GetPreselectedProfile(Components.CHAT), string.Empty);
// The launcher explicitly wants no profile:
if (profileId == Guid.Empty)
return new(Profile.NO_PROFILE, string.Empty);
//
// We already handled the empty GUID above, so GetProfileById returning the no-profile
// entry here can only mean that the referenced profile is gone:
//
var profile = settingsManager.GetProfileById(profileId.Value.ToString());
return profile == Profile.NO_PROFILE
? new(null, string.Format(TB("The assistant chat launcher references profile '{0}', but that profile does not exist."), profileId))
: new(profile, string.Empty);
}
private (ChatTemplate? ChatTemplate, string ErrorMessage) ResolveChatTemplate(Guid? chatTemplateId)
{
if (chatTemplateId is null)
return new(settingsManager.GetPreselectedChatTemplate(Components.CHAT), string.Empty);
// The launcher explicitly wants no chat template:
if (chatTemplateId == Guid.Empty)
return new(ChatTemplate.NO_CHAT_TEMPLATE, string.Empty);
//
// We already handled the empty GUID above, so GetChatTemplateById returning the
// no-template entry here can only mean that the referenced template is gone:
//
var chatTemplate = settingsManager.GetChatTemplateById(chatTemplateId.Value.ToString());
return chatTemplate == ChatTemplate.NO_CHAT_TEMPLATE
? new(null, string.Format(TB("The assistant chat launcher references chat template '{0}', but that template does not exist."), chatTemplateId))
: new(chatTemplate, string.Empty);
}
private async Task<(DataSourceOptions? Options, string ErrorMessage)> ResolveDataSourceOptionsAsync(PluginAssistants assistantPlugin, ProviderSettings provider, ChatTemplate chatTemplate, IReadOnlyList<Guid>? launcherDataSourceIds)
{
//
// 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, 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>(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 dataSource = settingsManager.ConfigurationData.DataSources.FirstOrDefault(candidate =>
string.Equals(candidate.Id, dataSourceId, StringComparison.OrdinalIgnoreCase));
if (dataSource is null)
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 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.
//
chosenOptions.PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList();
IReadOnlyList<IDataSource> availableDataSources;
try
{
//
// 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 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, 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(chosenOptions, string.Empty);
}
}