Recompute the token count instead of announcing it

This commit is contained in:
Thorsten Sommer committed 2026-09-12 18:56:20 +02:00
1 parent 5ee4685f6f
commit 8468cdc7be
9 files changed
+618 -93

No files matched your search

@@ -22,6 +22,17 @@ public sealed record ConversationParts
/// </summary>
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>
/// The documents whose content is put into the request.
/// </summary>
@@ -52,6 +63,7 @@ public sealed record ConversationParts
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent)
{
var texts = new List<string>();
var growing = new List<string>();
var documents = new List<FileAttachment>();
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))
continue;
texts.Add(text.Text);
if (text.IsStreaming)
growing.Add(text.Text);
else
texts.Add(text.Text);
Sort(text.FileAttachments, documents, ref images);
}
}
if (!string.IsNullOrWhiteSpace(draft))
texts.Add(draft);
growing.Add(draft);
if (draftAttachments is not null)
Sort(draftAttachments, documents, ref images);
@@ -84,6 +100,7 @@ public sealed record ConversationParts
return new()
{
Texts = texts,
GrowingTexts = growing,
Documents = documents,
Images = imagesAreSent ? images : 0,
};
@@ -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
}
@@ -258,8 +258,16 @@ public partial class AttachDocuments : MSGComponentBase
this.DocumentPaths = await ReviewAttachmentsDialog.OpenDialogAsync(this.DialogService, this.DocumentPaths);
foreach (var removedAttachment in previousAttachments.Except(this.DocumentPaths))
ManagedTranscriptAttachment.TryDeleteOwnedFile(removedAttachment);
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()
@@ -51,7 +51,6 @@
Disabled="@this.IsInputForbidden()"
Immediate="@true"
OnKeyUp="@this.InputKeyEvent"
WhenTextChangedAsync="@(_ =>this.CalculateTokenCount())"
UserAttributes="@USER_INPUT_ATTRIBUTES"
Class="@this.UserInputClass"
DebounceTime="TimeSpan.FromSeconds(1)"
@@ -98,6 +98,52 @@ public partial class ChatComponent : MSGComponentBase
private int workspaceHeaderSyncVersion;
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>
/// The culture the token numbers are written in.
/// </summary>
@@ -176,6 +222,14 @@ public partial class ChatComponent : MSGComponentBase
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
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:
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();
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);
}
@@ -439,14 +502,6 @@ public partial class ChatComponent : MSGComponentBase
await this.ApplyLoadedChatParameterAsync();
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 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 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()
{
@@ -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
// none, and the choice then travels in the thread a new chat is started with -- so the
// count below has to happen either way, which is what the early return here used to skip.
// none, and the choice then travels in the thread a new chat is started with.
//
if (this.ChatThread is not null)
{
@@ -669,9 +750,6 @@ public partial class ChatComponent : MSGComponentBase
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)
@@ -685,13 +763,6 @@ public partial class ChatComponent : MSGComponentBase
if (this.ChatThread is not null)
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()
@@ -729,9 +800,6 @@ public partial class ChatComponent : MSGComponentBase
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
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()
@@ -794,10 +862,7 @@ public partial class ChatComponent : MSGComponentBase
// Was a modifier key pressed as well?
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:
switch (this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)
{
@@ -825,20 +890,13 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
}
private async Task ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
private void ComposerAttachmentsChanged(HashSet<FileAttachment> attachments)
{
if (!ReferenceEquals(this.ComposerState.FileAttachments, attachments))
this.ComposerState.ReplaceFileAttachments(attachments);
this.ComposerState.MarkUserDraft();
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>
@@ -974,13 +1032,6 @@ public partial class ChatComponent : MSGComponentBase
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:
this.hasUnsavedChanges = true;
@@ -1025,7 +1076,7 @@ public partial class ChatComponent : MSGComponentBase
private void ApplyToolSelectionOfLoadedChat() =>
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);
@@ -1040,12 +1091,6 @@ public partial class ChatComponent : MSGComponentBase
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
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()
@@ -1166,7 +1211,6 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncForegroundChatAsync();
this.MarkCurrentChatAsLoadedParameter();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.CalculateTokenCount();
}
private async Task MoveChatToWorkspace()
@@ -1270,9 +1314,8 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncForegroundChatAsync();
this.ApplyStandardDataSourceOptions();
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
await this.CalculateTokenCount();
}
private async Task SelectProviderWhenLoadingChat()
{
var chatProvider = this.ChatThread?.SelectedProvider;
@@ -1306,9 +1349,6 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
await this.SaveThread();
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)
@@ -1326,46 +1366,40 @@ public partial class ChatComponent : MSGComponentBase
await this.SendMessage(reuseLastUserPrompt: true);
}
private async Task EditLastUserBlock(IContent block)
private Task EditLastUserBlock(IContent block)
{
if(this.ChatThread is null)
return;
return Task.CompletedTask;
if (block is not ContentText textBlock)
return;
return Task.CompletedTask;
var lastBlock = this.ChatThread.Blocks.Last();
var lastBlockContent = lastBlock.Content;
if(lastBlockContent is null)
return;
return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block);
this.ChatThread.Remove(lastBlockContent);
this.hasUnsavedChanges = true;
this.StateHasChanged();
//
// 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();
return Task.CompletedTask;
}
private async Task EditLastBlock(IContent block)
private Task EditLastBlock(IContent block)
{
if(this.ChatThread is null)
return;
return Task.CompletedTask;
if (block is not ContentText textBlock)
return;
return Task.CompletedTask;
this.RestoreComposerFromTextBlock(textBlock);
this.ChatThread.Remove(block);
this.hasUnsavedChanges = true;
this.StateHasChanged();
await this.CalculateTokenCount();
return Task.CompletedTask;
}
private void RestoreComposerFromTextBlock(ContentText textBlock)
@@ -1387,24 +1421,47 @@ public partial class ChatComponent : MSGComponentBase
/// estimate, and it says so.
///
/// 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
/// also triggered while parameters are set.
/// component reference, which is only set once the component has rendered.
///
/// Called by the tracker, never directly. Whoever thinks something changed nudges it instead,
/// and it decides when the work is worth doing.
/// </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;
//
// 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,
// and it may even bring an example conversation along -- reporting nothing for all of it
// would tell a person their window is empty while their first message already is not.
// 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.
//
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);
if (counted == this.conversationTokens)
await this.InvokeAsync(() =>
{
//
// 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, and it may even bring an example conversation along -- reporting nothing for all
// of it would tell a person their window is empty while their first message is not.
//
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
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;
this.conversationTokens = counted;
this.StateHasChanged();
await this.InvokeAsync(() =>
{
if (counted == this.conversationTokens)
return;
this.conversationTokens = counted;
this.StateHasChanged();
});
}
/// <summary>
@@ -1477,9 +1534,6 @@ public partial class ChatComponent : MSGComponentBase
this.hasUnsavedChanges = true;
if(this.autoSaveEnabled)
await this.SaveThread();
// The answer just became part of what every further message carries:
await this.CalculateTokenCount();
break;
case Event.WORKSPACE_RENAMED:
@@ -1537,6 +1591,10 @@ public partial class ChatComponent : MSGComponentBase
protected override async ValueTask DisposeResourcesAsync()
{
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)
{
await this.SaveThread();
+13
View File
@@ -101,6 +101,19 @@
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 {
--custom-icon-color: #000000;
}