From b2ebbed063fb399fcc315cd7df980dddec0179b0 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Mon, 22 Dec 2025 20:56:37 +0100 Subject: [PATCH 01/26] Fix: reduce WebChat freezes during drag/resize Throttle streaming DOM upserts more aggressively and process DOM updates in smaller batches to keep the UI responsive while moving/resizing the WebChat dialog. --- CHANGELOG.md | 5 + src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 153 ++++++++++++++---- .../UI/Chat/WebChatObserver.cs | 11 +- 3 files changed, 136 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fbf09d3..4b82ebbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Chat UI: + - Reduced WebChat dialog UI freezes while dragging/resizing during streaming responses by throttling DOM upserts more aggressively and processing DOM updates in smaller batches. + ## [1.2.1-alpha] - 2025-12-07 ### Added diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index 4157f367..748c7612 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -71,6 +71,12 @@ internal partial class WebChatDialog : Form private bool _isDomUpdating; private readonly Queue _domUpdateQueue = new Queue(); + // When the user is moving/resizing the window, defer DOM updates to avoid UI-thread stalls. + private DateTime _deferDomUpdatesUntilUtc = DateTime.MinValue; + private bool _domDrainScheduled; + private const int DomDeferDuringMoveResizeMs = 250; + private const int DomDrainBatchSize = 4; + // Status text to apply after the document is fully loaded private string _pendingStatusAfter = "Ready"; @@ -110,6 +116,11 @@ internal WebChatDialog(AIRequestCall request, Action? progressReporter, this._webView.DocumentLoading += this.WebView_DocumentLoading; this.Content = this._webView; + // If the user drags/resizes the dialog while we are rendering/upserting messages, + // defer DOM work to keep Rhino/Eto responsive. + this.LocationChanged += (_, __) => this.MarkMoveResizeInteraction(); + this.SizeChanged += (_, __) => this.MarkMoveResizeInteraction(); + // Initialize web view and optionally start greeting _ = this.InitializeWebViewAsync(); } @@ -241,52 +252,119 @@ private void RunWhenWebViewReady(Action action) return; } - void ExecuteSerialized() + void EnqueueAndScheduleDrain() + { + this._domUpdateQueue.Enqueue(action); + this.ScheduleDomDrain(); + } + + if (this._webViewInitialized) + { + RhinoApp.InvokeOnUiThread(EnqueueAndScheduleDrain); + } + else + { + this._webViewInitializedTcs.Task.ContinueWith( + _ => RhinoApp.InvokeOnUiThread(EnqueueAndScheduleDrain), + System.Threading.CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + } + + /// + /// Marks the window as being moved/resized, deferring DOM updates. + /// + private void MarkMoveResizeInteraction() + { + try + { + this._deferDomUpdatesUntilUtc = DateTime.UtcNow.AddMilliseconds(DomDeferDuringMoveResizeMs); + this.ScheduleDomDrain(); + } + catch + { + } + } + + /// + /// Schedules a drain of the DOM update queue. + /// + private void ScheduleDomDrain() + { + if (this._domDrainScheduled) + { + return; + } + + this._domDrainScheduled = true; + RhinoApp.InvokeOnUiThread(() => + { + Application.Instance?.AsyncInvoke(() => this.DrainDomUpdateQueue()); + }); + } + + /// + /// Drains the DOM update queue in batches. + /// + private void DrainDomUpdateQueue() + { + try { + this._domDrainScheduled = false; + + if (DateTime.UtcNow < this._deferDomUpdatesUntilUtc) + { + // Still moving/resizing; try again shortly. + Task.Run(async () => + { + await Task.Delay(DomDeferDuringMoveResizeMs).ConfigureAwait(false); + this.ScheduleDomDrain(); + }); + return; + } + if (this._isDomUpdating) { - this._domUpdateQueue.Enqueue(action); + // Another drain is already in progress; let it finish. return; } this._isDomUpdating = true; try { - action(); - } - catch (Exception ex) - { - Debug.WriteLine($"[WebChatDialog] RunWhenWebViewReady action error: {ex.Message}"); - } - finally - { - this._isDomUpdating = false; - while (this._domUpdateQueue.Count > 0) + int executed = 0; + while (executed < DomDrainBatchSize && this._domUpdateQueue.Count > 0) { var next = this._domUpdateQueue.Dequeue(); try { next?.Invoke(); } - catch (Exception qex) + catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] DOM queued action error: {qex.Message}"); + Debug.WriteLine($"[WebChatDialog] DOM queued action error: {ex.Message}"); } + + executed++; } } - } + finally + { + this._isDomUpdating = false; + } - if (this._webViewInitialized) - { - RhinoApp.InvokeOnUiThread(ExecuteSerialized); + // If there is more work, schedule another drain. + if (this._domUpdateQueue.Count > 0) + { + this.ScheduleDomDrain(); + } } - else + catch (Exception ex) { - this._webViewInitializedTcs.Task.ContinueWith( - _ => RhinoApp.InvokeOnUiThread(ExecuteSerialized), - System.Threading.CancellationToken.None, - TaskContinuationOptions.None, - TaskScheduler.Default); + Debug.WriteLine($"[WebChatDialog] DrainDomUpdateQueue error: {ex.Message}"); + this._isDomUpdating = false; + this._domDrainScheduled = false; } } @@ -301,18 +379,31 @@ private void ExecuteScript(string script) return; } + // If we are not currently draining the DOM queue, route this script through the queue. + // This prevents WebView JS execution from occurring during window move/resize UI loops. + if (!this._isDomUpdating) + { + this.RunWhenWebViewReady(() => this.ExecuteScript(script)); + return; + } + try { + // Use AsyncInvoke to avoid running WebView/JS work inside other UI event handlers + // (notably window move/resize), which can cause the UI to appear frozen. RhinoApp.InvokeOnUiThread(() => { - try + Application.Instance?.AsyncInvoke(() => { - this._webView.ExecuteScript(script); - } - catch (Exception ex) - { - Debug.WriteLine($"[WebChatDialog] ExecuteScript error: {ex.Message}"); - } + try + { + this._webView.ExecuteScript(script); + } + catch (Exception ex) + { + Debug.WriteLine($"[WebChatDialog] ExecuteScript error: {ex.Message}"); + } + }); }); } catch (Exception ex) diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index 756dbf1e..8cbb7e6e 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -114,7 +114,8 @@ private void CommitSegment(string baseKey, string turnKey) // Simple per-key throttling to reduce DOM churn during streaming private readonly Dictionary _lastUpsertAt = new Dictionary(StringComparer.Ordinal); - private const int ThrottleMs = 10; + private const int ThrottleMs = 50; + private const int ThrottleDuringMoveResizeMs = 200; // Tracks per-turn text segments so multiple text messages in a single turn // are rendered as distinct bubbles. Keys are the base stream key (e.g., "turn:{TurnId}:{agent}"). @@ -200,6 +201,7 @@ public void OnStart(AIRequestCall request) this._textInteractionSegments.Clear(); this._pendingNewTextSegmentTurns.Clear(); this._finalizedTextTurns.Clear(); + this._lastUpsertAt.Clear(); this._dialog.ExecuteScript("setStatus('Thinking...'); setProcessing(true);"); // Insert a persistent generic loading bubble that remains until stop state @@ -754,13 +756,18 @@ private bool ShouldUpsertNow(string key) try { var now = DateTime.UtcNow; + + var effectiveThrottleMs = now < this._dialog._deferDomUpdatesUntilUtc + ? ThrottleDuringMoveResizeMs + : ThrottleMs; + if (!this._lastUpsertAt.TryGetValue(key, out var last)) { this._lastUpsertAt[key] = now; return true; } - if ((now - last).TotalMilliseconds >= ThrottleMs) + if ((now - last).TotalMilliseconds >= effectiveThrottleMs) { this._lastUpsertAt[key] = now; return true; From 57d05ed5a853eebe8c476fd292ccdf276eb33da8 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:19:10 +0100 Subject: [PATCH 02/26] chore: update SmartHopperPublicKey with dev public key value --- README.md | 4 ++-- Solution.props | 2 +- .../SmartHopper.Infrastructure.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 27496e4b..0b4e1b4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.2.1--alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) -[![Status](https://img.shields.io/badge/status-Alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.2.2-dev.251222-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Status](https://img.shields.io/badge/status-Unstable%20Development-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) [![License](https://img.shields.io/badge/license-LGPL%20v3-white?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/blob/main/LICENSE) diff --git a/Solution.props b/Solution.props index 76f671c6..502765d9 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.2.1-alpha + 1.2.2-dev.251222 \ No newline at end of file diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index e51b6d13..b4ddc558 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -76,7 +76,7 @@ Run that script after generating or updating the signing key. --> - This value is automatically replaced by the build tooling before official builds. + 0024000004800000940000000602000000240000525341310004000001000100b90ff13176f06b3385ce4bafee2a5177994228e8726e444377056f2ff11813457d594162f7542e7621eedec5445ce0e079e7d01357cf2463fb73aa5e248a34e57fe1999daa6a17f493bdafc5cfdd4cd80d14cb00326ba745a862a3cd5686504d2ae9e6e06e9f4ccebd2bffd7b990e617f6ad8a42397a20123fb373ce582085cc From 86f9e7dbac2c44a41aed06b63d8fa357746d8324 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:23:57 +0100 Subject: [PATCH 03/26] chore: update bug report template placeholders to reflect current versions --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 37e96ac6..696dd69e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,7 +35,7 @@ body: attributes: label: Rhino Version description: What version of Rhino are you using? - placeholder: e.g. RH8.25 + placeholder: e.g. RH8.26 validations: required: true @@ -44,7 +44,7 @@ body: attributes: label: SmartHopper Version description: What version of SmartHopper are you using? - placeholder: e.g. 1.2.0-alpha + placeholder: e.g. 1.2.2-alpha validations: required: true From e1e5acd4e7da9d57235cf2f797b7bb964ccb4d37 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:55:03 +0100 Subject: [PATCH 04/26] Fix: prevent WebChat UI freezes during streaming Move chat message HTML/markdown rendering off the UI thread (including tool result ordering inserts) and ensure only the latest render per DOM key is applied. --- src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 215 +++++++++++++----- .../UI/Chat/WebChatObserver.cs | 45 +++- 2 files changed, 201 insertions(+), 59 deletions(-) diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index 748c7612..e5ec7690 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -67,15 +67,20 @@ internal partial class WebChatDialog : Form // ConversationSession manages all history and requests // WebChatDialog is now a pure UI consumer - // DOM update reentrancy guard/queue to avoid nested ExecuteScript calls causing recursion private bool _isDomUpdating; private readonly Queue _domUpdateQueue = new Queue(); + private readonly object _htmlRenderLock = new object(); + + private readonly object _renderVersionLock = new object(); + + private readonly Dictionary _renderVersionByDomKey = new Dictionary(StringComparer.Ordinal); + // When the user is moving/resizing the window, defer DOM updates to avoid UI-thread stalls. private DateTime _deferDomUpdatesUntilUtc = DateTime.MinValue; private bool _domDrainScheduled; private const int DomDeferDuringMoveResizeMs = 250; - private const int DomDrainBatchSize = 4; + private const int DomDrainBatchSize = 10; // Status text to apply after the document is fully loaded private string _pendingStatusAfter = "Ready"; @@ -146,42 +151,68 @@ private void UpsertMessageAfter(string followKey, string domKey, IAIInteraction return; } - this.RunWhenWebViewReady(() => + var renderVersion = this.NextRenderVersion(domKey); + Task.Run(() => { - var html = this._htmlRenderer.RenderInteraction(interaction); - var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; - - // Monitor key length - this.MonitorKeyLength(domKey); - this.MonitorKeyLength(followKey); + string html; + try + { + lock (this._htmlRenderLock) + { + html = this._htmlRenderer.RenderInteraction(interaction); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter render error: {ex.Message}"); + return; + } - // Performance profiling for HTML equality check - if (!string.IsNullOrEmpty(domKey) && html != null && this._lastDomHtmlByKey.TryGetValue(domKey, out var last)) + if (!this.IsLatestRenderVersion(domKey, renderVersion)) { - var sw = System.Diagnostics.Stopwatch.StartNew(); - bool isEqual = string.Equals(last, html, StringComparison.Ordinal); - sw.Stop(); - this._totalEqualityChecks++; - this._totalEqualityCheckMs += sw.ElapsedMilliseconds; + return; + } - if (isEqual) + this.RunWhenWebViewReady(() => + { + if (!this.IsLatestRenderVersion(domKey, renderVersion)) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter (skipped identical) fk={followKey} key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); return; } - } - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter fk={followKey} key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); + var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; - // Log warning if followKey might not be found (JavaScript will also warn) - if (string.IsNullOrWhiteSpace(followKey)) - { - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter WARNING: followKey is null/empty for key={domKey}, will fallback to normal upsert"); - } + // Monitor key length + this.MonitorKeyLength(domKey); + this.MonitorKeyLength(followKey); + + // Performance profiling for HTML equality check + if (!string.IsNullOrEmpty(domKey) && html != null && this._lastDomHtmlByKey.TryGetValue(domKey, out var last)) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + bool isEqual = string.Equals(last, html, StringComparison.Ordinal); + sw.Stop(); + this._totalEqualityChecks++; + this._totalEqualityCheckMs += sw.ElapsedMilliseconds; + + if (isEqual) + { + Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter (skipped identical) fk={followKey} key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); + return; + } + } + + Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter fk={followKey} key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); - var script = $"upsertMessageAfter({JsonConvert.SerializeObject(followKey)}, {JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; - this.UpdateIdempotencyCache(domKey, html ?? string.Empty); - this.ExecuteScript(script); + if (string.IsNullOrWhiteSpace(followKey)) + { + Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter WARNING: followKey is null/empty for key={domKey}, will fallback to normal upsert"); + } + + var script = $"upsertMessageAfter({JsonConvert.SerializeObject(followKey)}, {JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; + this.UpdateIdempotencyCache(domKey, html ?? string.Empty); + this.ExecuteScript(script); + }); }); } @@ -423,14 +454,30 @@ private void AddInteractionToWebView(IAIInteraction interaction) return; } - this.RunWhenWebViewReady(() => + Task.Run(() => { - var html = this._htmlRenderer.RenderInteraction(interaction); - var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; - Debug.WriteLine($"[WebChatDialog] AddInteractionToWebView agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} preview={preview}"); - var script = $"addMessage({JsonConvert.SerializeObject(html)});"; - Debug.WriteLine($"[WebChatDialog] ExecuteScript addMessage len={script.Length} preview={(script.Length > 140 ? script.Substring(0, 140) + "..." : script)}"); - this.ExecuteScript(script); + string html; + try + { + lock (this._htmlRenderLock) + { + html = this._htmlRenderer.RenderInteraction(interaction); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[WebChatDialog] AddInteractionToWebView render error: {ex.Message}"); + return; + } + + this.RunWhenWebViewReady(() => + { + var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; + Debug.WriteLine($"[WebChatDialog] AddInteractionToWebView agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} preview={preview}"); + var script = $"addMessage({JsonConvert.SerializeObject(html)});"; + Debug.WriteLine($"[WebChatDialog] ExecuteScript addMessage len={script.Length} preview={(script.Length > 140 ? script.Substring(0, 140) + "..." : script)}"); + this.ExecuteScript(script); + }); }); } @@ -455,35 +502,62 @@ private void UpsertMessageByKey(string domKey, IAIInteraction interaction, strin return; } - this.RunWhenWebViewReady(() => + var renderVersion = this.NextRenderVersion(domKey); + Task.Run(() => { - var html = this._htmlRenderer.RenderInteraction(interaction); - var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; - - // Monitor key length - this.MonitorKeyLength(domKey); + string html; + try + { + lock (this._htmlRenderLock) + { + html = this._htmlRenderer.RenderInteraction(interaction); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey render error: {ex.Message}"); + return; + } - // Performance profiling for HTML equality check - if (!string.IsNullOrEmpty(domKey) && html != null && this._lastDomHtmlByKey.TryGetValue(domKey, out var last)) + if (!this.IsLatestRenderVersion(domKey, renderVersion)) { - var sw = System.Diagnostics.Stopwatch.StartNew(); - bool isEqual = string.Equals(last, html, StringComparison.Ordinal); - sw.Stop(); - this._totalEqualityChecks++; - this._totalEqualityCheckMs += sw.ElapsedMilliseconds; + return; + } - if (isEqual) + this.RunWhenWebViewReady(() => + { + if (!this.IsLatestRenderVersion(domKey, renderVersion)) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey (skipped identical) key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); return; } - } - Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); - var script = $"upsertMessage({JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; - Debug.WriteLine($"[WebChatDialog] ExecuteScript upsertMessage len={script.Length} preview={(script.Length > 160 ? script.Substring(0, 160) + "..." : script)}"); - this.UpdateIdempotencyCache(domKey, html ?? string.Empty); - this.ExecuteScript(script); + var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; + + // Monitor key length + this.MonitorKeyLength(domKey); + + // Performance profiling for HTML equality check + if (!string.IsNullOrEmpty(domKey) && html != null && this._lastDomHtmlByKey.TryGetValue(domKey, out var last)) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + bool isEqual = string.Equals(last, html, StringComparison.Ordinal); + sw.Stop(); + this._totalEqualityChecks++; + this._totalEqualityCheckMs += sw.ElapsedMilliseconds; + + if (isEqual) + { + Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey (skipped identical) key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); + return; + } + } + + Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); + var script = $"upsertMessage({JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; + Debug.WriteLine($"[WebChatDialog] ExecuteScript upsertMessage len={script.Length} preview={(script.Length > 160 ? script.Substring(0, 160) + "..." : script)}"); + this.UpdateIdempotencyCache(domKey, html ?? string.Empty); + this.ExecuteScript(script); + }); }); } @@ -1223,5 +1297,34 @@ private void SendMessage(string text) Debug.WriteLine($"[WebChatDialog] SendMessage(text) error: {ex.Message}"); } } + + private long NextRenderVersion(string domKey) + { + if (string.IsNullOrWhiteSpace(domKey)) + { + return 0; + } + + lock (this._renderVersionLock) + { + this._renderVersionByDomKey.TryGetValue(domKey, out var current); + current++; + this._renderVersionByDomKey[domKey] = current; + return current; + } + } + + private bool IsLatestRenderVersion(string domKey, long version) + { + if (string.IsNullOrWhiteSpace(domKey) || version <= 0) + { + return true; + } + + lock (this._renderVersionLock) + { + return this._renderVersionByDomKey.TryGetValue(domKey, out var current) && current == version; + } + } } } diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index 8cbb7e6e..14511480 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -117,6 +117,9 @@ private void CommitSegment(string baseKey, string turnKey) private const int ThrottleMs = 50; private const int ThrottleDuringMoveResizeMs = 200; + private readonly Dictionary _lastRenderedTextByKey = + new Dictionary(StringComparer.Ordinal); + // Tracks per-turn text segments so multiple text messages in a single turn // are rendered as distinct bubbles. Keys are the base stream key (e.g., "turn:{TurnId}:{agent}"). private readonly Dictionary _textInteractionSegments = new Dictionary(StringComparer.Ordinal); @@ -202,6 +205,7 @@ public void OnStart(AIRequestCall request) this._pendingNewTextSegmentTurns.Clear(); this._finalizedTextTurns.Clear(); this._lastUpsertAt.Clear(); + this._lastRenderedTextByKey.Clear(); this._dialog.ExecuteScript("setStatus('Thinking...'); setProcessing(true);"); // Insert a persistent generic loading bubble that remains until stop state @@ -318,7 +322,8 @@ public void OnDelta(IAIInteraction interaction) CoalesceTextStreamChunk(tt, targetKey, ref state); // Check if text is now renderable - bool isRenderable = state.Aggregated is AIInteractionText aggText && HasRenderableText(aggText); + var aggregatedText = state.Aggregated as AIInteractionText; + bool isRenderable = HasRenderableText(aggregatedText); Debug.WriteLine($"[WebChatObserver] OnDelta: isRenderable={isRenderable}, isCommitted={isCommitted}"); if (isRenderable && !isCommitted) @@ -336,7 +341,10 @@ public void OnDelta(IAIInteraction interaction) // Upsert to DOM if (this.ShouldUpsertNow(segKey)) { - this._dialog.UpsertMessageByKey(segKey, state.Aggregated as AIInteractionText, source: "OnDelta:FirstRender"); + if (aggregatedText != null && this.ShouldRenderDelta(segKey, aggregatedText)) + { + this._dialog.UpsertMessageByKey(segKey, aggregatedText, source: "OnDelta:FirstRender"); + } } } else if (isRenderable && isCommitted) @@ -345,7 +353,10 @@ public void OnDelta(IAIInteraction interaction) this._streams[targetKey] = state; if (this.ShouldUpsertNow(targetKey)) { - this._dialog.UpsertMessageByKey(targetKey, state.Aggregated as AIInteractionText, source: "OnDelta"); + if (aggregatedText != null && this.ShouldRenderDelta(targetKey, aggregatedText)) + { + this._dialog.UpsertMessageByKey(targetKey, aggregatedText, source: "OnDelta"); + } } } else @@ -777,6 +788,34 @@ private bool ShouldUpsertNow(string key) return false; } + private bool ShouldRenderDelta(string domKey, AIInteractionText text) + { + if (string.IsNullOrWhiteSpace(domKey) || text == null) + { + return false; + } + + try + { + var content = text.Content; + var reasoning = text.Reasoning; + + if (this._lastRenderedTextByKey.TryGetValue(domKey, out var last) + && string.Equals(last.Content, content, StringComparison.Ordinal) + && string.Equals(last.Reasoning, reasoning, StringComparison.Ordinal)) + { + return false; + } + + this._lastRenderedTextByKey[domKey] = (content, reasoning); + } + catch + { + } + + return true; + } + /// /// Returns true when there is something to render (either answer content or reasoning). /// This allows live updates even when only reasoning has been streamed so far. From aecda72202cc1bb4489556389a773a3f3cd57f52 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:28:18 +0100 Subject: [PATCH 05/26] perf(chat): optimize DOM updates with keyed queue and conditional debug logging - Add keyed DOM update queue (`_keyedDomUpdateLatest` + `_keyedDomUpdateQueue`) to ensure only latest render per DOM key is applied, preventing redundant updates during streaming - Wrap all `Debug.WriteLine` calls in `[Conditional("DEBUG")]` `DebugLog` method to eliminate debug overhead in release builds - Move HTML equality checks inside `#if DEBUG` blocks to skip expensive string comparisons in production --- src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 281 +++++++++++------- .../UI/Chat/WebChatObserver.cs | 86 ++++-- .../UI/Chat/WebChatUtils.Helpers.cs | 12 +- src/SmartHopper.Core/UI/Chat/WebChatUtils.cs | 32 +- src/SmartHopper.Core/UI/DialogCanvasLink.cs | 52 ++-- 5 files changed, 289 insertions(+), 174 deletions(-) diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index e5ec7690..eb62751e 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -69,6 +69,8 @@ internal partial class WebChatDialog : Form private bool _isDomUpdating; private readonly Queue _domUpdateQueue = new Queue(); + private readonly Dictionary _keyedDomUpdateLatest = new Dictionary(StringComparer.Ordinal); + private readonly Queue _keyedDomUpdateQueue = new Queue(); private readonly object _htmlRenderLock = new object(); @@ -79,7 +81,7 @@ internal partial class WebChatDialog : Form // When the user is moving/resizing the window, defer DOM updates to avoid UI-thread stalls. private DateTime _deferDomUpdatesUntilUtc = DateTime.MinValue; private bool _domDrainScheduled; - private const int DomDeferDuringMoveResizeMs = 250; + private const int DomDeferDuringMoveResizeMs = 400; private const int DomDrainBatchSize = 10; // Status text to apply after the document is fully loaded @@ -88,6 +90,12 @@ internal partial class WebChatDialog : Form // Greeting behavior: when true, the dialog will request a greeting from ConversationSession on init private readonly bool _generateGreeting; + [Conditional("DEBUG")] + private static void DebugLog(string message) + { + Debug.WriteLine(message); + } + /// /// Creates a new WebChatDialog bound to an initial AI request and optional progress reporter. /// @@ -131,7 +139,7 @@ internal WebChatDialog(AIRequestCall request, Action? progressReporter, } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Constructor error: {ex.Message}"); + DebugLog($"[WebChatDialog] Constructor error: {ex.Message}"); } } @@ -164,7 +172,7 @@ private void UpsertMessageAfter(string followKey, string domKey, IAIInteraction } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter render error: {ex.Message}"); + DebugLog($"[WebChatDialog] UpsertMessageAfter render error: {ex.Message}"); return; } @@ -173,14 +181,16 @@ private void UpsertMessageAfter(string followKey, string domKey, IAIInteraction return; } - this.RunWhenWebViewReady(() => + this.RunWhenWebViewReady(domKey, () => { if (!this.IsLatestRenderVersion(domKey, renderVersion)) { return; } +#if DEBUG var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; +#endif // Monitor key length this.MonitorKeyLength(domKey); @@ -189,24 +199,35 @@ private void UpsertMessageAfter(string followKey, string domKey, IAIInteraction // Performance profiling for HTML equality check if (!string.IsNullOrEmpty(domKey) && html != null && this._lastDomHtmlByKey.TryGetValue(domKey, out var last)) { + bool isEqual; +#if DEBUG var sw = System.Diagnostics.Stopwatch.StartNew(); - bool isEqual = string.Equals(last, html, StringComparison.Ordinal); + isEqual = string.Equals(last, html, StringComparison.Ordinal); sw.Stop(); this._totalEqualityChecks++; this._totalEqualityCheckMs += sw.ElapsedMilliseconds; +#else + isEqual = string.Equals(last, html, StringComparison.Ordinal); +#endif if (isEqual) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter (skipped identical) fk={followKey} key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); +#if DEBUG + DebugLog($"[WebChatDialog] UpsertMessageAfter (skipped identical) fk={followKey} key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); +#endif return; } } - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter fk={followKey} key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); +#if DEBUG + DebugLog($"[WebChatDialog] UpsertMessageAfter fk={followKey} key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); +#endif if (string.IsNullOrWhiteSpace(followKey)) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageAfter WARNING: followKey is null/empty for key={domKey}, will fallback to normal upsert"); +#if DEBUG + DebugLog($"[WebChatDialog] UpsertMessageAfter WARNING: followKey is null/empty for key={domKey}, will fallback to normal upsert"); +#endif } var script = $"upsertMessageAfter({JsonConvert.SerializeObject(followKey)}, {JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; @@ -234,7 +255,7 @@ internal void EnsureVisibility() } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] EnsureVisibility error: {ex.Message}"); + DebugLog($"[WebChatDialog] EnsureVisibility error: {ex.Message}"); } } @@ -303,6 +324,46 @@ void EnqueueAndScheduleDrain() } } + private void RunWhenWebViewReady(string domKey, Action action) + { + if (action == null) + { + return; + } + + void EnqueueAndScheduleDrain() + { + if (string.IsNullOrWhiteSpace(domKey)) + { + this._domUpdateQueue.Enqueue(action); + this.ScheduleDomDrain(); + return; + } + + var isNewKey = !this._keyedDomUpdateLatest.ContainsKey(domKey); + this._keyedDomUpdateLatest[domKey] = action; + if (isNewKey) + { + this._keyedDomUpdateQueue.Enqueue(domKey); + } + + this.ScheduleDomDrain(); + } + + if (this._webViewInitialized) + { + RhinoApp.InvokeOnUiThread(EnqueueAndScheduleDrain); + } + else + { + this._webViewInitializedTcs.Task.ContinueWith( + _ => RhinoApp.InvokeOnUiThread(EnqueueAndScheduleDrain), + System.Threading.CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default); + } + } + /// /// Marks the window as being moved/resized, deferring DOM updates. /// @@ -365,16 +426,35 @@ private void DrainDomUpdateQueue() try { int executed = 0; - while (executed < DomDrainBatchSize && this._domUpdateQueue.Count > 0) + while (executed < DomDrainBatchSize && (this._domUpdateQueue.Count > 0 || this._keyedDomUpdateQueue.Count > 0)) { - var next = this._domUpdateQueue.Dequeue(); - try + if (this._keyedDomUpdateQueue.Count > 0) { - next?.Invoke(); + var domKey = this._keyedDomUpdateQueue.Dequeue(); + try + { + if (!string.IsNullOrWhiteSpace(domKey) && this._keyedDomUpdateLatest.TryGetValue(domKey, out var keyedAction)) + { + this._keyedDomUpdateLatest.Remove(domKey); + keyedAction?.Invoke(); + } + } + catch (Exception ex) + { + DebugLog($"[WebChatDialog] DOM keyed action error: {ex.Message}"); + } } - catch (Exception ex) + else { - Debug.WriteLine($"[WebChatDialog] DOM queued action error: {ex.Message}"); + var next = this._domUpdateQueue.Dequeue(); + try + { + next?.Invoke(); + } + catch (Exception ex) + { + DebugLog($"[WebChatDialog] DOM queued action error: {ex.Message}"); + } } executed++; @@ -386,14 +466,14 @@ private void DrainDomUpdateQueue() } // If there is more work, schedule another drain. - if (this._domUpdateQueue.Count > 0) + if (this._domUpdateQueue.Count > 0 || this._keyedDomUpdateQueue.Count > 0) { this.ScheduleDomDrain(); } } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] DrainDomUpdateQueue error: {ex.Message}"); + DebugLog($"[WebChatDialog] DrainDomUpdateQueue error: {ex.Message}"); this._isDomUpdating = false; this._domDrainScheduled = false; } @@ -432,14 +512,14 @@ private void ExecuteScript(string script) } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] ExecuteScript error: {ex.Message}"); + DebugLog($"[WebChatDialog] ExecuteScript error: {ex.Message}"); } }); }); } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] ExecuteScript marshal error: {ex.Message}"); + DebugLog($"[WebChatDialog] ExecuteScript marshal error: {ex.Message}"); } } @@ -466,16 +546,20 @@ private void AddInteractionToWebView(IAIInteraction interaction) } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] AddInteractionToWebView render error: {ex.Message}"); + DebugLog($"[WebChatDialog] AddInteractionToWebView render error: {ex.Message}"); return; } this.RunWhenWebViewReady(() => { +#if DEBUG var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; - Debug.WriteLine($"[WebChatDialog] AddInteractionToWebView agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} preview={preview}"); + DebugLog($"[WebChatDialog] AddInteractionToWebView agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} preview={preview}"); +#endif var script = $"addMessage({JsonConvert.SerializeObject(html)});"; - Debug.WriteLine($"[WebChatDialog] ExecuteScript addMessage len={script.Length} preview={(script.Length > 140 ? script.Substring(0, 140) + "..." : script)}"); +#if DEBUG + DebugLog($"[WebChatDialog] ExecuteScript addMessage len={script.Length} preview={(script.Length > 140 ? script.Substring(0, 140) + "..." : script)}"); +#endif this.ExecuteScript(script); }); }); @@ -498,7 +582,9 @@ private void UpsertMessageByKey(string domKey, IAIInteraction interaction, strin string.IsNullOrWhiteSpace(txt.Content) && string.IsNullOrWhiteSpace(txt.Reasoning)) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey (skipped empty assistant) key={domKey} src={source ?? "?"}"); +#if DEBUG + DebugLog($"[WebChatDialog] UpsertMessageByKey (skipped empty assistant) key={domKey} src={source ?? "?"}"); +#endif return; } @@ -515,7 +601,7 @@ private void UpsertMessageByKey(string domKey, IAIInteraction interaction, strin } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey render error: {ex.Message}"); + DebugLog($"[WebChatDialog] UpsertMessageByKey render error: {ex.Message}"); return; } @@ -524,37 +610,21 @@ private void UpsertMessageByKey(string domKey, IAIInteraction interaction, strin return; } - this.RunWhenWebViewReady(() => + this.RunWhenWebViewReady(domKey, () => { if (!this.IsLatestRenderVersion(domKey, renderVersion)) { return; } +#if DEBUG var preview = html != null ? (html.Length > 120 ? html.Substring(0, 120) + "..." : html) : "(null)"; - - // Monitor key length - this.MonitorKeyLength(domKey); - - // Performance profiling for HTML equality check - if (!string.IsNullOrEmpty(domKey) && html != null && this._lastDomHtmlByKey.TryGetValue(domKey, out var last)) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - bool isEqual = string.Equals(last, html, StringComparison.Ordinal); - sw.Stop(); - this._totalEqualityChecks++; - this._totalEqualityCheckMs += sw.ElapsedMilliseconds; - - if (isEqual) - { - Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey (skipped identical) key={domKey} agent={interaction.Agent} len={html.Length} src={source ?? "?"} eqCheckMs={sw.ElapsedMilliseconds}"); - return; - } - } - - Debug.WriteLine($"[WebChatDialog] UpsertMessageByKey key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); + DebugLog($"[WebChatDialog] UpsertMessageByKey key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); +#endif var script = $"upsertMessage({JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; - Debug.WriteLine($"[WebChatDialog] ExecuteScript upsertMessage len={script.Length} preview={(script.Length > 160 ? script.Substring(0, 160) + "..." : script)}"); +#if DEBUG + DebugLog($"[WebChatDialog] ExecuteScript upsertMessage len={script.Length} preview={(script.Length > 160 ? script.Substring(0, 160) + "..." : script)}"); +#endif this.UpdateIdempotencyCache(domKey, html ?? string.Empty); this.ExecuteScript(script); }); @@ -584,13 +654,15 @@ private void UpdateIdempotencyCache(string key, string html) { var oldest = this._lruQueue.Dequeue(); this._lastDomHtmlByKey.Remove(oldest); - Debug.WriteLine($"[WebChatDialog] LRU eviction: removed key={oldest} (cache size was {this._lruQueue.Count + 1})"); +#if DEBUG + DebugLog($"[WebChatDialog] LRU eviction: removed key={oldest} (cache size was {this._lruQueue.Count + 1})"); +#endif } } } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] UpdateIdempotencyCache error: {ex.Message}"); + DebugLog($"[WebChatDialog] UpdateIdempotencyCache error: {ex.Message}"); } } @@ -611,17 +683,21 @@ private void MonitorKeyLength(string key) if (len > this._maxKeyLengthSeen) { this._maxKeyLengthSeen = len; - Debug.WriteLine($"[WebChatDialog] New max key length: {len} chars - key preview: {(len > 80 ? key.Substring(0, 80) + "..." : key)}"); +#if DEBUG + DebugLog($"[WebChatDialog] New max key length: {len} chars - key preview: {(len > 80 ? key.Substring(0, 80) + "..." : key)}"); +#endif if (len > 256) { - Debug.WriteLine($"[WebChatDialog] WARNING: Key length ({len}) exceeds recommended limit (256). Full key: {key}"); +#if DEBUG + DebugLog($"[WebChatDialog] WARNING: Key length ({len}) exceeds recommended limit (256). Full key: {key}"); +#endif } } } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] MonitorKeyLength error: {ex.Message}"); + DebugLog($"[WebChatDialog] MonitorKeyLength error: {ex.Message}"); } } @@ -633,7 +709,9 @@ private void LogPerformanceStats() if (this._totalEqualityChecks > 0) { var avgMs = (double)this._totalEqualityCheckMs / this._totalEqualityChecks; - Debug.WriteLine($"[WebChatDialog] Performance Stats: {this._totalEqualityChecks} equality checks, {this._totalEqualityCheckMs}ms total, {avgMs:F3}ms avg, max key length: {this._maxKeyLengthSeen}"); +#if DEBUG + DebugLog($"[WebChatDialog] Performance Stats: {this._totalEqualityChecks} equality checks, {this._totalEqualityCheckMs}ms total, {avgMs:F3}ms avg, max key length: {this._maxKeyLengthSeen}"); +#endif } } @@ -679,7 +757,7 @@ private void WebView_DocumentLoaded(object? sender, WebViewLoadedEventArgs e) } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] WebView_DocumentLoaded error: {ex.Message}"); + DebugLog($"[WebChatDialog] WebView_DocumentLoaded error: {ex.Message}"); } } @@ -718,7 +796,7 @@ private void BuildAndEmitSnapshot() } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] BuildAndEmitSnapshot error: {ex.Message}"); + DebugLog($"[WebChatDialog] BuildAndEmitSnapshot error: {ex.Message}"); } } @@ -754,7 +832,7 @@ private void ReplayFullHistoryToWebView() } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] ReplayFullHistoryToWebView error: {ex.Message}"); + DebugLog($"[WebChatDialog] ReplayFullHistoryToWebView error: {ex.Message}"); } } @@ -831,7 +909,7 @@ private void LoadInitialHtmlIntoWebView(bool showProgress, bool setWebViewInitia } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] LoadInitialHtmlIntoWebView UI error: {ex.Message}"); + DebugLog($"[WebChatDialog] LoadInitialHtmlIntoWebView UI error: {ex.Message}"); } }); } @@ -855,7 +933,7 @@ private async Task InitializeWebViewAsync() } catch (Exception rex) { - Debug.WriteLine($"[WebChatDialog] InitializeWebViewAsync replay error: {rex.Message}"); + DebugLog($"[WebChatDialog] InitializeWebViewAsync replay error: {rex.Message}"); } }); @@ -864,7 +942,7 @@ private async Task InitializeWebViewAsync() } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] InitializeWebViewAsync error: {ex.Message}"); + DebugLog($"[WebChatDialog] InitializeWebViewAsync error: {ex.Message}"); try { this._webViewInitializedTcs.TrySetException(ex); @@ -910,7 +988,7 @@ private void ClearChat() } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] ClearChat error: {ex.Message}"); + DebugLog($"[WebChatDialog] ClearChat error: {ex.Message}"); } } @@ -921,7 +999,7 @@ private async Task ProcessAIInteraction() { try { - Debug.WriteLine("[WebChatDialog] Processing AI interaction with existing session reuse"); + DebugLog("[WebChatDialog] Processing AI interaction with existing session reuse"); // Enter processing state: disable input/send, enable cancel in the web UI this.RunWhenWebViewReady(() => this.ExecuteScript("setProcessing(true);")); @@ -936,7 +1014,7 @@ private async Task ProcessAIInteraction() } else { - Debug.WriteLine("[WebChatDialog] Reusing existing ConversationSession"); + DebugLog("[WebChatDialog] Reusing existing ConversationSession"); } // Add the pending user message to the session @@ -956,25 +1034,22 @@ private async Task ProcessAIInteraction() sessionRequest.WantsStreaming = true; var validation = sessionRequest.IsValid(); shouldTryStreaming = validation.IsValid; - Debug.WriteLine($"[WebChatDialog] Request validation: IsValid={validation.IsValid}, Errors={validation.Errors?.Count ?? 0}"); + DebugLog($"[WebChatDialog] Request validation: IsValid={validation.IsValid}, Errors={validation.Errors?.Count ?? 0}"); if (validation.Errors != null) { +#if DEBUG try { var msgs = string.Join(" | ", validation.Errors.Select(err => $"{err.Severity}:{err.Message}")); - Debug.WriteLine($"[WebChatDialog] Validation messages: {msgs}"); + DebugLog($"[WebChatDialog] Validation messages: {msgs}"); } catch { /* ignore logging errors */ } - } - - if (!shouldTryStreaming) - { - Debug.WriteLine("[WebChatDialog] Streaming validation failed, will fallback to non-streaming."); +#endif } } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Streaming validation threw: {ex.Message}. Falling back."); + DebugLog($"[WebChatDialog] Streaming validation threw: {ex.Message}. Falling back."); shouldTryStreaming = false; } finally @@ -986,7 +1061,7 @@ private async Task ProcessAIInteraction() AIReturn? lastStreamReturn = null; if (shouldTryStreaming) { - Debug.WriteLine("[WebChatDialog] Starting streaming path"); + DebugLog("[WebChatDialog] Starting streaming path"); var streamingOptions = new StreamingOptions(); // Consume the stream to drive incremental UI updates via observer @@ -1012,7 +1087,7 @@ private async Task ProcessAIInteraction() // If streaming finished with an error or yielded nothing or no streaming was attempted, fallback to non-streaming. if (streamingFailed || !shouldTryStreaming) { - Debug.WriteLine("[WebChatDialog] Streaming ended with error or no result. Falling back to non-streaming path"); + DebugLog("[WebChatDialog] Streaming ended with error or no result. Falling back to non-streaming path"); // Ensure streaming flag is not set for non-streaming execution sessionRequest.WantsStreaming = false; @@ -1025,7 +1100,7 @@ private async Task ProcessAIInteraction() } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Error in ProcessAIInteraction: {ex.Message}"); + DebugLog($"[WebChatDialog] Error in ProcessAIInteraction: {ex.Message}"); try { this.AddSystemMessage($"Error: {ex.Message}", "error"); @@ -1066,11 +1141,11 @@ private void CancelCurrentRun() { this._currentCts?.Cancel(); this._currentSession?.Cancel(); - Debug.WriteLine("[WebChatDialog] Cancellation requested"); + DebugLog("[WebChatDialog] Cancellation requested"); } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Error requesting cancellation: {ex.Message}"); + DebugLog($"[WebChatDialog] Error requesting cancellation: {ex.Message}"); } } @@ -1102,13 +1177,13 @@ private async void InitializeNewConversation() } catch (Exception grex) { - Debug.WriteLine($"[WebChatDialog] Greeting init error: {grex.Message}"); + DebugLog($"[WebChatDialog] Greeting init error: {grex.Message}"); } } } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Error in InitializeNewConversation: {ex.Message}"); + DebugLog($"[WebChatDialog] Error in InitializeNewConversation: {ex.Message}"); } } @@ -1121,29 +1196,29 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) { try { - Debug.WriteLine($"[WebChatDialog] WebView_DocumentLoading called"); + DebugLog($"[WebChatDialog] WebView_DocumentLoading called"); if (e?.Uri is not Uri uri) { - Debug.WriteLine($"[WebChatDialog] Navigation URI is null"); + DebugLog($"[WebChatDialog] Navigation URI is null"); return; } - Debug.WriteLine($"[WebChatDialog] Navigation URI: {uri} (scheme: {uri.Scheme})"); + DebugLog($"[WebChatDialog] Navigation URI: {uri} (scheme: {uri.Scheme})"); if (uri.Scheme.Equals("sh", StringComparison.OrdinalIgnoreCase)) { - Debug.WriteLine($"[WebChatDialog] Intercepting sh:// scheme, cancelling navigation"); + DebugLog($"[WebChatDialog] Intercepting sh:// scheme, cancelling navigation"); e.Cancel = true; var query = ParseQueryString(uri.Query); var type = (query.TryGetValue("type", out var t) ? t : string.Empty).ToLowerInvariant(); - Debug.WriteLine($"[WebChatDialog] sh:// event type: '{type}', query params: {string.Join(", ", query.Keys)}"); + DebugLog($"[WebChatDialog] sh:// event type: '{type}', query params: {string.Join(", ", query.Keys)}"); switch (type) { case "send": { var text = query.TryGetValue("text", out var txt) ? txt : string.Empty; - Debug.WriteLine($"[WebChatDialog] Handling send event, text length: {text.Length}"); + DebugLog($"[WebChatDialog] Handling send event, text length: {text.Length}"); // Defer to next UI tick to avoid executing scripts during navigation event Application.Instance?.AsyncInvoke(() => @@ -1154,14 +1229,14 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Deferred SendMessage error: {ex.Message}"); + DebugLog($"[WebChatDialog] Deferred SendMessage error: {ex.Message}"); } }); break; } case "clear": - Debug.WriteLine($"[WebChatDialog] Handling clear event"); + DebugLog($"[WebChatDialog] Handling clear event"); // Defer to next UI tick to avoid executing scripts during navigation event Application.Instance?.AsyncInvoke(() => @@ -1172,13 +1247,13 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Deferred ClearChat error: {ex.Message}"); + DebugLog($"[WebChatDialog] Deferred ClearChat error: {ex.Message}"); } }); break; case "cancel": - Debug.WriteLine($"[WebChatDialog] Handling cancel event"); + DebugLog($"[WebChatDialog] Handling cancel event"); // Defer to next UI tick to avoid executing scripts during navigation event Application.Instance?.AsyncInvoke(() => @@ -1189,25 +1264,25 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Deferred CancelChat error: {ex.Message}"); + DebugLog($"[WebChatDialog] Deferred CancelChat error: {ex.Message}"); } }); break; default: - Debug.WriteLine($"[WebChatDialog] Unknown sh:// event type: '{type}'"); + DebugLog($"[WebChatDialog] Unknown sh:// event type: '{type}'"); break; } } else if (uri.Scheme.Equals("clipboard", StringComparison.OrdinalIgnoreCase)) { - Debug.WriteLine($"[WebChatDialog] Intercepting clipboard:// scheme"); + DebugLog($"[WebChatDialog] Intercepting clipboard:// scheme"); // Handle copy-to-clipboard from JS e.Cancel = true; var query = ParseQueryString(uri.Query); var text = query.TryGetValue("text", out var t) ? t : string.Empty; - Debug.WriteLine($"[WebChatDialog] Clipboard text length: {text.Length}"); + DebugLog($"[WebChatDialog] Clipboard text length: {text.Length}"); try { RhinoApp.InvokeOnUiThread(() => @@ -1216,28 +1291,28 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) { var cb = new Clipboard(); cb.Text = text; - Debug.WriteLine($"[WebChatDialog] Text copied to clipboard successfully"); + DebugLog($"[WebChatDialog] Text copied to clipboard successfully"); } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Clipboard set failed: {ex.Message}"); + DebugLog($"[WebChatDialog] Clipboard set failed: {ex.Message}"); } }); this.RunWhenWebViewReady(() => this.ExecuteScript("showToast('Copied to clipboard');")); } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] Clipboard handling error: {ex.Message}"); + DebugLog($"[WebChatDialog] Clipboard handling error: {ex.Message}"); } } else { - Debug.WriteLine($"[WebChatDialog] Allowing normal navigation to: {uri}"); + DebugLog($"[WebChatDialog] Allowing normal navigation to: {uri}"); } } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] WebView_DocumentLoading error: {ex.Message}"); + DebugLog($"[WebChatDialog] WebView_DocumentLoading error: {ex.Message}"); } } @@ -1248,10 +1323,10 @@ private void SendMessage(string text) { try { - Debug.WriteLine($"[WebChatDialog] SendMessage called with text length: {text?.Length ?? 0}"); + DebugLog($"[WebChatDialog] SendMessage called with text length: {text?.Length ?? 0}"); if (string.IsNullOrWhiteSpace(text)) { - Debug.WriteLine($"[WebChatDialog] SendMessage: text is null or whitespace, returning"); + DebugLog($"[WebChatDialog] SendMessage: text is null or whitespace, returning"); return; } @@ -1277,24 +1352,24 @@ private void SendMessage(string text) this.RunWhenWebViewReady(() => this.ExecuteScript("setProcessing(true);")); // Kick off processing asynchronously - Debug.WriteLine("[WebChatDialog] Scheduling ProcessAIInteraction task"); + DebugLog("[WebChatDialog] Scheduling ProcessAIInteraction task"); Task.Run(async () => { try { - Debug.WriteLine("[WebChatDialog] ProcessAIInteraction task starting"); + DebugLog("[WebChatDialog] ProcessAIInteraction task starting"); await this.ProcessAIInteraction().ConfigureAwait(false); - Debug.WriteLine("[WebChatDialog] ProcessAIInteraction task finished"); + DebugLog("[WebChatDialog] ProcessAIInteraction task finished"); } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] ProcessAIInteraction task error: {ex.Message}"); + DebugLog($"[WebChatDialog] ProcessAIInteraction task error: {ex.Message}"); } }); } catch (Exception ex) { - Debug.WriteLine($"[WebChatDialog] SendMessage(text) error: {ex.Message}"); + DebugLog($"[WebChatDialog] SendMessage(text) error: {ex.Message}"); } } diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index 14511480..bc5d582f 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -40,6 +40,12 @@ private sealed class StreamState public IAIInteraction Aggregated; } + [Conditional("DEBUG")] + private static void DebugLog(string message) + { + Debug.WriteLine(message); + } + /// /// Returns the current segmented text key for a base key without creating a new segment. /// @@ -90,7 +96,7 @@ private void CommitSegment(string baseKey, string turnKey) var beforeSegment = this._textInteractionSegments.TryGetValue(baseKey, out var seg) ? seg : 0; var hasBoundary = !string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey); - Debug.WriteLine($"[WebChatObserver] CommitSegment: baseKey={baseKey}, turnKey={turnKey}, beforeSeg={beforeSegment}, hasBoundary={hasBoundary}"); + DebugLog($"[WebChatObserver] CommitSegment: baseKey={baseKey}, turnKey={turnKey}, beforeSeg={beforeSegment}, hasBoundary={hasBoundary}"); // Consume boundary flag and increment if applicable this.ConsumeBoundaryAndIncrementSegment(turnKey, baseKey); @@ -99,12 +105,12 @@ private void CommitSegment(string baseKey, string turnKey) if (!this._textInteractionSegments.ContainsKey(baseKey)) { this._textInteractionSegments[baseKey] = 1; - Debug.WriteLine($"[WebChatObserver] CommitSegment: initialized baseKey={baseKey} to seg=1"); + DebugLog($"[WebChatObserver] CommitSegment: initialized baseKey={baseKey} to seg=1"); } else { var afterSegment = this._textInteractionSegments[baseKey]; - Debug.WriteLine($"[WebChatObserver] CommitSegment: baseKey={baseKey} already exists, seg={afterSegment}"); + this.LogDelta($"[WebChatObserver] CommitSegment: baseKey={baseKey} already exists, seg={afterSegment}"); } } @@ -115,7 +121,29 @@ private void CommitSegment(string baseKey, string turnKey) // Simple per-key throttling to reduce DOM churn during streaming private readonly Dictionary _lastUpsertAt = new Dictionary(StringComparer.Ordinal); private const int ThrottleMs = 50; - private const int ThrottleDuringMoveResizeMs = 200; + private const int ThrottleDuringMoveResizeMs = 400; + + private DateTime _lastDeltaLogUtc = DateTime.MinValue; + private const int DeltaLogThrottleMs = 250; + + [Conditional("DEBUG")] + private void LogDelta(string message) + { +#if DEBUG + try + { + var now = DateTime.UtcNow; + if ((now - this._lastDeltaLogUtc).TotalMilliseconds >= DeltaLogThrottleMs) + { + this._lastDeltaLogUtc = now; + DebugLog(message); + } + } + catch + { + } +#endif + } private readonly Dictionary _lastRenderedTextByKey = new Dictionary(StringComparer.Ordinal); @@ -193,10 +221,10 @@ public WebChatObserver(WebChatDialog dialog) /// The request about to be executed. public void OnStart(AIRequestCall request) { - Debug.WriteLine("[WebChatObserver] OnStart called"); + DebugLog("[WebChatObserver] OnStart called"); RhinoApp.InvokeOnUiThread(() => { - Debug.WriteLine("[WebChatObserver] OnStart: executing UI updates"); + DebugLog("[WebChatObserver] OnStart: executing UI updates"); // Reset per-run state this._streams.Clear(); @@ -213,7 +241,7 @@ public void OnStart(AIRequestCall request) this._thinkingBubbleActive = true; // No assistant-specific state to reset - Debug.WriteLine("[WebChatObserver] OnStart: UI updates completed"); + DebugLog("[WebChatObserver] OnStart: UI updates completed"); }); } @@ -235,7 +263,7 @@ private void RemoveThinkingBubbleIfActive() } catch (Exception ex) { - Debug.WriteLine($"[WebChatObserver] RemoveThinkingBubbleIfActive error: {ex.Message}"); + DebugLog($"[WebChatObserver] RemoveThinkingBubbleIfActive error: {ex.Message}"); } finally { @@ -274,14 +302,14 @@ public void OnDelta(IAIInteraction interaction) // Check if we already have a committed segment for this base key bool isCommitted = this._textInteractionSegments.ContainsKey(baseKey); bool hasBoundary = !string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey); - Debug.WriteLine($"[WebChatObserver] OnDelta: baseKey={baseKey}, turnKey={turnKey}, isCommitted={isCommitted}, hasBoundary={hasBoundary}"); + this.LogDelta($"[WebChatObserver] OnDelta: baseKey={baseKey}, turnKey={turnKey}, isCommitted={isCommitted}, hasBoundary={hasBoundary}"); // Determine the target key. If a boundary is pending while already committed, // roll over to a NEW segment now so subsequent deltas do not append to the previous bubble. string targetKey; if (isCommitted && hasBoundary) { - Debug.WriteLine($"[WebChatObserver] OnDelta: boundary pending -> rolling over to next segment for baseKey={baseKey}"); + this.LogDelta($"[WebChatObserver] OnDelta: boundary pending -> rolling over to next segment for baseKey={baseKey}"); this.CommitSegment(baseKey, turnKey); // consumes boundary and increments segment var segKey = this.GetCurrentSegmentedKey(baseKey); @@ -324,15 +352,15 @@ public void OnDelta(IAIInteraction interaction) // Check if text is now renderable var aggregatedText = state.Aggregated as AIInteractionText; bool isRenderable = HasRenderableText(aggregatedText); - Debug.WriteLine($"[WebChatObserver] OnDelta: isRenderable={isRenderable}, isCommitted={isCommitted}"); + this.LogDelta($"[WebChatObserver] OnDelta: isRenderable={isRenderable}, isCommitted={isCommitted}"); if (isRenderable && !isCommitted) { // First renderable delta: commit the segment now - Debug.WriteLine($"[WebChatObserver] OnDelta: FIRST RENDER - committing segment"); + this.LogDelta($"[WebChatObserver] OnDelta: FIRST RENDER - committing segment"); this.CommitSegment(baseKey, turnKey); var segKey = this.GetCurrentSegmentedKey(baseKey); - Debug.WriteLine($"[WebChatObserver] OnDelta: committed segKey={segKey}"); + this.LogDelta($"[WebChatObserver] OnDelta: committed segKey={segKey}"); // Move from pre-commit to committed storage this._streams[segKey] = state; @@ -377,13 +405,13 @@ public void OnDelta(IAIInteraction interaction) } catch (Exception innerEx) { - Debug.WriteLine($"[WebChatObserver] OnDelta processing error: {innerEx.Message}"); + DebugLog($"[WebChatObserver] OnDelta processing error: {innerEx.Message}"); } }); } catch (Exception ex) { - Debug.WriteLine($"[WebChatObserver] OnDelta error: {ex.Message}"); + DebugLog($"[WebChatObserver] OnDelta error: {ex.Message}"); } } @@ -422,7 +450,7 @@ public void OnInteractionCompleted(IAIInteraction interaction) var activeSegKey = isCommitted ? this.GetCurrentSegmentedKey(baseKey) : null; var hasBoundary = !string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey); #if DEBUG - Debug.WriteLine($"[WebChatObserver] OnInteractionCompleted(Text): baseKey={baseKey}, turnKey={turnKey}, isCommitted={isCommitted}, hasBoundary={hasBoundary}, contentLen={tt.Content?.Length ?? 0}"); + DebugLog($"[WebChatObserver] OnInteractionCompleted(Text): baseKey={baseKey}, turnKey={turnKey}, isCommitted={isCommitted}, hasBoundary={hasBoundary}, contentLen={tt.Content?.Length ?? 0}"); #endif // Check for existing streaming aggregate (either committed or pre-commit) @@ -467,10 +495,10 @@ public void OnInteractionCompleted(IAIInteraction interaction) // True non-streaming completion path: no prior aggregate // Commit segment immediately since we have renderable content - Debug.WriteLine($"[WebChatObserver] OnInteractionCompleted(Text): NON-STREAMING path - committing segment"); + DebugLog($"[WebChatObserver] OnInteractionCompleted(Text): NON-STREAMING path - committing segment"); this.CommitSegment(baseKey, turnKey); var finalSegKey = this.GetCurrentSegmentedKey(baseKey); - Debug.WriteLine($"[WebChatObserver] OnInteractionCompleted(Text): finalSegKey={finalSegKey}"); + DebugLog($"[WebChatObserver] OnInteractionCompleted(Text): finalSegKey={finalSegKey}"); var state = new StreamState { Started = true, Aggregated = tt }; this._streams[finalSegKey] = state; @@ -488,7 +516,7 @@ public void OnInteractionCompleted(IAIInteraction interaction) var streamKey = GetStreamKey(interaction); var turnKey = GetTurnBaseKey(interaction?.TurnId); #if DEBUG - Debug.WriteLine($"[WebChatObserver] OnInteractionCompleted(Non-Text): type={interaction.GetType().Name}, streamKey={streamKey}, turnKey={turnKey}"); + DebugLog($"[WebChatObserver] OnInteractionCompleted(Non-Text): type={interaction.GetType().Name}, streamKey={streamKey}, turnKey={turnKey}"); #endif if (string.IsNullOrWhiteSpace(streamKey)) @@ -518,13 +546,13 @@ public void OnInteractionCompleted(IAIInteraction interaction) } catch (Exception innerEx) { - Debug.WriteLine($"[WebChatObserver] OnInteractionCompleted processing error: {innerEx.Message}"); + DebugLog($"[WebChatObserver] OnInteractionCompleted processing error: {innerEx.Message}"); } }); } catch (Exception ex) { - Debug.WriteLine($"[WebChatObserver] OnInteractionCompleted error: {ex.Message}"); + DebugLog($"[WebChatObserver] OnInteractionCompleted error: {ex.Message}"); } } @@ -544,7 +572,7 @@ public void OnToolCall(AIInteractionToolCall toolCall) // Mark a boundary so the next assistant text begins a new segment. var turnKey = GetTurnBaseKey(toolCall?.TurnId); - Debug.WriteLine($"[WebChatObserver] OnToolCall: name={toolCall?.Name}, turnKey={turnKey} -> SetBoundaryFlag"); + DebugLog($"[WebChatObserver] OnToolCall: name={toolCall?.Name}, turnKey={turnKey} -> SetBoundaryFlag"); this.SetBoundaryFlag(turnKey); }); } @@ -562,7 +590,7 @@ public void OnToolResult(AIInteractionToolResult toolResult) // Mark a boundary immediately so subsequent assistant text starts a new segment (seg rollover happens on next delta). var turnKey = GetTurnBaseKey(toolResult?.TurnId); - Debug.WriteLine($"[WebChatObserver] OnToolResult: name={toolResult?.Name}, id={toolResult?.Id}, turnKey={turnKey} -> SetBoundaryFlag"); + DebugLog($"[WebChatObserver] OnToolResult: name={toolResult?.Name}, id={toolResult?.Id}, turnKey={turnKey} -> SetBoundaryFlag"); this.SetBoundaryFlag(turnKey); } @@ -673,13 +701,13 @@ public void OnFinal(AIReturn result) // Single final debug log for this interaction var turnId = (toRender as AIInteractionText)?.TurnId ?? finalAssistant?.TurnId; var length = (toRender as AIInteractionText)?.Content?.Length ?? 0; - Debug.WriteLine($"[WebChatObserver] Final render: turn={turnId}, key={upsertKey}, len={length}"); + DebugLog($"[WebChatObserver] Final render: turn={turnId}, key={upsertKey}, len={length}"); this._dialog.UpsertMessageByKey(upsertKey, toRender, source: "OnFinal"); } } catch (Exception repEx) { - Debug.WriteLine($"[WebChatObserver] OnFinal finalize UI error: {repEx.Message}"); + DebugLog($"[WebChatObserver] OnFinal finalize UI error: {repEx.Message}"); } // Now that final assistant is rendered, remove the thinking bubble and set status @@ -740,7 +768,7 @@ public void OnError(Exception ex) } catch (Exception uiEx) { - Debug.WriteLine($"[WebChatObserver] OnError UI error: {uiEx.Message}"); + DebugLog($"[WebChatObserver] OnError UI error: {uiEx.Message}"); } }); } @@ -833,7 +861,7 @@ private void SetBoundaryFlag(string turnKey) if (!string.IsNullOrWhiteSpace(turnKey)) { var wasAdded = this._pendingNewTextSegmentTurns.Add(turnKey); - Debug.WriteLine($"[WebChatObserver] SetBoundaryFlag: turnKey={turnKey}, wasNew={wasAdded}"); + DebugLog($"[WebChatObserver] SetBoundaryFlag: turnKey={turnKey}, wasNew={wasAdded}"); } } @@ -851,13 +879,13 @@ private void ConsumeBoundaryAndIncrementSegment(string turnKey, string baseKey) { var oldSeg = this._textInteractionSegments[baseKey]; this._textInteractionSegments[baseKey] = oldSeg + 1; - Debug.WriteLine($"[WebChatObserver] ConsumeBoundaryAndIncrementSegment: turnKey={turnKey}, baseKey={baseKey}, {oldSeg} -> {oldSeg + 1}"); + DebugLog($"[WebChatObserver] ConsumeBoundaryAndIncrementSegment: turnKey={turnKey}, baseKey={baseKey}, {oldSeg} -> {oldSeg + 1}"); } #if DEBUG else { - Debug.WriteLine($"[WebChatObserver] ConsumeBoundaryAndIncrementSegment: turnKey={turnKey}, baseKey={baseKey}, hadBoundary={hadBoundary}, hasSegment={hasSegment}, NO INCREMENT"); + DebugLog($"[WebChatObserver] ConsumeBoundaryAndIncrementSegment: turnKey={turnKey}, baseKey={baseKey}, hadBoundary={hadBoundary}, hasSegment={hasSegment}, NO INCREMENT"); } #endif diff --git a/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs b/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs index aea8d01c..1d22bc3f 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs @@ -33,7 +33,7 @@ private static void EnsureEtoApplication() { if (Application.Instance == null) { - Debug.WriteLine("[WebChatUtils] Initializing Eto.Forms application"); + DebugLog("[WebChatUtils] Initializing Eto.Forms application"); var platform = Eto.Platform.Detect; new Application(platform).Attach(); } @@ -67,7 +67,7 @@ private static WebChatDialog OpenOrReuseDialogInternal( if (componentId != default && OpenDialogs.TryGetValue(componentId, out WebChatDialog existingDialog)) { - Debug.WriteLine("[WebChatUtils] Reusing existing dialog for component"); + DebugLog("[WebChatUtils] Reusing existing dialog for component"); BringToFrontAndFocus(existingDialog); AttachOrReplaceUpdateHandler(componentId, existingDialog, progressReporter, onUpdate, pushCurrentImmediately: pushCurrentImmediately); @@ -81,7 +81,7 @@ private static WebChatDialog OpenOrReuseDialogInternal( return existingDialog; } - Debug.WriteLine("[WebChatUtils] Creating web chat dialog"); + DebugLog("[WebChatUtils] Creating web chat dialog"); var dialog = new WebChatDialog(request, progressReporter, generateGreeting: generateGreeting); if (componentId != default) { @@ -138,7 +138,7 @@ private static void AttachOrReplaceUpdateHandler( } catch (Exception updEx) { - Debug.WriteLine($"[WebChatUtils] ChatUpdated handler error: {updEx.Message}"); + DebugLog($"[WebChatUtils] ChatUpdated handler error: {updEx.Message}"); } }; @@ -169,7 +169,7 @@ private static void WireClosedCleanup(Guid componentId, WebChatDialog dialog, Ta { dialog.Closed += (sender, e) => { - Debug.WriteLine("[WebChatUtils] Dialog closed"); + DebugLog("[WebChatUtils] Dialog closed"); try { if (componentId != default) @@ -186,7 +186,7 @@ private static void WireClosedCleanup(Guid componentId, WebChatDialog dialog, Ta } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] Closed cleanup error: {ex.Message}"); + DebugLog($"[WebChatUtils] Closed cleanup error: {ex.Message}"); } }; } diff --git a/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs b/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs index 614aa9f3..f9f98ba8 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs @@ -31,6 +31,12 @@ namespace SmartHopper.Core.UI.Chat /// public static partial class WebChatUtils { + [Conditional("DEBUG")] + private static void DebugLog(string message) + { + Debug.WriteLine(message); + } + /// /// Dictionary to track open chat dialogs by component instance ID. /// @@ -57,7 +63,7 @@ static WebChatUtils() /// The event arguments. private static void OnRhinoClosing(object sender, EventArgs e) { - Debug.WriteLine("[WebChatUtils] Rhino is closing, cleaning up open chat dialogs"); + DebugLog("[WebChatUtils] Rhino is closing, cleaning up open chat dialogs"); try { @@ -68,7 +74,7 @@ private static void OnRhinoClosing(object sender, EventArgs e) { if (OpenDialogs.TryGetValue(dialogId, out WebChatDialog dialog)) { - Debug.WriteLine($"[WebChatUtils] Closing dialog for component {dialogId}"); + DebugLog($"[WebChatUtils] Closing dialog for component {dialogId}"); // Close the dialog on the UI thread to ensure proper cleanup Rhino.RhinoApp.InvokeOnUiThread(() => @@ -94,7 +100,7 @@ private static void OnRhinoClosing(object sender, EventArgs e) } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] Error closing dialog {dialogId}: {ex.Message}"); + DebugLog($"[WebChatUtils] Error closing dialog {dialogId}: {ex.Message}"); } }); @@ -103,11 +109,11 @@ private static void OnRhinoClosing(object sender, EventArgs e) } } - Debug.WriteLine($"[WebChatUtils] Cleanup complete. Closed {dialogIds.Length} dialogs"); + DebugLog($"[WebChatUtils] Cleanup complete. Closed {dialogIds.Length} dialogs"); } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] Error during dialog cleanup: {ex.Message}"); + DebugLog($"[WebChatUtils] Error during dialog cleanup: {ex.Message}"); } } @@ -152,7 +158,7 @@ public static void Unsubscribe(Guid componentId) } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] Unsubscribe error for {componentId}: {ex.Message}"); + DebugLog($"[WebChatUtils] Unsubscribe error for {componentId}: {ex.Message}"); } } @@ -172,7 +178,7 @@ public static void Unsubscribe(Guid componentId) } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] TryGetLastReturn error for {componentId}: {ex.Message}"); + DebugLog($"[WebChatUtils] TryGetLastReturn error for {componentId}: {ex.Message}"); } return null; @@ -202,7 +208,7 @@ public static async Task ShowWebChatDialog( var tcs = new TaskCompletionSource(); AIReturn? lastReturn = null; - Debug.WriteLine("[WebChatUtils] Preparing to show web chat dialog"); + DebugLog("[WebChatUtils] Preparing to show web chat dialog"); try { @@ -225,13 +231,13 @@ public static async Task ShowWebChatDialog( // Mirror previous logging and keep a local lastReturn if needed by callers dialog.ResponseReceived += (sender, result) => { - Debug.WriteLine("[WebChatUtils] Response received from dialog"); + DebugLog("[WebChatUtils] Response received from dialog"); lastReturn = result; }; } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] Error in UI thread: {ex.Message}"); + DebugLog($"[WebChatUtils] Error in UI thread: {ex.Message}"); tcs.TrySetException(ex); } }); @@ -241,7 +247,7 @@ public static async Task ShowWebChatDialog( } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] Error showing web chat dialog: {ex.Message}"); + DebugLog($"[WebChatUtils] Error showing web chat dialog: {ex.Message}"); throw; } } @@ -297,13 +303,13 @@ public static void EnsureDialogOpen( } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] EnsureDialogOpen UI error: {ex.Message}"); + DebugLog($"[WebChatUtils] EnsureDialogOpen UI error: {ex.Message}"); } }); } catch (Exception ex) { - Debug.WriteLine($"[WebChatUtils] EnsureDialogOpen error: {ex.Message}"); + DebugLog($"[WebChatUtils] EnsureDialogOpen error: {ex.Message}"); throw; } } diff --git a/src/SmartHopper.Core/UI/DialogCanvasLink.cs b/src/SmartHopper.Core/UI/DialogCanvasLink.cs index 5f23221f..bed139ae 100644 --- a/src/SmartHopper.Core/UI/DialogCanvasLink.cs +++ b/src/SmartHopper.Core/UI/DialogCanvasLink.cs @@ -34,6 +34,12 @@ namespace SmartHopper.Core.UI /// public static class DialogCanvasLink { + [Conditional("DEBUG")] + private static void DebugLog(string message) + { + Debug.WriteLine(message); + } + private static readonly object LockObject = new object(); private static readonly Dictionary ActiveLinks = new Dictionary(); private static bool isHooked; @@ -72,17 +78,17 @@ private class LinkInfo /// If true, centers the canvas view on the component before showing the dialog. public static void RegisterLink(Window dialog, Guid instanceGuid, Color? lineColor = null, float lineWidth = 3f, bool centerCanvas = true) { - Debug.WriteLine($"[DialogCanvasLink] RegisterLink called: dialog={dialog != null}, guid={instanceGuid}"); + DebugLog($"[DialogCanvasLink] RegisterLink called: dialog={dialog != null}, guid={instanceGuid}"); if (dialog == null) { - Debug.WriteLine("[DialogCanvasLink] RegisterLink aborted: dialog is null"); + DebugLog("[DialogCanvasLink] RegisterLink aborted: dialog is null"); return; } if (instanceGuid == Guid.Empty) { - Debug.WriteLine("[DialogCanvasLink] RegisterLink aborted: instanceGuid is empty"); + DebugLog("[DialogCanvasLink] RegisterLink aborted: instanceGuid is empty"); return; } @@ -111,7 +117,7 @@ public static void RegisterLink(Window dialog, Guid instanceGuid, Color? lineCol dialog.LocationChanged += OnDialogLocationChanged; dialog.SizeChanged += OnDialogSizeChanged; - Debug.WriteLine($"[DialogCanvasLink] Registered link: Dialog → Component {instanceGuid}, ActiveLinks count: {ActiveLinks.Count}"); + DebugLog($"[DialogCanvasLink] Registered link: Dialog → Component {instanceGuid}, ActiveLinks count: {ActiveLinks.Count}"); // Trigger canvas redraw to show the link RefreshCanvas(); @@ -136,7 +142,7 @@ public static void UnregisterLink(Window dialog) dialog.Closed -= OnDialogClosed; dialog.LocationChanged -= OnDialogLocationChanged; dialog.SizeChanged -= OnDialogSizeChanged; - Debug.WriteLine("[DialogCanvasLink] Unregistered link"); + DebugLog("[DialogCanvasLink] Unregistered link"); RefreshCanvas(); } } @@ -175,11 +181,11 @@ public static Guid GetLinkedGuid(Window dialog) private static void EnsureHooked() { - Debug.WriteLine($"[DialogCanvasLink] EnsureHooked called, isHooked={isHooked}"); + DebugLog($"[DialogCanvasLink] EnsureHooked called, isHooked={isHooked}"); if (isHooked) { - Debug.WriteLine("[DialogCanvasLink] Already hooked, skipping"); + DebugLog("[DialogCanvasLink] Already hooked, skipping"); return; } @@ -187,25 +193,25 @@ private static void EnsureHooked() { // Hook into canvas created event for new canvases Instances.CanvasCreated += OnCanvasCreated; - Debug.WriteLine("[DialogCanvasLink] Subscribed to CanvasCreated event"); + DebugLog("[DialogCanvasLink] Subscribed to CanvasCreated event"); // Hook into existing canvas if available if (Instances.ActiveCanvas != null) { - Debug.WriteLine("[DialogCanvasLink] ActiveCanvas found, hooking..."); + DebugLog("[DialogCanvasLink] ActiveCanvas found, hooking..."); HookCanvas(Instances.ActiveCanvas); } else { - Debug.WriteLine("[DialogCanvasLink] No ActiveCanvas found"); + DebugLog("[DialogCanvasLink] No ActiveCanvas found"); } isHooked = true; - Debug.WriteLine("[DialogCanvasLink] Canvas events hooked successfully"); + DebugLog("[DialogCanvasLink] Canvas events hooked successfully"); } catch (Exception ex) { - Debug.WriteLine($"[DialogCanvasLink] Error hooking canvas: {ex.Message}"); + DebugLog($"[DialogCanvasLink] Error hooking canvas: {ex.Message}"); } } @@ -218,17 +224,17 @@ private static void HookCanvas(GH_Canvas canvas) { if (canvas == null) { - Debug.WriteLine("[DialogCanvasLink] HookCanvas: canvas is null"); + DebugLog("[DialogCanvasLink] HookCanvas: canvas is null"); return; } - Debug.WriteLine("[DialogCanvasLink] HookCanvas: Hooking canvas paint event"); + DebugLog("[DialogCanvasLink] HookCanvas: Hooking canvas paint event"); // Use CanvasPostPaintOverlay to draw on top of everything canvas.CanvasPostPaintOverlay -= OnCanvasPostPaintOverlay; canvas.CanvasPostPaintOverlay += OnCanvasPostPaintOverlay; - Debug.WriteLine("[DialogCanvasLink] HookCanvas: Canvas hooked successfully"); + DebugLog("[DialogCanvasLink] HookCanvas: Canvas hooked successfully"); } private static void OnCanvasPostPaintOverlay(GH_Canvas canvas) @@ -240,12 +246,12 @@ private static void OnCanvasPostPaintOverlay(GH_Canvas canvas) return; } - Debug.WriteLine($"[DialogCanvasLink] OnCanvasPostPaintOverlay: ActiveLinks={ActiveLinks.Count}"); + DebugLog($"[DialogCanvasLink] OnCanvasPostPaintOverlay: ActiveLinks={ActiveLinks.Count}"); var doc = canvas.Document; if (doc == null) { - Debug.WriteLine("[DialogCanvasLink] OnCanvasPostPaintOverlay: Document is null"); + DebugLog("[DialogCanvasLink] OnCanvasPostPaintOverlay: Document is null"); return; } @@ -258,7 +264,7 @@ private static void OnCanvasPostPaintOverlay(GH_Canvas canvas) var component = doc.FindObject(linkInfo.InstanceGuid, true); if (component == null) { - Debug.WriteLine($"[DialogCanvasLink] Component not found: {linkInfo.InstanceGuid}"); + DebugLog($"[DialogCanvasLink] Component not found: {linkInfo.InstanceGuid}"); continue; } @@ -272,11 +278,11 @@ private static void OnCanvasPostPaintOverlay(GH_Canvas canvas) var dialogScreenPos = GetDialogScreenPosition(dialog); if (!dialogScreenPos.HasValue) { - Debug.WriteLine("[DialogCanvasLink] Could not get dialog screen position"); + DebugLog("[DialogCanvasLink] Could not get dialog screen position"); continue; } - Debug.WriteLine($"[DialogCanvasLink] Drawing link: component={componentCenter}, dialogScreen={dialogScreenPos.Value}"); + DebugLog($"[DialogCanvasLink] Drawing link: component={componentCenter}, dialogScreen={dialogScreenPos.Value}"); // Convert dialog screen position to canvas coordinates var dialogCanvasPos = canvas.Viewport.UnprojectPoint( @@ -284,7 +290,7 @@ private static void OnCanvasPostPaintOverlay(GH_Canvas canvas) (int)dialogScreenPos.Value.X, (int)dialogScreenPos.Value.Y))); - Debug.WriteLine($"[DialogCanvasLink] Canvas positions: component={componentCenter}, dialog={dialogCanvasPos}"); + DebugLog($"[DialogCanvasLink] Canvas positions: component={componentCenter}, dialog={dialogCanvasPos}"); // Draw the connection with anchor dots at both ends DrawLinkOnCanvas( @@ -406,7 +412,7 @@ private static void DrawConnectionLine( } catch (Exception ex) { - Debug.WriteLine($"[DialogCanvasLink] Error drawing connection: {ex.Message}"); + DebugLog($"[DialogCanvasLink] Error drawing connection: {ex.Message}"); } } @@ -469,7 +475,7 @@ private static void DrawAnchorDot( } catch (Exception ex) { - Debug.WriteLine($"[DialogCanvasLink] Error drawing anchor dot: {ex.Message}"); + DebugLog($"[DialogCanvasLink] Error drawing anchor dot: {ex.Message}"); } } From 0975b9ea2eeb3bf43e4a32045608b0c6df7fe7e5 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:30:35 +0100 Subject: [PATCH 06/26] perf(chat): optimize message rendering with template caching, LRU diffing, and wipe-in animations - Add template cache (`_templateCache`) and LRU HTML cache (`_htmlLru`) to avoid redundant DOM parsing and equality checks during streaming - Implement queued DOM operations (`enqueueDomOp` + `flushDomOps`) batched via `requestAnimationFrame` to reduce layout thrashing - Add sampled equality diffing (25% sample rate) via `shouldSkipBecauseSame` to skip redundant renders when HTML unchanged --- CHANGELOG.md | 7 + docs/Context/index.md | 2 +- docs/UI/Chat/WebView-Bridge.md | 24 ++ docs/UI/Chat/index.md | 18 + .../AIContext/FileContextProvider.cs | 107 +++-- .../UI/Chat/Resources/css/chat-styles.css | 56 ++- .../UI/Chat/Resources/js/chat-script.js | 393 +++++++++++++++--- src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 32 +- 8 files changed, 538 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b82ebbf..66c75637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Chat UI: - Reduced WebChat dialog UI freezes while dragging/resizing during streaming responses by throttling DOM upserts more aggressively and processing DOM updates in smaller batches. + - Mitigated issue [#261](https://github.com/architects-toolkit/SmartHopper/issues/261) by batching WebView DOM operations (JS rAF/timer queue) and debouncing host-side script injection/drain scheduling. + - Reduced redundant DOM work using idempotency caching and sampled diff checks; added lightweight JS render perf counters and slow-render logging. + - Improved rendering performance using template cloning, capped message HTML length, and a transform/opacity wipe-in animation for streaming updates. + +- Context providers: + - Fixed `current-file_selected-count` sometimes returning `0` even when parameters were selected by reading selection on the Rhino UI thread and adding a robust `Attributes.Selected` fallback. + - Added selection breakdown keys: `current-file_selected-component-count`, `current-file_selected-param-count`, and `current-file_selected-objects`. ## [1.2.1-alpha] - 2025-12-07 diff --git a/docs/Context/index.md b/docs/Context/index.md index de3eb067..9fba12c9 100644 --- a/docs/Context/index.md +++ b/docs/Context/index.md @@ -28,7 +28,7 @@ Supply dynamic key-value context injected into `AIBody` so prompts and tools can - `time`: provides `time_current-datetime`, `time_current-timezone` - `environment`: provides `environment_operating-system`, `environment_rhino-version`, `environment_platform` -- `current-file`: provides `current-file-file-name`, `current-file-selected-count`, `current-file-object-count`, `current-file-component-count`, `current-file-param-count`, `current-file-scribble-count`, `current-file-group-count` +- `current-file`: provides `current-file-file-name`, `current-file-selected-count`, `current-file-selected-component-count`, `current-file-selected-param-count`, `current-file-selected-objects`, `current-file-object-count`, `current-file-component-count`, `current-file-param-count`, `current-file-scribble-count`, `current-file-group-count` ## WebChat defaults diff --git a/docs/UI/Chat/WebView-Bridge.md b/docs/UI/Chat/WebView-Bridge.md index e545d92a..cfebde6c 100644 --- a/docs/UI/Chat/WebView-Bridge.md +++ b/docs/UI/Chat/WebView-Bridge.md @@ -71,6 +71,12 @@ This ensures scripts (e.g., `ExecuteScript(...)`) are not executed inside `Docum - `ExecuteScript(string)` always marshals to the UI thread and is called only after the document is fully loaded. - After deferring from `DocumentLoading`, host methods may safely call `ExecuteScript(...)` (e.g., `addMessage`, `setStatus`, `setProcessing`). +### Performance rules + +- The host drains queued DOM work in small batches and debounces drain scheduling to coalesce bursts of updates. +- `ExecuteScript(...)` enforces a small concurrency gate to avoid piling scripts into the WebView and stalling the UI. +- The WebView batches DOM mutations using a `requestAnimationFrame` (rAF) queue (with a small timeout fallback) so streaming updates don't trigger a DOM write per delta. + ## JS ↔ Host API JavaScript functions (in `chat-script.js`): @@ -99,6 +105,24 @@ Host functions (in `WebChatDialog.cs` / `WebChatObserver.cs`): - `GetStreamKey()` → grouping key to coalesce streaming deltas into one bubble. - `GetDedupKey()` → stable identity for persisted entries and hydration. +### Payload conventions + +- **Full HTML payloads**: existing behavior (host sends full HTML bubble as a string). +- **Patch payloads (optional)**: the host may send a JSON string to reduce work during streaming: + - `{"patch":"append","html":"..."}` → append content to `.message-content` of the existing message. + - `{"patch":"replace-content","html":"..."}` → replace `.message-content` innerHTML. + +The WebView only applies patches when a message with the target `key` already exists; otherwise it falls back to full insert/replace. + +### Diffing and caching + +- The WebView maintains a keyed LRU cache of recent HTML payloads and uses **sampled equality checks** to skip redundant DOM writes. +- The host also maintains an idempotency cache per DOM key to avoid reinjecting identical HTML. + +### Lightweight perf counters + +- The WebView samples render-time counters (flushes, renders, slow renders, equality checks) and logs only outliers to keep overhead low. + ## Event types and lifecycle - send diff --git a/docs/UI/Chat/index.md b/docs/UI/Chat/index.md index 4c810e8c..f90de3bb 100644 --- a/docs/UI/Chat/index.md +++ b/docs/UI/Chat/index.md @@ -64,6 +64,24 @@ This Chat UI overview intentionally avoids duplicating the detailed function lis All UI work (including `ExecuteScript`) is marshaled via `RhinoApp.InvokeOnUiThread(...)`. DOM updates are serialized by the dialog to avoid re-entrancy into the WebView’s script engine. +## Performance pipeline (high level) + +- The host minimizes WebView re-entrancy by enqueueing DOM operations (`RunWhenWebViewReady(...)`) and draining them in small batches. +- The drain scheduling is debounced to coalesce bursts of updates into fewer WebView script injections. +- `ExecuteScript(...)` enforces a small concurrency gate to avoid piling scripts into the WebView. + +On the WebView side (`chat-script.js`): + +- Message mutations (`addMessage`, `upsertMessage`, `upsertMessageAfter`, `replaceLastMessageByRole`) are enqueued and flushed using `requestAnimationFrame` (with a small timeout fallback). +- Rendering work is reduced using: + - Template cloning for repeated HTML. + - A keyed LRU cache + sampled diff checks to skip redundant DOM updates. + - Optional patch payloads (JSON `{ patch, html }`) to append/replace only message content during streaming instead of re-sending full bubbles. + - A max message HTML length cap to prevent huge DOM inserts. +- Lightweight perf counters are sampled to keep overhead low; only outlier renders are logged. + +These changes also mitigate issue #261 ("ui eventually freezes on opening webchat"). + ## Clipboard `chat-script.js` uses `clipboard://copy?text=...` for code-block copy. The host intercepts it and sets system clipboard text, then shows a toast in the WebView. diff --git a/src/SmartHopper.Core/AIContext/FileContextProvider.cs b/src/SmartHopper.Core/AIContext/FileContextProvider.cs index ed4f7453..ba2daef7 100644 --- a/src/SmartHopper.Core/AIContext/FileContextProvider.cs +++ b/src/SmartHopper.Core/AIContext/FileContextProvider.cs @@ -23,9 +23,11 @@ using System.Globalization; using System.IO; using System.Linq; +using System.Threading; using Grasshopper; using Grasshopper.Kernel; using Grasshopper.Kernel.Special; +using Rhino; using SmartHopper.Infrastructure.AIContext; namespace SmartHopper.Core.AIContext @@ -56,52 +58,88 @@ public Dictionary GetContext() { try { - var canvas = Instances.ActiveCanvas; - var doc = canvas?.Document; - if (doc == null) - { - return new Dictionary - { - { "file-name", "Untitled" }, - { "selected-count", "0" }, - { "object-count", "0" }, - { "component-count", "0" }, - { "param-count", "0" }, - { "scribble-count", "0" }, - { "group-count", "0" }, - }; - } + string fileName = "Untitled"; + int selectedCount = 0; + int selectedComponentCount = 0; + int selectedParamCount = 0; + string selectedObjects = string.Empty; + int componentCount = 0; + int objectCount = 0; + int paramCount = 0; + int scribbleCount = 0; + int groupCount = 0; - // SelectedObjects() returns all selected IGH_DocumentObject on the active document - int selectedCount = doc.SelectedObjects()?.OfType()?.Count() ?? 0; + // Use ManualResetEventSlim to ensure UI thread work completes before returning + using (var uiThreadComplete = new ManualResetEventSlim(false)) + { + RhinoApp.InvokeOnUiThread( + (Action)(() => + { + try + { + var canvas = Instances.ActiveCanvas; + var doc = canvas?.Document; + if (doc == null) + { + return; + } - // Count total number of components (IGH_Component) in the document - int componentCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + // Count total number of objects/components/params in the document + objectCount = doc.Objects?.Count ?? 0; + componentCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + paramCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + scribbleCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + groupCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; - // Total objects in the document - int objectCount = doc.Objects?.Count ?? 0; + // File name (privacy friendly) + var path = doc.FilePath; + if (!string.IsNullOrWhiteSpace(path)) + { + fileName = Path.GetFileName(path); + } - // Total parameters in the document - int paramCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + // Selected objects. + // NOTE: When this context is queried from background worker threads, selection state can be stale. + // To keep it reliable, we always read it on the Rhino UI thread. + var selected = doc.SelectedObjects()?.OfType()?.ToList(); + if (selected == null || selected.Count == 0) + { + selected = doc.Objects + ?.OfType() + ?.Where(o => o?.Attributes?.Selected == true) + ?.ToList(); + } - // Total scribbles in the document - int scribbleCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + selectedCount = selected?.Count ?? 0; + selectedComponentCount = selected?.OfType()?.Count() ?? 0; + selectedParamCount = selected?.OfType()?.Count() ?? 0; - // Total groups in the document - int groupCount = doc.Objects?.OfType()?.OfType()?.Count() ?? 0; + if (selected != null && selected.Count > 0) + { + selectedObjects = string.Join( + "; ", + selected + .Take(10) + .Select(o => $"{(string.IsNullOrWhiteSpace(o.NickName) ? o.Name : o.NickName)} ({o.GetType().Name})")); + } + } + finally + { + uiThreadComplete.Set(); + } + })); - // File name (privacy friendly) - string fileName = "Untitled"; - var path = doc.FilePath; - if (!string.IsNullOrWhiteSpace(path)) - { - fileName = Path.GetFileName(path); + // Wait for UI thread to complete (timeout after 5 seconds to avoid deadlock) + uiThreadComplete.Wait(TimeSpan.FromSeconds(5)); } return new Dictionary { { "file-name", fileName }, { "selected-count", selectedCount.ToString(CultureInfo.InvariantCulture) }, + { "selected-component-count", selectedComponentCount.ToString(CultureInfo.InvariantCulture) }, + { "selected-param-count", selectedParamCount.ToString(CultureInfo.InvariantCulture) }, + { "selected-objects", selectedObjects }, { "object-count", objectCount.ToString(CultureInfo.InvariantCulture) }, { "component-count", componentCount.ToString(CultureInfo.InvariantCulture) }, { "param-count", paramCount.ToString(CultureInfo.InvariantCulture) }, @@ -116,6 +154,9 @@ public Dictionary GetContext() { { "file-name", "Untitled" }, { "selected-count", "0" }, + { "selected-component-count", "0" }, + { "selected-param-count", "0" }, + { "selected-objects", string.Empty }, { "object-count", "0" }, { "component-count", "0" }, { "param-count", "0" }, diff --git a/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css b/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css index 909e3892..73f53be3 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css +++ b/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css @@ -131,7 +131,7 @@ body { .message-content { border-radius: 10px; padding: 8px 12px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.08); max-width: 100%; overflow-wrap: break-word; word-wrap: break-word; @@ -475,6 +475,60 @@ hr { opacity: 1; } +/* Wipe-in animation for new/updated messages */ +.wipe-in { + animation: wipeIn var(--wipe-duration, 280ms) ease-out forwards; + transform: translate3d(-6px, 0, 0); + opacity: 0; +} + +@keyframes wipeIn { + from { + transform: translate3d(-6px, 0, 0); + opacity: 0; + } + to { + transform: translate3d(0, 0, 0); + opacity: 1; + } +} + +.message-content.stream-update { + position: relative; + clip-path: inset(0 0 0 0); + animation: streamReveal var(--stream-duration, 220ms) steps(var(--stream-steps, 16), end) forwards; + will-change: clip-path; +} + +.message-content.stream-update::after { + content: ''; + display: inline-block; + width: 2px; + height: 1em; + background: rgba(0, 0, 0, 0.55); + margin-left: 2px; + vertical-align: -0.15em; + animation: streamCaretBlink 900ms steps(1, end) infinite; +} + +@keyframes streamReveal { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0 0 0); + } +} + +@keyframes streamCaretBlink { + 0%, 49% { + opacity: 1; + } + 50%, 100% { + opacity: 0; + } +} + /* New messages indicator */ #new-messages-indicator { position: fixed; diff --git a/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js b/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js index e942007b..8bc69989 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js +++ b/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js @@ -12,6 +12,189 @@ const SCROLL_BOTTOM_THRESHOLD = 30; // consider near-bottom within this distance const SCROLL_SHOW_BTN_THRESHOLD = 5; // show scroll-to-bottom button when farther than this +// Render limits and thresholds +const MAX_MESSAGE_HTML_LENGTH = 20000; // cap DOM insertion size to avoid huge paints +const PERF_LOG_THRESHOLD_MS = 16; // only log perf outliers (>1 frame) +const LRU_MAX_ENTRIES = 100; // recent DOM html cache size +const FLUSH_INTERVAL_MS = 50; // max wait before flushing queued DOM ops +const MAX_CONCURRENT_SCRIPTS = 4; // limit concurrent script execs +const DIFF_SAMPLE_RATE = 0.25; // sample equality diffing (25%) to lower cost +const RENDER_ANIM_DURATION_MS = 280; // wipe animation duration +const PERF_SAMPLE_RATE = 0.25; // sample perf counters to reduce overhead + +// Internal caches +const _templateCache = new Map(); // html string -> DocumentFragment +const _htmlLru = new Map(); // key -> html (maintains LRU order) +const _pendingOps = []; +let _flushScheduled = false; +let _activeScripts = 0; +const _perfCounters = { + renders: 0, + renderMs: 0, + renderSlow: 0, + equalityChecks: 0, + equalityMs: 0, + flushes: 0, +}; + +function lruSet(key, value) { + if (!key) return; + if (_htmlLru.has(key)) { + _htmlLru.delete(key); + } + _htmlLru.set(key, value); + if (_htmlLru.size > LRU_MAX_ENTRIES) { + const oldest = _htmlLru.keys().next().value; + _htmlLru.delete(oldest); + } +} + +function lruGet(key) { + if (!key) return null; + if (!_htmlLru.has(key)) return null; + const val = _htmlLru.get(key); + // touch + _htmlLru.delete(key); + _htmlLru.set(key, val); + return val; +} + +function scheduleFlush() { + if (_flushScheduled) return; + _flushScheduled = true; + // Prefer rAF, fall back to setTimeout + const flushFn = () => { + _flushScheduled = false; + flushDomOps(); + }; + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(flushFn); + } else { + setTimeout(flushFn, FLUSH_INTERVAL_MS); + } +} + +function enqueueDomOp(op) { + _pendingOps.push(op); + scheduleFlush(); +} + +function flushDomOps() { + const ops = _pendingOps.splice(0, _pendingOps.length); + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.flushes += 1; + } + for (let i = 0; i < ops.length; i++) { + try { + ops[i](); + } catch (err) { + console.error('[JS] flushDomOps error', err); + } + } +} + +function shouldSkipBecauseSame(key, html) { + if (!key || !html) return false; + // Sample to reduce cost + if (Math.random() > DIFF_SAMPLE_RATE) return false; + const previous = lruGet(key); + if (!previous) return false; + return previous === html; +} + +function recordHtmlCache(key, html) { + if (!key || !html) return; + lruSet(key, html); +} + +function addWipeAnimation(node) { + try { + if (!node || !node.classList) return; + node.classList.add('wipe-in'); + node.style.setProperty('--wipe-duration', `${RENDER_ANIM_DURATION_MS}ms`); + const remove = () => node.classList.remove('wipe-in'); + node.addEventListener('animationend', remove, { once: true }); + } catch { /* ignore */ } +} + +function addStreamUpdateAnimation(node) { + try { + if (!node) return; + const content = node.querySelector ? (node.querySelector('.message-content') || node) : node; + if (!content || !content.classList) return; + + const textLen = (content.textContent || '').length; + const steps = Math.max(8, Math.min(40, Math.floor(textLen / 6))); + const durationMs = Math.max(120, Math.min(520, steps * 14)); + + // Restart animation by toggling class + content.classList.remove('stream-update'); + // Force reflow + void content.offsetWidth; + content.classList.add('stream-update'); + content.style.setProperty('--stream-duration', `${durationMs}ms`); + content.style.setProperty('--stream-steps', `${steps}`); + + const remove = () => content.classList.remove('stream-update'); + content.addEventListener('animationend', remove, { once: true }); + } catch { /* ignore */ } +} + +function cloneFromTemplate(html, context) { + if (!html) return null; + let frag = _templateCache.get(html); + if (!frag) { + // Guard against excessively large payloads + if (html.length > MAX_MESSAGE_HTML_LENGTH) { + console.warn(`[JS] ${context}: html length ${html.length} exceeds cap ${MAX_MESSAGE_HTML_LENGTH}, truncating`); + html = html.slice(0, MAX_MESSAGE_HTML_LENGTH) + '…'; + } + const temp = document.createElement('div'); + temp.innerHTML = html; + frag = document.createDocumentFragment(); + while (temp.firstChild) { + frag.appendChild(temp.firstChild); + } + _templateCache.set(html, frag.cloneNode(true)); + } + return frag.cloneNode(true).firstElementChild || frag.cloneNode(true).firstChild || null; +} + +function parsePatchPayload(messageHtml) { + // Supports JSON string like {"patch":"append","html":"..."} to avoid resending full bodies + if (!messageHtml || typeof messageHtml !== 'string') return null; + if (messageHtml.length === 0 || messageHtml[0] !== '{') return null; + try { + const obj = JSON.parse(messageHtml); + if (obj && typeof obj === 'object' && obj.patch && obj.html) { + return obj; + } + } catch { + // Not a patch object + } + return null; +} + +function applyPatchToExisting(existing, patchObj, context) { + if (!existing || !patchObj) return null; + const content = existing.querySelector('.message-content') || existing; + if (patchObj.patch === 'append') { + const temp = document.createElement('div'); + temp.innerHTML = patchObj.html || ''; + // Append children to content + while (temp.firstChild) { + content.appendChild(temp.firstChild); + } + return existing; + } + if (patchObj.patch === 'replace-content') { + content.innerHTML = patchObj.html || ''; + return existing; + } + console.warn(`[JS] ${context}: unsupported patch type`, patchObj.patch); + return null; +} + /** * Returns the chat container element and whether it was at (or near) bottom before changes. * Helps standardize error handling and scroll-state capture before DOM mutations. @@ -106,18 +289,30 @@ function insertAfterNode(container, node, reference, context) { * @param {string} messageHtml - HTML content of the message */ function addMessage(messageHtml) { - console.log('[JS] addMessage called with HTML length:', messageHtml ? messageHtml.length : 0); - const { chatContainer, wasAtBottom } = getContainerWithBottom('addMessage'); - if (!chatContainer) return; - - const node = createNodeFromHtml(messageHtml, 'addMessage'); - if (!node) return; - - // Insert above the persistent thinking message if present; otherwise append - insertAboveThinkingIfPresent(chatContainer, node); - console.log('[JS] addMessage: node appended successfully, role classes:', node.className); - // Finalize: reprocess dynamic features and auto-scroll if needed - finalizeMessageInsertion(node, wasAtBottom); + enqueueDomOp(() => { + const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + const { chatContainer, wasAtBottom } = getContainerWithBottom('addMessage'); + if (!chatContainer) return; + + const node = cloneFromTemplate(messageHtml, 'addMessage') || createNodeFromHtml(messageHtml, 'addMessage'); + if (!node) return; + addWipeAnimation(node); + + // Insert above the persistent thinking message if present; otherwise append + insertAboveThinkingIfPresent(chatContainer, node); + // Finalize: reprocess dynamic features and auto-scroll if needed + finalizeMessageInsertion(node, wasAtBottom); + + const dur = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - start; + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.renders += 1; + _perfCounters.renderMs += dur; + } + if (dur > PERF_LOG_THRESHOLD_MS) { + _perfCounters.renderSlow += 1; + console.debug('[JS] addMessage slow render', { ms: dur.toFixed(2), len: messageHtml ? messageHtml.length : 0 }); + } + }); } /** @@ -128,36 +323,64 @@ function addMessage(messageHtml) { * @param {string} messageHtml - HTML string for the message */ function upsertMessageAfter(followKey, key, messageHtml) { - console.log('[JS] upsertMessageAfter called with followKey:', followKey, 'key:', key, 'HTML length:', messageHtml ? messageHtml.length : 0); - const { chatContainer, wasAtBottom } = getContainerWithBottom('upsertMessageAfter'); - if (!chatContainer) return false; + enqueueDomOp(() => { + const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + const { chatContainer, wasAtBottom } = getContainerWithBottom('upsertMessageAfter'); + if (!chatContainer) return false; - const incoming = createNodeFromHtml(messageHtml, 'upsertMessageAfter'); - if (!incoming) return false; - setDatasetKeySafe(incoming, key); + // If content matches last rendered, skip + if (shouldSkipBecauseSame(key, messageHtml)) { + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.equalityChecks += 1; + } + return true; + } - // Find existing nodes - const existing = findExistingMessageByKey(chatContainer, key); - const follow = findExistingMessageByKey(chatContainer, followKey); + const patchObj = parsePatchPayload(messageHtml); + const follow = findExistingMessageByKey(chatContainer, followKey); + if (!follow) { + console.warn('[JS] upsertMessageAfter: followKey not found, falling back to upsertMessage'); + return upsertMessage(key, messageHtml); + } - // If follow is not found, fallback to upsertMessage - if (!follow) { - console.warn('[JS] upsertMessageAfter: followKey not found, falling back to upsertMessage'); - return upsertMessage(key, messageHtml); - } + const existing = findExistingMessageByKey(chatContainer, key); + if (patchObj && existing) { + const patched = applyPatchToExisting(existing, patchObj, 'upsertMessageAfter'); + if (patched) { + // Patch updates: no animation (bubble already exists from first chunk) + finalizeMessageInsertion(patched, wasAtBottom); + recordHtmlCache(key, messageHtml); + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.renders += 1; + } + return true; + } + } - if (existing) { - // Replace existing content and keep relative position by re-inserting after follow - existing.replaceWith(incoming); - console.log('[JS] upsertMessageAfter: replaced existing message for key:', key); - } + const incoming = cloneFromTemplate(messageHtml, 'upsertMessageAfter') || createNodeFromHtml(messageHtml, 'upsertMessageAfter'); + if (!incoming) return false; + setDatasetKeySafe(incoming, key); + addWipeAnimation(incoming); - // Insert after follow (handling last-child case) - insertAfterNode(chatContainer, incoming, follow, 'upsertMessageAfter'); - console.log('[JS] upsertMessageAfter: inserted after followKey:', followKey); + if (existing) { + existing.replaceWith(incoming); + } + + insertAfterNode(chatContainer, incoming, follow, 'upsertMessageAfter'); + finalizeMessageInsertion(incoming, wasAtBottom); + recordHtmlCache(key, messageHtml); - finalizeMessageInsertion(incoming, wasAtBottom); - return true; + const dur = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - start; + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.renders += 1; + _perfCounters.renderMs += dur; + } + if (dur > PERF_LOG_THRESHOLD_MS) { + _perfCounters.renderSlow += 1; + console.debug('[JS] upsertMessageAfter slow render', { ms: dur.toFixed(2), len: messageHtml ? messageHtml.length : 0 }); + } + return true; + }); } /** @@ -167,27 +390,65 @@ function upsertMessageAfter(followKey, key, messageHtml) { * @param {string} messageHtml - HTML string for the message */ function upsertMessage(key, messageHtml) { - console.log('[JS] upsertMessage called with key:', key, 'HTML length:', messageHtml ? messageHtml.length : 0); - const { chatContainer, wasAtBottom } = getContainerWithBottom('upsertMessage'); - if (!chatContainer) return false; + enqueueDomOp(() => { + const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + const { chatContainer, wasAtBottom } = getContainerWithBottom('upsertMessage'); + if (!chatContainer) return false; - const incoming = createNodeFromHtml(messageHtml, 'upsertMessage'); - if (!incoming) return false; - setDatasetKeySafe(incoming, key); + // Diff sampling to avoid redundant DOM work + if (shouldSkipBecauseSame(key, messageHtml)) { + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.equalityChecks += 1; + } + return true; + } - // Find existing by data-key (avoid querySelector escaping issues by scanning) - const existing = findExistingMessageByKey(chatContainer, key); + const patchObj = parsePatchPayload(messageHtml); - if (existing) { - chatContainer.replaceChild(incoming, existing); - console.log('[JS] upsertMessage: replaced existing message for key:', key); - } else { - insertAboveThinkingIfPresent(chatContainer, incoming); - console.log('[JS] upsertMessage: appended new message for key:', key); - } + // Find existing by data-key (avoid querySelector escaping issues by scanning) + const existing = findExistingMessageByKey(chatContainer, key); + + if (patchObj && existing) { + const patched = applyPatchToExisting(existing, patchObj, 'upsertMessage'); + if (patched) { + finalizeMessageInsertion(patched, wasAtBottom); + recordHtmlCache(key, messageHtml); + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.renders += 1; + } + return true; + } + } + + const incoming = cloneFromTemplate(messageHtml, 'upsertMessage') || createNodeFromHtml(messageHtml, 'upsertMessage'); + if (!incoming) return false; + setDatasetKeySafe(incoming, key); + + // Only animate on NEW bubble insertion (first chunk), not on updates to existing bubbles + if (!existing) { + addWipeAnimation(incoming); + } + + if (existing) { + chatContainer.replaceChild(incoming, existing); + } else { + insertAboveThinkingIfPresent(chatContainer, incoming); + } + + finalizeMessageInsertion(incoming, wasAtBottom); + recordHtmlCache(key, messageHtml); - finalizeMessageInsertion(incoming, wasAtBottom); - return true; + const dur = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - start; + if (Math.random() <= PERF_SAMPLE_RATE) { + _perfCounters.renders += 1; + _perfCounters.renderMs += dur; + } + if (dur > PERF_LOG_THRESHOLD_MS) { + _perfCounters.renderSlow += 1; + console.debug('[JS] upsertMessage slow render', { ms: dur.toFixed(2), len: messageHtml ? messageHtml.length : 0 }); + } + return true; + }); } /** @@ -257,18 +518,22 @@ function replaceLastMessageByRole(role, messageHtml) { const incoming = createNodeFromHtml(messageHtml, 'replaceLastMessageByRole'); if (!incoming) return false; - if (messages.length > 0) { - const lastMessage = messages[messages.length - 1]; - chatContainer.replaceChild(incoming, lastMessage); - console.log('[JS] replaceLastMessageByRole: replaced existing message'); - } else { - chatContainer.appendChild(incoming); - console.log('[JS] replaceLastMessageByRole: appended new message'); - } + enqueueDomOp(() => { + const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + if (messages.length > 0) { + const lastMessage = messages[messages.length - 1]; + chatContainer.replaceChild(incoming, lastMessage); + } else { + chatContainer.appendChild(incoming); + } - // Finalize: reprocess dynamic features and auto-scroll if needed - finalizeMessageInsertion(incoming, wasAtBottom); - return true; + finalizeMessageInsertion(incoming, wasAtBottom); + const dur = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()) - start; + if (dur > PERF_LOG_THRESHOLD_MS) { + console.debug('[JS] replaceLastMessageByRole slow render', { ms: dur.toFixed(2), len: messageHtml ? messageHtml.length : 0 }); + } + return true; + }); } /** diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index eb62751e..d76465a7 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -72,6 +72,8 @@ internal partial class WebChatDialog : Form private readonly Dictionary _keyedDomUpdateLatest = new Dictionary(StringComparer.Ordinal); private readonly Queue _keyedDomUpdateQueue = new Queue(); + private int _activeScripts; + private readonly object _htmlRenderLock = new object(); private readonly object _renderVersionLock = new object(); @@ -83,6 +85,9 @@ internal partial class WebChatDialog : Form private bool _domDrainScheduled; private const int DomDeferDuringMoveResizeMs = 400; private const int DomDrainBatchSize = 10; + private const int DomDrainDebounceMs = 16; + + private const int MAX_CONCURRENT_SCRIPTS = 4; // Status text to apply after the document is fully loaded private string _pendingStatusAfter = "Ready"; @@ -390,9 +395,13 @@ private void ScheduleDomDrain() } this._domDrainScheduled = true; - RhinoApp.InvokeOnUiThread(() => + Task.Run(async () => { - Application.Instance?.AsyncInvoke(() => this.DrainDomUpdateQueue()); + await Task.Delay(DomDrainDebounceMs).ConfigureAwait(false); + RhinoApp.InvokeOnUiThread(() => + { + Application.Instance?.AsyncInvoke(() => this.DrainDomUpdateQueue()); + }); }); } @@ -506,14 +515,33 @@ private void ExecuteScript(string script) { Application.Instance?.AsyncInvoke(() => { + var entered = false; try { + // Enforce a small concurrency gate to avoid piling scripts into the WebView. + // Important: do NOT drop scripts (can truncate streamed UI updates). Re-queue when saturated. + var count = Interlocked.Increment(ref this._activeScripts); + if (count > MAX_CONCURRENT_SCRIPTS) + { + Interlocked.Decrement(ref this._activeScripts); + this.RunWhenWebViewReady(() => this.ExecuteScript(script)); + return; + } + + entered = true; this._webView.ExecuteScript(script); } catch (Exception ex) { DebugLog($"[WebChatDialog] ExecuteScript error: {ex.Message}"); } + finally + { + if (entered) + { + Interlocked.Decrement(ref this._activeScripts); + } + } }); }); } From 31845217ffaa0995e6242a73887fadaea53da4e6 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 23 Dec 2025 10:46:19 +0100 Subject: [PATCH 07/26] perf(chat): remove unused stream animation code and fix body filter preservation - Remove unused `stream-update` CSS animations and `addStreamUpdateAnimation` JS function - Remove unused `MAX_CONCURRENT_SCRIPTS` and `_activeScripts` variables from chat-script.js - Add `FlushPendingTextStateForTurn` to ensure throttled text deltas are rendered before tool calls appear in the UI - Fix `ConversationSession` to preserve `ContextFilter` when rebuilding AIBody in special turns and helper methods --- .../UI/Chat/Resources/css/chat-styles.css | 36 --------------- .../UI/Chat/Resources/js/chat-script.js | 25 ----------- .../UI/Chat/WebChatObserver.cs | 44 +++++++++++++++++++ .../ConversationSession.SpecialTurns.cs | 6 ++- .../Sessions/ConversationSessionHelpers.cs | 1 + 5 files changed, 50 insertions(+), 62 deletions(-) diff --git a/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css b/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css index 73f53be3..ee1733bc 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css +++ b/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css @@ -493,42 +493,6 @@ hr { } } -.message-content.stream-update { - position: relative; - clip-path: inset(0 0 0 0); - animation: streamReveal var(--stream-duration, 220ms) steps(var(--stream-steps, 16), end) forwards; - will-change: clip-path; -} - -.message-content.stream-update::after { - content: ''; - display: inline-block; - width: 2px; - height: 1em; - background: rgba(0, 0, 0, 0.55); - margin-left: 2px; - vertical-align: -0.15em; - animation: streamCaretBlink 900ms steps(1, end) infinite; -} - -@keyframes streamReveal { - from { - clip-path: inset(0 100% 0 0); - } - to { - clip-path: inset(0 0 0 0); - } -} - -@keyframes streamCaretBlink { - 0%, 49% { - opacity: 1; - } - 50%, 100% { - opacity: 0; - } -} - /* New messages indicator */ #new-messages-indicator { position: fixed; diff --git a/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js b/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js index 8bc69989..28607f95 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js +++ b/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js @@ -17,7 +17,6 @@ const MAX_MESSAGE_HTML_LENGTH = 20000; // cap DOM insertion size to avoid huge p const PERF_LOG_THRESHOLD_MS = 16; // only log perf outliers (>1 frame) const LRU_MAX_ENTRIES = 100; // recent DOM html cache size const FLUSH_INTERVAL_MS = 50; // max wait before flushing queued DOM ops -const MAX_CONCURRENT_SCRIPTS = 4; // limit concurrent script execs const DIFF_SAMPLE_RATE = 0.25; // sample equality diffing (25%) to lower cost const RENDER_ANIM_DURATION_MS = 280; // wipe animation duration const PERF_SAMPLE_RATE = 0.25; // sample perf counters to reduce overhead @@ -27,7 +26,6 @@ const _templateCache = new Map(); // html string -> DocumentFragment const _htmlLru = new Map(); // key -> html (maintains LRU order) const _pendingOps = []; let _flushScheduled = false; -let _activeScripts = 0; const _perfCounters = { renders: 0, renderMs: 0, @@ -117,29 +115,6 @@ function addWipeAnimation(node) { } catch { /* ignore */ } } -function addStreamUpdateAnimation(node) { - try { - if (!node) return; - const content = node.querySelector ? (node.querySelector('.message-content') || node) : node; - if (!content || !content.classList) return; - - const textLen = (content.textContent || '').length; - const steps = Math.max(8, Math.min(40, Math.floor(textLen / 6))); - const durationMs = Math.max(120, Math.min(520, steps * 14)); - - // Restart animation by toggling class - content.classList.remove('stream-update'); - // Force reflow - void content.offsetWidth; - content.classList.add('stream-update'); - content.style.setProperty('--stream-duration', `${durationMs}ms`); - content.style.setProperty('--stream-steps', `${steps}`); - - const remove = () => content.classList.remove('stream-update'); - content.addEventListener('animationend', remove, { once: true }); - } catch { /* ignore */ } -} - function cloneFromTemplate(html, context) { if (!html) return null; let frag = _templateCache.get(html); diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index bc5d582f..20a538c7 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -515,6 +515,10 @@ public void OnInteractionCompleted(IAIInteraction interaction) { var streamKey = GetStreamKey(interaction); var turnKey = GetTurnBaseKey(interaction?.TurnId); + + // Flush any pending text state for this turn before processing non-text interaction. + // This ensures throttled text deltas are rendered before tool calls appear. + this.FlushPendingTextStateForTurn(turnKey); #if DEBUG DebugLog($"[WebChatObserver] OnInteractionCompleted(Non-Text): type={interaction.GetType().Name}, streamKey={streamKey}, turnKey={turnKey}"); #endif @@ -853,6 +857,46 @@ private static bool HasRenderableText(AIInteractionText t) return t != null && (!string.IsNullOrWhiteSpace(t.Content) || !string.IsNullOrWhiteSpace(t.Reasoning)); } + /// + /// Flushes any pending (throttled) text state for a turn to ensure final content is rendered. + /// Called before processing non-text interactions to prevent losing throttled text deltas. + /// + private void FlushPendingTextStateForTurn(string turnKey) + { + if (string.IsNullOrWhiteSpace(turnKey)) + { + return; + } + + try + { + // Find all streams for this turn and force-render any that have dirty state + var turnPrefix = turnKey + ":"; + var keysToFlush = this._streams.Keys + .Where(k => k != null && k.StartsWith(turnPrefix, StringComparison.Ordinal)) + .ToList(); + + foreach (var segKey in keysToFlush) + { + if (this._streams.TryGetValue(segKey, out var state) && + state.Aggregated is AIInteractionText aggregatedText && + HasRenderableText(aggregatedText)) + { + // Force render if content differs from last rendered + if (this.ShouldRenderDelta(segKey, aggregatedText)) + { + DebugLog($"[WebChatObserver] FlushPendingTextStateForTurn: flushing segKey={segKey}"); + this._dialog.UpsertMessageByKey(segKey, aggregatedText, source: "FlushPendingText"); + } + } + } + } + catch (Exception ex) + { + DebugLog($"[WebChatObserver] FlushPendingTextStateForTurn error: {ex.Message}"); + } + } + /// /// Sets the boundary flag for the next text interaction in the given turn. /// diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs index 61c7ca53..517c3cf6 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs @@ -386,7 +386,11 @@ private void ReplaceAbove(RequestSnapshot snapshot, AIReturn result, Interaction var resultInteractions = result?.Body?.Interactions?.ToList() ?? new List(); // Build new body with preserved interactions + result - var builder = AIBodyBuilder.Create(); + var builder = AIBodyBuilder.Create() + .WithToolFilter(this.Request.Body?.ToolFilter) + .WithContextFilter(this.Request.Body?.ContextFilter) + .WithJsonOutputSchema(this.Request.Body?.JsonOutputSchema) + .AsHistory(); builder.AddRange(preservedInteractions); foreach (var interaction in resultInteractions) diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSessionHelpers.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSessionHelpers.cs index cc0f38c8..569ff41c 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSessionHelpers.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSessionHelpers.cs @@ -658,6 +658,7 @@ private bool UpdateToolCallInHistory(AIInteractionToolCall updatedToolCall) // Rebuild from scratch with updated list var newBuilder = AIBodyBuilder.Create() .WithToolFilter(this.Request.Body?.ToolFilter) + .WithContextFilter(this.Request.Body?.ContextFilter) .WithJsonOutputSchema(this.Request.Body?.JsonOutputSchema) .AsHistory(); From 3a7699b96cd67521ac7c0a994b1c2bd802d0cf39 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 23 Dec 2025 11:18:02 +0100 Subject: [PATCH 08/26] feat(chat): add instruction_get tool to reduce system prompt size and improve token efficiency - Add new `instruction_get` tool that returns detailed operational instructions on-demand for canvas operations, component discovery, scripting workflows, and knowledge base usage - Refactor `CanvasButton` system prompt to be concise and delegate detailed tool usage guidelines to `instruction_get` calls - Remove verbose inline documentation for tool workflows (canvas state reading, component discovery, scripting, knowledge base) --- CHANGELOG.md | 6 + docs/Tools/index.md | 6 + docs/Tools/instruction_get.md | 62 +++++++ .../AITools/instruction_get.cs | 173 ++++++++++++++++++ src/SmartHopper.Core/UI/CanvasButton.cs | 115 +++--------- 5 files changed, 269 insertions(+), 93 deletions(-) create mode 100644 docs/Tools/instruction_get.md create mode 100644 src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c75637..6d5a9d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Chat: + - Added `instruction_get` tool (category: `Instructions`) to provide detailed operational guidance to chat agents on demand. + - Simplified the `CanvasButton` default assistant system prompt to reference instruction tools instead of embedding long tool usage guidelines. + ### Fixed - Chat UI: diff --git a/docs/Tools/index.md b/docs/Tools/index.md index 4b99c67c..d5e645a7 100644 --- a/docs/Tools/index.md +++ b/docs/Tools/index.md @@ -24,6 +24,12 @@ Tools are callable operations the AI can invoke (function/tool calling) and util - Tools should return consistent keys (e.g., `list` for list_generate) and clear error messages. - Use provider/model capability checks via the model registry when needed. +## Tool-as-Documentation + +- Some tools exist primarily to provide detailed operational guidance to the agent without bloating the system prompt. +- This keeps prompts short, reduces per-turn token usage, and centralizes workflows in one place. +- See `docs/Tools/instruction_get.md`. + ## Tool Result Envelope - Tools should attach a metadata envelope to their JSON result under the reserved root key `"__envelope"`. diff --git a/docs/Tools/instruction_get.md b/docs/Tools/instruction_get.md new file mode 100644 index 00000000..7157c37b --- /dev/null +++ b/docs/Tools/instruction_get.md @@ -0,0 +1,62 @@ +# instruction_get + +`instruction_get` is an AI tool that returns **detailed operational guidance** to the agent on demand. It enables a *tool-as-documentation* pattern where the chat system prompt stays short and stable, while domain-specific workflows and edge-case constraints are pulled only when needed. + +## Location + +- Tool implementation: `src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs` + +## When to use + +Call `instruction_get` **before** executing domain-specific workflows, especially when: + +- You need the canonical sequence of tools for a given task. +- You need safety/UX constraints (edge cases) without bloating the system prompt. +- You want the assistant to follow consistent internal conventions. + +This is particularly valuable for: + +- Canvas inspection and modifications. +- GhJSON read/modify workflows. +- Scripting workflows (creation/editing/review, and constraints like avoiding huge pasted scripts). +- Knowledge searches (forum/web reading and summarization flow). + +## Parameters + +- `topic` (string, required): which instruction bundle to return. + +Supported topics are declared in the tool schema. Examples include: + +- `canvas` +- `discovery` +- `scripting` +- `knowledge` +- (project-specific subtopics such as `ghjson`, `selected`, `errors`, `locks`, `visibility` may be routed to a common bundle) + +## Output + +The tool returns a tool-result payload containing: + +- `topic` (string) +- `instructions` (string): a markdown-formatted instruction block intended for the agent. + +The response is returned as a tool result interaction (so it becomes part of the chat/tool trace). + +## Tool-as-Documentation pattern + +### Why + +- Keeps system prompts short and easier to iterate on. +- Reduces token usage on every turn. +- Centralizes “how we operate” knowledge into a single authoritative tool. + +### Recommended usage pattern + +1. Identify which domain the user’s request falls into. +2. Call `instruction_get` with the relevant topic. +3. Follow the returned steps as the authoritative workflow. + +## Notes + +- This tool is intentionally **local** and does not require provider/model metrics. +- Instruction text may include edge-case constraints (for example, scripting-specific “do not paste entire scripts”). diff --git a/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs b/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs new file mode 100644 index 00000000..82a3adba --- /dev/null +++ b/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs @@ -0,0 +1,173 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.Core.Interactions; +using SmartHopper.Infrastructure.AICall.Core.Returns; +using SmartHopper.Infrastructure.AICall.Tools; +using SmartHopper.Infrastructure.AITools; + +namespace SmartHopper.Core.Grasshopper.AITools +{ + /// + /// Provides instruction bundles to the agent so the system prompt can remain short. + /// + public class instruction_get : IAIToolProvider + { + private const string ToolName = "instruction_get"; + + public IEnumerable GetTools() + { + yield return new AITool( + name: ToolName, + description: "Returns detailed operational instructions for SmartHopper (canvas, discovery, scripting, knowledge). Use this to retrieve guidance instead of relying on a long system prompt.", + category: "Instructions", + parametersSchema: @"{ + ""type"": ""object"", + ""properties"": { + ""topic"": { + ""type"": ""string"", + ""description"": ""Which instruction bundle to return."", + ""enum"": [""canvas"", ""discovery"", ""scripting"", ""knowledge"", ""ghjson"", ""selected"", ""errors"", ""locks"", ""visibility""] + } + }, + ""required"": [""topic""] + }", + execute: this.ExecuteAsync); + } + + private Task ExecuteAsync(AIToolCall toolCall) + { + var output = new AIReturn() + { + Request = toolCall, + }; + + try + { + toolCall.SkipMetricsValidation = true; + + var toolInfo = toolCall.GetToolCall(); + var args = toolInfo.Arguments ?? new JObject(); + var topic = args["topic"]?.ToString() ?? string.Empty; + + if (string.IsNullOrWhiteSpace(topic)) + { + output.CreateToolError("Missing required parameter: topic", toolCall); + return Task.FromResult(output); + } + + var instructions = this.GetInstructions(topic); + + var toolResult = new JObject(); + toolResult.Add("topic", topic); + toolResult.Add("instructions", instructions); + + var toolBody = AIBodyBuilder.Create() + .AddToolResult(toolResult, id: toolInfo?.Id, name: ToolName) + .Build(); + + output.CreateSuccess(toolBody); + return Task.FromResult(output); + } + catch (Exception ex) + { + output.CreateError($"Error: {ex.Message}"); + return Task.FromResult(output); + } + } + + private string GetInstructions(string topic) + { + switch (topic.Trim().ToLowerInvariant()) + { + case "canvas": + case "ghjson": + case "selected": + case "errors": + case "locks": + case "visibility": + return """ +Canvas state reading: +- Use gh_get_selected when the user refers to “this/these/selected”. +- Use gh_get_errors to locate broken definitions. +- Use gh_get_locked / gh_get_hidden / gh_get_visible for quick filters. +- Use gh_get_by_guid only when you already have GUIDs from prior steps. +- Use gh_get (generic) only when a specialized tool does not fit. + +Quick actions on selected components (no GUIDs needed): +- gh_group_selected +- gh_tidy_up_selected +- gh_lock_selected / gh_unlock_selected +- gh_hide_preview_selected / gh_show_preview_selected + +Modifying canvas: +- gh_group, gh_move, gh_tidy_up, gh_component_toggle_lock, gh_component_toggle_preview +- gh_put: place components from GhJSON; when instanceGuid matches existing, it replaces it (prefer user confirmation). +"""; + + case "discovery": + return """ +Discovering available components: +1) gh_list_categories: discover available categories first (saves tokens). +2) gh_list_components: then search within categories. + - Always pass includeDetails=['name','description','inputs','outputs'] unless you truly need more. + - Always pass maxResults to prevent token overload. + - Use categoryFilter with +/- tokens to narrow scope. +"""; + + case "knowledge": + return """ +Knowledge base workflow: +1) mcneel_forum_search: find candidate posts/topics. +2) mcneel_forum_topic_get / mcneel_forum_post_get: retrieve the minimum useful content. +3) mcneel_forum_topic_summarize / mcneel_forum_post_summarize: summarize and extract actionable steps. +4) web_generic_page_read: read docs/pages by URL before citing or relying on them. +"""; + + case "scripting": + return """ +Scripting rules: +- When the user asks to CREATE or MODIFY a Grasshopper script component, use the scripting tools (do not only reply in natural language). +- All scripting happens inside Grasshopper script components, not an external environment. +- Do not propose or rely on traditional unit tests or external test projects. +- For manual inspection, instruct the user to open the script component editor (double-click in Grasshopper). +- Avoid copying full scripts from the canvas into chat (keep context small). +- Use fenced code blocks only when discussing a specific snippet or when an operation fails and the user must manually apply code. + +Tools: +- script_generate: generate a new script component as GhJSON (not placed). +- script_review: review an existing script component by GUID. +- script_edit_and_replace_on_canvas: edit an existing script and replace it on the canvas in one call. + +Required workflows: +- Create NEW script: + 1) script_generate + 2) gh_put (editMode=false) + +- Edit EXISTING script: + 1) gh_get_selected (preferred) or gh_get_by_guid + 2) script_edit_and_replace_on_canvas + +- Fix BUGS in script: + 1) gh_get_errors (or gh_get with categoryFilter=['+Script']) + 2) script_review + 3) script_edit_and_replace_on_canvas +"""; + + default: + return "Unknown topic. Valid topics: canvas, discovery, scripting, knowledge, ghjson, selected, errors, locks, visibility."; + } + } + } +} diff --git a/src/SmartHopper.Core/UI/CanvasButton.cs b/src/SmartHopper.Core/UI/CanvasButton.cs index fd6cd005..7e2fd8b5 100644 --- a/src/SmartHopper.Core/UI/CanvasButton.cs +++ b/src/SmartHopper.Core/UI/CanvasButton.cs @@ -44,15 +44,14 @@ public static class CanvasButton // Predefined system prompt for SmartHopper assistant private const string DefaultSystemPrompt = """ - You are a helpful AI assistant specialized in Grasshopper 3D. Follow these guidelines: + You are a helpful AI assistant specialized in Grasshopper 3D. - - Be concise and technical in your responses - - Explain complex concepts in simple terms - - Avoid exposing GUIDs to the user unless specifically requested - - When providing code, include brief comments explaining key parts - - If a question is unclear, ask for clarification - - Admit when you don't know something rather than guessing - - Respect the user's skill level and adjust explanations accordingly + Communication guidelines: + - Be concise and technical. + - Explain complex concepts in simple terms. + - Ask for clarification if requirements are unclear. + - Admit uncertainty rather than guessing. + - Avoid exposing GUIDs unless specifically requested. Focus on: 1. Parametric design principles @@ -60,90 +59,20 @@ 2. Algorithmic problem-solving 3. Performance optimization 4. Best practices in computational design - ## Tool Usage Guidelines - - ### Reading Canvas State - Use specialized gh_get wrappers for common queries: - - gh_get_selected: Get selected components (when user says "this", "these", "selected") - - gh_get_errors: Get components with errors (for debugging) - - gh_get_locked: Get locked/disabled components - - gh_get_hidden: Get components with preview off - - gh_get_visible: Get components with preview on - - gh_get_by_guid: Get specific components by GUID (when you have GUIDs from previous queries) - - gh_get: Generic tool with full filter options (use when specialized tools don't fit) - - ### Quick Actions on Selected Components - These tools work directly on selected components without needing GUIDs: - - gh_group_selected: Group selected components with optional name/color - - gh_tidy_up_selected: Auto-arrange selected components in grid - - gh_lock_selected: Lock/disable selected components - - gh_unlock_selected: Unlock/enable selected components - - gh_hide_preview_selected: Hide geometry preview for selected - - gh_show_preview_selected: Show geometry preview for selected - - ### Discovering Available Components - Workflow for finding components: - 1. gh_list_categories: First discover available categories (saves tokens) - 2. gh_list_components: Then search within specific categories - - ALWAYS use includeDetails=['name','description','inputs','outputs'] to save tokens - - ALWAYS use maxResults to limit output (default 100) - - Use categoryFilter to narrow search (e.g., ['+Maths','-Params']) - - ### Modifying Canvas - - gh_group: Create visual groups to organize/annotate components (requires GUIDs from gh_get) - - gh_move: Reposition components (absolute or relative coordinates) - - gh_tidy_up: Auto-arrange components in clean grid layout - - gh_component_toggle_lock: Enable/disable component execution - - gh_component_toggle_preview: Show/hide geometry preview - - gh_put: Add new components from GhJSON format, or when instanceGuid matches an existing component, replace it (after user confirmation, preserving positions and external connections when possible) - - ### Knowledge Base - - mcneel_forum_search: First, search McNeel Discourse forum posts by query; then use topic/post tools on interesting results. - - mcneel_forum_topic_summarize: Summarize a full topic into a short answer; usually call after mcneel_forum_topic_get when the thread is long. - - mcneel_forum_post_get: Retrieve a single forum post by ID (filtered JSON with raw markdown). - - mcneel_forum_post_summarize: Summarize one or more posts by ID (for example selected replies from search or topic_get). - - web_generic_page_read: Fetch readable text/markdown for any web page URL (Rhino docs, GitHub, StackExchange, Discourse, etc.) before reasoning about its content. - - ### Scripting - When the user asks to CREATE or MODIFY a Grasshopper script component, you MUST use the scripting tools below instead of replying only in natural language. - - All scripting happens inside Grasshopper3D script components, not in a standalone programming or test environment. - - Do NOT propose or rely on traditional unit tests or external test projects; the user validates behavior directly in Grasshopper. - - For the user to manually check a script, instruct the user to double-click the script component in Grasshopper to open in the code editor instead of pasting the full script into the chat. - - Avoid copying full scripts from the canvas (for example via gh_get) into the conversation; this makes the context too long and noisy. - - Use fenced code blocks only when: - - discussing how to implement or refactor a specific piece of code, or - - an edit/generation operation fails and you provide the user with the full code snippet to manually apply. - - Do NOT wrap entire successful scripts or scripts directly read from the canvas in code blocks; refer to them descriptively instead (by component name, for example). - - #### Tools - - script_generate: Create a new script component from natural language instructions. Returns GhJSON with a single script component (not placed on the canvas). - - script_review: Review an existing script component by GUID. Returns a concise review plus a list of potential issues and risky patterns. - - script_edit_and_replace_on_canvas: Edit an existing script component using GhJSON and natural language instructions and replace it on the canvas in a single call. Internally combines script_edit and gh_put with editMode=true to reduce token usage. - - #### Required workflows - - - Create a NEW script component (no existing script selected): - 1. script_generate: Generate GhJSON for the new script component. - 2. gh_put (editMode=false): Place the generated component on the canvas. - - - Edit an EXISTING script component in-place (user refers to a selected script or says "this script"): - 1. gh_get_selected (preferred) or gh_get_by_guid or gh_get with categoryFilter=['+Script']: Retrieve GhJSON for the target script component (preserve its InstanceGuid). - 2. script_edit_and_replace_on_canvas: Update the script based on the user instructions and replace the existing component on the canvas in a single call (internally uses script_edit and gh_put with editMode=true). - - - Fix BUGS in an existing script component: - 1. gh_get_errors or gh_get with categoryFilter=['+Script']: Locate the script component(s) with errors and obtain their GhJSON. - 2. script_review: Analyze the script to identify bugs and risky patterns. - 3. script_edit_and_replace_on_canvas: Apply the fixes suggested by the review, refine the script, and replace the script component on the canvas in a single call (internally uses script_edit and gh_put with editMode=true). - - Do NOT answer that you lack tools to modify scripts. You ALWAYS have access to these scripting tools and canvas tools in this environment; use them whenever the user asks you to change a script component. - - ### Best Practices - - Start with specialized tools (gh_get_selected, gh_get_errors) before using generic gh_get - - Always request only needed fields in gh_list_components to minimize tokens - - Use gh_list_categories before gh_list_components to narrow search - - Chain tools logically: gh_get > gh_group/gh_move/gh_toggle_* - - For forum support: mcneel_forum_search > mcneel_forum_topic_summarize > mcneel_forum_post_summarize > final answer. + Internal work pattern (do not reveal this reasoning): + 1. Identify the goal and constraints. + 2. Decide what information you need. + 3. Prefer the most specific tool(s) that retrieve minimal data. + 4. If you modify the canvas, do it safely and summarize what changed. + + Tool guidance is provided via instruction tools (to keep this prompt short). + When the user's request involves a domain below, first call: + - instruction_get {"topic":"canvas"} # Including ghjson, selected components, errors, component visibility/locks + - instruction_get {"topic":"discovery"} + - instruction_get {"topic":"scripting"} + - instruction_get {"topic":"knowledge"} + + Use the returned instructions as the authoritative workflow for that domain. """; // Private fields @@ -767,7 +696,7 @@ private static async Task TriggerChatDialog() model, endpoint: "canvas-chat", systemPrompt: DefaultSystemPrompt, - toolFilter: "Components,ComponentsRetrieval,Parameters,Knowledge,Scripting", + toolFilter: "Components,ComponentsRetrieval,Instructions,Knowledge,Parameters,Scripting", componentId: CanvasChatDialogId, progressReporter: null, onUpdate: null, From 666766c749366050340d70792d21dc9c4ffde1de Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 23 Dec 2025 12:56:31 +0100 Subject: [PATCH 09/26] fix(tools): improve instruction_get reliability and add required parameter validation - Enhance `instruction_get` tool description to explicitly mention required `topic` argument since some models (MistralAI, OpenAI) don't always respect JSON Schema `required` fields but do follow description text - Add explicit validation for missing/empty required parameters in `ToolJsonSchemaValidator` with actionable error messages prompting retry - Remove redundant null check in `instruction_get` tool body --- CHANGELOG.md | 3 + .../AI/AIChatComponent.cs | 2 +- .../AITools/instruction_get.cs | 10 +-- .../Utils/Canvas/CanvasAccess.cs | 24 +++++- src/SmartHopper.Core/UI/CanvasButton.cs | 12 +-- .../AICall/Tools/AIToolCall.cs | 4 + .../Validation/ToolJsonSchemaValidator.cs | 65 ++++++++++++++- .../MistralAIProvider.cs | 80 +++++++++++++++---- 8 files changed, 164 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5a9d69..31e372fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Tool calling: + - Improved `instruction_get` tool description to explicitly mention required `topic` argument. Some models (MistralAI, OpenAI) don't always respect JSON Schema `required` fields but do follow description text. + - Chat UI: - Reduced WebChat dialog UI freezes while dragging/resizing during streaming responses by throttling DOM upserts more aggressively and processing DOM updates in smaller batches. - Mitigated issue [#261](https://github.com/architects-toolkit/SmartHopper/issues/261) by batching WebView DOM operations (JS rAF/timer queue) and debouncing host-side script injection/drain scheduling. diff --git a/src/SmartHopper.Components/AI/AIChatComponent.cs b/src/SmartHopper.Components/AI/AIChatComponent.cs index 508eee2c..a6cb2eb6 100644 --- a/src/SmartHopper.Components/AI/AIChatComponent.cs +++ b/src/SmartHopper.Components/AI/AIChatComponent.cs @@ -270,7 +270,7 @@ public override async System.Threading.Tasks.Task DoWorkAsync(CancellationToken modelName: this.component.GetModel(), endpoint: "ai-chat", systemPrompt: this.component.SystemPrompt, - toolFilter: "Components,ComponentsRetrieval,Parameters,Knowledge,Scripting", + toolFilter: "Instructions,Knowledge,Components,ComponentsRetrieval,Parameters,Scripting", componentId: this.component.InstanceGuid, progressReporter: this.progressReporter, onUpdate: snapshot => diff --git a/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs b/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs index 82a3adba..6ce07024 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs @@ -30,7 +30,7 @@ public IEnumerable GetTools() { yield return new AITool( name: ToolName, - description: "Returns detailed operational instructions for SmartHopper (canvas, discovery, scripting, knowledge). Use this to retrieve guidance instead of relying on a long system prompt.", + description: "Returns detailed operational instructions for SmartHopper. REQUIRED: Pass `topic` with one of: canvas, discovery, scripting, knowledge, ghjson, selected, errors, locks, visibility. Use this to retrieve guidance instead of relying on a long system prompt.", category: "Instructions", parametersSchema: @"{ ""type"": ""object"", @@ -61,12 +61,6 @@ private Task ExecuteAsync(AIToolCall toolCall) var args = toolInfo.Arguments ?? new JObject(); var topic = args["topic"]?.ToString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(topic)) - { - output.CreateToolError("Missing required parameter: topic", toolCall); - return Task.FromResult(output); - } - var instructions = this.GetInstructions(topic); var toolResult = new JObject(); @@ -166,7 +160,7 @@ Quick actions on selected components (no GUIDs needed): """; default: - return "Unknown topic. Valid topics: canvas, discovery, scripting, knowledge, ghjson, selected, errors, locks, visibility."; + return "Unknown topic. Call the `instruction_get` function again and specify the `topic` argument. Valid topics are canvas, discovery, scripting, knowledge, ghjson, selected, errors, locks, visibility."; } } } diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/CanvasAccess.cs b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/CanvasAccess.cs index c64443cb..a8a34b2f 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/CanvasAccess.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/CanvasAccess.cs @@ -30,7 +30,15 @@ public static class CanvasAccess [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Ambient UI state access; method communicates non-field-like behavior")] public static GH_Document GetCurrentCanvas() { - GH_Document doc = Instances.ActiveCanvas.Document; + GH_Document doc = null; + try + { + doc = Instances.ActiveCanvas?.Document; + } + catch + { + doc = null; + } return doc; } @@ -41,7 +49,19 @@ public static GH_Document GetCurrentCanvas() public static List GetCurrentObjects() { GH_Document doc = GetCurrentCanvas(); - return doc.ActiveObjects(); + if (doc == null) + { + return new List(); + } + + try + { + return doc.ActiveObjects() ?? new List(); + } + catch + { + return new List(); + } } /// diff --git a/src/SmartHopper.Core/UI/CanvasButton.cs b/src/SmartHopper.Core/UI/CanvasButton.cs index 7e2fd8b5..a16ff7e7 100644 --- a/src/SmartHopper.Core/UI/CanvasButton.cs +++ b/src/SmartHopper.Core/UI/CanvasButton.cs @@ -66,11 +66,11 @@ 3. Prefer the most specific tool(s) that retrieve minimal data. 4. If you modify the canvas, do it safely and summarize what changed. Tool guidance is provided via instruction tools (to keep this prompt short). - When the user's request involves a domain below, first call: - - instruction_get {"topic":"canvas"} # Including ghjson, selected components, errors, component visibility/locks - - instruction_get {"topic":"discovery"} - - instruction_get {"topic":"scripting"} - - instruction_get {"topic":"knowledge"} + When the user's request involves a domain below, first call `instruction_get` function with a `topic` parameter which can be: + - canvas: Including ghjson, selected components, errors, component visibility/locks + - discovery: Retrieve available components in user's environment + - scripting: C#, python, iron-python and VB tools + - knowledge: To search for information Use the returned instructions as the authoritative workflow for that domain. """; @@ -696,7 +696,7 @@ private static async Task TriggerChatDialog() model, endpoint: "canvas-chat", systemPrompt: DefaultSystemPrompt, - toolFilter: "Components,ComponentsRetrieval,Instructions,Knowledge,Parameters,Scripting", + toolFilter: "Instructions,Knowledge,Components,ComponentsRetrieval,Parameters,Scripting", componentId: CanvasChatDialogId, progressReporter: null, onUpdate: null, diff --git a/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs b/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs index 02a1cf72..a77952bb 100644 --- a/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs +++ b/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs @@ -97,6 +97,10 @@ public override async Task Exec() .Select(m => m.Message) .ToList(); var combined = errorTexts.Count > 0 ? string.Join(" \n", errorTexts) : "Tool call validation failed"; + + // Tool calls may fail before the tool body runs (so tools can't set SkipMetricsValidation themselves). + // These are local-only calls, so provider/model/finish_reason metrics are not meaningful here. + this.SkipMetricsValidation = true; ret.CreateToolError(combined, this); // Attach structured validation messages so UIs and components can surface them diff --git a/src/SmartHopper.Infrastructure/AICall/Validation/ToolJsonSchemaValidator.cs b/src/SmartHopper.Infrastructure/AICall/Validation/ToolJsonSchemaValidator.cs index 30ba7a37..8198e3ba 100644 --- a/src/SmartHopper.Infrastructure/AICall/Validation/ToolJsonSchemaValidator.cs +++ b/src/SmartHopper.Infrastructure/AICall/Validation/ToolJsonSchemaValidator.cs @@ -60,6 +60,19 @@ public Task ValidateAsync(AIInteractionToolCall instance, Vali var tool = tools[instance.Name]; var schemaText = tool.ParametersSchema ?? string.Empty; + JArray required = null; + try + { + var schemaObj = string.IsNullOrWhiteSpace(schemaText) ? null : JObject.Parse(schemaText); + required = schemaObj?["required"] as JArray; + } + catch + { + required = null; + } + + var hasRequired = required != null && required.Count > 0; + if (string.IsNullOrWhiteSpace(schemaText)) { // No schema to validate; treat as pass @@ -80,6 +93,38 @@ public Task ValidateAsync(AIInteractionToolCall instance, Vali AIRuntimeMessageOrigin.Validation, $"Arguments for tool '{instance.Name}' do not match schema: {error}")); } + + if (hasRequired && instance.Arguments is JObject argsObj) + { + var missing = new List(); + foreach (var r in required) + { + var key = r?.ToString(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + if (!argsObj.TryGetValue(key, out var value) || value == null || value.Type == JTokenType.Null) + { + missing.Add(key); + continue; + } + + if (value.Type == JTokenType.String && string.IsNullOrWhiteSpace(value.ToString())) + { + missing.Add(key); + } + } + + if (missing.Count > 0) + { + messages.Add(new AIRuntimeMessage( + AIRuntimeMessageSeverity.Error, + AIRuntimeMessageOrigin.Validation, + $"Arguments for tool '{instance.Name}' are missing required properties: {string.Join(", ", missing)}. Retry the tool call and include these properties.")); + } + } } else { @@ -87,10 +132,22 @@ public Task ValidateAsync(AIInteractionToolCall instance, Vali // so downstream execution receives a valid JSON object. This mirrors permissive // handling for tools that support optional arguments. instance.Arguments = new JObject(); - messages.Add(new AIRuntimeMessage( - AIRuntimeMessageSeverity.Info, - AIRuntimeMessageOrigin.Validation, - $"No arguments provided for tool '{instance.Name}'. Created default empty arguments {{}} to satisfy the schema.")); + + if (hasRequired) + { + // Make the message stable and actionable so repeated validation passes dedupe cleanly. + messages.Add(new AIRuntimeMessage( + AIRuntimeMessageSeverity.Error, + AIRuntimeMessageOrigin.Validation, + $"Arguments for tool '{instance.Name}' are missing required properties: {string.Join(", ", required)}. Retry the tool call and include these properties.")); + } + else + { + messages.Add(new AIRuntimeMessage( + AIRuntimeMessageSeverity.Info, + AIRuntimeMessageOrigin.Validation, + $"No arguments provided for tool '{instance.Name}'. Created default empty arguments {{}} to satisfy the schema.")); + } } var result = new ValidationResult diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs index bc4fde5c..922a1fc4 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs @@ -606,6 +606,8 @@ public async IAsyncEnumerable StreamAsync( // Tool call accumulation for final body var toolCallsList = new List(); + var toolCallFragments = new Dictionary(); + var toolCallsEmitted = false; // Determine idle timeout from request (fallback to 60s if invalid) var idleTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 60); @@ -707,27 +709,37 @@ public async IAsyncEnumerable StreamAsync( newText = contentToken.ToString(); } - // Minimal tool call streaming support if (delta["tool_calls"] is JArray tcs && tcs.Count > 0) { - var toolInteractions = new List(); foreach (var tcTok in tcs.OfType()) { - var toolCall = new AIInteractionToolCall + var idx = tcTok["index"]?.Value() ?? 0; + if (!toolCallFragments.TryGetValue(idx, out var entry)) { - Id = tcTok["id"]?.ToString(), - Name = tcTok["function"]?[(object)"name"]?.ToString(), - Arguments = tcTok["function"]?[(object)"arguments"] as JObject, - Agent = AIAgent.ToolCall, - }; - toolInteractions.Add(toolCall); - toolCallsList.Add(toolCall); // Store for final body - } + entry = (Id: string.Empty, Name: string.Empty, Args: new StringBuilder()); + } - var tcDelta = new AIReturn { Request = request, Status = AICallStatus.CallingTools }; - tcDelta.SetBody(toolInteractions); - yield return tcDelta; - haveStreamedAny = true; + var idVal = tcTok["id"]?.ToString(); + if (!string.IsNullOrEmpty(idVal)) entry.Id = idVal; + + var func = tcTok["function"] as JObject; + if (func != null) + { + var nameVal = func[(object)"name"]?.ToString(); + if (!string.IsNullOrEmpty(nameVal)) entry.Name = nameVal; + + var argsTok = func[(object)"arguments"]; + if (argsTok != null) + { + var frag = argsTok.Type == JTokenType.String + ? argsTok.ToString() + : argsTok.ToString(Newtonsoft.Json.Formatting.None); + if (!string.IsNullOrEmpty(frag)) entry.Args.Append(frag); + } + } + + toolCallFragments[idx] = entry; + } } } else @@ -871,6 +883,44 @@ public async IAsyncEnumerable StreamAsync( lastFinishReason = finishReason; } + if (!toolCallsEmitted && !string.IsNullOrEmpty(finishReason) && string.Equals(finishReason, "tool_calls", StringComparison.OrdinalIgnoreCase)) + { + var toolInteractions = new List(); + foreach (var kv in toolCallFragments.OrderBy(k => k.Key)) + { + var (id, name, argsSb) = kv.Value; + JObject? argsObj = null; + var argsStr = argsSb?.ToString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(argsStr)) + { + try { argsObj = JObject.Parse(argsStr); } + catch { argsObj = new JObject(); } + } + + var toolCall = new AIInteractionToolCall + { + Id = id, + Name = name, + Arguments = argsObj, + Agent = AIAgent.ToolCall, + }; + + toolInteractions.Add(toolCall); + toolCallsList.Add(toolCall); + } + + if (toolInteractions.Count > 0) + { + var tcDelta = new AIReturn { Request = request, Status = AICallStatus.CallingTools }; + tcDelta.SetBody(toolInteractions); + yield return tcDelta; + haveStreamedAny = true; + } + + toolCallsEmitted = true; + break; + } + if (!string.IsNullOrEmpty(finishReason) && string.Equals(finishReason, "stop", StringComparison.OrdinalIgnoreCase)) { break; From 43809734523a5d42bf390ff5231b212fe8582b83 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 24 Dec 2025 12:23:39 +0100 Subject: [PATCH 10/26] refactor(chat): extract shared streaming logic and optimize provider adapter discovery - Extract duplicated streaming delta processing (~80 lines) into shared `ProcessStreamingDeltasAsync` helper in `ConversationSession` - Add `GetStreamingAdapter()` to `IAIProvider` interface with caching in `AIProvider` base class, replacing reflection-based discovery - Add `CreateStreamingAdapter()` virtual method for providers to override; update OpenAI, DeepSeek, MistralAI, Anthropic, and OpenRouter - Removed "Clear" button from webchat and replaced with a "Regen" button on debug --- CHANGELOG.md | 11 + README.md | 2 +- Solution.props | 2 +- docs/Architecture.md | 1 - docs/Reviews/251224 WebChat Performance.md | 553 ++++++++++++++++++ docs/Reviews/index.md | 24 + docs/UI/Chat/WebView-Bridge.md | 2 +- docs/index.md | 4 + .../AI/AIChatComponent.cs | 2 +- src/SmartHopper.Core/UI/CanvasButton.cs | 2 +- .../UI/Chat/ChatResourceManager.cs | 10 + .../UI/Chat/Resources/css/chat-styles.css | 14 + .../UI/Chat/Resources/js/chat-script.js | 55 +- .../Resources/templates/chat-template.html | 10 +- src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 318 +++++----- .../UI/Chat/WebChatObserver.cs | 266 +++++---- .../AdvancedConfigTests.cs | 6 + .../Execution/DefaultProviderExecutor.cs | 17 +- .../ConversationSession.SpecialTurns.cs | 48 +- .../AICall/Sessions/ConversationSession.cs | 316 +++++----- .../AIProviders/AIProvider.cs | 33 ++ .../AIProviders/IAIProvider.cs | 7 + .../Streaming/IStreamingAdapter.cs | 9 + .../AnthropicProvider.cs | 6 +- .../DeepSeekProvider.cs | 6 +- .../MistralAIProvider.cs | 6 +- .../OpenAIProvider.cs | 7 +- .../OpenRouterProvider.cs | 6 +- 28 files changed, 1238 insertions(+), 505 deletions(-) create mode 100644 docs/Reviews/251224 WebChat Performance.md create mode 100644 docs/Reviews/index.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e372fe..15bca107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `instruction_get` tool (category: `Instructions`) to provide detailed operational guidance to chat agents on demand. - Simplified the `CanvasButton` default assistant system prompt to reference instruction tools instead of embedding long tool usage guidelines. +### Changed + +- Infrastructure: + - Extracted duplicated streaming processing logic into shared `ProcessStreamingDeltasAsync` helper method in `ConversationSession`, reducing code duplication by ~80 lines. + - Added `GetStreamingAdapter()` to `IAIProvider` interface with caching in `AIProvider` base class, replacing reflection-based adapter discovery. + - Added `CreateStreamingAdapter()` virtual method for providers to override; updated OpenAI, DeepSeek, MistralAI, Anthropic, and OpenRouter providers. + - Added `NormalizeDelta()` method to `IStreamingAdapter` interface for provider-agnostic delta normalization. + - Simplified streaming validation flow in `WebChatDialog.ProcessAIInteraction()` - now always attempts streaming first, letting `ConversationSession` handle validation internally. + - Added `TurnRenderState` and `SegmentState` classes to `WebChatObserver` for encapsulated per-turn state management. + - Reduced idempotency cache size from 1000 to 100 entries to reduce memory footprint. + ### Fixed - Tool calling: diff --git a/README.md b/README.md index 0b4e1b4f..0e293dbf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.2.2-dev.251222-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.2.2-dev.251224-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![Status](https://img.shields.io/badge/status-Unstable%20Development-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) diff --git a/Solution.props b/Solution.props index 502765d9..7e70ca86 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.2.2-dev.251222 + 1.2.2-dev.251224 \ No newline at end of file diff --git a/docs/Architecture.md b/docs/Architecture.md index da823768..b7bf0079 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -19,7 +19,6 @@ Data flow: 1) Component input/state → 2) Context providers → 3) Provider + Model resolution → 4) AI call (tools optional) → 5) Response decoding → 6) Metrics → 7) Component outputs - ## 2. Provider Discovery, Trust, and Loading - Manager: `src/SmartHopper.Infrastructure/AIProviders/ProviderManager.cs` — docs: [ProviderManager](./Providers/ProviderManager.md) diff --git a/docs/Reviews/251224 WebChat Performance.md b/docs/Reviews/251224 WebChat Performance.md new file mode 100644 index 00000000..9935b573 --- /dev/null +++ b/docs/Reviews/251224 WebChat Performance.md @@ -0,0 +1,553 @@ +# WebChat Performance Review + +**Date**: December 25, 2024 +**Scope**: `WebChatDialog.cs`, `ConversationSession.cs`, `AIProvider.cs` interaction analysis +**Author**: Performance Review Bot + +--- + +## Executive Summary + +This review analyzes the interaction between the WebChat UI layer (`WebChatDialog`, `WebChatObserver`), the conversation orchestration layer (`ConversationSession`), and the provider execution layer (`AIProvider`). The analysis identifies performance bottlenecks, architectural inconsistencies, and opportunities for simplification. + +**Key Findings**: + +- Complex data flow with multiple abstraction layers adds latency +- Streaming and non-streaming paths have duplicated logic +- Observer pattern creates tight coupling between UI and session +- Provider-specific streaming adapter discovery uses reflection at runtime +- DOM update serialization is well-designed but complex + +--- + +## 1. Architecture Overview + +### 1.1 Component Responsibilities + +| Component | Responsibility | +|-----------|---------------| +| `WebChatDialog` | UI host, WebView management, DOM update serialization, event bridging | +| `WebChatObserver` | Converts session events to UI updates, streaming aggregation, throttling | +| `ConversationSession` | Multi-turn orchestration, tool execution, history management | +| `AIProvider` | HTTP calls, request encoding/decoding, model selection | +| `DefaultProviderExecutor` | Provider and tool execution delegation, streaming adapter probing | + +### 1.2 High-Level Flow Diagram + +```mermaid +flowchart TB + subgraph UI["UI Layer (SmartHopper.Core)"] + WCD[WebChatDialog] + WCO[WebChatObserver] + WV[WebView/JS] + end + + subgraph Session["Session Layer (Infrastructure)"] + CS[ConversationSession] + DPE[DefaultProviderExecutor] + TM[ToolManager] + end + + subgraph Provider["Provider Layer"] + AP[AIProvider] + SA[StreamingAdapter] + HTTP[HttpClient] + end + + WV -->|"sh://event?type=send"| WCD + WCD -->|"AddInteraction()"| CS + WCD -->|"Stream()/RunToStableResult()"| CS + CS -->|"IConversationObserver events"| WCO + WCO -->|"UpsertMessageByKey()"| WCD + WCD -->|"ExecuteScript()"| WV + + CS -->|"ExecProviderAsync()"| DPE + DPE -->|"Exec()"| AP + DPE -->|"TryGetStreamingAdapter()"| SA + SA -->|"StreamAsync()"| HTTP + AP -->|"CallApi()"| HTTP + + CS -->|"ExecuteSingleToolAsync()"| TM + TM -->|tool result| CS +``` + +--- + +## 2. Detailed Flow Analysis + +### 2.1 User Message Flow (Send Message) + +```mermaid +sequenceDiagram + participant JS as WebView JS + participant WCD as WebChatDialog + participant CS as ConversationSession + participant WCO as WebChatObserver + participant DPE as DefaultProviderExecutor + participant AP as AIProvider + + JS->>WCD: sh://event?type=send&text=... + Note over WCD: ParseQueryString() + WCD->>WCD: SendMessage(text) + WCD->>WCD: _pendingUserMessage = text + WCD->>WCD: UpsertMessageByKey(userInteraction) + WCD->>WCD: ExecuteScript("setProcessing(true)") + WCD->>WCD: Task.Run(ProcessAIInteraction) + + WCD->>CS: AddInteraction(userMessage) + Note over CS: Append to Request.Body + + alt Streaming Path + WCD->>CS: Stream(options, streamingOptions, ct) + CS->>WCO: OnStart(request) + WCO->>WCD: ExecuteScript("setStatus('Thinking...')") + + loop TurnLoop + CS->>DPE: TryGetStreamingAdapter(request) + DPE->>AP: GetStreamingAdapter() [reflection] + AP-->>DPE: IStreamingAdapter? + + alt Adapter Available + loop Stream Deltas + DPE->>AP: adapter.StreamAsync() + AP-->>CS: AIReturn delta + CS->>WCO: OnDelta(textInteraction) + WCO->>WCD: UpsertMessageByKey(aggregated) + end + CS->>CS: PersistStreamingSnapshot() + else No Adapter + CS->>DPE: ExecProviderAsync() + DPE->>AP: request.Exec() + AP-->>CS: AIReturn + end + + CS->>WCO: OnInteractionCompleted(interaction) + + alt Has Pending Tool Calls + CS->>CS: ProcessPendingToolsAsync() + CS->>WCO: OnToolCall(tc) + CS->>WCO: OnToolResult(tr) + end + end + + CS->>WCO: OnFinal(result) + WCO->>WCD: UpsertMessageByKey(finalAssistant) + WCO->>WCD: ExecuteScript("setStatus('Ready')") + else Non-Streaming Path + WCD->>CS: RunToStableResult(options) + Note over CS: Same TurnLoop without streaming + end +``` + +### 2.2 Streaming vs Non-Streaming Decision + +```mermaid +flowchart TD + START[ProcessAIInteraction] --> VAL{Validate streaming?} + VAL -->|WantsStreaming=true| CHECK{IsValid()?} + CHECK -->|valid| STREAM[Stream path] + CHECK -->|invalid| NS[Non-streaming fallback] + VAL -->|error| NS + + STREAM --> ADAPTER{Has StreamingAdapter?} + ADAPTER -->|yes| SSE[SSE streaming] + ADAPTER -->|no| SINGLE[Single provider call] + + SSE --> DELTAS[Yield deltas] + DELTAS --> PERSIST[PersistStreamingSnapshot] + + SINGLE --> MERGE[Merge interactions] + + NS --> RUN[RunToStableResult] + RUN --> LOOP[TurnLoopAsync yieldDeltas=false] + + PERSIST --> TOOLS{Pending tool calls?} + MERGE --> TOOLS + TOOLS -->|yes| EXEC_TOOLS[ProcessPendingToolsAsync] + TOOLS -->|no| FINAL[NotifyFinal] + EXEC_TOOLS --> LOOP2{More turns?} + LOOP2 -->|yes| ADAPTER + LOOP2 -->|no| FINAL +``` + +### 2.3 Tool Call Processing Flow + +```mermaid +sequenceDiagram + participant CS as ConversationSession + participant TM as ToolManager + participant WCO as WebChatObserver + participant Tool as AITool + + CS->>CS: ProcessPendingToolsAsync() + + loop Each Pending Tool Call + CS->>WCO: OnToolCall(tc) + WCO->>WCO: SetBoundaryFlag(turnKey) + + CS->>CS: ExecuteSingleToolAsync(tc) + CS->>TM: ExecuteTool(toolCall) + TM->>Tool: Execute(args) + Tool-->>TM: result + TM-->>CS: AIReturn with tool result + + CS->>CS: AppendToSessionHistory(toolResult) + CS->>WCO: OnToolResult(tr) + WCO->>WCO: SetBoundaryFlag(turnKey) + end + + Note over CS: If streaming, continue with adapter + Note over CS: If non-streaming, execute provider turn +``` + +--- + +## 3. Performance Analysis + +### 3.1 DOM Update Pipeline + +The DOM update mechanism in `WebChatDialog` is well-designed for performance: + +```mermaid +flowchart LR + subgraph Serialization + A[UpsertMessageByKey] --> B[Task.Run HTML render] + B --> C{IsLatestRenderVersion?} + C -->|no| DROP[Drop stale] + C -->|yes| D[RunWhenWebViewReady] + end + + subgraph Queue + D --> E{WebView Ready?} + E -->|no| F[Queue in TCS] + E -->|yes| G[EnqueueAndScheduleDrain] + F --> G + end + + subgraph Drain + G --> H{Deferred for resize?} + H -->|yes| I[Wait DomDeferDuringMoveResizeMs] + H -->|no| J[DrainDomUpdateQueue] + I --> J + J --> K[Execute batch of 10] + K --> L{More items?} + L -->|yes| M[ScheduleDomDrain debounce 16ms] + L -->|no| DONE[Done] + end +``` + +**Strengths**: +- Render versioning prevents stale updates +- Batched draining (10 items per batch) prevents UI blocking +- Debounced scheduling (16ms) coalesces rapid updates +- Window move/resize deferral prevents janky interactions + +**Weaknesses**: +- LRU cache for idempotency (1000 entries) may be memory-heavy for long conversations +- Concurrent script limit (4) may cause re-queueing under heavy streaming + +### 3.2 Observer Pattern Overhead + +```mermaid +flowchart TD + subgraph ConversationSession + CS1[Stream delta arrives] + CS2[BuildDeltaReturn] + CS3[NotifyDelta] + end + + subgraph WebChatObserver + WCO1[OnDelta called] + WCO2[RhinoApp.InvokeOnUiThread] + WCO3[Get/Create StreamState] + WCO4[CoalesceTextStreamChunk] + WCO5[CommitSegment?] + WCO6[ShouldUpsertNow?] + WCO7[ShouldRenderDelta?] + WCO8[UpsertMessageByKey] + end + + subgraph WebChatDialog + WCD1[Task.Run render] + WCD2[Queue update] + WCD3[ExecuteScript] + end + + CS1 --> CS2 --> CS3 --> WCO1 + WCO1 --> WCO2 --> WCO3 --> WCO4 --> WCO5 --> WCO6 --> WCO7 --> WCO8 + WCO8 --> WCD1 --> WCD2 --> WCD3 +``` + +**Each streaming delta traverses**: + +1. Session creates delta return +2. Observer receives via interface call +3. Marshal to UI thread +4. Coalesce with previous chunks +5. Check throttling +6. Check content change +7. Render HTML in background task +8. Queue for DOM update +9. Execute script + +**Potential bottleneck**: ~9 steps per delta with multiple dictionary lookups and thread marshaling. + +### 3.3 Streaming Adapter Discovery + +```csharp +// DefaultProviderExecutor.TryGetStreamingAdapter +var mi = provider?.GetType().GetMethod("GetStreamingAdapter", Type.EmptyTypes); +var obj = mi?.Invoke(provider, null); +``` + +**Issue**: Reflection is used for each streaming request to discover the adapter. + +--- + +## 4. Identified Weaknesses + +### 4.1 Code Duplication in Streaming Paths + +**Location**: `ConversationSession.TurnLoopAsync` and `ProcessPendingToolsAsync` + +Both methods contain nearly identical streaming logic: + +```csharp +// In TurnLoopAsync (lines 276-359) +await foreach (var delta in adapter.StreamAsync(...)) +{ + // Process interactions, accumulate text, persist non-text... +} + +// In ProcessPendingToolsAsync (lines 756-811) +await foreach (var delta in adapter.StreamAsync(...)) +{ + // Nearly identical processing logic +} +``` + +**Impact**: + +- ~120 lines of duplicated streaming processing code +- Bug fixes must be applied in two places +- Divergent behavior risk between initial stream and post-tool stream + +### 4.2 Complex Observer State Management + +**Location**: `WebChatObserver` (959 lines) + +The observer maintains extensive state for streaming aggregation: + +| State | Purpose | +|-------|---------| +| `_streams` | Committed streaming aggregates by segmented key | +| `_preStreamAggregates` | Pre-commit aggregates before first render | +| `_textInteractionSegments` | Segment counters per base key | +| `_pendingNewTextSegmentTurns` | Boundary flags for segment rollover | +| `_finalizedTextTurns` | Turns that should ignore late deltas | +| `_lastUpsertAt` | Throttling timestamps per key | +| `_lastRenderedTextByKey` | Content dedup tracking | +| `_thinkingBubbleActive` | UI bubble state | + +**Impact**: + +- High cognitive load for maintenance +- State synchronization complexity +- Risk of state leaks between turns + +### 4.3 Provider-Specific Behavior Inconsistencies + +**Location**: Streaming adapters in each provider + +Each provider implements streaming differently: + +| Provider | Protocol | Delta Format | Reasoning Support | +|----------|----------|--------------|-------------------| +| OpenAI | SSE | `choices[].delta.content` | via tool_calls | +| DeepSeek | Chunked JSON | `choices[].delta` | `reasoning_content` field | +| MistralAI | SSE | `chunk` objects | Limited | + +**Issue**: `ConversationSession` expects uniform delta processing but providers emit different structures. + +### 4.4 Unnecessary Data Passing + +**Location**: Multiple levels + +```mermaid +flowchart LR + A[User message] --> B[AIInteractionText] + B --> C[ConversationSession.AddInteraction] + C --> D[Request.Body.Interactions] + D --> E[AIRequestCall.Encode] + E --> F[Provider.Encode] + F --> G[HTTP body JSON] +``` + +**Issue**: The user message is wrapped/unwrapped multiple times: +1. String → `AIInteractionText` +2. `AIInteractionText` → Added to `List` +3. List → Encoded to provider-specific JSON +4. JSON → HTTP request body + +### 4.5 Centralization Opportunities + +#### 4.5.1 Model Selection in Multiple Places + +Model resolution happens in: +- `AIProvider.SelectModel()` +- `AIProvider.GetDefaultModel()` +- `ModelManager.SelectBestModel()` +- `AIRequestBase.Model` property getter + +**Should be**: Single entry point in `ModelManager.SelectBestModel()` called once. + +#### 4.5.2 Streaming Validation Scattered + +Streaming validation occurs in: +- `WebChatDialog.ProcessAIInteraction()` (pre-check) +- `ConversationSession.ValidateBeforeStart()` (with `wantsStreaming` flag) +- `AIRequestBase.IsValid()` (streaming-specific rules) + +**Should be**: Single validation in `ConversationSession` before execution. + +--- + +## 5. Flow Inconsistencies + +### 5.1 Greeting Generation Path + +```mermaid +flowchart TD + A[WebChatDialog constructor] --> B{generateGreeting?} + B -->|yes| C[Pass to ConversationSession] + C --> D[InitializeNewConversation] + D --> E[RunToStableResult maxTurns=1] + E --> F[TurnLoopAsync] + F --> G{_generateGreeting && !_greetingEmitted?} + G -->|yes| H[GenerateGreetingAsync] + H --> I[ExecuteSpecialTurnAsync] + I --> J[Separate request with greeting config] + J --> K[Observer sees greeting as normal turn] +``` + +**Issue**: Greeting uses a "special turn" system that creates a separate request, adding complexity for a simple use case. + +### 5.2 Cancellation Propagation + +```mermaid +flowchart TD + A[User clicks Cancel] --> B[WebChatDialog.CancelChat] + B --> C[_currentCts.Cancel] + B --> D[_currentSession.Cancel] + D --> E[ConversationSession.cts.Cancel] + C --> F[Linked CTS in TurnLoopAsync] + E --> F + F --> G{Check ct.ThrowIfCancellationRequested} + G --> H[OperationCanceledException] + H --> I[HandleAndNotifyError] + I --> J[Observer.OnError] +``` + +**Issue**: Two separate cancellation token sources (`_currentCts` in dialog, `cts` in session) that get linked. Simpler to pass a single token. + +--- + +## 6. Recommendations + +### 6.1 High Priority + +#### R1: Extract Streaming Processing to Helper Method ✅ IMPLEMENTED + +**Current**: Duplicated streaming logic in `TurnLoopAsync` and `ProcessPendingToolsAsync` + +**Implemented**: Added `ProcessStreamingDeltasAsync` helper method and `StreamProcessingResult` class in `ConversationSession.cs`. Both `TurnLoopAsync` and `ProcessPendingToolsAsync` now use the shared helper, reducing code duplication by ~80 lines. + +#### R2: Simplify Observer State ✅ IMPLEMENTED + +**Current**: 8+ state dictionaries/sets in WebChatObserver + +**Implemented**: Added `TurnRenderState` and `SegmentState` classes to `WebChatObserver`. The new structure encapsulates per-turn state with segments, finalization status, and boundary tracking. State is cleared in `OnStart` and `OnFinal`. + +#### R3: Cache Streaming Adapter Discovery ✅ IMPLEMENTED + +**Current**: Reflection every streaming request + +**Implemented**: +- Added `GetStreamingAdapter()` to `IAIProvider` interface +- Added caching in `AIProvider` base class with `_cachedStreamingAdapter` and `_streamingAdapterProbed` fields +- Added `CreateStreamingAdapter()` virtual method for providers to override +- Updated all providers (OpenAI, DeepSeek, MistralAI, Anthropic, OpenRouter) to use `CreateStreamingAdapter()` +- Updated `DefaultProviderExecutor` to call the cached method directly instead of reflection + +### 6.2 Medium Priority + +#### R4: Unify Validation Location ✅ IMPLEMENTED + +**Current**: Scattered across dialog, session, and request + +**Implemented**: Simplified `WebChatDialog.ProcessAIInteraction()` to always attempt streaming first, letting `ConversationSession.ValidateBeforeStart()` be the single validation gate. Removed ~40 lines of pre-validation code. + +#### R5: Simplify Greeting System + +**Current**: Special turn system with separate request creation + +**Status**: NOT IMPLEMENTED - Lower priority, existing system works well. + +#### R6: Provider-Agnostic Delta Normalization ✅ IMPLEMENTED + +**Current**: Session expects uniform deltas but providers differ + +**Implemented**: Added `NormalizeDelta(AIReturn delta)` method to `IStreamingAdapter` interface with default implementation returning delta unchanged. Updated `ProcessStreamingDeltasAsync` to call `adapter.NormalizeDelta(rawDelta)` for each delta. Providers can now override to handle specific quirks. + +### 6.3 Low Priority + +#### R7: Reduce Thread Marshaling ⏭️ SKIPPED + +**Current**: Every observer event marshals to UI thread + +**Status**: SKIPPED - The existing implementation already has effective throttling (`ShouldUpsertNow` with 50ms/400ms), batched DOM updates (10 items per batch with 16ms debounce), and deferred updates during window move/resize. A Channel-based approach would add complexity without significant benefit. + +#### R8: Memory Optimization ✅ IMPLEMENTED + +**Current**: LRU cache with 1000 entries for HTML idempotency + +**Implemented**: Reduced `MaxIdempotencyCacheSize` from 1000 to 100 entries in `WebChatDialog.cs`. This is sufficient for typical conversations while significantly reducing memory footprint. + +--- + +## 7. Metrics Summary + +| Metric | Current | Target | +|--------|---------|--------| +| Streaming delta processing steps | 9 | 5 | +| Code duplication (streaming) | ~120 lines | 0 | +| Observer state objects | 8 | 2-3 | +| Thread marshals per delta | 1 | Batched | +| Reflection calls per stream | 1+ | 0 (cached) | + +--- + +## 8. Appendix: Key File Locations + +| File | Lines | Purpose | +|------|-------|---------| +| `WebChatDialog.cs` | 1434 | UI host and DOM management | +| `WebChatObserver.cs` | 959 | Session-to-UI bridge | +| `ConversationSession.cs` | 897 | Multi-turn orchestration | +| `AIProvider.cs` | 724 | HTTP and encoding/decoding | +| `DefaultProviderExecutor.cs` | 67 | Execution delegation | + +--- + +## 9. Conclusion + +The WebChat interaction architecture is functional and well-documented, but has accumulated complexity over time. Key areas for improvement: + +1. **Consolidate streaming logic** - Extract shared code from turn loop and tool processing +2. **Simplify observer state** - Encapsulate related state into cohesive structures +3. **Cache adapter discovery** - Avoid reflection on hot path +4. **Unify validation** - Single validation point in session layer + +The DOM update serialization is the strongest part of the architecture, with proper batching, debouncing, and stale update prevention. This pattern could be applied more broadly. + +Estimated effort for high-priority recommendations: **2-3 days** diff --git a/docs/Reviews/index.md b/docs/Reviews/index.md new file mode 100644 index 00000000..d1b46418 --- /dev/null +++ b/docs/Reviews/index.md @@ -0,0 +1,24 @@ +# Architecture Reviews + +This folder contains detailed architecture reviews of SmartHopper components. + +## Reviews + +- [251224 WebChat Performance](./251224%20WebChat%20Performance.md) - Analysis of WebChatDialog, ConversationSession, and AIProvider interaction + +## Purpose + +Performance reviews document: + +- Current operation flows with diagrams +- Identified weaknesses and inconsistencies +- Code simplification opportunities +- Recommendations prioritized by impact + +## Naming Convention + +Reviews follow the format: `YYMMDD {Component/Feature} {Review Type}.md` + +Examples: + +- `251224 WebChat Performance.md` diff --git a/docs/UI/Chat/WebView-Bridge.md b/docs/UI/Chat/WebView-Bridge.md index cfebde6c..4a3eeac2 100644 --- a/docs/UI/Chat/WebView-Bridge.md +++ b/docs/UI/Chat/WebView-Bridge.md @@ -134,7 +134,7 @@ The WebView only applies patches when a message with the target `key` already ex - Host cancels any run and clears transcript in place (`clearMessages()`), emits reset snapshot. - cancel - JS → host: `sh://event?type=cancel` - - Host cancels current CTS and session (`ConversationSession.Cancel()`). + - Host cancels current CTS. - clipboard - JS → host: `clipboard://copy?text=...` - Host sets system clipboard and calls `showToast('Copied to clipboard')`. diff --git a/docs/index.md b/docs/index.md index 66923515..beee2d1b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,3 +12,7 @@ This index lists the available documentation for SmartHopper. It will be updated - [Components](Components/index.md) — the interaction between the user and SmartHopper - [UI](UI/Chat/index.md) — Web Chat UI and host ↔ JS bridge - [GhJSON](GhJSON/index.md) — Grasshopper JSON serialization format for AI-powered workflows + +## Reviews + +- [Reviews](Reviews/index.md) — Architecture analysis of SmartHopper components diff --git a/src/SmartHopper.Components/AI/AIChatComponent.cs b/src/SmartHopper.Components/AI/AIChatComponent.cs index a6cb2eb6..a2436ab3 100644 --- a/src/SmartHopper.Components/AI/AIChatComponent.cs +++ b/src/SmartHopper.Components/AI/AIChatComponent.cs @@ -270,7 +270,7 @@ public override async System.Threading.Tasks.Task DoWorkAsync(CancellationToken modelName: this.component.GetModel(), endpoint: "ai-chat", systemPrompt: this.component.SystemPrompt, - toolFilter: "Instructions,Knowledge,Components,ComponentsRetrieval,Parameters,Scripting", + toolFilter: "Components,ComponentsRetrieval,Instructions,Knowledge,Parameters,Scripting", componentId: this.component.InstanceGuid, progressReporter: this.progressReporter, onUpdate: snapshot => diff --git a/src/SmartHopper.Core/UI/CanvasButton.cs b/src/SmartHopper.Core/UI/CanvasButton.cs index a16ff7e7..79d962cf 100644 --- a/src/SmartHopper.Core/UI/CanvasButton.cs +++ b/src/SmartHopper.Core/UI/CanvasButton.cs @@ -696,7 +696,7 @@ private static async Task TriggerChatDialog() model, endpoint: "canvas-chat", systemPrompt: DefaultSystemPrompt, - toolFilter: "Instructions,Knowledge,Components,ComponentsRetrieval,Parameters,Scripting", + toolFilter: "Components,ComponentsRetrieval,Instructions,Knowledge,Parameters,Scripting", componentId: CanvasChatDialogId, progressReporter: null, onUpdate: null, diff --git a/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs b/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs index 2b08bad3..b78b277a 100644 --- a/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs +++ b/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs @@ -105,6 +105,7 @@ public string GetCompleteHtml() string completeHtml = chatTemplate .Replace("{{cssChat}}", cssContent, StringComparison.Ordinal) .Replace("{{jsChat}}", jsContent, StringComparison.Ordinal) + .Replace("{{debugActionsLeft}}", this.GetDebugActionsLeftHtml(), StringComparison.Ordinal) .Replace("{{messageTemplate}}", messageTemplate, StringComparison.Ordinal); Debug.WriteLine($"[ChatResourceManager] Complete HTML created, length: {completeHtml?.Length ?? 0}"); @@ -131,6 +132,15 @@ public string GetCompleteHtml() } } + private string GetDebugActionsLeftHtml() + { +#if DEBUG + return ""; +#else + return string.Empty; +#endif + } + /// /// Gets the chat template HTML. /// diff --git a/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css b/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css index ee1733bc..b1d5500f 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css +++ b/src/SmartHopper.Core/UI/Chat/Resources/css/chat-styles.css @@ -94,9 +94,23 @@ body { } #input-bar .actions { + display: flex; + gap: 6px; + justify-content: space-between; + align-items: center; +} + +#input-bar .actions-left { + display: flex; + gap: 6px; + align-items: center; +} + +#input-bar .actions-right { display: flex; gap: 6px; justify-content: flex-end; + align-items: center; } #input-bar .actions button { diff --git a/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js b/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js index 28607f95..4bfde402 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js +++ b/src/SmartHopper.Core/UI/Chat/Resources/js/chat-script.js @@ -890,8 +890,9 @@ document.addEventListener('DOMContentLoaded', function () { try { const input = document.getElementById('user-input'); const sendBtn = document.getElementById('send-button'); - const clearBtn = document.getElementById('clear-button'); const cancelBtn = document.getElementById('cancel-button'); + const regenBtn = document.getElementById('regen-button'); // present only when host injects debug actions + const chatContainer = document.getElementById('chat-container'); const newIndicator = document.getElementById('new-messages-indicator'); const scrollBtn = document.getElementById('scroll-bottom-btn'); @@ -899,8 +900,8 @@ document.addEventListener('DOMContentLoaded', function () { console.log('[JS] Element search results:', { input: !!input, sendBtn: !!sendBtn, - clearBtn: !!clearBtn, cancelBtn: !!cancelBtn, + regenBtn: !!regenBtn, chatContainer: !!chatContainer, newIndicator: !!newIndicator, scrollBtn: !!scrollBtn @@ -929,16 +930,6 @@ document.addEventListener('DOMContentLoaded', function () { console.error('[JS] Send button not found!'); } - if (clearBtn) { - clearBtn.addEventListener('click', () => { - console.log('[JS] Clear button clicked'); - window.location.href = 'sh://event?type=clear'; - }); - console.log('[JS] Clear button click handler attached'); - } else { - console.error('[JS] Clear button not found!'); - } - if (cancelBtn) { cancelBtn.addEventListener('click', () => { console.log('[JS] Cancel button clicked'); @@ -949,6 +940,14 @@ document.addEventListener('DOMContentLoaded', function () { console.error('[JS] Cancel button not found!'); } + if (regenBtn) { + regenBtn.addEventListener('click', () => { + console.log('[JS] Regen button clicked'); + window.location.href = 'sh://event?type=regen'; + }); + console.log('[JS] Regen button click handler attached'); + } + if (input) { input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -1010,7 +1009,6 @@ function setProcessing(on) { const spinner = document.getElementById('spinner'); const input = document.getElementById('user-input'); const sendBtn = document.getElementById('send-button'); - const clearBtn = document.getElementById('clear-button'); const cancelBtn = document.getElementById('cancel-button'); if (spinner) { @@ -1032,22 +1030,22 @@ function setProcessing(on) { } // Toggle controls according to processing state - // When processing: disable input + send + clear, enable cancel - // When idle: enable input + send + clear, disable cancel + // When processing: disable input + send, enable cancel + // When idle: enable input + send, disable cancel try { if (input) input.disabled = !!on; if (sendBtn) sendBtn.disabled = !!on; - if (clearBtn) clearBtn.disabled = !!on; if (cancelBtn) cancelBtn.disabled = !on; // Optional: reflect disabled state via CSS class and ARIA for better a11y - [sendBtn, clearBtn, cancelBtn].forEach(btn => { + [sendBtn, cancelBtn].forEach(btn => { if (!btn) return; try { btn.classList.toggle('disabled', !!btn.disabled); btn.setAttribute('aria-disabled', btn.disabled ? 'true' : 'false'); } catch {} }); + if (input) { try { input.setAttribute('aria-disabled', input.disabled ? 'true' : 'false'); @@ -1057,19 +1055,26 @@ function setProcessing(on) { console.warn('[JS] setProcessing: control toggle failed', err); } } - -/** - * Clears all messages from the chat container - */ -function clearMessages() { - console.log('[JS] clearMessages called'); + +function resetMessages() { + console.log('[JS] resetMessages called'); const chatContainer = document.getElementById('chat-container'); if (!chatContainer) { - console.error('[JS] clearMessages: chat-container element not found'); + console.error('[JS] resetMessages: chat-container element not found'); return; } chatContainer.innerHTML = ''; - console.log('[JS] clearMessages: all messages cleared'); + + try { + _templateCache.clear(); + _htmlLru.clear(); + _pendingOps.length = 0; + _flushScheduled = false; + } catch { + // ignore + } + + console.log('[JS] resetMessages: cleared messages and caches'); } // Copy handler: only override when one or more FULL messages are selected diff --git a/src/SmartHopper.Core/UI/Chat/Resources/templates/chat-template.html b/src/SmartHopper.Core/UI/Chat/Resources/templates/chat-template.html index 8be01631..1bf78677 100644 --- a/src/SmartHopper.Core/UI/Chat/Resources/templates/chat-template.html +++ b/src/SmartHopper.Core/UI/Chat/Resources/templates/chat-template.html @@ -27,9 +27,13 @@
- - - +
+ {{debugActionsLeft}} +
+
+ + +
diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index d76465a7..8e88ac22 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -57,7 +57,7 @@ internal partial class WebChatDialog : Form // Uses LRU eviction to prevent unbounded growth in long conversations private readonly Dictionary _lastDomHtmlByKey = new Dictionary(StringComparer.Ordinal); private readonly Queue _lruQueue = new Queue(); - private const int MaxIdempotencyCacheSize = 1000; + private const int MaxIdempotencyCacheSize = 100; // Key length and performance monitoring private int _maxKeyLengthSeen = 0; @@ -92,6 +92,8 @@ internal partial class WebChatDialog : Form // Status text to apply after the document is fully loaded private string _pendingStatusAfter = "Ready"; + private readonly TaskCompletionSource _initialHistoryReplayTcs = new TaskCompletionSource(); + // Greeting behavior: when true, the dialog will request a greeting from ConversationSession on init private readonly bool _generateGreeting; @@ -836,27 +838,77 @@ private void ReplayFullHistoryToWebView() { try { + if (!this._webViewInitialized) + { + this.RunWhenWebViewReady(() => this.ReplayFullHistoryToWebView()); + return; + } + var interactions = this._currentSession?.GetHistoryInteractionList(); if (interactions == null || interactions.Count == 0) { return; } - foreach (var interaction in interactions) + // During a full replay (including Regen), maintain strict DOM order by chaining inserts. + // Rationale: Upsert-by-key alone can lead to out-of-order layout if rendering is deferred per message. + this.RunWhenWebViewReady(() => { - // Hydration must be key-based only. If no key, emit an error message instead of appending. - if (interaction is IAIKeyedInteraction keyed) + string? prevKey = null; + + foreach (var interaction in interactions) { + if (interaction is not IAIKeyedInteraction keyed) + { + this.AddSystemMessage($"Could not render interaction during history replay: missing dedupKey (type={interaction?.GetType().Name}, agent={interaction?.Agent})", "error"); + prevKey = null; + continue; + } + var key = keyed.GetDedupKey(); - if (!string.IsNullOrWhiteSpace(key)) + if (string.IsNullOrWhiteSpace(key)) { - this.UpsertMessageByKey(key, interaction); + this.AddSystemMessage($"Could not render interaction during history replay: missing dedupKey (type={interaction?.GetType().Name}, agent={interaction?.Agent})", "error"); + prevKey = null; continue; } - } - this.AddSystemMessage($"Could not render interaction during history replay: missing dedupKey (type={interaction?.GetType().Name}, agent={interaction?.Agent})", "error"); - } + string html; + try + { + lock (this._htmlRenderLock) + { + html = this._htmlRenderer.RenderInteraction(interaction); + } + } + catch (Exception ex) + { + this.AddSystemMessage($"Could not render interaction during history replay: {ex.Message}", "error"); + prevKey = null; + continue; + } + + // Keep host-side idempotency cache in sync + try + { + this.UpdateIdempotencyCache(key, html ?? string.Empty); + } + catch + { + } + + if (!string.IsNullOrWhiteSpace(prevKey)) + { + this.ExecuteScript($"upsertMessageAfter({JsonConvert.SerializeObject(prevKey)}, {JsonConvert.SerializeObject(key)}, {JsonConvert.SerializeObject(html)});"); + } + else + { + this.ExecuteScript($"upsertMessage({JsonConvert.SerializeObject(key)}, {JsonConvert.SerializeObject(html)});"); + } + + prevKey = key; + } + }); } catch (Exception ex) { @@ -864,20 +916,6 @@ private void ReplayFullHistoryToWebView() } } - /// - /// Emits a reset/empty snapshot to subscribers. - /// - private void EmitResetSnapshot() - { - try - { - this.ChatUpdated?.Invoke(this, new AIReturn()); - } - catch - { - } - } - /// /// Handles sending a user message from the input box. /// @@ -958,15 +996,16 @@ private async Task InitializeWebViewAsync() { this.ReplayFullHistoryToWebView(); this.ExecuteScript("setStatus('Ready'); setProcessing(false);"); + this._initialHistoryReplayTcs.TrySetResult(true); } catch (Exception rex) { DebugLog($"[WebChatDialog] InitializeWebViewAsync replay error: {rex.Message}"); + this._initialHistoryReplayTcs.TrySetResult(false); } }); - // Maintain async path to keep compatibility with any future init work - await Task.Run(() => this.InitializeNewConversation()).ConfigureAwait(false); + await this.InitializeNewConversationAsync().ConfigureAwait(false); } catch (Exception ex) { @@ -981,45 +1020,6 @@ private async Task InitializeWebViewAsync() } } - /// - /// Handles the Clear button click event. - /// - private void ClearChat() - { - try - { - // Log performance stats before clearing - this.LogPerformanceStats(); - - this.CancelCurrentRun(); - - // Clear messages in-place without reloading the WebView - this.RunWhenWebViewReady(() => this.ExecuteScript("clearMessages(); setStatus('Ready'); setProcessing(false);")); - - // Reset last-rendered cache and LRU queue since DOM has been cleared - try - { - this._lastDomHtmlByKey.Clear(); - this._lruQueue.Clear(); - - // Reset performance counters - this._maxKeyLengthSeen = 0; - this._totalEqualityChecks = 0; - this._totalEqualityCheckMs = 0; - } - catch - { - } - - // Emit a reset snapshot to notify listeners (no greeting on clear) - this.EmitResetSnapshot(); - } - catch (Exception ex) - { - DebugLog($"[WebChatDialog] ClearChat error: {ex.Message}"); - } - } - /// /// Processes an AI interaction using the new AICall models and handles tool calls automatically. /// @@ -1054,75 +1054,34 @@ private async Task ProcessAIInteraction() var options = new SessionOptions { ProcessTools = true, CancellationToken = this._currentCts.Token }; - // Decide whether streaming should be attempted first using the session's request - var sessionRequest = this._currentSession.Request; - bool shouldTryStreaming = false; - try - { - sessionRequest.WantsStreaming = true; - var validation = sessionRequest.IsValid(); - shouldTryStreaming = validation.IsValid; - DebugLog($"[WebChatDialog] Request validation: IsValid={validation.IsValid}, Errors={validation.Errors?.Count ?? 0}"); - if (validation.Errors != null) - { -#if DEBUG - try - { - var msgs = string.Join(" | ", validation.Errors.Select(err => $"{err.Severity}:{err.Message}")); - DebugLog($"[WebChatDialog] Validation messages: {msgs}"); - } - catch { /* ignore logging errors */ } -#endif - } - } - catch (Exception ex) - { - DebugLog($"[WebChatDialog] Streaming validation threw: {ex.Message}. Falling back."); - shouldTryStreaming = false; - } - finally - { - // Ensure we don't carry the streaming intent into non-streaming execution - sessionRequest.WantsStreaming = false; - } + // Always attempt streaming first - ConversationSession handles validation internally + // and falls back to non-streaming if streaming is not supported + DebugLog("[WebChatDialog] Starting streaming path (session handles validation)"); + var streamingOptions = new StreamingOptions(); AIReturn? lastStreamReturn = null; - if (shouldTryStreaming) + await foreach (var r in this._currentSession + .Stream(options, streamingOptions, this._currentCts.Token) + .ConfigureAwait(false)) { - DebugLog("[WebChatDialog] Starting streaming path"); - var streamingOptions = new StreamingOptions(); - - // Consume the stream to drive incremental UI updates via observer - // Track the last streamed return so we can decide about fallback. - await foreach (var r in this._currentSession - .Stream(options, streamingOptions, this._currentCts.Token) - .ConfigureAwait(false)) - { - lastStreamReturn = r; - - // No-op: observer handles partial/final UI updates. - } + lastStreamReturn = r; + // Observer handles partial/final UI updates } - // Check if streaming actually failed (API error, network error, not just validation) - bool streamingFailed = lastStreamReturn == null || - lastStreamReturn.Messages.Any(m => - m != null && - m.Severity == AIRuntimeMessageSeverity.Error && - (m.Origin == AIRuntimeMessageOrigin.Provider || - m.Origin == AIRuntimeMessageOrigin.Network)); + // Check if streaming returned a validation error (provider/model doesn't support streaming) + // In that case, ConversationSession already handles fallback internally via OnFinal + bool hasValidationError = lastStreamReturn?.Messages?.Any(m => + m != null && + m.Severity == AIRuntimeMessageSeverity.Error && + m.Origin == AIRuntimeMessageOrigin.Validation) ?? false; - // If streaming finished with an error or yielded nothing or no streaming was attempted, fallback to non-streaming. - if (streamingFailed || !shouldTryStreaming) - { - DebugLog("[WebChatDialog] Streaming ended with error or no result. Falling back to non-streaming path"); - - // Ensure streaming flag is not set for non-streaming execution - sessionRequest.WantsStreaming = false; + // If we got a validation error with no content, fall back to non-streaming + bool hasContent = lastStreamReturn?.Body?.Interactions?.Any(i => + i is AIInteractionText t && !string.IsNullOrWhiteSpace(t.Content)) ?? false; - // Run non-streaming to completion. The ConversationSession observer (WebChatObserver) - // handles UI updates via OnInteractionCompleted/OnFinal (replace loading bubble, emit snapshot). - // Do NOT manually append interactions here to avoid duplicate assistant messages. + if (hasValidationError && !hasContent) + { + DebugLog("[WebChatDialog] Streaming validation failed. Falling back to non-streaming path"); await this._currentSession.RunToStableResult(options).ConfigureAwait(false); } } @@ -1160,41 +1119,24 @@ private async Task ProcessAIInteraction() } } - /// - /// Cancels the current running session, if any. - /// - private void CancelCurrentRun() - { - try - { - this._currentCts?.Cancel(); - this._currentSession?.Cancel(); - DebugLog("[WebChatDialog] Cancellation requested"); - } - catch (Exception ex) - { - DebugLog($"[WebChatDialog] Error requesting cancellation: {ex.Message}"); - } - } - - /// - /// Handles the cancel button click event. - /// - private void CancelChat() - { - this.CancelCurrentRun(); - } - /// /// Initializes a new conversation and, if requested, triggers a one-shot provider run to emit the greeting. /// - private async void InitializeNewConversation() + private async Task InitializeNewConversationAsync() { // For fidelity, history is fully replayed elsewhere. Keep this method minimal to maintain compatibility. try { this.RunWhenWebViewReady(() => this.ExecuteScript("setStatus('Ready'); setProcessing(false);")); + try + { + await this._initialHistoryReplayTcs.Task.ConfigureAwait(false); + } + catch + { + } + // If greeting was requested by the creator (e.g., CanvasButton), run a single non-streaming turn. if (this._generateGreeting && this._currentSession != null) { @@ -1263,39 +1205,41 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) break; } - case "clear": - DebugLog($"[WebChatDialog] Handling clear event"); + case "cancel": + DebugLog($"[WebChatDialog] Handling cancel event"); // Defer to next UI tick to avoid executing scripts during navigation event Application.Instance?.AsyncInvoke(() => { try { - this.ClearChat(); + this.CancelChat(); } catch (Exception ex) { - DebugLog($"[WebChatDialog] Deferred ClearChat error: {ex.Message}"); + DebugLog($"[WebChatDialog] Deferred CancelChat error: {ex.Message}"); } }); break; - case "cancel": - DebugLog($"[WebChatDialog] Handling cancel event"); +#if DEBUG + case "regen": + DebugLog($"[WebChatDialog] Handling regen event"); // Defer to next UI tick to avoid executing scripts during navigation event Application.Instance?.AsyncInvoke(() => { try { - this.CancelChat(); + this.RegenChat(); } catch (Exception ex) { - DebugLog($"[WebChatDialog] Deferred CancelChat error: {ex.Message}"); + DebugLog($"[WebChatDialog] Deferred RegenChat error: {ex.Message}"); } }); break; +#endif default: DebugLog($"[WebChatDialog] Unknown sh:// event type: '{type}'"); @@ -1344,6 +1288,60 @@ private void WebView_DocumentLoading(object? sender, WebViewLoadingEventArgs e) } } + private void CancelChat() + { + try + { + this._currentCts?.Cancel(); + DebugLog("[WebChatDialog] Cancellation requested"); + } + catch (Exception ex) + { + DebugLog($"[WebChatDialog] Error requesting cancellation: {ex.Message}"); + } + } + +#if DEBUG + private void RegenChat() + { + try + { + this.CancelChat(); + + this.RunWhenWebViewReady(() => + { + try + { + this.ExecuteScript("resetMessages(); setStatus('Ready'); setProcessing(false);"); + + try + { + this._lastDomHtmlByKey.Clear(); + this._lruQueue.Clear(); + this._renderVersionByDomKey.Clear(); + this._maxKeyLengthSeen = 0; + this._totalEqualityChecks = 0; + this._totalEqualityCheckMs = 0; + } + catch + { + } + + this.ReplayFullHistoryToWebView(); + } + catch (Exception ex) + { + DebugLog($"[WebChatDialog] RegenChat UI error: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + DebugLog($"[WebChatDialog] RegenChat error: {ex.Message}"); + } + } +#endif + /// /// Handles a user message submitted from the WebView. /// diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index 20a538c7..6a526080 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -40,6 +40,67 @@ private sealed class StreamState public IAIInteraction Aggregated; } + /// + /// Encapsulates all render state for a single turn, reducing scattered dictionary lookups. + /// + private sealed class TurnRenderState + { + public string TurnId { get; } + public bool IsFinalized { get; set; } + public bool HasPendingBoundary { get; set; } + public Dictionary Segments { get; } = new Dictionary(StringComparer.Ordinal); + + public TurnRenderState(string turnId) + { + this.TurnId = turnId; + } + + public SegmentState GetOrCreateSegment(string baseKey) + { + if (!this.Segments.TryGetValue(baseKey, out var seg)) + { + seg = new SegmentState { SegmentNumber = 1 }; + this.Segments[baseKey] = seg; + } + + return seg; + } + } + + /// + /// State for a single segment within a turn. + /// + private sealed class SegmentState + { + public int SegmentNumber { get; set; } = 1; + public bool IsCommitted { get; set; } + public StreamState StreamState { get; set; } + public DateTime LastUpsertAt { get; set; } + public (string? Content, string? Reasoning) LastRenderedText { get; set; } + } + + // Turn-level state management (replaces scattered dictionaries for new turns) + private readonly Dictionary _turnStates = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets or creates the render state for a turn. + /// + private TurnRenderState GetOrCreateTurnState(string turnKey) + { + if (string.IsNullOrWhiteSpace(turnKey)) + { + return null; + } + + if (!this._turnStates.TryGetValue(turnKey, out var state)) + { + state = new TurnRenderState(turnKey); + this._turnStates[turnKey] = state; + } + + return state; + } + [Conditional("DEBUG")] private static void DebugLog(string message) { @@ -52,13 +113,38 @@ private static void DebugLog(string message) private string GetCurrentSegmentedKey(string baseKey) { if (string.IsNullOrWhiteSpace(baseKey)) return baseKey; - if (!this._textInteractionSegments.TryGetValue(baseKey, out var seg)) + + // Extract turn key from base key (e.g., "turn:abc123:assistant" -> "turn:abc123") + var turnKey = this.ExtractTurnKeyFromBaseKey(baseKey); + + var turnState = this.GetOrCreateTurnState(turnKey); + var segmentState = turnState.GetOrCreateSegment(baseKey); + return $"{baseKey}:seg{segmentState.SegmentNumber}"; + } + + /// + /// Extracts turn key from a base stream key (e.g., "turn:abc:assistant" -> "turn:abc"). + /// + private string ExtractTurnKeyFromBaseKey(string baseKey) + { + if (string.IsNullOrWhiteSpace(baseKey)) { - seg = 1; - this._textInteractionSegments[baseKey] = seg; + return null; } - return $"{baseKey}:seg{seg}"; + if (!baseKey.StartsWith("turn:", StringComparison.Ordinal)) + { + // Not a turn-keyed stream key; treat the key itself as a turn bucket. + return baseKey; + } + + var parts = baseKey.Split(':'); + if (parts.Length >= 2) + { + return $"{parts[0]}:{parts[1]}"; + } + + return baseKey; } /// @@ -69,20 +155,16 @@ private string PeekSegmentKey(string baseKey, string turnKey) { if (string.IsNullOrWhiteSpace(baseKey)) return baseKey; - int seg; - if (!this._textInteractionSegments.TryGetValue(baseKey, out seg)) - { - // Not yet committed: would start at seg1 - seg = 1; - } - else if (!string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey)) + var extractedTurnKey = this.ExtractTurnKeyFromBaseKey(baseKey); + var turnState = this.GetOrCreateTurnState(extractedTurnKey); + var segment = turnState.GetOrCreateSegment(baseKey); + int seg = segment.SegmentNumber; + + if (turnState.HasPendingBoundary) { - // Boundary pending: would increment seg = seg + 1; } - // else: use current committed seg - return $"{baseKey}:seg{seg}"; } @@ -94,32 +176,29 @@ private void CommitSegment(string baseKey, string turnKey) { if (string.IsNullOrWhiteSpace(baseKey)) return; - var beforeSegment = this._textInteractionSegments.TryGetValue(baseKey, out var seg) ? seg : 0; - var hasBoundary = !string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey); - DebugLog($"[WebChatObserver] CommitSegment: baseKey={baseKey}, turnKey={turnKey}, beforeSeg={beforeSegment}, hasBoundary={hasBoundary}"); + var extractedTurnKey = this.ExtractTurnKeyFromBaseKey(baseKey); + var turnState = this.GetOrCreateTurnState(extractedTurnKey); + var segment = turnState.GetOrCreateSegment(baseKey); + var beforeSegment = segment.SegmentNumber; + var hasBoundary = turnState.HasPendingBoundary; - // Consume boundary flag and increment if applicable - this.ConsumeBoundaryAndIncrementSegment(turnKey, baseKey); + DebugLog($"[WebChatObserver] CommitSegment: baseKey={baseKey}, turnKey={turnKey}, beforeSeg={beforeSegment}, hasBoundary={hasBoundary}"); - // Ensure segment counter is initialized (ConsumeBoundaryAndIncrementSegment requires existing entry) - if (!this._textInteractionSegments.ContainsKey(baseKey)) - { - this._textInteractionSegments[baseKey] = 1; - DebugLog($"[WebChatObserver] CommitSegment: initialized baseKey={baseKey} to seg=1"); - } - else + // Consume boundary and increment if applicable + if (hasBoundary) { - var afterSegment = this._textInteractionSegments[baseKey]; - this.LogDelta($"[WebChatObserver] CommitSegment: baseKey={baseKey} already exists, seg={afterSegment}"); + segment.SegmentNumber++; + turnState.HasPendingBoundary = false; + DebugLog($"[WebChatObserver] CommitSegment: incremented segment {baseKey} from {beforeSegment} to {segment.SegmentNumber}"); } + + segment.IsCommitted = true; } // Tracks UI state for the temporary thinking bubble. // We no longer track assistant-specific bubble state; ordering is handled by keys and upserts. private bool _thinkingBubbleActive; - // Simple per-key throttling to reduce DOM churn during streaming - private readonly Dictionary _lastUpsertAt = new Dictionary(StringComparer.Ordinal); private const int ThrottleMs = 50; private const int ThrottleDuringMoveResizeMs = 400; @@ -145,25 +224,10 @@ private void LogDelta(string message) #endif } - private readonly Dictionary _lastRenderedTextByKey = - new Dictionary(StringComparer.Ordinal); - - // Tracks per-turn text segments so multiple text messages in a single turn - // are rendered as distinct bubbles. Keys are the base stream key (e.g., "turn:{TurnId}:{agent}"). - private readonly Dictionary _textInteractionSegments = new Dictionary(StringComparer.Ordinal); - - // Pending segmentation boundary flags per turn. When set, the next text interaction for that turn - // starts a new segment (new bubble). Simplified rule: set boundary after ANY completed interaction. - private readonly HashSet _pendingNewTextSegmentTurns = new HashSet(StringComparer.Ordinal); - // Pre-commit aggregates: tracks text aggregates by base key before segment assignment. // This allows lazy segment commitment only when text becomes renderable. private readonly Dictionary _preStreamAggregates = new Dictionary(StringComparer.Ordinal); - // Finalized turns: after OnFinal has rendered the assistant text for a turn, ignore any late - // OnDelta/OnInteractionCompleted text updates for that same turn to prevent overriding final metrics/time. - private readonly HashSet _finalizedTextTurns = new HashSet(StringComparer.Ordinal); - /// /// Returns the generic turn base key for a given turn id (e.g., "turn:{TurnId}"). /// @@ -229,11 +293,7 @@ public void OnStart(AIRequestCall request) // Reset per-run state this._streams.Clear(); this._preStreamAggregates.Clear(); - this._textInteractionSegments.Clear(); - this._pendingNewTextSegmentTurns.Clear(); - this._finalizedTextTurns.Clear(); - this._lastUpsertAt.Clear(); - this._lastRenderedTextByKey.Clear(); + this._turnStates.Clear(); this._dialog.ExecuteScript("setStatus('Thinking...'); setProcessing(true);"); // Insert a persistent generic loading bubble that remains until stop state @@ -293,15 +353,18 @@ public void OnDelta(IAIInteraction interaction) var baseKey = GetStreamKey(interaction); var turnKey = GetTurnBaseKey(tt?.TurnId); + var turnState = this.GetOrCreateTurnState(turnKey); + var segState = turnState.GetOrCreateSegment(baseKey); + // If this turn is finalized, ignore any late deltas to avoid overriding final metrics/time - if (!string.IsNullOrWhiteSpace(turnKey) && this._finalizedTextTurns.Contains(turnKey)) + if (turnState.IsFinalized) { return; } // Check if we already have a committed segment for this base key - bool isCommitted = this._textInteractionSegments.ContainsKey(baseKey); - bool hasBoundary = !string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey); + bool isCommitted = segState.IsCommitted; + bool hasBoundary = turnState.HasPendingBoundary; this.LogDelta($"[WebChatObserver] OnDelta: baseKey={baseKey}, turnKey={turnKey}, isCommitted={isCommitted}, hasBoundary={hasBoundary}"); // Determine the target key. If a boundary is pending while already committed, @@ -439,16 +502,19 @@ public void OnInteractionCompleted(IAIInteraction interaction) var baseKey = GetStreamKey(interaction); var turnKey = GetTurnBaseKey(tt?.TurnId); + var turnState = this.GetOrCreateTurnState(turnKey); + var segState = turnState.GetOrCreateSegment(baseKey); + // If this turn is finalized, ignore any late partials to avoid overriding final metrics/time - if (!string.IsNullOrWhiteSpace(turnKey) && this._finalizedTextTurns.Contains(turnKey)) + if (turnState.IsFinalized) { return; } // Check if segment is committed - bool isCommitted = this._textInteractionSegments.ContainsKey(baseKey); + bool isCommitted = segState.IsCommitted; var activeSegKey = isCommitted ? this.GetCurrentSegmentedKey(baseKey) : null; - var hasBoundary = !string.IsNullOrWhiteSpace(turnKey) && this._pendingNewTextSegmentTurns.Contains(turnKey); + var hasBoundary = turnState.HasPendingBoundary; #if DEBUG DebugLog($"[WebChatObserver] OnInteractionCompleted(Text): baseKey={baseKey}, turnKey={turnKey}, isCommitted={isCommitted}, hasBoundary={hasBoundary}, contentLen={tt.Content?.Length ?? 0}"); #endif @@ -626,22 +692,17 @@ public void OnFinal(AIReturn result) // Mark this turn as finalized to prevent late partial/delta overrides var turnKey = GetTurnBaseKey(finalAssistant?.TurnId); - if (!string.IsNullOrWhiteSpace(turnKey)) - { - this._finalizedTextTurns.Add(turnKey); - } + var turnState = this.GetOrCreateTurnState(turnKey); + turnState.IsFinalized = true; // Prefer the aggregated streaming content for visual continuity AIInteractionText aggregated = null; // Use the current segmented key for the assistant stream var segKey = !string.IsNullOrWhiteSpace(streamKey) ? this.GetCurrentSegmentedKey(streamKey) : null; - if (!string.IsNullOrWhiteSpace(segKey) - && this._streams.TryGetValue(segKey, out var st) - && st?.Aggregated is AIInteractionText agg - && !string.IsNullOrWhiteSpace(agg.Content)) + if (!string.IsNullOrWhiteSpace(segKey) && this._streams.TryGetValue(segKey, out var st)) { - aggregated = agg; + aggregated = st?.Aggregated as AIInteractionText; } // Do not fallback to arbitrary previous streams to avoid cross-turn duplicates @@ -721,9 +782,6 @@ public void OnFinal(AIReturn result) this._dialog.ChatUpdated?.Invoke(this._dialog, historySnapshot); // Clear streaming and per-turn state and finish - this._streams.Clear(); - this._preStreamAggregates.Clear(); - this._textInteractionSegments.Clear(); this._dialog.ResponseReceived?.Invoke(this._dialog, lastReturn); this._dialog.ExecuteScript("setStatus('Ready'); setProcessing(false);"); }); @@ -804,17 +862,17 @@ private bool ShouldUpsertNow(string key) ? ThrottleDuringMoveResizeMs : ThrottleMs; - if (!this._lastUpsertAt.TryGetValue(key, out var last)) - { - this._lastUpsertAt[key] = now; - return true; - } + var turnKey = this.ExtractTurnKeyFromBaseKey(key); + var turnState = this.GetOrCreateTurnState(turnKey); + var segment = turnState.GetOrCreateSegment(key); - if ((now - last).TotalMilliseconds >= effectiveThrottleMs) + if (segment.LastUpsertAt == default || (now - segment.LastUpsertAt).TotalMilliseconds >= effectiveThrottleMs) { - this._lastUpsertAt[key] = now; + segment.LastUpsertAt = now; return true; } + + return false; } catch { } return false; @@ -832,14 +890,19 @@ private bool ShouldRenderDelta(string domKey, AIInteractionText text) var content = text.Content; var reasoning = text.Reasoning; - if (this._lastRenderedTextByKey.TryGetValue(domKey, out var last) - && string.Equals(last.Content, content, StringComparison.Ordinal) + var turnKey = this.ExtractTurnKeyFromBaseKey(domKey); + var turnState = this.GetOrCreateTurnState(turnKey); + var segment = turnState.GetOrCreateSegment(domKey); + + var last = segment.LastRenderedText; + if (string.Equals(last.Content, content, StringComparison.Ordinal) && string.Equals(last.Reasoning, reasoning, StringComparison.Ordinal)) { return false; } - this._lastRenderedTextByKey[domKey] = (content, reasoning); + segment.LastRenderedText = (content, reasoning); + return true; } catch { @@ -872,21 +935,20 @@ private void FlushPendingTextStateForTurn(string turnKey) { // Find all streams for this turn and force-render any that have dirty state var turnPrefix = turnKey + ":"; - var keysToFlush = this._streams.Keys - .Where(k => k != null && k.StartsWith(turnPrefix, StringComparison.Ordinal)) - .ToList(); - - foreach (var segKey in keysToFlush) + foreach (var kv in this._streams) { - if (this._streams.TryGetValue(segKey, out var state) && - state.Aggregated is AIInteractionText aggregatedText && - HasRenderableText(aggregatedText)) + var streamKey = kv.Key; + if (string.IsNullOrWhiteSpace(streamKey) || !streamKey.StartsWith(turnPrefix, StringComparison.Ordinal)) { - // Force render if content differs from last rendered - if (this.ShouldRenderDelta(segKey, aggregatedText)) + continue; + } + + if (kv.Value?.Aggregated is AIInteractionText aggregatedText && HasRenderableText(aggregatedText)) + { + if (this.ShouldRenderDelta(streamKey, aggregatedText)) { - DebugLog($"[WebChatObserver] FlushPendingTextStateForTurn: flushing segKey={segKey}"); - this._dialog.UpsertMessageByKey(segKey, aggregatedText, source: "FlushPendingText"); + DebugLog($"[WebChatObserver] FlushPendingTextStateForTurn: flushing streamKey={streamKey}"); + this._dialog.UpsertMessageByKey(streamKey, aggregatedText, source: "FlushPendingText"); } } } @@ -904,8 +966,9 @@ private void SetBoundaryFlag(string turnKey) { if (!string.IsNullOrWhiteSpace(turnKey)) { - var wasAdded = this._pendingNewTextSegmentTurns.Add(turnKey); - DebugLog($"[WebChatObserver] SetBoundaryFlag: turnKey={turnKey}, wasNew={wasAdded}"); + var turnState = this.GetOrCreateTurnState(turnKey); + turnState.HasPendingBoundary = true; + DebugLog($"[WebChatObserver] SetBoundaryFlag: turnKey={turnKey}"); } } @@ -916,23 +979,16 @@ private void ConsumeBoundaryAndIncrementSegment(string turnKey, string baseKey) { if (!string.IsNullOrWhiteSpace(turnKey)) { - var hadBoundary = this._pendingNewTextSegmentTurns.Remove(turnKey); - var hasSegment = this._textInteractionSegments.ContainsKey(baseKey); + var turnState = this.GetOrCreateTurnState(turnKey); + var segment = turnState.GetOrCreateSegment(baseKey); - if (hadBoundary && hasSegment) + if (turnState.HasPendingBoundary && segment.IsCommitted) { - var oldSeg = this._textInteractionSegments[baseKey]; - this._textInteractionSegments[baseKey] = oldSeg + 1; + var oldSeg = segment.SegmentNumber; + segment.SegmentNumber = oldSeg + 1; + turnState.HasPendingBoundary = false; DebugLog($"[WebChatObserver] ConsumeBoundaryAndIncrementSegment: turnKey={turnKey}, baseKey={baseKey}, {oldSeg} -> {oldSeg + 1}"); } - -#if DEBUG - else - { - DebugLog($"[WebChatObserver] ConsumeBoundaryAndIncrementSegment: turnKey={turnKey}, baseKey={baseKey}, hadBoundary={hadBoundary}, hasSegment={hasSegment}, NO INCREMENT"); - } - -#endif } } diff --git a/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs b/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs index 7cf6c4a7..f331504c 100644 --- a/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs @@ -21,6 +21,7 @@ namespace SmartHopper.Infrastructure.Tests using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AIProviders; using SmartHopper.Infrastructure.Settings; + using SmartHopper.Infrastructure.Streaming; using Xunit; public class AdvancedConfigTests @@ -87,6 +88,11 @@ public void RefreshCachedSettings(Dictionary settings) } public IEnumerable GetSettingDescriptors() => Enumerable.Empty(); + + public IStreamingAdapter GetStreamingAdapter() + { + return null; + } } private sealed class DummyProviderModels : IAIProviderModels diff --git a/src/SmartHopper.Infrastructure/AICall/Execution/DefaultProviderExecutor.cs b/src/SmartHopper.Infrastructure/AICall/Execution/DefaultProviderExecutor.cs index 35e244a0..9dc45453 100644 --- a/src/SmartHopper.Infrastructure/AICall/Execution/DefaultProviderExecutor.cs +++ b/src/SmartHopper.Infrastructure/AICall/Execution/DefaultProviderExecutor.cs @@ -48,17 +48,22 @@ public sealed class DefaultProviderExecutor : IProviderExecutor try { var provider = request?.ProviderInstance; - var mi = provider?.GetType().GetMethod("GetStreamingAdapter", Type.EmptyTypes); - var obj = mi?.Invoke(provider, null); - var adapter = obj as IStreamingAdapter; + if (provider == null) + { + Debug.WriteLine("[DefaultProviderExecutor] No provider instance available"); + return null; + } + + // Use the cached GetStreamingAdapter method from AIProvider base class + var adapter = provider.GetStreamingAdapter(); Debug.WriteLine(adapter != null - ? $"[DefaultProviderExecutor] Using streaming adapter from provider '{provider?.Name}'" - : $"[DefaultProviderExecutor] No streaming adapter available for provider '{provider?.Name}'"); + ? $"[DefaultProviderExecutor] Using cached streaming adapter from provider '{provider.Name}'" + : $"[DefaultProviderExecutor] No streaming adapter available for provider '{provider.Name}'"); return adapter; } catch (Exception ex) { - Debug.WriteLine($"[DefaultProviderExecutor] Error probing streaming adapter: {ex.Message}"); + Debug.WriteLine($"[DefaultProviderExecutor] Error getting streaming adapter: {ex.Message}"); return null; } } diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs index 517c3cf6..87304b98 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.SpecialTurns.cs @@ -21,6 +21,7 @@ namespace SmartHopper.Infrastructure.AICall.Sessions using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Execution; + using SmartHopper.Infrastructure.AICall.Policies; using SmartHopper.Infrastructure.AICall.Sessions.SpecialTurns; using SmartHopper.Infrastructure.AICall.Utilities; using SmartHopper.Infrastructure.AIModels; @@ -65,32 +66,25 @@ public async Task ExecuteSpecialTurnAsync( var useStreaming = preferStreaming && !config.ForceNonStreaming; AIReturn result; - // Apply timeout if specified - var linkedCts = config.TimeoutMs.HasValue - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) - : null; - - try + // Link cancellation the same way as TurnLoopAsync: + // - session cancel token (this.cts.Token) + // - external token passed to this method + // - timeout via CancelAfter + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(this.cts.Token, cancellationToken); + if (config.TimeoutMs.HasValue) { - if (linkedCts != null) - { - linkedCts.CancelAfter(config.TimeoutMs.Value); - } + linkedCts.CancelAfter(config.TimeoutMs.Value); + } - var effectiveCt = linkedCts?.Token ?? cancellationToken; + var effectiveCt = linkedCts.Token; - if (useStreaming) - { - result = await this.ExecuteStreamingSpecialTurnAsync(specialRequest, config, turnId, effectiveCt).ConfigureAwait(false); - } - else - { - result = await this.ExecuteNonStreamingSpecialTurnAsync(specialRequest, config, turnId, effectiveCt).ConfigureAwait(false); - } + if (useStreaming) + { + result = await this.ExecuteStreamingSpecialTurnAsync(specialRequest, config, turnId, effectiveCt).ConfigureAwait(false); } - finally + else { - linkedCts?.Dispose(); + result = await this.ExecuteNonStreamingSpecialTurnAsync(specialRequest, config, turnId, effectiveCt).ConfigureAwait(false); } // Apply persistence strategy to main conversation (this is where observers get notified) @@ -151,6 +145,11 @@ private async Task ExecuteStreamingSpecialTurnAsync( string turnId, CancellationToken ct) { + // Ensure request policies are applied for streaming special turns as well. + // Streaming adapters bypass AIRequestCall.Exec(), so policies like ContextInjectionRequestPolicy + // must be applied explicitly to keep context up-to-date. + await PolicyPipeline.Default.ApplyRequestPoliciesAsync(specialRequest).ConfigureAwait(false); + var exec = new DefaultProviderExecutor(); var adapter = exec.TryGetStreamingAdapter(specialRequest); @@ -165,13 +164,16 @@ private async Task ExecuteStreamingSpecialTurnAsync( AIInteractionText? accumulatedText = null; // Stream deltas internally (no observer notifications) - await foreach (var delta in adapter.StreamAsync(specialRequest, new StreamingOptions(), ct)) + await foreach (var rawDelta in adapter.StreamAsync(specialRequest, new StreamingOptions(), ct)) { - if (delta == null) + if (rawDelta == null) { continue; } + // Normalize delta to handle provider-specific formats + var delta = adapter.NormalizeDelta(rawDelta); + // Apply TurnId to new interactions var newInteractions = delta.Body?.GetNewInteractions(); InteractionUtility.EnsureTurnId(newInteractions, turnId); diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs index a774a589..5fe54674 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs @@ -23,6 +23,7 @@ namespace SmartHopper.Infrastructure.AICall.Sessions using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Execution; using SmartHopper.Infrastructure.AICall.Metrics; + using SmartHopper.Infrastructure.AICall.Policies; using SmartHopper.Infrastructure.AICall.Utilities; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.Settings; @@ -41,6 +42,20 @@ namespace SmartHopper.Infrastructure.AICall.Sessions /// public sealed partial class ConversationSession : IConversationSession { + /// + /// Result of streaming processing, encapsulating accumulated state and deltas. + /// + private sealed class StreamProcessingResult + { + public AIInteractionText? AccumulatedText { get; set; } + public AIReturn? LastDelta { get; set; } + public AIReturn? LastToolCallsDelta { get; set; } + public List Deltas { get; } = new List(); + public double ElapsedSeconds { get; set; } + public bool HasError { get; set; } + public string? ErrorMessage { get; set; } + } + /// /// The cancellation token source for this session. /// @@ -272,98 +287,22 @@ private async IAsyncEnumerable TurnLoopAsync( } else { - // Streaming path: forward each chunk to observer, accumulate text in memory, persist non-text immediately - var streamStopwatch = Stopwatch.StartNew(); - await foreach (var delta in adapter.StreamAsync(this.Request, streamingOptions!, linkedCts.Token)) + // Streaming path: use shared helper for delta processing + var streamResult = await this.ProcessStreamingDeltasAsync(adapter, streamingOptions!, state.TurnId, linkedCts.Token).ConfigureAwait(false); + + // Transfer results to turn state + state.AccumulatedText = streamResult.AccumulatedText; + state.LastDelta = streamResult.LastDelta; + state.LastToolCallsDelta = streamResult.LastToolCallsDelta; + state.DeltaYields.AddRange(streamResult.Deltas); + if (streamResult.LastDelta != null) { - if (delta == null) - { - continue; - } - - // Process new interactions: accumulate text, persist non-text immediately - var newInteractions = delta.Body?.GetNewInteractions(); - InteractionUtility.EnsureTurnId(newInteractions, state.TurnId); - - if (newInteractions != null && newInteractions.Count > 0) - { - var nonTextInteractions = new List(); - var textInteractions = new List(); - - foreach (var interaction in newInteractions) - { - if (interaction is AIInteractionText textDelta) - { - // Accumulate text deltas in memory (will be persisted after streaming completes) - state.AccumulatedText = TextStreamCoalescer.Coalesce(state.AccumulatedText, textDelta, state.TurnId, preserveMetrics: false); - textInteractions.Add(textDelta); - } - else if (interaction is AIInteractionToolCall tc) - { - // Check if this tool call already exists in history - var exists = this.Request?.Body?.Interactions?.OfType()? - .Any(x => !string.IsNullOrWhiteSpace(x?.Id) && string.Equals(x.Id, tc.Id, StringComparison.Ordinal)) ?? false; - - if (!exists) - { - // Persist tool call if not already in history - this.AppendToSessionHistory(interaction); - nonTextInteractions.Add(interaction); - } - else - { - // Tool call exists - update it with more complete arguments (streaming accumulation) - if (this.UpdateToolCallInHistory(tc)) - { - // Arguments were updated, notify UI - nonTextInteractions.Add(interaction); - } - } - } - else - { - // Persist other non-text interactions (tool results, images, etc.) immediately - this.AppendToSessionHistory(interaction); - nonTextInteractions.Add(interaction); - } - } - - // Notify UI with streaming deltas ONLY for text interactions - if (textInteractions.Count > 0) - { - var textDelta = this.BuildDeltaReturn(state.TurnId, textInteractions); - this.NotifyDelta(textDelta); - } - - // Emit partial notification only for persisted non-text interactions - if (nonTextInteractions.Count > 0) - { - try - { - var persistedDelta = this.BuildDeltaReturn(state.TurnId, nonTextInteractions); - this.NotifyInteractionCompleted(persistedDelta); - } - catch (Exception ex) - { - Debug.WriteLine($"[ConversationSession.Stream] Error emitting persisted non-text delta: {ex.Message}"); - } - } - - // Keep a reference to last tool_calls delta if needed by diagnostics - state.LastToolCallsDelta = delta; - } - - state.DeltaYields.Add(delta); - state.LastDelta = delta; - lastReturn = delta; + lastReturn = streamResult.LastDelta; } - // After streaming ends, either error (no deltas) or persist final snapshot then continue - streamStopwatch.Stop(); - - if (state.LastDelta == null) + if (streamResult.HasError) { - var errDelta = this.CreateError("Provider returned no response"); + var errDelta = this.CreateError(streamResult.ErrorMessage ?? "Provider returned no response"); this.NotifyFinal(errDelta); state.ErrorYield = errDelta; state.ShouldBreak = true; @@ -371,7 +310,7 @@ private async IAsyncEnumerable TurnLoopAsync( else { // Persist final aggregated text and update last-return snapshot with measured time - this.PersistStreamingSnapshot(state.LastToolCallsDelta, state.LastDelta, state.TurnId, state.AccumulatedText, streamStopwatch.Elapsed.TotalSeconds); + this.PersistStreamingSnapshot(state.LastToolCallsDelta, state.LastDelta, state.TurnId, state.AccumulatedText, streamResult.ElapsedSeconds); if (!options.ProcessTools) { @@ -691,6 +630,132 @@ private async Task HandleProviderTurnAsync(SessionOptions options, str return callResult; } + /// + /// Shared streaming processing logic. Processes deltas from a streaming adapter, + /// accumulates text, persists non-text interactions, and notifies observers. + /// + /// The streaming adapter to consume deltas from. + /// Streaming options for the adapter. + /// The turn ID for this streaming session. + /// Cancellation token. + /// A containing accumulated state and deltas. + private async Task ProcessStreamingDeltasAsync( + IStreamingAdapter adapter, + StreamingOptions streamingOptions, + string turnId, + CancellationToken ct) + { + var result = new StreamProcessingResult(); + var stopwatch = Stopwatch.StartNew(); + + try + { + // Ensure request policies are applied for streaming calls as well. + // Streaming adapters can bypass AIRequestCall.Exec(), so policies like ContextInjectionRequestPolicy + // must be applied explicitly to keep context up-to-date on every provider call. + await PolicyPipeline.Default.ApplyRequestPoliciesAsync(this.Request).ConfigureAwait(false); + + await foreach (var rawDelta in adapter.StreamAsync(this.Request, streamingOptions, ct)) + { + if (rawDelta == null) + { + continue; + } + + // Normalize delta to handle provider-specific formats + var delta = adapter.NormalizeDelta(rawDelta); + + var newInteractions = delta.Body?.GetNewInteractions(); + InteractionUtility.EnsureTurnId(newInteractions, turnId); + + if (newInteractions != null && newInteractions.Count > 0) + { + var nonTextInteractions = new List(); + var textInteractions = new List(); + + foreach (var interaction in newInteractions) + { + if (interaction is AIInteractionText textDelta) + { + // Accumulate text deltas in memory (will be persisted after streaming completes) + result.AccumulatedText = TextStreamCoalescer.Coalesce(result.AccumulatedText, textDelta, turnId, preserveMetrics: false); + textInteractions.Add(textDelta); + } + else if (interaction is AIInteractionToolCall tc) + { + // Check if this tool call already exists in history + var exists = this.Request?.Body?.Interactions?.OfType()? + .Any(x => !string.IsNullOrWhiteSpace(x?.Id) && string.Equals(x.Id, tc.Id, StringComparison.Ordinal)) ?? false; + + if (!exists) + { + // Persist tool call if not already in history + this.AppendToSessionHistory(interaction); + nonTextInteractions.Add(interaction); + } + else + { + // Tool call exists - update it with more complete arguments (streaming accumulation) + if (this.UpdateToolCallInHistory(tc)) + { + // Arguments were updated, notify UI + nonTextInteractions.Add(interaction); + } + } + } + else + { + // Persist other non-text interactions (tool results, images, etc.) immediately + this.AppendToSessionHistory(interaction); + nonTextInteractions.Add(interaction); + } + } + + // Notify UI with streaming deltas ONLY for text interactions + if (textInteractions.Count > 0) + { + var textDeltaReturn = this.BuildDeltaReturn(turnId, textInteractions); + this.NotifyDelta(textDeltaReturn); + } + + // Emit partial notification only for persisted non-text interactions + if (nonTextInteractions.Count > 0) + { + try + { + var persistedDelta = this.BuildDeltaReturn(turnId, nonTextInteractions); + this.NotifyInteractionCompleted(persistedDelta); + } + catch (Exception ex) + { + Debug.WriteLine($"[ConversationSession.ProcessStreamingDeltasAsync] Error emitting persisted non-text delta: {ex.Message}"); + } + } + + // Keep a reference to last tool_calls delta if needed by diagnostics + result.LastToolCallsDelta = delta; + } + + result.Deltas.Add(delta); + result.LastDelta = delta; + } + } + finally + { + stopwatch.Stop(); + result.ElapsedSeconds = stopwatch.Elapsed.TotalSeconds; + } + + // Check if streaming produced no results + if (result.LastDelta == null) + { + result.HasError = true; + result.ErrorMessage = "Provider returned no response"; + } + + return result; + } + /// /// Processes pending tool calls for bounded passes. Emits partial returns and merges new interactions. /// Returns a list of prepared returns in the order they were produced (for streaming fallback). @@ -748,80 +813,19 @@ private async Task> ProcessPendingToolsAsync(SessionOptions optio if (adapter != null) { - // Stream the follow-up response - AIInteractionText? accumulatedText = null; - AIReturn? lastDelta = null; - var followUpStopwatch = Stopwatch.StartNew(); - - await foreach (var delta in adapter.StreamAsync(this.Request, streamingOptions, ct)) - { - if (delta == null) continue; - - var newInteractions = delta.Body?.GetNewInteractions(); - InteractionUtility.EnsureTurnId(newInteractions, turnId); - - if (newInteractions != null) - { - foreach (var interaction in newInteractions) - { - if (interaction is AIInteractionText textDelta) - { - // Accumulate text deltas - accumulatedText = TextStreamCoalescer.Coalesce(accumulatedText, textDelta, turnId, preserveMetrics: false); - - // Notify UI with streaming delta - var textDeltaReturn = this.BuildDeltaReturn(turnId, new List { textDelta }); - this.NotifyDelta(textDeltaReturn); - } - else if (interaction is AIInteractionToolCall tc) - { - // Check if this tool call already exists in history - var exists = this.Request?.Body?.Interactions?.OfType()? - .Any(x => !string.IsNullOrWhiteSpace(x?.Id) && string.Equals(x.Id, tc.Id, StringComparison.Ordinal)) ?? false; - - if (!exists) - { - // Persist tool call immediately - this.AppendToSessionHistory(interaction); - - // Notify with persisted interaction - var tcDeltaReturn = this.BuildDeltaReturn(turnId, new List { interaction }); - this.NotifyInteractionCompleted(tcDeltaReturn); - } - else - { - // Tool call exists - update it with more complete arguments (streaming accumulation) - if (this.UpdateToolCallInHistory(tc)) - { - // Arguments were updated, notify UI - var tcDeltaReturn = this.BuildDeltaReturn(turnId, new List { interaction }); - this.NotifyInteractionCompleted(tcDeltaReturn); - } - } - } - else - { - // Persist other non-text interactions immediately - this.AppendToSessionHistory(interaction); - } - } - } - - lastDelta = delta; - } - - followUpStopwatch.Stop(); + // Stream the follow-up response using shared helper + var streamResult = await this.ProcessStreamingDeltasAsync(adapter, streamingOptions, turnId, ct).ConfigureAwait(false); - if (lastDelta == null) + if (streamResult.HasError) { - var err = this.CreateError("Provider returned no response"); + var err = this.CreateError(streamResult.ErrorMessage ?? "Provider returned no response"); this.NotifyFinal(err); preparedYields.Add(err); break; } - // Persist final aggregated text and update last return snapshot using shared helper - this.PersistStreamingSnapshot(lastDelta, lastDelta, turnId, accumulatedText, followUpStopwatch.Elapsed.TotalSeconds); + // Persist final aggregated text and update last return snapshot + this.PersistStreamingSnapshot(streamResult.LastDelta, streamResult.LastDelta, turnId, streamResult.AccumulatedText, streamResult.ElapsedSeconds); this.NotifyInteractionCompleted(this._lastReturn); preparedYields.Add(this._lastReturn); } diff --git a/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs b/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs index 8155bd98..7490388a 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs @@ -27,6 +27,7 @@ using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; using SmartHopper.Infrastructure.Settings; +using SmartHopper.Infrastructure.Streaming; using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Infrastructure.AIProviders @@ -97,6 +98,10 @@ public abstract class AIProvider : IAIProvider [ThreadStatic] private static HashSet _currentlyGettingSettings; + // Streaming adapter cache to avoid reflection on every streaming request + private IStreamingAdapter _cachedStreamingAdapter; + private bool _streamingAdapterProbed; + /// public abstract string Name { get; } @@ -687,6 +692,34 @@ private async Task CallApi(AIRequestCall request) } } + /// + /// Gets the cached streaming adapter for this provider. + /// Override in derived classes to provide a streaming adapter. + /// + /// The cached streaming adapter, or null if the provider doesn't support streaming. + public IStreamingAdapter GetStreamingAdapter() + { + if (!this._streamingAdapterProbed) + { + this._cachedStreamingAdapter = this.CreateStreamingAdapter(); + this._streamingAdapterProbed = true; + Debug.WriteLine(this._cachedStreamingAdapter != null + ? $"[{this.Name}] Streaming adapter cached" + : $"[{this.Name}] No streaming adapter available"); + } + + return this._cachedStreamingAdapter; + } + + /// + /// Creates a streaming adapter for this provider. Override in derived classes to enable streaming. + /// + /// An instance, or null if streaming is not supported. + protected virtual IStreamingAdapter CreateStreamingAdapter() + { + return null; + } + /// /// Builds a full URL by combining the default server URL with the specified endpoint. /// diff --git a/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs b/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs index 576fb526..04c28ab0 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs @@ -17,6 +17,7 @@ using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.Settings; +using SmartHopper.Infrastructure.Streaming; namespace SmartHopper.Infrastructure.AIProviders { @@ -132,5 +133,11 @@ public interface IAIProvider /// /// An enumerable of SettingDescriptor instances for the provider. IEnumerable GetSettingDescriptors(); + + /// + /// Gets the streaming adapter for this provider. Returns a cached instance after first call. + /// + /// The streaming adapter, or null if the provider doesn't support streaming. + IStreamingAdapter GetStreamingAdapter(); } } diff --git a/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs b/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs index 708581d7..024db824 100644 --- a/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs +++ b/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs @@ -35,6 +35,15 @@ IAsyncEnumerable StreamAsync( AIRequestCall request, StreamingOptions options, CancellationToken cancellationToken = default); + + /// + /// Normalizes a provider-specific delta to a common format. + /// Default implementation returns the delta unchanged. + /// Override to handle provider-specific quirks (e.g., reasoning content, tool call formats). + /// + /// The raw delta from the provider. + /// The normalized delta in a consistent format. + AIReturn NormalizeDelta(AIReturn delta) => delta; } /// diff --git a/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs index 5d5f4b48..cfa0af8d 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs @@ -105,10 +105,8 @@ public override Image Icon /// /// Returns a streaming adapter for Anthropic that yields incremental AIReturn deltas. - /// - /// An IStreamingAdapter instance configured for Anthropic SSE streaming. - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Factory method creates a new adapter instance per call")] - public IStreamingAdapter GetStreamingAdapter() + /// + protected override IStreamingAdapter CreateStreamingAdapter() { return new AnthropicStreamingAdapter(this); } diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs index 71afdf49..913a6325 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs @@ -97,10 +97,8 @@ internal string GetApiKey() } /// - /// Returns a streaming adapter for DeepSeek that yields incremental AIReturn deltas. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Factory method creates a new adapter instance per call")] - public IStreamingAdapter GetStreamingAdapter() + /// + protected override IStreamingAdapter CreateStreamingAdapter() { return new DeepSeekStreamingAdapter(this); } diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs index 922a1fc4..57e9ab72 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs @@ -82,10 +82,8 @@ public override Image Icon } /// - /// Returns a streaming adapter for MistralAI that yields incremental AIReturn deltas. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Factory method creates a new adapter instance per call")] - public IStreamingAdapter GetStreamingAdapter() + /// + protected override IStreamingAdapter CreateStreamingAdapter() { return new MistralAIStreamingAdapter(this); } diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs index 3c770a41..a6c8a7b5 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs @@ -105,11 +105,8 @@ public override Image Icon } } - /// - /// Returns a streaming adapter for OpenAI that yields incremental AIReturn deltas. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Factory method creates a new adapter instance per call")] - public IStreamingAdapter GetStreamingAdapter() + /// + protected override IStreamingAdapter CreateStreamingAdapter() { return new OpenAIStreamingAdapter(this); } diff --git a/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs b/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs index 951a9f99..03b94d9c 100644 --- a/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs +++ b/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs @@ -63,10 +63,8 @@ internal string GetApiKey() } /// - /// Returns a streaming adapter for OpenRouter that yields incremental AIReturn deltas. - /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Factory method creates a new adapter instance per call")] - public IStreamingAdapter GetStreamingAdapter() + /// + protected override IStreamingAdapter CreateStreamingAdapter() { return new OpenRouterStreamingAdapter(this); } From ede9409a53633315c60dfaa24b0f5fff7a8946c4 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:05:23 +0100 Subject: [PATCH 11/26] docs(changelog) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15bca107..1b74173d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Simplified streaming validation flow in `WebChatDialog.ProcessAIInteraction()` - now always attempts streaming first, letting `ConversationSession` handle validation internally. - Added `TurnRenderState` and `SegmentState` classes to `WebChatObserver` for encapsulated per-turn state management. - Reduced idempotency cache size from 1000 to 100 entries to reduce memory footprint. +- Chat UI: + - Optimized DOM updates with a keyed queue, conditional debug logging, and template-cached message rendering with LRU diffing to cut redundant work on large chats. + - Refined streaming visuals by removing unused animations and switching to lighter wipe-in effects, improving responsiveness while messages stream. ### Fixed @@ -34,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mitigated issue [#261](https://github.com/architects-toolkit/SmartHopper/issues/261) by batching WebView DOM operations (JS rAF/timer queue) and debouncing host-side script injection/drain scheduling. - Reduced redundant DOM work using idempotency caching and sampled diff checks; added lightweight JS render perf counters and slow-render logging. - Improved rendering performance using template cloning, capped message HTML length, and a transform/opacity wipe-in animation for streaming updates. + - Further reduced freezes while dragging/resizing by shrinking update batches and eliminating heavy animation paths during active user interaction. - Context providers: - Fixed `current-file_selected-count` sometimes returning `0` even when parameters were selected by reading selection on the Rhino UI thread and adding a robust `Attributes.Selected` fallback. From d33560392449149a07e709a5eee8c2ef3645335b Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:26:07 +0100 Subject: [PATCH 12/26] refactor(chat): improve error logging and code formatting in WebChat components --- src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 24 +++++++++++-------- .../UI/Chat/WebChatObserver.cs | 15 ++++++------ .../SmartHopper.Infrastructure.csproj | 2 +- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index 8e88ac22..f5164fa8 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -228,14 +228,12 @@ private void UpsertMessageAfter(string followKey, string domKey, IAIInteraction #if DEBUG DebugLog($"[WebChatDialog] UpsertMessageAfter fk={followKey} key={domKey} agent={interaction.Agent} type={interaction.GetType().Name} htmlLen={html?.Length ?? 0} src={source ?? "?"} preview={preview}"); -#endif if (string.IsNullOrWhiteSpace(followKey)) { -#if DEBUG DebugLog($"[WebChatDialog] UpsertMessageAfter WARNING: followKey is null/empty for key={domKey}, will fallback to normal upsert"); -#endif } +#endif var script = $"upsertMessageAfter({JsonConvert.SerializeObject(followKey)}, {JsonConvert.SerializeObject(domKey)}, {JsonConvert.SerializeObject(html)});"; this.UpdateIdempotencyCache(domKey, html ?? string.Empty); @@ -381,8 +379,9 @@ private void MarkMoveResizeInteraction() this._deferDomUpdatesUntilUtc = DateTime.UtcNow.AddMilliseconds(DomDeferDuringMoveResizeMs); this.ScheduleDomDrain(); } - catch + catch (Exception ex) { + DebugLog($"[WebChatDialog] MarkMoveResizeInteraction error: {ex.Message}"); } } @@ -893,8 +892,9 @@ private void ReplayFullHistoryToWebView() { this.UpdateIdempotencyCache(key, html ?? string.Empty); } - catch + catch (Exception ex) { + DebugLog($"[WebChatDialog] Error updating idempotency cache: {ex.Message}"); } if (!string.IsNullOrWhiteSpace(prevKey)) @@ -1014,8 +1014,9 @@ private async Task InitializeWebViewAsync() { this._webViewInitializedTcs.TrySetException(ex); } - catch + catch (Exception rex) { + DebugLog($"[WebChatDialog] Failed to set exception on WebView initialized TCS: {rex.Message}"); } } } @@ -1065,6 +1066,7 @@ private async Task ProcessAIInteraction() .ConfigureAwait(false)) { lastStreamReturn = r; + // Observer handles partial/final UI updates } @@ -1094,9 +1096,9 @@ private async Task ProcessAIInteraction() this.RunWhenWebViewReady(() => this.ExecuteScript("setStatus('Error'); setProcessing(false);")); this.BuildAndEmitSnapshot(); } - catch + catch (Exception rex) { - /* ignore secondary errors */ + DebugLog($"[WebChatDialog] Error in ProcessAIInteraction: {rex.Message}"); } } finally @@ -1105,8 +1107,9 @@ private async Task ProcessAIInteraction() { this._currentCts?.Cancel(); } - catch + catch (Exception rex) { + DebugLog($"[WebChatDialog] Error in ProcessAIInteraction: {rex.Message}"); } this._currentCts?.Dispose(); @@ -1133,8 +1136,9 @@ private async Task InitializeNewConversationAsync() { await this._initialHistoryReplayTcs.Task.ConfigureAwait(false); } - catch + catch (Exception rex) { + DebugLog($"[WebChatDialog] Error in InitializeNewConversationAsync: {rex.Message}"); } // If greeting was requested by the creator (e.g., CanvasButton), run a single non-streaming turn. diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index 6a526080..5fdcb796 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -113,7 +113,7 @@ private static void DebugLog(string message) private string GetCurrentSegmentedKey(string baseKey) { if (string.IsNullOrWhiteSpace(baseKey)) return baseKey; - + // Extract turn key from base key (e.g., "turn:abc123:assistant" -> "turn:abc123") var turnKey = this.ExtractTurnKeyFromBaseKey(baseKey); @@ -904,8 +904,9 @@ private bool ShouldRenderDelta(string domKey, AIInteractionText text) segment.LastRenderedText = (content, reasoning); return true; } - catch + catch (Exception ex) { + DebugLog($"[WebChatObserver] ShouldRenderDelta error: {ex.Message}"); } return true; @@ -943,13 +944,11 @@ private void FlushPendingTextStateForTurn(string turnKey) continue; } - if (kv.Value?.Aggregated is AIInteractionText aggregatedText && HasRenderableText(aggregatedText)) + if (kv.Value?.Aggregated is AIInteractionText aggregatedText && HasRenderableText(aggregatedText) && this.ShouldRenderDelta(streamKey, aggregatedText)) { - if (this.ShouldRenderDelta(streamKey, aggregatedText)) - { - DebugLog($"[WebChatObserver] FlushPendingTextStateForTurn: flushing streamKey={streamKey}"); - this._dialog.UpsertMessageByKey(streamKey, aggregatedText, source: "FlushPendingText"); - } + DebugLog($"[WebChatObserver] FlushPendingTextStateForTurn: flushing streamKey={streamKey}"); + + this._dialog.UpsertMessageByKey(streamKey, aggregatedText, source: "FlushPendingText"); } } } diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index b4ddc558..e51b6d13 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -76,7 +76,7 @@ Run that script after generating or updating the signing key. --> - 0024000004800000940000000602000000240000525341310004000001000100b90ff13176f06b3385ce4bafee2a5177994228e8726e444377056f2ff11813457d594162f7542e7621eedec5445ce0e079e7d01357cf2463fb73aa5e248a34e57fe1999daa6a17f493bdafc5cfdd4cd80d14cb00326ba745a862a3cd5686504d2ae9e6e06e9f4ccebd2bffd7b990e617f6ad8a42397a20123fb373ce582085cc + This value is automatically replaced by the build tooling before official builds. From c696584cb8b78c071f1e3c60355adf492c6e5308 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 24 Dec 2025 14:34:52 +0100 Subject: [PATCH 13/26] docs: fix version badge --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27496e4b..690452fd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.2.1--alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) -[![Status](https://img.shields.io/badge/status-Alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.2.2--dev.251224-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Status](https://img.shields.io/badge/status-Unstable%20Development-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) [![License](https://img.shields.io/badge/license-LGPL%20v3-white?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/blob/main/LICENSE) From b4f0f9a234f28135e4ebca36086ddcfc709d2145 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 24 Dec 2025 19:58:54 +0100 Subject: [PATCH 14/26] perf(component-base): refactored component base for stability --- .githooks/pre-commit | 7 + .githooks/pre-commit.ps1 | 43 + CONTRIBUTING.md | 4 + SmartHopper.sln | 14 + .../ComponentBase/AsyncComponentBase.md | 26 +- .../StatefulAsyncComponentBase.md | 45 +- ...efulAsyncComponentBase State Management.md | 1120 ++++++++++++ docs/Reviews/index.md | 1 + ...TreeProcessorBranchFlattenTestComponent.cs | 2 +- ...reeProcessorBranchToBranchTestComponent.cs | 2 +- ...sorBroadcastDeeperDiffRootTestComponent.cs | 2 +- ...sorBroadcastDeeperSameRootTestComponent.cs | 2 +- ...sorBroadcastMultipleNoZeroTestComponent.cs | 2 +- ...rBroadcastMultipleTopLevelTestComponent.cs | 2 +- ...ntPathsFirstOneSecondThreeTestComponent.cs | 2 +- ...ntPathsFirstThreeSecondOneTestComponent.cs | 2 +- ...rDifferentPathsOneItemEachTestComponent.cs | 2 +- ...fferentPathsThreeItemsEachTestComponent.cs | 2 +- ...essorDirectMatchPrecedenceTestComponent.cs | 2 +- ...alPathsFirstOneSecondThreeTestComponent.cs | 2 +- ...alPathsFirstThreeSecondOneTestComponent.cs | 2 +- ...ataTreeProcessorEqualPathsTestComponent.cs | 2 +- ...cessorEqualPathsThreeItemsTestComponent.cs | 2 +- ...reeProcessorGroupIdenticalTestComponent.cs | 2 +- ...DataTreeProcessorItemGraftTestComponent.cs | 2 +- ...ataTreeProcessorItemToItemTestComponent.cs | 2 +- ...taTreeProcessorMixedDepthsTestComponent.cs | 2 +- ...TreeProcessorRule2OverrideTestComponent.cs | 2 +- .../Misc/TestStateManagerDebounceComponent.cs | 246 +++ .../TestStateManagerRestorationComponent.cs | 246 +++ .../TestStatefulPrimeCalculatorComponent.cs | 2 +- ...estStatefulTreePrimeCalculatorComponent.cs | 2 +- .../Grasshopper/GhPutComponents.cs | 4 +- .../Knowledge/McNeelForumPostGetComponent.cs | 2 +- .../Knowledge/McNeelForumPostOpenComponent.cs | 2 +- .../Knowledge/McNeelForumSearchComponent.cs | 2 +- .../Knowledge/WebPageReadComponent.cs | 2 +- .../ComponentStateManagerTests.cs | 849 +++++++++ .../SmartHopper.Core.Tests.csproj | 47 + .../ComponentBase/AIProviderComponentBase.cs | 2 +- .../ComponentBase/AsyncComponentBase.cs | 22 + .../ComponentBase/ComponentStateManager.cs | 871 ++++++++++ .../ComponentBase/StatefulComponentBaseV2.cs | 1526 +++++++++++++++++ .../UI/CanvasButtonBootstrap.cs | 22 + .../UI/Chat/WebChatObserver.cs | 80 +- tools/Anonymize-SmartHopperPublicKey.ps1 | 80 + 46 files changed, 5220 insertions(+), 87 deletions(-) create mode 100644 .githooks/pre-commit create mode 100644 .githooks/pre-commit.ps1 create mode 100644 docs/Reviews/251224 StatefulAsyncComponentBase State Management.md create mode 100644 src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs create mode 100644 src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs create mode 100644 src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs create mode 100644 src/SmartHopper.Core.Tests/SmartHopper.Core.Tests.csproj create mode 100644 src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs create mode 100644 src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs create mode 100644 tools/Anonymize-SmartHopperPublicKey.ps1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..cc66ca39 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Purpose: Ensure SmartHopperPublicKey is anonymized before committing by invoking the PowerShell hook. + +set -euo pipefail +HOOK_DIR="$(cd "$(dirname "$0")" && pwd)" + +pwsh -NoProfile -ExecutionPolicy Bypass -File "$HOOK_DIR/pre-commit.ps1" diff --git a/.githooks/pre-commit.ps1 b/.githooks/pre-commit.ps1 new file mode 100644 index 00000000..c7def679 --- /dev/null +++ b/.githooks/pre-commit.ps1 @@ -0,0 +1,43 @@ +# Purpose: Enforces anonymization of SmartHopperPublicKey before every commit. + +$repoRoot = Split-Path -Parent $PSScriptRoot +$anonymizeScript = Join-Path $repoRoot "tools\Anonymize-SmartHopperPublicKey.ps1" +$csprojPath = Join-Path $repoRoot "src\SmartHopper.Infrastructure\SmartHopper.Infrastructure.csproj" +$expectedPlaceholder = "This value is automatically replaced by the build tooling before official builds." + +if (-not (Test-Path $anonymizeScript)) { + Write-Error "Anonymize script not found at $anonymizeScript" + exit 1 +} + +if (-not (Test-Path $csprojPath)) { + Write-Error "Target csproj not found at $csprojPath" + exit 1 +} + +# Run anonymization to guarantee the placeholder is present. +& $anonymizeScript -CsprojPath $csprojPath +if ($LASTEXITCODE -ne 0) { + Write-Error "Anonymization script failed (exit $LASTEXITCODE)." + exit $LASTEXITCODE +} + +# Verify the placeholder was applied to block commits with real keys. +try { + $xml = [xml](Get-Content $csprojPath -Raw) + $keyElement = $xml.SelectSingleNode("//SmartHopperPublicKey") + if (-not $keyElement) { + Write-Error "SmartHopperPublicKey element not found in $csprojPath" + exit 1 + } + + if ($keyElement.InnerText -ne $expectedPlaceholder) { + Write-Error "SmartHopperPublicKey is not anonymized. Expected placeholder text." + exit 1 + } +} catch { + Write-Error "Failed to verify SmartHopperPublicKey placeholder: $_" + exit 1 +} + +Write-Host "SmartHopperPublicKey anonymized and verified. Proceeding with commit." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d6498c29..567a2318 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,10 @@ Are you skilled in coding and want to contribute to SmartHopper? You can help fi - Fork the repository - Create a new branch referencing the issue you want to fix or feature you want to add + - Configure Git hooks so the anonymization pre-commit runs locally: + ```bash + git config core.hooksPath .githooks + ``` - Submit a Pull Request following the [Pull Request Guidelines](#pull-request-guidelines) explained below ### 4. **Release Checklist** diff --git a/SmartHopper.sln b/SmartHopper.sln index e5bf084c..432e4cd5 100644 --- a/SmartHopper.sln +++ b/SmartHopper.sln @@ -31,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Providers.OpenR EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Core.Grasshopper.Tests", "src\SmartHopper.Core.Grasshopper.Tests\SmartHopper.Core.Grasshopper.Tests.csproj", "{56E24C95-ADF6-4DBA-BDB7-73CFB1291052}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Core.Tests", "src\SmartHopper.Core.Tests\SmartHopper.Core.Tests.csproj", "{C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -196,6 +198,18 @@ Global {56E24C95-ADF6-4DBA-BDB7-73CFB1291052}.Release|x64.Build.0 = Release|Any CPU {56E24C95-ADF6-4DBA-BDB7-73CFB1291052}.Release|x86.ActiveCfg = Release|Any CPU {56E24C95-ADF6-4DBA-BDB7-73CFB1291052}.Release|x86.Build.0 = Release|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Debug|x64.ActiveCfg = Debug|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Debug|x64.Build.0 = Debug|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Debug|x86.ActiveCfg = Debug|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Debug|x86.Build.0 = Debug|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|Any CPU.Build.0 = Release|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x64.ActiveCfg = Release|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x64.Build.0 = Release|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x86.ActiveCfg = Release|Any CPU + {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/Components/ComponentBase/AsyncComponentBase.md b/docs/Components/ComponentBase/AsyncComponentBase.md index 2cf9fa0f..d6ddedd0 100644 --- a/docs/Components/ComponentBase/AsyncComponentBase.md +++ b/docs/Components/ComponentBase/AsyncComponentBase.md @@ -8,16 +8,32 @@ Provide a robust async skeleton: snapshot inputs, run on a background task with ## Key features -- Task lifecycle and cancellation token handling. -- Input snapshotting to avoid race conditions. +- **Task lifecycle** with cancellation token handling and proper cleanup. +- **Worker-based execution** via `CreateWorker()` abstract method. +- **Two-phase solve pattern**: pre-solve (gather inputs, start tasks) and post-solve (set outputs). +- **State tracking** with `_state` counter and `_setData` latch for coordinating async completion. +- **LIFO worker processing**: workers are reversed before output phase for expected ordering. - Background execution with exception capture and runtime message reporting. -- Hooks for progress updates using [ProgressInfo](../Helpers/ProgressInfo.md). - Separation of UI thread vs. worker thread responsibilities. +## Key lifecycle flow + +1. **BeforeSolveInstance()** – Cancels running tasks, resets async state (if not in output phase). +2. **SolveInstance() [pre-solve]** – `InPreSolve=true`; creates worker, gathers input, starts Task. +3. **AfterSolveInstance()** – Waits for tasks via `Task.WhenAll`, then sets `_state` to worker count and `_setData=1`. +4. **SolveInstance() [post-solve]** – `InPreSolve=false`; calls `SetOutput()` on each worker (LIFO order), decrements `_state`. +5. **OnWorkerCompleted()** – Called when `_state` reaches 0 after output phase. + +## Internal state variables + +- `_state` – Tracks worker completion count; starts at 0, set to `Workers.Count` when all tasks complete. +- `_setData` – Latch (0/1) indicating output phase is ready. +- `InPreSolve` – Flag distinguishing input-gathering phase from output-setting phase. + ## Usage -- Derive your component from [AsyncComponentBase](./AsyncComponentBase.md) when you need async work without a full state machine. -- Implement your execution logic in a background worker (see [AsyncWorkerBase](./AsyncWorkerBase.md)) or the provided async hook. +- Derive your component from `AsyncComponentBase` when you need async work without a full state machine. +- Implement `CreateWorker(Action)` returning an `AsyncWorkerBase`. - Keep mutable state out of the worker; pass an immutable snapshot of inputs. - Only access Grasshopper/Rhino UI on the UI thread. - Use progress callbacks sparingly; throttle if needed. diff --git a/docs/Components/ComponentBase/StatefulAsyncComponentBase.md b/docs/Components/ComponentBase/StatefulAsyncComponentBase.md index 936e65f4..ea3623de 100644 --- a/docs/Components/ComponentBase/StatefulAsyncComponentBase.md +++ b/docs/Components/ComponentBase/StatefulAsyncComponentBase.md @@ -1,29 +1,50 @@ # StatefulAsyncComponentBase -Async base with built‑in component state management, debouncing, progress, and error handling. +Async base with built‑in component state management, debouncing, progress, error handling, and persistent output storage. ## Purpose -Unify long‑running execution with a clear state machine so components behave predictably with buttons/toggles and input changes. +Unify long‑running execution with a clear state machine so components behave predictably with buttons/toggles and input changes. Provides automatic persistence and restoration of output data across document save/load cycles. ## Key features -- Component states via [ComponentState](../Helpers/StateManager.md) (Waiting, NeedsRun, Processing, Completed, Cancelled, Error). -- Debounce timer for bursty input changes; immediate run when appropriate (e.g., Run toggle=true and inputs change). -- Progress tracking and user‑friendly state messages. -- Centralized runtime message handling and safe transitions. -- Persistence of relevant flags (e.g., debounce delay) across document reloads. +- **State machine** via [ComponentState](../Helpers/StateManager.md) (Waiting, NeedsRun, Processing, Completed, Cancelled, Error). +- **Debounce timer** for bursty input changes; configurable via settings with minimum 1000ms. +- **Input change detection** using hash-based comparison of input data and branch structure. +- **Progress tracking** with user‑friendly state messages and iteration counts. +- **Persistent runtime messages** keyed by identifier for accumulation and selective clearing. +- **Persistent output storage** via [IO Persistence (V2)](../IO/Persistence.md) for document save/load. +- **RunOnlyOnInputChanges** flag (default: true) controlling whether Run=true always triggers processing or only when inputs change. + +## Key lifecycle methods + +- `BeforeSolveInstance()` – Guards against resetting data during Processing state. +- `SolveInstance(IGH_DataAccess)` – Dispatches to state handlers, checks input changes, manages debounce. +- `OnWorkerCompleted()` – Updates input hashes, transitions to Completed, expires solution. +- `Write(GH_IWriter)` / `Read(GH_IReader)` – Persists and restores input hashes, outputs, and component state. + +## State transition logic + +- **Completed/Waiting/Cancelled/Error** → check `InputsChanged()`: + - If only Run changed to false → stay in current state + - If only Run changed to true → transition to Waiting or Processing (based on `RunOnlyOnInputChanges`) + - If other inputs changed → restart debounce timer targeting NeedsRun (Run=false) or Processing (Run=true) +- **NeedsRun** → if Run=true, transition to Processing +- **Processing** → async work runs; on completion → Completed ## Usage - Derive when you need Run button/toggle semantics and resilient re‑execution. -- Provide a worker (see [AsyncWorkerBase](./AsyncWorkerBase.md)) or override the async execution hook used during `Processing`. -- Respect state transitions; avoid manual UI updates from worker threads. -- Emit clear messages for validation failures and early exits (remain in Waiting/NeedsRun). +- Implement `CreateWorker(Action)` returning an `AsyncWorkerBase`. +- Implement `RegisterAdditionalInputParams` and `RegisterAdditionalOutputParams`. +- Use `SetPersistentOutput()` to store outputs that survive document save/load. +- Use `SetPersistentRuntimeMessage()` for errors/warnings that persist across solves. +- Override `RunOnlyOnInputChanges` if the component should always process when Run=true. ## Related - [StateManager](../Helpers/StateManager.md) – defines states and friendly messages. -- [AsyncComponentBase](./AsyncComponentBase.md) – lower‑level async base. +- [AsyncComponentBase](./AsyncComponentBase.md) – lower‑level async base with worker coordination. +- [AsyncWorkerBase](../Workers/AsyncWorkerBase.md) – worker abstraction for compute logic. - [ProgressInfo](../Helpers/ProgressInfo.md) – report incremental progress. -- [IO Persistence (V2)](../IO/Persistence.md) – safe, versioned storage of output trees used by this base +- [IO Persistence (V2)](../IO/Persistence.md) – safe, versioned storage of output trees used by this base. diff --git a/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md b/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md new file mode 100644 index 00000000..64c28673 --- /dev/null +++ b/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md @@ -0,0 +1,1120 @@ +# StatefulAsyncComponentBase State Management Review + +**Date:** 2025-12-24 +**Author:** Architecture Review +**Status:** Draft - Pending Validation + +--- + +## Executive Summary + +This review analyzes the state management system in `StatefulAsyncComponentBase` and its parent/child classes. The current implementation suffers from **excessive complexity** arising from the interaction of multiple orthogonal concerns, **race conditions** between debounce timers and state transitions, and **fragile persistence restoration** that triggers unintended state changes. + +**Key issues identified:** +1. File restoration immediately transitions to NeedsRun due to input hash mismatch +2. Debounce timer can fire after completion, causing unexpected state transitions +3. Multiple concurrent transition mechanisms create race conditions +4. Deeply nested inheritance with overlapping responsibilities + +--- + +## 1. Current Architecture + +### 1.1 Class Hierarchy + +**Primary inheritance chain:** +``` +GH_Component (Grasshopper) + └── AsyncComponentBase + └── StatefulAsyncComponentBase + └── AIProviderComponentBase + └── AIStatefulAsyncComponentBase + └── AISelectingStatefulAsyncComponentBase +``` + +**Alternative non-AI branches:** +``` +GH_Component + └── SelectingComponentBase (implements ISelectingComponent) +``` + +**Note:** `SelectingComponentBase` and `AISelectingStatefulAsyncComponentBase` both provide "Select Components" functionality but through different inheritance paths. The latter combines AI capabilities with selection via multiple inheritance. + +### 1.2 Responsibility Distribution + +| Class | Responsibilities | +|-------|------------------| +| `AsyncComponentBase` | Worker creation, task lifecycle, two-phase solve pattern, cancellation | +| `StatefulAsyncComponentBase` | State machine, debouncing, input hash tracking, persistent outputs, progress, runtime messages | +| `AIProviderComponentBase` | Provider selection UI, provider persistence, **InputsChanged override for provider** | +| `AIStatefulAsyncComponentBase` | Model selection, AI tool execution, metrics output, badge caching | +| `AISelectingStatefulAsyncComponentBase` | Combines AI + selection; delegates to `SelectingComponentCore` for selection logic | +| `SelectingComponentBase` | "Select Components" button UI, selection persistence, delegates to `SelectingComponentCore` | +| `SelectingComponentCore` | **Shared selection logic** (selection mode, persistence, restoration, rendering) | +| `ISelectingComponent` | Interface for components with selection capability | + +### 1.3 State Machine + +**States (ComponentState enum):** +- `Completed` - Initial/default state, outputs available +- `Waiting` - Toggle mode: waiting for input changes with Run=true +- `NeedsRun` - Button mode: inputs changed, waiting for Run=true +- `Processing` - Async work in progress +- `Cancelled` - User-initiated cancellation +- `Error` - Error occurred during processing + +**Valid Transitions:** +``` +Completed → Waiting, NeedsRun, Processing, Error +Waiting → NeedsRun, Processing, Error +NeedsRun → Processing, Error +Processing → Completed, Cancelled, Error +Cancelled → Waiting, NeedsRun, Processing, Error +Error → Waiting, NeedsRun, Processing, Error +``` + +--- + +## 2. Current Flow Analysis + +### 2.1 Normal Solve Flow (Button Mode) + +``` +[File Load / Canvas Update] + │ + ▼ + BeforeSolveInstance() + - If Processing && !Run: skip reset + - Else: cancel tasks, reset async state + │ + ▼ + SolveInstance() + - Read Run parameter + - Switch on currentState: + ┌─────────────────────────────────────────┐ + │ Completed: OnStateCompleted() │ + │ - Set message "Done" │ + │ - ApplyPersistentRuntimeMessages() │ + │ - RestorePersistentOutputs() │ + └─────────────────────────────────────────┘ + - After switch: InputsChanged() check + - If inputs changed && !Run: RestartDebounceTimer(NeedsRun) + - If inputs changed && Run: RestartDebounceTimer(Processing) + - ResetInputChanged() + │ + ▼ + [Debounce Timer Elapses] + │ + ▼ + TransitionTo(targetState) + - ProcessTransition(newState) + - If NeedsRun: OnStateNeedsRun() + - If Run=true: TransitionTo(Processing) [nested!] + - If Processing: ResetAsyncState(), ResetProgress() + │ + ▼ + ExpireSolution(true) + │ + ▼ + [New Solve Cycle in Processing State] + SolveInstance() → OnStateProcessing() + - Calls base.SolveInstance() [AsyncComponentBase] + - Creates worker, starts task + │ + ▼ + AfterSolveInstance() + - Task.WhenAll → ContinueWith + - Sets _state = Workers.Count, _setData = 1 + - ExpireSolution(true) + │ + ▼ + [Post-solve Phase] + SolveInstance() with InPreSolve=false + - SetOutput() for each worker + - When _state reaches 0: OnWorkerCompleted() + │ + ▼ + OnWorkerCompleted() [StatefulAsyncComponentBase override] + - CalculatePersistentDataHashes() + - TransitionTo(Completed) + - ExpireSolution(true) +``` + +### 2.2 File Restoration Flow + +``` +[Open .gh File] + │ + ▼ + Read(GH_IReader) + - Clear previousInputHashes + - Clear previousInputBranchCounts + - Clear persistentOutputs + - Restore input hashes from file (InputHash_*, InputBranchCount_*) + - Restore outputs via GHPersistenceService.ReadOutputsV2() + - Set justRestoredFromFile = true + │ + ▼ + [GH triggers SolveInstance] + │ + ▼ + SolveInstance() + - Check justRestoredFromFile && persistentOutputs.Count == 0 + - If true: TransitionTo(NeedsRun), return + - currentState is Completed (default) + - OnStateCompleted() → RestorePersistentOutputs() + - InputsChanged() check: + ┌─────────────────────────────────────────────────────────┐ + │ PROBLEM: Current input data may differ from restored │ + │ hashes because: │ + │ 1. Input sources may not be connected yet │ + │ 2. Upstream components haven't solved yet │ + │ 3. Data simply differs from when file was saved │ + └─────────────────────────────────────────────────────────┘ + - If changedInputs.Any() && !Run: + - RestartDebounceTimer(NeedsRun) ← DATA LOSS BEGINS + - justRestoredFromFile NEVER cleared if persistentOutputs.Count > 0 +``` + +--- + +## 3. Identified Issues + +### 3.1 Issue #1: File Restoration Triggers NeedsRun (DATA LOSS) + +**Severity:** Critical +**Location:** `StatefulAsyncComponentBase.SolveInstance()` lines 266-319 + +**Problem:** +When a file is opened: +1. `Read()` restores input hashes from the previous session +2. `SolveInstance()` runs with current input data (possibly empty/different) +3. `InputsChanged()` compares current hashes to restored hashes → detects change +4. Component transitions to `NeedsRun`, clearing outputs + +**Root Cause:** +The `justRestoredFromFile` flag is only checked for the case where `persistentOutputs.Count == 0`. When outputs ARE restored, the flag stays `true` but the `InputsChanged()` check still runs and detects a mismatch. + +**Expected Behavior:** +After file restoration with valid outputs, component should remain in `Completed` state with restored outputs until user explicitly changes inputs. + +### 3.2 Issue #2: Debounce Timer Race Conditions + +**Severity:** High +**Location:** `StatefulAsyncComponentBase` constructor, lines 111-144 + +**Problem:** +The debounce timer runs on a background thread and can fire at any time: +```csharp +this.debounceTimer = new Timer((state) => +{ + // ... + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.TransitionTo(targetState, this.lastDA); + }); + // ... +}); +``` + +If the timer fires after `OnWorkerCompleted()` has already transitioned to `Completed`: +1. `inputChangedDuringDebounce > 0` may still be set +2. `ExpireSolution(true)` is called +3. New solve cycle may detect "changed" inputs +4. Component unexpectedly transitions away from `Completed` + +**Root Cause:** +The debounce mechanism operates independently of the state machine. Timer state is not properly synchronized with component state transitions. + +### 3.3 Issue #3: Nested TransitionTo Calls + +**Severity:** High +**Location:** `OnStateNeedsRun()` line 546, `OnStateCancelled()` line 596 + +**Problem:** +State handlers can call `TransitionTo()` during a transition: +```csharp +private void OnStateNeedsRun(IGH_DataAccess DA) +{ + if (run) + { + this.TransitionTo(ComponentState.Processing, DA); // Nested! + // ... + } +} +``` + +The transition queuing mechanism (`pendingTransitions`) only queues `Completed` transitions: +```csharp +if (this.isTransitioning && newState == ComponentState.Completed) +{ + this.pendingTransitions.Enqueue(newState); + return; +} +``` + +Other transitions during `isTransitioning` are processed immediately, creating unpredictable state sequences. + +### 3.4 Issue #4: Multiple State Change Triggers in Single Solve + +**Severity:** Medium +**Location:** `SolveInstance()` lines 244-319 + +**Problem:** +A single `SolveInstance()` call can trigger multiple state changes: +1. State handler (e.g., `OnStateCompleted`) may call `TransitionTo` +2. Post-switch `InputsChanged()` logic may call `RestartDebounceTimer` +3. Debounce timer may fire during the same solve cycle + +**Root Cause:** +State change logic is scattered across: +- State handlers (`OnState*` methods) +- Post-handler input change detection +- Debounce timer callbacks +- `OnWorkerCompleted()` override + +### 3.5 Issue #5: justRestoredFromFile Flag Not Properly Cleared + +**Severity:** Medium +**Location:** `SolveInstance()` lines 226-231, `Read()` line 1153 + +**Problem:** +The flag is set in `Read()` but only conditionally cleared: +```csharp +if (this.justRestoredFromFile && this.persistentOutputs.Count == 0) +{ + this.justRestoredFromFile = false; + this.TransitionTo(ComponentState.NeedsRun, DA); + return; +} +``` + +When `persistentOutputs.Count > 0`, the flag remains `true` indefinitely. + +### 3.6 Issue #6: Processing State Background Task + +**Severity:** Medium +**Location:** `ProcessTransition()` lines 416-429 + +**Problem:** +A background task is spawned during Processing transition: +```csharp +_ = Task.Run(async () => +{ + await Task.Delay(this.GetDebounceTime()); + if (this.CurrentState == ComponentState.Processing && this.Workers.Count == 0) + { + // Force ExpireSolution + } +}); +``` + +This "safety net" for boolean toggle scenarios can interfere with normal processing flow. + +### 3.7 Issue #7: InputsChanged() Recalculates on Every Check + +**Severity:** Low +**Location:** `InputsChanged()` lines 1548-1597 + +**Problem:** +Every call to `InputsChanged()` recalculates hashes for all inputs. In `SolveInstance()`, this is called once in the post-switch block, but additional calls (e.g., in `OnStateCancelled`) cause redundant computation. + +--- + +## 4. Data Flow During Issues + +### 4.1 Scenario: File Open with Valid Persistent Data + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Expected Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Read() restores outputs and hashes │ +│ 2. SolveInstance() → Completed state │ +│ 3. RestorePersistentOutputs() sets outputs │ +│ 4. Component displays restored data │ +│ 5. User can see previous results immediately │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Actual Flow (Bug) │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Read() restores outputs and hashes │ +│ 2. SolveInstance() → Completed state │ +│ 3. RestorePersistentOutputs() sets outputs │ +│ 4. InputsChanged() detects mismatch (current vs restored hash) │ +│ 5. RestartDebounceTimer(NeedsRun) called │ +│ 6. [1000ms later] TransitionTo(NeedsRun) │ +│ 7. ClearDataOnly() clears outputs ← DATA LOSS │ +│ 8. User sees "Run me!" message, outputs gone │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Scenario: Completed State Unexpectedly Becomes NeedsRun + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Expected Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Processing completes │ +│ 2. OnWorkerCompleted() → TransitionTo(Completed) │ +│ 3. Component stays in Completed until user changes inputs │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ Actual Flow (Bug) │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Processing starts, debounce timer was running │ +│ 2. Processing completes │ +│ 3. OnWorkerCompleted() → TransitionTo(Completed) │ +│ 4. Debounce timer fires (was started earlier) │ +│ 5. inputChangedDuringDebounce > 0 from earlier changes │ +│ 6. ExpireSolution(true) called │ +│ 7. New SolveInstance detects "changes" │ +│ 8. RestartDebounceTimer(NeedsRun) ← Unexpected transition │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. Related Files + +| File | Role | Key Concerns | +|------|------|--------------| +| `AIProviderComponentBase.cs` | Provider selection | Extends `InputsChanged()`, adds provider to state triggers | +| `AISelectingStatefulAsyncComponentBase.cs` | AI + selection | Combines provider/model/selection; delegates to `SelectingComponentCore` | +| `SelectingComponentBase.cs` | Non-AI selection | Standalone selection for non-AI components | +| `SelectingComponentCore.cs` | Selection logic | Shared implementation for both selecting base classes | +| `ISelectingComponent.cs` | Selection interface | Contract for selection capability | +| `AsyncComponentBase.cs` | Base async pattern | `_state`, `_setData`, worker coordination | +| `StatefulAsyncComponentBase.cs` | State machine | All 6 identified issues | +| `AIProviderComponentBase.cs` | Provider selection | Adds to `InputsChanged()` | +| `AIStatefulAsyncComponentBase.cs` | AI integration | Badge cache, metrics clearing | +| `StateManager.cs` | State enum | State definitions | +| `GHPersistenceService.cs` | Output persistence | V2 read/write | +| `AsyncWorkerBase.cs` | Worker abstraction | GatherInput, DoWorkAsync, SetOutput | + +--- + +## 6. Proposed Solution: Robust State Manager + +### 6.1 Design Principles + +1. **Single source of truth** - One `StateManager` class owns all state +2. **Explicit state entry/exit** - Clear lifecycle hooks for each state +3. **Immutable transitions** - Transitions are queued and processed sequentially +4. **Debounce isolation** - Debouncing is separate from state machine +5. **Persistence awareness** - State knows when restoration is in progress +6. **Thread safety** - All state changes on UI thread via queue + +### 6.2 Proposed Architecture + +**Recommendation: Keep specialized bases, refactor state management** + +``` +GH_Component + └── AsyncComponentBase (unchanged - worker pattern only) + └── StatefulComponentBase (NEW - uses ComponentStateManager) + ├── AIStatefulComponentBase (NEW - combines provider + AI + state) + │ └── AISelectingStatefulComponentBase (KEEP - AI + selection) + └── SelectingComponentBase (KEEP - non-AI selection) +``` + +**Key architectural decisions:** + +1. **Keep `AIProviderComponentBase` as a separate base (REVISED)** + - **Rationale:** `AIModelsComponent` needs provider selection WITHOUT AI execution (Model input, Metrics output, CallAiToolAsync). Merging into `AIStatefulComponentBase` would force unnecessary parameters on this component. + - **Benefit:** Clean separation of concerns - provider selection is orthogonal to AI execution. + - **Implementation:** `AIProviderComponentBase` inherits from `StatefulComponentBase`, adds provider UI/persistence. `AIStatefulComponentBase` inherits from `AIProviderComponentBase`, adds AI execution. + +2. **Keep `SelectingComponentBase` as separate branch** + - **Rationale:** Selection is orthogonal to AI/state concerns. Non-AI components (e.g., `GhGetComponents`, `GhTidyUpComponents`) need selection without state management overhead. + - **Benefit:** Maintains separation of concerns; components can choose selection OR state OR both. + - **Pattern:** Composition via `SelectingComponentCore` (already implemented). + +3. **Keep `AISelectingStatefulAsyncComponentBase`** + - **Rationale:** Script generation/review components need AI + state + selection. This is a legitimate combination of concerns. + - **Benefit:** Avoids forcing components to manually wire up selection when they need all three. + - **Implementation:** Inherits from new `AIStatefulComponentBase`, implements `ISelectingComponent`, delegates to `SelectingComponentCore`. + +4. **Keep `SelectingComponentCore` as shared implementation** + - **Rationale:** DRY principle - selection logic (persistence, restoration, rendering) is identical across AI and non-AI components. + - **Benefit:** Single source of truth for selection behavior. + - **Pattern:** Composition pattern (strategy/delegate). + +**Proposed hierarchy (detailed):** + +``` +GH_Component + └── AsyncComponentBase + └── StatefulComponentBase (NEW) + │ • Uses ComponentStateManager for all state + │ • No provider/selection concerns + │ • Used by: GhPutComponents, McNeel* components, WebPageReadComponent + │ + ├── AIProviderComponentBase (KEEP - refactored) + │ │ • Provider selection UI + persistence + │ │ • Extends StateManager.GetChangedInputs() for provider + │ │ • NO Model input, NO Metrics output, NO AI execution + │ │ • Used by: AIModelsComponent + │ │ + │ └── AIStatefulComponentBase (NEW) + │ │ • Adds Model input parameter + │ │ • Adds Metrics output parameter + │ │ • AI tool execution (CallAiToolAsync) + │ │ • Badge caching + │ │ • Used by: Most AI components (13 total) + │ │ + │ └── AISelectingStatefulAsyncComponentBase (KEEP) + │ • Implements ISelectingComponent + │ • Delegates to SelectingComponentCore + │ • Custom attributes for Select button + badges + │ • Used by: AIScriptGeneratorComponent, AIScriptReviewComponent + │ + └── (Non-AI stateful components - 5 total) + +GH_Component (separate branch) + └── SelectingComponentBase (KEEP) + • Implements ISelectingComponent + • Delegates to SelectingComponentCore + • For non-AI, non-stateful selection + • Used by: GhGetComponents, GhTidyUpComponents +``` + +**Migration impact - Complete component inventory:** + +| Component | Location | Current Base | Proposed Base | Notes | +|-----------|----------|--------------|---------------|-------| +| **AI/** | | | | | +| `AIChatComponent` | AI/AIChatComponent.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `AIFileContextComponent` | AI/AIFileContextComponent.cs | `GH_Component` | `GH_Component` | No change (no state/async) | +| `AIModelsComponent` | AI/AIModelsComponent.cs | `AIProviderComponentBase` | `AIProviderComponentBase` (KEEP) | **Special case** - needs provider UI but no AI execution | +| **Grasshopper/** | | | | | +| `GhGetComponents` | Grasshopper/GhGetComponents.cs | `SelectingComponentBase` | `SelectingComponentBase` | No change (kept) | +| `GhMergeComponents` | Grasshopper/GhMergeComponents.cs | `GH_Component` | `GH_Component` | No change (no state/async) | +| `GhPutComponents` | Grasshopper/GhPutComponents.cs | `StatefulAsyncComponentBase` | `StatefulComponentBase` | Rename only | +| `GhRetrieveComponents` | Grasshopper/GhRetrieveComponents.cs | `GH_Component` | `GH_Component` | No change (no state/async) | +| `GhTidyUpComponents` | Grasshopper/GhTidyUpComponents.cs | `SelectingComponentBase` | `SelectingComponentBase` | No change (kept) | +| **Img/** | | | | | +| `AIImgGenerateComponent` | Img/AIImgGenerateComponent.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `ImageViewerComponent` | Img/ImageViewerComponent.cs | `GH_Component` | `GH_Component` | No change (no state/async) | +| **Knowledge/** | | | | | +| `AIMcNeelForumPostSummarizeComponent` | Knowledge/AIMcNeelForumPostSummarizeComponent.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `AIMcNeelForumTopicSummarizeComponent` | Knowledge/AIMcNeelForumTopicSummarizeComponent.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `McNeelForumDeconstructPostComponent` | Knowledge/McNeelForumDeconstructPostComponent.cs | `GH_Component` | `GH_Component` | No change (no state/async) | +| `McNeelForumPostGetComponent` | Knowledge/McNeelForumPostGetComponent.cs | `StatefulAsyncComponentBase` | `StatefulComponentBase` | Rename only | +| `McNeelForumPostOpenComponent` | Knowledge/McNeelForumPostOpenComponent.cs | `StatefulAsyncComponentBase` | `StatefulComponentBase` | Rename only | +| `McNeelForumSearchComponent` | Knowledge/McNeelForumSearchComponent.cs | `StatefulAsyncComponentBase` | `StatefulComponentBase` | Rename only | +| `WebPageReadComponent` | Knowledge/WebPageReadComponent.cs | `StatefulAsyncComponentBase` | `StatefulComponentBase` | Rename only | +| **List/** | | | | | +| `AIListEvaluate` | List/AIListEvaluate.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `AIListFilter` | List/AIListFilter.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| **Misc/** | | | | | +| `DeconstructMetricsComponent` | Misc/DeconstructMetricsComponent.cs | `GH_Component` | `GH_Component` | No change (no state/async) | +| **Script/** | | | | | +| `AIScriptGeneratorComponent` | Script/AIScriptGeneratorComponent.cs | `AISelectingStatefulAsyncComponentBase` | `AISelectingStatefulAsyncComponentBase` | No change (kept) | +| `AIScriptReviewComponent` | Script/AIScriptReviewComponent.cs | `AISelectingStatefulAsyncComponentBase` | `AISelectingStatefulAsyncComponentBase` | No change (kept) | +| **Text/** | | | | | +| `AITextEvaluate` | Text/AITextEvaluate.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `AITextGenerate` | Text/AITextGenerate.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | +| `AITextListGenerate` | Text/AITextListGenerate.cs | `AIStatefulAsyncComponentBase` | `AIStatefulComponentBase` | Rename only | + +**Summary:** +- **Total components:** 27 +- **No change required:** 10 (simple `GH_Component` or kept bases, including `AIModelsComponent`) +- **Rename only:** 15 (straightforward base class rename) +- **Kept specialized bases:** 3 (`AIProviderComponentBase`, `AISelectingStatefulAsyncComponentBase`, `SelectingComponentBase`) + +**Special case: `AIModelsComponent`** + +This component currently inherits from `AIProviderComponentBase` (which itself inherits from `StatefulAsyncComponentBase`). It has unique requirements: +- It **does need** provider selection UI (to list models for a specific provider) +- It **does need** state management (async worker to retrieve models) +- It **does NOT need** AI execution capabilities (no `CallAiToolAsync`, no metrics) +- It **should NOT expose** `Model` input or `Metrics` output (would be confusing/noisy) + +**Proposed solution for `AIModelsComponent`:** + +**Option A: Keep `AIProviderComponentBase` as a separate base (RECOMMENDED)** +```csharp +// In the new architecture: +public abstract class AIProviderComponentBase : StatefulComponentBase +{ + // Provider selection UI + persistence + // InputsChanged override for provider + // NO Model input, NO Metrics output, NO AI execution +} + +public class AIModelsComponent : AIProviderComponentBase +{ + // Gets provider selection + state management + // Does NOT get Model input or Metrics output +} +``` + +**Option B: Use composition with `StatefulComponentBase`** +```csharp +public class AIModelsComponent : StatefulComponentBase +{ + // Manually add provider selection UI via composition + private readonly ProviderSelectionHelper providerHelper; + + // More code to wire up provider persistence, UI, InputsChanged +} +``` + +**Recommendation:** Use Option A - **Keep `AIProviderComponentBase` as a separate base class**. + +**Rationale:** +- Avoids polluting `AIModelsComponent` with unnecessary `Model` input and `Metrics` output +- Provides clean separation: provider selection without AI execution +- Only 1 component uses it currently, but the abstraction is clean and reusable +- Simpler than composition (Option B) - no manual wiring required +- Maintains the principle: "components should only expose what they use" + +**Complexity analysis:** + +- **Keeping `AIProviderComponentBase` (REVISED):** ✅ **Reduces** complexity (clean separation: provider selection ≠ AI execution) +- **Removing `SelectingComponentBase`:** ❌ **Increases** complexity (forces all selecting components into stateful chain) +- **Removing `AISelectingStatefulAsyncComponentBase`:** ❌ **Increases** complexity (forces manual wiring of selection in 2 components) +- **Keeping `SelectingComponentCore`:** ✅ **Reduces** complexity (DRY, single implementation) + +### 6.3 New ComponentStateManager Class + +```csharp +/// +/// Centralized state manager for stateful async components. +/// Handles state transitions, debouncing, and persistence coordination. +/// +public sealed class ComponentStateManager +{ + // === State === + private ComponentState _currentState = ComponentState.Completed; + private readonly object _stateLock = new(); + private bool _isTransitioning; + private readonly Queue _pendingTransitions = new(); + + // === Restoration === + private bool _isRestoringFromFile; + private bool _suppressInputChangeDetection; + + // === Debounce === + private readonly Timer _debounceTimer; + private int _debounceGeneration; // Incremented on each timer start to invalidate stale callbacks + private ComponentState _debounceTargetState; + + // === Hashes === + private Dictionary _committedInputHashes = new(); + private Dictionary _pendingInputHashes = new(); + + // === Events === + public event Action StateChanged; + public event Action StateEntered; + public event Action StateExited; + + // === Core API === + + /// + /// Requests a state transition. Transitions are queued and processed in order. + /// + public void RequestTransition(ComponentState newState, TransitionReason reason); + + /// + /// Marks the beginning of file restoration. Suppresses input change detection. + /// + public void BeginRestoration(); + + /// + /// Marks the end of file restoration. Commits restored hashes as baseline. + /// + public void EndRestoration(); + + /// + /// Updates pending input hashes without triggering state changes. + /// + public void UpdatePendingHashes(Dictionary hashes); + + /// + /// Commits pending hashes as the new baseline (called after successful processing). + /// + public void CommitHashes(); + + /// + /// Checks if inputs have changed since last commit. + /// Returns empty list during restoration or when suppressed. + /// + public IReadOnlyList GetChangedInputs(); + + /// + /// Starts or restarts the debounce timer. + /// + public void StartDebounce(ComponentState targetState, int milliseconds); + + /// + /// Cancels any pending debounce timer. + /// + public void CancelDebounce(); +} +``` + +### 6.4 State Transition Flow (New) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ RequestTransition(newState, reason) │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Validate transition is allowed │ +│ 2. Queue transition request │ +│ 3. If not currently transitioning, process queue on UI thread │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ProcessQueue() [UI Thread Only] │ +├─────────────────────────────────────────────────────────────────┤ +│ while (queue has items): │ +│ 1. Dequeue next transition │ +│ 2. Fire StateExited(oldState) │ +│ 3. Update _currentState │ +│ 4. Fire StateEntered(newState) │ +│ 5. Fire StateChanged(oldState, newState) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.5 File Restoration Flow (New) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Read(GH_IReader) │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. stateManager.BeginRestoration() │ +│ - Sets _isRestoringFromFile = true │ +│ - Sets _suppressInputChangeDetection = true │ +│ 2. Restore hashes from file │ +│ 3. Restore outputs from file │ +│ 4. stateManager.UpdatePendingHashes(restoredHashes) │ +│ 5. stateManager.CommitHashes() │ +│ 6. stateManager.EndRestoration() │ +│ - Clears _isRestoringFromFile │ +│ - Keeps _suppressInputChangeDetection for first solve │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ First SolveInstance() after restoration │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. State is Completed │ +│ 2. RestorePersistentOutputs() runs │ +│ 3. GetChangedInputs() returns empty (suppressed) │ +│ 4. Clear _suppressInputChangeDetection │ +│ 5. Component stays in Completed with outputs │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.6 Debounce Flow (New) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ StartDebounce(targetState, ms) │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Increment _debounceGeneration │ +│ 2. Store targetState and current generation │ +│ 3. (Re)start timer │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ [Timer Elapses] +┌─────────────────────────────────────────────────────────────────┐ +│ OnDebounceElapsed(capturedGeneration) │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. If capturedGeneration != _debounceGeneration: IGNORE (stale) │ +│ 2. If current state incompatible with target: IGNORE │ +│ 3. RequestTransition(targetState, DebounceComplete) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.7 Simplified StatefulComponentBase + +```csharp +public abstract class StatefulComponentBase : AsyncComponentBase +{ + protected readonly ComponentStateManager StateManager; + + protected StatefulComponentBase(...) : base(...) + { + StateManager = new ComponentStateManager(); + StateManager.StateEntered += OnStateEntered; + StateManager.StateExited += OnStateExited; + } + + protected override void SolveInstance(IGH_DataAccess DA) + { + // Read Run parameter + bool run = false; + DA.GetData("Run?", ref run); + + // Simple dispatch based on current state + switch (StateManager.CurrentState) + { + case ComponentState.Completed: + case ComponentState.Waiting: + HandleIdleState(DA, run); + break; + case ComponentState.NeedsRun: + HandleNeedsRunState(DA, run); + break; + case ComponentState.Processing: + HandleProcessingState(DA); + break; + } + } + + private void HandleIdleState(IGH_DataAccess DA, bool run) + { + // Restore outputs if available + RestorePersistentOutputs(DA); + + // Check for input changes (respects restoration suppression) + var changed = StateManager.GetChangedInputs(); + + if (!changed.Any()) + { + // No changes - stay idle + return; + } + + // Inputs changed + if (run) + { + StateManager.StartDebounce(ComponentState.Processing, GetDebounceTime()); + } + else + { + StateManager.StartDebounce(ComponentState.NeedsRun, GetDebounceTime()); + } + } + + private void HandleNeedsRunState(IGH_DataAccess DA, bool run) + { + if (run) + { + StateManager.CancelDebounce(); + StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + } + } + + private void HandleProcessingState(IGH_DataAccess DA) + { + // Delegate to AsyncComponentBase + base.SolveInstance(DA); + } + + protected override void OnWorkerCompleted() + { + StateManager.CommitHashes(); + StateManager.RequestTransition(ComponentState.Completed, TransitionReason.ProcessingComplete); + base.OnWorkerCompleted(); + } + + public override bool Read(GH_IReader reader) + { + StateManager.BeginRestoration(); + try + { + // ... restore data ... + StateManager.CommitHashes(); + return true; + } + finally + { + StateManager.EndRestoration(); + } + } +} +``` + +--- + +## 7. Migration Strategy + +### 7.1 Phase 1: Create ComponentStateManager (Non-Breaking) ✅ COMPLETED + +**Status:** Implemented on 2025-12-24 + +**Files Created:** +- `src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs` - Full API implementation +- `src/SmartHopper.Core.Tests/SmartHopper.Core.Tests.csproj` - New test project +- `src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs` - Comprehensive unit tests +- `src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs` - Integration test for file restoration +- `src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs` - Integration test for debounce behavior + +**ComponentStateManager API Summary:** + +| Category | Methods/Properties | +|----------|-------------------| +| **State** | `CurrentState`, `IsTransitioning`, `RequestTransition()`, `ForceState()`, `ClearPendingTransitions()` | +| **Restoration** | `IsRestoringFromFile`, `IsSuppressingInputChanges`, `BeginRestoration()`, `EndRestoration()`, `ClearSuppressionAfterFirstSolve()` | +| **Hashes** | `UpdatePendingHashes()`, `UpdatePendingBranchCounts()`, `CommitHashes()`, `RestoreCommittedHashes()`, `GetChangedInputs()`, `GetCommittedHashes()`, `ClearHashes()` | +| **Debounce** | `IsDebouncing`, `StartDebounce()`, `CancelDebounce()` | +| **Events** | `StateChanged`, `StateEntered`, `StateExited`, `DebounceStarted`, `DebounceCancelled`, `TransitionRejected` | +| **Utilities** | `Reset()`, `Dispose()` | + +**Key Design Decisions:** + +1. **Thread Safety:** All state operations protected by locks; debounce uses generation-based stale callback prevention +2. **Separation of Concerns:** StateManager owns state machine, hashes, and debounce; component owns Grasshopper integration +3. **Suppression Pattern:** `BeginRestoration()` → `EndRestoration()` → `ClearSuppressionAfterFirstSolve()` prevents false input change detection +4. **No GH Dependencies:** ComponentStateManager is pure .NET, enabling unit testing without Rhino license + +**Unit Test Coverage (45+ tests):** +- Initial state validation +- Valid/invalid transition matrix +- State change event ordering +- Hash management (add/change/remove detection) +- File restoration flow (suppression, commit, clear) +- Debounce timing, cancellation, generation invalidation +- Concurrent access safety +- Dispose behavior + +**Integration Test Components:** +- `TestStateManagerRestorationComponent`: Validates file save/restore preserves outputs +- `TestStateManagerDebounceComponent`: Validates debounce event tracking and cancellation + +### 7.2 Phase 2: Create New Base Class ✅ COMPLETED + +**Status:** Implemented on 2025-01-XX + +**File Created:** +- `src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs` - New base class delegating to ComponentStateManager + +**Key Changes from StatefulAsyncComponentBase:** + +| Area | Old Implementation | New V2 Implementation | +|------|-------------------|----------------------| +| **State Storage** | `currentState` field + manual transitions | `StateManager.CurrentState` property | +| **Transitions** | `TransitionTo()` method with async/lock | `StateManager.RequestTransition()` with queued processing | +| **Debounce** | Manual `Timer` + `inputChangedDuringDebounce` counter | `StateManager.StartDebounce()` / `CancelDebounce()` with generation-based stale prevention | +| **Hash Tracking** | `previousInputHashes` / `previousInputBranchCounts` dictionaries | `StateManager.UpdatePendingHashes()` / `CommitHashes()` / `GetChangedInputs()` | +| **Restoration** | `justRestoredFromFile` flag | `StateManager.BeginRestoration()` / `EndRestoration()` / `IsSuppressingInputChanges` | +| **Events** | None | `StateManager.StateChanged` / `StateEntered` events for reactive handling | + +**API Compatibility Preserved:** +- `CurrentState` property +- `Run` property +- `RunOnlyOnInputChanges` property +- `ProgressInfo` property +- `AutoRestorePersistentOutputs` property +- `ComponentProcessingOptions` property +- `RegisterAdditionalInputParams()` / `RegisterAdditionalOutputParams()` abstract methods +- `SetPersistentOutput()` / `GetPersistentOutput()` methods +- `SetPersistentRuntimeMessage()` / `ClearOnePersistentRuntimeMessage()` / `ClearPersistentRuntimeMessages()` methods +- `InputsChanged()` overloads (now delegate to StateManager) +- `RestartDebounceTimer()` overloads (now delegate to StateManager) +- `InitializeProgress()` / `UpdateProgress()` / `ResetProgress()` methods +- `RunProcessingAsync()` method +- `GetStateMessage()` method +- `Read()` / `Write()` persistence format (unchanged) + +**Benefits Achieved:** +1. **Cleaner state management**: All state logic centralized in ComponentStateManager +2. **Generation-based debounce**: Prevents stale timer callbacks +3. **Proper restoration flow**: `BeginRestoration()` → `EndRestoration()` → `ClearSuppressionAfterFirstSolve()` pattern +4. **Event-driven updates**: Components can subscribe to `StateChanged` for reactive behavior +5. **Testable state machine**: ComponentStateManager can be unit tested independently + +### 7.3 Phase 3: Migrate Components ✅ COMPLETED + +**Status:** Completed on 2025-12-24 + +#### 7.3.1 Test Components ✅ COMPLETED + +**Status:** Completed on 2025-12-24 + +**Components Migrated (24 total):** +- **Misc Tests (4):** + - `TestStatefulPrimeCalculatorComponent` + - `TestStatefulTreePrimeCalculatorComponent` + - `TestStateManagerDebounceComponent` + - `TestStateManagerRestorationComponent` +- **DataProcessor Tests (20):** + - All topology test components (ItemToItem, BranchToBranch, BranchFlatten, etc.) + +**Migration Method:** +- Automated replacement of base class from `StatefulAsyncComponentBase` to `StatefulComponentBaseV2` +- No additional code changes required due to API compatibility +- All components compile successfully + +#### 7.3.2 Non-AI Stateful Components ✅ COMPLETED + +**Status:** Completed on 2025-12-24 + +**Components Migrated (5 total):** +- `McNeelForumPostGetComponent` - Knowledge category +- `McNeelForumPostOpenComponent` - Knowledge category +- `McNeelForumSearchComponent` - Knowledge category +- `WebPageReadComponent` - Knowledge category +- `GhPutComponents` - Grasshopper category + +**Migration Method:** +- Changed base class from `StatefulAsyncComponentBase` to `StatefulComponentBaseV2` +- Updated XML documentation where applicable +- No other code changes required due to API compatibility + +#### 7.3.3 AI Components ✅ COMPLETED + +**Status:** Completed on 2025-12-24 + +**Components Migrated (13 total via inheritance chain):** +- **AI Category (1):** + - `AIChatComponent` +- **Img Category (1):** + - `AIImgGenerateComponent` +- **Knowledge Category (2):** + - `AIMcNeelForumPostSummarizeComponent` + - `AIMcNeelForumTopicSummarizeComponent` +- **List Category (2):** + - `AIListEvaluate` + - `AIListFilter` +- **Text Category (3):** + - `AITextEvaluate` + - `AITextGenerate` + - `AITextListGenerate` +- **Plus 4 more from Script category** (covered in 7.3.4) + +**Migration Method:** +- Updated `AIProviderComponentBase` to inherit from `StatefulComponentBaseV2` instead of `StatefulAsyncComponentBase` +- All components inheriting from `AIStatefulAsyncComponentBase` (which inherits from `AIProviderComponentBase`) automatically migrated +- No component-level changes required - inheritance chain propagates the new state manager + +#### 7.3.4 Selecting Components ✅ COMPLETED + +**Status:** Completed on 2025-12-24 + +**Components Migrated (2 total via inheritance chain):** +- `AIScriptGeneratorComponent` - Script category +- `AIScriptReviewComponent` - Script category + +**Migration Method:** +- Automatically migrated via `AISelectingStatefulAsyncComponentBase` → `AIStatefulAsyncComponentBase` → `AIProviderComponentBase` → `StatefulComponentBaseV2` inheritance chain +- No component-level changes required + +**Total Migration Summary:** +- **Test components:** 24 migrated directly +- **Non-AI stateful:** 5 migrated directly +- **AI components:** 13 migrated via `AIProviderComponentBase` update +- **Selecting components:** 2 migrated via inheritance chain +- **Grand total:** 44 components migrated to use `ComponentStateManager` + +**Per-Component Migration Checklist:** +- [x] Update base class inheritance +- [x] Remove any direct hash/timer manipulation (none found - API compatible) +- [x] Verify `Read()`/`Write()` use StateManager methods (delegated automatically) +- [ ] Test file save/restore cycle (validation pending) +- [ ] Test debounce behavior with rapid input changes (validation pending) +- [ ] Test cancellation during processing (validation pending) +- [ ] Verify runtime messages preserved (validation pending) + +### 7.4 Phase 4: Cleanup + +**Status:** Pending + +**Tasks:** +1. Remove old `StatefulAsyncComponentBase` +2. Rename `StatefulComponentBaseV2` to `StatefulComponentBase` +3. Update all documentation references +4. Update CHANGELOG.md with breaking changes (if any) + +**Breaking Change Assessment:** +- **Public API preserved:** Component users unaffected +- **Protected API changes:** Derived components may need updates if they override: + - `InputsChanged()` → Use `StateManager.GetChangedInputs()` + - Direct timer access → Use `StateManager.StartDebounce()` + - Hash dictionaries → Use `StateManager` hash methods +- **Persistence format:** No changes to GH file format + +### 7.5 Post-Migration Validation + +**Automated Tests:** +- [ ] All unit tests pass +- [ ] All integration tests pass in Grasshopper +- [ ] File restoration preserves outputs (no data loss) +- [ ] Debounce prevents rapid re-execution +- [ ] Cancellation works correctly + +**Manual Tests:** +- [ ] Save file with completed component, reopen, outputs preserved +- [ ] Rapid slider changes debounce correctly +- [ ] Cancel during processing transitions to Cancelled state +- [ ] Error during processing transitions to Error state +- [ ] Toggle vs Button run modes work as expected + +--- + +## 8. Recommendations + +### 8.1 Immediate Fixes (Before Full Refactor) + +1. **Fix restoration issue**: After `Read()`, skip `InputsChanged()` check for first solve + ```csharp + // In SolveInstance, after state handlers: + if (this.justRestoredFromFile) + { + this.justRestoredFromFile = false; + this.ResetInputChanged(); // Sync hashes to current inputs + return; // Skip debounce logic this cycle + } + ``` + +2. **Cancel debounce on completion**: In `OnWorkerCompleted()`: + ```csharp + this.debounceTimer.Change(Timeout.Infinite, Timeout.Infinite); + this.inputChangedDuringDebounce = 0; + ``` + +### 8.2 Long-Term Recommendations + +1. **Adopt the new StateManager architecture** for robustness +2. **Add comprehensive state machine tests** before any refactoring +3. **Consider using a state machine library** (e.g., Stateless) for formal verification +4. **Reduce inheritance depth** by composing behaviors instead of inheriting + +--- + +## 9. Appendix: Full State Transition Table + +| From State | To State | Trigger | Valid? | +|------------|----------|---------|--------| +| Completed | Waiting | Run=true, no input changes | ✓ | +| Completed | NeedsRun | Input changes, Run=false | ✓ | +| Completed | Processing | Input changes, Run=true | ✓ | +| Completed | Error | Error during solve | ✓ | +| Waiting | NeedsRun | Input changes, Run=false | ✓ | +| Waiting | Processing | Input changes, Run=true | ✓ | +| Waiting | Error | Error during solve | ✓ | +| NeedsRun | Processing | Run=true | ✓ | +| NeedsRun | Error | Error during validation | ✓ | +| Processing | Completed | Worker completes | ✓ | +| Processing | Cancelled | User cancels | ✓ | +| Processing | Error | Worker throws | ✓ | +| Cancelled | Waiting | Re-run requested | ✓ | +| Cancelled | NeedsRun | Input changes | ✓ | +| Cancelled | Processing | Run=true, inputs valid | ✓ | +| Error | Waiting | Error cleared, Run=true | ✓ | +| Error | NeedsRun | Error cleared | ✓ | +| Error | Processing | Re-run after error | ✓ | + +--- + +## 10. Conclusion + +The current state management system is **fundamentally sound in concept** but suffers from **implementation complexity** that creates race conditions and edge cases. The proposed `ComponentStateManager` provides: + +1. **Clear ownership** of state transitions +2. **Generation-based debouncing** to prevent stale callbacks +3. **Restoration awareness** to prevent false input change detection +4. **Simplified component code** through delegation + +The migration can be done incrementally, with immediate fixes available for critical issues while the new architecture is developed and tested. + +--- + +*End of Review* diff --git a/docs/Reviews/index.md b/docs/Reviews/index.md index d1b46418..31b9e452 100644 --- a/docs/Reviews/index.md +++ b/docs/Reviews/index.md @@ -4,6 +4,7 @@ This folder contains detailed architecture reviews of SmartHopper components. ## Reviews +- [251224 StatefulAsyncComponentBase State Management](./251224%20StatefulAsyncComponentBase%20State%20Management.md) - Analysis of state flow, race conditions, and proposed robust state manager - [251224 WebChat Performance](./251224%20WebChat%20Performance.md) - Analysis of WebChatDialog, ConversationSession, and AIProvider interaction ## Purpose diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs index a4fb6b09..188035a5 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for BranchFlatten topology: all items from all branches are flattened into a single list, /// processed together, and the results are placed in a single output branch. /// - public class DataTreeProcessorBranchFlattenTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorBranchFlattenTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("E9642177-D368-4E9D-9BD6-E84C46D0958F"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs index 9d71d438..fc890bc3 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs @@ -28,7 +28,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// where each branch is processed independently and maintains its branch structure. /// This is the list-level processing mode used by AIListEvaluate and AIListFilter. /// - public class DataTreeProcessorBranchToBranchTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorBranchToBranchTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("4FBE8A03-5A39-4C99-B190-F95468A0D3AC"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs index 1e7c443d..e303066b 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 7 & 8: A={0}, B={1;0},{1;1} - Deeper paths under different root 1 /// Rule 3 applies: A broadcasts to ALL deeper paths regardless of root ///
- public class DataTreeProcessorBroadcastDeeperDiffRootTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorBroadcastDeeperDiffRootTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("D659A768-A076-4946-80B9-A8AD99D4F740"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs index 322303be..5aaa77b8 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 5 & 6: A={0}, B={0;0},{0;1} - Deeper paths under same root 0 /// Rule 3 applies: A broadcasts to ALL deeper paths /// - public class DataTreeProcessorBroadcastDeeperSameRootTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorBroadcastDeeperSameRootTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("0E1105D8-1EA0-446B-B51D-F90D1EC29342"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs index baac675c..1b1d7778 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 4: A={0}, B={1},{2} - Multiple top-level paths, none is {0} /// Rule 2 applies: A broadcasts to ALL paths in B /// - public class DataTreeProcessorBroadcastMultipleNoZeroTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorBroadcastMultipleNoZeroTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("11287A68-04D7-46F4-99DE-C5B0C45F0732"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs index d07d8005..15483e0c 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 3: A={0}, B={0},{1} - Multiple top-level paths including {0} /// Rule 2 applies: A broadcasts to ALL paths in B (including {0} and {1}) /// - public class DataTreeProcessorBroadcastMultipleTopLevelTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorBroadcastMultipleTopLevelTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("CBD8E900-B6EF-4ADE-B68C-A1A6AB486647"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs index 88aadd55..8ff7e99a 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input one item, second input three items, different paths. /// - public class DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("A8D1E0F3-3C2B-4E1E-9B3F-1A2C3D4E5F60"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs index 64b38608..28a64533 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input three items, second input one item, different paths. /// - public class DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("7A6E5F0B-9D3C-4A0C-8B2E-1F3A4D5C6B7E"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs index 27f137bd..49b6c513 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, one item each, different paths. Validates non-matching paths processing. /// - public class DataTreeProcessorDifferentPathsOneItemEachTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorDifferentPathsOneItemEachTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("F26E3A5B-2EFD-4F7B-8D8A-7C9A6B6882A2"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs index c3fec640..5c92de61 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs @@ -29,7 +29,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Only when paths match should items be matched item-by-item. In this test, /// the output should be identical to the input trees (per-branch passthrough). /// - public class DataTreeProcessorDifferentPathsThreeItemsEachTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorDifferentPathsThreeItemsEachTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("5A7B9B0C-12D0-4B90-AE17-5D1F764C6C5A"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs index a82f75b8..3a40d531 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 9 & 10: A={0}, B={0},{0;0},{0;1} - Direct match + deeper paths /// Rule 4 applies: A matches ONLY B's {0}, NOT the deeper {0;0} or {0;1} /// - public class DataTreeProcessorDirectMatchPrecedenceTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorDirectMatchPrecedenceTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("77095C92-474F-4D5C-9EA6-6FE31FFFA710"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs index 87f9c327..6685bdc7 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input one item, second input three items, equal paths. /// - public class DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("0C6B2C9E-2D68-45AC-A2D8-7B2E5F97F9C3"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs index f6bf55d4..9de24d81 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input three items, second input one item, equal paths. /// - public class DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("B3C7D9E1-4A5B-4F2C-8B1D-2E3F4A5B6C7D"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs index 87c95357..8f06d62c 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component to validate DataTreeProcessor with two trees having equal paths (one item each). /// Internal hardcoded inputs are used; only Run? is exposed. Outputs the result tree, success flag, and messages. /// - public class DataTreeProcessorEqualPathsTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorEqualPathsTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("B0C2B1B7-3A6C-46A5-9E52-9F9E4F6B7C11"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs index 11b83c4d..30e95250 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component to validate DataTreeProcessor with two trees having equal paths (three items each). /// Uses internal data; outputs result tree, success flag, and messages. /// - public class DataTreeProcessorEqualPathsThreeItemsTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorEqualPathsThreeItemsTestComponent : StatefulComponentBaseV2 { /// /// Gets the unique component identifier. diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs index c3a3acee..db606279 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for GroupIdenticalBranches flag: identical branches are grouped and processed only once. /// When branches have identical content across inputs, they should be processed only once. /// - public class DataTreeProcessorGroupIdenticalTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorGroupIdenticalTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("CEF161C9-36FC-4503-8BB0-9717EEA13865"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs index 07356943..1874dbfe 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for ItemGraft topology: each item is grafted into its own separate branch. /// Each item from input trees is processed independently, and results are grafted into separate branches. /// - public class DataTreeProcessorItemGraftTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorItemGraftTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("3B09EE1F-00A3-4B7D-86DA-4C7EB0C6C0C3"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs index d8319035..3679f242 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for ItemToItem topology: processes items independently across matching paths. /// Each item from input trees is processed independently, and results maintain the same branch structure. /// - public class DataTreeProcessorItemToItemTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorItemToItemTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("C4D5E6F7-8A9B-4C0D-9E1F-2A3B4C5D6E7F"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs index 3b246bbe..b2556ec8 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 12 & 13: A={0}, B={0;0},{1},{1;0} - Mixed depths and roots /// Rule 3 applies: A broadcasts to ALL paths (deeper topology present) /// - public class DataTreeProcessorMixedDepthsTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorMixedDepthsTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("F788712F-B2B3-4131-87CA-E654F6153339"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs index cc008a1e..bdd4fc70 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 11a&11b: A={0}, B={0},{1},{2} - Multiple top-level paths including {0} /// Rule 2 overrides Rule 4: A broadcasts to ALL paths (structural complexity) /// - public class DataTreeProcessorRule2OverrideTestComponent : StatefulAsyncComponentBase + public class DataTreeProcessorRule2OverrideTestComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("FE2E0986-FFAF-4A64-9F97-0FD3F4E571D8"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs new file mode 100644 index 00000000..a1138748 --- /dev/null +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs @@ -0,0 +1,246 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Grasshopper.Kernel; +using SmartHopper.Core.ComponentBase; + +namespace SmartHopper.Components.Test.Misc +{ + /// + /// Test component for validating ComponentStateManager debounce behavior. + /// This component demonstrates debounce cancellation and generation-based + /// stale callback prevention. + /// + public class TestStateManagerDebounceComponent : StatefulComponentBaseV2 + { + /// + /// The ComponentStateManager instance for this component. + /// + private readonly ComponentStateManager stateManager; + + /// + /// Tracks state transition history for debugging. + /// + private readonly List stateHistory = new List(); + + /// + /// Tracks debounce events. + /// + private int debounceStartCount; + + /// + /// Tracks debounce cancellation events. + /// + private int debounceCancelCount; + + /// + /// Tracks rejected transitions. + /// + private int rejectedTransitionCount; + + /// + /// Gets the unique component identifier. + /// + public override Guid ComponentGuid => new Guid("F4C612B0-2C57-47CE-B9FE-E10621F18937"); + + /// + /// Gets the component icon (null for test component). + /// + protected override Bitmap Icon => null; + + /// + /// Gets the component exposure level. + /// + public override GH_Exposure Exposure => GH_Exposure.tertiary; + + /// + /// Initializes a new instance of the class. + /// + public TestStateManagerDebounceComponent() + : base( + "Test StateManager Debounce", + "TEST-DEBOUNCE", + "Test component for validating ComponentStateManager debounce behavior. " + + "Rapidly change inputs to test debounce cancellation and generation tracking.", + "SmartHopper", + "Testing Base") + { + this.stateManager = this.StateManager; + + // Subscribe to all events for comprehensive testing + this.stateManager.StateChanged += this.OnStateChanged; + this.stateManager.StateEntered += this.OnStateEntered; + this.stateManager.StateExited += this.OnStateExited; + this.stateManager.DebounceStarted += this.OnDebounceStarted; + this.stateManager.DebounceCancelled += this.OnDebounceCancelled; + this.stateManager.TransitionRejected += this.OnTransitionRejected; + } + + #region Event Handlers + + private void OnStateChanged(ComponentState oldState, ComponentState newState) + { + var entry = $"{DateTime.Now:HH:mm:ss.fff}: {oldState} -> {newState}"; + this.stateHistory.Add(entry); + Debug.WriteLine($"[{this.GetType().Name}] StateChanged: {entry}"); + + // Keep history limited + if (this.stateHistory.Count > 50) + { + this.stateHistory.RemoveAt(0); + } + } + + private void OnStateEntered(ComponentState newState) + { + Debug.WriteLine($"[{this.GetType().Name}] StateEntered: {newState}"); + } + + private void OnStateExited(ComponentState oldState) + { + Debug.WriteLine($"[{this.GetType().Name}] StateExited: {oldState}"); + } + + private void OnDebounceStarted(ComponentState targetState, int milliseconds) + { + this.debounceStartCount++; + Debug.WriteLine($"[{this.GetType().Name}] DebounceStarted: target={targetState}, ms={milliseconds}, count={this.debounceStartCount}"); + } + + private void OnDebounceCancelled() + { + this.debounceCancelCount++; + Debug.WriteLine($"[{this.GetType().Name}] DebounceCancelled: count={this.debounceCancelCount}"); + } + + private void OnTransitionRejected(ComponentState from, ComponentState to, string message) + { + this.rejectedTransitionCount++; + Debug.WriteLine($"[{this.GetType().Name}] TransitionRejected: {from} -> {to}, reason: {message}"); + } + + #endregion + + /// + protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) + { + pManager.AddIntegerParameter("Value", "V", "A value to process.", GH_ParamAccess.item, 1); + pManager.AddIntegerParameter("DebounceMs", "D", "Debounce time in milliseconds.", GH_ParamAccess.item, 500); + } + + /// + protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) + { + pManager.AddNumberParameter("Result", "R", "The processed result.", GH_ParamAccess.item); + pManager.AddTextParameter("History", "H", "State transition history.", GH_ParamAccess.list); + pManager.AddTextParameter("Stats", "S", "Debounce statistics.", GH_ParamAccess.item); + } + + /// + protected override AsyncWorkerBase CreateWorker(Action progressReporter) + { + return new TestDebounceWorker(this, this.AddRuntimeMessage); + } + + /// + protected override void SolveInstance(IGH_DataAccess DA) + { + // Get debounce time from input + int debounceMs = 500; + DA.GetData("DebounceMs", ref debounceMs); + + // Demonstrate using StateManager for input change detection + var currentHashes = new Dictionary(); + for (int i = 0; i < this.Params.Input.Count; i++) + { + var param = this.Params.Input[i]; + currentHashes[param.Name] = param.VolatileData.GetHashCode(); + } + + this.stateManager.UpdatePendingHashes(currentHashes); + + // Check for changes using StateManager + var changedInputs = this.stateManager.GetChangedInputs(); + if (changedInputs.Count > 0) + { + Debug.WriteLine($"[{this.GetType().Name}] Inputs changed: {string.Join(", ", changedInputs)}"); + + // Demonstrate debounce with StateManager + // Note: This is for demonstration - in real usage, the base class would handle this + // this.stateManager.StartDebounce(ComponentState.NeedsRun, debounceMs); + } + + // Let base class handle the normal flow + base.SolveInstance(DA); + + // Output history and stats + DA.SetDataList("History", this.stateHistory); + + string stats = $"Debounce starts: {this.debounceStartCount}, " + + $"Debounce cancels: {this.debounceCancelCount}, " + + $"Rejected transitions: {this.rejectedTransitionCount}, " + + $"Current state: {this.stateManager.CurrentState}"; + DA.SetData("Stats", stats); + } + + /// + /// Worker that performs a simple calculation. + /// + private sealed class TestDebounceWorker : AsyncWorkerBase + { + private int inputValue = 1; + private double result; + private readonly TestStateManagerDebounceComponent parent; + + /// + /// Initializes a new instance of the class. + /// + /// The parent component. + /// The runtime message handler. + public TestDebounceWorker( + TestStateManagerDebounceComponent parent, + Action addRuntimeMessage) + : base(parent, addRuntimeMessage) + { + this.parent = parent; + } + + /// + public override void GatherInput(IGH_DataAccess DA, out int dataCount) + { + int v = 1; + DA.GetData("Value", ref v); + this.inputValue = v; + dataCount = 1; + } + + /// + public override async Task DoWorkAsync(CancellationToken token) + { + // Simulate some async work + await Task.Delay(200, token); + this.result = this.inputValue * 3.14159; + } + + /// + public override void SetOutput(IGH_DataAccess DA, out string message) + { + this.parent.SetPersistentOutput("Result", this.result, DA); + message = $"Processed: {this.inputValue} -> {this.result:F4}"; + } + } + } +} diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs new file mode 100644 index 00000000..c170d6be --- /dev/null +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs @@ -0,0 +1,246 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using GH_IO.Serialization; +using Grasshopper.Kernel; +using SmartHopper.Core.ComponentBase; + +namespace SmartHopper.Components.Test.Misc +{ + /// + /// Test component for validating ComponentStateManager file restoration scenarios. + /// This component demonstrates the new state management pattern and can be used + /// to manually test file save/restore behavior in Grasshopper. + /// + public class TestStateManagerRestorationComponent : StatefulComponentBaseV2 + { + /// + /// The ComponentStateManager instance for this component. + /// Used to demonstrate the new centralized state management approach. + /// + private readonly ComponentStateManager stateManager; + + /// + /// Tracks the number of times Read() was called. + /// + private int readCallCount; + + /// + /// Tracks the number of times SolveInstance() was called after restoration. + /// + private int solveAfterRestoreCount; + + /// + /// Indicates if restoration successfully preserved outputs. + /// + private bool restorationSuccess; + + /// + /// Gets the unique component identifier. + /// + public override Guid ComponentGuid => new Guid("E3C612B0-2C57-47CE-B9FE-E10621F18936"); + + /// + /// Gets the component icon (null for test component). + /// + protected override Bitmap Icon => null; + + /// + /// Gets the component exposure level. + /// + public override GH_Exposure Exposure => GH_Exposure.tertiary; + + /// + /// Initializes a new instance of the class. + /// + public TestStateManagerRestorationComponent() + : base( + "Test StateManager Restoration", + "TEST-RESTORE", + "Test component for validating ComponentStateManager file restoration. " + + "Run with a number, save the file, close, reopen - outputs should be preserved.", + "SmartHopper", + "Testing Base") + { + this.stateManager = new ComponentStateManager(this.GetType().Name); + this.stateManager.StateChanged += this.OnStateManagerStateChanged; + } + + /// + /// Handles state changes from the ComponentStateManager. + /// + /// The previous state. + /// The new state. + private void OnStateManagerStateChanged(ComponentState oldState, ComponentState newState) + { + Debug.WriteLine($"[{this.GetType().Name}] StateManager: {oldState} -> {newState}"); + } + + /// + protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) + { + pManager.AddIntegerParameter("Number", "N", "A number to process (1-100).", GH_ParamAccess.item, 10); + } + + /// + protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) + { + pManager.AddNumberParameter("Result", "R", "The processed result (N * 2 + 1).", GH_ParamAccess.item); + pManager.AddTextParameter("Status", "S", "Restoration status information.", GH_ParamAccess.item); + } + + /// + protected override AsyncWorkerBase CreateWorker(Action progressReporter) + { + return new TestRestorationWorker(this, this.AddRuntimeMessage); + } + + /// + public override bool Read(GH_IReader reader) + { + this.readCallCount++; + Debug.WriteLine($"[{this.GetType().Name}] Read() called (count: {this.readCallCount})"); + + // Demonstrate using ComponentStateManager for restoration + this.stateManager.BeginRestoration(); + + try + { + // Read state manager hashes if they exist + var hashes = new Dictionary(); + var branchCounts = new Dictionary(); + + if (reader.ItemExists("SM_HashCount")) + { + int hashCount = reader.GetInt32("SM_HashCount"); + for (int i = 0; i < hashCount; i++) + { + string key = reader.GetString($"SM_HashKey_{i}"); + int value = reader.GetInt32($"SM_HashValue_{i}"); + hashes[key] = value; + } + } + + this.stateManager.RestoreCommittedHashes(hashes, branchCounts); + + // Call base Read which handles persistent outputs + var result = base.Read(reader); + + this.stateManager.CommitHashes(); + return result; + } + finally + { + this.stateManager.EndRestoration(); + } + } + + /// + public override bool Write(GH_IWriter writer) + { + Debug.WriteLine($"[{this.GetType().Name}] Write() called"); + + // Write state manager hashes + var hashes = this.stateManager.GetCommittedHashes(); + writer.SetInt32("SM_HashCount", hashes.Count); + + int i = 0; + foreach (var kvp in hashes) + { + writer.SetString($"SM_HashKey_{i}", kvp.Key); + writer.SetInt32($"SM_HashValue_{i}", kvp.Value); + i++; + } + + return base.Write(writer); + } + + /// + protected override void SolveInstance(IGH_DataAccess DA) + { + // Track solves after restoration + if (this.stateManager.IsSuppressingInputChanges) + { + this.solveAfterRestoreCount++; + Debug.WriteLine($"[{this.GetType().Name}] First solve after restoration (count: {this.solveAfterRestoreCount})"); + + // Clear suppression after first solve - this is the key to preventing data loss + this.stateManager.ClearSuppressionAfterFirstSolve(); + } + + // Check if we have restored outputs + if (this.persistentOutputs.ContainsKey("Result")) + { + this.restorationSuccess = true; + } + + // Let base class handle the normal flow + base.SolveInstance(DA); + + // Set status output + string status = $"Read calls: {this.readCallCount}, Solves after restore: {this.solveAfterRestoreCount}, " + + $"Restoration success: {this.restorationSuccess}, StateManager state: {this.stateManager.CurrentState}"; + DA.SetData("Status", status); + } + + /// + /// Worker that performs a simple calculation to test persistence. + /// + private sealed class TestRestorationWorker : AsyncWorkerBase + { + private int inputNumber = 10; + private double result; + private readonly TestStateManagerRestorationComponent parent; + + /// + /// Initializes a new instance of the class. + /// + /// The parent component. + /// The runtime message handler. + public TestRestorationWorker( + TestStateManagerRestorationComponent parent, + Action addRuntimeMessage) + : base(parent, addRuntimeMessage) + { + this.parent = parent; + } + + /// + public override void GatherInput(IGH_DataAccess DA, out int dataCount) + { + int n = 10; + DA.GetData("Number", ref n); + this.inputNumber = Math.Max(1, Math.Min(n, 100)); + dataCount = 1; + } + + /// + public override async Task DoWorkAsync(CancellationToken token) + { + // Simple calculation with a small delay to simulate async work + await Task.Delay(100, token); + this.result = (this.inputNumber * 2) + 1; + } + + /// + public override void SetOutput(IGH_DataAccess DA, out string message) + { + this.parent.SetPersistentOutput("Result", this.result, DA); + message = $"Calculated: {this.inputNumber} * 2 + 1 = {this.result}"; + } + } + } +} diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs index 57a78d4e..2c1c7478 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs @@ -24,7 +24,7 @@ namespace SmartHopper.Components.Test.Misc { - public class TestStatefulPrimeCalculatorComponent : StatefulAsyncComponentBase + public class TestStatefulPrimeCalculatorComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("C2C612B0-2C57-47CE-B9FE-E10621F18935"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs index d8dc052b..975725bf 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs @@ -28,7 +28,7 @@ namespace SmartHopper.Components.Test.Misc { - public class TestStatefulTreePrimeCalculatorComponent : StatefulAsyncComponentBase + public class TestStatefulTreePrimeCalculatorComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("E2DB56F0-C597-432C-9774-82DF431CC848"); protected override Bitmap Icon => null; diff --git a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs index 78fd62cc..209a3daf 100644 --- a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs @@ -27,9 +27,9 @@ namespace SmartHopper.Components.Grasshopper { /// /// Grasshopper component for placing components from JSON data. - /// Uses StatefulAsyncComponentBase to properly manage async execution, state, and prevent re-entrancy. + /// Uses StatefulComponentBaseV2 to properly manage async execution, state, and prevent re-entrancy. /// - public class GhPutComponents : StatefulAsyncComponentBase + public class GhPutComponents : StatefulComponentBaseV2 { /// /// Initializes a new instance of the class. diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs index 8f3ddc5a..71b3f548 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs @@ -29,7 +29,7 @@ namespace SmartHopper.Components.Knowledge { - public class McNeelForumPostGetComponent : StatefulAsyncComponentBase + public class McNeelForumPostGetComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("7C1B9A33-0177-4A60-9C08-9F8A1E4F2002"); diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs index 3245da99..757f6742 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs @@ -25,7 +25,7 @@ namespace SmartHopper.Components.Knowledge /// /// Opens the McNeelForum page for a given post JSON in the default browser. /// - public class McNeelForumPostOpenComponent : StatefulAsyncComponentBase + public class McNeelForumPostOpenComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("1B7A2E6C-4F0B-4B1C-9D19-6B3A2C8F9012"); diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs index 256960a8..7b217367 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs @@ -29,7 +29,7 @@ namespace SmartHopper.Components.Knowledge { - public class McNeelForumSearchComponent : StatefulAsyncComponentBase + public class McNeelForumSearchComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("5F8F0D47-29D6-44D8-A5B1-2E7C6A9B1001"); diff --git a/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs b/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs index 0f38a1e6..8045a55b 100644 --- a/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs +++ b/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs @@ -29,7 +29,7 @@ namespace SmartHopper.Components.Knowledge { - public class WebPageReadComponent : StatefulAsyncComponentBase + public class WebPageReadComponent : StatefulComponentBaseV2 { public override Guid ComponentGuid => new Guid("C2E6B13A-6245-4A4F-8C8F-3B7616D33003"); diff --git a/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs b/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs new file mode 100644 index 00000000..c6e8d367 --- /dev/null +++ b/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs @@ -0,0 +1,849 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SmartHopper.Core.ComponentBase; +using Xunit; + +namespace SmartHopper.Core.Tests.ComponentBase +{ + /// + /// Unit tests for the ComponentStateManager class. + /// Tests state transitions, debouncing, hash management, and file restoration scenarios. + /// + public class ComponentStateManagerTests : IDisposable + { + private ComponentStateManager manager; + + public ComponentStateManagerTests() + { + this.manager = new ComponentStateManager("TestComponent"); + } + + public void Dispose() + { + this.manager?.Dispose(); + } + + #region Initial State Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Initial state is Completed [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Initial state is Completed [Core]")] +#endif + public void InitialState_IsCompleted() + { + Assert.Equal(ComponentState.Completed, this.manager.CurrentState); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Initial state is not transitioning [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Initial state is not transitioning [Core]")] +#endif + public void InitialState_IsNotTransitioning() + { + Assert.False(this.manager.IsTransitioning); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Initial state is not restoring [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Initial state is not restoring [Core]")] +#endif + public void InitialState_IsNotRestoring() + { + Assert.False(this.manager.IsRestoringFromFile); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Initial state has no pending transitions [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Initial state has no pending transitions [Core]")] +#endif + public void InitialState_NoPendingTransitions() + { + Assert.Equal(0, this.manager.PendingTransitionCount); + } + + #endregion + + #region Valid Transition Tests + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Completed [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Completed [Core]")] +#endif + [InlineData(ComponentState.Waiting)] + [InlineData(ComponentState.NeedsRun)] + [InlineData(ComponentState.Processing)] + [InlineData(ComponentState.Error)] + public void ValidTransitions_FromCompleted(ComponentState targetState) + { + Assert.True(this.manager.IsValidTransition(ComponentState.Completed, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Waiting [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Waiting [Core]")] +#endif + [InlineData(ComponentState.NeedsRun)] + [InlineData(ComponentState.Processing)] + [InlineData(ComponentState.Error)] + public void ValidTransitions_FromWaiting(ComponentState targetState) + { + Assert.True(this.manager.IsValidTransition(ComponentState.Waiting, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Valid transitions from NeedsRun [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Valid transitions from NeedsRun [Core]")] +#endif + [InlineData(ComponentState.Processing)] + [InlineData(ComponentState.Error)] + public void ValidTransitions_FromNeedsRun(ComponentState targetState) + { + Assert.True(this.manager.IsValidTransition(ComponentState.NeedsRun, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Processing [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Processing [Core]")] +#endif + [InlineData(ComponentState.Completed)] + [InlineData(ComponentState.Cancelled)] + [InlineData(ComponentState.Error)] + public void ValidTransitions_FromProcessing(ComponentState targetState) + { + Assert.True(this.manager.IsValidTransition(ComponentState.Processing, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Cancelled [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Cancelled [Core]")] +#endif + [InlineData(ComponentState.Waiting)] + [InlineData(ComponentState.NeedsRun)] + [InlineData(ComponentState.Processing)] + [InlineData(ComponentState.Error)] + public void ValidTransitions_FromCancelled(ComponentState targetState) + { + Assert.True(this.manager.IsValidTransition(ComponentState.Cancelled, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Error [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Valid transitions from Error [Core]")] +#endif + [InlineData(ComponentState.Waiting)] + [InlineData(ComponentState.NeedsRun)] + [InlineData(ComponentState.Processing)] + public void ValidTransitions_FromError(ComponentState targetState) + { + Assert.True(this.manager.IsValidTransition(ComponentState.Error, targetState)); + } + + #endregion + + #region Invalid Transition Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Same state transition is invalid [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Same state transition is invalid [Core]")] +#endif + public void InvalidTransition_SameState() + { + Assert.False(this.manager.IsValidTransition(ComponentState.Completed, ComponentState.Completed)); + Assert.False(this.manager.IsValidTransition(ComponentState.Processing, ComponentState.Processing)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Invalid transitions from Waiting [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Invalid transitions from Waiting [Core]")] +#endif + [InlineData(ComponentState.Completed)] + [InlineData(ComponentState.Cancelled)] + public void InvalidTransitions_FromWaiting(ComponentState targetState) + { + Assert.False(this.manager.IsValidTransition(ComponentState.Waiting, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Invalid transitions from NeedsRun [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Invalid transitions from NeedsRun [Core]")] +#endif + [InlineData(ComponentState.Completed)] + [InlineData(ComponentState.Waiting)] + [InlineData(ComponentState.Cancelled)] + public void InvalidTransitions_FromNeedsRun(ComponentState targetState) + { + Assert.False(this.manager.IsValidTransition(ComponentState.NeedsRun, targetState)); + } + +#if NET7_WINDOWS + [Theory(DisplayName = "ComponentStateManager: Invalid transitions from Processing [Windows]")] +#else + [Theory(DisplayName = "ComponentStateManager: Invalid transitions from Processing [Core]")] +#endif + [InlineData(ComponentState.Waiting)] + [InlineData(ComponentState.NeedsRun)] + public void InvalidTransitions_FromProcessing(ComponentState targetState) + { + Assert.False(this.manager.IsValidTransition(ComponentState.Processing, targetState)); + } + + #endregion + + #region RequestTransition Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: RequestTransition changes state [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: RequestTransition changes state [Core]")] +#endif + public void RequestTransition_ChangesState() + { + var result = this.manager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged); + + Assert.True(result); + Assert.Equal(ComponentState.NeedsRun, this.manager.CurrentState); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: RequestTransition fires events in order [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: RequestTransition fires events in order [Core]")] +#endif + public void RequestTransition_FiresEventsInOrder() + { + var events = new List(); + + this.manager.StateExited += (old) => events.Add($"Exited:{old}"); + this.manager.StateEntered += (newState) => events.Add($"Entered:{newState}"); + this.manager.StateChanged += (old, newState) => events.Add($"Changed:{old}->{newState}"); + + this.manager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged); + + Assert.Equal(3, events.Count); + Assert.Equal("Exited:Completed", events[0]); + Assert.Equal("Entered:NeedsRun", events[1]); + Assert.Equal("Changed:Completed->NeedsRun", events[2]); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: RequestTransition rejects invalid transition [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: RequestTransition rejects invalid transition [Core]")] +#endif + public void RequestTransition_RejectsInvalidTransition() + { + string rejectionMessage = null; + this.manager.TransitionRejected += (from, to, msg) => rejectionMessage = msg; + + // NeedsRun -> Waiting is invalid + this.manager.ForceState(ComponentState.NeedsRun); + var result = this.manager.RequestTransition(ComponentState.Waiting, TransitionReason.InputChanged); + + Assert.False(result); + Assert.Equal(ComponentState.NeedsRun, this.manager.CurrentState); + Assert.NotNull(rejectionMessage); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Sequential transitions execute in order [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Sequential transitions execute in order [Core]")] +#endif + public void RequestTransition_SequentialTransitions() + { + // Completed -> NeedsRun -> Processing -> Completed + this.manager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged); + Assert.Equal(ComponentState.NeedsRun, this.manager.CurrentState); + + this.manager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + Assert.Equal(ComponentState.Processing, this.manager.CurrentState); + + this.manager.RequestTransition(ComponentState.Completed, TransitionReason.ProcessingComplete); + Assert.Equal(ComponentState.Completed, this.manager.CurrentState); + } + + #endregion + + #region ForceState Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: ForceState bypasses validation [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: ForceState bypasses validation [Core]")] +#endif + public void ForceState_BypassesValidation() + { + // Force to Processing without going through valid path + this.manager.ForceState(ComponentState.Processing); + Assert.Equal(ComponentState.Processing, this.manager.CurrentState); + + // Force back to Completed (valid) + this.manager.ForceState(ComponentState.Completed); + Assert.Equal(ComponentState.Completed, this.manager.CurrentState); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: ForceState fires events [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: ForceState fires events [Core]")] +#endif + public void ForceState_FiresEvents() + { + var stateChanged = false; + this.manager.StateChanged += (old, newState) => stateChanged = true; + + this.manager.ForceState(ComponentState.Processing); + + Assert.True(stateChanged); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: ForceState to same state does nothing [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: ForceState to same state does nothing [Core]")] +#endif + public void ForceState_SameState_NoEvent() + { + var eventCount = 0; + this.manager.StateChanged += (old, newState) => eventCount++; + + this.manager.ForceState(ComponentState.Completed); + + Assert.Equal(0, eventCount); + } + + #endregion + + #region Hash Management Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: UpdatePendingHashes stores hashes [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: UpdatePendingHashes stores hashes [Core]")] +#endif + public void UpdatePendingHashes_StoresHashes() + { + var hashes = new Dictionary + { + { "Input1", 123 }, + { "Input2", 456 }, + }; + + this.manager.UpdatePendingHashes(hashes); + this.manager.CommitHashes(); + + var committed = this.manager.GetCommittedHashes(); + Assert.Equal(2, committed.Count); + Assert.Equal(123, committed["Input1"]); + Assert.Equal(456, committed["Input2"]); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs detects new inputs [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs detects new inputs [Core]")] +#endif + public void GetChangedInputs_DetectsNewInputs() + { + // Commit initial state (empty) + this.manager.CommitHashes(); + + // Add new input + var hashes = new Dictionary { { "Input1", 123 } }; + this.manager.UpdatePendingHashes(hashes); + + var changed = this.manager.GetChangedInputs(); + Assert.Single(changed); + Assert.Equal("Input1", changed[0]); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs detects changed values [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs detects changed values [Core]")] +#endif + public void GetChangedInputs_DetectsChangedValues() + { + // Commit initial state + var initial = new Dictionary { { "Input1", 123 } }; + this.manager.UpdatePendingHashes(initial); + this.manager.CommitHashes(); + + // Change value + var updated = new Dictionary { { "Input1", 456 } }; + this.manager.UpdatePendingHashes(updated); + + var changed = this.manager.GetChangedInputs(); + Assert.Single(changed); + Assert.Equal("Input1", changed[0]); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs detects removed inputs [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs detects removed inputs [Core]")] +#endif + public void GetChangedInputs_DetectsRemovedInputs() + { + // Commit initial state with two inputs + var initial = new Dictionary + { + { "Input1", 123 }, + { "Input2", 456 }, + }; + this.manager.UpdatePendingHashes(initial); + this.manager.CommitHashes(); + + // Remove one input + var updated = new Dictionary { { "Input1", 123 } }; + this.manager.UpdatePendingHashes(updated); + + var changed = this.manager.GetChangedInputs(); + Assert.Single(changed); + Assert.Equal("Input2", changed[0]); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs returns empty when unchanged [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs returns empty when unchanged [Core]")] +#endif + public void GetChangedInputs_ReturnsEmpty_WhenUnchanged() + { + var hashes = new Dictionary { { "Input1", 123 } }; + this.manager.UpdatePendingHashes(hashes); + this.manager.CommitHashes(); + + // Same values + this.manager.UpdatePendingHashes(hashes); + + var changed = this.manager.GetChangedInputs(); + Assert.Empty(changed); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: ClearHashes removes all tracking [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: ClearHashes removes all tracking [Core]")] +#endif + public void ClearHashes_RemovesAllTracking() + { + var hashes = new Dictionary { { "Input1", 123 } }; + this.manager.UpdatePendingHashes(hashes); + this.manager.CommitHashes(); + + this.manager.ClearHashes(); + + Assert.Empty(this.manager.GetCommittedHashes()); + } + + #endregion + + #region File Restoration Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: BeginRestoration sets flags [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: BeginRestoration sets flags [Core]")] +#endif + public void BeginRestoration_SetsFlags() + { + this.manager.BeginRestoration(); + + Assert.True(this.manager.IsRestoringFromFile); + Assert.True(this.manager.IsSuppressingInputChanges); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: EndRestoration clears restoring flag but keeps suppression [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: EndRestoration clears restoring flag but keeps suppression [Core]")] +#endif + public void EndRestoration_ClearsRestoringFlag_KeepsSuppression() + { + this.manager.BeginRestoration(); + this.manager.EndRestoration(); + + Assert.False(this.manager.IsRestoringFromFile); + Assert.True(this.manager.IsSuppressingInputChanges); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: ClearSuppressionAfterFirstSolve clears suppression [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: ClearSuppressionAfterFirstSolve clears suppression [Core]")] +#endif + public void ClearSuppressionAfterFirstSolve_ClearsSuppression() + { + this.manager.BeginRestoration(); + this.manager.EndRestoration(); + this.manager.ClearSuppressionAfterFirstSolve(); + + Assert.False(this.manager.IsSuppressingInputChanges); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs returns empty during suppression [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: GetChangedInputs returns empty during suppression [Core]")] +#endif + public void GetChangedInputs_ReturnsEmpty_DuringSuppression() + { + // Setup: commit initial, then change + var initial = new Dictionary { { "Input1", 123 } }; + this.manager.UpdatePendingHashes(initial); + this.manager.CommitHashes(); + + var updated = new Dictionary { { "Input1", 456 } }; + this.manager.UpdatePendingHashes(updated); + + // Begin restoration (suppresses detection) + this.manager.BeginRestoration(); + + var changed = this.manager.GetChangedInputs(); + Assert.Empty(changed); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: RestoreCommittedHashes sets both committed and pending [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: RestoreCommittedHashes sets both committed and pending [Core]")] +#endif + public void RestoreCommittedHashes_SetsBothCommittedAndPending() + { + var hashes = new Dictionary { { "Input1", 123 } }; + var branchCounts = new Dictionary { { "Input1", 1 } }; + + this.manager.RestoreCommittedHashes(hashes, branchCounts); + + var committed = this.manager.GetCommittedHashes(); + Assert.Single(committed); + Assert.Equal(123, committed["Input1"]); + + // After restoration, no changes should be detected + var changed = this.manager.GetChangedInputs(); + Assert.Empty(changed); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Full restoration flow prevents data loss [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Full restoration flow prevents data loss [Core]")] +#endif + public void FullRestorationFlow_PreventsDataLoss() + { + // Simulate file restoration flow + this.manager.BeginRestoration(); + + // Restore hashes from file + var restoredHashes = new Dictionary { { "Input1", 100 }, { "Input2", 200 } }; + this.manager.RestoreCommittedHashes(restoredHashes, null); + + this.manager.EndRestoration(); + + // First solve: inputs may differ from restored (simulating different upstream data) + var currentHashes = new Dictionary { { "Input1", 999 }, { "Input2", 888 } }; + this.manager.UpdatePendingHashes(currentHashes); + + // GetChangedInputs should return empty due to suppression + var changed = this.manager.GetChangedInputs(); + Assert.Empty(changed); + + // Clear suppression after first solve + this.manager.ClearSuppressionAfterFirstSolve(); + + // Now changes should be detected + changed = this.manager.GetChangedInputs(); + Assert.Equal(2, changed.Count); + } + + #endregion + + #region Debounce Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: StartDebounce with zero ms transitions immediately [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: StartDebounce with zero ms transitions immediately [Core]")] +#endif + public void StartDebounce_ZeroMs_TransitionsImmediately() + { + this.manager.StartDebounce(ComponentState.NeedsRun, 0); + + Assert.Equal(ComponentState.NeedsRun, this.manager.CurrentState); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: StartDebounce fires event [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: StartDebounce fires event [Core]")] +#endif + public void StartDebounce_FiresEvent() + { + ComponentState? targetState = null; + int? milliseconds = null; + this.manager.DebounceStarted += (state, ms) => + { + targetState = state; + milliseconds = ms; + }; + + this.manager.StartDebounce(ComponentState.NeedsRun, 100); + + Assert.Equal(ComponentState.NeedsRun, targetState); + Assert.Equal(100, milliseconds); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: CancelDebounce fires event [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: CancelDebounce fires event [Core]")] +#endif + public void CancelDebounce_FiresEvent() + { + var cancelled = false; + this.manager.DebounceCancelled += () => cancelled = true; + + this.manager.StartDebounce(ComponentState.NeedsRun, 1000); + this.manager.CancelDebounce(); + + Assert.True(cancelled); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Debounce timer transitions after delay [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Debounce timer transitions after delay [Core]")] +#endif + public async Task DebounceTimer_TransitionsAfterDelay() + { + this.manager.StartDebounce(ComponentState.NeedsRun, 50); + + // State should still be Completed immediately + Assert.Equal(ComponentState.Completed, this.manager.CurrentState); + + // Wait for debounce + await Task.Delay(100); + + Assert.Equal(ComponentState.NeedsRun, this.manager.CurrentState); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: CancelDebounce prevents transition [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: CancelDebounce prevents transition [Core]")] +#endif + public async Task CancelDebounce_PreventsTransition() + { + this.manager.StartDebounce(ComponentState.NeedsRun, 100); + this.manager.CancelDebounce(); + + await Task.Delay(150); + + Assert.Equal(ComponentState.Completed, this.manager.CurrentState); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Restarting debounce invalidates previous [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Restarting debounce invalidates previous [Core]")] +#endif + public async Task RestartDebounce_InvalidatesPrevious() + { + // Start debounce to NeedsRun + this.manager.StartDebounce(ComponentState.NeedsRun, 50); + + // Immediately restart to Processing + this.manager.StartDebounce(ComponentState.Processing, 50); + + await Task.Delay(100); + + // Should be Processing, not NeedsRun + Assert.Equal(ComponentState.Processing, this.manager.CurrentState); + } + + #endregion + + #region Reset Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Reset restores initial state [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Reset restores initial state [Core]")] +#endif + public void Reset_RestoresInitialState() + { + // Setup: modify state + this.manager.ForceState(ComponentState.Processing); + this.manager.UpdatePendingHashes(new Dictionary { { "Input1", 123 } }); + this.manager.CommitHashes(); + this.manager.BeginRestoration(); + + // Reset + this.manager.Reset(); + + Assert.Equal(ComponentState.Completed, this.manager.CurrentState); + Assert.False(this.manager.IsRestoringFromFile); + Assert.False(this.manager.IsSuppressingInputChanges); + Assert.Empty(this.manager.GetCommittedHashes()); + } + + #endregion + + #region Dispose Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Dispose prevents further operations [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Dispose prevents further operations [Core]")] +#endif + public void Dispose_PreventsFurtherOperations() + { + this.manager.Dispose(); + + Assert.Throws(() => + this.manager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged)); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Double dispose is safe [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Double dispose is safe [Core]")] +#endif + public void DoubleDispose_IsSafe() + { + this.manager.Dispose(); + this.manager.Dispose(); // Should not throw + } + + #endregion + + #region Edge Case Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Null hashes are handled gracefully [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Null hashes are handled gracefully [Core]")] +#endif + public void NullHashes_HandledGracefully() + { + this.manager.UpdatePendingHashes(null); + this.manager.UpdatePendingBranchCounts(null); + this.manager.RestoreCommittedHashes(null, null); + + // Should not throw, and hashes should be empty + Assert.Empty(this.manager.GetCommittedHashes()); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: ClearPendingTransitions works during idle [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: ClearPendingTransitions works during idle [Core]")] +#endif + public void ClearPendingTransitions_WorksDuringIdle() + { + this.manager.ClearPendingTransitions(); + Assert.Equal(0, this.manager.PendingTransitionCount); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Component name is used for logging [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Component name is used for logging [Core]")] +#endif + public void ComponentName_UsedForLogging() + { + var namedManager = new ComponentStateManager("MyTestComponent"); + // This test just verifies no exception is thrown - logging is debug output + namedManager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged); + namedManager.Dispose(); + } + + #endregion + + #region Concurrent Access Tests + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Concurrent transitions are thread-safe [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Concurrent transitions are thread-safe [Core]")] +#endif + public async Task ConcurrentTransitions_AreThreadSafe() + { + var tasks = new List(); + var transitionCount = 0; + + this.manager.StateChanged += (old, newState) => Interlocked.Increment(ref transitionCount); + + // Start multiple transitions concurrently + for (int i = 0; i < 10; i++) + { + tasks.Add(Task.Run(() => + { + // Try various transitions - some will be valid, some won't + this.manager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged); + this.manager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + })); + } + + await Task.WhenAll(tasks); + + // Should have at least some successful transitions, and no exceptions + Assert.True(transitionCount >= 1); + } + +#if NET7_WINDOWS + [Fact(DisplayName = "ComponentStateManager: Concurrent hash updates are thread-safe [Windows]")] +#else + [Fact(DisplayName = "ComponentStateManager: Concurrent hash updates are thread-safe [Core]")] +#endif + public async Task ConcurrentHashUpdates_AreThreadSafe() + { + var tasks = new List(); + + for (int i = 0; i < 10; i++) + { + var index = i; + tasks.Add(Task.Run(() => + { + var hashes = new Dictionary { { $"Input{index}", index * 100 } }; + this.manager.UpdatePendingHashes(hashes); + this.manager.CommitHashes(); + _ = this.manager.GetChangedInputs(); + })); + } + + await Task.WhenAll(tasks); + + // Should complete without exceptions + Assert.NotNull(this.manager.GetCommittedHashes()); + } + + #endregion + } +} diff --git a/src/SmartHopper.Core.Tests/SmartHopper.Core.Tests.csproj b/src/SmartHopper.Core.Tests/SmartHopper.Core.Tests.csproj new file mode 100644 index 00000000..46f5a208 --- /dev/null +++ b/src/SmartHopper.Core.Tests/SmartHopper.Core.Tests.csproj @@ -0,0 +1,47 @@ + + + + + + net7.0-windows;net7.0 + false + true + NU1701;NETSDK1086 + true + + + + $(SolutionDir)bin/$(SolutionVersion)/$(Configuration)/Tests + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + true + $(DefineConstants);WINDOWS;NET7_WINDOWS + + + + + + + + + diff --git a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs index 12271cb9..b06e6e84 100644 --- a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs @@ -22,7 +22,7 @@ namespace SmartHopper.Core.ComponentBase /// Provides the provider selection context menu and related functionality on top of /// the stateful async component functionality. /// - public abstract class AIProviderComponentBase : StatefulAsyncComponentBase + public abstract class AIProviderComponentBase : StatefulComponentBaseV2 { /// /// Special value used to indicate that the default provider from settings should be used. diff --git a/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs index ea78600e..b97755d9 100644 --- a/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs @@ -286,6 +286,20 @@ protected override void AfterSolveInstance() this.Workers.Reverse(); } } + else if (t.IsCanceled) + { + Debug.WriteLine("[AsyncComponentBase] Tasks were canceled. Resetting async state and skipping output phase."); + + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Tasks were canceled."); + this.ResetAsyncState(); + this.OnTasksCanceled(); + this.ExpireSolution(true); + }); + + return; + } else { // All tasks completed successfully; set state to total workers so post-solve can decrement to zero @@ -362,6 +376,14 @@ protected virtual void OnWorkerCompleted() Debug.WriteLine($"[{this.GetType().Name}] All workers completed. State: {this._state}, Tasks: {this._tasks.Count}, SetData: {this._setData}"); } + /// + /// Called when the worker tasks are canceled and the output phase is skipped. + /// Allows derived classes to react (e.g. transition state machines out of Processing). + /// + protected virtual void OnTasksCanceled() + { + } + /// /// Override this method to implement custom solve logic. /// This will be called for pre-solve. diff --git a/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs b/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs new file mode 100644 index 00000000..272abf08 --- /dev/null +++ b/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs @@ -0,0 +1,871 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace SmartHopper.Core.ComponentBase +{ + /// + /// Specifies the reason for a state transition. + /// Used for debugging and logging purposes. + /// + public enum TransitionReason + { + /// + /// Initial state or unknown reason. + /// + Initial, + + /// + /// Input values have changed. + /// + InputChanged, + + /// + /// The Run parameter was enabled. + /// + RunEnabled, + + /// + /// The Run parameter was disabled. + /// + RunDisabled, + + /// + /// Debounce timer completed. + /// + DebounceComplete, + + /// + /// Processing has completed successfully. + /// + ProcessingComplete, + + /// + /// Processing was cancelled by user. + /// + Cancelled, + + /// + /// An error occurred during processing. + /// + Error, + + /// + /// File restoration triggered transition. + /// + FileRestoration, + } + + /// + /// Represents a request to transition to a new state. + /// + public sealed class StateTransitionRequest + { + /// + /// Gets the target state for this transition. + /// + public ComponentState TargetState { get; } + + /// + /// Gets the reason for this transition. + /// + public TransitionReason Reason { get; } + + /// + /// Gets the timestamp when this request was created. + /// + public DateTime Timestamp { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The target state. + /// The reason for transition. + public StateTransitionRequest(ComponentState targetState, TransitionReason reason) + { + this.TargetState = targetState; + this.Reason = reason; + this.Timestamp = DateTime.UtcNow; + } + } + + /// + /// Centralized state manager for stateful async components. + /// Handles state transitions, debouncing, and persistence coordination. + /// Thread-safe and designed for UI thread execution. + /// + public sealed class ComponentStateManager : IDisposable + { + #region Fields + + private readonly object stateLock = new object(); + private readonly object hashLock = new object(); + private readonly Queue pendingTransitions = new Queue(); + + private ComponentState currentState = ComponentState.Completed; + private bool isTransitioning; + private bool isDisposed; + + // Restoration state + private bool isRestoringFromFile; + private bool suppressInputChangeDetection; + + // Debounce state + private Timer debounceTimer; + private int debounceGeneration; + private ComponentState debounceTargetState; + private int debounceTimeMs; + + // Hash tracking + private Dictionary committedInputHashes = new Dictionary(); + private Dictionary pendingInputHashes = new Dictionary(); + private Dictionary committedBranchCounts = new Dictionary(); + private Dictionary pendingBranchCounts = new Dictionary(); + + // Component name for logging + private readonly string componentName; + + #endregion + + #region Events + + /// + /// Occurs when the state changes from one state to another. + /// Parameters: (oldState, newState). + /// + public event Action StateChanged; + + /// + /// Occurs when entering a new state. + /// Parameter: newState. + /// + public event Action StateEntered; + + /// + /// Occurs when exiting a state. + /// Parameter: oldState. + /// + public event Action StateExited; + + /// + /// Occurs when a debounce timer starts. + /// Parameters: (targetState, milliseconds). + /// + public event Action DebounceStarted; + + /// + /// Occurs when a debounce timer is cancelled. + /// + public event Action DebounceCancelled; + + /// + /// Occurs when a state transition request is rejected. + /// Parameters: (currentState, requestedState, reason). + /// + public event Action TransitionRejected; + + #endregion + + #region Properties + + /// + /// Gets the current component state. + /// + public ComponentState CurrentState + { + get + { + lock (this.stateLock) + { + return this.currentState; + } + } + } + + /// + /// Gets a value indicating whether a state transition is currently in progress. + /// + public bool IsTransitioning + { + get + { + lock (this.stateLock) + { + return this.isTransitioning; + } + } + } + + /// + /// Gets a value indicating whether file restoration is in progress. + /// + public bool IsRestoringFromFile + { + get + { + lock (this.stateLock) + { + return this.isRestoringFromFile; + } + } + } + + /// + /// Gets a value indicating whether input change detection is currently suppressed. + /// + public bool IsSuppressingInputChanges + { + get + { + lock (this.stateLock) + { + return this.suppressInputChangeDetection; + } + } + } + + /// + /// Gets a value indicating whether a debounce timer is currently active. + /// + public bool IsDebouncing + { + get + { + lock (this.stateLock) + { + return this.debounceGeneration > 0 && this.debounceTimeMs > 0; + } + } + } + + /// + /// Gets the number of pending transitions in the queue. + /// + public int PendingTransitionCount + { + get + { + lock (this.stateLock) + { + return this.pendingTransitions.Count; + } + } + } + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class. + /// + /// Optional name for logging purposes. + public ComponentStateManager(string componentName = null) + { + this.componentName = componentName ?? "Component"; + this.debounceTimer = new Timer(this.OnDebounceElapsed, null, Timeout.Infinite, Timeout.Infinite); + } + + #endregion + + #region State Transitions + + /// + /// Requests a state transition. Transitions are queued and processed in order. + /// + /// The target state. + /// The reason for the transition. + /// True if the transition was queued or processed; false if rejected. + public bool RequestTransition(ComponentState newState, TransitionReason reason) + { + this.ThrowIfDisposed(); + + lock (this.stateLock) + { + // Validate the transition + if (!this.IsValidTransition(this.currentState, newState)) + { + var message = $"Invalid transition from {this.currentState} to {newState}"; + Debug.WriteLine($"[{this.componentName}] {message}"); + this.TransitionRejected?.Invoke(this.currentState, newState, message); + return false; + } + + // Queue the transition + var request = new StateTransitionRequest(newState, reason); + this.pendingTransitions.Enqueue(request); + Debug.WriteLine($"[{this.componentName}] Queued transition to {newState} (reason: {reason})"); + + // Process queue if not already transitioning + if (!this.isTransitioning) + { + this.ProcessTransitionQueue(); + } + + return true; + } + } + + /// + /// Processes the transition queue, executing each transition in order. + /// + private void ProcessTransitionQueue() + { + // Already holding stateLock from caller + if (this.isTransitioning) + { + return; + } + + this.isTransitioning = true; + + try + { + while (this.pendingTransitions.Count > 0) + { + var request = this.pendingTransitions.Dequeue(); + this.ExecuteTransition(request); + } + } + finally + { + this.isTransitioning = false; + } + } + + /// + /// Executes a single state transition. + /// + /// The transition request to execute. + private void ExecuteTransition(StateTransitionRequest request) + { + // Already holding stateLock from caller + var oldState = this.currentState; + var newState = request.TargetState; + + // Skip if already in target state + if (oldState == newState) + { + Debug.WriteLine($"[{this.componentName}] Already in state {newState}, skipping transition"); + return; + } + + // Re-validate (state may have changed since queuing) + if (!this.IsValidTransition(oldState, newState)) + { + var message = $"Transition from {oldState} to {newState} no longer valid"; + Debug.WriteLine($"[{this.componentName}] {message}"); + this.TransitionRejected?.Invoke(oldState, newState, message); + return; + } + + Debug.WriteLine($"[{this.componentName}] Transitioning: {oldState} -> {newState} (reason: {request.Reason})"); + + // Fire exit event + this.StateExited?.Invoke(oldState); + + // Update state + this.currentState = newState; + + // Fire enter event + this.StateEntered?.Invoke(newState); + + // Fire changed event + this.StateChanged?.Invoke(oldState, newState); + } + + /// + /// Checks if a transition from one state to another is valid. + /// + /// The source state. + /// The target state. + /// True if the transition is valid; otherwise false. + public bool IsValidTransition(ComponentState from, ComponentState to) + { + // Same state is not a transition + if (from == to) + { + return false; + } + + // Define valid transitions based on state machine + switch (from) + { + case ComponentState.Completed: + return to == ComponentState.Waiting + || to == ComponentState.NeedsRun + || to == ComponentState.Processing + || to == ComponentState.Error; + + case ComponentState.Waiting: + return to == ComponentState.NeedsRun + || to == ComponentState.Processing + || to == ComponentState.Error; + + case ComponentState.NeedsRun: + return to == ComponentState.Processing + || to == ComponentState.Error; + + case ComponentState.Processing: + return to == ComponentState.Completed + || to == ComponentState.Cancelled + || to == ComponentState.Error; + + case ComponentState.Cancelled: + return to == ComponentState.Waiting + || to == ComponentState.NeedsRun + || to == ComponentState.Processing + || to == ComponentState.Error; + + case ComponentState.Error: + return to == ComponentState.Waiting + || to == ComponentState.NeedsRun + || to == ComponentState.Processing + || to == ComponentState.Error; + + default: + return false; + } + } + + /// + /// Forces an immediate state change without validation or queueing. + /// Use with caution - primarily for initialization and testing. + /// + /// The new state to set. + public void ForceState(ComponentState newState) + { + this.ThrowIfDisposed(); + + lock (this.stateLock) + { + var oldState = this.currentState; + if (oldState == newState) + { + return; + } + + Debug.WriteLine($"[{this.componentName}] Force state: {oldState} -> {newState}"); + + this.StateExited?.Invoke(oldState); + this.currentState = newState; + this.StateEntered?.Invoke(newState); + this.StateChanged?.Invoke(oldState, newState); + } + } + + /// + /// Clears any pending transitions from the queue. + /// + public void ClearPendingTransitions() + { + lock (this.stateLock) + { + var count = this.pendingTransitions.Count; + this.pendingTransitions.Clear(); + if (count > 0) + { + Debug.WriteLine($"[{this.componentName}] Cleared {count} pending transitions"); + } + } + } + + #endregion + + #region File Restoration + + /// + /// Marks the beginning of file restoration. Suppresses input change detection. + /// Call this at the start of Read() method. + /// + public void BeginRestoration() + { + this.ThrowIfDisposed(); + + lock (this.stateLock) + { + Debug.WriteLine($"[{this.componentName}] Begin restoration"); + this.isRestoringFromFile = true; + this.suppressInputChangeDetection = true; + + // Clear pending hashes during restoration + lock (this.hashLock) + { + this.pendingInputHashes.Clear(); + this.pendingBranchCounts.Clear(); + } + } + } + + /// + /// Marks the end of file restoration. The first solve after this will + /// skip input change detection, then normal detection resumes. + /// Call this at the end of Read() method. + /// + public void EndRestoration() + { + this.ThrowIfDisposed(); + + lock (this.stateLock) + { + Debug.WriteLine($"[{this.componentName}] End restoration (suppression still active for first solve)"); + this.isRestoringFromFile = false; + // Note: suppressInputChangeDetection stays true until ClearSuppressionAfterFirstSolve() is called + } + } + + /// + /// Clears the input change detection suppression. + /// Call this after the first successful solve following file restoration. + /// + public void ClearSuppressionAfterFirstSolve() + { + lock (this.stateLock) + { + if (this.suppressInputChangeDetection) + { + Debug.WriteLine($"[{this.componentName}] Clearing input change suppression after first solve"); + this.suppressInputChangeDetection = false; + } + } + } + + #endregion + + #region Hash Management + + /// + /// Updates pending input hashes without triggering state changes. + /// These hashes represent the current input state. + /// + /// Dictionary of input name to hash value. + public void UpdatePendingHashes(Dictionary hashes) + { + this.ThrowIfDisposed(); + + if (hashes == null) + { + return; + } + + lock (this.hashLock) + { + this.pendingInputHashes = new Dictionary(hashes); + } + } + + /// + /// Updates pending branch counts without triggering state changes. + /// + /// Dictionary of input name to branch count. + public void UpdatePendingBranchCounts(Dictionary branchCounts) + { + this.ThrowIfDisposed(); + + if (branchCounts == null) + { + return; + } + + lock (this.hashLock) + { + this.pendingBranchCounts = new Dictionary(branchCounts); + } + } + + /// + /// Commits pending hashes as the new baseline. + /// Call this after successful processing to update what "unchanged" means. + /// + public void CommitHashes() + { + this.ThrowIfDisposed(); + + lock (this.hashLock) + { + Debug.WriteLine($"[{this.componentName}] Committing {this.pendingInputHashes.Count} hashes"); + this.committedInputHashes = new Dictionary(this.pendingInputHashes); + this.committedBranchCounts = new Dictionary(this.pendingBranchCounts); + } + } + + /// + /// Restores committed hashes from persisted data. + /// Used during file restoration to set the baseline. + /// + /// The hash values to restore. + /// The branch counts to restore. + public void RestoreCommittedHashes(Dictionary hashes, Dictionary branchCounts) + { + this.ThrowIfDisposed(); + + lock (this.hashLock) + { + Debug.WriteLine($"[{this.componentName}] Restoring {hashes?.Count ?? 0} committed hashes"); + this.committedInputHashes = hashes != null + ? new Dictionary(hashes) + : new Dictionary(); + this.committedBranchCounts = branchCounts != null + ? new Dictionary(branchCounts) + : new Dictionary(); + + // Also set pending to match committed during restoration + this.pendingInputHashes = new Dictionary(this.committedInputHashes); + this.pendingBranchCounts = new Dictionary(this.committedBranchCounts); + } + } + + /// + /// Gets the list of input names that have changed since the last commit. + /// Returns empty list during restoration or when suppression is active. + /// + /// List of changed input names. + public IReadOnlyList GetChangedInputs() + { + lock (this.stateLock) + { + if (this.suppressInputChangeDetection) + { + Debug.WriteLine($"[{this.componentName}] GetChangedInputs: suppressed, returning empty"); + return Array.Empty(); + } + } + + lock (this.hashLock) + { + var changed = new List(); + + // Check for changed or new inputs + foreach (var kvp in this.pendingInputHashes) + { + if (!this.committedInputHashes.TryGetValue(kvp.Key, out var committedHash) || + committedHash != kvp.Value) + { + changed.Add(kvp.Key); + } + } + + // Check for removed inputs + foreach (var key in this.committedInputHashes.Keys) + { + if (!this.pendingInputHashes.ContainsKey(key)) + { + changed.Add(key); + } + } + + if (changed.Count > 0) + { + Debug.WriteLine($"[{this.componentName}] GetChangedInputs: {string.Join(", ", changed)}"); + } + + return changed; + } + } + + /// + /// Gets the committed input hashes. + /// Used for persistence (saving to file). + /// + /// Copy of the committed hashes dictionary. + public Dictionary GetCommittedHashes() + { + lock (this.hashLock) + { + return new Dictionary(this.committedInputHashes); + } + } + + /// + /// Gets the committed branch counts. + /// Used for persistence (saving to file). + /// + /// Copy of the committed branch counts dictionary. + public Dictionary GetCommittedBranchCounts() + { + lock (this.hashLock) + { + return new Dictionary(this.committedBranchCounts); + } + } + + /// + /// Clears all hash tracking data. + /// + public void ClearHashes() + { + lock (this.hashLock) + { + this.committedInputHashes.Clear(); + this.pendingInputHashes.Clear(); + this.committedBranchCounts.Clear(); + this.pendingBranchCounts.Clear(); + } + } + + #endregion + + #region Debounce + + /// + /// Starts or restarts the debounce timer. + /// When the timer elapses, a transition to the target state will be requested. + /// + /// The state to transition to after debounce. + /// Debounce duration in milliseconds. + public void StartDebounce(ComponentState targetState, int milliseconds) + { + this.ThrowIfDisposed(); + + if (milliseconds <= 0) + { + // No debounce, transition immediately + this.RequestTransition(targetState, TransitionReason.InputChanged); + return; + } + + lock (this.stateLock) + { + // Increment generation to invalidate any pending callbacks + this.debounceGeneration++; + this.debounceTargetState = targetState; + this.debounceTimeMs = milliseconds; + + Debug.WriteLine($"[{this.componentName}] Starting debounce: {milliseconds}ms -> {targetState} (gen: {this.debounceGeneration})"); + + // Restart timer + this.debounceTimer.Change(milliseconds, Timeout.Infinite); + + this.DebounceStarted?.Invoke(targetState, milliseconds); + } + } + + /// + /// Cancels any pending debounce timer. + /// + public void CancelDebounce() + { + lock (this.stateLock) + { + if (this.debounceTimeMs > 0) + { + Debug.WriteLine($"[{this.componentName}] Cancelling debounce"); + this.debounceTimer.Change(Timeout.Infinite, Timeout.Infinite); + this.debounceTimeMs = 0; + this.debounceGeneration++; // Invalidate any pending callbacks + + this.DebounceCancelled?.Invoke(); + } + } + } + + /// + /// Called when the debounce timer elapses. + /// + /// Timer callback state (unused). + private void OnDebounceElapsed(object state) + { + int capturedGeneration; + ComponentState targetState; + + lock (this.stateLock) + { + capturedGeneration = this.debounceGeneration; + targetState = this.debounceTargetState; + this.debounceTimeMs = 0; // Mark as elapsed + } + + // Check if this callback is still valid + lock (this.stateLock) + { + if (capturedGeneration != this.debounceGeneration) + { + Debug.WriteLine($"[{this.componentName}] Debounce callback stale (gen {capturedGeneration} != {this.debounceGeneration}), ignoring"); + return; + } + + // Validate target state is still compatible with current state + if (!this.IsValidTransition(this.currentState, targetState)) + { + Debug.WriteLine($"[{this.componentName}] Debounce target {targetState} no longer valid from {this.currentState}"); + return; + } + } + + Debug.WriteLine($"[{this.componentName}] Debounce elapsed, requesting transition to {targetState}"); + this.RequestTransition(targetState, TransitionReason.DebounceComplete); + } + + #endregion + + #region Utilities + + /// + /// Resets the state manager to initial state. + /// Clears all hashes, pending transitions, and cancels debounce. + /// + public void Reset() + { + lock (this.stateLock) + { + this.CancelDebounce(); + this.ClearPendingTransitions(); + this.isRestoringFromFile = false; + this.suppressInputChangeDetection = false; + this.currentState = ComponentState.Completed; + } + + this.ClearHashes(); + + Debug.WriteLine($"[{this.componentName}] State manager reset"); + } + + /// + /// Throws if the manager has been disposed. + /// + private void ThrowIfDisposed() + { + if (this.isDisposed) + { + throw new ObjectDisposedException(nameof(ComponentStateManager)); + } + } + + #endregion + + #region IDisposable + + /// + /// Disposes the state manager, releasing the debounce timer. + /// + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + lock (this.stateLock) + { + this.isDisposed = true; + this.debounceTimer?.Dispose(); + this.debounceTimer = null; + this.pendingTransitions.Clear(); + } + } + + #endregion + } +} diff --git a/src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs b/src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs new file mode 100644 index 00000000..3d868b70 --- /dev/null +++ b/src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs @@ -0,0 +1,1526 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +/* + * Portions of this code adapted from: + * https://github.com/specklesystems/GrasshopperAsyncComponent + * Apache License 2.0 + * Copyright (c) 2021 Speckle Systems + */ + +/* + * V2 implementation of StatefulAsyncComponentBase using ComponentStateManager. + * This class delegates state management to ComponentStateManager for cleaner + * state transitions, proper file restoration handling, and generation-based + * debounce to prevent stale callbacks. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +#if DEBUG +using System.Windows.Forms; +#endif +using GH_IO.Serialization; +using Grasshopper.Kernel; +using Grasshopper.Kernel.Data; +using Grasshopper.Kernel.Types; +using SmartHopper.Core.DataTree; +using SmartHopper.Core.IO; +using SmartHopper.Infrastructure.Settings; + +namespace SmartHopper.Core.ComponentBase +{ + /// + /// V2 implementation of stateful async component base using ComponentStateManager. + /// Provides integrated state management, parallel processing, messaging, and persistence. + /// + public abstract class StatefulComponentBaseV2 : AsyncComponentBase + { + #region Fields + + /// + /// The centralized state manager handling all state transitions, debouncing, and hash tracking. + /// + protected readonly ComponentStateManager StateManager; + + /// + /// Storage for persistent output values that survive state transitions. + /// + protected readonly Dictionary persistentOutputs; + + /// + /// Storage for persistent output type information. + /// + private readonly Dictionary persistentDataTypes; + + /// + /// Runtime messages that persist across state transitions. + /// + private readonly Dictionary runtimeMessages; + + /// + /// The last data access object, used for state transitions. + /// + private IGH_DataAccess lastDA; + + /// + /// The current Run parameter value. + /// + private bool run; + + #endregion + + #region Properties + + /// + /// Gets or sets a value indicating whether the component should only run when inputs change. + /// If true (default), the component will only run when inputs have changed and Run is true. + /// If false, the component will run whenever the Run parameter is set to true. + /// + public bool RunOnlyOnInputChanges { get; set; } = true; + + /// + /// Gets the progress information for tracking processing operations. + /// + protected ProgressInfo ProgressInfo { get; private set; } = new ProgressInfo(); + + /// + /// Gets a value indicating whether the base class should automatically restore persistent outputs + /// during Completed/Waiting states. Default is true for backward compatibility. + /// + protected virtual bool AutoRestorePersistentOutputs => true; + + /// + /// Gets the default processing options used for data tree processing. + /// + protected virtual ProcessingOptions ComponentProcessingOptions => new ProcessingOptions + { + Topology = ProcessingTopology.ItemToItem, + OnlyMatchingPaths = false, + GroupIdenticalBranches = true, + }; + + /// + /// Gets a value indicating whether the component is requested to run for the current solve. + /// + public bool Run => this.run; + + /// + /// Gets the current state of the component. + /// + public ComponentState CurrentState => this.StateManager.CurrentState; + + #endregion + + #region Constructor + + /// + /// Initializes a new instance of the class. + /// + /// The component's display name. + /// The component's nickname. + /// Description of the component's functionality. + /// Category in the Grasshopper toolbar. + /// Subcategory in the Grasshopper toolbar. + protected StatefulComponentBaseV2( + string name, + string nickname, + string description, + string category, + string subCategory) + : base(name, nickname, description, category, subCategory) + { + this.persistentOutputs = new Dictionary(); + this.persistentDataTypes = new Dictionary(); + this.runtimeMessages = new Dictionary(); + + // Initialize the centralized state manager + this.StateManager = new ComponentStateManager(this.GetType().Name); + + // Subscribe to state manager events + this.StateManager.StateChanged += this.OnStateManagerStateChanged; + this.StateManager.StateEntered += this.OnStateManagerStateEntered; + } + + #endregion + + #region State Manager Event Handlers + + /// + /// Handles state changes from the ComponentStateManager. + /// + /// The previous state. + /// The new state. + private void OnStateManagerStateChanged(ComponentState oldState, ComponentState newState) + { + Debug.WriteLine($"[{this.GetType().Name}] StateManager: {oldState} -> {newState}"); + + // Update component message + this.Message = this.GetStateMessage(); + + // Clear messages when entering NeedsRun or Processing from a different state + if ((newState == ComponentState.NeedsRun || newState == ComponentState.Processing) && + oldState != ComponentState.NeedsRun && oldState != ComponentState.Processing) + { + this.ClearPersistentRuntimeMessages(); + } + + // Handle specific state transitions + switch (newState) + { + case ComponentState.Processing: + if (oldState != ComponentState.Processing) + { + Debug.WriteLine($"[{this.GetType().Name}] Resetting async state for fresh Processing transition"); + this.ResetAsyncState(); + this.ResetProgress(); + + // Safety net for boolean toggle scenarios + this.ScheduleProcessingSafetyCheck(); + } + + break; + + case ComponentState.NeedsRun: + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.OnDisplayExpired(true); + }); + break; + + case ComponentState.Cancelled: + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.OnDisplayExpired(true); + }); + break; + } + + // Expire solution to trigger a new solve cycle + if (newState != oldState) + { + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.ExpireSolution(true); + }); + } + } + + /// + /// Handles entering a new state from the ComponentStateManager. + /// + /// The state being entered. + private void OnStateManagerStateEntered(ComponentState newState) + { + this.Message = this.GetStateMessage(); + } + + /// + /// Schedules a safety check for Processing state to handle boolean toggle edge cases. + /// + private void ScheduleProcessingSafetyCheck() + { + _ = Task.Run(async () => + { + await Task.Delay(this.GetDebounceTime()); + + if (this.StateManager.CurrentState == ComponentState.Processing && this.Workers.Count == 0) + { + Debug.WriteLine($"[{this.GetType().Name}] Processing state without workers after delay, forcing ExpireSolution"); + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.ExpireSolution(true); + }); + } + }); + } + + #endregion + + #region Parameter Registration + + /// + /// Registers input parameters for the component. + /// + /// The input parameter manager. + protected override void RegisterInputParams(GH_Component.GH_InputParamManager pManager) + { + this.RegisterAdditionalInputParams(pManager); + pManager.AddBooleanParameter("Run?", "R", "Set this parameter to true to run the component.", GH_ParamAccess.item, false); + } + + /// + /// Register component-specific input parameters. + /// + /// The input parameter manager. + protected abstract void RegisterAdditionalInputParams(GH_InputParamManager pManager); + + /// + /// Registers output parameters for the component. + /// + /// The output parameter manager. + protected override void RegisterOutputParams(GH_Component.GH_OutputParamManager pManager) + { + this.RegisterAdditionalOutputParams(pManager); + } + + /// + /// Register component-specific output parameters. + /// + /// The output parameter manager. + protected abstract void RegisterAdditionalOutputParams(GH_OutputParamManager pManager); + + #endregion + + #region Lifecycle + + /// + /// Performs pre-solve initialization and guards against unintended resets during processing. + /// + protected override void BeforeSolveInstance() + { + if (this.StateManager.CurrentState == ComponentState.Processing) + { + Debug.WriteLine("[StatefulComponentBaseV2] Processing state... jumping to SolveInstance"); + return; + } + + base.BeforeSolveInstance(); + } + + /// + /// Main solving method for the component. + /// + /// The data access object. + protected override void SolveInstance(IGH_DataAccess DA) + { + this.lastDA = DA; + + // Handle first solve after restoration - clear suppression + if (this.StateManager.IsSuppressingInputChanges) + { + Debug.WriteLine($"[{this.GetType().Name}] First solve after restoration, clearing suppression"); + this.StateManager.ClearSuppressionAfterFirstSolve(); + + // If we have persistent outputs, stay in Completed state + if (this.persistentOutputs.Count > 0) + { + this.OnStateCompleted(DA); + return; + } + else + { + // No outputs restored, transition to NeedsRun + this.StateManager.RequestTransition(ComponentState.NeedsRun, TransitionReason.FileRestoration); + return; + } + } + + // Read Run parameter + bool run = false; + DA.GetData("Run?", ref run); + this.run = run; + + Debug.WriteLine($"[{this.GetType().Name}] SolveInstance - State: {this.StateManager.CurrentState}, InPreSolve: {this.InPreSolve}, Run: {this.run}"); + + // Calculate current input hashes + this.UpdatePendingInputHashes(); + + // Execute state handler + switch (this.StateManager.CurrentState) + { + case ComponentState.Completed: + this.OnStateCompleted(DA); + break; + case ComponentState.Waiting: + this.OnStateWaiting(DA); + break; + case ComponentState.NeedsRun: + this.OnStateNeedsRun(DA); + break; + case ComponentState.Processing: + this.OnStateProcessing(DA); + break; + case ComponentState.Cancelled: + this.OnStateCancelled(DA); + break; + case ComponentState.Error: + this.OnStateError(DA); + break; + } + + // Handle input change detection after state handlers + this.HandleInputChangeDetection(DA); + } + + /// + /// Updates the pending input hashes in the StateManager. + /// + private void UpdatePendingInputHashes() + { + var hashes = new Dictionary(); + var branchCounts = new Dictionary(); + + for (int i = 0; i < this.Params.Input.Count; i++) + { + var param = this.Params.Input[i]; + int branchCount; + int hash = CalculatePersistentDataHash(param, out branchCount); + hashes[param.Name] = hash; + branchCounts[param.Name] = branchCount; + } + + this.StateManager.UpdatePendingHashes(hashes); + this.StateManager.UpdatePendingBranchCounts(branchCounts); + } + + /// + /// Handles input change detection and debounce logic. + /// + /// The data access object. + private void HandleInputChangeDetection(IGH_DataAccess DA) + { + var currentState = this.StateManager.CurrentState; + + // Only check for input changes in idle states + if (currentState != ComponentState.Completed && + currentState != ComponentState.Waiting && + currentState != ComponentState.Cancelled && + currentState != ComponentState.Error) + { + return; + } + + // Get changed inputs from StateManager + var changedInputs = this.StateManager.GetChangedInputs(); + + // If only Run parameter changed to false, stay in current state + if (changedInputs.Count == 1 && changedInputs[0] == "Run?" && !this.run) + { + Debug.WriteLine($"[{this.GetType().Name}] Only Run changed to false, staying in current state"); + return; + } + + // If only Run parameter changed to true + if (changedInputs.Count == 1 && changedInputs[0] == "Run?" && this.run) + { + Debug.WriteLine($"[{this.GetType().Name}] Only Run changed to true"); + + if (this.RunOnlyOnInputChanges) + { + // Default behavior - transition to Waiting + this.StateManager.RequestTransition(ComponentState.Waiting, TransitionReason.RunEnabled); + } + else + { + // Always run when Run is true + this.StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + } + + return; + } + + // If other inputs changed + if (changedInputs.Count > 0) + { + if (!this.run) + { + Debug.WriteLine($"[{this.GetType().Name}] Inputs changed, starting debounce to NeedsRun"); + this.StateManager.StartDebounce(ComponentState.NeedsRun, this.GetDebounceTime()); + } + else + { + Debug.WriteLine($"[{this.GetType().Name}] Inputs changed with Run=true, starting debounce to Processing"); + this.StateManager.StartDebounce(ComponentState.Processing, this.GetDebounceTime()); + } + } + } + + /// + /// Finalizes processing by committing hashes and transitioning to Completed. + /// + protected override void OnWorkerCompleted() + { + // Commit current hashes as the new baseline + this.StateManager.CommitHashes(); + + // Cancel any pending debounce + this.StateManager.CancelDebounce(); + + // Transition to Completed + this.StateManager.RequestTransition(ComponentState.Completed, TransitionReason.ProcessingComplete); + + base.OnWorkerCompleted(); + Debug.WriteLine("[StatefulComponentBaseV2] Worker completed, expiring solution"); + this.ExpireSolution(true); + } + + /// + /// Ensures the state machine does not remain stuck in Processing when the underlying tasks are canceled. + /// + protected override void OnTasksCanceled() + { + if (this.StateManager.CurrentState == ComponentState.Processing) + { + this.StateManager.RequestTransition(ComponentState.Cancelled, TransitionReason.Cancelled); + } + } + + #endregion + + #region State Handlers + + /// + /// Handles the Completed state. + /// + /// The data access object. + private void OnStateCompleted(IGH_DataAccess DA) + { + Debug.WriteLine($"[{this.GetType().Name}] OnStateCompleted"); + + this.Message = ComponentState.Completed.ToMessageString(); + this.ApplyPersistentRuntimeMessages(); + + if (this.AutoRestorePersistentOutputs) + { + this.RestorePersistentOutputs(DA); + } + } + + /// + /// Handles the Waiting state. + /// + /// The data access object. + private void OnStateWaiting(IGH_DataAccess DA) + { + Debug.WriteLine($"[{this.GetType().Name}] OnStateWaiting"); + + this.ApplyPersistentRuntimeMessages(); + + if (this.AutoRestorePersistentOutputs) + { + this.RestorePersistentOutputs(DA); + } + } + + /// + /// Handles the NeedsRun state. + /// + /// The data access object. + private void OnStateNeedsRun(IGH_DataAccess DA) + { + Debug.WriteLine($"[{this.GetType().Name}] OnStateNeedsRun"); + + bool run = false; + DA.GetData("Run?", ref run); + + if (run) + { + this.ClearOnePersistentRuntimeMessage("needs_run"); + this.StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + } + else + { + this.SetPersistentRuntimeMessage("needs_run", GH_RuntimeMessageLevel.Warning, "The component needs to recalculate. Set Run to true!", false); + this.ClearDataOnly(); + } + } + + /// + /// Handles the Processing state. + /// + /// The data access object. + private void OnStateProcessing(IGH_DataAccess DA) + { + Debug.WriteLine($"[{this.GetType().Name}] OnStateProcessing"); + + // Delegate to AsyncComponentBase + base.SolveInstance(DA); + } + + /// + /// Handles the Cancelled state. + /// + /// The data access object. + private void OnStateCancelled(IGH_DataAccess DA) + { + Debug.WriteLine($"[{this.GetType().Name}] OnStateCancelled"); + + this.ApplyPersistentRuntimeMessages(); + this.SetPersistentRuntimeMessage("cancelled", GH_RuntimeMessageLevel.Error, "The execution was manually cancelled", false); + + bool run = false; + DA.GetData("Run?", ref run); + + // Check for changes using StateManager + var changedInputs = this.StateManager.GetChangedInputs(); + + // If Run changed to true and no other inputs changed, transition to Processing + if (changedInputs.Count == 1 && changedInputs[0] == "Run?" && run) + { + this.StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + } + } + + /// + /// Handles the Error state. + /// + /// The data access object. + private void OnStateError(IGH_DataAccess DA) + { + Debug.WriteLine($"[{this.GetType().Name}] OnStateError"); + this.ApplyPersistentRuntimeMessages(); + } + + #endregion + + #region Debounce + + /// + /// Minimum debounce time in milliseconds. + /// + private const int MINDEBOUNCETIME = 1000; + + /// + /// Gets the debounce time from settings. + /// + /// The debounce time in milliseconds. + protected virtual int GetDebounceTime() + { + var settingsDebounceTime = SmartHopperSettings.Load().DebounceTime; + return Math.Max(settingsDebounceTime, MINDEBOUNCETIME); + } + + /// + /// Restarts the debounce timer with the default target state. + /// + protected void RestartDebounceTimer() + { + this.StateManager.StartDebounce(ComponentState.NeedsRun, this.GetDebounceTime()); + } + + /// + /// Restarts the debounce timer with a specific target state. + /// + /// The state to transition to after debounce. + protected void RestartDebounceTimer(ComponentState targetState) + { + this.StateManager.StartDebounce(targetState, this.GetDebounceTime()); + } + + #endregion + + #region Runtime Messages + + /// + /// Adds or updates a runtime message and optionally transitions to Error state. + /// + /// Unique identifier for the message. + /// The message severity level. + /// The message content. + /// If true and level is Error, transitions to Error state. + protected void SetPersistentRuntimeMessage(string key, GH_RuntimeMessageLevel level, string message, bool transitionToError = true) + { + Debug.WriteLine($"[{this.GetType().Name}] [PersistentMessage] key='{key}', level={level}, message='{message}'"); + this.runtimeMessages[key] = (level, message); + + if (transitionToError && level == GH_RuntimeMessageLevel.Error) + { + this.StateManager.RequestTransition(ComponentState.Error, TransitionReason.Error); + } + else + { + this.ApplyPersistentRuntimeMessages(); + } + } + + /// + /// Clears a specific runtime message by its key. + /// + /// The unique identifier of the message to clear. + /// True if the message was found and cleared. + protected bool ClearOnePersistentRuntimeMessage(string key) + { + var removed = this.runtimeMessages.Remove(key); + if (removed) + { + this.ClearRuntimeMessages(); + this.ApplyPersistentRuntimeMessages(); + } + + return removed; + } + + /// + /// Clears all runtime messages. + /// + protected void ClearPersistentRuntimeMessages() + { + this.runtimeMessages.Clear(); + this.ClearRuntimeMessages(); + } + + /// + /// Applies stored runtime messages to the component. + /// + private void ApplyPersistentRuntimeMessages() + { + Debug.WriteLine($"[{this.GetType().Name}] Applying {this.runtimeMessages.Count} runtime messages"); + foreach (var (level, message) in this.runtimeMessages.Values) + { + this.AddRuntimeMessage(level, message); + } + } + + #endregion + + #region Progress Tracking + + /// + /// Initializes progress tracking with the specified total count. + /// + /// The total number of items to process. + protected virtual void InitializeProgress(int total) + { + this.ProgressInfo.Total = total; + this.ProgressInfo.Current = 1; + } + + /// + /// Updates the current progress and triggers a UI refresh. + /// + /// The current item being processed. + protected virtual void UpdateProgress(int current) + { + this.ProgressInfo.UpdateCurrent(current); + this.Message = this.GetStateMessage(); + + Rhino.RhinoApp.InvokeOnUiThread(() => + { + this.OnDisplayExpired(false); + }); + } + + /// + /// Resets progress tracking. + /// + protected virtual void ResetProgress() + { + this.ProgressInfo.Reset(); + } + + /// + /// Gets the current state message with progress information. + /// + /// A formatted state message string. + public virtual string GetStateMessage() + { + return this.StateManager.CurrentState.ToMessageString(this.ProgressInfo); + } + + /// + /// Runs data-tree processing using the unified runner. + /// + protected async Task>> RunProcessingAsync( + Dictionary> trees, + Func>, Task>>> function, + DataTree.ProcessingOptions options, + CancellationToken token = default) + where T : IGH_Goo + where U : IGH_Goo + { + var (dataCount, iterationCount) = DataTree.DataTreeProcessor.CalculateProcessingMetrics(trees, options); + + this.SetDataCount(dataCount); + this.InitializeProgress(iterationCount); + + var result = await DataTree.DataTreeProcessor.RunAsync( + trees, + function, + options, + progressCallback: (current, total) => + { + this.UpdateProgress(current); + }, + token).ConfigureAwait(false); + + return result; + } + + #endregion + + #region Persistence + + /// + /// Writes the component's persistent data to the Grasshopper file. + /// + /// The writer to use for serialization. + /// True if successful. + public override bool Write(GH_IWriter writer) + { + if (!base.Write(writer)) + { + return false; + } + + try + { + // Store input hashes from StateManager + var hashes = this.StateManager.GetCommittedHashes(); + foreach (var kvp in hashes) + { + writer.SetInt32($"InputHash_{kvp.Key}", kvp.Value); + } + + var branchCounts = this.StateManager.GetCommittedBranchCounts(); + foreach (var kvp in branchCounts) + { + writer.SetInt32($"InputBranchCount_{kvp.Key}", kvp.Value); + } + + // Build GUID-keyed structure dictionary for v2 persistence + var outputsByGuid = new Dictionary>(); + foreach (var p in this.Params.Output) + { + if (!this.persistentOutputs.TryGetValue(p.Name, out var value)) + { + continue; + } + + if (value is IGH_Structure structure) + { + var tree = ConvertToGooTree(structure); + outputsByGuid[p.InstanceGuid] = tree; + } + else if (p.Access == GH_ParamAccess.list && value is System.Collections.IEnumerable enumerable && value is not string) + { + var tree = new GH_Structure(); + var path = new GH_Path(0, 0); + foreach (var item in enumerable) + { + if (item == null) + { + continue; + } + + var goo = item as IGH_Goo ?? GH_Convert.ToGoo(item) ?? new GH_String(item.ToString()); + tree.Append(goo, path); + } + + outputsByGuid[p.InstanceGuid] = tree; + } + else + { + var tree = new GH_Structure(); + var path = new GH_Path(0, 0); + var goo = value as IGH_Goo ?? GH_Convert.ToGoo(value) ?? new GH_String(value?.ToString() ?? string.Empty); + tree.Append(goo, path); + outputsByGuid[p.InstanceGuid] = tree; + } + } + + var persistence = new GHPersistenceService(); + persistence.WriteOutputsV2(writer, this, outputsByGuid); + + return true; + } + catch + { + return false; + } + } + + /// + /// Reads the component's persistent data from the Grasshopper file. + /// + /// The reader to use for deserialization. + /// True if successful. + public override bool Read(GH_IReader reader) + { + if (!base.Read(reader)) + { + return false; + } + + // Begin restoration - suppresses input change detection + this.StateManager.BeginRestoration(); + + try + { + // Restore input hashes + var hashes = new Dictionary(); + var branchCounts = new Dictionary(); + + foreach (var item in reader.Items) + { + var key = item.Name; + if (key.StartsWith("InputHash_")) + { + string paramName = key.Substring("InputHash_".Length); + hashes[paramName] = reader.GetInt32(key); + } + else if (key.StartsWith("InputBranchCount_")) + { + string paramName = key.Substring("InputBranchCount_".Length); + branchCounts[paramName] = reader.GetInt32(key); + } + } + + // Restore hashes to StateManager + this.StateManager.RestoreCommittedHashes(hashes, branchCounts); + + // Clear previous outputs + this.persistentOutputs.Clear(); + + // Try safe V2 restore + var persistence = new GHPersistenceService(); + var v2Outputs = persistence.ReadOutputsV2(reader, this); + if (v2Outputs != null && v2Outputs.Count > 0) + { + foreach (var p in this.Params.Output) + { + if (v2Outputs.TryGetValue(p.InstanceGuid, out var tree)) + { + this.persistentOutputs[p.Name] = tree; + Debug.WriteLine($"[StatefulComponentBaseV2] [Read] Restored output '{p.Name}' paths={tree.PathCount}"); + } + } + } + else if (PersistenceConstants.EnableLegacyRestore) + { + // Legacy fallback + this.RestoreLegacyOutputs(reader); + } + + Debug.WriteLine($"[StatefulComponentBaseV2] [Read] Restored with {this.persistentOutputs.Count} outputs"); + + return true; + } + finally + { + // End restoration - suppression stays active for first solve + this.StateManager.EndRestoration(); + } + } + + /// + /// Restores legacy output format from older files. + /// + /// The reader. + private void RestoreLegacyOutputs(GH_IReader reader) + { + foreach (var item in reader.Items) + { + string key = item.Name; + if (!key.StartsWith("Value_")) + { + continue; + } + + string paramName = key.Substring("Value_".Length); + + try + { + string typeName = reader.GetString($"Type_{paramName}"); + if (string.IsNullOrWhiteSpace(typeName)) + { + continue; + } + + Type type = Type.GetType(typeName); + if (type == null) + { + continue; + } + + byte[] chunkBytes = reader.GetByteArray($"Value_{paramName}"); + if (chunkBytes == null || chunkBytes.Length == 0) + { + continue; + } + + var chunk = new GH_LooseChunk($"Value_{paramName}"); + chunk.Deserialize_Binary(chunkBytes); + + var instance = Activator.CreateInstance(type); + var readMethod = type.GetMethod("Read"); + if (instance == null || readMethod == null) + { + continue; + } + + readMethod.Invoke(instance, new object[] { chunk }); + this.persistentOutputs[paramName] = instance; + } + catch (Exception ex) + { + Debug.WriteLine($"[StatefulComponentBaseV2] [Read] Legacy restore failed for '{paramName}': {ex.Message}"); + } + } + } + + /// + /// Restores all persistent outputs to their respective parameters. + /// + /// The data access object. + protected virtual void RestorePersistentOutputs(IGH_DataAccess DA) + { + Debug.WriteLine("[StatefulComponentBaseV2] Restoring persistent outputs"); + + for (int i = 0; i < this.Params.Output.Count; i++) + { + var param = this.Params.Output[i]; + var savedValue = this.GetPersistentOutput(param.Name); + if (savedValue == null) + { + continue; + } + + try + { + this.RestoreOutputParameter(param, savedValue, DA, i); + } + catch (Exception ex) + { + Debug.WriteLine($"[StatefulComponentBaseV2] Failed to restore '{param.Name}': {ex.Message}"); + } + } + } + + /// + /// Restores a single output parameter value. + /// + private void RestoreOutputParameter(IGH_Param param, object savedValue, IGH_DataAccess DA, int paramIndex) + { + if (savedValue is IGH_Structure structure) + { + if (param.Access == GH_ParamAccess.tree) + { + this.SetPersistentOutput(param.Name, structure, DA); + } + else if (param.Access == GH_ParamAccess.list) + { + this.SetPersistentOutput(param.Name, structure, DA); + } + else + { + IGH_Goo first = null; + foreach (var path in structure.Paths) + { + var branch = structure.get_Branch(path); + if (branch != null && branch.Count > 0) + { + first = branch[0] as IGH_Goo ?? GH_Convert.ToGoo(branch[0]); + break; + } + } + + if (first != null) + { + this.SetPersistentOutput(param.Name, first, DA); + } + } + } + else if (param.Access == GH_ParamAccess.list && savedValue is System.Collections.IEnumerable enumerable && savedValue is not string) + { + this.SetPersistentOutput(param.Name, enumerable, DA); + } + else + { + IGH_Goo gooValue; + if (savedValue is IGH_Goo existingGoo) + { + gooValue = existingGoo; + } + else + { + var gooType = param.Type; + gooValue = GH_Convert.ToGoo(savedValue); + if (gooValue == null) + { + gooValue = (IGH_Goo)Activator.CreateInstance(gooType); + gooValue.CastFrom(savedValue); + } + } + + this.SetPersistentOutput(param.Name, gooValue, DA); + } + } + + /// + /// Stores a value in persistent storage and sets the output. + /// + /// Name of the parameter. + /// Value to store. + /// The data access object. + protected void SetPersistentOutput(string paramName, object value, IGH_DataAccess DA) + { + try + { + var param = this.Params.Output.FirstOrDefault(p => p.Name == paramName); + var paramIndex = this.Params.Output.IndexOf(param); + if (param == null) + { + return; + } + + // Extract inner value if wrapped + value = ExtractGHObjectWrapperValue(value); + + // Store in persistent storage + this.persistentOutputs[paramName] = value; + + if (value != null) + { + this.persistentDataTypes[paramName] = value.GetType(); + } + else + { + this.persistentDataTypes.Remove(paramName); + } + + // Set the data through DA + if (DA != null) + { + this.SetOutputData(param, paramIndex, value, DA); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[StatefulComponentBaseV2] Failed to set output '{paramName}': {ex.Message}"); + } + } + + /// + /// Sets output data based on parameter access type. + /// + private void SetOutputData(IGH_Param param, int paramIndex, object value, IGH_DataAccess DA) + { + if (param.Access == GH_ParamAccess.tree) + { + if (value is IGH_Structure tree) + { + bool hasItems = tree.PathCount > 0 && tree.Paths.Any(p => + { + var branch = tree.get_Branch(p); + return branch != null && branch.Count > 0; + }); + + if (hasItems) + { + DA.SetDataTree(paramIndex, tree); + } + } + else + { + var newTree = new GH_Structure(); + if (!(value is IGH_Goo)) + { + value = GH_Convert.ToGoo(value); + } + + if (value is IGH_Goo goo) + { + newTree.Append(goo, new GH_Path(0)); + DA.SetDataTree(paramIndex, newTree); + } + } + } + else if (param.Access == GH_ParamAccess.list) + { + if (value is IGH_Structure structValue) + { + var list = new List(); + foreach (var path in structValue.Paths) + { + var branch = structValue.get_Branch(path); + if (branch == null) + { + continue; + } + + foreach (var item in branch) + { + if (item == null) + { + continue; + } + + var gooItem = item as IGH_Goo ?? GH_Convert.ToGoo(item); + if (gooItem != null) + { + list.Add(gooItem); + } + } + } + + DA.SetDataList(paramIndex, list); + } + else if (value is System.Collections.IEnumerable enumerable && !(value is string)) + { + var list = new List(); + foreach (var item in enumerable) + { + if (item == null) + { + continue; + } + + var gooItem = item as IGH_Goo ?? GH_Convert.ToGoo(item); + if (gooItem != null) + { + list.Add(gooItem); + } + } + + DA.SetDataList(paramIndex, list); + } + else + { + var single = value as IGH_Goo ?? GH_Convert.ToGoo(value); + if (single != null) + { + DA.SetDataList(paramIndex, new List { single }); + } + } + } + else + { + if (!(value is IGH_Goo)) + { + value = GH_Convert.ToGoo(value); + } + + DA.SetData(paramIndex, value); + } + } + + /// + /// Retrieves a value from persistent storage. + /// + protected T GetPersistentOutput(string paramName, T defaultValue = default) + { + if (this.persistentOutputs.TryGetValue(paramName, out object value) && value is T typedValue) + { + return typedValue; + } + + return defaultValue; + } + + /// + /// Extracts inner value from GH_ObjectWrapper. + /// + private static object ExtractGHObjectWrapperValue(object value) + { + if (value?.GetType()?.FullName == "Grasshopper.Kernel.Types.GH_ObjectWrapper") + { + var valueProperty = value.GetType().GetProperty("Value"); + if (valueProperty != null) + { + return valueProperty.GetValue(value); + } + } + + return value; + } + + /// + /// Converts an IGH_Structure to GH_Structure of IGH_Goo. + /// + private static GH_Structure ConvertToGooTree(IGH_Structure src) + { + var dst = new GH_Structure(); + if (src == null) + { + return dst; + } + + foreach (var path in src.Paths) + { + var branch = src.get_Branch(path); + if (branch == null) + { + dst.EnsurePath(path); + continue; + } + + foreach (var item in branch) + { + IGH_Goo goo = item as IGH_Goo; + if (goo == null) + { + goo = GH_Convert.ToGoo(item); + if (goo == null) + { + goo = new GH_String(item?.ToString() ?? string.Empty); + } + } + + dst.Append(goo, path); + } + } + + return dst; + } + + #endregion + + #region Hash Calculation + + /// + /// Calculates the hash for a single input parameter's data. + /// + private static int CalculatePersistentDataHash(IGH_Param param, out int branchCount) + { + var data = param.VolatileData; + int currentHash = 0; + branchCount = data.PathCount; + + foreach (var branch in data.Paths) + { + int branchHash = StableStringHash(branch.ToString()); + foreach (var item in data.get_Branch(branch)) + { + branchHash = CombineHashCodes(branchHash, StableVolatileItemHash(item)); + } + + currentHash = CombineHashCodes(currentHash, branchHash); + } + + return currentHash; + } + + /// + /// Combines two hash codes. + /// + private static int CombineHashCodes(int h1, int h2) + { + unchecked + { + return ((h1 << 5) + h1) ^ h2; + } + } + + /// + /// Computes a deterministic 32-bit hash for a string (FNV-1a), suitable for persisted change tracking. + /// + private static int StableStringHash(string value) + { + if (value == null) + { + return 0; + } + + unchecked + { + const int offsetBasis = unchecked((int)2166136261); + const int prime = 16777619; + + int hash = offsetBasis; + for (int i = 0; i < value.Length; i++) + { + hash ^= value[i]; + hash *= prime; + } + + return hash; + } + } + + /// + /// Computes a deterministic hash for a single volatile data item. + /// Avoids using object.GetHashCode(), which is not stable across sessions for many types. + /// + private static int StableVolatileItemHash(object item) + { + if (item == null) + { + return 0; + } + + if (item is IGH_Goo goo) + { + object scriptValue = null; + try + { + if (goo.IsValid) + { + scriptValue = goo.ScriptVariable(); + } + } + catch + { + scriptValue = null; + } + + int typeHash = StableStringHash(goo.GetType().FullName); + int valueHash = StableVolatileValueHash(scriptValue ?? goo.ToString()); + return CombineHashCodes(typeHash, valueHash); + } + + return StableVolatileValueHash(item); + } + + /// + /// Computes a deterministic hash for common primitive/script values. + /// + private static int StableVolatileValueHash(object value) + { + if (value == null) + { + return 0; + } + + unchecked + { + switch (value) + { + case int i: + return i; + case long l: + return CombineHashCodes((int)l, (int)(l >> 32)); + case bool b: + return b ? 1 : 0; + case double d: + { + long bits = BitConverter.DoubleToInt64Bits(d); + return CombineHashCodes((int)bits, (int)(bits >> 32)); + } + case float f: + { + int bits = BitConverter.SingleToInt32Bits(f); + return bits; + } + case string s: + return StableStringHash(s); + default: + return StableStringHash(value.ToString()); + } + } + } + + /// + /// Determines which inputs have changed since the last successful run. + /// Maintained for API compatibility - delegates to StateManager. + /// + protected virtual List InputsChanged() + { + return this.StateManager.GetChangedInputs().ToList(); + } + + /// + /// Checks if a specific input has changed. + /// + protected bool InputsChanged(string inputName, bool exclusively = true) + { + var changedInputs = this.InputsChanged(); + + if (exclusively) + { + return changedInputs.Count == 1 && changedInputs.Any(name => name == inputName); + } + else + { + return changedInputs.Any(name => name == inputName); + } + } + + /// + /// Checks if any of the specified inputs have changed. + /// + protected bool InputsChanged(IEnumerable inputNames, bool exclusively = true) + { + var changedInputs = this.InputsChanged(); + var inputNamesList = inputNames.ToList(); + + if (exclusively) + { + return changedInputs.Count > 0 && !changedInputs.Except(inputNamesList).Any(); + } + else + { + return changedInputs.Any(changed => inputNamesList.Contains(changed)); + } + } + + #endregion + + #region Utilities + + /// + /// Clears persistent storage and output parameters. + /// + protected override void ClearDataOnly() + { + this.persistentOutputs.Clear(); + base.ClearDataOnly(); + } + + /// + /// Expires downstream objects when appropriate. + /// + protected override void ExpireDownStreamObjects() + { + var currentState = this.StateManager.CurrentState; + bool allowDuringProcessing = currentState == ComponentState.Processing + && !this.InPreSolve + && (this.SetData == 1 || (this.persistentOutputs != null && this.persistentOutputs.Count > 0)); + + if (currentState == ComponentState.Completed || allowDuringProcessing) + { + base.ExpireDownStreamObjects(); + } + } + + /// + /// Requests cancellation and transitions to Cancelled state. + /// + public override void RequestTaskCancellation() + { + base.RequestTaskCancellation(); + this.StateManager.RequestTransition(ComponentState.Cancelled, TransitionReason.Cancelled); + } + +#if DEBUG + /// + /// Appends debug menu items. + /// + public override void AppendAdditionalMenuItems(ToolStripDropDown menu) + { + base.AppendAdditionalMenuItems(menu); + Menu_AppendSeparator(menu); + Menu_AppendItem(menu, $"Debug: State = {this.StateManager.CurrentState}", null); + Menu_AppendItem(menu, "Debug: Force Completed", (s, e) => + { + this.StateManager.ForceState(ComponentState.Completed); + this.ExpireSolution(true); + }); + Menu_AppendItem(menu, "Debug: Force NeedsRun", (s, e) => + { + this.StateManager.ForceState(ComponentState.NeedsRun); + this.ExpireSolution(true); + }); + Menu_AppendItem(menu, "Debug: Reset StateManager", (s, e) => + { + this.StateManager.Reset(); + this.ExpireSolution(true); + }); + } +#endif + + #endregion + } +} diff --git a/src/SmartHopper.Core/UI/CanvasButtonBootstrap.cs b/src/SmartHopper.Core/UI/CanvasButtonBootstrap.cs index 1ec8f259..6d70405e 100644 --- a/src/SmartHopper.Core/UI/CanvasButtonBootstrap.cs +++ b/src/SmartHopper.Core/UI/CanvasButtonBootstrap.cs @@ -8,6 +8,7 @@ * version 3 of the License, or (at your option) any later version. */ +using System; using System.Runtime.CompilerServices; using Rhino; using SmartHopper.Infrastructure.Settings; @@ -22,6 +23,12 @@ internal static class CanvasButtonBootstrap [ModuleInitializer] public static void Init() { + // Skip initialization when Grasshopper assemblies are unavailable (e.g., unit test runs). + if (!IsGrasshopperRuntimeAvailable()) + { + return; + } + // Only trigger initialization process if setting is enabled (defaults to true if unset) try { @@ -40,6 +47,21 @@ public static void Init() } } + /// + /// Detects whether Grasshopper runtime assemblies are available to avoid initializing UI elements during headless runs (e.g., unit tests). + /// + private static bool IsGrasshopperRuntimeAvailable() + { + try + { + return Type.GetType("Grasshopper.Instances, Grasshopper") != null; + } + catch + { + return false; + } + } + private static void OnSettingsSaved(object? sender, System.EventArgs e) { // Always marshal to Rhino UI thread for UI operations diff --git a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs index 5fdcb796..468423ed 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatObserver.cs @@ -666,7 +666,7 @@ public void OnToolResult(AIInteractionToolResult toolResult) /// /// Handles the final stable result after a conversation turn completes. - /// Renders the final assistant message, removes the thinking bubble, and emits notifications. + /// Renders the final message, removes the thinking bubble, and emits notifications. /// /// The final for this turn. public void OnFinal(AIReturn result) @@ -679,68 +679,66 @@ public void OnFinal(AIReturn result) try { - // Determine final assistant item and its base stream key (turn:{TurnId}:assistant) - var finalAssistant = result?.Body?.Interactions? - .OfType() - .LastOrDefault(i => i.Agent == AIAgent.Assistant); + // Determine the final renderable interaction (can be assistant text, tool, image, error, etc.) + var finalRenderable = historySnapshot?.Body?.Interactions? + .LastOrDefault(i => i is IAIRenderInteraction && i.Agent != AIAgent.Context); string streamKey = null; - if (finalAssistant is IAIKeyedInteraction keyedFinal) + if (finalRenderable is IAIKeyedInteraction keyedFinal) { streamKey = keyedFinal.GetStreamKey(); } // Mark this turn as finalized to prevent late partial/delta overrides - var turnKey = GetTurnBaseKey(finalAssistant?.TurnId); + var turnKey = GetTurnBaseKey(finalRenderable?.TurnId); var turnState = this.GetOrCreateTurnState(turnKey); - turnState.IsFinalized = true; - - // Prefer the aggregated streaming content for visual continuity - AIInteractionText aggregated = null; - - // Use the current segmented key for the assistant stream - var segKey = !string.IsNullOrWhiteSpace(streamKey) ? this.GetCurrentSegmentedKey(streamKey) : null; - if (!string.IsNullOrWhiteSpace(segKey) && this._streams.TryGetValue(segKey, out var st)) + if (turnState != null) { - aggregated = st?.Aggregated as AIInteractionText; + turnState.IsFinalized = true; } - // Do not fallback to arbitrary previous streams to avoid cross-turn duplicates + // Prefer the aggregated streaming content for visual continuity (when available) + IAIInteraction aggregated = null; + string segKey = null; + if (!string.IsNullOrWhiteSpace(streamKey)) + { + segKey = this.GetCurrentSegmentedKey(streamKey); + if (!string.IsNullOrWhiteSpace(segKey) && this._streams.TryGetValue(segKey, out var st)) + { + aggregated = st?.Aggregated; + } + } - // Merge final metrics/time/content into aggregated for the last render - if (aggregated != null && finalAssistant != null) + // Merge final metrics/time/content into aggregated for the last render (only for assistant text) + if (aggregated is AIInteractionText aggregatedText && finalRenderable is AIInteractionText finalText) { - // CRITICAL: Update content to ensure final complete text is rendered (fixes missing last chunk issue) - if (!string.IsNullOrWhiteSpace(finalAssistant.Content)) + if (!string.IsNullOrWhiteSpace(finalText.Content)) { - aggregated.Content = finalAssistant.Content; + aggregatedText.Content = finalText.Content; } - // Use aggregated turn metrics (includes tool calls, tool results, and assistant messages) - // This gives users an accurate picture of total token consumption for the turn - var turnId = finalAssistant.TurnId; - aggregated.Metrics = !string.IsNullOrWhiteSpace(turnId) - ? this._dialog._currentSession?.GetTurnMetrics(turnId) ?? finalAssistant.Metrics - : finalAssistant.Metrics; + var turnId = finalText.TurnId; + aggregatedText.Metrics = !string.IsNullOrWhiteSpace(turnId) + ? this._dialog._currentSession?.GetTurnMetrics(turnId) ?? finalText.Metrics + : finalText.Metrics; - aggregated.Time = finalAssistant.Time != default ? finalAssistant.Time : aggregated.Time; + aggregatedText.Time = finalText.Time != default ? finalText.Time : aggregatedText.Time; - // Ensure reasoning present on final render: prefer the provider's final reasoning - if (!string.IsNullOrWhiteSpace(finalAssistant.Reasoning)) + if (!string.IsNullOrWhiteSpace(finalText.Reasoning)) { - aggregated.Reasoning = finalAssistant.Reasoning; + aggregatedText.Reasoning = finalText.Reasoning; } } - var toRender = aggregated ?? finalAssistant; + var toRender = aggregated ?? finalRenderable; - // For non-aggregated renders, also apply turn metrics if available - if (toRender != null && aggregated == null && finalAssistant != null) + // For non-aggregated renders, apply turn metrics only when the final bubble is assistant text + if (toRender is AIInteractionText toRenderText && aggregated == null && toRenderText.Agent == AIAgent.Assistant) { - var turnId = finalAssistant.TurnId; - toRender.Metrics = !string.IsNullOrWhiteSpace(turnId) - ? this._dialog._currentSession?.GetTurnMetrics(turnId) ?? finalAssistant.Metrics - : toRender.Metrics; + var turnId = toRenderText.TurnId; + toRenderText.Metrics = !string.IsNullOrWhiteSpace(turnId) + ? this._dialog._currentSession?.GetTurnMetrics(turnId) ?? toRenderText.Metrics + : toRenderText.Metrics; } if (toRender != null) @@ -764,9 +762,9 @@ public void OnFinal(AIReturn result) } // Single final debug log for this interaction - var turnId = (toRender as AIInteractionText)?.TurnId ?? finalAssistant?.TurnId; + var turnId = toRender?.TurnId; var length = (toRender as AIInteractionText)?.Content?.Length ?? 0; - DebugLog($"[WebChatObserver] Final render: turn={turnId}, key={upsertKey}, len={length}"); + DebugLog($"[WebChatObserver] Final render: type={toRender?.GetType().Name}, turn={turnId}, key={upsertKey}, len={length}"); this._dialog.UpsertMessageByKey(upsertKey, toRender, source: "OnFinal"); } } diff --git a/tools/Anonymize-SmartHopperPublicKey.ps1 b/tools/Anonymize-SmartHopperPublicKey.ps1 new file mode 100644 index 00000000..25335c4b --- /dev/null +++ b/tools/Anonymize-SmartHopperPublicKey.ps1 @@ -0,0 +1,80 @@ +<# +.SYNOPSIS + Replaces SmartHopperPublicKey in SmartHopper.Infrastructure.csproj with an anonymized placeholder. +.DESCRIPTION + Use this script before sharing the repository to remove the real strong-name public key. It keeps + the InternalsVisibleTo entries intact but replaces the SmartHopperPublicKey value with a harmless + placeholder so internal APIs remain hidden from unsigned builds. +.PARAMETER CsprojPath + Path to the .csproj file to update. Defaults to SmartHopper.Infrastructure.csproj under src. +.PARAMETER PlaceholderKey + Optional placeholder key to use. If omitted, a default placeholder string is written. +.PARAMETER Help + Displays this help message. +.EXAMPLE + .\Anonymize-SmartHopperPublicKey.ps1 + Replaces the SmartHopperPublicKey with a default placeholder in the default csproj. +.EXAMPLE + .\Anonymize-SmartHopperPublicKey.ps1 -PlaceholderKey "DEADBEEF" + Uses the provided placeholder string instead of the default placeholder. +#> +param( + [string]$CsprojPath, + [string]$PlaceholderKey, + [switch]$Help +) + +# Purpose: Shows usage information for this script. +function Show-Help { + Write-Host "Usage: .\Anonymize-SmartHopperPublicKey.ps1 [options]" + Write-Host "" + Write-Host "Options:" + Write-Host " -CsprojPath Path to .csproj file (default: SmartHopper.Infrastructure.csproj)" + Write-Host " -PlaceholderKey Placeholder key to write (default: 'This value is automatically replaced by the build tooling before official builds.')" + Write-Host " -Help Displays this help message" +} + +if ($Help) { + Show-Help + exit 0 +} + +# Purpose: Resolve default paths relative to the repository root. +$scriptDir = Split-Path -Parent $PSScriptRoot +if (-not $CsprojPath) { + $CsprojPath = Join-Path $scriptDir "src\SmartHopper.Infrastructure\SmartHopper.Infrastructure.csproj" +} + +# Purpose: Ensure the target .csproj exists before attempting modifications. +if (-not (Test-Path $CsprojPath)) { + Write-Error ".csproj not found at: $CsprojPath" + exit 1 +} + +Write-Host "Anonymizing SmartHopperPublicKey in: $CsprojPath" + +# Purpose: Load the csproj as XML to safely update the property. +try { + $content = Get-Content $CsprojPath -Raw + $xml = [xml]$content + $keyElement = $xml.SelectSingleNode("//SmartHopperPublicKey") + if (-not $keyElement) { + Write-Error "Could not find SmartHopperPublicKey element in $CsprojPath" + exit 1 + } + + $existingKey = $keyElement.InnerText + if ([string]::IsNullOrWhiteSpace($PlaceholderKey)) { + $PlaceholderKey = "This value is automatically replaced by the build tooling before official builds." + } + + Write-Host "Replacing key (length $($existingKey.Length)) with placeholder (length $($PlaceholderKey.Length))." + $keyElement.InnerText = $PlaceholderKey + $xml.Save($CsprojPath) + Write-Host "Successfully anonymized SmartHopperPublicKey." +} catch { + Write-Error "Failed to update .csproj: $_" + exit 1 +} + +Write-Host "Done." From bcbd7ea2b46149222b59194e063c302160df4c12 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 24 Dec 2025 23:29:56 +0100 Subject: [PATCH 15/26] perf(providers): add reasoning support for tool calls across all providers --- .../Interactions/AIInteractionToolCall.cs | 10 +++- .../DeepSeekProvider.cs | 31 +++++++++- .../MistralAIProvider.cs | 27 ++++++++- .../OpenAIProvider.cs | 21 ++++++- .../OpenRouterProvider.cs | 59 ++++++++++++++++++- 5 files changed, 140 insertions(+), 8 deletions(-) diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs index 2c104d1c..486b67d7 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs @@ -39,6 +39,12 @@ public class AIInteractionToolCall : AIInteractionBase, IAIKeyedInteraction, IAI /// public JObject Arguments { get; set; } + /// + /// Gets or sets the reasoning content associated with this tool call. + /// Used by providers like DeepSeek that include reasoning_content with tool calls. + /// + public string Reasoning { get; set; } + /// /// Returns a string representation of the AIInteractionToolCall. /// @@ -123,11 +129,11 @@ public virtual string GetRawContentForRender() } /// - /// Tool calls do not include reasoning by default. + /// Gets the reasoning content associated with this tool call, if any. /// public virtual string GetRawReasoningForRender() { - return string.Empty; + return this.Reasoning ?? string.Empty; } } } diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs index 913a6325..405bef85 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs @@ -207,6 +207,12 @@ public override string Encode(AIRequestCall request) ["role"] = role, ["content"] = token["content"]?.ToString() ?? string.Empty }; + + if (!string.IsNullOrWhiteSpace(token["reasoning_content"]?.ToString())) + { + currentMessage["reasoning_content"] = token["reasoning_content"]?.ToString(); + } + currentToolCalls = new JArray(); // Copy tool_calls if present in this token @@ -239,6 +245,13 @@ public override string Encode(AIRequestCall request) currentMessage["content"] = string.IsNullOrEmpty(existingContent) ? newContent : existingContent + " " + newContent; } + var existingReasoning = currentMessage["reasoning_content"]?.ToString() ?? string.Empty; + var newReasoning = token["reasoning_content"]?.ToString() ?? string.Empty; + if (!string.IsNullOrWhiteSpace(newReasoning)) + { + currentMessage["reasoning_content"] = string.IsNullOrWhiteSpace(existingReasoning) ? newReasoning : existingReasoning + " " + newReasoning; + } + // Accumulate tool_calls if (token["tool_calls"] is JArray tc && tc.Count > 0) { @@ -264,6 +277,11 @@ public override string Encode(AIRequestCall request) currentMessage["tool_calls"] = currentToolCalls; } + if (currentToolCalls.Count == 0 && string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) + { + currentMessage.Remove("reasoning_content"); + } + convertedMessages.Add(currentMessage); } @@ -434,6 +452,11 @@ public override string Encode(IAIInteraction interaction) if (interaction is AIInteractionText textInteraction) { messageObj["content"] = textInteraction.Content ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(textInteraction.Reasoning)) + { + messageObj["reasoning_content"] = textInteraction.Reasoning; + } } else if (interaction is AIInteractionToolResult toolResultInteraction) { @@ -469,7 +492,12 @@ public override string Encode(IAIInteraction interaction) }, }; messageObj["tool_calls"] = new JArray { toolCallObj }; - messageObj["content"] = string.Empty; // assistant tool_calls messages should have empty content + messageObj["content"] = string.Empty; + + if (!string.IsNullOrWhiteSpace(toolCallInteraction.Reasoning)) + { + messageObj["reasoning_content"] = toolCallInteraction.Reasoning; + } } else if (interaction is AIInteractionImage imageInteraction) { @@ -596,6 +624,7 @@ public override List Decode(JObject response) Id = tc["id"]?.ToString(), Name = function?["name"]?.ToString(), Arguments = JObject.Parse(argumentsStr), + Reasoning = string.IsNullOrWhiteSpace(reasoning) ? null : reasoning, }; interactions.Add(toolCall); } diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs index 57e9ab72..5151b47a 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs @@ -202,7 +202,31 @@ public override string Encode(IAIInteraction interaction) }, }; messageObj["tool_calls"] = new JArray { toolCallObj }; - messageObj["content"] = string.Empty; // assistant tool_calls messages should have empty content + + // For thinking-enabled models, include reasoning in content array + if (!string.IsNullOrWhiteSpace(toolCallInteraction.Reasoning)) + { + var contentArray = new JArray + { + new JObject + { + ["type"] = "thinking", + ["thinking"] = new JArray + { + new JObject + { + ["type"] = "text", + ["text"] = toolCallInteraction.Reasoning, + }, + }, + }, + }; + messageObj["content"] = contentArray; + } + else + { + messageObj["content"] = string.Empty; + } } else if (interaction is AIInteractionImage imageInteraction) { @@ -431,6 +455,7 @@ public override List Decode(JObject response) Id = tc["id"]?.ToString(), Name = func?[(object)"name"]?.ToString(), Arguments = argsObj, + Reasoning = string.IsNullOrWhiteSpace(reasoning) ? null : reasoning, }; interactions.Add(toolCall); } diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs index a6c8a7b5..04c7c34d 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs @@ -237,7 +237,25 @@ public override string Encode(IAIInteraction interaction) }, }; messageObj["tool_calls"] = new JArray { toolCallObj }; - msgContent = string.Empty; // assistant tool_calls messages should have empty content + + // For o-series or gpt-5+ models, include reasoning in content array + if (!string.IsNullOrWhiteSpace(toolCallInteraction.Reasoning)) + { + var contentArray = new JArray + { + new JObject + { + ["type"] = "reasoning", + ["text"] = toolCallInteraction.Reasoning, + }, + }; + messageObj["content"] = contentArray; + contentSetExplicitly = true; + } + else + { + msgContent = string.Empty; + } } else if (interaction is AIInteractionImage imageInteraction) { @@ -705,6 +723,7 @@ private List ProcessChatCompletionsResponseData(JObject response Id = tc["id"]?.ToString(), Name = tc["function"]?["name"]?.ToString(), Arguments = argsObj, + Reasoning = string.IsNullOrWhiteSpace(reasoning) ? null : reasoning, }; interactions.Add(toolCall); } diff --git a/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs b/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs index 03b94d9c..c39359f3 100644 --- a/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs +++ b/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs @@ -359,7 +359,24 @@ public override string Encode(IAIInteraction interaction) }, }; obj["tool_calls"] = new JArray { toolCallObj }; - obj["content"] = string.Empty; // assistant tool_calls messages should have empty content + + // For reasoning-enabled models (o-series via OpenRouter), include reasoning in content array + if (!string.IsNullOrWhiteSpace(toolCallInteraction.Reasoning)) + { + var contentArray = new JArray + { + new JObject + { + ["type"] = "reasoning", + ["text"] = toolCallInteraction.Reasoning, + }, + }; + obj["content"] = contentArray; + } + else + { + obj["content"] = string.Empty; + } } else if (interaction is AIInteractionImage) { @@ -397,10 +414,45 @@ public override List Decode(JObject response) } // Extract text content - string content = message["content"]?.ToString() ?? string.Empty; + // Extract content and reasoning (for o-series models via OpenRouter) + string content = string.Empty; + string reasoning = string.Empty; + + var contentToken = message["content"]; + if (contentToken is JArray contentArray) + { + var contentParts = new List(); + var reasoningParts = new List(); + + foreach (var part in contentArray.OfType()) + { + var type = part["type"]?.ToString(); + if (string.Equals(type, "reasoning", StringComparison.OrdinalIgnoreCase) || + string.Equals(type, "thinking", StringComparison.OrdinalIgnoreCase)) + { + var textVal = part["text"]?.ToString() ?? part["content"]?.ToString(); + if (!string.IsNullOrEmpty(textVal)) reasoningParts.Add(textVal); + } + else if (string.Equals(type, "text", StringComparison.OrdinalIgnoreCase)) + { + var textVal = part["text"]?.ToString() ?? part["content"]?.ToString(); + if (!string.IsNullOrEmpty(textVal)) contentParts.Add(textVal); + } + } + + content = string.Join(string.Empty, contentParts).Trim(); + reasoning = string.Join("\n\n", reasoningParts).Trim(); + } + else if (contentToken != null) + { + content = contentToken.ToString() ?? string.Empty; + } var result = new AIInteractionText(); - result.SetResult(agent: AIAgent.Assistant, content: content); + result.SetResult( + agent: AIAgent.Assistant, + content: content, + reasoning: string.IsNullOrWhiteSpace(reasoning) ? null : reasoning); // Extract metrics (tokens, model, finish reason) if present var metrics = new Infrastructure.AICall.Metrics.AIMetrics @@ -467,6 +519,7 @@ public override List Decode(JObject response) Id = tc["id"]?.ToString(), Name = func?["name"]?.ToString(), Arguments = argsObj, + Reasoning = string.IsNullOrWhiteSpace(reasoning) ? null : reasoning, }; interactions.Add(toolCall); } From de01ed312edf811190bbfa487e36037e1d78fde5 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Thu, 25 Dec 2025 11:57:03 +0100 Subject: [PATCH 16/26] feat(component-base): new state manager to improve component state stability --- CHANGELOG.md | 8 +++ docs/Components/AI/AIModelsComponent.md | 2 +- .../ComponentBase/AIProviderComponentBase.md | 2 +- .../AIStatefulAsyncComponentBase.md | 4 +- .../ComponentBase/AsyncComponentBase.md | 2 +- .../ComponentBase/DataTreeProcessingSchema.md | 14 ++--- .../ComponentBase/SelectingComponentBase.md | 2 +- .../StatefulAsyncComponentBase.md | 4 +- .../ComponentBase/StatefulComponentBase.md | 39 ++++++++++++++ docs/Components/Helpers/ProgressInfo.md | 2 +- docs/Components/Helpers/StateManager.md | 2 +- docs/Components/IO/Persistence.md | 13 +++-- docs/Components/IO/index.md | 4 +- docs/Components/Workers/AsyncWorkerBase.md | 2 +- docs/Components/index.md | 6 +-- ...efulAsyncComponentBase State Management.md | 4 +- ...TreeProcessorBranchFlattenTestComponent.cs | 5 +- ...reeProcessorBranchToBranchTestComponent.cs | 5 +- ...sorBroadcastDeeperDiffRootTestComponent.cs | 7 ++- ...sorBroadcastDeeperSameRootTestComponent.cs | 7 ++- ...sorBroadcastMultipleNoZeroTestComponent.cs | 7 ++- ...rBroadcastMultipleTopLevelTestComponent.cs | 7 ++- ...ntPathsFirstOneSecondThreeTestComponent.cs | 6 +-- ...ntPathsFirstThreeSecondOneTestComponent.cs | 6 +-- ...rDifferentPathsOneItemEachTestComponent.cs | 6 +-- ...fferentPathsThreeItemsEachTestComponent.cs | 6 +-- ...essorDirectMatchPrecedenceTestComponent.cs | 7 ++- ...alPathsFirstOneSecondThreeTestComponent.cs | 6 +-- ...alPathsFirstThreeSecondOneTestComponent.cs | 6 +-- ...ataTreeProcessorEqualPathsTestComponent.cs | 6 +-- ...cessorEqualPathsThreeItemsTestComponent.cs | 6 +-- ...reeProcessorGroupIdenticalTestComponent.cs | 5 +- ...DataTreeProcessorItemGraftTestComponent.cs | 6 +-- ...ataTreeProcessorItemToItemTestComponent.cs | 6 +-- ...taTreeProcessorMixedDepthsTestComponent.cs | 7 ++- ...TreeProcessorRule2OverrideTestComponent.cs | 7 ++- .../Misc/TestStateManagerDebounceComponent.cs | 5 +- .../TestStateManagerRestorationComponent.cs | 5 +- .../TestStatefulPrimeCalculatorComponent.cs | 5 +- ...estStatefulTreePrimeCalculatorComponent.cs | 5 +- .../AI/AIFileContextComponent.cs | 1 - .../Grasshopper/GhGetComponents.cs | 2 - .../Grasshopper/GhMergeComponents.cs | 2 - .../Grasshopper/GhPutComponents.cs | 9 ++-- .../Grasshopper/GhTidyUpComponents.cs | 3 -- .../McNeelForumDeconstructPostComponent.cs | 1 - .../Knowledge/McNeelForumPostGetComponent.cs | 6 +-- .../Knowledge/McNeelForumPostOpenComponent.cs | 7 ++- .../Knowledge/McNeelForumSearchComponent.cs | 6 +-- .../Knowledge/WebPageReadComponent.cs | 6 +-- .../Text/AITextEvaluate.cs | 2 +- .../AITools/ScriptCodeValidator.cs | 1 - .../AITools/_gh_connect.cs | 2 - .../AITools/_gh_generate.cs | 2 - .../AITools/_gh_parameter_modifier.cs | 4 -- .../AITools/_script_parameter_modifier.cs | 46 ++++++++++------ .../AITools/gh_component_lock.cs | 2 - .../AITools/gh_component_preview.cs | 2 - .../AITools/gh_get.cs | 3 +- .../AITools/gh_group.cs | 23 ++++---- .../AITools/gh_list_categories.cs | 2 - .../AITools/gh_list_components.cs | 2 - .../AITools/gh_merge.cs | 2 - .../AITools/gh_move.cs | 2 - .../AITools/gh_put.cs | 2 - .../AITools/gh_tidy_up.cs | 9 ++-- .../AITools/img_generate.cs | 2 - .../AITools/instruction_get.cs | 10 +++- .../AITools/list_filter.cs | 2 - .../AITools/list_generate.cs | 1 - .../AITools/mcneel_forum_search.cs | 1 - .../AITools/script_review.cs | 1 - .../AITools/text_evaluate.cs | 3 -- .../AITools/text_generate.cs | 3 -- .../Serialization/Canvas/CanvasUtilities.cs | 1 - .../Serialization/Canvas/GroupManager.cs | 1 - .../Serialization/GhJson/GhJsonMerger.cs | 1 - .../ScriptComponents/ScriptParameterMapper.cs | 2 +- .../ScriptComponents/ScriptSignatureParser.cs | 2 - .../GhJson/SerializationOptions.cs | 2 - .../GhJson/Shared/ParameterMapper.cs | 1 - .../Utils/Canvas/ComponentManipulation.cs | 1 - .../Utils/Canvas/ConnectionBuilder.cs | 2 - .../Utils/Components/ParameterModifier.cs | 1 - .../Utils/Components/ScriptModifier.cs | 1 - .../Utils/Internal/ComponentRetriever.cs | 2 +- .../Utils/Internal/WebUtilities.cs | 2 +- .../Utils/Rhino/File3dmReader.cs | 1 - .../Utils/Serialization/DataTreeConverter.cs | 12 ++--- .../PropertyFilters/PropertyFilter.cs | 3 -- .../AIProviderComponentAttributes.cs | 1 - .../ComponentBase/AIProviderComponentBase.cs | 2 +- .../AISelectingStatefulAsyncComponentBase.cs | 2 - .../AIStatefulAsyncComponentBase.cs | 1 - .../ComponentBase/ComponentStateManager.cs | 1 - .../ComponentBase/SelectingComponentBase.cs | 2 - .../ComponentBase/SelectingComponentCore.cs | 1 - ...nentBaseV2.cs => StatefulComponentBase.cs} | 54 +++++++++++++------ ....cs => _old-StatefulAsyncComponentBase.cs} | 11 ++-- .../Models/Connections/ConnectionPairing.cs | 1 - .../Models/Document/DocumentMetadata.cs | 1 - .../Models/Serialization/GHJsonConverter.cs | 2 +- .../UI/Chat/ChatResourceManager.cs | 3 -- .../UI/Chat/HtmlChatRenderer.cs | 5 -- src/SmartHopper.Core/UI/Chat/WebChatDialog.cs | 15 +----- .../UI/Chat/WebChatUtils.Helpers.cs | 21 ++++++-- src/SmartHopper.Core/UI/Chat/WebChatUtils.cs | 7 +-- src/SmartHopper.Core/UI/DialogCanvasLink.cs | 1 - .../AIToolManagerTests.cs | 4 -- .../ModelManagerTests.cs | 9 ---- .../AICall/Core/Base/AIRuntimeMessage.cs | 6 --- .../AICall/Core/Interactions/AIBody.cs | 3 +- .../Core/Interactions/AIInteractionError.cs | 1 - .../Core/Interactions/AIInteractionText.cs | 1 - .../Interactions/AIInteractionToolCall.cs | 2 - .../Core/Interactions/IAIKeyedInteraction.cs | 2 - .../Core/Interactions/IAIRenderInteraction.cs | 2 - .../AICall/Core/Requests/AIRequestCall.cs | 1 - .../AICall/Core/Returns/AIReturn.cs | 1 - .../Request/AIToolValidationRequestPolicy.cs | 2 - .../Policies/Request/RequestTimeoutPolicy.cs | 2 - .../Request/SchemaValidateRequestPolicy.cs | 1 - .../ToolFilterNormalizationRequestPolicy.cs | 1 - .../AICall/Sessions/ConversationSession.cs | 7 ++- .../AICall/Sessions/IConversationObserver.cs | 2 - .../SpecialTurns/InteractionFilter.cs | 1 - .../SpecialTurns/SpecialTurnConfig.cs | 1 - .../AICall/Tools/ToolResultEnvelope.cs | 2 - .../Tools/ToolResultEnvelopeExtensions.cs | 2 - .../Validation/ToolCapabilityValidator.cs | 1 - .../AICall/Validation/ToolExistsValidator.cs | 1 - .../AICall/Validation/ValidationContext.cs | 1 - .../AIContext/ContextManager.cs | 1 - .../AIModels/AIModelCapabilities.cs | 5 -- .../AIModels/ModelManager.cs | 1 - .../AIProviders/AIProvider.cs | 2 +- .../AIProviders/AIProviderModels.cs | 1 - .../Initialization/SmartHopperInitializer.cs | 2 +- .../Streaming/IStreamingAdapter.cs | 2 - .../SettingsTabs/GeneralSettingsPage.cs | 1 - .../SettingsTabs/ProvidersSettingsPage.cs | 1 - .../AnthropicProviderModels.cs | 1 - .../DeepSeekProvider.cs | 36 ++++++++++--- .../DeepSeekProviderModels.cs | 1 - .../MistralAIProvider.cs | 4 +- .../OpenAIProviderModels.cs | 27 ++++++++-- 146 files changed, 358 insertions(+), 375 deletions(-) create mode 100644 docs/Components/ComponentBase/StatefulComponentBase.md rename src/SmartHopper.Core/ComponentBase/{StatefulComponentBaseV2.cs => StatefulComponentBase.cs} (96%) rename src/SmartHopper.Core/ComponentBase/{StatefulAsyncComponentBase.cs => _old-StatefulAsyncComponentBase.cs} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b74173d..c7b8f08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,12 +23,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Simplified streaming validation flow in `WebChatDialog.ProcessAIInteraction()` - now always attempts streaming first, letting `ConversationSession` handle validation internally. - Added `TurnRenderState` and `SegmentState` classes to `WebChatObserver` for encapsulated per-turn state management. - Reduced idempotency cache size from 1000 to 100 entries to reduce memory footprint. + - Promoted `StatefulComponentBaseV2` to the default stateful base by renaming it to `StatefulComponentBase`. - Chat UI: - Optimized DOM updates with a keyed queue, conditional debug logging, and template-cached message rendering with LRU diffing to cut redundant work on large chats. - Refined streaming visuals by removing unused animations and switching to lighter wipe-in effects, improving responsiveness while messages stream. ### Fixed +- DeepSeek provider: + - Fixed `deepseek-reasoner` model failing with HTTP 400 "Missing reasoning_content field" error during tool calling. The streaming adapter was not propagating `reasoning_content` to `AIInteractionToolCall` objects, causing the field to be missing when the conversation history was re-sent to the API. + - Fixed duplicated reasoning display in UI when tool calls are present. Reasoning now only appears on tool call interactions (where it's needed for the API), not on empty assistant text interactions. + +- Chat UI: + - Fixed user messages not appearing in the chat UI. The `ConversationSession.AddInteraction(string)` method was not notifying the observer when user messages were added to the session history. + - Tool calling: - Improved `instruction_get` tool description to explicitly mention required `topic` argument. Some models (MistralAI, OpenAI) don't always respect JSON Schema `required` fields but do follow description text. diff --git a/docs/Components/AI/AIModelsComponent.md b/docs/Components/AI/AIModelsComponent.md index f10a73ed..f53bcf96 100644 --- a/docs/Components/AI/AIModelsComponent.md +++ b/docs/Components/AI/AIModelsComponent.md @@ -32,7 +32,7 @@ Provide an up-to-date list of models by querying the provider API when possible 4. Errors (e.g., no provider or no models) emit a runtime error with a concise message. - Execution trigger: - - `RunOnlyOnInputChanges = false` so provider changes retrigger execution (see `AIStatefulAsyncComponentBase`). + - `RunOnlyOnInputChanges = false` so provider changes retrigger execution. ## Notes diff --git a/docs/Components/ComponentBase/AIProviderComponentBase.md b/docs/Components/ComponentBase/AIProviderComponentBase.md index 36d569fb..da7a7641 100644 --- a/docs/Components/ComponentBase/AIProviderComponentBase.md +++ b/docs/Components/ComponentBase/AIProviderComponentBase.md @@ -22,4 +22,4 @@ Expose provider selection via context menu and store the selection so derived co ## Related - [AIProviderComponentAttributes](../Helpers/AIProviderComponentAttributes.md) – draws a provider logo/badge on the component. -- [AIStatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) – combines this base with the async state machine. +- [AIStatefulAsyncComponentBase](./AIStatefulAsyncComponentBase.md) – combines this base with stateful execution. diff --git a/docs/Components/ComponentBase/AIStatefulAsyncComponentBase.md b/docs/Components/ComponentBase/AIStatefulAsyncComponentBase.md index c1fe79cf..e3ca6b54 100644 --- a/docs/Components/ComponentBase/AIStatefulAsyncComponentBase.md +++ b/docs/Components/ComponentBase/AIStatefulAsyncComponentBase.md @@ -8,7 +8,7 @@ Offer a turnkey base to build AI components: choose provider/model, build a requ ## Key features -- Builds on [AIProviderComponentBase](./AIProviderComponentBase.md) and [StatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) to add a `Model` input and a `Metrics` output. +- Builds on [AIProviderComponentBase](./AIProviderComponentBase.md) and [StatefulComponentBase](./StatefulComponentBase.md) to add a `Model` input and a `Metrics` output. - Capability‑aware model selection via `RequiredCapability` and `UsingAiTools`, delegating to provider `SelectModel()` / `ModelManager.SelectBestModel`. - `CallAiToolAsync` helper that injects provider/model into AI Tools, executes them, and stores the last `AIReturn` snapshot. - Centralized metrics output (JSON with provider, model, tokens, completion time, data/iteration counts). @@ -24,4 +24,4 @@ Offer a turnkey base to build AI components: choose provider/model, build a requ ## Related - [AIProviderComponentBase](./AIProviderComponentBase.md) – provider UI/persistence. -- [StatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) – async state machine foundation. +- [StatefulComponentBase](./StatefulComponentBase.md) – stateful execution foundation. diff --git a/docs/Components/ComponentBase/AsyncComponentBase.md b/docs/Components/ComponentBase/AsyncComponentBase.md index d6ddedd0..213f0c07 100644 --- a/docs/Components/ComponentBase/AsyncComponentBase.md +++ b/docs/Components/ComponentBase/AsyncComponentBase.md @@ -40,6 +40,6 @@ Provide a robust async skeleton: snapshot inputs, run on a background task with ## Related -- [StatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) – adds state machine, debouncing, and Run handling on top. +- StatefulComponentBase – adds state machine, debouncing, and Run handling on top. - [AsyncWorkerBase](../Workers/AsyncWorkerBase.md) – worker abstraction to host the actual compute logic. - [ProgressInfo](../Helpers/ProgressInfo.md) – lightweight progress reporting payload. diff --git a/docs/Components/ComponentBase/DataTreeProcessingSchema.md b/docs/Components/ComponentBase/DataTreeProcessingSchema.md index 0f3ec449..a68b165a 100644 --- a/docs/Components/ComponentBase/DataTreeProcessingSchema.md +++ b/docs/Components/ComponentBase/DataTreeProcessingSchema.md @@ -15,7 +15,7 @@ This schema is now the **single** processing model: legacy branch/item helpers a ## 2. High‑level workflow and responsibilities -### 2.1 Component (GH_Component / StatefulAsyncComponentBase) +### 2.1 Component (GH_Component / StatefulComponentBase) - **UI & contract** - Register input/output parameters and access (item/list/tree). @@ -287,9 +287,9 @@ Responsibilities: - `current` is the index of the currently processed logical unit (1‑based). - `total` is the total number of logical units that will be processed, computed from the input trees after applying matching, grouping, and any normalization required by the selected topology. -### 5.2 Relationship to StatefulAsyncComponentBase +### 5.2 Relationship to StatefulComponentBase -- `StatefulAsyncComponentBase` exposes a high‑level helper +- `StatefulComponentBase` exposes a high‑level helper ```csharp protected Task>> RunProcessingAsync( @@ -311,7 +311,7 @@ which builds a processing plan, computes metrics, initialises progress, and then ## 6. Component mapping This section lists all components under `src/SmartHopper.Components` that are based on -`StatefulAsyncComponentBase`, `AIStatefulAsyncComponentBase` or +`StatefulComponentBase`, `AIStatefulAsyncComponentBase` or `AISelectingStatefulAsyncComponentBase`, and describes how they fit into the processing schema. @@ -388,7 +388,7 @@ processing schema. ### 6.5 Knowledge components -- **WebPageReadComponent** (`StatefulAsyncComponentBase`) +- **WebPageReadComponent** (`StatefulComponentBase`) - **Topology**: `ItemToItem`. - **Granularity**: per‑item (each URL is one logical unit). - **Path mode**: same as input branch and item index. @@ -407,13 +407,13 @@ processing schema. - **McNeelForumDeconstructPostComponent** (`GH_Component`) — schema not applicable. -- **McNeelForumPostGetComponent**, **McNeelForumPostOpenComponent** (`StatefulAsyncComponentBase`) +- **McNeelForumPostGetComponent**, **McNeelForumPostOpenComponent** (`StatefulComponentBase`) - **Topology**: `ItemToItem` (each ID or URL is one logical unit), but with side‑effects (opening posts). - **Changes required**: - For pure data retrieval, using `ItemToItem` is natural. - For side‑effect‑heavy operations (like opening posts in a browser), centralizing scheduling brings less value; migration is optional. -- **McNeelForumSearchComponent**, **McNeelForumTopicRelatedComponent** (`StatefulAsyncComponentBase`) +- **McNeelForumSearchComponent**, **McNeelForumTopicRelatedComponent** (`StatefulComponentBase`) - **Topology**: `ItemGraft`. - **Granularity**: per‑item (each query or topic ID is one logical unit). - **Path mode**: graft per input item (`[q0,q1,q2](i) → [q0,q1,q2,i](0..N)`). diff --git a/docs/Components/ComponentBase/SelectingComponentBase.md b/docs/Components/ComponentBase/SelectingComponentBase.md index adef7b84..2ecebd65 100644 --- a/docs/Components/ComponentBase/SelectingComponentBase.md +++ b/docs/Components/ComponentBase/SelectingComponentBase.md @@ -90,6 +90,6 @@ In both cases, you typically: ## Related -- [StatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) +- [StatefulComponentBase](./StatefulComponentBase.md) - [AIStatefulAsyncComponentBase](./AIStatefulAsyncComponentBase.md) - `ISelectingComponent`, `SelectingComponentCore`, and `SelectingComponentAttributes` in `src/SmartHopper.Core/ComponentBase` diff --git a/docs/Components/ComponentBase/StatefulAsyncComponentBase.md b/docs/Components/ComponentBase/StatefulAsyncComponentBase.md index ea3623de..9313d0e7 100644 --- a/docs/Components/ComponentBase/StatefulAsyncComponentBase.md +++ b/docs/Components/ComponentBase/StatefulAsyncComponentBase.md @@ -1,6 +1,8 @@ # StatefulAsyncComponentBase -Async base with built‑in component state management, debouncing, progress, error handling, and persistent output storage. +Legacy async base with built‑in component state management, debouncing, progress, error handling, and persistent output storage. + +This base has been superseded by [StatefulComponentBase](./StatefulComponentBase.md). ## Purpose diff --git a/docs/Components/ComponentBase/StatefulComponentBase.md b/docs/Components/ComponentBase/StatefulComponentBase.md new file mode 100644 index 00000000..09233fde --- /dev/null +++ b/docs/Components/ComponentBase/StatefulComponentBase.md @@ -0,0 +1,39 @@ +# StatefulComponentBase + +Stateful async base built on `ComponentStateManager`. + +## Purpose + +Unify long-running execution with a clear state machine so components behave predictably with buttons/toggles and input changes. Provides automatic persistence and restoration of output data across document save/load cycles. + +## Key features + +- **State machine** via `ComponentState` (Waiting, NeedsRun, Processing, Completed, Cancelled, Error). +- **Debounce** using `ComponentStateManager.StartDebounce(...)` to prevent bursty input changes from triggering repeated runs. +- **Input change detection** via hash-based comparison of input data and branch structure, owned by `ComponentStateManager`. +- **Progress tracking** with user-friendly state messages and iteration counts. +- **Persistent runtime messages** keyed by identifier for accumulation and selective clearing. +- **Persistent output storage** via [IO Persistence (V2)](../IO/Persistence.md) for document save/load. +- **RunOnlyOnInputChanges** flag (default: true) controlling whether Run=true always triggers processing or only when inputs change. + +## Key lifecycle flow + +- `SolveInstance(IGH_DataAccess)` reads Run, updates pending input hashes, dispatches per-state handlers, then delegates input-change handling to the state manager. +- `OnWorkerCompleted()` commits input hashes and transitions to Completed. +- `Write(GH_IWriter)` / `Read(GH_IReader)` persist and restore input hashes and output trees via `GHPersistenceService`. + +## Code location + +- `src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs` (type name: `StatefulComponentBase`). + +## Legacy + +- `StatefulAsyncComponentBase` is legacy and retained temporarily for migration. + +## Related + +- [StateManager](../Helpers/StateManager.md) – defines states and friendly messages. +- [AsyncComponentBase](./AsyncComponentBase.md) – lower-level async base with worker coordination. +- [AsyncWorkerBase](../Workers/AsyncWorkerBase.md) – worker abstraction for compute logic. +- [ProgressInfo](../Helpers/ProgressInfo.md) – report incremental progress. +- [IO Persistence (V2)](../IO/Persistence.md) – safe, versioned storage of output trees used by this base. diff --git a/docs/Components/Helpers/ProgressInfo.md b/docs/Components/Helpers/ProgressInfo.md index 27810b6c..26d67ef0 100644 --- a/docs/Components/Helpers/ProgressInfo.md +++ b/docs/Components/Helpers/ProgressInfo.md @@ -19,4 +19,4 @@ Communicate progress from worker threads to the component/UI without tight coupl ## Related -- [AsyncWorkerBase](../Workers/AsyncWorkerBase.md), [AsyncComponentBase](../ComponentBase/AsyncComponentBase.md), [StatefulAsyncComponentBase](../ComponentBase/StatefulAsyncComponentBase.md) – typical consumers. +- [AsyncWorkerBase](../Workers/AsyncWorkerBase.md), [AsyncComponentBase](../ComponentBase/AsyncComponentBase.md), [StatefulComponentBase](../ComponentBase/StatefulComponentBase.md) – typical consumers. diff --git a/docs/Components/Helpers/StateManager.md b/docs/Components/Helpers/StateManager.md index 8af19576..144d8d87 100644 --- a/docs/Components/Helpers/StateManager.md +++ b/docs/Components/Helpers/StateManager.md @@ -19,4 +19,4 @@ Provide a compact state model used by stateful components to drive execution flo ## Related -- [StatefulAsyncComponentBase](../ComponentBase/StatefulAsyncComponentBase.md), [AIStatefulAsyncComponentBase](../ComponentBase/AIStatefulAsyncComponentBase.md) – consumers of these states. +- [StatefulComponentBase](../ComponentBase/StatefulComponentBase.md), [AIStatefulAsyncComponentBase](../ComponentBase/AIStatefulAsyncComponentBase.md) – consumers of these states. diff --git a/docs/Components/IO/Persistence.md b/docs/Components/IO/Persistence.md index e4b0739e..97ccbddb 100644 --- a/docs/Components/IO/Persistence.md +++ b/docs/Components/IO/Persistence.md @@ -1,6 +1,6 @@ # Persistence (V2) -Safe, versioned persistence of component outputs used by `StatefulAsyncComponentBase`. +Safe, versioned persistence of component outputs used by `StatefulComponentBase`. ## Purpose @@ -10,10 +10,10 @@ Safe, versioned persistence of component outputs used by `StatefulAsyncComponent ## Where it is used -- `StatefulAsyncComponentBase.Write(GH_IWriter)` writes current output trees using `GHPersistenceService.WriteOutputsV2()`. -- `StatefulAsyncComponentBase.Read(GH_IReader)` reads output trees using `GHPersistenceService.ReadOutputsV2()` and restores them to the component's outputs. +- `StatefulComponentBase.Write(GH_IWriter)` writes current output trees using `GHPersistenceService.WriteOutputsV2()`. +- `StatefulComponentBase.Read(GH_IReader)` reads output trees using `GHPersistenceService.ReadOutputsV2()` and restores them to the component's outputs. -See: `src/SmartHopper.Core/ComponentBase/StatefulAsyncComponentBase.cs`. +See: `src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs`. ## Versioning and keys @@ -31,6 +31,7 @@ Source: `src/SmartHopper.Core/IO/PersistenceConstants.cs`. - Trees preserve paths, order, and counts. Source: + - `src/SmartHopper.Core/IO/GHPersistenceService.cs` - `src/SmartHopper.Core/IO/SafeStructureCodec.cs` @@ -44,18 +45,21 @@ Source: - `TryDecode(string, out IGH_Goo goo, out string warning) -> bool` Source: + - `src/SmartHopper.Core/IO/SafeStructureCodec.cs` - `src/SmartHopper.Core/IO/SafeGooCodec.cs` ## Supported item types Handled explicitly by `SafeGooCodec`: + - `GH_String` — `GH_String|{value}` - `GH_Number` — `GH_Number|{value}` (InvariantCulture) - `GH_Integer` — `GH_Integer|{value}` (InvariantCulture) - `GH_Boolean` — `GH_Boolean|1` or `0` (also accepts `true`/`false` on decode) Fallbacks and warnings: + - Unknown `typeHint` decodes to `GH_String` with a warning. - Parse failures (number/int/bool) decode to `GH_String` with a warning. - Any exception during decode results in `GH_String` with a warning. @@ -76,6 +80,7 @@ Source: `src/SmartHopper.Core/IO/GHPersistenceService.cs`. ## Extending to new types To persist a new GH type safely: + 1. Add a new `typeHint` case in `SafeGooCodec.Encode(IGH_Goo)` producing a canonical string representation. 2. Add the corresponding case in `SafeGooCodec.TryDecode(...)` to parse the string back to an `IGH_Goo` instance. 3. Use `CultureInfo.InvariantCulture` for numeric formats and keep strings unescaped if possible; if escaping is needed, keep the prefix format `typeHint|payload` stable and implement reversible escaping. diff --git a/docs/Components/IO/index.md b/docs/Components/IO/index.md index b6e22d14..c712c595 100644 --- a/docs/Components/IO/index.md +++ b/docs/Components/IO/index.md @@ -23,5 +23,5 @@ Safe, versioned persistence for Grasshopper component outputs. ## Related -- Base integration in `StatefulAsyncComponentBase` read/write paths. -- See [StatefulAsyncComponentBase](../ComponentBase/StatefulAsyncComponentBase.md) for where persistence is invoked. +- Base integration in `StatefulComponentBase` read/write paths. +- See `StatefulComponentBase` (in `src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs`) for where persistence is invoked. diff --git a/docs/Components/Workers/AsyncWorkerBase.md b/docs/Components/Workers/AsyncWorkerBase.md index f461286d..b3deb1e1 100644 --- a/docs/Components/Workers/AsyncWorkerBase.md +++ b/docs/Components/Workers/AsyncWorkerBase.md @@ -21,5 +21,5 @@ Separate UI/component concerns from the algorithm that runs off the UI thread. ## Related -- [AsyncComponentBase](../ComponentBase/AsyncComponentBase.md), [StatefulAsyncComponentBase](../ComponentBase/StatefulAsyncComponentBase.md) – hosts for workers. +- [AsyncComponentBase](../ComponentBase/AsyncComponentBase.md), [StatefulComponentBase](../ComponentBase/StatefulComponentBase.md) – hosts for workers. - [ProgressInfo](../Helpers/ProgressInfo.md) – progress payload. diff --git a/docs/Components/index.md b/docs/Components/index.md index fa099491..a6fd743c 100644 --- a/docs/Components/index.md +++ b/docs/Components/index.md @@ -12,9 +12,9 @@ Expose AI capabilities (chat, list/text generation, image generation, canvas uti - `src/SmartHopper.Components.Test/` — test-only components (not built in Release) - Bases in `src/SmartHopper.Core/ComponentBase/`: - [AsyncComponentBase](./ComponentBase/AsyncComponentBase.md) — base for long-running async operations off the UI thread - - [StatefulAsyncComponentBase](./ComponentBase/StatefulAsyncComponentBase.md) — async state machine with debouncing, progress, and error handling - - [AIProviderComponentBase](./ComponentBase/AIProviderComponentBase.md) — provider/model selection UI and persistence - - [AIStatefulAsyncComponentBase](./ComponentBase/AIStatefulAsyncComponentBase.md) — AI provider integration + stateful async execution + - [StatefulComponentBase](./ComponentBase/StatefulComponentBase.md) — state machine with debouncing, progress, and error handling + - [AIProviderComponentBase](./ComponentBase/AIProviderComponentBase.md) — provider selection UI and persistence + - [AIStatefulAsyncComponentBase](./ComponentBase/AIStatefulAsyncComponentBase.md) — AI provider integration + stateful execution - [SelectingComponentBase](./ComponentBase/SelectingComponentBase.md) — adds a "Select Components" button and selection management - [Data-tree processing schema](./ComponentBase/DataTreeProcessingSchema.md) — centralized data-tree processing model and topologies - AI catalog: [AI Components](./AI/index.md) diff --git a/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md b/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md index 64c28673..466e5394 100644 --- a/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md +++ b/docs/Reviews/251224 StatefulAsyncComponentBase State Management.md @@ -1008,8 +1008,8 @@ public abstract class StatefulComponentBase : AsyncComponentBase - [x] Update base class inheritance - [x] Remove any direct hash/timer manipulation (none found - API compatible) - [x] Verify `Read()`/`Write()` use StateManager methods (delegated automatically) -- [ ] Test file save/restore cycle (validation pending) -- [ ] Test debounce behavior with rapid input changes (validation pending) +- [x] Test file save/restore cycle (validation pending) +- [x] Test debounce behavior with rapid input changes (validation pending) - [ ] Test cancellation during processing (validation pending) - [ ] Verify runtime messages preserved (validation pending) diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs index 188035a5..1dfdcd5a 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for BranchFlatten topology: all items from all branches are flattened into a single list, /// processed together, and the results are placed in a single output branch. /// - public class DataTreeProcessorBranchFlattenTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorBranchFlattenTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("E9642177-D368-4E9D-9BD6-E84C46D0958F"); protected override Bitmap Icon => null; @@ -172,3 +172,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs index fc890bc3..060bb061 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -28,7 +28,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// where each branch is processed independently and maintains its branch structure. /// This is the list-level processing mode used by AIListEvaluate and AIListFilter. /// - public class DataTreeProcessorBranchToBranchTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorBranchToBranchTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("4FBE8A03-5A39-4C99-B190-F95468A0D3AC"); protected override Bitmap Icon => null; @@ -177,3 +177,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs index e303066b..73b9e004 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 7 & 8: A={0}, B={1;0},{1;1} - Deeper paths under different root 1 /// Rule 3 applies: A broadcasts to ALL deeper paths regardless of root /// - public class DataTreeProcessorBroadcastDeeperDiffRootTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorBroadcastDeeperDiffRootTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("D659A768-A076-4946-80B9-A8AD99D4F740"); protected override Bitmap Icon => null; @@ -162,3 +160,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs index 5aaa77b8..1f25255c 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 5 & 6: A={0}, B={0;0},{0;1} - Deeper paths under same root 0 /// Rule 3 applies: A broadcasts to ALL deeper paths /// - public class DataTreeProcessorBroadcastDeeperSameRootTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorBroadcastDeeperSameRootTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("0E1105D8-1EA0-446B-B51D-F90D1EC29342"); protected override Bitmap Icon => null; @@ -169,3 +167,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs index 1b1d7778..37bd26d4 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 4: A={0}, B={1},{2} - Multiple top-level paths, none is {0} /// Rule 2 applies: A broadcasts to ALL paths in B /// - public class DataTreeProcessorBroadcastMultipleNoZeroTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorBroadcastMultipleNoZeroTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("11287A68-04D7-46F4-99DE-C5B0C45F0732"); protected override Bitmap Icon => null; @@ -162,3 +160,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs index 15483e0c..93621a62 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 3: A={0}, B={0},{1} - Multiple top-level paths including {0} /// Rule 2 applies: A broadcasts to ALL paths in B (including {0} and {1}) /// - public class DataTreeProcessorBroadcastMultipleTopLevelTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorBroadcastMultipleTopLevelTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("CBD8E900-B6EF-4ADE-B68C-A1A6AB486647"); protected override Bitmap Icon => null; @@ -164,3 +162,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs index 8ff7e99a..c54676aa 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -26,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input one item, second input three items, different paths. /// - public class DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("A8D1E0F3-3C2B-4E1E-9B3F-1A2C3D4E5F60"); protected override Bitmap Icon => null; @@ -174,3 +173,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs index 28a64533..fb9c59ce 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -26,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input three items, second input one item, different paths. /// - public class DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("7A6E5F0B-9D3C-4A0C-8B2E-1F3A4D5C6B7E"); protected override Bitmap Icon => null; @@ -175,3 +174,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs index 49b6c513..c68d3f01 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -26,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, one item each, different paths. Validates non-matching paths processing. /// - public class DataTreeProcessorDifferentPathsOneItemEachTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorDifferentPathsOneItemEachTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("F26E3A5B-2EFD-4F7B-8D8A-7C9A6B6882A2"); protected override Bitmap Icon => null; @@ -173,3 +172,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs index 5c92de61..9b634cbb 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -29,7 +28,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Only when paths match should items be matched item-by-item. In this test, /// the output should be identical to the input trees (per-branch passthrough). /// - public class DataTreeProcessorDifferentPathsThreeItemsEachTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorDifferentPathsThreeItemsEachTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("5A7B9B0C-12D0-4B90-AE17-5D1F764C6C5A"); protected override Bitmap Icon => null; @@ -192,3 +191,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs index 3a40d531..cd129dab 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 9 & 10: A={0}, B={0},{0;0},{0;1} - Direct match + deeper paths /// Rule 4 applies: A matches ONLY B's {0}, NOT the deeper {0;0} or {0;1} /// - public class DataTreeProcessorDirectMatchPrecedenceTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorDirectMatchPrecedenceTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("77095C92-474F-4D5C-9EA6-6FE31FFFA710"); protected override Bitmap Icon => null; @@ -164,3 +162,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs index 6685bdc7..c06993bb 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -26,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input one item, second input three items, equal paths. /// - public class DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("0C6B2C9E-2D68-45AC-A2D8-7B2E5F97F9C3"); protected override Bitmap Icon => null; @@ -173,3 +172,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs index 9de24d81..71fde5c7 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -26,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// /// Test component: two inputs, first input three items, second input one item, equal paths. /// - public class DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("B3C7D9E1-4A5B-4F2C-8B1D-2E3F4A5B6C7D"); protected override Bitmap Icon => null; @@ -173,3 +172,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs index 8f06d62c..997e6216 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; using System.Threading; using System.Threading.Tasks; @@ -26,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component to validate DataTreeProcessor with two trees having equal paths (one item each). /// Internal hardcoded inputs are used; only Run? is exposed. Outputs the result tree, success flag, and messages. /// - public class DataTreeProcessorEqualPathsTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorEqualPathsTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("B0C2B1B7-3A6C-46A5-9E52-9F9E4F6B7C11"); protected override Bitmap Icon => null; @@ -185,3 +184,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs index 30e95250..03bf89d2 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component to validate DataTreeProcessor with two trees having equal paths (three items each). /// Uses internal data; outputs result tree, success flag, and messages. /// - public class DataTreeProcessorEqualPathsThreeItemsTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorEqualPathsThreeItemsTestComponent : StatefulComponentBase { /// /// Gets the unique component identifier. @@ -201,3 +200,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs index db606279..11417560 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -27,7 +27,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for GroupIdenticalBranches flag: identical branches are grouped and processed only once. /// When branches have identical content across inputs, they should be processed only once. /// - public class DataTreeProcessorGroupIdenticalTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorGroupIdenticalTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("CEF161C9-36FC-4503-8BB0-9717EEA13865"); protected override Bitmap Icon => null; @@ -180,3 +180,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs index 1874dbfe..4b8e240d 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for ItemGraft topology: each item is grafted into its own separate branch. /// Each item from input trees is processed independently, and results are grafted into separate branches. /// - public class DataTreeProcessorItemGraftTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorItemGraftTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("3B09EE1F-00A3-4B7D-86DA-4C7EB0C6C0C3"); protected override Bitmap Icon => null; @@ -175,3 +174,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs index 3679f242..ca3ca2d4 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +26,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test component for ItemToItem topology: processes items independently across matching paths. /// Each item from input trees is processed independently, and results maintain the same branch structure. /// - public class DataTreeProcessorItemToItemTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorItemToItemTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("C4D5E6F7-8A9B-4C0D-9E1F-2A3B4C5D6E7F"); protected override Bitmap Icon => null; @@ -172,3 +171,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs index b2556ec8..859ef7b4 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 12 & 13: A={0}, B={0;0},{1},{1;0} - Mixed depths and roots /// Rule 3 applies: A broadcasts to ALL paths (deeper topology present) /// - public class DataTreeProcessorMixedDepthsTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorMixedDepthsTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("F788712F-B2B3-4131-87CA-E654F6153339"); protected override Bitmap Icon => null; @@ -167,3 +165,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs index bdd4fc70..8e237462 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -10,9 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Drawing; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; @@ -27,7 +25,7 @@ namespace SmartHopper.Components.Test.DataProcessor /// Test Case 11a&11b: A={0}, B={0},{1},{2} - Multiple top-level paths including {0} /// Rule 2 overrides Rule 4: A broadcasts to ALL paths (structural complexity) /// - public class DataTreeProcessorRule2OverrideTestComponent : StatefulComponentBaseV2 + public class DataTreeProcessorRule2OverrideTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("FE2E0986-FFAF-4A64-9F97-0FD3F4E571D8"); protected override Bitmap Icon => null; @@ -166,3 +164,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs index a1138748..866649c9 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -24,7 +24,7 @@ namespace SmartHopper.Components.Test.Misc /// This component demonstrates debounce cancellation and generation-based /// stale callback prevention. /// - public class TestStateManagerDebounceComponent : StatefulComponentBaseV2 + public class TestStateManagerDebounceComponent : StatefulComponentBase { /// /// The ComponentStateManager instance for this component. @@ -244,3 +244,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs index c170d6be..22c41771 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -25,7 +25,7 @@ namespace SmartHopper.Components.Test.Misc /// This component demonstrates the new state management pattern and can be used /// to manually test file save/restore behavior in Grasshopper. /// - public class TestStateManagerRestorationComponent : StatefulComponentBaseV2 + public class TestStateManagerRestorationComponent : StatefulComponentBase { /// /// The ComponentStateManager instance for this component. @@ -244,3 +244,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs index 2c1c7478..e8a31111 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024 Marc Roca Musach * @@ -24,7 +24,7 @@ namespace SmartHopper.Components.Test.Misc { - public class TestStatefulPrimeCalculatorComponent : StatefulComponentBaseV2 + public class TestStatefulPrimeCalculatorComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("C2C612B0-2C57-47CE-B9FE-E10621F18935"); protected override Bitmap Icon => null; @@ -124,3 +124,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs index 975725bf..7fbbd9da 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024 Marc Roca Musach * @@ -28,7 +28,7 @@ namespace SmartHopper.Components.Test.Misc { - public class TestStatefulTreePrimeCalculatorComponent : StatefulComponentBaseV2 + public class TestStatefulTreePrimeCalculatorComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("E2DB56F0-C597-432C-9774-82DF431CC848"); protected override Bitmap Icon => null; @@ -151,3 +151,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components/AI/AIFileContextComponent.cs b/src/SmartHopper.Components/AI/AIFileContextComponent.cs index 42f4d3dd..70230592 100644 --- a/src/SmartHopper.Components/AI/AIFileContextComponent.cs +++ b/src/SmartHopper.Components/AI/AIFileContextComponent.cs @@ -13,7 +13,6 @@ using System.Drawing; using Grasshopper.Kernel; using SmartHopper.Infrastructure.AIContext; -using SmartHopper.Infrastructure.AIProviders; namespace SmartHopper.Components.AI { diff --git a/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs b/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs index 6455ca59..7ebc39af 100644 --- a/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs @@ -19,8 +19,6 @@ using SmartHopper.Core.ComponentBase; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; -using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; namespace SmartHopper.Components.Grasshopper diff --git a/src/SmartHopper.Components/Grasshopper/GhMergeComponents.cs b/src/SmartHopper.Components/Grasshopper/GhMergeComponents.cs index ccfe179a..c1580a6d 100644 --- a/src/SmartHopper.Components/Grasshopper/GhMergeComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhMergeComponents.cs @@ -15,8 +15,6 @@ using SmartHopper.Components.Properties; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; -using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; namespace SmartHopper.Components.Grasshopper diff --git a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs index 209a3daf..67a44ab8 100644 --- a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024 Marc Roca Musach * @@ -27,9 +27,9 @@ namespace SmartHopper.Components.Grasshopper { /// /// Grasshopper component for placing components from JSON data. - /// Uses StatefulComponentBaseV2 to properly manage async execution, state, and prevent re-entrancy. + /// Uses StatefulComponentBase to properly manage async execution, state, and prevent re-entrancy. /// - public class GhPutComponents : StatefulComponentBaseV2 + public class GhPutComponents : StatefulComponentBase { /// /// Initializes a new instance of the class. @@ -43,7 +43,7 @@ public GhPutComponents() } /// - public override Guid ComponentGuid => new ("25E07FD9-382C-48C0-8A97-8BFFAEAD8592"); + public override Guid ComponentGuid => new("25E07FD9-382C-48C0-8A97-8BFFAEAD8592"); /// protected override Bitmap Icon => Resources.ghput; @@ -239,3 +239,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs b/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs index 112fdcf4..75c8b9c6 100644 --- a/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs @@ -20,10 +20,7 @@ using SmartHopper.Core.ComponentBase; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; -using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; -using SmartHopper.Infrastructure.AITools; namespace SmartHopper.Components.Grasshopper { diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumDeconstructPostComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumDeconstructPostComponent.cs index 291cbe75..8228f8ac 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumDeconstructPostComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumDeconstructPostComponent.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using Grasshopper.Kernel; using Newtonsoft.Json.Linq; using SmartHopper.Components.Properties; diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs index 71b3f548..1a3d0c51 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -25,11 +25,10 @@ using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; -using SmartHopper.Infrastructure.AITools; namespace SmartHopper.Components.Knowledge { - public class McNeelForumPostGetComponent : StatefulComponentBaseV2 + public class McNeelForumPostGetComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("7C1B9A33-0177-4A60-9C08-9F8A1E4F2002"); @@ -199,3 +198,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs index 757f6742..63df8833 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -9,13 +9,11 @@ */ using System; -using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.Threading; using System.Threading.Tasks; using Grasshopper.Kernel; -using Grasshopper.Kernel.Types; using Newtonsoft.Json.Linq; using SmartHopper.Components.Properties; using SmartHopper.Core.ComponentBase; @@ -25,7 +23,7 @@ namespace SmartHopper.Components.Knowledge /// /// Opens the McNeelForum page for a given post JSON in the default browser. /// - public class McNeelForumPostOpenComponent : StatefulComponentBaseV2 + public class McNeelForumPostOpenComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("1B7A2E6C-4F0B-4B1C-9D19-6B3A2C8F9012"); @@ -178,3 +176,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs index 7b217367..b27f2551 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -25,11 +25,10 @@ using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; -using SmartHopper.Infrastructure.AITools; namespace SmartHopper.Components.Knowledge { - public class McNeelForumSearchComponent : StatefulComponentBaseV2 + public class McNeelForumSearchComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("5F8F0D47-29D6-44D8-A5B1-2E7C6A9B1001"); @@ -242,3 +241,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs b/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs index 8045a55b..5841acca 100644 --- a/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs +++ b/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -25,11 +25,10 @@ using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; -using SmartHopper.Infrastructure.AITools; namespace SmartHopper.Components.Knowledge { - public class WebPageReadComponent : StatefulComponentBaseV2 + public class WebPageReadComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("C2E6B13A-6245-4A4F-8C8F-3B7616D33003"); @@ -209,3 +208,4 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } + diff --git a/src/SmartHopper.Components/Text/AITextEvaluate.cs b/src/SmartHopper.Components/Text/AITextEvaluate.cs index ea1f977e..fc756140 100644 --- a/src/SmartHopper.Components/Text/AITextEvaluate.cs +++ b/src/SmartHopper.Components/Text/AITextEvaluate.cs @@ -179,7 +179,7 @@ private static async Task>> ProcessData(Dict Debug.WriteLine($"[ProcessData] Tool result: {toolResult?.ToString() ?? "null"}"); - var resultToken = toolResult? ["result"]; + var resultToken = toolResult?["result"]; bool? parsedResult = ParseBooleanResult(resultToken); if (!parsedResult.HasValue) diff --git a/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs b/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs index 1b15c8c0..f2133de3 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Text.RegularExpressions; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs b/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs index e8786ade..0c0616a5 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs @@ -15,9 +15,7 @@ using Grasshopper; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/_gh_generate.cs b/src/SmartHopper.Core.Grasshopper/AITools/_gh_generate.cs index 8e46da94..336c2b1a 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/_gh_generate.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/_gh_generate.cs @@ -17,9 +17,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Serialization; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/_gh_parameter_modifier.cs b/src/SmartHopper.Core.Grasshopper/AITools/_gh_parameter_modifier.cs index d90a4b6e..83f6d68a 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/_gh_parameter_modifier.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/_gh_parameter_modifier.cs @@ -11,18 +11,14 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using Grasshopper; using Grasshopper.Kernel; -using Grasshopper.Kernel.Data; using Newtonsoft.Json.Linq; using Rhino; using SmartHopper.Core.Grasshopper.Utils.Canvas; using SmartHopper.Core.Grasshopper.Utils.Components; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/_script_parameter_modifier.cs b/src/SmartHopper.Core.Grasshopper/AITools/_script_parameter_modifier.cs index 3b329a81..adc3361f 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/_script_parameter_modifier.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/_script_parameter_modifier.cs @@ -17,12 +17,9 @@ using Newtonsoft.Json.Linq; using Rhino; using RhinoCodePlatform.GH; -using SmartHopper.Core.Grasshopper.Serialization.GhJson.ScriptComponents; using SmartHopper.Core.Grasshopper.Utils.Canvas; using SmartHopper.Core.Grasshopper.Utils.Components; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; @@ -203,7 +200,8 @@ public IEnumerable GetTools() requiredCapabilities: this.toolCapabilityRequirements); } - private async Task AddInputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_add_input", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task AddInputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_add_input", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var name = args["name"]?.ToString() ?? throw new ArgumentException("Missing name"); var typeHint = args["typeHint"]?.ToString() ?? "object"; var access = args["access"]?.ToString() ?? "item"; @@ -213,7 +211,8 @@ private async Task AddInputParameterAsync(AIToolCall toolCall) => awai return $"Added input '{name}' ({typeHint}, {access})"; })); - private async Task AddOutputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_add_output", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task AddOutputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_add_output", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var name = args["name"]?.ToString() ?? throw new ArgumentException("Missing name"); var typeHint = args["typeHint"]?.ToString() ?? "object"; var description = args["description"]?.ToString() ?? ""; @@ -221,7 +220,8 @@ private async Task AddOutputParameterAsync(AIToolCall toolCall) => awa return $"Added output '{name}' ({typeHint})"; })); - private async Task RemoveInputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_remove_input", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task RemoveInputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_remove_input", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); if (index < 0 || index >= comp.Params.Input.Count) throw new ArgumentException($"Index {index} out of range"); var paramName = comp.Params.Input[index].Name; @@ -229,7 +229,8 @@ private async Task RemoveInputParameterAsync(AIToolCall toolCall) => a return $"Removed input '{paramName}' at index {index}"; })); - private async Task RemoveOutputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_remove_output", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task RemoveOutputParameterAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_remove_output", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); if (index < 0 || index >= comp.Params.Output.Count) throw new ArgumentException($"Index {index} out of range"); var paramName = comp.Params.Output[index].Name; @@ -237,7 +238,8 @@ private async Task RemoveOutputParameterAsync(AIToolCall toolCall) => return $"Removed output '{paramName}' at index {index}"; })); - private async Task SetInputTypeHintAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_type_input", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task SetInputTypeHintAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_type_input", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); var typeHint = args["typeHint"]?.ToString() ?? throw new ArgumentException("Missing typeHint"); if (index < 0 || index >= comp.Params.Input.Count) throw new ArgumentException($"Index {index} out of range"); @@ -246,7 +248,8 @@ private async Task SetInputTypeHintAsync(AIToolCall toolCall) => await return $"Set type hint '{typeHint}' for input '{paramName}'"; })); - private async Task SetOutputTypeHintAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_type_output", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task SetOutputTypeHintAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_type_output", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); var typeHint = args["typeHint"]?.ToString() ?? throw new ArgumentException("Missing typeHint"); if (index < 0 || index >= comp.Params.Output.Count) throw new ArgumentException($"Index {index} out of range"); @@ -255,11 +258,13 @@ private async Task SetOutputTypeHintAsync(AIToolCall toolCall) => awai return $"Set type hint '{typeHint}' for output '{paramName}'"; })); - private async Task SetInputAccessAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_access", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task SetInputAccessAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_access", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); var access = args["access"]?.ToString() ?? throw new ArgumentException("Missing access"); if (index < 0 || index >= comp.Params.Input.Count) throw new ArgumentException($"Index {index} out of range"); - var accessType = access.ToLower() switch { + var accessType = access.ToLower() switch + { "item" => GH_ParamAccess.item, "list" => GH_ParamAccess.list, "tree" => GH_ParamAccess.tree, @@ -270,13 +275,15 @@ private async Task SetInputAccessAsync(AIToolCall toolCall) => await E return $"Set access '{access}' for input '{paramName}'"; })); - private async Task ToggleStandardOutputAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_toggle_std_output", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task ToggleStandardOutputAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_toggle_std_output", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var show = args["show"]?.ToObject() ?? throw new ArgumentException("Missing show"); ScriptModifier.SetShowStandardOutput(scriptComp, show); return $"{(show ? "Showed" : "Hid")} standard output parameter"; })); - private async Task SetPrincipalInputAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_set_principal_input", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task SetPrincipalInputAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_set_principal_input", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); if (index < 0 || index >= comp.Params.Input.Count) throw new ArgumentException($"Index {index} out of range"); var paramName = comp.Params.Input[index].Name; @@ -284,7 +291,8 @@ private async Task SetPrincipalInputAsync(AIToolCall toolCall) => awai return $"Set principal input to '{paramName}' at index {index}"; })); - private async Task SetInputOptionalAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_optional", args => ExecuteScriptOp(args, (scriptComp, comp) => { + private async Task SetInputOptionalAsync(AIToolCall toolCall) => await ExecuteScriptModification(toolCall, "script_parameter_set_optional", args => ExecuteScriptOp(args, (scriptComp, comp) => + { var index = args["index"]?.ToObject() ?? throw new ArgumentException("Missing index"); var optional = args["optional"]?.ToObject() ?? throw new ArgumentException("Missing optional"); if (index < 0 || index >= comp.Params.Input.Count) throw new ArgumentException($"Index {index} out of range"); @@ -315,8 +323,10 @@ private async Task ExecuteScriptModification(AIToolCall toolCall, stri var toolInfo = toolCall.GetToolCall(); var args = toolInfo.Arguments ?? new JObject(); var tcs = new TaskCompletionSource(); - RhinoApp.InvokeOnUiThread(() => { - try { + RhinoApp.InvokeOnUiThread(() => + { + try + { string message = operation(args); Debug.WriteLine($"[{toolName}] {message}"); var body = AIBodyBuilder.Create() @@ -328,7 +338,9 @@ private async Task ExecuteScriptModification(AIToolCall toolCall, stri .Build(); output.CreateSuccess(body, toolCall); tcs.SetResult(output); - } catch (Exception ex) { + } + catch (Exception ex) + { tcs.SetException(ex); } }); diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_component_lock.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_component_lock.cs index 77d26928..58874059 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_component_lock.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_component_lock.cs @@ -15,9 +15,7 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_component_preview.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_component_preview.cs index 14fbc5c2..0278f4b1 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_component_preview.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_component_preview.cs @@ -15,9 +15,7 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs index 30acbd14..b8fdb113 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs @@ -581,7 +581,8 @@ private Task GhGetToolAsync(AIToolCall toolCall, string[] predefinedAt serOptions3.IncludeGroups = false; var fullDoc = GhJsonSerializer.Serialize(allObjects, serOptions3); var edges = fullDoc.Connections - .Select(c => { + .Select(c => + { if (c.TryResolveGuids(fullDoc.GetIdToGuidMapping(), out var from, out var to)) return (from: from, to: to, valid: true); return (from: Guid.Empty, to: Guid.Empty, valid: false); diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_group.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_group.cs index 2a71ea34..d9c34e37 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_group.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_group.cs @@ -18,7 +18,6 @@ using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Converters; using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Core.Grasshopper.Utils.Serialization; using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; @@ -117,8 +116,8 @@ private Task GhGroupAsync(AIToolCall toolCall) if (!validGuids.Any()) { - output.CreateError("No valid GUIDs provided for grouping."); - return Task.FromResult(output); + output.CreateError("No valid GUIDs provided for grouping."); + return Task.FromResult(output); } GH_Group group = null; @@ -171,17 +170,17 @@ private Task GhGroupAsync(AIToolCall toolCall) // Resolve task result if (group != null) { - var toolResult = new JObject - { - ["group"] = group.InstanceGuid.ToString(), - ["grouped"] = JArray.FromObject(validGuids.Select(g => g.ToString())), - }; + var toolResult = new JObject + { + ["group"] = group.InstanceGuid.ToString(), + ["grouped"] = JArray.FromObject(validGuids.Select(g => g.ToString())), + }; - var body = AIBodyBuilder.Create() - .AddToolResult(toolResult) - .Build(); + var body = AIBodyBuilder.Create() + .AddToolResult(toolResult) + .Build(); - output.CreateSuccess(body, toolCall); + output.CreateSuccess(body, toolCall); } else { diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_list_categories.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_list_categories.cs index 1d99af23..e5a1bbb0 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_list_categories.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_list_categories.cs @@ -14,9 +14,7 @@ using System.Threading.Tasks; using Grasshopper; using Newtonsoft.Json.Linq; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_list_components.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_list_components.cs index 8fd2db6b..6e2818a4 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_list_components.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_list_components.cs @@ -18,9 +18,7 @@ using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Canvas; using SmartHopper.Core.Grasshopper.Utils.Internal; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_merge.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_merge.cs index a43a03f5..21424827 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_merge.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_merge.cs @@ -16,9 +16,7 @@ using SmartHopper.Core.Grasshopper.Serialization.GhJson; using SmartHopper.Core.Grasshopper.Utils.Serialization; using SmartHopper.Core.Models.Serialization; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_move.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_move.cs index fb19a715..d21ae814 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_move.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_move.cs @@ -16,9 +16,7 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs index b00f31e9..f08fda2e 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs @@ -22,9 +22,7 @@ using SmartHopper.Core.Grasshopper.Utils.Canvas; using SmartHopper.Core.Grasshopper.Utils.Serialization; using SmartHopper.Core.Models.Serialization; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_tidy_up.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_tidy_up.cs index cd5f31f0..524c7b27 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_tidy_up.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_tidy_up.cs @@ -18,10 +18,7 @@ using SmartHopper.Core.Grasshopper.Graph; using SmartHopper.Core.Grasshopper.Serialization.GhJson; using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Core.Grasshopper.Utils.Serialization; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; @@ -121,9 +118,9 @@ private async Task GhTidyUpAsync(AIToolCall toolCall) var currentObjs = CanvasAccess.GetCurrentObjects(); var selected = currentObjs.Where(o => guids.Contains(o.InstanceGuid.ToString())).ToList(); - if (!selected.Any()) - { - Debug.WriteLine("[GhObjTools] GhTidyUpAsync: No matching GUIDs found."); + if (!selected.Any()) + { + Debug.WriteLine("[GhObjTools] GhTidyUpAsync: No matching GUIDs found."); output.CreateError("No matching components found for provided GUIDs."); return output; } diff --git a/src/SmartHopper.Core.Grasshopper/AITools/img_generate.cs b/src/SmartHopper.Core.Grasshopper/AITools/img_generate.cs index d83d1971..0d8ac627 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/img_generate.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/img_generate.cs @@ -12,8 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Grasshopper.Kernel; -using Grasshopper.Kernel.Types; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs b/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs index 6ce07024..b0d625dd 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/instruction_get.cs @@ -30,7 +30,7 @@ public IEnumerable GetTools() { yield return new AITool( name: ToolName, - description: "Returns detailed operational instructions for SmartHopper. REQUIRED: Pass `topic` with one of: canvas, discovery, scripting, knowledge, ghjson, selected, errors, locks, visibility. Use this to retrieve guidance instead of relying on a long system prompt.", + description: "Returns detailed operational instructions for SmartHopper. REQUIRED: Pass `topic` with one of: canvas, ghjson, selected, errors, locks, visibility, discovery, scripting, python, csharp, vb, knowledge, mcneel-forum, research, web. Use this to retrieve guidance instead of relying on a long system prompt.", category: "Instructions", parametersSchema: @"{ ""type"": ""object"", @@ -121,6 +121,9 @@ Quick actions on selected components (no GUIDs needed): """; case "knowledge": + case "mcneel-forum": + case "research": + case "web": return """ Knowledge base workflow: 1) mcneel_forum_search: find candidate posts/topics. @@ -130,6 +133,9 @@ Quick actions on selected components (no GUIDs needed): """; case "scripting": + case "python": + case "csharp": + case "vb": return """ Scripting rules: - When the user asks to CREATE or MODIFY a Grasshopper script component, use the scripting tools (do not only reply in natural language). @@ -160,7 +166,7 @@ Quick actions on selected components (no GUIDs needed): """; default: - return "Unknown topic. Call the `instruction_get` function again and specify the `topic` argument. Valid topics are canvas, discovery, scripting, knowledge, ghjson, selected, errors, locks, visibility."; + return "Unknown topic. Call the `instruction_get` function again and specify the `topic` argument. Valid topics are canvas, ghjson, selected, errors, locks, visibility, discovery, scripting, python, csharp, vb, knowledge, mcneel-forum, research, web."; } } } diff --git a/src/SmartHopper.Core.Grasshopper/AITools/list_filter.cs b/src/SmartHopper.Core.Grasshopper/AITools/list_filter.cs index 750d82be..d05149a0 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/list_filter.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/list_filter.cs @@ -13,7 +13,6 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; -using Grasshopper.Kernel; using Grasshopper.Kernel.Types; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Parsing; @@ -24,7 +23,6 @@ using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; -using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Core.Grasshopper.AITools { diff --git a/src/SmartHopper.Core.Grasshopper/AITools/list_generate.cs b/src/SmartHopper.Core.Grasshopper/AITools/list_generate.cs index c204c515..01239554 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/list_generate.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/list_generate.cs @@ -24,7 +24,6 @@ using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; -using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Core.Grasshopper.AITools { diff --git a/src/SmartHopper.Core.Grasshopper/AITools/mcneel_forum_search.cs b/src/SmartHopper.Core.Grasshopper/AITools/mcneel_forum_search.cs index c4889ac2..fc3b8f2f 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/mcneel_forum_search.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/mcneel_forum_search.cs @@ -16,7 +16,6 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.Tools; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs b/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs index 30b13f63..15d9cb19 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs @@ -27,7 +27,6 @@ using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; -using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Core.Grasshopper.AITools { diff --git a/src/SmartHopper.Core.Grasshopper/AITools/text_evaluate.cs b/src/SmartHopper.Core.Grasshopper/AITools/text_evaluate.cs index 0a9c4ed8..746274e0 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/text_evaluate.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/text_evaluate.cs @@ -12,8 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Grasshopper.Kernel; -using Grasshopper.Kernel.Types; using Newtonsoft.Json.Linq; using SmartHopper.Core.Grasshopper.Utils.Parsing; using SmartHopper.Infrastructure.AICall.Core.Base; @@ -23,7 +21,6 @@ using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; -using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Core.Grasshopper.AITools { diff --git a/src/SmartHopper.Core.Grasshopper/AITools/text_generate.cs b/src/SmartHopper.Core.Grasshopper/AITools/text_generate.cs index 47f2fe86..2c283dd9 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/text_generate.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/text_generate.cs @@ -12,8 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; -using Grasshopper.Kernel; -using Grasshopper.Kernel.Types; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; @@ -22,7 +20,6 @@ using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; -using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Core.Grasshopper.AITools { diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/CanvasUtilities.cs b/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/CanvasUtilities.cs index e5428de5..9c668c92 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/CanvasUtilities.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/CanvasUtilities.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using Grasshopper.Kernel; using SmartHopper.Core.Grasshopper.Serialization.GhJson; diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/GroupManager.cs b/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/GroupManager.cs index 3783f518..4c5abb21 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/GroupManager.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/Canvas/GroupManager.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; -using System.Linq; using Grasshopper; using Grasshopper.Kernel; using Grasshopper.Kernel.Special; diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonMerger.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonMerger.cs index bb01f6d7..b7ffe3ca 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonMerger.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonMerger.cs @@ -15,7 +15,6 @@ using SmartHopper.Core.Models.Components; using SmartHopper.Core.Models.Connections; using SmartHopper.Core.Models.Document; -using SmartHopper.Core.Models.Serialization; namespace SmartHopper.Core.Grasshopper.Serialization.GhJson { diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptParameterMapper.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptParameterMapper.cs index 577509d5..3eea9d08 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptParameterMapper.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptParameterMapper.cs @@ -324,7 +324,7 @@ public static ScriptVariableParam CreateParameter( if (typeHintProp != null && typeHintProp.CanRead) { var appliedHint = typeHintProp.GetValue(param) as string; - Debug.WriteLine($"[ScriptParameterMapper] Final TypeHint on '{variableName}': '{appliedHint ?? "null"}' (expected: '{settings.TypeHint ?? "null"}')" ); + Debug.WriteLine($"[ScriptParameterMapper] Final TypeHint on '{variableName}': '{appliedHint ?? "null"}' (expected: '{settings.TypeHint ?? "null"}')"); } } catch { } diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptSignatureParser.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptSignatureParser.cs index e398926b..1187ac80 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptSignatureParser.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/ScriptComponents/ScriptSignatureParser.cs @@ -11,10 +11,8 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Text; using System.Text.RegularExpressions; -using Grasshopper.Kernel; using RhinoCodePlatform.GH; using SmartHopper.Core.Grasshopper.Utils.Internal; using SmartHopper.Core.Models.Components; diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/SerializationOptions.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/SerializationOptions.cs index 79550651..f172a5bb 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/SerializationOptions.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/SerializationOptions.cs @@ -8,10 +8,8 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using SmartHopper.Core.Grasshopper.Utils.Serialization; using SmartHopper.Core.Grasshopper.Utils.Serialization.PropertyFilters; -using SmartHopper.Core.Models.Serialization; namespace SmartHopper.Core.Grasshopper.Serialization.GhJson { diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/Shared/ParameterMapper.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/Shared/ParameterMapper.cs index 2c10a50d..0e51be71 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/Shared/ParameterMapper.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/Shared/ParameterMapper.cs @@ -11,7 +11,6 @@ using System; using System.Diagnostics; using Grasshopper.Kernel; -using Grasshopper.Kernel.Special; using SmartHopper.Core.Models.Components; namespace SmartHopper.Core.Grasshopper.Serialization.GhJson.Shared diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentManipulation.cs b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentManipulation.cs index 1530a6de..e962c8fc 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentManipulation.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentManipulation.cs @@ -13,7 +13,6 @@ using System.Drawing; using Grasshopper; using Grasshopper.Kernel; -using SmartHopper.Core.Grasshopper.Utils.Canvas; namespace SmartHopper.Core.Grasshopper.Utils.Canvas { diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ConnectionBuilder.cs b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ConnectionBuilder.cs index c40efe34..aa8e0064 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ConnectionBuilder.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ConnectionBuilder.cs @@ -13,8 +13,6 @@ using System.Linq; using Grasshopper; using Grasshopper.Kernel; -using SmartHopper.Core.Grasshopper.Utils.Canvas; -using SmartHopper.Core.Grasshopper.Utils.Internal; namespace SmartHopper.Core.Grasshopper.Utils.Canvas { diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Components/ParameterModifier.cs b/src/SmartHopper.Core.Grasshopper/Utils/Components/ParameterModifier.cs index d54fb353..767829e8 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Components/ParameterModifier.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Components/ParameterModifier.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using Grasshopper.Kernel; -using Grasshopper.Kernel.Data; namespace SmartHopper.Core.Grasshopper.Utils.Components { diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Components/ScriptModifier.cs b/src/SmartHopper.Core.Grasshopper/Utils/Components/ScriptModifier.cs index 02e27840..ef25a29a 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Components/ScriptModifier.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Components/ScriptModifier.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using Grasshopper.Kernel; -using Grasshopper.Kernel.Data; using Newtonsoft.Json.Linq; using RhinoCodePlatform.GH; using RhinoCodePluginGH.Parameters; diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Internal/ComponentRetriever.cs b/src/SmartHopper.Core.Grasshopper/Utils/Internal/ComponentRetriever.cs index c8d4450b..04b57868 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Internal/ComponentRetriever.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Internal/ComponentRetriever.cs @@ -87,7 +87,7 @@ internal sealed class ComponentRetriever /// Available Grasshopper component categories (e.g. Params, Maths, Vector, Curve, Surface, Mesh, etc.). /// Maps common abbreviations or alternate names to canonical category tokens. /// - public static readonly Dictionary CategorySynonyms = new () + public static readonly Dictionary CategorySynonyms = new() { { "PARAM", "PARAMS" }, { "PARAMETERS", "PARAMS" }, diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Internal/WebUtilities.cs b/src/SmartHopper.Core.Grasshopper/Utils/Internal/WebUtilities.cs index 705eb620..4c05193e 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Internal/WebUtilities.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Internal/WebUtilities.cs @@ -18,7 +18,7 @@ namespace SmartHopper.Core.Grasshopper.Utils.Internal /// internal class WebUtilities { - private readonly List _disallowed = new (); + private readonly List _disallowed = new(); /// /// Initializes a new instance of the class. diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Rhino/File3dmReader.cs b/src/SmartHopper.Core.Grasshopper/Utils/Rhino/File3dmReader.cs index 4c1c6571..14947ddb 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Rhino/File3dmReader.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Rhino/File3dmReader.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Linq; using Newtonsoft.Json.Linq; using RhinoDocObjects = global::Rhino.DocObjects; using RhinoFileIO = global::Rhino.FileIO; diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Serialization/DataTreeConverter.cs b/src/SmartHopper.Core.Grasshopper/Utils/Serialization/DataTreeConverter.cs index 78296ef8..48f6d7e8 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Serialization/DataTreeConverter.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Serialization/DataTreeConverter.cs @@ -33,11 +33,11 @@ public static partial class DataTreeConverter public static Dictionary> IGHStructureToDictionary(IGH_Structure structure) { - Dictionary> result = new (); + Dictionary> result = new(); foreach (GH_Path path in structure.Paths) { - List dataList = new (); + List dataList = new(); // Iterate over the data items in the path foreach (object dataItem in structure.get_Branch(path)) @@ -103,7 +103,7 @@ private static object SerializeDataItem(object dataItem) public static Dictionary IGHStructureDictionaryTo1DDictionary(Dictionary> dictionary) { - Dictionary result = new (); + Dictionary result = new(); foreach (var kvp in dictionary) { @@ -130,7 +130,7 @@ public static Dictionary IGHStructureDictionaryTo1DDictionary(Di public static GH_Structure JObjectToIGHStructure(JToken input, Func convertFunction) where T : IGH_Goo { - GH_Structure result = new (); + GH_Structure result = new(); // Handle JArray input if (input is JArray array) @@ -149,7 +149,7 @@ public static GH_Structure JObjectToIGHStructure(JToken input, Func indices = new (); + List indices = new(); foreach (var element in pathElements) { if (int.TryParse(element, out int index)) diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Serialization/PropertyFilters/PropertyFilter.cs b/src/SmartHopper.Core.Grasshopper/Utils/Serialization/PropertyFilters/PropertyFilter.cs index b045167b..7891f9f4 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Serialization/PropertyFilters/PropertyFilter.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Serialization/PropertyFilters/PropertyFilter.cs @@ -8,11 +8,8 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using System.Linq; -using Grasshopper.Kernel; -using Grasshopper.Kernel.Parameters; using Grasshopper.Kernel.Special; using RhinoCodePlatform.GH; diff --git a/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs index 3da5eebe..99a38c83 100644 --- a/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs +++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Drawing; using Grasshopper.GUI; using Grasshopper.GUI.Canvas; diff --git a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs index b06e6e84..849c23db 100644 --- a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs @@ -22,7 +22,7 @@ namespace SmartHopper.Core.ComponentBase /// Provides the provider selection context menu and related functionality on top of /// the stateful async component functionality. /// - public abstract class AIProviderComponentBase : StatefulComponentBaseV2 + public abstract class AIProviderComponentBase : StatefulComponentBase { /// /// Special value used to indicate that the default provider from settings should be used. diff --git a/src/SmartHopper.Core/ComponentBase/AISelectingStatefulAsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AISelectingStatefulAsyncComponentBase.cs index e9d95e6a..66a155c8 100644 --- a/src/SmartHopper.Core/ComponentBase/AISelectingStatefulAsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AISelectingStatefulAsyncComponentBase.cs @@ -11,13 +11,11 @@ using System; using System.Collections.Generic; using System.Drawing; -using System.Linq; using System.Windows.Forms; using Grasshopper; using Grasshopper.GUI; using Grasshopper.GUI.Canvas; using Grasshopper.Kernel; -using Grasshopper.Kernel.Attributes; using Timer = System.Timers.Timer; namespace SmartHopper.Core.ComponentBase diff --git a/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs index 547fc35e..4feedddd 100644 --- a/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs @@ -27,7 +27,6 @@ using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Returns; -using SmartHopper.Infrastructure.AICall.Metrics; using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; diff --git a/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs b/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs index 272abf08..c22acfd4 100644 --- a/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs +++ b/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs @@ -11,7 +11,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Threading; namespace SmartHopper.Core.ComponentBase diff --git a/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs b/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs index cea367f8..cccf89f5 100644 --- a/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs @@ -15,14 +15,12 @@ using System; using System.Collections.Generic; using System.Drawing; -using System.Linq; using System.Windows.Forms; using Grasshopper; using Grasshopper.GUI; using Grasshopper.GUI.Canvas; using Grasshopper.Kernel; using Grasshopper.Kernel.Attributes; -using SmartHopper.Core.UI; using Timer = System.Timers.Timer; namespace SmartHopper.Core.ComponentBase diff --git a/src/SmartHopper.Core/ComponentBase/SelectingComponentCore.cs b/src/SmartHopper.Core/ComponentBase/SelectingComponentCore.cs index 0480dcc9..a0aff336 100644 --- a/src/SmartHopper.Core/ComponentBase/SelectingComponentCore.cs +++ b/src/SmartHopper.Core/ComponentBase/SelectingComponentCore.cs @@ -17,7 +17,6 @@ using System.Drawing; using System.Linq; using Grasshopper; -using Grasshopper.GUI; using Grasshopper.GUI.Canvas; using Grasshopper.Kernel; using SmartHopper.Core.UI; diff --git a/src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs similarity index 96% rename from src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs rename to src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs index 3d868b70..2cf87ca4 100644 --- a/src/SmartHopper.Core/ComponentBase/StatefulComponentBaseV2.cs +++ b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -25,7 +25,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -46,7 +45,7 @@ namespace SmartHopper.Core.ComponentBase /// V2 implementation of stateful async component base using ComponentStateManager. /// Provides integrated state management, parallel processing, messaging, and persistence. /// - public abstract class StatefulComponentBaseV2 : AsyncComponentBase + public abstract class StatefulComponentBase : AsyncComponentBase { #region Fields @@ -80,6 +79,10 @@ public abstract class StatefulComponentBaseV2 : AsyncComponentBase /// private bool run; + private bool hasPreviousRun; + + private bool previousRun; + #endregion #region Properties @@ -127,14 +130,14 @@ public abstract class StatefulComponentBaseV2 : AsyncComponentBase #region Constructor /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The component's display name. /// The component's nickname. /// Description of the component's functionality. /// Category in the Grasshopper toolbar. /// Subcategory in the Grasshopper toolbar. - protected StatefulComponentBaseV2( + protected StatefulComponentBase( string name, string nickname, string description, @@ -265,7 +268,7 @@ protected override void RegisterInputParams(GH_Component.GH_InputParamManager pM /// Register component-specific input parameters. /// /// The input parameter manager. - protected abstract void RegisterAdditionalInputParams(GH_InputParamManager pManager); + protected abstract void RegisterAdditionalInputParams(GH_Component.GH_InputParamManager pManager); /// /// Registers output parameters for the component. @@ -280,7 +283,7 @@ protected override void RegisterOutputParams(GH_Component.GH_OutputParamManager /// Register component-specific output parameters. /// /// The output parameter manager. - protected abstract void RegisterAdditionalOutputParams(GH_OutputParamManager pManager); + protected abstract void RegisterAdditionalOutputParams(GH_Component.GH_OutputParamManager pManager); #endregion @@ -293,7 +296,7 @@ protected override void BeforeSolveInstance() { if (this.StateManager.CurrentState == ComponentState.Processing) { - Debug.WriteLine("[StatefulComponentBaseV2] Processing state... jumping to SolveInstance"); + Debug.WriteLine("[StatefulComponentBase] Processing state... jumping to SolveInstance"); return; } @@ -333,6 +336,10 @@ protected override void SolveInstance(IGH_DataAccess DA) DA.GetData("Run?", ref run); this.run = run; + // Note: GH_Button drives volatile data and may not affect PersistentData hashes. + // Track the last observed Run value to detect button pulses reliably. + bool runValueChanged = !this.hasPreviousRun || this.previousRun != this.run; + Debug.WriteLine($"[{this.GetType().Name}] SolveInstance - State: {this.StateManager.CurrentState}, InPreSolve: {this.InPreSolve}, Run: {this.run}"); // Calculate current input hashes @@ -362,7 +369,10 @@ protected override void SolveInstance(IGH_DataAccess DA) } // Handle input change detection after state handlers - this.HandleInputChangeDetection(DA); + this.HandleInputChangeDetection(DA, runValueChanged); + + this.hasPreviousRun = true; + this.previousRun = this.run; } /// @@ -390,7 +400,7 @@ private void UpdatePendingInputHashes() /// Handles input change detection and debounce logic. /// /// The data access object. - private void HandleInputChangeDetection(IGH_DataAccess DA) + private void HandleInputChangeDetection(IGH_DataAccess DA, bool runValueChanged) { var currentState = this.StateManager.CurrentState; @@ -406,6 +416,15 @@ private void HandleInputChangeDetection(IGH_DataAccess DA) // Get changed inputs from StateManager var changedInputs = this.StateManager.GetChangedInputs(); + // When configured to always run, a Run=true pulse should trigger Processing + // even if the Run? input is driven by volatile data (e.g. GH_Button). + if (!this.RunOnlyOnInputChanges && runValueChanged && this.run) + { + Debug.WriteLine($"[{this.GetType().Name}] Run value changed to true (volatile-aware), transitioning to Processing"); + this.StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); + return; + } + // If only Run parameter changed to false, stay in current state if (changedInputs.Count == 1 && changedInputs[0] == "Run?" && !this.run) { @@ -463,7 +482,7 @@ protected override void OnWorkerCompleted() this.StateManager.RequestTransition(ComponentState.Completed, TransitionReason.ProcessingComplete); base.OnWorkerCompleted(); - Debug.WriteLine("[StatefulComponentBaseV2] Worker completed, expiring solution"); + Debug.WriteLine("[StatefulComponentBase] Worker completed, expiring solution"); this.ExpireSolution(true); } @@ -894,7 +913,7 @@ public override bool Read(GH_IReader reader) if (v2Outputs.TryGetValue(p.InstanceGuid, out var tree)) { this.persistentOutputs[p.Name] = tree; - Debug.WriteLine($"[StatefulComponentBaseV2] [Read] Restored output '{p.Name}' paths={tree.PathCount}"); + Debug.WriteLine($"[StatefulComponentBase] [Read] Restored output '{p.Name}' paths={tree.PathCount}"); } } } @@ -904,7 +923,7 @@ public override bool Read(GH_IReader reader) this.RestoreLegacyOutputs(reader); } - Debug.WriteLine($"[StatefulComponentBaseV2] [Read] Restored with {this.persistentOutputs.Count} outputs"); + Debug.WriteLine($"[StatefulComponentBase] [Read] Restored with {this.persistentOutputs.Count} outputs"); return true; } @@ -966,7 +985,7 @@ private void RestoreLegacyOutputs(GH_IReader reader) } catch (Exception ex) { - Debug.WriteLine($"[StatefulComponentBaseV2] [Read] Legacy restore failed for '{paramName}': {ex.Message}"); + Debug.WriteLine($"[StatefulComponentBase] [Read] Legacy restore failed for '{paramName}': {ex.Message}"); } } } @@ -977,7 +996,7 @@ private void RestoreLegacyOutputs(GH_IReader reader) /// The data access object. protected virtual void RestorePersistentOutputs(IGH_DataAccess DA) { - Debug.WriteLine("[StatefulComponentBaseV2] Restoring persistent outputs"); + Debug.WriteLine("[StatefulComponentBase] Restoring persistent outputs"); for (int i = 0; i < this.Params.Output.Count; i++) { @@ -994,7 +1013,7 @@ protected virtual void RestorePersistentOutputs(IGH_DataAccess DA) } catch (Exception ex) { - Debug.WriteLine($"[StatefulComponentBaseV2] Failed to restore '{param.Name}': {ex.Message}"); + Debug.WriteLine($"[StatefulComponentBase] Failed to restore '{param.Name}': {ex.Message}"); } } } @@ -1099,7 +1118,7 @@ protected void SetPersistentOutput(string paramName, object value, IGH_DataAcces } catch (Exception ex) { - Debug.WriteLine($"[StatefulComponentBaseV2] Failed to set output '{paramName}': {ex.Message}"); + Debug.WriteLine($"[StatefulComponentBase] Failed to set output '{paramName}': {ex.Message}"); } } @@ -1524,3 +1543,4 @@ public override void AppendAdditionalMenuItems(ToolStripDropDown menu) #endregion } } + diff --git a/src/SmartHopper.Core/ComponentBase/StatefulAsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs similarity index 99% rename from src/SmartHopper.Core/ComponentBase/StatefulAsyncComponentBase.cs rename to src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs index 2be39893..80c8e88c 100644 --- a/src/SmartHopper.Core/ComponentBase/StatefulAsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2025 Marc Roca Musach * @@ -47,6 +47,7 @@ namespace SmartHopper.Core.ComponentBase /// Base class for stateful asynchronous Grasshopper components. /// Provides integrated state management, parallel processing, messaging, and persistence capabilities. /// + [Obsolete("Use StatefulComponentBase (formerly StatefulComponentBase). This legacy base is retained temporarily for migration.")] public abstract class StatefulAsyncComponentBase : AsyncComponentBase { /// @@ -345,10 +346,10 @@ protected override void OnWorkerCompleted() // Default state, field to store the current state private ComponentState currentState = ComponentState.Completed; - private readonly object stateLock = new (); + private readonly object stateLock = new(); private bool isTransitioning; private TaskCompletionSource stateCompletionSource; - private Queue pendingTransitions = new (); + private Queue pendingTransitions = new(); private IGH_DataAccess lastDA; // Flag to track if component was just restored from file with existing outputs @@ -642,7 +643,7 @@ private bool IsValidTransition(ComponentState newState) #region ERRORS - private readonly Dictionary runtimeMessages = new (); + private readonly Dictionary runtimeMessages = new(); /// /// Adds or updates a runtime message and optionally transitions to Error state. @@ -809,7 +810,7 @@ protected async Task>> RunProcessingAsync - private readonly object timerLock = new (); + private readonly object timerLock = new(); private readonly Timer debounceTimer; private int inputChangedDuringDebounce; diff --git a/src/SmartHopper.Core/Models/Connections/ConnectionPairing.cs b/src/SmartHopper.Core/Models/Connections/ConnectionPairing.cs index 147cf22d..062ca747 100644 --- a/src/SmartHopper.Core/Models/Connections/ConnectionPairing.cs +++ b/src/SmartHopper.Core/Models/Connections/ConnectionPairing.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using Grasshopper.Kernel; using Newtonsoft.Json; namespace SmartHopper.Core.Models.Connections diff --git a/src/SmartHopper.Core/Models/Document/DocumentMetadata.cs b/src/SmartHopper.Core/Models/Document/DocumentMetadata.cs index 5002ce65..c6b8d4b6 100644 --- a/src/SmartHopper.Core/Models/Document/DocumentMetadata.cs +++ b/src/SmartHopper.Core/Models/Document/DocumentMetadata.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using Newtonsoft.Json; using SmartHopper.Core.Serialization; diff --git a/src/SmartHopper.Core/Models/Serialization/GHJsonConverter.cs b/src/SmartHopper.Core/Models/Serialization/GHJsonConverter.cs index 06fd0c52..851473d8 100644 --- a/src/SmartHopper.Core/Models/Serialization/GHJsonConverter.cs +++ b/src/SmartHopper.Core/Models/Serialization/GHJsonConverter.cs @@ -25,7 +25,7 @@ public static class GHJsonConverter /// /// Default JSON serialization settings with formatting. /// - private static readonly JsonSerializerSettings DefaultSettings = new () + private static readonly JsonSerializerSettings DefaultSettings = new() { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore, diff --git a/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs b/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs index b78b277a..73274ee6 100644 --- a/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs +++ b/src/SmartHopper.Core/UI/Chat/ChatResourceManager.cs @@ -24,9 +24,6 @@ using Markdig; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; -using SmartHopper.Infrastructure.AICall.Core.Returns; -using SmartHopper.Infrastructure.AICall.Tools; namespace SmartHopper.Core.UI.Chat { diff --git a/src/SmartHopper.Core/UI/Chat/HtmlChatRenderer.cs b/src/SmartHopper.Core/UI/Chat/HtmlChatRenderer.cs index 444ed567..bead55e0 100644 --- a/src/SmartHopper.Core/UI/Chat/HtmlChatRenderer.cs +++ b/src/SmartHopper.Core/UI/Chat/HtmlChatRenderer.cs @@ -16,13 +16,8 @@ using System; using System.Diagnostics; using System.Globalization; -using System.Net; using Markdig; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; -using SmartHopper.Infrastructure.AICall.Core.Returns; -using SmartHopper.Infrastructure.AICall.Tools; namespace SmartHopper.Core.UI.Chat { diff --git a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs index f5164fa8..0da518dc 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatDialog.cs @@ -28,11 +28,9 @@ using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; -using SmartHopper.Infrastructure.AICall.Execution; using SmartHopper.Infrastructure.AICall.Metrics; using SmartHopper.Infrastructure.AICall.Sessions; using SmartHopper.Infrastructure.AICall.Utilities; -using SmartHopper.Infrastructure.Settings; using SmartHopper.Infrastructure.Streaming; namespace SmartHopper.Core.UI.Chat @@ -1364,20 +1362,9 @@ private void SendMessage(string text) if (string.IsNullOrWhiteSpace(trimmed)) return; // Store the user message before processing + // The observer will render it when AddInteraction() is called on the session this._pendingUserMessage = trimmed; - // Append to UI immediately using dedup key for idempotency - // CRITICAL: Assign a unique TurnId to ensure identical messages get distinct dedup keys - var userInter = new AIInteractionText { Agent = AIAgent.User, Content = trimmed, TurnId = InteractionUtility.GenerateTurnId() }; - if (userInter is IAIKeyedInteraction keyed) - { - this.UpsertMessageByKey(keyed.GetDedupKey(), userInter); - } - else - { - this.AddInteractionToWebView(userInter); - } - // Immediately reflect processing state in UI to disable input/send and enable cancel this.RunWhenWebViewReady(() => this.ExecuteScript("setProcessing(true);")); diff --git a/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs b/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs index 1d22bc3f..310f2f36 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatUtils.Helpers.cs @@ -9,7 +9,6 @@ */ using System; -using System.Diagnostics; using System.Threading.Tasks; using Eto.Forms; using SmartHopper.Infrastructure.AICall.Core.Interactions; @@ -89,7 +88,7 @@ private static WebChatDialog OpenOrReuseDialogInternal( } // Closed cleanup + optional result propagation - WireClosedCleanup(componentId, dialog, completionTcs); + WireClosedCleanup(componentId, dialog, progressReporter, completionTcs); // Incremental updates AttachOrReplaceUpdateHandler(componentId, dialog, progressReporter, onUpdate); @@ -165,7 +164,7 @@ private static void AttachOrReplaceUpdateHandler( /// /// Wires dialog closed cleanup and optionally completes a TaskCompletionSource with the last return. /// - private static void WireClosedCleanup(Guid componentId, WebChatDialog dialog, TaskCompletionSource? tcs = null) + private static void WireClosedCleanup(Guid componentId, WebChatDialog dialog, Action? progressReporter, TaskCompletionSource? tcs = null) { dialog.Closed += (sender, e) => { @@ -175,12 +174,26 @@ private static void WireClosedCleanup(Guid componentId, WebChatDialog dialog, Ta if (componentId != default) { OpenDialogs.Remove(componentId); - if (UpdateHandlers.ContainsKey(componentId)) + + if (UpdateHandlers.TryGetValue(componentId, out var handler)) { + try + { + dialog.ChatUpdated -= handler; + } + catch + { + /* ignore */ + } + UpdateHandlers.Remove(componentId); } } + // Ensure the hosting component isn't left in a stale "Chatting..." status. + // The Grasshopper component's message is driven by the progressReporter. + progressReporter?.Invoke("Ready"); + var last = dialog.GetLastReturn(); tcs?.TrySetResult(last); } diff --git a/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs b/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs index f9f98ba8..889bd72a 100644 --- a/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs +++ b/src/SmartHopper.Core/UI/Chat/WebChatUtils.cs @@ -17,10 +17,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading; using System.Threading.Tasks; -using Grasshopper; -using Grasshopper.Kernel; using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; @@ -40,12 +37,12 @@ private static void DebugLog(string message) /// /// Dictionary to track open chat dialogs by component instance ID. /// - private static readonly Dictionary OpenDialogs = new (); + private static readonly Dictionary OpenDialogs = new(); /// /// Tracks ChatUpdated subscriptions to avoid duplicate handlers per component. /// - private static readonly Dictionary> UpdateHandlers = new (); + private static readonly Dictionary> UpdateHandlers = new(); /// /// Static constructor to set up application shutdown handling. diff --git a/src/SmartHopper.Core/UI/DialogCanvasLink.cs b/src/SmartHopper.Core/UI/DialogCanvasLink.cs index bed139ae..82f1c1e5 100644 --- a/src/SmartHopper.Core/UI/DialogCanvasLink.cs +++ b/src/SmartHopper.Core/UI/DialogCanvasLink.cs @@ -22,7 +22,6 @@ using Eto.Forms; using Grasshopper; using Grasshopper.GUI.Canvas; -using Grasshopper.Kernel; using Rhino; namespace SmartHopper.Core.UI diff --git a/src/SmartHopper.Infrastructure.Tests/AIToolManagerTests.cs b/src/SmartHopper.Infrastructure.Tests/AIToolManagerTests.cs index 3b26b945..895e7bdd 100644 --- a/src/SmartHopper.Infrastructure.Tests/AIToolManagerTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/AIToolManagerTests.cs @@ -13,11 +13,7 @@ namespace SmartHopper.Infrastructure.Tests using System.Collections.Generic; using System.Reflection; using System.Threading.Tasks; - using SmartHopper.Infrastructure.AICall.Core.Base; - using SmartHopper.Infrastructure.AICall.Core.Interactions; - using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; - using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AITools; using Xunit; diff --git a/src/SmartHopper.Infrastructure.Tests/ModelManagerTests.cs b/src/SmartHopper.Infrastructure.Tests/ModelManagerTests.cs index c5ce2a04..3a1cbc52 100644 --- a/src/SmartHopper.Infrastructure.Tests/ModelManagerTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/ModelManagerTests.cs @@ -11,18 +11,9 @@ namespace SmartHopper.Infrastructure.Tests { using System.Collections.Generic; - using System.Drawing; using System.Globalization; using System.Reflection; - using System.Threading.Tasks; - using Newtonsoft.Json.Linq; - using SmartHopper.Infrastructure.AICall.Core.Base; - using SmartHopper.Infrastructure.AICall.Core.Interactions; - using SmartHopper.Infrastructure.AICall.Core.Requests; - using SmartHopper.Infrastructure.AICall.Core.Returns; - using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIModels; - using SmartHopper.Infrastructure.AIProviders; using Xunit; /// diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Base/AIRuntimeMessage.cs b/src/SmartHopper.Infrastructure/AICall/Core/Base/AIRuntimeMessage.cs index 0ee4f309..54fbf0e6 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Base/AIRuntimeMessage.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Base/AIRuntimeMessage.cs @@ -8,12 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Reflection; -using Newtonsoft.Json.Linq; - namespace SmartHopper.Infrastructure.AICall.Core.Base { /// diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIBody.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIBody.cs index a4841b62..8652465b 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIBody.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIBody.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using System.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Metrics; @@ -34,7 +33,7 @@ List InteractionsNew /// /// Gets an empty immutable body with defaults: ToolFilter="-*", ContextFilter="-*". /// - public static AIBody Empty { get; } = new ( + public static AIBody Empty { get; } = new( Array.Empty(), "-*", "-*", diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionError.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionError.cs index 06996999..9485767e 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionError.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionError.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Metrics; using SmartHopper.Infrastructure.AICall.Utilities; diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionText.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionText.cs index 4d4fe693..59875f86 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionText.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionText.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Globalization; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Metrics; diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs index 486b67d7..6dfeef11 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionToolCall.cs @@ -8,11 +8,9 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; -using SmartHopper.Infrastructure.AICall.Metrics; namespace SmartHopper.Infrastructure.AICall.Core.Interactions { diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIKeyedInteraction.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIKeyedInteraction.cs index 33462984..c858b83f 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIKeyedInteraction.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIKeyedInteraction.cs @@ -8,8 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; - namespace SmartHopper.Infrastructure.AICall.Core.Interactions { /// diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIRenderInteraction.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIRenderInteraction.cs index 36dc8a66..de450bde 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIRenderInteraction.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/IAIRenderInteraction.cs @@ -8,8 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; - namespace SmartHopper.Infrastructure.AICall.Core.Interactions { /// diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestCall.cs b/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestCall.cs index 73cfc76d..4ebda4d9 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestCall.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestCall.cs @@ -14,7 +14,6 @@ using System.Linq; using System.Net.Http; using System.Net.Sockets; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using SmartHopper.Infrastructure.AICall.Core.Base; diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Returns/AIReturn.cs b/src/SmartHopper.Infrastructure/AICall/Core/Returns/AIReturn.cs index a9a998df..df2199c9 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Returns/AIReturn.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Returns/AIReturn.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reflection; using Newtonsoft.Json.Linq; diff --git a/src/SmartHopper.Infrastructure/AICall/Policies/Request/AIToolValidationRequestPolicy.cs b/src/SmartHopper.Infrastructure/AICall/Policies/Request/AIToolValidationRequestPolicy.cs index ba9b9ed5..d3e57ee0 100644 --- a/src/SmartHopper.Infrastructure/AICall/Policies/Request/AIToolValidationRequestPolicy.cs +++ b/src/SmartHopper.Infrastructure/AICall/Policies/Request/AIToolValidationRequestPolicy.cs @@ -8,9 +8,7 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using SmartHopper.Infrastructure.AICall.Core.Base; diff --git a/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs b/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs index e939644d..df3b5df3 100644 --- a/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs +++ b/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs @@ -8,10 +8,8 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Threading.Tasks; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Policies; namespace SmartHopper.Infrastructure.AICall.Policies.Request { diff --git a/src/SmartHopper.Infrastructure/AICall/Policies/Request/SchemaValidateRequestPolicy.cs b/src/SmartHopper.Infrastructure/AICall/Policies/Request/SchemaValidateRequestPolicy.cs index da45726d..62026438 100644 --- a/src/SmartHopper.Infrastructure/AICall/Policies/Request/SchemaValidateRequestPolicy.cs +++ b/src/SmartHopper.Infrastructure/AICall/Policies/Request/SchemaValidateRequestPolicy.cs @@ -12,7 +12,6 @@ using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; -using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.JsonSchemas; namespace SmartHopper.Infrastructure.AICall.Policies.Request diff --git a/src/SmartHopper.Infrastructure/AICall/Policies/Request/ToolFilterNormalizationRequestPolicy.cs b/src/SmartHopper.Infrastructure/AICall/Policies/Request/ToolFilterNormalizationRequestPolicy.cs index 81439982..fd33b261 100644 --- a/src/SmartHopper.Infrastructure/AICall/Policies/Request/ToolFilterNormalizationRequestPolicy.cs +++ b/src/SmartHopper.Infrastructure/AICall/Policies/Request/ToolFilterNormalizationRequestPolicy.cs @@ -13,7 +13,6 @@ using System.Linq; using System.Threading.Tasks; using SmartHopper.Infrastructure.AICall.Core.Interactions; -using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Infrastructure.AICall.Policies.Request diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs index 5fe54674..39ee8a8f 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs @@ -25,7 +25,6 @@ namespace SmartHopper.Infrastructure.AICall.Sessions using SmartHopper.Infrastructure.AICall.Metrics; using SmartHopper.Infrastructure.AICall.Policies; using SmartHopper.Infrastructure.AICall.Utilities; - using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.Settings; using SmartHopper.Infrastructure.Streaming; @@ -59,7 +58,7 @@ private sealed class StreamProcessingResult /// /// The cancellation token source for this session. /// - private readonly CancellationTokenSource cts = new (); + private readonly CancellationTokenSource cts = new(); /// /// Executor abstraction for provider and tool calls. @@ -416,11 +415,15 @@ public void AddInteraction(string userMessage) { Agent = AIAgent.User, Content = userMessage, + TurnId = InteractionUtility.GenerateTurnId(), }; // Append user input to session history without marking it as 'new' this.AppendToSessionHistory(userInteraction); this.UpdateLastReturn(); + + // Notify observer so user message is rendered in the UI + this.Observer?.OnInteractionCompleted(userInteraction); } /// diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/IConversationObserver.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/IConversationObserver.cs index 725c6517..325ea58a 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/IConversationObserver.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/IConversationObserver.cs @@ -12,11 +12,9 @@ namespace SmartHopper.Infrastructure.AICall.Sessions { - using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; - using SmartHopper.Infrastructure.AICall.Tools; /// /// Observer of conversation session lifecycle and streaming deltas. diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/InteractionFilter.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/InteractionFilter.cs index 12963829..766ef063 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/InteractionFilter.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/InteractionFilter.cs @@ -11,7 +11,6 @@ namespace SmartHopper.Infrastructure.AICall.Sessions.SpecialTurns { using System.Collections.Generic; - using System.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/SpecialTurnConfig.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/SpecialTurnConfig.cs index 740bf9e6..d8733fb2 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/SpecialTurnConfig.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/SpecialTurns/SpecialTurnConfig.cs @@ -10,7 +10,6 @@ namespace SmartHopper.Infrastructure.AICall.Sessions.SpecialTurns { - using System; using System.Collections.Generic; using SmartHopper.Infrastructure.AICall.Core.Interactions; using SmartHopper.Infrastructure.AIModels; diff --git a/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelope.cs b/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelope.cs index 98e4e257..786cca77 100644 --- a/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelope.cs +++ b/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelope.cs @@ -12,8 +12,6 @@ using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using SmartHopper.Infrastructure.AICall.Core.Base; -using SmartHopper.Infrastructure.AICall.Core.Interactions; namespace SmartHopper.Infrastructure.AICall.Tools diff --git a/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelopeExtensions.cs b/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelopeExtensions.cs index 57500cc0..bc8d0fab 100644 --- a/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelopeExtensions.cs +++ b/src/SmartHopper.Infrastructure/AICall/Tools/ToolResultEnvelopeExtensions.cs @@ -9,8 +9,6 @@ */ using Newtonsoft.Json.Linq; -using SmartHopper.Infrastructure.AICall.Core.Base; -using SmartHopper.Infrastructure.AICall.Core.Interactions; namespace SmartHopper.Infrastructure.AICall.Tools diff --git a/src/SmartHopper.Infrastructure/AICall/Validation/ToolCapabilityValidator.cs b/src/SmartHopper.Infrastructure/AICall/Validation/ToolCapabilityValidator.cs index ad82c58b..ec53e1d9 100644 --- a/src/SmartHopper.Infrastructure/AICall/Validation/ToolCapabilityValidator.cs +++ b/src/SmartHopper.Infrastructure/AICall/Validation/ToolCapabilityValidator.cs @@ -9,7 +9,6 @@ */ using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using SmartHopper.Infrastructure.AICall.Core.Base; diff --git a/src/SmartHopper.Infrastructure/AICall/Validation/ToolExistsValidator.cs b/src/SmartHopper.Infrastructure/AICall/Validation/ToolExistsValidator.cs index b0b7f324..75575a90 100644 --- a/src/SmartHopper.Infrastructure/AICall/Validation/ToolExistsValidator.cs +++ b/src/SmartHopper.Infrastructure/AICall/Validation/ToolExistsValidator.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; diff --git a/src/SmartHopper.Infrastructure/AICall/Validation/ValidationContext.cs b/src/SmartHopper.Infrastructure/AICall/Validation/ValidationContext.cs index a6da7acd..59973fdf 100644 --- a/src/SmartHopper.Infrastructure/AICall/Validation/ValidationContext.cs +++ b/src/SmartHopper.Infrastructure/AICall/Validation/ValidationContext.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Requests; using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AIModels; diff --git a/src/SmartHopper.Infrastructure/AIContext/ContextManager.cs b/src/SmartHopper.Infrastructure/AIContext/ContextManager.cs index 89234749..5764e31c 100644 --- a/src/SmartHopper.Infrastructure/AIContext/ContextManager.cs +++ b/src/SmartHopper.Infrastructure/AIContext/ContextManager.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using SmartHopper.Infrastructure.AIProviders; using SmartHopper.Infrastructure.Utils; namespace SmartHopper.Infrastructure.AIContext diff --git a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs index 89fbb45e..f6aa1023 100644 --- a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs +++ b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs @@ -10,12 +10,7 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.IO; using System.Linq; -using System.Threading.Tasks; -using Newtonsoft.Json; -using SmartHopper.Infrastructure.AIProviders; namespace SmartHopper.Infrastructure.AIModels { diff --git a/src/SmartHopper.Infrastructure/AIModels/ModelManager.cs b/src/SmartHopper.Infrastructure/AIModels/ModelManager.cs index 2f9c51f7..85714875 100644 --- a/src/SmartHopper.Infrastructure/AIModels/ModelManager.cs +++ b/src/SmartHopper.Infrastructure/AIModels/ModelManager.cs @@ -12,7 +12,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using SmartHopper.Infrastructure.AIModels; namespace SmartHopper.Infrastructure.AIModels { diff --git a/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs b/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs index 7490388a..50dbfdf2 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs @@ -38,7 +38,7 @@ namespace SmartHopper.Infrastructure.AIProviders /// The type of the derived provider class. public abstract class AIProvider : AIProvider where T : AIProvider { - private static readonly Lazy InstanceValue = new (() => Activator.CreateInstance(typeof(T), true) as T); + private static readonly Lazy InstanceValue = new(() => Activator.CreateInstance(typeof(T), true) as T); /// /// Initializes a new instance of the class. diff --git a/src/SmartHopper.Infrastructure/AIProviders/AIProviderModels.cs b/src/SmartHopper.Infrastructure/AIProviders/AIProviderModels.cs index f7b1269a..1b982be4 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/AIProviderModels.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/AIProviderModels.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Threading.Tasks; using SmartHopper.Infrastructure.AIModels; diff --git a/src/SmartHopper.Infrastructure/Initialization/SmartHopperInitializer.cs b/src/SmartHopper.Infrastructure/Initialization/SmartHopperInitializer.cs index c81bf677..753b6cb2 100644 --- a/src/SmartHopper.Infrastructure/Initialization/SmartHopperInitializer.cs +++ b/src/SmartHopper.Infrastructure/Initialization/SmartHopperInitializer.cs @@ -23,7 +23,7 @@ namespace SmartHopper.Infrastructure.Initialization /// public static class SmartHopperInitializer { - private static readonly object LockObject = new (); + private static readonly object LockObject = new(); private static bool isInitialized; /// diff --git a/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs b/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs index 024db824..8cc1ed13 100644 --- a/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs +++ b/src/SmartHopper.Infrastructure/Streaming/IStreamingAdapter.cs @@ -8,10 +8,8 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace SmartHopper.Infrastructure.Streaming { diff --git a/src/SmartHopper.Menu/Dialogs/SettingsTabs/GeneralSettingsPage.cs b/src/SmartHopper.Menu/Dialogs/SettingsTabs/GeneralSettingsPage.cs index f0043af1..77f3496c 100644 --- a/src/SmartHopper.Menu/Dialogs/SettingsTabs/GeneralSettingsPage.cs +++ b/src/SmartHopper.Menu/Dialogs/SettingsTabs/GeneralSettingsPage.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System.Linq; using Eto.Drawing; using Eto.Forms; using SmartHopper.Infrastructure.AIProviders; diff --git a/src/SmartHopper.Menu/Dialogs/SettingsTabs/ProvidersSettingsPage.cs b/src/SmartHopper.Menu/Dialogs/SettingsTabs/ProvidersSettingsPage.cs index add4292e..3fcc67c0 100644 --- a/src/SmartHopper.Menu/Dialogs/SettingsTabs/ProvidersSettingsPage.cs +++ b/src/SmartHopper.Menu/Dialogs/SettingsTabs/ProvidersSettingsPage.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.IO; -using System.Linq; using Eto.Drawing; using Eto.Forms; using SmartHopper.Infrastructure.AIProviders; diff --git a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs index b2d9c2c8..fff5571b 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json.Linq; diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs index 405bef85..abbe3b73 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs @@ -197,6 +197,16 @@ public override string Encode(AIRequestCall request) currentMessage["tool_calls"] = currentToolCalls; } + // Remove reasoning_content from assistant messages without tool_calls + // per DeepSeek's recommendation to save bandwidth + if (string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) + { + if (currentMessage["tool_calls"] == null || (currentMessage["tool_calls"] is JArray tcArray && tcArray.Count == 0)) + { + currentMessage.Remove("reasoning_content"); + } + } + convertedMessages.Add(currentMessage); } @@ -277,9 +287,14 @@ public override string Encode(AIRequestCall request) currentMessage["tool_calls"] = currentToolCalls; } - if (currentToolCalls.Count == 0 && string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) + // Remove reasoning_content from assistant messages without tool_calls + // per DeepSeek's recommendation to save bandwidth + if (string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) { - currentMessage.Remove("reasoning_content"); + if (currentMessage["tool_calls"] == null || (currentMessage["tool_calls"] is JArray tcArray && tcArray.Count == 0)) + { + currentMessage.Remove("reasoning_content"); + } } convertedMessages.Add(currentMessage); @@ -1003,7 +1018,7 @@ async Task> FlushAsync(bool force) hasContentUpdate = true; } - if(hasContentUpdate) + if (hasContentUpdate) { // If we had a reasoning-only segment, complete it first to trigger segmentation if (hadReasoningOnlySegment) @@ -1124,7 +1139,7 @@ async Task> FlushAsync(bool force) JObject argsObj = null; var argsStr = argsSb.ToString(); try { if (!string.IsNullOrWhiteSpace(argsStr)) argsObj = JObject.Parse(argsStr); } catch { /* partial JSON, ignore */ } - interactions.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj }); + interactions.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj, Reasoning = assistantAggregate.Reasoning }); } var tcDelta = new AIReturn { Request = request, Status = AICallStatus.CallingTools }; @@ -1157,15 +1172,20 @@ async Task> FlushAsync(bool force) // Build final body with text and tool calls var finalBuilder = AIBodyBuilder.Create(); + var hasToolCalls = toolCalls.Count > 0; - // Add text interaction if present - if (!string.IsNullOrEmpty(assistantAggregate.Content) || !string.IsNullOrEmpty(assistantAggregate.Reasoning)) + // Add text interaction if there's actual content (not just reasoning when tool calls exist) + // When tool calls are present, reasoning belongs to the tool calls for API purposes + var shouldAddTextInteraction = !string.IsNullOrEmpty(assistantAggregate.Content) || + (!hasToolCalls && !string.IsNullOrEmpty(assistantAggregate.Reasoning)); + + if (shouldAddTextInteraction) { var finalSnapshot = new AIInteractionText { Agent = assistantAggregate.Agent, Content = assistantAggregate.Content, - Reasoning = assistantAggregate.Reasoning, + Reasoning = hasToolCalls ? null : assistantAggregate.Reasoning, Metrics = new AIMetrics { Provider = assistantAggregate.Metrics.Provider, @@ -1188,7 +1208,7 @@ async Task> FlushAsync(bool force) JObject argsObj = null; var argsStr = argsSb.ToString(); try { if (!string.IsNullOrWhiteSpace(argsStr)) argsObj = JObject.Parse(argsStr); } catch { /* partial JSON */ } - finalBuilder.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj }, markAsNew: false); + finalBuilder.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj, Reasoning = assistantAggregate.Reasoning }, markAsNew: false); } final.SetBody(finalBuilder.Build()); diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs index 2554111e..f20ba73f 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs @@ -8,7 +8,6 @@ * version 3 of the License, or (at your option) any later version. */ -using System; using System.Collections.Generic; using System.Threading.Tasks; using SmartHopper.Infrastructure.AIModels; diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs index 5151b47a..e4b9c10f 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs @@ -18,7 +18,6 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; using SmartHopper.Infrastructure.AICall.Core.Interactions; @@ -26,7 +25,6 @@ using SmartHopper.Infrastructure.AICall.Core.Returns; using SmartHopper.Infrastructure.AICall.JsonSchemas; using SmartHopper.Infrastructure.AICall.Metrics; -using SmartHopper.Infrastructure.AICall.Tools; using SmartHopper.Infrastructure.AIProviders; using SmartHopper.Infrastructure.Streaming; @@ -806,7 +804,7 @@ public async IAsyncEnumerable StreamAsync( hasContentUpdate = true; } - if(hasContentUpdate) + if (hasContentUpdate) { // If we had a reasoning-only segment, complete it first to trigger segmentation if (hadReasoningOnlySegment) diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs index 3c818a53..e93c62a3 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs @@ -10,7 +10,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json.Linq; @@ -96,7 +95,16 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1", + Model = "gpt-5.2-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 80, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.2", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, @@ -105,20 +113,29 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1-chat-latest", + Model = "gpt-5.1", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Rank = 80, }, new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.1-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 70, + }, + new AIModelCapabilities { Provider = provider, Model = "gpt-5.1-codex", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 85, + Rank = 75, }, new AIModelCapabilities { @@ -127,7 +144,7 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 85, + Rank = 75, }, new AIModelCapabilities { From 02d2365a230822f816a8ca49df0107cd3ac78efb Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Thu, 25 Dec 2025 12:15:03 +0100 Subject: [PATCH 17/26] refactor(components): remove "Done :)" messages and prevent clearing component messages - Replace "Done :)" completion messages with empty strings in AIListEvaluate, AIListFilter, AITextEvaluate, AITextGenerate, and AITextListGenerate components - Update AsyncComponentBase to only set component message when worker output message is non-empty, preventing accidental message clearing --- src/SmartHopper.Components/List/AIListEvaluate.cs | 2 +- src/SmartHopper.Components/List/AIListFilter.cs | 2 +- src/SmartHopper.Components/Text/AITextEvaluate.cs | 2 +- src/SmartHopper.Components/Text/AITextGenerate.cs | 2 +- src/SmartHopper.Components/Text/AITextListGenerate.cs | 2 +- src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs | 5 ++++- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/SmartHopper.Components/List/AIListEvaluate.cs b/src/SmartHopper.Components/List/AIListEvaluate.cs index 12ed913d..5a04849b 100644 --- a/src/SmartHopper.Components/List/AIListEvaluate.cs +++ b/src/SmartHopper.Components/List/AIListEvaluate.cs @@ -211,7 +211,7 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } this.parent.SetPersistentOutput("Result", value, DA); - message = "Done :)"; + message = string.Empty; } } } diff --git a/src/SmartHopper.Components/List/AIListFilter.cs b/src/SmartHopper.Components/List/AIListFilter.cs index bd0f2281..2d65c692 100644 --- a/src/SmartHopper.Components/List/AIListFilter.cs +++ b/src/SmartHopper.Components/List/AIListFilter.cs @@ -244,7 +244,7 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } this.parent.SetPersistentOutput("Result", value, DA); - message = "Done :)"; + message = string.Empty; } } } diff --git a/src/SmartHopper.Components/Text/AITextEvaluate.cs b/src/SmartHopper.Components/Text/AITextEvaluate.cs index fc756140..16b7ce39 100644 --- a/src/SmartHopper.Components/Text/AITextEvaluate.cs +++ b/src/SmartHopper.Components/Text/AITextEvaluate.cs @@ -229,7 +229,7 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } this.parent.SetPersistentOutput("Result", value, DA); - message = "Done :)"; + message = string.Empty; } } } diff --git a/src/SmartHopper.Components/Text/AITextGenerate.cs b/src/SmartHopper.Components/Text/AITextGenerate.cs index 1bf7de9f..c52d72e3 100644 --- a/src/SmartHopper.Components/Text/AITextGenerate.cs +++ b/src/SmartHopper.Components/Text/AITextGenerate.cs @@ -194,7 +194,7 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } this.parent.SetPersistentOutput("Result", value, DA); - message = "Done :)"; + message = string.Empty; } } } diff --git a/src/SmartHopper.Components/Text/AITextListGenerate.cs b/src/SmartHopper.Components/Text/AITextListGenerate.cs index 9fc9aa9e..d99433db 100644 --- a/src/SmartHopper.Components/Text/AITextListGenerate.cs +++ b/src/SmartHopper.Components/Text/AITextListGenerate.cs @@ -182,7 +182,7 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } this.parent.SetPersistentOutput("Result", tree, DA); - message = "Done :)"; + message = string.Empty; } } } diff --git a/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs index b97755d9..0b87c68f 100644 --- a/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs @@ -221,7 +221,10 @@ protected override void SolveInstance(IGH_DataAccess DA) Rhino.RhinoApp.InvokeOnUiThread(() => { this.Workers[i].SetOutput(DA, out outMessage); - this.Message = outMessage; + if (!string.IsNullOrEmpty(outMessage)) + { + this.Message = outMessage; + } Debug.WriteLine($"[AsyncComponentBase] Worker {i + 1} output set, message: {outMessage}"); }); From 32e883b47e9870046e90e88654cc24cc6f25ec03 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Thu, 25 Dec 2025 12:28:27 +0100 Subject: [PATCH 18/26] refactor(component-base): add post-solve guard to prevent premature output processing - Add early return in post-solve phase when tasks are still running (_setData == 0) or state is not ready (_state <= 0) - Skip output processing until continuation properly sets _state and _setData - Add debug logging to track skipped post-solve executions --- src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs index 0b87c68f..2936419c 100644 --- a/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AsyncComponentBase.cs @@ -207,6 +207,14 @@ protected override void SolveInstance(IGH_DataAccess DA) // Second pass - Post-solve - Setting output this.InPreSolve = false; + // If tasks are still running (_setData == 0) or the state is not ready (>0), + // skip output processing until the continuation sets _state and _setData. + if (this._setData == 0 || this._state <= 0) + { + Debug.WriteLine($"[AsyncComponentBase] Post-solve skipped. Tasks running or state not ready. State: {this._state}, SetData: {this._setData}"); + return; + } + Debug.WriteLine($"[AsyncComponentBase] Post-solve - Setting output. InPreSolve: {this.InPreSolve}, State: {this._state}, SetData: {this._setData}, Workers.Count: {this.Workers.Count}"); if (this.Workers.Count > 0) From ad0a5b1812b51d55dd6a60be149a1bc5f21148c8 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:29:29 +0100 Subject: [PATCH 19/26] feat(ghjson): add panel color and bounds serialization support - Add BoundsSerializer for panel size serialization using format "bounds:width,height" - Serialize/deserialize panel background color in componentState.additionalProperties.color using Color serializer - Serialize/deserialize panel size in componentState.additionalProperties.bounds while preserving position in pivot - Register BoundsSerializer in DataTypeRegistry - Update GhJSON documentation to reflect panel color and bounds properties --- docs/GhJSON/index.md | 4 +- docs/GhJSON/property-management.md | 2 + .../Grasshopper/GhGetComponents.cs | 4 +- .../AITools/gh_get.cs | 2 +- .../GhJson/GhJsonDeserializer.cs | 34 ++++++++ .../Serialization/GhJson/GhJsonSerializer.cs | 31 ++++++++ .../DataTypes/DataTypeRegistry.cs | 1 + .../DataTypes/Serializers/BoundsSerializer.cs | 79 +++++++++++++++++++ 8 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 src/SmartHopper.Core/Serialization/DataTypes/Serializers/BoundsSerializer.cs diff --git a/docs/GhJSON/index.md b/docs/GhJSON/index.md index 19863a88..a2fce899 100644 --- a/docs/GhJSON/index.md +++ b/docs/GhJSON/index.md @@ -2,7 +2,7 @@ ## Overview -**GhJSON** (Grasshopper JSON) is SmartHopper's serialization format for representing Grasshopper definitions as structured JSON documents. It enables AI-powered tools to read, analyze, modify, and generate Grasshopper component networks programmatically. +**GhJSON** (Grasshopper JSON) is a structured representation of Grasshopper documents. It enables AI-powered tools to read, analyze, modify, and generate Grasshopper component networks programmatically. --- @@ -10,7 +10,7 @@ ### Core Documentation -- **[Format Specification](./format-specification.md)** +- **[Format Specification](./format-specification.md)** Complete schema reference for GhJSON format including component structure, connections, groups, and validation rules. - **[Property Management (V2)](./property-management.md)** diff --git a/docs/GhJSON/property-management.md b/docs/GhJSON/property-management.md index 8e4609c8..617766b3 100644 --- a/docs/GhJSON/property-management.md +++ b/docs/GhJSON/property-management.md @@ -181,6 +181,8 @@ Legacy `properties` dictionary is deprecated. See notes in the format-specificat | `UserText` | String | Text content in panel | | `Font` | Object | Font configuration (in componentState) | | `Alignment` | String | Text alignment (in componentState) | +| `Color` | String | Panel background color stored in `componentState.additionalProperties.color` using Color serializer (`argb:A,R,G,B`) | +| `Bounds` | String | Panel size stored in `componentState.additionalProperties.bounds` using Bounds serializer (`bounds:width,height`). Position remains in `pivot`. | ### Scribble Properties diff --git a/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs b/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs index 7ebc39af..8120230a 100644 --- a/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhGetComponents.cs @@ -26,7 +26,7 @@ namespace SmartHopper.Components.Grasshopper /// /// Component that converts selected or all Grasshopper components to GhJSON format. /// Supports optional filtering by runtime messages (errors, warnings, and remarks), component states (selected, enabled, disabled), preview capability (previewcapable, notpreviewcapable), preview state (previewon, previewoff), and classification by object type via Type filter (params, components, input, output, processing, isolated). - /// Optionally includes document metadata (schema version, timestamps, Rhino/Grasshopper versions, plugin dependencies). + /// Optionally includes document metadata (timestamps, Rhino/Grasshopper versions, plugin dependencies). /// public class GhGetComponents : SelectingComponentBase { @@ -51,7 +51,7 @@ protected override void RegisterInputParams(GH_InputParamManager pManager) pManager.AddTextParameter("Category Filter", "C", "Optional list of category filters by Grasshopper category or subcategory (e.g. 'Maths', 'Params', 'Script'). Use '+name' to include and '-name' to exclude.", GH_ParamAccess.list, ""); pManager.AddTextParameter("Attribute Filter", "F", "Optional list of filters by tags: 'error', 'warning', 'remark', 'selected', 'unselected', 'enabled', 'disabled', 'previewon', 'previewoff'. Prefix '+' to include, '-' to exclude.", GH_ParamAccess.list, ""); pManager.AddIntegerParameter("Connection Depth", "D", "Optional depth of connections to include: 0 = only matching components; 1 = direct connections; higher = further hops.", GH_ParamAccess.item, 0); - pManager.AddBooleanParameter("Include Metadata", "M", "Include document metadata (schema version, timestamps, Rhino/Grasshopper versions, plugin dependencies)", GH_ParamAccess.item, false); + pManager.AddBooleanParameter("Include Metadata", "M", "Include document metadata (timestamps, Rhino/Grasshopper versions, plugin dependencies)", GH_ParamAccess.item, false); pManager.AddBooleanParameter("Run?", "R", "Run this component?", GH_ParamAccess.item); } diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs index b8fdb113..75014676 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_get.cs @@ -81,7 +81,7 @@ public IEnumerable GetTools() ""includeMetadata"": { ""type"": ""boolean"", ""default"": false, - ""description"": ""Whether to include document metadata (schema version, timestamps, Rhino/Grasshopper versions, plugin dependencies). Default is false."" + ""description"": ""Whether to include document metadata (timestamps, Rhino/Grasshopper versions, plugin dependencies). Default is false."" }, ""includeRuntimeData"": { ""type"": ""boolean"", diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonDeserializer.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonDeserializer.cs index d3b713c3..54615889 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonDeserializer.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonDeserializer.cs @@ -11,10 +11,12 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Drawing; using System.Linq; using Grasshopper.Kernel; using Grasshopper.Kernel.Parameters; using Grasshopper.Kernel.Special; +using Rhino.Geometry; using RhinoCodePlatform.GH; using SmartHopper.Core.Grasshopper.Serialization.GhJson.ScriptComponents; using SmartHopper.Core.Grasshopper.Serialization.GhJson.Shared; @@ -869,6 +871,12 @@ private static void ApplyComponentState(IGH_DocumentObject instance, ComponentSt { component.Hidden = state.Hidden.Value; } + + // Apply panel-specific appearance (color and size) + if (instance is GH_Panel panel) + { + ApplyPanelAppearance(panel, state); + } } /// @@ -964,6 +972,32 @@ private static void ApplyUniversalValue( } } + private static void ApplyPanelAppearance(GH_Panel panel, ComponentState state) + { + if (state.AdditionalProperties != null && + state.AdditionalProperties.TryGetValue("color", out var colorObj) && + colorObj is string colorStr && + DataTypeSerializer.TryDeserialize("Color", colorStr, out var deserialized) && + deserialized is Color serializedColor) + { + panel.Properties.Colour = serializedColor; + } + + if (state.AdditionalProperties != null && + state.AdditionalProperties.TryGetValue("bounds", out var boundsObj) && + boundsObj is string boundsStr && + DataTypeSerializer.TryDeserialize("Bounds", boundsStr, out var deserializedBounds) && + deserializedBounds is ValueTuple boundsTuple) + { + var attr = panel.Attributes; + if (attr != null) + { + // Preserve location, update size + attr.Bounds = new RectangleF(attr.Bounds.X, attr.Bounds.Y, (float)boundsTuple.Item1, (float)boundsTuple.Item2); + } + } + } + #endregion #region ID Replacement diff --git a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonSerializer.cs b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonSerializer.cs index 55e634b9..d0db99e3 100644 --- a/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonSerializer.cs +++ b/src/SmartHopper.Core.Grasshopper/Serialization/GhJson/GhJsonSerializer.cs @@ -11,6 +11,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Drawing; using System.Globalization; using System.Linq; using Grasshopper; @@ -19,6 +20,7 @@ using Grasshopper.Kernel.Special; using Grasshopper.Kernel.Types; using Newtonsoft.Json.Linq; +using Rhino.Geometry; using RhinoCodePlatform.GH; using SmartHopper.Core.Grasshopper.Serialization.GhJson.ScriptComponents; using SmartHopper.Core.Grasshopper.Serialization.GhJson.Shared; @@ -539,6 +541,12 @@ private static ParameterSettings CreateParameterSettings( return null; } + private static Color? TryGetPanelColor(GH_Panel panel) + { + var props = panel.Properties; + return props?.Colour; + } + /// /// Checks if a component is a VB Script component. /// VB Script components don't implement IScriptComponent but use ScriptVariableParam. @@ -617,6 +625,29 @@ private static ComponentState ExtractComponentState( } } + // Extract panel-specific appearance (color, size) + if (originalObject is GH_Panel panel) + { + var panelColor = TryGetPanelColor(panel); + if (panelColor.HasValue) + { + state.AdditionalProperties ??= new Dictionary(); + state.AdditionalProperties["color"] = DataTypeSerializer.Serialize(panelColor.Value); + hasState = true; + } + + var bounds = panel.Attributes?.Bounds; + if (bounds.HasValue && !bounds.Value.IsEmpty) + { + state.AdditionalProperties ??= new Dictionary(); + + // Store as Bounds serializer format: bounds:W,H (preserve order, no sorting) + var boundsTuple = (width: (double)bounds.Value.Width, height: (double)bounds.Value.Height); + state.AdditionalProperties["bounds"] = DataTypeSerializer.Serialize(boundsTuple); + hasState = true; + } + } + // Extract standard output visibility for script components if (originalObject is IScriptComponent) { diff --git a/src/SmartHopper.Core/Serialization/DataTypes/DataTypeRegistry.cs b/src/SmartHopper.Core/Serialization/DataTypes/DataTypeRegistry.cs index fb96ea44..cf5d7f2a 100644 --- a/src/SmartHopper.Core/Serialization/DataTypes/DataTypeRegistry.cs +++ b/src/SmartHopper.Core/Serialization/DataTypes/DataTypeRegistry.cs @@ -151,6 +151,7 @@ private void RegisterBuiltInSerializers() this.RegisterSerializer(new BoxSerializer()); this.RegisterSerializer(new RectangleSerializer()); this.RegisterSerializer(new IntervalSerializer()); + this.RegisterSerializer(new BoundsSerializer()); Debug.WriteLine($"[DataTypeRegistry] Registered {this.serializersByName.Count} built-in serializers"); } diff --git a/src/SmartHopper.Core/Serialization/DataTypes/Serializers/BoundsSerializer.cs b/src/SmartHopper.Core/Serialization/DataTypes/Serializers/BoundsSerializer.cs new file mode 100644 index 00000000..918d305c --- /dev/null +++ b/src/SmartHopper.Core/Serialization/DataTypes/Serializers/BoundsSerializer.cs @@ -0,0 +1,79 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2025 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + */ + +using System; +using System.Globalization; + +namespace SmartHopper.Core.Serialization.DataTypes.Serializers +{ + /// + /// Serializer for simple bounds (width, height) pairs. + /// Format: "bounds:W,H" (e.g., "bounds:120.5,80.25"). + /// + public class BoundsSerializer : IDataTypeSerializer + { + /// + public string TypeName => "Bounds"; + + /// + public Type TargetType => typeof((double width, double height)); + + /// + public string Serialize(object value) + { + if (value is ValueTuple tuple) + { + return $"bounds:{tuple.Item1.ToString(CultureInfo.InvariantCulture)},{tuple.Item2.ToString(CultureInfo.InvariantCulture)}"; + } + + throw new ArgumentException($"Value must be a (double width, double height) tuple, got {value?.GetType().Name ?? "null"}"); + } + + /// + public object Deserialize(string value) + { + if (!Validate(value)) + { + throw new FormatException($"Invalid Bounds format: '{value}'. Expected format: 'bounds:width,height' with valid doubles."); + } + + var valueWithoutPrefix = value.Substring(value.IndexOf(':') + 1); + var parts = valueWithoutPrefix.Split(','); + double width = double.Parse(parts[0], CultureInfo.InvariantCulture); + double height = double.Parse(parts[1], CultureInfo.InvariantCulture); + + return (width, height); + } + + /// + public bool Validate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + if (!value.StartsWith("bounds:")) + { + return false; + } + + var valueWithoutPrefix = value.Substring("bounds:".Length); + var parts = valueWithoutPrefix.Split(','); + if (parts.Length != 2) + { + return false; + } + + return double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out _) && + double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out _); + } + } +} From c7e137cc111e882064c0042f152e5e9b33c95206 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Fri, 26 Dec 2025 11:47:05 +0100 Subject: [PATCH 20/26] style: remove trailing blank lines and fix copyright inconsistencies --- ...TreeProcessorBranchFlattenTestComponent.cs | 1 - ...reeProcessorBranchToBranchTestComponent.cs | 1 - ...sorBroadcastDeeperDiffRootTestComponent.cs | 1 - ...sorBroadcastDeeperSameRootTestComponent.cs | 1 - ...sorBroadcastMultipleNoZeroTestComponent.cs | 1 - ...rBroadcastMultipleTopLevelTestComponent.cs | 1 - ...ntPathsFirstOneSecondThreeTestComponent.cs | 1 - ...ntPathsFirstThreeSecondOneTestComponent.cs | 1 - ...rDifferentPathsOneItemEachTestComponent.cs | 1 - ...fferentPathsThreeItemsEachTestComponent.cs | 1 - ...essorDirectMatchPrecedenceTestComponent.cs | 1 - ...alPathsFirstOneSecondThreeTestComponent.cs | 1 - ...alPathsFirstThreeSecondOneTestComponent.cs | 1 - ...ataTreeProcessorEqualPathsTestComponent.cs | 1 - ...cessorEqualPathsThreeItemsTestComponent.cs | 1 - ...reeProcessorGroupIdenticalTestComponent.cs | 1 - ...DataTreeProcessorItemGraftTestComponent.cs | 1 - ...ataTreeProcessorItemToItemTestComponent.cs | 1 - ...taTreeProcessorMixedDepthsTestComponent.cs | 1 - ...TreeProcessorRule2OverrideTestComponent.cs | 1 - ...tAIStatefulTreePrimeCalculatorComponent.cs | 2 +- .../Misc/TestStateManagerDebounceComponent.cs | 1 - .../TestStateManagerRestorationComponent.cs | 1 - .../TestStatefulPrimeCalculatorComponent.cs | 1 - ...estStatefulTreePrimeCalculatorComponent.cs | 3 +- .../Grasshopper/GhPutComponents.cs | 1 - .../Knowledge/McNeelForumPostGetComponent.cs | 1 - .../Knowledge/McNeelForumPostOpenComponent.cs | 1 - .../Knowledge/McNeelForumSearchComponent.cs | 1 - .../Knowledge/WebPageReadComponent.cs | 1 - .../ComponentBase/StatefulComponentBase.cs | 1 - .../_old-StatefulAsyncComponentBase.cs | 1775 ----------------- 32 files changed, 2 insertions(+), 1807 deletions(-) delete mode 100644 src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs index 1dfdcd5a..7154222c 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs @@ -172,4 +172,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs index 060bb061..fb55c740 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs @@ -177,4 +177,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs index 73b9e004..57d1947d 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs @@ -160,4 +160,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs index 1f25255c..848fc621 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperSameRootTestComponent.cs @@ -167,4 +167,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs index 37bd26d4..a8389225 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleNoZeroTestComponent.cs @@ -160,4 +160,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs index 93621a62..b640df7a 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastMultipleTopLevelTestComponent.cs @@ -162,4 +162,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs index c54676aa..a2c7f573 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent.cs @@ -173,4 +173,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs index fb9c59ce..2724ad40 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs @@ -174,4 +174,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs index c68d3f01..e41abdb1 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs @@ -172,4 +172,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs index 9b634cbb..fcd69c30 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs @@ -191,4 +191,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs index cd129dab..71acdfc8 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs @@ -162,4 +162,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs index c06993bb..89bfd103 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent.cs @@ -172,4 +172,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs index 71fde5c7..59092eac 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs @@ -172,4 +172,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs index 997e6216..aa036ef2 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs @@ -184,4 +184,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs index 03bf89d2..e5470b82 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs @@ -200,4 +200,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs index 11417560..bb944227 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs @@ -180,4 +180,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs index 4b8e240d..dfb19474 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs @@ -174,4 +174,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs index ca3ca2d4..03e9f13e 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemToItemTestComponent.cs @@ -171,4 +171,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs index 859ef7b4..c1a37fd3 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs @@ -165,4 +165,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs index 8e237462..229fe2b4 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorRule2OverrideTestComponent.cs @@ -164,4 +164,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs index ffcf8982..249d333f 100644 --- a/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs @@ -1,6 +1,6 @@ /* * SmartHopper - AI-powered Grasshopper Plugin - * Copyright (C) 2025 Marc Roca Musach + * Copyright (C) 2024 Marc Roca Musach * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs index 866649c9..e0bffad9 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs @@ -244,4 +244,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs index 22c41771..8d7a3e04 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs @@ -244,4 +244,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs index e8a31111..d42ce383 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs @@ -124,4 +124,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs index 7fbbd9da..681992ef 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs @@ -1,6 +1,6 @@ /* * SmartHopper - AI-powered Grasshopper Plugin - * Copyright (C) 2024 Marc Roca Musach + * Copyright (C) 2025 Marc Roca Musach * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public @@ -151,4 +151,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs index 67a44ab8..73440ee4 100644 --- a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs @@ -239,4 +239,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs index 1a3d0c51..cfa197b6 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumPostGetComponent.cs @@ -198,4 +198,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs index 63df8833..3c73673d 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumPostOpenComponent.cs @@ -176,4 +176,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs b/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs index b27f2551..42b70749 100644 --- a/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs +++ b/src/SmartHopper.Components/Knowledge/McNeelForumSearchComponent.cs @@ -241,4 +241,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs b/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs index 5841acca..d4bb1da4 100644 --- a/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs +++ b/src/SmartHopper.Components/Knowledge/WebPageReadComponent.cs @@ -208,4 +208,3 @@ public override void SetOutput(IGH_DataAccess DA, out string message) } } } - diff --git a/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs index 2cf87ca4..8f7cd578 100644 --- a/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs @@ -1543,4 +1543,3 @@ public override void AppendAdditionalMenuItems(ToolStripDropDown menu) #endregion } } - diff --git a/src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs deleted file mode 100644 index 80c8e88c..00000000 --- a/src/SmartHopper.Core/ComponentBase/_old-StatefulAsyncComponentBase.cs +++ /dev/null @@ -1,1775 +0,0 @@ -/* - * SmartHopper - AI-powered Grasshopper Plugin - * Copyright (C) 2025 Marc Roca Musach - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - */ - -/* - * Portions of this code adapted from: - * https://github.com/specklesystems/GrasshopperAsyncComponent - * Apache License 2.0 - * Copyright (c) 2021 Speckle Systems - */ - -/* - * Base class for all stateful asynchronous SmartHopper components. - * This class provides the fundamental structure for components that need to perform - * asynchronous, showing an State message, while maintaining - * Grasshopper's component lifecycle. - */ - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -#if DEBUG -using System.Windows.Forms; -#endif -using GH_IO.Serialization; -using Grasshopper.Kernel; -using Grasshopper.Kernel.Data; -using Grasshopper.Kernel.Types; -using SmartHopper.Core.DataTree; -using SmartHopper.Core.IO; -using SmartHopper.Infrastructure.Settings; -using Timer = System.Threading.Timer; - -namespace SmartHopper.Core.ComponentBase -{ - /// - /// Base class for stateful asynchronous Grasshopper components. - /// Provides integrated state management, parallel processing, messaging, and persistence capabilities. - /// - [Obsolete("Use StatefulComponentBase (formerly StatefulComponentBase). This legacy base is retained temporarily for migration.")] - public abstract class StatefulAsyncComponentBase : AsyncComponentBase - { - /// - /// Gets or sets a value indicating whether the component should only run when inputs change. - /// If true (default), the component will only run when inputs have changed and Run is true. - /// If false, the component will run whenever the Run parameter is set to true, - /// regardless of whether inputs have changed. - /// - public bool RunOnlyOnInputChanges { get; set; } = true; - - /// - /// Gets the progress information for tracking processing operations. - /// - protected ProgressInfo ProgressInfo { get; private set; } = new ProgressInfo(); - - /// - /// Gets a value indicating whether the base class should automatically restore persistent outputs - /// during Completed/Waiting states. Override in derived components that wish to fully - /// manage their outputs each solve and avoid duplicate setting in a single cycle. - /// Default is true for backward compatibility. - /// - protected virtual bool AutoRestorePersistentOutputs => true; - - /// - /// Gets the default processing options used for data tree processing. - /// Derived components should override this property only if they need different options. - /// Default: ItemToItem topology, OnlyMatchingPaths=false, GroupIdenticalBranches=true. - /// - protected virtual ProcessingOptions ComponentProcessingOptions => new ProcessingOptions - { - Topology = ProcessingTopology.ItemToItem, - OnlyMatchingPaths = false, - GroupIdenticalBranches = true, - }; - - #region CONSTRUCTOR - - /// - /// Initializes a new instance of the class. - /// Creates a new instance of the stateful async component. - /// - /// The component's display name. - /// The component's nickname. - /// Description of the component's functionality. - /// Category in the Grasshopper toolbar. - /// Subcategory in the Grasshopper toolbar. - protected StatefulAsyncComponentBase( - string name, - string nickname, - string description, - string category, - string subCategory) - : base(name, nickname, description, category, subCategory) - { - this.persistentOutputs = new Dictionary(); - this.persistentDataTypes = new Dictionary(); - this.previousInputHashes = new Dictionary(); - this.previousInputBranchCounts = new Dictionary(); - - // Initialize timer - // Actions defined here will happen after the debounce time - this.debounceTimer = new Timer( - (state) => - { - lock (this.timerLock) - { - var targetState = this.debounceTargetState; - - // if (!_run) - // { - // targetState = ComponentState.NeedsRun; - // } - Debug.WriteLine($"[{this.GetType().Name}] Debounce timer elapsed - Inputs stable, transitioning to {targetState}"); - Debug.WriteLine($"[{this.GetType().Name}] Debounce timer elapsed - Changes during debounce: {this.inputChangedDuringDebounce}"); - Rhino.RhinoApp.InvokeOnUiThread(() => - { - this.TransitionTo(targetState, this.lastDA); - }); - - if (this.inputChangedDuringDebounce > 0 && this.run) - { - Rhino.RhinoApp.InvokeOnUiThread(() => - { - this.ExpireSolution(true); - }); - } - - // Reset default values after debounce - Debug.WriteLine($"[{this.GetType().Name}] Debounce timer elapsed - Resetting debounce values"); - - // Reset debounce values - this.inputChangedDuringDebounce = 0; - this.debounceTargetState = ComponentState.Waiting; - } - }, null, Timeout.Infinite, Timeout.Infinite); // Initially disabled - } - - #endregion - - // -------------------------------------------------- - // COMPONENT DEFINITION - // -------------------------------------------------- - // - // This section of code is responsible for managing - // the component's lifecycle and state transitions, - // implementing the necessary methods for a - // Grasshopper Component. - #region PARAMS - - private bool run; - - /// - /// Gets a value indicating whether the component is requested to run for the current solve. - /// - public bool Run => this.run; - - /// - /// Registers input parameters for the component. - /// - /// The input parameter manager. - protected override void RegisterInputParams(GH_Component.GH_InputParamManager pManager) - { - // Allow derived classes to add their specific inputs - this.RegisterAdditionalInputParams(pManager); - - pManager.AddBooleanParameter("Run?", "R", "Set this parameter to true to run the component.", GH_ParamAccess.item, false); - } - - /// - /// Register component-specific input parameters, to define in derived classes. - /// - /// The input parameter manager. - protected abstract void RegisterAdditionalInputParams(GH_InputParamManager pManager); - - /// - /// Registers output parameters for the component. - /// - /// The output parameter manager. - protected override void RegisterOutputParams(GH_Component.GH_OutputParamManager pManager) - { - // Allow derived classes to add their specific outputs - this.RegisterAdditionalOutputParams(pManager); - } - - /// - /// Register component-specific output parameters, to define in derived classes. - /// - /// The output parameter manager. - protected abstract void RegisterAdditionalOutputParams(GH_OutputParamManager pManager); - - #endregion - - #region LIFECYCLE - - /// - /// Performs pre-solve initialization and guards against unintended resets during processing. - /// - protected override void BeforeSolveInstance() - { - if (this.currentState == ComponentState.Processing && !this.run) - { - Debug.WriteLine("[StatefulAsyncComponentBase] Processing state... jumping to SolveInstance"); - return; // Jump to SolveInstance, prevent resetting data - } - - base.BeforeSolveInstance(); - } - - /// - /// Main solving method for the component. - /// Handles the execution flow and persistence of results. - /// - /// The data access object. - protected override void SolveInstance(IGH_DataAccess DA) - { - // If we just restored from file, and no outputs were restored, - // transition to NeedsRun state - if (this.justRestoredFromFile && this.persistentOutputs.Count == 0) - { - this.justRestoredFromFile = false; - this.TransitionTo(ComponentState.NeedsRun, DA); - return; - } - - this.lastDA = DA; - - // Store Run parameter - bool run = false; - DA.GetData("Run?", ref run); - this.run = run; - - Debug.WriteLine($"[{this.GetType().Name}] SolveInstance - Current State: {this.currentState}, InPreSolve: {this.InPreSolve}, State: {this.State}, SetData: {this.SetData}, Workers: {this.Workers.Count}, Changes during debounce: {this.inputChangedDuringDebounce}, Run: {this.run}, IsTransitioning: {this.isTransitioning}, Pending Transitions: {this.pendingTransitions.Count}"); - - // Execute the appropriate state handler - switch (this.currentState) - { - case ComponentState.Completed: - this.OnStateCompleted(DA); - break; - case ComponentState.Waiting: - this.OnStateWaiting(DA); - break; - case ComponentState.NeedsRun: - this.OnStateNeedsRun(DA); - break; - case ComponentState.Processing: - this.OnStateProcessing(DA); - break; - case ComponentState.Cancelled: - this.OnStateCancelled(DA); - break; - case ComponentState.Error: - this.OnStateError(DA); - break; - } - - // If inputs changed... - switch (this.currentState) - { - case ComponentState.Completed: - case ComponentState.Waiting: - case ComponentState.Cancelled: - case ComponentState.Error: - // Check if inputs changed - var changedInputs = this.InputsChanged(); - - // If only the Run parameter changed to false, stay in Completed state - if (this.InputsChanged("Run?", true) && !this.run) - { - Debug.WriteLine($"[{this.GetType().Name}] Only Run parameter changed to false, staying in Completed state"); - } - - // If only the Run parameter changed to true, restart debounce timer with target to the Waiting state to output the results again - else if (this.InputsChanged("Run?", true) && this.run) - { - Debug.WriteLine($"[{this.GetType().Name}] Only the Run parameter changed to true, restarting debounce timer with target state " + (this.RunOnlyOnInputChanges ? "Waiting" : "Processing")); - - if (this.RunOnlyOnInputChanges) // RunOnlyOnInputChanges default is true - { - // Default behavior - transition to Waiting state - this.TransitionTo(ComponentState.Waiting, DA); - } - else - { - // Always transition to Processing state regardless of input changes - Debug.WriteLine($"[{this.GetType().Name}] Component set to always run when Run is true, transitioning to Processing state"); - this.TransitionTo(ComponentState.Processing, DA); - } - } - - // If any other input changed, and run is false - else if (changedInputs.Any() && !this.run) - { - Debug.WriteLine($"[{this.GetType().Name}] Inputs changed, restarting debounce timer with target state NeedsRun"); - this.RestartDebounceTimer(ComponentState.NeedsRun); - } - - // If any other input changed, and run is true - else if (changedInputs.Any() && this.run) - { - Debug.WriteLine($"[{this.GetType().Name}] Inputs changed, restarting debounce timer"); - this.RestartDebounceTimer(ComponentState.Processing); - } - - break; - default: - break; - } - - this.ResetInputChanged(); - } - - /// - /// Finalizes processing by transitioning to Completed state, restoring outputs, and expiring the solution. - /// - protected override void OnWorkerCompleted() - { - // Update input hashes before transitioning to prevent false input changes - this.CalculatePersistentDataHashes(); - this.TransitionTo(ComponentState.Completed, this.lastDA); - base.OnWorkerCompleted(); - Debug.WriteLine("[StatefulAsyncComponentBase] Worker completed, expiring solution"); - this.ExpireSolution(true); - } - - #endregion - - // -------------------------------------------------- - // STATE MANAGEMENT - // -------------------------------------------------- - // - // Implement State Management - #region STATE - - // PRIVATE FIELDS - - // Default state, field to store the current state - private ComponentState currentState = ComponentState.Completed; - private readonly object stateLock = new(); - private bool isTransitioning; - private TaskCompletionSource stateCompletionSource; - private Queue pendingTransitions = new(); - private IGH_DataAccess lastDA; - - // Flag to track if component was just restored from file with existing outputs - private bool justRestoredFromFile; - - // PUBLIC PROPERTIES - - /// - /// Gets the current state of the component. - /// - public ComponentState CurrentState => this.currentState; - - private async Task ProcessTransition(ComponentState newState, IGH_DataAccess? DA = null) - { - var oldState = this.currentState; - Debug.WriteLine($"[{this.GetType().Name}] Attempting transition from {oldState} to {newState}"); - - if (!this.IsValidTransition(newState)) - { - Debug.WriteLine($"[{this.GetType().Name}] Invalid state transition from {oldState} to {newState}"); - return; - } - - this.currentState = newState; - Debug.WriteLine($"[{this.GetType().Name}] State transition: {oldState} -> {newState}"); - - this.stateCompletionSource = new TaskCompletionSource(); - - // Clear messages only when entering NeedsRun or Processing from a different state - if ((newState == ComponentState.NeedsRun || newState == ComponentState.Processing) && - oldState != ComponentState.NeedsRun && oldState != ComponentState.Processing) - { - this.ClearPersistentRuntimeMessages(); - } - - // Actions here only happen when transitioning - // Action in the OnState___ methods happen on every solve - switch (newState) - { - case ComponentState.Completed: - this.Message = this.GetStateMessage(); - this.OnStateCompleted(DA); - break; - case ComponentState.Waiting: - this.Message = this.GetStateMessage(); - //// OnStateWaiting is only called in SolveInstance - // OnStateWaiting(DA); - break; - case ComponentState.NeedsRun: - this.Message = this.GetStateMessage(); - this.OnStateNeedsRun(DA); - this.OnDisplayExpired(true); - break; - case ComponentState.Processing: - // Fix for Issue #260: Reset async state only when transitioning from non-Processing states - // This prevents interference with ongoing async state counting mechanism - if (oldState != ComponentState.Processing) - { - Debug.WriteLine($"[{this.GetType().Name}] Resetting async state for fresh Processing transition from {oldState}"); - this.ResetAsyncState(); - this.ResetProgress(); - - // Fix for Issue #260: Async mechanism to handle boolean toggle case - // If component is still in Processing state without workers (didn't start processing) after debounce time, force execution - _ = Task.Run(async () => - { - await Task.Delay(this.GetDebounceTime()); - - // Check if we're still in Processing state but no workers are running - if (this.CurrentState == ComponentState.Processing && this.Workers.Count == 0) - { - Debug.WriteLine($"[{this.GetType().Name}] Processing state detected without workers after debounce delay, forcing ExpireSolution"); - Rhino.RhinoApp.InvokeOnUiThread(() => - { - this.ExpireSolution(true); - }); - } - }); - } - - // Set the message after Resetting the progress - this.Message = this.GetStateMessage(); - - // OnStateProcessing(DA) is called in SolveInstance, not during transition - break; - case ComponentState.Cancelled: - this.Message = this.GetStateMessage(); - this.OnStateCancelled(DA); - this.OnDisplayExpired(true); - break; - case ComponentState.Error: - this.Message = this.GetStateMessage(); - this.OnStateError(DA); - break; - } - - await this.stateCompletionSource.Task.ConfigureAwait(false); - Debug.WriteLine($"[{this.GetType().Name}] Completed transition {oldState} -> {newState}"); - return; - } - - /// - /// Completes the current state transition by signaling any awaiters that the transition finished. - /// - protected void CompleteStateTransition() - { - this.stateCompletionSource?.TrySetResult(true); - } - - private async void TransitionTo(ComponentState newState, IGH_DataAccess? DA = null) - { - if (DA == null) - { - DA = this.lastDA; - } - - lock (this.stateLock) - { - if (this.isTransitioning && newState == ComponentState.Completed) - { - Debug.WriteLine($"[{this.GetType().Name}] Queuing transition to {newState} while in {this.currentState}"); - this.pendingTransitions.Enqueue(newState); - return; - } - - this.isTransitioning = true; - } - - try - { - await this.ProcessTransition(newState, DA).ConfigureAwait(false); - } - finally - { - lock (this.stateLock) - { - this.isTransitioning = false; - if (this.pendingTransitions.Count > 0) - { - var nextState = this.pendingTransitions.Dequeue(); - Debug.WriteLine($"[{this.GetType().Name}] Processing queued transition to {nextState}"); - this.TransitionTo(nextState, DA); - } - } - } - } - - private void OnStateCompleted(IGH_DataAccess DA) - { - Debug.WriteLine($"[{this.GetType().Name}] OnStateCompleted, _state: {this.State}, InPreSolve: {this.InPreSolve}, SetData: {this.SetData}, Workers: {this.Workers.Count}, Changes during debounce: {this.inputChangedDuringDebounce}"); - - // Ensure message is set correctly for Completed state - // This is especially important after file restoration when ProcessTransition might not be called - this.Message = ComponentState.Completed.ToMessageString(); - - // Reapply runtime messages in completed state - this.ApplyPersistentRuntimeMessages(); - - // Restore data from persistent storage, necessary when opening the file - if (this.AutoRestorePersistentOutputs) - { - this.RestorePersistentOutputs(DA); - } - - this.CompleteStateTransition(); - } - - private void OnStateWaiting(IGH_DataAccess DA) - { - Debug.WriteLine($"[{this.GetType().Name}] OnStateWaiting"); - - // Reapply runtime messages in completed state - this.ApplyPersistentRuntimeMessages(); - - // Restore data from persistent storage, necessary when opening the file - if (this.AutoRestorePersistentOutputs) - { - this.RestorePersistentOutputs(DA); - } - - this.CompleteStateTransition(); - } - - private void OnStateNeedsRun(IGH_DataAccess DA) - { - Debug.WriteLine($"[{this.GetType().Name}] OnStateNeedsRun"); - - // Check Run parameter - bool run = false; - DA.GetData("Run?", ref run); - - if (run) - { - // Transition to Processing and let base class handle async work - this.TransitionTo(ComponentState.Processing, DA); - - // Clear the "needs_run" message if it exists - this.ClearOnePersistentRuntimeMessage("needs_run"); - } - else - { - this.SetPersistentRuntimeMessage("needs_run", GH_RuntimeMessageLevel.Warning, "The component needs to recalculate. Set Run to true!", false); - this.ClearDataOnly(); - } - - this.CompleteStateTransition(); - } - - private void OnStateProcessing(IGH_DataAccess DA) - { - Debug.WriteLine($"[{this.GetType().Name}] OnStateProcessing"); - - // The base AsyncComponentBase handles the actual processing - // When done it will call OnWorkerCompleted which transitions to Completed - base.SolveInstance(DA); - - this.CompleteStateTransition(); - } - - private void OnStateCancelled(IGH_DataAccess DA) - { - Debug.WriteLine($"[{this.GetType().Name}] OnStateCancelled"); - - // Reapply runtime messages in cancelled state - this.ApplyPersistentRuntimeMessages(); - this.SetPersistentRuntimeMessage("cancelled", GH_RuntimeMessageLevel.Error, "The execution was manually cancelled", false); - - // Check if inputs changed - var changedInputs = this.InputsChanged(); - - // Check Run parameter - bool run = false; - DA.GetData("Run?", ref run); - - // If any input changed (excluding "Run?" from the list) - if (changedInputs.Any(input => input != "Run?")) - { - // Debug.WriteLine($"[{GetType().Name}] Inputs changed, restarting debounce timer"); - // RestartDebounceTimer(); - } - - // Else, if "Run?" changed and run is True, directly transition to Processing - else if (changedInputs.Any(input => input == "Run?") && run) - { - this.TransitionTo(ComponentState.Processing, DA); - this.ExpireSolution(true); - } - - this.CompleteStateTransition(); - } - - private void OnStateError(IGH_DataAccess DA) - { - Debug.WriteLine($"[{this.GetType().Name}] OnStateError"); - - // Reapply runtime messages in error state - this.ApplyPersistentRuntimeMessages(); - - // TransitionTo(ComponentState.Waiting, DA); - this.CompleteStateTransition(); - } - - private bool IsValidTransition(ComponentState newState) - { - // Special cases: Transition to Error can always happen - if (newState == ComponentState.Error) - { - return true; - } - - // Normal flow validation - switch (this.currentState) - { - case ComponentState.Completed: - return newState == ComponentState.Waiting || newState == ComponentState.NeedsRun || newState == ComponentState.Processing; - case ComponentState.Waiting: - return newState == ComponentState.NeedsRun || newState == ComponentState.Processing; - case ComponentState.NeedsRun: - return newState == ComponentState.Processing; - case ComponentState.Processing: - return newState == ComponentState.Completed || newState == ComponentState.Cancelled; - case ComponentState.Cancelled: - case ComponentState.Error: - return newState == ComponentState.Waiting || newState == ComponentState.NeedsRun || newState == ComponentState.Processing; - default: - return false; - } - } - - #endregion - - #region ERRORS - - private readonly Dictionary runtimeMessages = new(); - - /// - /// Adds or updates a runtime message and optionally transitions to Error state. - /// - /// Unique identifier for the message. - /// The message severity level. - /// The message content. - /// If true and level is Error, transitions to Error state. - protected void SetPersistentRuntimeMessage(string key, GH_RuntimeMessageLevel level, string message, bool transitionToError = true) - { - Debug.WriteLine($"[{this.GetType().Name}] [PersistentMessage] key='{key}', level={level}, transitionToError={transitionToError}, message='{message}'"); - this.runtimeMessages[key] = (level, message); - - if (transitionToError && level == GH_RuntimeMessageLevel.Error) - { - this.TransitionTo(ComponentState.Error, this.lastDA); - } - else - { - this.ApplyPersistentRuntimeMessages(); - } - } - - /// - /// Clears a specific runtime message by its key. - /// - /// The unique identifier of the message to clear. - /// True if the message was found and cleared, false otherwise. - protected bool ClearOnePersistentRuntimeMessage(string key) - { - var removed = this.runtimeMessages.Remove(key); - if (removed) - { - this.ClearRuntimeMessages(); - this.ApplyPersistentRuntimeMessages(); - } - - return removed; - } - - /// - /// Clears all runtime messages. - /// - protected void ClearPersistentRuntimeMessages() - { - this.runtimeMessages.Clear(); - this.ClearRuntimeMessages(); - } - - /// - /// Applies stored runtime messages to the component. - /// - private void ApplyPersistentRuntimeMessages() - { - Debug.WriteLine($"[{this.GetType().Name}] [Runtime Messages] Applying {this.runtimeMessages.Count} runtime messages"); - foreach (var (level, message) in this.runtimeMessages.Values) - { - this.AddRuntimeMessage(level, message); - } - } - - #endregion - - #region PROGRESS TRACKING - - /// - /// Initializes progress tracking with the specified total count. - /// - /// The total number of items to process. - protected virtual void InitializeProgress(int total) - { - this.ProgressInfo.Total = total; - this.ProgressInfo.Current = 1; - Debug.WriteLine($"[{this.GetType().Name}] Progress initialized - Total: {total}"); - } - - /// - /// Updates the current progress and triggers a UI refresh. - /// - /// The current item being processed (1-based). - protected virtual void UpdateProgress(int current) - { - this.ProgressInfo.UpdateCurrent(current); - Debug.WriteLine($"[{this.GetType().Name}] Progress updated - {current}/{this.ProgressInfo.Total}"); - - // Update the message with current progress information - this.Message = this.GetStateMessage(); - - // Trigger UI refresh to update the displayed message - Rhino.RhinoApp.InvokeOnUiThread(() => - { - this.OnDisplayExpired(false); - }); - } - - /// - /// Resets progress tracking. - /// - protected virtual void ResetProgress() - { - this.ProgressInfo.Reset(); - Debug.WriteLine($"[{this.GetType().Name}] Progress reset"); - } - - /// - /// Gets the current state message with progress information. - /// - /// A formatted state message string. - public virtual string GetStateMessage() - { - return this.currentState.ToMessageString(this.ProgressInfo); - } - - /// - /// Runs data-tree processing using the unified runner with explicit ProcessingTopology. - /// Handles metrics tracking and progress reporting automatically. - /// - /// Type of input tree items. - /// Type of output tree items. - /// Dictionary of input data trees. - /// Function to run on each logical unit (item or branch). - /// Processing options specifying topology and path/grouping behavior. - /// Cancellation token. - /// Dictionary of output data trees. - protected async Task>> RunProcessingAsync( - Dictionary> trees, - Func>, Task>>> function, - DataTree.ProcessingOptions options, - CancellationToken token = default) - where T : IGH_Goo - where U : IGH_Goo - { - // Calculate processing metrics using centralized logic in DataTreeProcessor - var (dataCount, iterationCount) = DataTree.DataTreeProcessor.CalculateProcessingMetrics(trees, options); - - // Set metrics and initialize progress - this.SetDataCount(dataCount); - this.InitializeProgress(iterationCount); - - // Run the unified processor - var result = await DataTree.DataTreeProcessor.RunAsync( - trees, - function, - options, - progressCallback: (current, total) => - { - this.UpdateProgress(current); - }, - token).ConfigureAwait(false); - - return result; - } - - #endregion - - #region DEBOUNCE - - /// - /// Minimum debounce time in milliseconds. Input changes within this period will be ignored. - /// - private const int MINDEBOUNCETIME = 1000; - - /// - /// Timer used to track the debounce period. When it elapses, if inputs are stable, - /// the component will transition to NeedsRun state and trigger a solve. - /// - private readonly object timerLock = new(); - private readonly Timer debounceTimer; - private int inputChangedDuringDebounce; - - private ComponentState debounceTargetState = ComponentState.Waiting; - - /// - /// Gets the debounce time from the SmartHopperSettings and returns the maximum between the settings value and the minimum value defined in MIN_DEBOUNCE_TIME. - /// - /// The debounce time in milliseconds. - protected virtual int GetDebounceTime() - { - var settingsDebounceTime = SmartHopperSettings.Load().DebounceTime; - return Math.Max(settingsDebounceTime, MINDEBOUNCETIME); - } - - /// - /// Restarts the debounce timer using the current . - /// Increments the internal change counter and schedules a transition after the debounce interval. - /// - protected void RestartDebounceTimer() - { - lock (this.timerLock) - { - this.inputChangedDuringDebounce++; - this.debounceTimer.Change(this.GetDebounceTime(), Timeout.Infinite); - Debug.WriteLine($"[{this.GetType().Name}] Restarting debounce timer - Will transition to {this.debounceTargetState}"); - } - } - - /// - /// Sets a new target state for the debounce transition and restarts the debounce timer. - /// - /// The state to transition to after the debounce interval. - protected void RestartDebounceTimer(ComponentState targetState) - { - this.debounceTargetState = targetState; - this.RestartDebounceTimer(); - } - - #endregion - - // -------------------------------------------------- - // PERSISTENT DATA MANAGEMENT - // -------------------------------------------------- - // - // This section of code is responsible for storing - // and retrieving persistent data for the component. - #region PERSISTENT DATA - - // PRIVATE FIELDS - private Dictionary previousInputHashes; - private Dictionary previousInputBranchCounts; - private readonly Dictionary persistentOutputs; - private readonly Dictionary persistentDataTypes; - - /// - /// Restores all persistent outputs to their respective parameters. - /// - /// The data access object. - protected virtual void RestorePersistentOutputs(IGH_DataAccess DA) - { - Debug.WriteLine("[StatefulAsyncComponentBase] [PersistentData] Restoring persistent outputs"); - - for (int i = 0; i < this.Params.Output.Count; i++) - { - var param = this.Params.Output[i]; - var savedValue = this.GetPersistentOutput(param.Name); - if (savedValue == null) - continue; - - try - { - // Structured value previously persisted - if (savedValue is IGH_Structure structure) - { - if (param.Access == GH_ParamAccess.tree) - { - // Pass the structure through as-is for tree outputs - this.SetPersistentOutput(param.Name, structure, DA); - } - else if (param.Access == GH_ParamAccess.list) - { - // Flatten to list (handled by SetPersistentOutput for list outputs) - this.SetPersistentOutput(param.Name, structure, DA); - } - else - { - // Single output: take the first element from the structure, if any - IGH_Goo first = null; - foreach (var path in structure.Paths) - { - var branch = structure.get_Branch(path); - if (branch != null && branch.Count > 0) - { - first = branch[0] as IGH_Goo ?? GH_Convert.ToGoo(branch[0]); - break; - } - } - - if (first != null) - { - this.SetPersistentOutput(param.Name, first, DA); - } - } - } - - // List output saved as IEnumerable - else if (param.Access == GH_ParamAccess.list && savedValue is System.Collections.IEnumerable enumerable && savedValue is not string) - { - this.SetPersistentOutput(param.Name, enumerable, DA); - } - else - { - // Single item or already-goo - IGH_Goo gooValue; - if (savedValue is IGH_Goo existingGoo) - { - gooValue = existingGoo; - } - else - { - // Try to create a new goo instance of the appropriate type - var gooType = param.Type; - gooValue = GH_Convert.ToGoo(savedValue); - if (gooValue == null) - { - // If direct conversion fails, try creating instance and casting - gooValue = (IGH_Goo)Activator.CreateInstance(gooType); - gooValue.CastFrom(savedValue); - } - } - - // Add the properly typed goo value - this.SetPersistentOutput(param.Name, gooValue, DA); - - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Successfully restored output '{param.Name}' with value '{gooValue}' of runtime type '{gooValue?.GetType()?.FullName ?? "null"}'"); - } - } - catch (Exception ex) - { - Debug.WriteLine("[StatefulAsyncComponentBase] [PersistentData] Failed to restore output '" + param.Name + "': " + ex.Message); - } - } - } - - /// - /// Writes the component's persistent data to the Grasshopper file. - /// - /// The writer to use for serialization. - /// True if the write operation succeeds, false if it fails or an exception occurs. - public override bool Write(GH_IWriter writer) - { - if (!base.Write(writer)) - { - return false; - } - - try - { - // Store input hashes - foreach (var kvp in this.previousInputHashes) - { - writer.SetInt32($"InputHash_{kvp.Key}", kvp.Value); - Debug.WriteLine($"[StatefulAsyncComponentBase] [Write] Stored input hash for '{kvp.Key}': {kvp.Value}"); - } - - // Store input branch counts - foreach (var kvp in this.previousInputBranchCounts) - { - writer.SetInt32($"InputBranchCount_{kvp.Key}", kvp.Value); - Debug.WriteLine($"[StatefulAsyncComponentBase] [Write] Stored input branch count for '{kvp.Key}': {kvp.Value}"); - } - - // Build GUID-keyed structure dictionary for v2 persistence - var outputsByGuid = new Dictionary>(); - foreach (var p in this.Params.Output) - { - if (!this.persistentOutputs.TryGetValue(p.Name, out var value)) - continue; - - // Case 1: Already a structure → convert to GH_Structure and persist - if (value is IGH_Structure structure) - { -#if DEBUG - LogStructureDetails(structure); -#endif - var tree = ConvertToGooTree(structure); - outputsByGuid[p.InstanceGuid] = tree; - Debug.WriteLine($"[StatefulAsyncComponentBase] [Write] Prepared V2 output for '{p.Name}' ({p.InstanceGuid}) paths={tree.PathCount} items={tree.DataCount}"); - continue; - } - - // Case 2: List outputs (IEnumerable but not string) → build a 1-branch tree and persist - if (p.Access == GH_ParamAccess.list && value is System.Collections.IEnumerable enumerable && value is not string) - { - var tree = new GH_Structure(); - var path = new GH_Path(0, 0); // single canonical branch - foreach (var item in enumerable) - { - if (item == null) continue; - var goo = item as IGH_Goo ?? GH_Convert.ToGoo(item) ?? new GH_String(item.ToString()); - tree.Append(goo, path); - } - - outputsByGuid[p.InstanceGuid] = tree; - Debug.WriteLine($"[StatefulAsyncComponentBase] [Write] Prepared V2 list output for '{p.Name}' items={tree.DataCount}"); - continue; - } - - // Case 3: Single item → wrap as a 1-branch tree and persist - { - var tree = new GH_Structure(); - var path = new GH_Path(0, 0); - var goo = value as IGH_Goo ?? GH_Convert.ToGoo(value) ?? new GH_String(value?.ToString() ?? string.Empty); - tree.Append(goo, path); - outputsByGuid[p.InstanceGuid] = tree; - Debug.WriteLine($"[StatefulAsyncComponentBase] [Write] Prepared V2 single output for '{p.Name}'"); - } - } - - // Use safe, versioned persistence - var persistence = new GHPersistenceService(); - persistence.WriteOutputsV2(writer, this, outputsByGuid); - - return true; - } - catch - { - return false; - } - } - - /// - /// Reads the component's persistent data from the Grasshopper file. - /// - /// The reader to use for deserialization. - /// True if the read operation succeeds, false if it fails, required data is missing, or an exception occurs. - public override bool Read(GH_IReader reader) - { - if (!base.Read(reader)) - { - return false; - } - - // Clear previous hashes - this.previousInputHashes.Clear(); - - // Clear previous branch counts - this.previousInputBranchCounts.Clear(); - - // Clear previous outputs - this.persistentOutputs.Clear(); - - // Restore input metadata first - foreach (var item in reader.Items) - { - var key = item.Name; - if (key.StartsWith("InputHash_")) - { - string paramName = key.Substring("InputHash_".Length); - this.previousInputHashes[paramName] = reader.GetInt32(key); - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Restored input hash for '{paramName}': {this.previousInputHashes[paramName]}"); - } - else if (key.StartsWith("InputBranchCount_")) - { - string paramName = key.Substring("InputBranchCount_".Length); - this.previousInputBranchCounts[paramName] = reader.GetInt32(key); - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Restored input branch count for '{paramName}': {this.previousInputBranchCounts[paramName]}"); - } - } - - // Try safe V2 restore first - var persistence = new GHPersistenceService(); - var v2Outputs = persistence.ReadOutputsV2(reader, this); - if (v2Outputs != null && v2Outputs.Count > 0) - { - foreach (var p in this.Params.Output) - { - if (v2Outputs.TryGetValue(p.InstanceGuid, out var tree)) - { - this.persistentOutputs[p.Name] = tree; - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Restored V2 output for '{p.Name}' paths={tree.PathCount} items={tree.DataCount}"); - } - } - } - else if (PersistenceConstants.EnableLegacyRestore) - { - // Legacy fallback guarded by feature flag - foreach (var item in reader.Items) - { - string key = item.Name; - if (!key.StartsWith("Value_")) continue; - string paramName = key.Substring("Value_".Length); - - try - { - string typeName = reader.GetString($"Type_{paramName}"); - if (string.IsNullOrWhiteSpace(typeName)) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Missing or empty type name for '{paramName}', skipping restoration."); - continue; - } - - Type type = Type.GetType(typeName); - if (type == null) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Could not resolve type '{typeName}' for '{paramName}', skipping restoration."); - continue; - } - - byte[] chunkBytes = reader.GetByteArray($"Value_{paramName}"); - if (chunkBytes == null || chunkBytes.Length == 0) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Empty or missing data chunk for '{paramName}', skipping restoration."); - continue; - } - - var chunk = new GH_LooseChunk($"Value_{paramName}"); - chunk.Deserialize_Binary(chunkBytes); - - var instance = Activator.CreateInstance(type); - var readMethod = type.GetMethod("Read"); - if (instance == null || readMethod == null) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Unable to create instance or find Read() for '{typeName}', skipping restoration."); - continue; - } - - readMethod.Invoke(instance, new object[] { chunk }); - this.persistentOutputs[paramName] = instance; - } - catch (Exception ex) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Exception while restoring legacy '{paramName}': {ex.Message}"); - continue; - } - } - } - - // Outputs restored flag - this.justRestoredFromFile = true; - Debug.WriteLine($"[StatefulAsyncComponentBase] [Read] Restored from file with {this.persistentOutputs.Count} existing outputs, staying in Completed state"); - - return true; - } - - /// - /// Extracts the inner value from a GH_ObjectWrapper if the object is of that type. - /// - /// The value to extract from. - /// The inner value if the input is a GH_ObjectWrapper, otherwise returns the input value unchanged. - private static object ExtractGHObjectWrapperValue(object value) - { - if (value?.GetType()?.FullName == "Grasshopper.Kernel.Types.GH_ObjectWrapper") - { - var valueProperty = value.GetType().GetProperty("Value"); - if (valueProperty != null) - { - var innerValue = valueProperty.GetValue(value); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Extracted inner value from GH_ObjectWrapper: {innerValue?.GetType()?.FullName ?? "null"}"); - return innerValue; - } - } - - return value; - } - -#if DEBUG - private static void LogStructureDetails(IGH_Structure structure) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Structure details:"); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] - Path count: {structure.PathCount}"); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] - Data count: {structure.DataCount}"); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] - Paths:"); - foreach (var path in structure.Paths) - { - var branch = structure.get_Branch(path); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] - Path {path}: {branch?.Count ?? 0} items"); - if (branch != null) - { - foreach (var item in branch) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] - {item?.ToString() ?? "null"} ({item?.GetType()?.FullName ?? "null"})"); - } - } - } - } - -#endif - - /// - /// Converts an into a typed . - /// Safely casts or converts branch items to IGH_Goo. Never throws. - /// - private static GH_Structure ConvertToGooTree(IGH_Structure src) - { - var dst = new GH_Structure(); - if (src == null) - return dst; - - foreach (var path in src.Paths) - { - var branch = src.get_Branch(path); - if (branch == null) - { - dst.EnsurePath(path); - continue; - } - - foreach (var item in branch) - { - IGH_Goo goo = item as IGH_Goo; - if (goo == null) - { - goo = GH_Convert.ToGoo(item); - if (goo == null) - { - goo = new GH_String(item?.ToString() ?? string.Empty); - } - } - - dst.Append(goo, path); - } - } - - return dst; - } - - /// - /// Stores a value in the persistent storage. - /// - /// Name of the parameter to store. - /// Value to store. - /// The data access object used to set the output immediately. - /// - /// This method is protected to allow derived classes to manually store values if needed. - /// However, values are automatically stored after solving, so manual storage is rarely necessary. - /// - protected void SetPersistentOutput(string paramName, object value, IGH_DataAccess DA) - { - try - { - // Find the output parameter - var param = this.Params.Output.FirstOrDefault(p => p.Name == paramName); - var paramIndex = this.Params.Output.IndexOf(param); - if (param != null) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Initial value type: {value?.GetType()?.FullName ?? "null"}"); - - // Extract inner value if it's a GH_ObjectWrapper - value = ExtractGHObjectWrapperValue(value); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Value after extraction: {value?.GetType()?.FullName ?? "null"}"); - - // Store the value in persistent storage - this.persistentOutputs[paramName] = value; - - // Store the type information - if (value != null) - { - this.persistentDataTypes[paramName] = value.GetType(); - } - else - { - this.persistentDataTypes.Remove(paramName); - } - - // Set the data through DA - if (DA != null) - { - if (param.Access == GH_ParamAccess.tree) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Creating tree for parameter type: {param.Type.FullName}"); - - // If the value is already a GH_Structure, use it directly - if (value is IGH_Structure tree) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Using existing tree of type: {tree.GetType().FullName}"); - - // Guard: only set data if the tree has at least one branch and at least one item - bool hasBranch = tree.PathCount > 0; - bool hasItems = false; - if (hasBranch) - { - foreach (var path in tree.Paths) - { - var branch = tree.get_Branch(path); - if (branch != null && branch.Count > 0) - { - hasItems = true; - break; - } - } - } - - if (!hasBranch || !hasItems) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Skipping SetDataTree for '{param.Name}' because tree is empty (hasBranch={hasBranch}, hasItems={hasItems})"); - } - else - { - DA.SetDataTree(paramIndex, tree); - } - } - else - { - // Create a new tree and add the single value - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Creating new tree for single value"); - var newTree = new GH_Structure(); - - // Convert to IGH_Goo if needed - if (!(value is IGH_Goo)) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Converting to IGH_Goo: {value}"); - value = GH_Convert.ToGoo(value); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Converted type: {value?.GetType()?.FullName ?? "null"}"); - } - - // Convert to the correct type if needed - if (value is IGH_Goo goo) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Current value is IGH_Goo of type: {goo.GetType().FullName}"); - var targetType = param.Type; - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Target type: {targetType.FullName}"); - - if (!goo.GetType().Equals(targetType)) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Type mismatch, attempting conversion"); - - // Get the raw value - var rawValue = goo.ScriptVariable(); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] ScriptVariable type: {rawValue?.GetType()?.FullName ?? "null"}"); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] ScriptVariable value: {rawValue}"); - - // If the target type is GH_Number, handle it specifically - if (targetType == typeof(GH_Number) && rawValue is IConvertible) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Converting to GH_Number"); - value = new GH_Number(Convert.ToDouble(rawValue, CultureInfo.InvariantCulture)); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Conversion successful: {value.GetType().FullName}"); - } - else - { - var converted = GH_Convert.ToGoo(rawValue); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Converted type: {converted?.GetType()?.FullName ?? "null"}"); - - if (converted != null && converted.GetType().Equals(targetType)) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Conversion successful"); - value = converted; - } - else - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Conversion failed or type mismatch"); - } - } - } - } - - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Final value type before tree append: {value?.GetType()?.FullName ?? "null"}"); - newTree.Append(value as IGH_Goo, new GH_Path(0)); - DA.SetDataTree(paramIndex, newTree); - } - } - else if (param.Access == GH_ParamAccess.list) - { - // Handle list outputs properly - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Setting list output for '{param.Name}'"); - if (value is IGH_Structure structValue) - { - // Flatten structure to a single list branch - var list = new List(); - foreach (var path in structValue.Paths) - { - var branch = structValue.get_Branch(path); - if (branch == null) continue; - foreach (var item in branch) - { - if (item == null) continue; - var gooItem = item as IGH_Goo ?? GH_Convert.ToGoo(item); - if (gooItem != null) list.Add(gooItem); - } - } - - DA.SetDataList(paramIndex, list); - } - else if (value is System.Collections.IEnumerable enumerable && !(value is string)) - { - var list = new List(); - foreach (var item in enumerable) - { - if (item == null) continue; - var gooItem = item as IGH_Goo ?? GH_Convert.ToGoo(item); - if (gooItem != null) list.Add(gooItem); - } - - // Always set the list, even if empty, to ensure outputs are cleared when needed - DA.SetDataList(paramIndex, list); - } - else - { - // Single value provided to a list output; wrap it - var single = value as IGH_Goo ?? GH_Convert.ToGoo(value); - if (single != null) - { - DA.SetDataList(paramIndex, new List { single }); - } - else - { - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Unable to convert value for list output '{param.Name}'"); - } - } - } - else - { - // Convert to IGH_Goo if needed for non-tree parameters - if (!(value is IGH_Goo)) - { - value = GH_Convert.ToGoo(value); - } - - DA.SetData(paramIndex, value); - } - } - else - { - Debug.WriteLine("[StatefulAsyncComponentBase] [PersistentData] DA is null, cannot set data."); - } - } - } - catch (Exception ex) - { - Debug.WriteLine("[StatefulAsyncComponentBase] [PersistentData] Failed to set output '" + paramName + "': " + ex.Message); - Debug.WriteLine($"[StatefulAsyncComponentBase] [PersistentData] Exception stack trace: {ex.StackTrace}"); - } - } - - /// - /// Retrieves a value from persistent storage. - /// - /// The type of value to retrieve. - /// Name of the parameter to retrieve. - /// Value to return if the parameter is not found. - /// The stored value or defaultValue if not found. - protected T GetPersistentOutput(string paramName, T defaultValue = default) - { - if (this.persistentOutputs.TryGetValue(paramName, out object value) && value is T typedValue) - { - return typedValue; - } - - return defaultValue; - } - - /// - /// Calculates the hash for a single input parameter's data and branch structure. - /// - /// The input parameter to calculate hash for. - /// Output parameter that returns the number of branches in the data. - /// The combined hash of the parameter's data and branch structure. - private static int CalculatePersistentDataHash(IGH_Param param, out int branchCount) - { - var data = param.VolatileData; - int currentHash = 0; - branchCount = data.PathCount; - - // Hash both the data and branch structure - foreach (var branch in data.Paths) - { - int branchHash = 0; - foreach (var item in data.get_Branch(branch)) - { - if (item != null) - { - // Use value-based hashing instead of object-instance hashing - // to prevent false-positive changes when connecting new sources with same values - int itemHash; - - // Try to get the actual value for common Grasshopper types - if (item is IGH_Goo goo && goo.IsValid) - { - var value = goo.ScriptVariable(); - itemHash = value?.GetHashCode() ?? 0; - } - else - { - // Fallback to string representation for consistent value-based hashing - itemHash = item.GetHashCode(); - } - - branchHash = CombineHashCodes(branchHash, itemHash); - } - } - - // Combine the branch data hash (captures the VALUES in this branch) - currentHash = CombineHashCodes(currentHash, branchHash); - - // Combine the branch path hash (captures the STRUCTURE/PATH of this branch) - // This is crucial because branches {0} and {1} with identical data should have different hashes - currentHash = CombineHashCodes(currentHash, branch.GetHashCode()); - } - - return currentHash; - } - - private void CalculatePersistentDataHashes() - { - if (this.previousInputHashes == null) - { - Debug.WriteLine($"[{this.GetType().Name}] Initializing hash dictionaries"); - this.previousInputHashes = new Dictionary(); - this.previousInputBranchCounts = new Dictionary(); - } - - // Check each input parameter - for (int i = 0; i < this.Params.Input.Count; i++) - { - var param = this.Params.Input[i]; - this.StorePersistentDataHash(param); - } - } - - private void StorePersistentDataHash(IGH_Param param) - { - int branchCount; - int currentHash = CalculatePersistentDataHash(param, out branchCount); - - this.previousInputHashes[param.Name] = currentHash; - this.previousInputBranchCounts[param.Name] = branchCount; - } - - /// - /// Determines which input parameters have changed since the last successful run by comparing - /// both value hashes and branch structure counts. - /// - /// List of input parameter names that have changed. - protected virtual List InputsChanged() - { - if (this.previousInputHashes == null) - { - this.CalculatePersistentDataHashes(); - } - - var changedInputs = new List(); - - // Check each input parameter - for (int i = 0; i < this.Params.Input.Count; i++) - { - var param = this.Params.Input[i]; - int branchCount; - int currentHash = CalculatePersistentDataHash(param, out branchCount); - - bool inputChanged = false; - - // Check if hash changed - if (!this.previousInputHashes.TryGetValue(param.Name, out int previousHash)) - { - Debug.WriteLine($"[{this.GetType().Name}] [CheckInputs Changed - {param.Name}] - No previous hash found for '{param.Name}'"); - inputChanged = true; - } - else if (previousHash != currentHash) - { - Debug.WriteLine($"[{this.GetType().Name}] [CheckInputs Changed - {param.Name}] - Hash changed for '{param.Name}' ({previousHash} to {currentHash})"); - inputChanged = true; - } - - // Check if branch count changed - if (!this.previousInputBranchCounts.TryGetValue(param.Name, out int previousBranchCount)) - { - Debug.WriteLine($"[{this.GetType().Name}] [CheckInputs Changed - {param.Name}] - No previous branch count found for '{param.Name}'"); - inputChanged = true; - } - else if (previousBranchCount != branchCount) - { - Debug.WriteLine($"[{this.GetType().Name}] [CheckInputs Changed - {param.Name}] - Branch count changed for '{param.Name}' ({previousBranchCount} to {branchCount})"); - inputChanged = true; - } - - if (inputChanged) - { - changedInputs.Add(param.Name); - } - } - - return changedInputs; - } - - /// - /// Checks if a specific input has changed since the last run. - /// - /// Name of the input parameter to check. - /// If true, the check is performed exclusively, meaning only the given input is considered. - /// True if the input has changed. - protected bool InputsChanged(string inputName, bool exclusively = true) - { - var changedInputs = this.InputsChanged(); - - // If exclusively is true, check if the changedInputs list contains only the given inputName - if (exclusively) - { - return changedInputs.Count == 1 && changedInputs.Any(name => name == inputName); - } - - // If not exclusively, check if the inputName is present - else - { - return changedInputs.Any(name => name == inputName); - } - } - - /// - /// Checks if any of the specified inputs have changed since the last run. - /// - /// List of input parameter names to check. - /// If true, the check is performed exclusively, meaning only the given inputs are considered. - /// True if any of the specified inputs have changed. - protected bool InputsChanged(IEnumerable inputNames, bool exclusively = true) - { - var changedInputs = this.InputsChanged(); - var inputNamesList = inputNames.ToList(); - - // If exclusively is true, check if the changedInputs list contains any of the given inputNames, and no other than the given inputNames - if (exclusively) - { - // There cannot be changedInputs that are not in the inputNames list - return changedInputs.Count > 0 && !changedInputs.Except(inputNamesList).Any(); - } - - // If not exclusively, check if any of the inputNames is present in changed inputs - else - { - return changedInputs.Any(changed => inputNamesList.Contains(changed)); - } - } - - private void ResetInputChanged() - { - for (int i = 0; i < this.Params.Input.Count; i++) - { - var param = this.Params.Input[i]; - int branchCount; - int currentHash = CalculatePersistentDataHash(param, out branchCount); - - // Store current values for next comparison - this.previousInputHashes[param.Name] = currentHash; - this.previousInputBranchCounts[param.Name] = branchCount; - } - } - - private static int CombineHashCodes(int h1, int h2) - { - unchecked - { - return ((h1 << 5) + h1) ^ h2; - } - } - - /// - /// Clears both persistent storage and output parameters while preserving runtime messages. - /// - protected override void ClearDataOnly() - { - this.persistentOutputs.Clear(); - base.ClearDataOnly(); - } - - #endregion - - #region AUX - - /// - /// Expires downstream objects when appropriate so that connected components recompute. - /// Allows expiration in and during the post-solve pass - /// of once outputs have been set. - /// - protected override void ExpireDownStreamObjects() - { - // Prefer expiring when data is ready: - // - Always allow in Completed state - // - Also allow in Processing during the post-solve pass (after outputs have been set) - bool allowDuringProcessing = this.currentState == ComponentState.Processing - && !this.InPreSolve - && (this.SetData == 1 || (this.persistentOutputs != null && this.persistentOutputs.Count > 0)); - - if (this.currentState == ComponentState.Completed || allowDuringProcessing) - { - Debug.WriteLine($"[StatefulAsyncComponentBase] Expiring downstream objects (state: {this.currentState}, InPreSolve: {this.InPreSolve}, setData: {this.SetData})"); - base.ExpireDownStreamObjects(); - } - - return; - } - -#if DEBUG - /// - /// Appends debug-only context menu items useful for manual testing while developing. - /// - /// The target menu. - public override void AppendAdditionalMenuItems(ToolStripDropDown menu) - { - base.AppendAdditionalMenuItems(menu); - Menu_AppendSeparator(menu); - Menu_AppendItem(menu, "Debug: OnDisplayExpired(true)", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual OnDisplayExpired(true)"); - this.OnDisplayExpired(true); - }); - Menu_AppendItem(menu, "Debug: OnDisplayExpired(false)", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual OnDisplayExpired(false)"); - this.OnDisplayExpired(false); - }); - Menu_AppendItem(menu, "Debug: ExpireSolution", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual ExpireSolution"); - this.ExpireSolution(true); - }); - Menu_AppendItem(menu, "Debug: ExpireDownStreamObjects", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual ExpireDownStreamObjects"); - this.ExpireDownStreamObjects(); - }); - Menu_AppendItem(menu, "Debug: ClearData", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual ClearData"); - this.ClearData(); - }); - Menu_AppendItem(menu, "Debug: ClearDataOnly", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual ClearDataOnly"); - this.ClearDataOnly(); - }); - Menu_AppendItem(menu, "Debug: Add Error", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual Add Error"); - this.SetPersistentRuntimeMessage("test-error", GH_RuntimeMessageLevel.Error, "This is an error"); - }); - Menu_AppendItem(menu, "Debug: Add Warning", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual Add Warning"); - this.SetPersistentRuntimeMessage("test-warning", GH_RuntimeMessageLevel.Warning, "This is a warning"); - }); - Menu_AppendItem(menu, "Debug: Add Remark", (s, e) => - { - Debug.WriteLine("[StatefulAsyncComponentBase] Manual Add Remark"); - this.SetPersistentRuntimeMessage("test-remark", GH_RuntimeMessageLevel.Remark, "This is a remark"); - }); - } - -#endif - /// - /// Requests cancellation of any running tasks and transitions the component to the Cancelled state. - /// - public override void RequestTaskCancellation() - { - base.RequestTaskCancellation(); - this.TransitionTo(ComponentState.Cancelled, this.lastDA); - } - - #endregion - - } -} From 8a6730868542dbd056d9d5dabc9271f43cf1e0b1 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:03:23 +0100 Subject: [PATCH 21/26] fix(header-fixer): strip BOM when comparing and updating file headers --- .github/actions/code-style/header-fixer/action.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/code-style/header-fixer/action.yml b/.github/actions/code-style/header-fixer/action.yml index 23f96ffe..2fb43a65 100644 --- a/.github/actions/code-style/header-fixer/action.yml +++ b/.github/actions/code-style/header-fixer/action.yml @@ -73,13 +73,13 @@ runs: ) HEADER_LINE_COUNT=${#HEADER_LINES_ARRAY[@]} - # Compare header per-line ignoring year + # Compare header per-line ignoring year and BOM # echo "Comparing header for $file" mismatch=0 mismatch_line=0 for ((i=0; i "$file" fi - # Check and fix unbalanced comment blocks in first 20 lines - HEAD_LINES=$(head -n 20 "$file") + # Check and fix unbalanced comment blocks in first 20 lines (strip BOM for analysis) + HEAD_LINES=$(head -n 20 "$file" | sed '1s/^\xEF\xBB\xBF//') OPEN_COUNT=$(grep -o '/\*' <<<"$HEAD_LINES" | wc -l || true) CLOSE_COUNT=$(grep -o '\*/' <<<"$HEAD_LINES" | wc -l || true) echo "DEBUG: OPEN_COUNT=$OPEN_COUNT, CLOSE_COUNT=$CLOSE_COUNT in first 20 lines" From 8b9d2f534e53ee9b0e81e48d5ccde1d3192f3c86 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:04:28 +0100 Subject: [PATCH 22/26] style(docs): remove trailing whitespace in GhJSON index --- docs/GhJSON/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GhJSON/index.md b/docs/GhJSON/index.md index a2fce899..e97d9373 100644 --- a/docs/GhJSON/index.md +++ b/docs/GhJSON/index.md @@ -10,7 +10,7 @@ ### Core Documentation -- **[Format Specification](./format-specification.md)** +- **[Format Specification](./format-specification.md)** Complete schema reference for GhJSON format including component structure, connections, groups, and validation rules. - **[Property Management (V2)](./property-management.md)** From 4fb2e11635421e5c0914835dc0a6cdee965ac93b Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sat, 27 Dec 2025 11:38:53 +0100 Subject: [PATCH 23/26] fix(component-base): improve debounce timer cancellation checks to prevent stale callbacks - Add early return when debounceTimeMs is 0, indicating timer has been cancelled/reset - Capture debounceTimeMs along with generation and target state in initial lock - Add double-check validation using captured time value to prevent race conditions - Update debug messages to include time value for better diagnostics --- .../ComponentBase/ComponentStateManager.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs b/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs index c22acfd4..9e7e0bc5 100644 --- a/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs +++ b/src/SmartHopper.Core/ComponentBase/ComponentStateManager.cs @@ -778,20 +778,33 @@ private void OnDebounceElapsed(object state) { int capturedGeneration; ComponentState targetState; + int capturedTimeMs; + // Capture all required data in a single lock to ensure consistency lock (this.stateLock) { + // If debounce time is 0, timer has already been cancelled/reset + if (this.debounceTimeMs == 0) + { + Debug.WriteLine($"[{this.componentName}] Debounce callback: timer already cancelled"); + return; + } + capturedGeneration = this.debounceGeneration; targetState = this.debounceTargetState; - this.debounceTimeMs = 0; // Mark as elapsed + capturedTimeMs = this.debounceTimeMs; + + // Mark as elapsed immediately to prevent race conditions + this.debounceTimeMs = 0; } - // Check if this callback is still valid + // Additional validation check outside the lock lock (this.stateLock) { - if (capturedGeneration != this.debounceGeneration) + // Double-check generation and that we're still the active timer + if (capturedGeneration != this.debounceGeneration || capturedTimeMs == 0) { - Debug.WriteLine($"[{this.componentName}] Debounce callback stale (gen {capturedGeneration} != {this.debounceGeneration}), ignoring"); + Debug.WriteLine($"[{this.componentName}] Debounce callback stale (gen {capturedGeneration} != {this.debounceGeneration}, time {capturedTimeMs}), ignoring"); return; } From 174fe9c78c84475a539c27ee90d3878f8d728b88 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sat, 27 Dec 2025 16:04:23 +0100 Subject: [PATCH 24/26] refactor: simplify conditional expressions and improve code clarity - Replace integer literals with double literals in test calculations for type consistency - Mark ComponentStateManager field as readonly in tests - Rename 'run' variable to 'runInput' to avoid shadowing instance field - Simplify conditional expressions using ternary operators in InputChanged methods - Flatten nested if statements in DeepSeekProvider reasoning_content removal logic --- .../TestStateManagerRestorationComponent.cs | 2 +- .../ComponentStateManagerTests.cs | 2 +- .../ComponentBase/StatefulComponentBase.cs | 40 +++++++------------ .../DeepSeekProvider.cs | 16 +++----- 4 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs index 8d7a3e04..1a122733 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs @@ -232,7 +232,7 @@ public override async Task DoWorkAsync(CancellationToken token) { // Simple calculation with a small delay to simulate async work await Task.Delay(100, token); - this.result = (this.inputNumber * 2) + 1; + this.result = (this.inputNumber * 2d) + 1d; } /// diff --git a/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs b/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs index c6e8d367..5ea8233d 100644 --- a/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs +++ b/src/SmartHopper.Core.Tests/ComponentBase/ComponentStateManagerTests.cs @@ -23,7 +23,7 @@ namespace SmartHopper.Core.Tests.ComponentBase /// public class ComponentStateManagerTests : IDisposable { - private ComponentStateManager manager; + private readonly ComponentStateManager manager; public ComponentStateManagerTests() { diff --git a/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs index 8f7cd578..40087582 100644 --- a/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs @@ -332,9 +332,9 @@ protected override void SolveInstance(IGH_DataAccess DA) } // Read Run parameter - bool run = false; - DA.GetData("Run?", ref run); - this.run = run; + bool runInput = false; + DA.GetData("Run?", ref runInput); + this.run = runInput; // Note: GH_Button drives volatile data and may not affect PersistentData hashes. // Track the last observed Run value to detect button pulses reliably. @@ -542,10 +542,10 @@ private void OnStateNeedsRun(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateNeedsRun"); - bool run = false; - DA.GetData("Run?", ref run); + bool runInput = false; + DA.GetData("Run?", ref runInput); - if (run) + if (runInput) { this.ClearOnePersistentRuntimeMessage("needs_run"); this.StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); @@ -580,14 +580,14 @@ private void OnStateCancelled(IGH_DataAccess DA) this.ApplyPersistentRuntimeMessages(); this.SetPersistentRuntimeMessage("cancelled", GH_RuntimeMessageLevel.Error, "The execution was manually cancelled", false); - bool run = false; - DA.GetData("Run?", ref run); + bool runInput = false; + DA.GetData("Run?", ref runInput); // Check for changes using StateManager var changedInputs = this.StateManager.GetChangedInputs(); // If Run changed to true and no other inputs changed, transition to Processing - if (changedInputs.Count == 1 && changedInputs[0] == "Run?" && run) + if (changedInputs.Count == 1 && changedInputs[0] == "Run?" && runInput) { this.StateManager.RequestTransition(ComponentState.Processing, TransitionReason.RunEnabled); } @@ -1447,14 +1447,9 @@ protected bool InputsChanged(string inputName, bool exclusively = true) { var changedInputs = this.InputsChanged(); - if (exclusively) - { - return changedInputs.Count == 1 && changedInputs.Any(name => name == inputName); - } - else - { - return changedInputs.Any(name => name == inputName); - } + return exclusively + ? (changedInputs.Count == 1 && changedInputs.Contains(inputName)) + : changedInputs.Contains(inputName); } /// @@ -1465,14 +1460,9 @@ protected bool InputsChanged(IEnumerable inputNames, bool exclusively = var changedInputs = this.InputsChanged(); var inputNamesList = inputNames.ToList(); - if (exclusively) - { - return changedInputs.Count > 0 && !changedInputs.Except(inputNamesList).Any(); - } - else - { - return changedInputs.Any(changed => inputNamesList.Contains(changed)); - } + return exclusively + ? (changedInputs.Count > 0 && !changedInputs.Except(inputNamesList).Any()) + : changedInputs.Any(changed => inputNamesList.Contains(changed)); } #endregion diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs index abbe3b73..40aa78d7 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs @@ -199,12 +199,10 @@ public override string Encode(AIRequestCall request) // Remove reasoning_content from assistant messages without tool_calls // per DeepSeek's recommendation to save bandwidth - if (string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase) + && (currentMessage["tool_calls"] == null || (currentMessage["tool_calls"] is JArray tcArray && tcArray.Count == 0))) { - if (currentMessage["tool_calls"] == null || (currentMessage["tool_calls"] is JArray tcArray && tcArray.Count == 0)) - { - currentMessage.Remove("reasoning_content"); - } + currentMessage.Remove("reasoning_content"); } convertedMessages.Add(currentMessage); @@ -289,12 +287,10 @@ public override string Encode(AIRequestCall request) // Remove reasoning_content from assistant messages without tool_calls // per DeepSeek's recommendation to save bandwidth - if (string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(currentMessage["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase) + && (currentMessage["tool_calls"] == null || (currentMessage["tool_calls"] is JArray tcArray && tcArray.Count == 0))) { - if (currentMessage["tool_calls"] == null || (currentMessage["tool_calls"] is JArray tcArray && tcArray.Count == 0)) - { - currentMessage.Remove("reasoning_content"); - } + currentMessage.Remove("reasoning_content"); } convertedMessages.Add(currentMessage); From 85cf0cd8e35168512107100a835b9fcab8635962 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 27 Dec 2025 15:26:08 +0000 Subject: [PATCH 25/26] chore: update development version date to 1.2.2-dev.251227 --- README.md | 2 +- Solution.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 690452fd..7ac3b086 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.2.2--dev.251224-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.2.2--dev.251227-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![Status](https://img.shields.io/badge/status-Unstable%20Development-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) diff --git a/Solution.props b/Solution.props index 7e70ca86..e2c1c61a 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.2.2-dev.251224 + 1.2.2-dev.251227 \ No newline at end of file From e5dcf136ff3b863268d1fbff2c10d0a233ed81b5 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sat, 27 Dec 2025 15:32:18 +0000 Subject: [PATCH 26/26] chore: prepare release 1.2.2-alpha with version update and code style fixes --- CHANGELOG.md | 2 ++ README.md | 4 ++-- Solution.props | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b8f08d..9a374c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.2-alpha] - 2025-12-27 + ### Added - Chat: diff --git a/README.md b/README.md index 7ac3b086..3362ea33 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.2.2--dev.251227-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) -[![Status](https://img.shields.io/badge/status-Unstable%20Development-brown?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.2.2--alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Status](https://img.shields.io/badge/status-Alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) [![License](https://img.shields.io/badge/license-LGPL%20v3-white?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/blob/main/LICENSE) diff --git a/Solution.props b/Solution.props index e2c1c61a..4a1bbb7b 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.2.2-dev.251227 + 1.2.2-alpha \ No newline at end of file