diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index ccf7ec3d..f931d596 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -93,7 +93,6 @@ public partial class ChatComponent : MSGComponentBase private Guid loadedParameterWorkspaceId = Guid.Empty; private Guid foregroundChatId = Guid.Empty; private int workspaceHeaderSyncVersion; - private HashSet chatDocumentPaths = []; private string tokenCount = "0"; private bool HasCustomTokenizer => !string.IsNullOrWhiteSpace(this.Provider.TokenizerPath); private string TokenCountMessage => this.HasCustomTokenizer @@ -394,7 +393,7 @@ public partial class ChatComponent : MSGComponentBase await this.ApplyLoadedChatParameterAsync(); await this.SyncForegroundChatAsync(); - if (providerChanged && this.HasCustomTokenizer && this.inputField is not null) + if (providerChanged && this.HasCustomTokenizer) await this.CalculateTokenCount(); await this.ConsumeMediaOutcomeAsync(); @@ -1323,13 +1322,19 @@ public partial class ChatComponent : MSGComponentBase return; } - if (this.inputField.Value is null) + // + // 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. Counting is also + // triggered while parameters are set, which happens before that. + // + var currentInput = this.UserInput; + if (string.IsNullOrEmpty(currentInput)) { this.tokenCount = "0"; return; } - var response = await this.RustService.GetTokenCount(this.Provider, this.inputField.Value); + var response = await this.RustService.GetTokenCount(this.Provider, currentInput); if (response is null) return; if (!response.Value.Success) diff --git a/app/MindWork AI Studio/Components/UserPromptComponent.cs b/app/MindWork AI Studio/Components/UserPromptComponent.cs index 03139a52..9056657f 100644 --- a/app/MindWork AI Studio/Components/UserPromptComponent.cs +++ b/app/MindWork AI Studio/Components/UserPromptComponent.cs @@ -8,20 +8,21 @@ namespace AIStudio.Components; /// Keeps the base API while adding a debounce timer. /// Callers can override any property as usual. /// -public class UserPromptComponent : MudTextField +public class UserPromptComponent : MudTextField, IDisposable { [Parameter] public TimeSpan DebounceTime { get; set; } = TimeSpan.FromMilliseconds(800); [Parameter] public Func WhenTextChangedAsync { get; set; } = _ => Task.CompletedTask; - + private readonly Timer debounceTimer = new(); private string text = string.Empty; private string lastParameterText = string.Empty; private string lastNotifiedText = string.Empty; private bool isInitialized; - + private bool isDisposed; + protected override async Task OnInitializedAsync() { this.text = this.Text ?? string.Empty; @@ -29,40 +30,79 @@ public class UserPromptComponent : MudTextField this.lastNotifiedText = this.text; this.debounceTimer.AutoReset = false; this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds; - this.debounceTimer.Elapsed += (_, _) => - { - this.debounceTimer.Stop(); - if (this.text == this.lastNotifiedText) - return; - - this.lastNotifiedText = this.text; - this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)); - this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)); - }; - + this.debounceTimer.Elapsed += this.WhenDebounceElapsed; + this.isInitialized = true; await base.OnInitializedAsync(); } - + protected override async Task OnParametersSetAsync() { // Ensure the timer uses the latest debouncing interval: - if (!this.isInitialized) + if (!this.isInitialized || this.isDisposed) + { + await base.OnParametersSetAsync(); return; - + } + if(Math.Abs(this.debounceTimer.Interval - this.DebounceTime.TotalMilliseconds) > 1) this.debounceTimer.Interval = this.DebounceTime.TotalMilliseconds; - + // Only sync when the parent's parameter actually changed since the last change: if (this.Text != this.lastParameterText) { this.text = this.Text ?? string.Empty; this.lastParameterText = this.text; } - + this.debounceTimer.Stop(); this.debounceTimer.Start(); - + await base.OnParametersSetAsync(); } -} + + private void WhenDebounceElapsed(object? sender, System.Timers.ElapsedEventArgs args) + { + this.debounceTimer.Stop(); + + // + // The timer runs on its own thread and may still fire while this component is being torn + // down. Notifying a renderer which is already gone would throw on that thread, where no + // caller is left to handle it. + // + if (this.isDisposed || this.text == this.lastNotifiedText) + return; + + this.lastNotifiedText = this.text; + this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(UserPromptComponent)}: notifying about changed text"); + this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(UserPromptComponent)}: handling changed text asynchronously"); + } + + #region IDisposable + + public void Dispose() + { + if (this.isDisposed) + return; + + // + // Set before stopping the timer: the handler might be running on the timer thread right + // now, and this is what tells it to leave the gone renderer alone. + // + this.isDisposed = true; + try + { + this.debounceTimer.Elapsed -= this.WhenDebounceElapsed; + this.debounceTimer.Stop(); + this.debounceTimer.Dispose(); + } + catch + { + // ignore + } + + GC.SuppressFinalize(this); + } + + #endregion +} \ No newline at end of file