AI-Studio/app/MindWork AI Studio/Tools/Services/DirectChatService.cs
Peer Hogeterp a4c35fc2f2
Added direct-chat launchers for assistant plugins and a dialog to reconfigure them (#935)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
2026-08-27 16:19:35 +02:00

202 lines
11 KiB
C#

using AIStudio.Chat;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.PluginSystem.Assistants;
using ProviderSettings = AIStudio.Settings.Provider;
namespace AIStudio.Tools.Services;
public sealed class DirectChatService(SettingsManager settingsManager, DataSourceService dataSourceService, 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(providerResult.Provider, launchConfiguration.DataSourceIds);
var dataSourceOptions = dataSourceOptionsResult.Options;
if (dataSourceOptions is null)
return new(null, dataSourceOptionsResult.ErrorMessage);
Guid workspaceId;
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 chatThread = new ChatThread
{
IncludeDateTime = true,
SelectedProvider = providerResult.Provider == ProviderSettings.NONE ? string.Empty : providerResult.Provider.Id,
SelectedProfile = profile.Id,
SelectedChatTemplate = chatTemplate.Id,
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(),
};
return new(new(chatThread, ApplySelectedChatTemplateToComposer: true, PreserveDataSourceOptions: launchConfiguration.DataSourceIds is not null), 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(ProviderSettings provider, IReadOnlyList<Guid>? dataSourceIds)
{
if (dataSourceIds is null)
return new(settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy(), 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."));
var requestedDataSources = new List<IDataSource>(dataSourceIds.Count);
foreach (var dataSourceId in dataSourceIds)
{
// 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));
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));
requestedDataSources.Add(dataSource);
}
IReadOnlyList<IDataSource> availableDataSources;
try
{
availableDataSources = await dataSourceService.GetAllowedDataSources(provider, 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."));
}
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)));
var standardOptions = settingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions;
return new(new()
{
DisableDataSources = false,
AutomaticDataSourceSelection = false,
AutomaticValidation = standardOptions.AutomaticValidation,
PreselectedDataSourceIds = requestedDataSources.Select(source => source.Id).ToList(),
}, string.Empty);
}
}