Added direct-chat launchers for assistant plugins and a dialog to reconfigure them (#935)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Peer HogeterpandThorsten Sommer authored and GitHub committed 2026-08-27 16:19:35 +02:00
1 parent f03c2d1c88
commit a4c35fc2f2
44 files changed
+2615 -219

No files matched your search

@@ -139,16 +139,24 @@ public partial class ChatComponent : MSGComponentBase
// Check for deferred messages of the kind 'SEND_TO_CHAT',
// aka the user sends an assistant result to the chat:
//
var deferredContent = MessageBus.INSTANCE.TakeDeferredMessages<ChatThread>(Event.SEND_TO_CHAT).LastOrDefault();
if (deferredContent is not null)
var deferredRequest = MessageBus.INSTANCE.TakeDeferredMessages<ChatStartRequest>(Event.SEND_TO_CHAT).LastOrDefault();
if (deferredRequest is not null)
{
//
// Yes, the user sent an assistant result to the chat.
//
// Use chat thread sent by the user:
this.ChatThread = deferredContent;
this.ChatThread = deferredRequest.ChatThread;
this.ChatThread.IncludeDateTime = true;
//
// Apply the chat template of the incoming chat to the composer. Like everywhere else,
// a draft the user typed themselves wins: we must not discard it just because someone
// started a preconfigured chat in the meantime.
//
if (deferredRequest.ApplySelectedChatTemplateToComposer && !this.ComposerState.HasUserDraft)
this.ComposerState.ApplyTemplate(this.SettingsManager.GetChatTemplateById(this.ChatThread.SelectedChatTemplate));
this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now.");
this.MarkCurrentChatAsLoadedParameter();
@@ -179,7 +187,8 @@ public partial class ChatComponent : MSGComponentBase
//
// Check if the user wants to apply the standard chat data source options:
//
if (this.SettingsManager.ConfigurationData.Chat.SendToChatDataSourceBehavior is SendToChatDataSourceBehavior.APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS)
if (!deferredRequest.PreserveDataSourceOptions &&
this.SettingsManager.ConfigurationData.Chat.SendToChatDataSourceBehavior is SendToChatDataSourceBehavior.APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS)
this.ChatThread.DataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy();
//
@@ -0,0 +1,44 @@
@inherits MSGComponentBase
@if (this.availableWorkspaces.Count > 0)
{
<MudSelect T="string" Value="@this.WorkspaceName" ValueChanged="@this.SelectExistingWorkspace" Strict="@false" Label="@T("Existing workspace (Optional)")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Folder">
@foreach (var workspace in this.availableWorkspaces)
{
<MudSelectItem T="string" Value="@workspace.Name">@workspace.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" Validation="@this.ValidateWorkspaceName" AdornmentIcon="@Icons.Material.Filled.CreateNewFolder" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Workspace name")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSelect T="string" Value="@this.ProviderId" ValueChanged="@this.SetProviderId" Label="@T("Chat provider")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.SmartToy">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
@foreach (var provider in this.SettingsManager.GetConfidentProviders(Components.CHAT))
{
<MudSelectItem T="string" Value="@provider.Id">
<ProviderLabel ProviderSettings="@provider" Text="@provider.InstanceName"/>
</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Value="@this.ProfileId" ValueChanged="@this.SetProfileId" Label="@T("Chat profile")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Person">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no profile")</MudSelectItem>
@foreach (var profile in this.SettingsManager.ConfigurationData.Profiles)
{
<MudSelectItem T="string" Value="@profile.Id">@profile.GetSafeName()</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Value="@this.ChatTemplateId" ValueChanged="@this.SetChatTemplateId" Label="@T("Chat template")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Article">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no chat template")</MudSelectItem>
@foreach (var chatTemplate in this.SettingsManager.ConfigurationData.ChatTemplates)
{
<MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Source">
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
{
<MudSelectItem T="string" Value="@dataSource.Id">@dataSource.Name</MudSelectItem>
}
</MudSelect>
@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The selection a direct chat launcher needs: the workspace its chat is created in, and the
/// provider, profile, chat template, and data sources that chat starts with.
/// </summary>
/// <remarks>
/// The Assistant Builder uses this form to describe a launcher it is about to generate, while the
/// launcher settings dialog uses it to change an installed launcher. Both keep their own state, so
/// every field is a two-way bound parameter here.
/// </remarks>
public partial class DirectChatLauncherForm : MSGComponentBase
{
/// <summary>
/// The name of the workspace the launcher opens its chat in. The workspace is created when it
/// does not exist yet, hence this is a free-text field and not a workspace ID.
/// </summary>
[Parameter]
public string WorkspaceName { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> WorkspaceNameChanged { get; set; }
/// <summary>
/// The provider ID for the chat, or an empty string to use the chat default.
/// </summary>
[Parameter]
public string ProviderId { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ProviderIdChanged { get; set; }
/// <summary>
/// The profile ID for the chat, an empty GUID for explicitly no profile, or an empty string to
/// use the chat default.
/// </summary>
[Parameter]
public string ProfileId { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ProfileIdChanged { get; set; }
/// <summary>
/// The chat template ID, an empty GUID for explicitly no template, or an empty string to use
/// the chat default.
/// </summary>
[Parameter]
public string ChatTemplateId { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ChatTemplateIdChanged { get; set; }
/// <summary>
/// The data sources the chat starts with. An empty selection keeps the normal chat defaults.
/// </summary>
[Parameter]
public IEnumerable<string> DataSourceIds { get; set; } = [];
[Parameter]
public EventCallback<IEnumerable<string>> DataSourceIdsChanged { get; set; }
/// <summary>
/// Validates the workspace name. The hosts differ here: the Builder requires a name only while
/// its launcher switch is on, whereas the settings dialog always requires one.
/// </summary>
[Parameter]
public Func<string, string?>? ValidateWorkspaceName { get; set; }
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the workspace name input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
await base.OnInitializedAsync();
var workspaceSnapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.availableWorkspaces = workspaceSnapshot.Workspaces;
}
#endregion
//
// Picking an existing workspace fills the name field. Clearing the select must not wipe a name
// the user typed, though, so an empty selection is ignored:
//
private async Task SelectExistingWorkspace(string workspaceName)
{
if (string.IsNullOrWhiteSpace(workspaceName))
return;
await this.SetWorkspaceName(workspaceName);
}
private async Task SetWorkspaceName(string workspaceName)
{
this.WorkspaceName = workspaceName;
await this.WorkspaceNameChanged.InvokeAsync(workspaceName);
}
private async Task SetProviderId(string providerId)
{
this.ProviderId = providerId;
await this.ProviderIdChanged.InvokeAsync(providerId);
}
private async Task SetProfileId(string profileId)
{
this.ProfileId = profileId;
await this.ProfileIdChanged.InvokeAsync(profileId);
}
private async Task SetChatTemplateId(string chatTemplateId)
{
this.ChatTemplateId = chatTemplateId;
await this.ChatTemplateIdChanged.InvokeAsync(chatTemplateId);
}
//
// MudSelect hands out its selection as a lazy sequence of nullable strings. We materialize it
// once and drop empty entries, so the host always receives a stable list of usable IDs:
//
private async Task SetDataSourceIds(IEnumerable<string?>? dataSourceIds)
{
var selectedDataSourceIds = dataSourceIds is null ? [] : dataSourceIds.Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id!).ToArray();
this.DataSourceIds = selectedDataSourceIds;
await this.DataSourceIdsChanged.InvokeAsync(selectedDataSourceIds);
}
private string GetSelectedDataSourceText(List<string?>? selectedValues)
{
if (selectedValues is null || selectedValues.Count == 0)
return T("Use the normal chat data source defaults");
return string.Format(T("{0} data source(s) selected"), selectedValues.Count);
}
}
@@ -0,0 +1,13 @@
@inherits MSGComponentBase
@if (this.CanEditSettings)
{
<MudTooltip Text="@T("Change what this tile opens")">
<MudIconButton Icon="@Icons.Material.Filled.Tune"
Color="Color.Default"
Variant="Variant.Text"
Size="Size.Medium"
Disabled="@this.isEditing"
OnClick="@this.OpenSettingsDialogAsync"/>
</MudTooltip>
}
@@ -0,0 +1,71 @@
using AIStudio.Dialogs;
using AIStudio.Tools.PluginSystem.Assistants;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
/// <summary>
/// Lets users change the chat a direct chat launcher opens, right from its tile.
/// </summary>
/// <remarks>
/// A launcher tile has no assistant page: opening it goes straight to the chat, so the revise
/// action on the dynamic assistant page can never be reached for one. Its tile is therefore the
/// place where users look for its settings.
/// </remarks>
public partial class DirectChatLauncherSettingsAction : MSGComponentBase
{
[Parameter, EditorRequired]
public PluginAssistants Plugin { get; set; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private ILogger<DirectChatLauncherSettingsAction> Logger { get; init; } = null!;
private bool isEditing;
//
// This check reads no files on purpose: it runs on every render of the assistants page. Whether
// the plugin file itself can be rewritten is decided by the dialog, which reads it anyway:
//
private bool CanEditSettings => DirectChatLauncherLuaWriter.CanRewrite(this.Plugin);
private async Task OpenSettingsDialogAsync()
{
if (!this.CanEditSettings || this.isEditing)
return;
this.isEditing = true;
await this.InvokeAsync(this.StateHasChanged);
try
{
var parameters = new DialogParameters<DirectChatLauncherSettingsDialog>
{
{ x => x.PluginId, this.Plugin.Id },
{ x => x.PluginLocalPath, this.Plugin.PluginPath },
};
var dialogReference = await this.DialogService.ShowAsync<DirectChatLauncherSettingsDialog>(this.T("Tile Settings"), parameters, DialogOptions.BLOCKING_FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DirectChatLauncherSettingsDialogResult result)
return;
this.Logger.LogInformation("The chat launcher '{PluginName}' ({PluginId}) has been updated from its tile.", result.PluginName, result.PluginId);
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Save, string.Format(this.T("The tile '{0}' has been updated."), result.PluginName)));
// Saving already ran LoadAll, which announced PLUGINS_RELOADED. We still announce the
// configuration change: with automatic audits enabled, the dialog stored an audit result:
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
finally
{
this.isEditing = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
}