Recompute the token count instead of announcing it

This commit is contained in:
Thorsten Sommer 2026-09-12 18:56:20 +02:00
parent 5ee4685f6f
commit 8468cdc7be
Signed by untrusted user who does not match committer: tsommer
GPG Key ID: 371BBA77A02C0108
9 changed files with 618 additions and 93 deletions

View File

@ -22,6 +22,17 @@ public sealed record ConversationParts
/// </summary> /// </summary>
public IReadOnlyList<string> Texts { get; init; } = []; public IReadOnlyList<string> Texts { get; init; } = [];
/// <summary>
/// The texts which are still being written.
/// </summary>
/// <remarks>
/// They cost exactly what the others cost; what sets them apart is that they will never be seen
/// again in this shape. The sentence somebody is typing changes with the next pause, and an
/// answer being streamed is a different text three seconds later -- so remembering what they
/// cost fills memory with answers nobody will ask for again.
/// </remarks>
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
/// <summary> /// <summary>
/// The documents whose content is put into the request. /// The documents whose content is put into the request.
/// </summary> /// </summary>
@ -52,6 +63,7 @@ public sealed record ConversationParts
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent) public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent)
{ {
var texts = new List<string>(); var texts = new List<string>();
var growing = new List<string>();
var documents = new List<FileAttachment>(); var documents = new List<FileAttachment>();
var images = 0; var images = 0;
@ -70,13 +82,17 @@ public sealed record ConversationParts
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text || string.IsNullOrWhiteSpace(text.Text)) if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text || string.IsNullOrWhiteSpace(text.Text))
continue; continue;
if (text.IsStreaming)
growing.Add(text.Text);
else
texts.Add(text.Text); texts.Add(text.Text);
Sort(text.FileAttachments, documents, ref images); Sort(text.FileAttachments, documents, ref images);
} }
} }
if (!string.IsNullOrWhiteSpace(draft)) if (!string.IsNullOrWhiteSpace(draft))
texts.Add(draft); growing.Add(draft);
if (draftAttachments is not null) if (draftAttachments is not null)
Sort(draftAttachments, documents, ref images); Sort(draftAttachments, documents, ref images);
@ -84,6 +100,7 @@ public sealed record ConversationParts
return new() return new()
{ {
Texts = texts, Texts = texts,
GrowingTexts = growing,
Documents = documents, Documents = documents,
Images = imagesAreSent ? images : 0, Images = imagesAreSent ? images : 0,
}; };

View File

@ -0,0 +1,172 @@
namespace AIStudio.Chat;
/// <summary>
/// Keeps a number up to date which nothing announces.
/// </summary>
/// <remarks>
/// A conversation is a plain list of plain objects. Nothing raises an event when a block is added,
/// when a document is attached, or when an answer grows by another sentence -- so a number derived
/// from all of that cannot be wired to the places which change it. It was tried: fifteen call sites,
/// and four review rounds each found another one which was missing.
///
/// So the number is recomputed instead of notified. Whoever thinks something may have changed nudges
/// this tracker, and the tracker decides when to do the work: many nudges in a row become one run, a
/// nudge arriving during a run becomes exactly one further run, and a minimum distance keeps a burst
/// of them from turning into a burst of counting.
///
/// The heartbeat is not distrust of the nudges. Attachments are read from disk every time they are
/// sent, so a file somebody edits in another program changes what the next message costs without
/// anything happening in AI Studio which anyone could nudge from.
/// </remarks>
/// <param name="recount">Does the actual work. Gets a token which ends it when the tracker goes away.</param>
/// <param name="quietTime">
/// How long to stay quiet after a run before honouring the next nudge. Asked again each time,
/// because what is reasonable depends on what is going on: a person who just switched a profile is
/// waiting for the number, while an answer being written moves it with every word and wants a
/// slower pace than the words arrive at.
/// </param>
/// <param name="heartbeat">How long to wait for a nudge before running anyway.</param>
public sealed class ConversationTokenTracker(Func<CancellationToken, Task> recount, Func<TimeSpan> quietTime, TimeSpan heartbeat) : IAsyncDisposable
{
/// <summary>
/// How long a tracker which is going away waits for its own loop.
/// </summary>
/// <remarks>
/// The loop ends on cancellation, so this is only ever reached when something it called does
/// not. Whoever is leaving the screen must not be the one who waits for that.
/// </remarks>
private static readonly TimeSpan SHUTDOWN_PATIENCE = TimeSpan.FromSeconds(2);
private readonly SemaphoreSlim wakeUp = new(0, 1);
private readonly CancellationTokenSource stopping = new();
private Task? loop;
/// <summary>
/// Starts the loop. Calling this twice does nothing the second time.
/// </summary>
public void Start() => this.loop ??= Task.Run(this.RunAsync);
/// <summary>
/// Says that something may have changed.
/// </summary>
/// <remarks>
/// Cheap on purpose, because it is called from the render path. It says "maybe", never "yes":
/// asking for a run which turns out to change nothing costs a few lookups, while missing one is
/// the bug this whole class exists to make impossible.
/// </remarks>
public void Nudge()
{
//
// One pending wake-up is all a loop can act on. A second one would only make it run again
// with the same answer.
//
if (this.wakeUp.CurrentCount > 0)
return;
try
{
this.wakeUp.Release();
}
catch (SemaphoreFullException)
{
//
// Two threads got past the check above at the same time. The one which won left the
// wake-up we wanted, so there is nothing left to do here.
//
}
catch (ObjectDisposedException)
{
// The tracker is going away, and a number nobody will look at needs no update.
}
}
private async Task RunAsync()
{
var token = this.stopping.Token;
while (!token.IsCancellationRequested)
{
try
{
//
// Sleeps until somebody nudges -- or until the heartbeat is due, which is what the
// timeout returning false means. Both lead to the same run, so the result is not
// even looked at.
//
await this.wakeUp.WaitAsync(heartbeat, token);
if (token.IsCancellationRequested)
return;
//
// Deliberately without draining further wake-ups first. A nudge which arrives while
// this run reads the conversation may well be about a change this run is already
// seeing -- and then the extra run costs a few lookups. Draining would risk the
// other case, where the change comes after the read and nobody asks again.
//
try
{
await recount(token);
}
catch (Exception) when (!token.IsCancellationRequested)
{
//
// One failed run must not end the loop: a tracker which died on a single bad
// answer would leave a stale number standing forever, which is the failure this
// class was built to rule out. Saying what went wrong is the job of the work
// itself, which is the only side that has a logger.
//
}
//
// The quiet time is kept after the work, not before it: the first nudge of a burst
// is answered at once, and the rest of the burst collapses into the single run which
// follows this delay.
//
// It is also what paces a run which feeds itself. Showing a new number renders, and
// a render nudges -- so while something changes continuously, this delay is the
// whole cadence.
//
await Task.Delay(quietTime(), token);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
// The tracker was disposed underneath this loop, which is another way of stopping.
return;
}
}
}
#region Implementation of IAsyncDisposable
public async ValueTask DisposeAsync()
{
await this.stopping.CancelAsync();
if (this.loop is not null)
{
try
{
//
// Awaited rather than abandoned, so that nothing is still counting into a component
// which is already gone. The counting itself takes the same token, so a run which
// sits in an IPC call ends with it -- and the patience is there for the case where
// it does not, because a chat being closed is not worth hanging on to.
//
await this.loop.WaitAsync(SHUTDOWN_PATIENCE);
}
catch (Exception)
{
// The loop ends on cancellation; whatever else it carries out is of no use here.
}
}
this.stopping.Dispose();
this.wakeUp.Dispose();
}
#endregion
}

View File

@ -260,6 +260,14 @@ public partial class AttachDocuments : MSGComponentBase
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment); ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
this.ReconcileOwnerPendingTranscripts(); this.ReconcileOwnerPendingTranscripts();
//
// Said out loud, like every other path in this file. Removing a file in the dialog changed
// the attachments while whoever owns them heard nothing about it -- the chat then kept
// showing what the message no longer carries.
//
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
await this.OnChange(this.DocumentPaths);
} }
private async Task ClearAllFiles() private async Task ClearAllFiles()

View File

@ -51,7 +51,6 @@
Disabled="@this.IsInputForbidden()" Disabled="@this.IsInputForbidden()"
Immediate="@true" Immediate="@true"
OnKeyUp="@this.InputKeyEvent" OnKeyUp="@this.InputKeyEvent"
WhenTextChangedAsync="@(_ =>this.CalculateTokenCount())"
UserAttributes="@USER_INPUT_ATTRIBUTES" UserAttributes="@USER_INPUT_ATTRIBUTES"
Class="@this.UserInputClass" Class="@this.UserInputClass"
DebounceTime="TimeSpan.FromSeconds(1)" DebounceTime="TimeSpan.FromSeconds(1)"

View File

@ -98,6 +98,52 @@ public partial class ChatComponent : MSGComponentBase
private int workspaceHeaderSyncVersion; private int workspaceHeaderSyncVersion;
private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE; private ConversationTokens conversationTokens = ConversationTokens.UNAVAILABLE;
/// <summary>
/// How much of the window must be used before the number starts saying so.
/// </summary>
private const double WINDOW_NEARLY_FULL = 0.8d;
/// <summary>
/// How long the token count stays quiet after it ran, while nothing is being written.
/// </summary>
/// <remarks>
/// A render is cheap to ask about and a count is not. This is what keeps a burst of renders --
/// loading a chat touches several things in a row -- from turning into a burst of counting,
/// while staying short enough that switching a profile moves the number right away.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_QUIET_TIME = TimeSpan.FromMilliseconds(500);
/// <summary>
/// How long the token count stays quiet while an answer is being written.
/// </summary>
/// <remarks>
/// An answer grows with every word, so each count finds a new number, shows it, and thereby
/// renders -- which asks for the next count. That makes this the whole cadence while a model
/// writes, and three seconds is the pace the chat itself keeps: the job service hands its
/// progress to the screen no more often than that.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_STREAMING_QUIET_TIME = TimeSpan.FromSeconds(3);
/// <summary>
/// How long the token count waits for a reason before counting anyway.
/// </summary>
/// <remarks>
/// For what happens outside AI Studio: an attached document is read from disk every time it is
/// sent, so somebody editing it in another program changes what the next message costs without
/// anything here rendering.
/// </remarks>
private static readonly TimeSpan TOKEN_COUNT_HEARTBEAT = TimeSpan.FromSeconds(10);
/// <summary>
/// Recomputes the token count whenever something might have changed.
/// </summary>
private ConversationTokenTracker? tokenTracker;
/// <summary>
/// How long to leave the token count alone after it ran.
/// </summary>
private TimeSpan TokenCountQuietTime() => this.IsCurrentChatStreaming ? TOKEN_COUNT_STREAMING_QUIET_TIME : TOKEN_COUNT_QUIET_TIME;
/// <summary> /// <summary>
/// The culture the token numbers are written in. /// The culture the token numbers are written in.
/// </summary> /// </summary>
@ -176,6 +222,14 @@ public partial class ChatComponent : MSGComponentBase
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged; this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
await this.RefreshCulture(); await this.RefreshCulture();
//
// The number under the input field follows from the conversation, and nothing in a
// conversation announces that it changed: blocks, attachments and the answer being written
// are plain objects somebody mutates. So it is recomputed rather than notified.
//
this.tokenTracker = new(this.RecountTokensAsync, this.TokenCountQuietTime, TOKEN_COUNT_HEARTBEAT);
this.tokenTracker.Start();
// Apply the filters for the message bus: // Apply the filters for the message bus:
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]); this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@ -424,6 +478,15 @@ public partial class ChatComponent : MSGComponentBase
await this.inputField.FocusAsync(); await this.inputField.FocusAsync();
this.previousInputForbidden = inputForbidden; this.previousInputForbidden = inputForbidden;
//
// Everything which can move the token count also renders this component: the selections in
// the toolbar, the attachments and the composer all travel through an event callback whose
// receiver is this component, and the streamed answer arrives as a message which already
// asks for a render. So this one line stands in for the fifteen call sites which used to be
// spread over this file -- and which kept missing one.
//
this.tokenTracker?.Nudge();
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
} }
@ -439,14 +502,6 @@ public partial class ChatComponent : MSGComponentBase
await this.ApplyLoadedChatParameterAsync(); await this.ApplyLoadedChatParameterAsync();
await this.SyncForegroundChatAsync(); await this.SyncForegroundChatAsync();
//
// Both of these change the answer, and the chat is the reason the count is no longer about
// the draft alone: opening another conversation, or loading one, changes what the next
// message would carry along with it.
//
await this.CalculateTokenCount();
await this.ConsumeMediaOutcomeAsync(); await this.ConsumeMediaOutcomeAsync();
await base.OnParametersSetAsync(); await base.OnParametersSetAsync();
} }
@ -590,7 +645,34 @@ public partial class ChatComponent : MSGComponentBase
private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty; private string UserInputStyle => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? this.Provider.UsedLLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager) : string.Empty;
private string UserInputClass => this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? "confidence-border" : string.Empty; private string UserInputClass => $"{(this.SettingsManager.ConfigurationData.Confidence.ShowProviderConfidence ? "confidence-border" : string.Empty)} {this.TokenBudgetClass}".Trim();
/// <summary>
/// How much of the model's context window the conversation already takes.
/// </summary>
/// <remarks>
/// Zero whenever nobody wrote the window down. There is then nothing to be full of, and a share
/// of an unknown total would be a number made up on the spot.
/// </remarks>
private double TokenBudgetFill => this.conversationTokens is { IsKnown: true, Window.IsKnown: true }
? (double) this.conversationTokens.Tokens / this.conversationTokens.Window.DefaultTokens
: 0d;
/// <summary>
/// What the number under the input field is coloured with, if anything.
/// </summary>
/// <remarks>
/// Two steps rather than a gradient: below four fifths there is nothing to do about it, above
/// it there is -- shorten the chat, start a new one, or pick a model which reads more -- and
/// past the window the request will be refused or trimmed by the provider.
/// </remarks>
private string TokenBudgetClass => this.TokenBudgetFill switch
{
>= 1d => "token-budget-exceeded",
>= WINDOW_NEARLY_FULL => "token-budget-nearly-full",
_ => string.Empty,
};
private void ApplyStandardDataSourceOptions() private void ApplyStandardDataSourceOptions()
{ {
@ -657,8 +739,7 @@ public partial class ChatComponent : MSGComponentBase
// //
// A thread which already exists has to carry the choice. Before the first message there is // A thread which already exists has to carry the choice. Before the first message there is
// none, and the choice then travels in the thread a new chat is started with -- so the // none, and the choice then travels in the thread a new chat is started with.
// count below has to happen either way, which is what the early return here used to skip.
// //
if (this.ChatThread is not null) if (this.ChatThread is not null)
{ {
@ -669,9 +750,6 @@ public partial class ChatComponent : MSGComponentBase
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
} }
// A profile is a paragraph of the system prompt, so choosing another one changes the count:
await this.CalculateTokenCount();
} }
private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate) private async Task ChatTemplateWasChanged(ChatTemplate chatTemplate)
@ -685,13 +763,6 @@ public partial class ChatComponent : MSGComponentBase
if (this.ChatThread is not null) if (this.ChatThread is not null)
await this.StartNewChat(true); await this.StartNewChat(true);
//
// Counted in both cases. Without a thread nothing is started anew, but the template already
// decides the system prompt, the attachments and possibly an example conversation of the
// first message, and all of that costs before anything is sent.
//
await this.CalculateTokenCount();
} }
private void RefreshCurrentProfileAndChatTemplate() private void RefreshCurrentProfileAndChatTemplate()
@ -729,9 +800,6 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.ApplyTemplate(this.currentChatTemplate); this.ComposerState.ApplyTemplate(this.currentChatTemplate);
await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange(); await this.ApplyUpdatedStandardDataSourceOptionsAfterConfigurationChange();
// The provider, the profile and the template may all have moved, and each of them counts:
await this.CalculateTokenCount();
} }
private IReadOnlyList<DataSourceAgentSelected> GetAgentSelectedDataSources() private IReadOnlyList<DataSourceAgentSelected> GetAgentSelectedDataSources()
@ -795,9 +863,6 @@ public partial class ChatComponent : MSGComponentBase
// Was a modifier key pressed as well? // Was a modifier key pressed as well?
var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey; var isModifier = keyEvent.AltKey || keyEvent.CtrlKey || keyEvent.MetaKey || keyEvent.ShiftKey;
if (isEnter)
await this.CalculateTokenCount();
// Depending on the user's settings, might react to shortcuts: // Depending on the user's settings, might react to shortcuts:
switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior) switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)
{ {
@ -825,20 +890,13 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
} }
private async Task ComposerAttachmentsChanged(HashSet<FileAttachment> attachments) private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
{ {
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments)) if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
this.ComposerState.ReplaceFileAttachments(attachments); this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft(); this.ComposerState.MarkUserDraft();
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
//
// A document is usually the largest thing a person attaches, so this is the moment the
// number matters most. It is also the expensive one: the file is read and measured here,
// once, and remembered afterwards.
//
await this.CalculateTokenCount();
} }
/// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary> /// <summary>Creates and stores a stable draft immediately after media import confirmation.</summary>
@ -974,13 +1032,6 @@ public partial class ChatComponent : MSGComponentBase
await this.inputField.BlurAsync(); await this.inputField.BlurAsync();
//
// The draft just became part of the conversation, so the number does not drop back: what
// was being typed a moment ago now travels with every further message.
//
await this.CalculateTokenCount();
// Enable the stream state for the chat component: // Enable the stream state for the chat component:
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
@ -1025,7 +1076,7 @@ public partial class ChatComponent : MSGComponentBase
private void ApplyToolSelectionOfLoadedChat() => private void ApplyToolSelectionOfLoadedChat() =>
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT)); this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
private async Task SelectedToolIdsChanged(HashSet<string> updatedToolIds) private void SelectedToolIdsChanged(HashSet<string> updatedToolIds)
{ {
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds); this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
@ -1040,12 +1091,6 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.SelectedToolIds = [..this.selectedToolIds]; this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
} }
//
// Every tool the model may run describes itself in the system prompt, so picking tools costs
// tokens before a single one of them is called.
//
await this.CalculateTokenCount();
} }
private async Task SaveThread() private async Task SaveThread()
@ -1166,7 +1211,6 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncForegroundChatAsync(); await this.SyncForegroundChatAsync();
this.MarkCurrentChatAsLoadedParameter(); this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.CalculateTokenCount();
} }
private async Task MoveChatToWorkspace() private async Task MoveChatToWorkspace()
@ -1270,7 +1314,6 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncForegroundChatAsync(); await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions(); this.ApplyStandardDataSourceOptions();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread); await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.CalculateTokenCount();
} }
private async Task SelectProviderWhenLoadingChat() private async Task SelectProviderWhenLoadingChat()
@ -1306,9 +1349,6 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
await this.SaveThread(); await this.SaveThread();
this.StateHasChanged(); this.StateHasChanged();
// One message less in the conversation is one message less in every request from here on:
await this.CalculateTokenCount();
} }
private async Task RegenerateBlock(IContent aiBlock) private async Task RegenerateBlock(IContent aiBlock)
@ -1326,46 +1366,40 @@ public partial class ChatComponent : MSGComponentBase
await this.SendMessage(reuseLastUserPrompt: true); await this.SendMessage(reuseLastUserPrompt: true);
} }
private async Task EditLastUserBlock(IContent block) private Task EditLastUserBlock(IContent block)
{ {
if(this.ChatThread is null) if(this.ChatThread is null)
return; return Task.CompletedTask;
if (block is not ContentText textBlock) if (block is not ContentText textBlock)
return; return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last(); var lastBlock = this.ChatThread.Blocks.Last();
var lastBlockContent = lastBlock.Content; var lastBlockContent = lastBlock.Content;
if(lastBlockContent is null) if(lastBlockContent is null)
return; return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock); this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block); this.ChatThread.Remove(block);
this.ChatThread.Remove(lastBlockContent); this.ChatThread.Remove(lastBlockContent);
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
return Task.CompletedTask;
//
// The message moved out of the conversation and back into the composer, attachments and
// all. It costs the same either way, but nothing says so unless it is counted again.
//
await this.CalculateTokenCount();
} }
private async Task EditLastBlock(IContent block) private Task EditLastBlock(IContent block)
{ {
if(this.ChatThread is null) if(this.ChatThread is null)
return; return Task.CompletedTask;
if (block is not ContentText textBlock) if (block is not ContentText textBlock)
return; return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock); this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block); this.ChatThread.Remove(block);
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
this.StateHasChanged(); this.StateHasChanged();
return Task.CompletedTask;
await this.CalculateTokenCount();
} }
private void RestoreComposerFromTextBlock(ContentText textBlock) private void RestoreComposerFromTextBlock(ContentText textBlock)
@ -1387,24 +1421,47 @@ public partial class ChatComponent : MSGComponentBase
/// estimate, and it says so. /// estimate, and it says so.
/// ///
/// Read the text from the bound property rather than from the input field: the field is a /// Read the text from the bound property rather than from the input field: the field is a
/// component reference, which is only set once the component has rendered, while counting is /// component reference, which is only set once the component has rendered.
/// also triggered while parameters are set. ///
/// Called by the tracker, never directly. Whoever thinks something changed nudges it instead,
/// and it decides when the work is worth doing.
/// </remarks> /// </remarks>
private async Task CalculateTokenCount() /// <param name="token">Ends the count when the component goes away.</param>
private async Task RecountTokensAsync(CancellationToken token)
{
var provider = AIStudio.Settings.Provider.NONE;
var parts = ConversationParts.NOTHING;
//
// Collected on the render thread, counted off it. Counting may take an IPC call per text,
// and while it runs, the background job which writes the answer appends to the very list
// which is walked here.
//
await this.InvokeAsync(() =>
{ {
// //
// Before the first message there is no thread yet, so what is measured is the one a new // Before the first message there is no thread yet, so what is measured is the one a new
// chat would start with. A preselected profile or a chat template is already part of that, // chat would start with. A preselected profile or a chat template is already part of
// and it may even bring an example conversation along -- reporting nothing for all of it // that, and it may even bring an example conversation along -- reporting nothing for all
// would tell a person their window is empty while their first message already is not. // of it would tell a person their window is empty while their first message is not.
// //
var thread = this.ChatThread ?? this.NewChatThread(string.Empty); var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
var counted = await this.ConversationTokenCounter.CountAsync(this.Provider, thread, this.BuildSystemPromptFor(thread), this.UserInput, this.ComposerState.FileAttachments); provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput());
});
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
if (token.IsCancellationRequested)
return;
await this.InvokeAsync(() =>
{
if (counted == this.conversationTokens) if (counted == this.conversationTokens)
return; return;
this.conversationTokens = counted; this.conversationTokens = counted;
this.StateHasChanged(); this.StateHasChanged();
});
} }
/// <summary> /// <summary>
@ -1477,9 +1534,6 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true; this.hasUnsavedChanges = true;
if(this.autoSaveEnabled) if(this.autoSaveEnabled)
await this.SaveThread(); await this.SaveThread();
// The answer just became part of what every further message carries:
await this.CalculateTokenCount();
break; break;
case Event.WORKSPACE_RENAMED: case Event.WORKSPACE_RENAMED:
@ -1537,6 +1591,10 @@ public partial class ChatComponent : MSGComponentBase
protected override async ValueTask DisposeResourcesAsync() protected override async ValueTask DisposeResourcesAsync()
{ {
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged; this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
if (this.tokenTracker is not null)
await this.tokenTracker.DisposeAsync();
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY) if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
{ {
await this.SaveThread(); await this.SaveThread();

View File

@ -101,6 +101,19 @@
border-color: var(--confidence-color) !important; border-color: var(--confidence-color) !important;
} }
/*
* The token count under the chat input. It is a plain grey number until the conversation is near
* the model's context window, and then it says so by colour: there is nothing to do about four
* fifths of a window, and quite a lot to do about a full one.
*/
.token-budget-nearly-full .mud-input-helper-text {
color: var(--mud-palette-warning);
}
.token-budget-exceeded .mud-input-helper-text {
color: var(--mud-palette-error);
}
:root { :root {
--custom-icon-color: #000000; --custom-icon-color: #000000;
} }

View File

@ -46,7 +46,47 @@ public sealed class ConversationPartsTests
var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true); var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true);
Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris.", "And of Italy?" })); Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "You are helpful.", "What is the capital of France?", "Paris." }));
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "And of Italy?" }));
});
}
[Test]
public void WhatIsStillBeingWrittenIsKeptApartFromWhatStands()
{
//
// Both cost the same and both are counted. They are kept apart because of what happens
// afterwards: a message which stands says the same thing forever and its count is worth
// remembering, while the answer being streamed is a different text three seconds later.
//
var streaming = Block("The answer so far");
((ContentText)streaming.Content!).IsStreaming = true;
var thread = new ChatThread { Blocks = [Block("A question."), streaming] };
var parts = ConversationParts.Of(thread, string.Empty, "a draft", null, imagesAreSent: true);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "A question." }));
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The answer so far", "a draft" }));
});
}
[Test]
public void AnAnswerWhichIsFinishedStandsLikeAnyOtherMessage()
{
var finished = Block("The whole answer.");
((ContentText)finished.Content!).IsStreaming = false;
var parts = ConversationParts.Of(new() { Blocks = [finished] }, string.Empty, string.Empty, null, imagesAreSent: true);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "The whole answer." }));
Assert.That(parts.GrowingTexts, Is.Empty);
});
} }
[Test] [Test]
@ -85,7 +125,11 @@ public sealed class ConversationPartsTests
{ {
var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true); var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true);
Assert.That(parts.Texts, Is.EqualTo(new[] { "Hello" })); Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty);
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "Hello" }));
});
} }
[TestCase("")] [TestCase("")]
@ -94,7 +138,11 @@ public sealed class ConversationPartsTests
{ {
var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true); var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty); Assert.That(parts.Texts, Is.Empty);
Assert.That(parts.GrowingTexts, Is.Empty);
});
} }
[Test] [Test]

View File

@ -0,0 +1,210 @@
using AIStudio.Chat;
namespace AIStudio.Tests.Chat;
/// <summary>
/// Checks when the token count is recomputed and when it is not.
/// </summary>
/// <remarks>
/// This is the part which kept going wrong. The number used to be wired to the places which change
/// the conversation -- fifteen of them in the end -- and four review rounds each found another place
/// which had been forgotten. So it is no longer wired to anything: whoever suspects a change nudges,
/// and what is checked here is that the tracker turns those nudges into the right amount of work.
///
/// The waits are generous on purpose. What is asserted is the behaviour, not the clock, so every
/// interval here is far enough apart that a busy build machine cannot turn one into the other.
/// </remarks>
[TestFixture]
public sealed class ConversationTokenTrackerTests
{
/// <summary>
/// A heartbeat which never fires, for the tests which are about nudges alone.
/// </summary>
private static readonly TimeSpan NO_HEARTBEAT = Timeout.InfiniteTimeSpan;
[Test]
public async Task ManyNudgesInARowCostOneCount()
{
//
// Loading a chat touches several things one after the other, and every one of them renders.
// Counting once per render would measure the same conversation half a dozen times.
//
var runs = 0;
var firstRun = new TaskCompletionSource();
await using var tracker = new ConversationTokenTracker(_ =>
{
Interlocked.Increment(ref runs);
firstRun.TrySetResult();
return Task.CompletedTask;
}, () => TimeSpan.FromSeconds(2), NO_HEARTBEAT);
tracker.Start();
for (var i = 0; i < 50; i++)
tracker.Nudge();
await firstRun.Task.WaitAsync(TimeSpan.FromSeconds(5));
await Task.Delay(200);
Assert.That(runs, Is.EqualTo(1));
}
[Test]
public async Task ANudgeArrivingDuringACountLeadsToExactlyOneMore()
{
//
// Something changed while we were reading, so the answer we just worked out may already be
// out of date -- but only one further count can be needed, however many nudges arrived.
//
var runs = 0;
var firstRunStarted = new TaskCompletionSource();
var releaseFirstRun = new TaskCompletionSource();
await using var tracker = new ConversationTokenTracker(async _ =>
{
if (Interlocked.Increment(ref runs) is not 1)
return;
firstRunStarted.TrySetResult();
await releaseFirstRun.Task;
}, () => TimeSpan.FromMilliseconds(100), NO_HEARTBEAT);
tracker.Start();
tracker.Nudge();
await firstRunStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
//
// Nudged while the first run is held up, five times over, because a render storm is what
// this has to survive.
//
for (var i = 0; i < 5; i++)
tracker.Nudge();
releaseFirstRun.SetResult();
await Task.Delay(1_000);
Assert.That(runs, Is.EqualTo(2));
}
[Test]
public async Task WithoutAnyNudgeTheHeartbeatStillCounts()
{
//
// For what happens outside AI Studio: an attached file somebody edits in another program
// changes what the next message costs, and nothing here renders because of it.
//
var runs = 0;
await using var tracker = new ConversationTokenTracker(_ =>
{
Interlocked.Increment(ref runs);
return Task.CompletedTask;
}, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(200));
tracker.Start();
await Task.Delay(1_000);
Assert.That(runs, Is.GreaterThanOrEqualTo(2));
}
[Test]
public async Task TheQuietTimeIsAskedAnewAfterEveryRun()
{
//
// Because the right answer changes with what is going on. Showing a new number renders, and
// a render nudges, so while something moves continuously this delay is the entire cadence
// -- and a chat which is waiting for a model wants a slower one than a chat which is not.
//
var runs = 0;
var asked = 0;
await using var tracker = new ConversationTokenTracker(_ =>
{
Interlocked.Increment(ref runs);
return Task.CompletedTask;
}, () =>
{
Interlocked.Increment(ref asked);
return TimeSpan.FromMilliseconds(50);
}, TimeSpan.FromMilliseconds(100));
tracker.Start();
await Task.Delay(1_000);
Assert.Multiple(() =>
{
Assert.That(runs, Is.GreaterThanOrEqualTo(2));
Assert.That(asked, Is.EqualTo(runs));
});
}
[Test]
public async Task NothingIsCountedAfterTheTrackerIsGone()
{
var runs = 0;
var tracker = new ConversationTokenTracker(_ =>
{
Interlocked.Increment(ref runs);
return Task.CompletedTask;
}, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(100));
tracker.Start();
await Task.Delay(400);
await tracker.DisposeAsync();
var afterDisposal = runs;
await Task.Delay(400);
Assert.Multiple(() =>
{
Assert.That(afterDisposal, Is.GreaterThan(0), "The tracker never ran, so this proves nothing about stopping it.");
Assert.That(runs, Is.EqualTo(afterDisposal));
});
}
[Test]
public async Task ACountWhichHangsDoesNotHoldUpDisposal()
{
//
// Counting ends in an IPC call to the runtime, and a component going away must not wait for
// one which is not coming back. The token handed to the work is the way out, and this is
// the test that it really is one.
//
var running = new TaskCompletionSource();
var tracker = new ConversationTokenTracker(async token =>
{
running.TrySetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, token);
}, () => TimeSpan.FromMilliseconds(50), NO_HEARTBEAT);
tracker.Start();
tracker.Nudge();
await running.Task.WaitAsync(TimeSpan.FromSeconds(5));
var disposal = tracker.DisposeAsync().AsTask();
var finishedInTime = await Task.WhenAny(disposal, Task.Delay(TimeSpan.FromSeconds(2))) == disposal;
Assert.That(finishedInTime, Is.True);
}
[Test]
public async Task AFailedCountDoesNotEndTheTracker()
{
//
// A tracker which died on one bad answer would leave a stale number standing forever, which
// is the one failure this whole mechanism exists to rule out.
//
var runs = 0;
await using var tracker = new ConversationTokenTracker(_ =>
{
Interlocked.Increment(ref runs);
throw new InvalidOperationException("The tokenizer did not answer.");
}, () => TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(100));
tracker.Start();
await Task.Delay(1_000);
Assert.That(runs, Is.GreaterThanOrEqualTo(2));
}
}