mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-15 11:28:27 +00:00
Moved the chats of the BeyondFacts Lab to the embedded chat of assistants (#1203)
This commit is contained in:
1 parent
dc97db256e
commit
cb54ad297d
27 files changed
+1023
-743
No files matched your search
@@ -1,15 +1,30 @@
|
||||
@using AIStudio.Chat
|
||||
@using AIStudio.Chat.Embedded
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(this.Heading))
|
||||
{
|
||||
<MudText Typo="Typo.h6" Class="mt-6 mb-2">@this.Heading</MudText>
|
||||
}
|
||||
@if (this.conversation is null)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-3"/>
|
||||
}
|
||||
else if (this.conversation.Thread is not { } thread)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
|
||||
@T("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it.")
|
||||
</MudAlert>
|
||||
@if (this.CanReset)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
|
||||
@TA("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it. You can start the conversation over, which deletes it.")
|
||||
</MudAlert>
|
||||
<MudButton StartIcon="@AppIcons.RESET" Color="Color.Error" OnClick="@this.ResetAsync">@T("Start over")</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined" Class="mb-3">
|
||||
@T("AI Studio cannot open this conversation. Its file may be damaged, or a newer version of AI Studio stored it.")
|
||||
</MudAlert>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -18,6 +33,15 @@ else
|
||||
<MudText Typo="Typo.body2" Class="mb-3">@this.Introduction</MudText>
|
||||
}
|
||||
|
||||
@if (!this.IsReadOnly && EmbeddedChatOpening.OffersStart(this.StartRequest, this.conversation))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@AppIcons.CHAT" OnClick="@this.StartAsync" Disabled="@(!this.CanSend)">
|
||||
@T("Start the conversation")
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var block in thread.Blocks)
|
||||
{
|
||||
@if (block is { HideFromUser: false, Content: not null })
|
||||
@@ -50,7 +74,7 @@ else
|
||||
OnKeyUp="@this.InputKeyEventAsync"
|
||||
UserAttributes="@this.inputAttributes"
|
||||
HelperText="@this.TokenText"
|
||||
Class="mt-2"
|
||||
Class="@this.InputClass"
|
||||
Disabled="@(!this.CanSend)"/>
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mt-2">
|
||||
@@ -62,6 +86,10 @@ else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Error" StartIcon="@AppIcons.STOP" OnClick="@this.CancelAsync">@T("Stop")</MudButton>
|
||||
}
|
||||
@if (this.CanReset && this.conversation.HasVisibleMessages)
|
||||
{
|
||||
<MudButton StartIcon="@AppIcons.RESET" OnClick="@this.ResetAsync" Disabled="@this.IsRunning">@T("Start over")</MudButton>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Chat.Embedded;
|
||||
using AIStudio.Dialogs;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Rust;
|
||||
@@ -8,6 +9,8 @@ using AIStudio.Tools.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
@@ -18,7 +21,8 @@ namespace AIStudio.Components;
|
||||
/// The chat itself lives in EmbeddedChatService, so an answer goes on while the user looks elsewhere; this component
|
||||
/// shows it and renders whenever the service says it changed. What a request carries beside the conversation, such as
|
||||
/// the system prompt and the tools, the assistant decides through PrepareAsync. The count of tokens leaves out the
|
||||
/// context window of the model on purpose: it would tell models apart which the user compares without knowing them.
|
||||
/// context window of the model by default: it would tell models apart which the user compares without knowing them.
|
||||
/// An assistant whose requests carry long instructions shows it, see ShowsContextWindow.
|
||||
/// </remarks>
|
||||
public partial class EmbeddedChat : MSGComponentBase
|
||||
{
|
||||
@@ -56,7 +60,7 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
/// Gets or sets how a request is readied before the message goes in, see EmbeddedChatRequest.PrepareAsync.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<ChatThread, Task<bool>> PrepareAsync { get; set; } = _ => Task.FromResult(true);
|
||||
public Func<ChatThread, Task<EmbeddedChatPreparation>> PrepareAsync { get; set; } = _ => Task.FromResult(EmbeddedChatPreparation.READY);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether messages to the user leave out which provider and which model answer.
|
||||
@@ -70,6 +74,47 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
[Parameter]
|
||||
public bool IsReadOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the user may start the conversation over, which deletes it. A chat which this version cannot
|
||||
/// open then offers that as well.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool AllowsReset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the question before the conversation starts over, e.g., which names what stays as it is; a general
|
||||
/// question when left out.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? ResetQuestion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the count of tokens shows how much of the context window of the model the conversation
|
||||
/// fills: it warns when most of it is used, and the chat sends no more when the next message and its answer would
|
||||
/// not fit anymore, see EmbeddedChatTokenBudget. Leave it out where the user must not tell the models apart.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool ShowsContextWindow { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how the count of tokens gets the system prompt of the next request, for an assistant which puts it
|
||||
/// together anew for every request in PrepareAsync; null as its result means it cannot be known now, and the count
|
||||
/// stays unknown. Without it, the count takes the system prompt the chat stores, which is the one of its last
|
||||
/// request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It must not fail: whatever goes wrong, sending reports anyway.
|
||||
/// </remarks>
|
||||
[Parameter]
|
||||
public Func<Task<string?>>? SystemPromptForCountAsync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a number which changes whenever what the next request carries beside the conversation changed,
|
||||
/// e.g., material of the assistant in its system prompt; the chat counts its tokens again then.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public int ContextVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether the user may attach files to a message.
|
||||
/// </summary>
|
||||
@@ -94,6 +139,20 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
[Parameter]
|
||||
public ContentText? OpeningMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a request which lets the model open the conversation, e.g., by asking for what it needs. While the
|
||||
/// chat holds no message the user sees, it offers a button which sends the request; the user does not see the
|
||||
/// request, and may write first instead. Leave OpeningMessage out with it.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? StartRequest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a heading above the chat, or nothing where the place of the chat names it already.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string? Heading { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets why the provider cannot answer, or null when it can.
|
||||
/// </summary>
|
||||
@@ -118,14 +177,25 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
[Inject]
|
||||
private ConversationTokenCounter TokenCounter { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<EmbeddedChat> Logger { get; init; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Ends a count when the component goes away.
|
||||
/// </summary>
|
||||
private readonly CancellationTokenSource countCancellation = new();
|
||||
|
||||
/// <summary>
|
||||
/// The token of countCancellation, taken while the source still lives, see BflChat.
|
||||
/// The token of countCancellation, taken while the source still lives.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A count goes on after an await, when the component may have gone away meanwhile, e.g., because the user moved on
|
||||
/// to another step of an assistant. The source is disposed by then and refuses to hand out its token; the token
|
||||
/// taken before still tells that the count was cancelled.
|
||||
/// </remarks>
|
||||
private CancellationToken countToken;
|
||||
|
||||
/// <summary>
|
||||
@@ -138,6 +208,12 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
/// </summary>
|
||||
private string countedProviderId = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The version of what a request carries beside the conversation which the tokens were counted for, see
|
||||
/// ContextVersion.
|
||||
/// </summary>
|
||||
private int countedContextVersion;
|
||||
|
||||
/// <summary>
|
||||
/// The chat shown, or null while it loads.
|
||||
/// </summary>
|
||||
@@ -170,10 +246,14 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
|
||||
private bool IsRunning => this.conversation?.IsRunning ?? false;
|
||||
|
||||
private bool CanSend => this.conversation is { IsAvailable: true, IsRunning: false } && this.ProviderIssue is null;
|
||||
private EmbeddedChatTokenBudgetState Budget => this.ShowsContextWindow ? EmbeddedChatTokenBudget.Of(this.tokens) : EmbeddedChatTokenBudgetState.UNKNOWN;
|
||||
|
||||
private bool CanSend => this.conversation is { IsAvailable: true, IsRunning: false } && this.ProviderIssue is null && this.Budget is not EmbeddedChatTokenBudgetState.FULL;
|
||||
|
||||
private bool CanSendMessage => this.CanSend && !string.IsNullOrWhiteSpace(this.userInput);
|
||||
|
||||
private bool CanReset => this.AllowsReset && !this.IsReadOnly;
|
||||
|
||||
#region Overrides of MSGComponentBase
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -196,7 +276,7 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
this.conversation = await this.ChatService.GetAsync(this.Store, this.Component, this.ChatId, this.CreateThread);
|
||||
await this.RecountAsync();
|
||||
}
|
||||
else if (this.countedProviderId != this.Provider.Id)
|
||||
else if (this.countedProviderId != this.Provider.Id || this.countedContextVersion != this.ContextVersion)
|
||||
await this.RecountAsync();
|
||||
|
||||
// Not awaited: the answer arrives through ConversationChanged, and the parameters of the next render must not wait for it.
|
||||
@@ -239,12 +319,14 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
/// Counts what the conversation carries so far, while no answer runs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The system prompt counts as the chat stores it: what PrepareAsync adds for a request is not known before. The
|
||||
/// count is therefore an estimate, and the message being written is left out.
|
||||
/// The system prompt counts as SystemPromptForCountAsync gives it, otherwise as the chat stores it: what
|
||||
/// PrepareAsync adds for a request is not known before then. The count is therefore an estimate, and the message
|
||||
/// being written is left out.
|
||||
/// </remarks>
|
||||
private async Task RecountAsync()
|
||||
{
|
||||
this.countedProviderId = this.Provider.Id;
|
||||
this.countedContextVersion = this.ContextVersion;
|
||||
if (this.IsReadOnly || this.conversation is not { Thread: { } thread, IsRunning: false } || this.ProviderIssue is not null || this.Provider.UsedLLMProvider is LLMProviders.NONE)
|
||||
{
|
||||
this.tokens = ConversationTokens.UNAVAILABLE;
|
||||
@@ -253,7 +335,14 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
|
||||
try
|
||||
{
|
||||
var parts = ConversationParts.Of(thread, thread.SystemPrompt, string.Empty, null, false, null);
|
||||
var systemPrompt = this.SystemPromptForCountAsync is { } systemPromptForCountAsync ? await systemPromptForCountAsync() : thread.SystemPrompt;
|
||||
if (systemPrompt is null)
|
||||
{
|
||||
this.tokens = ConversationTokens.UNAVAILABLE;
|
||||
return;
|
||||
}
|
||||
|
||||
var parts = ConversationParts.Of(thread, systemPrompt, string.Empty, null, false, null);
|
||||
this.tokens = await this.TokenCounter.CountAsync(this.Provider, parts, thread.ReportedHistoryFor(this.Provider.Model), this.countToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -263,11 +352,58 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets about how many tokens the conversation carries, or null when nothing could be counted.
|
||||
/// Gets about how many tokens the conversation carries, with how full the context of the model is when the chat shows
|
||||
/// it, or null when nothing could be counted.
|
||||
/// </summary>
|
||||
private string? TokenText => this.tokens.IsKnown
|
||||
? string.Format(T("approx. {0} tokens"), TokenAmount.Format(this.tokens.Tokens, Tools.PluginSystem.I18N.I.Culture))
|
||||
: null;
|
||||
private string? TokenText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!this.tokens.IsKnown)
|
||||
return null;
|
||||
|
||||
var culture = Tools.PluginSystem.I18N.I.Culture;
|
||||
var counted = TokenAmount.Format(this.tokens.Tokens, culture);
|
||||
if (!this.ShowsContextWindow)
|
||||
return string.Format(T("approx. {0} tokens"), counted);
|
||||
|
||||
if (!this.tokens.Window.IsKnown)
|
||||
return string.Format(T("approx. {0} tokens; the context window of this model is unknown"), counted);
|
||||
|
||||
var window = TokenAmount.Format(this.tokens.Window.DefaultTokens, culture);
|
||||
var budget = string.Format(this.tokens.IsEstimate && !this.tokens.HistoryIsReported ? T("approx. {0} of {1} tokens") : T("{0} of {1} tokens"), counted, window);
|
||||
return this.Budget switch
|
||||
{
|
||||
EmbeddedChatTokenBudgetState.FULL => $"{budget}. {this.FullContextText}",
|
||||
EmbeddedChatTokenBudgetState.WARNING => $"{budget}. {this.NearlyFullContextText}",
|
||||
_ => budget
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets what the user can do once the conversation fills the context of the model.
|
||||
/// </summary>
|
||||
private string FullContextText => this.CanReset
|
||||
? TA("This conversation fills the context window of the model. Start it over, or choose a provider whose model reads more at once.")
|
||||
: TA("This conversation fills the context window of the model. Choose a provider whose model reads more at once.");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the warning once the conversation fills most of the context of the model.
|
||||
/// </summary>
|
||||
private string NearlyFullContextText => this.CanReset
|
||||
? TA("This conversation fills most of the context window of the model. Soon, you will have to start it over.")
|
||||
: T("This conversation fills most of the context window of the model.");
|
||||
|
||||
/// <summary>
|
||||
/// Gets the classes of the message field; like on the chat page, they colour its helper text with the tokens.
|
||||
/// </summary>
|
||||
private string InputClass => this.Budget switch
|
||||
{
|
||||
EmbeddedChatTokenBudgetState.FULL => "mt-2 token-budget-exceeded",
|
||||
EmbeddedChatTokenBudgetState.WARNING => "mt-2 token-budget-nearly-full",
|
||||
_ => "mt-2"
|
||||
};
|
||||
|
||||
private Task AttachmentsChanged(HashSet<FileAttachment> changedAttachments)
|
||||
{
|
||||
@@ -289,6 +425,15 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
await this.ChatService.SendAsync(this.ChatId, this.RequestOf(message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets the model open the conversation, see StartRequest.
|
||||
/// </summary>
|
||||
private async Task StartAsync()
|
||||
{
|
||||
if (this.CanSend && !string.IsNullOrWhiteSpace(this.StartRequest))
|
||||
await this.ChatService.SendAsync(this.ChatId, this.RequestOf(new ContentText { Text = this.StartRequest }) with { IsHidden = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the opening message. When it cannot be sent, e.g., because the provider may not answer, the message waits
|
||||
/// in the field instead, so that the user can send it once the reason is gone.
|
||||
@@ -333,4 +478,30 @@ public partial class EmbeddedChat : MSGComponentBase
|
||||
/// Stops the answer the model writes.
|
||||
/// </summary>
|
||||
private Task CancelAsync() => this.ChatService.CancelAsync(this.ChatId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the conversation after asking, so that it starts over.
|
||||
/// </summary>
|
||||
private async Task ResetAsync()
|
||||
{
|
||||
var parameters = new DialogParameters<ConfirmDialog>
|
||||
{
|
||||
{ dialog => dialog.Message, this.ResetQuestion ?? TA("Delete this conversation and start it over?") }
|
||||
};
|
||||
|
||||
var reference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Start over"), parameters, DialogOptions.FULLSCREEN);
|
||||
var result = await reference.Result;
|
||||
if (result is null || result.Canceled)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await this.ChatService.ResetAsync(this.ChatId, this.CreateThread);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
this.Logger.LogError(exception, "Could not delete the embedded chat '{ChatId}'.", this.ChatId);
|
||||
await this.MessageBus.SendError(new(AppIcons.DELETE, TA("AI Studio could not delete this conversation. Please try again.")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user