diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d06621..aa6fe2d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Summary documentation at `docs/` (linked in README). - `ModelManager.SetDefault(provider, model, caps, exclusive)` helper to manage per-capability defaults. - New `AIRuntimeMessage` model to handle information, warning and error messages on AI Call. +- New component badges to visually identify verified and deprecated models. ### Changed diff --git a/docs/Components/ComponentBase/AIProviderComponentBase.md b/docs/Components/ComponentBase/AIProviderComponentBase.md index fc5c83df..36d569fb 100644 --- a/docs/Components/ComponentBase/AIProviderComponentBase.md +++ b/docs/Components/ComponentBase/AIProviderComponentBase.md @@ -21,5 +21,5 @@ Expose provider selection via context menu and store the selection so derived co ## Related -- [AIComponentAttributes](../Helpers/AIComponentAttributes.md) – draws a provider logo/badge on the component. +- [AIProviderComponentAttributes](../Helpers/AIProviderComponentAttributes.md) – draws a provider logo/badge on the component. - [AIStatefulAsyncComponentBase](./StatefulAsyncComponentBase.md) – combines this base with the async state machine. diff --git a/docs/Components/Helpers/AIComponentAttributes.md b/docs/Components/Helpers/AIComponentAttributes.md index bcf2db6c..6e1ef819 100644 --- a/docs/Components/Helpers/AIComponentAttributes.md +++ b/docs/Components/Helpers/AIComponentAttributes.md @@ -1,4 +1,4 @@ -# AIComponentAttributes +# AIProviderComponentAttributes Custom Grasshopper attributes that add a provider badge to AI components. diff --git a/docs/Suggestions.md b/docs/Suggestions.md index 17d24b40..fe0960d3 100644 --- a/docs/Suggestions.md +++ b/docs/Suggestions.md @@ -138,9 +138,10 @@ Breaking changes are acceptable; this is a forward-looking plan. - [ ] AI Provider streaming of responses - [ ] AI Provider-side prompt caching -- [ ] AI Call cancellation token +- [ ] AI Call cancellation token, callable by the user in components (cancelled state), webchat (with new ui button to cancel current call) and automatically called on closing webchat dialog or rhino - [ ] AI Call local caching of responses to avoid recomputation - [ ] AI Call compatibility with parallel tool calling - [ ] AI tool to generate Grasshopper definitions in GHJSON format -- [ ] New way to save Grasshopper files in GHJSON format +- [ ] New way to save Grasshopper files in GHJSON format to disk - [ ] Improve WebChat UI with full html environment, including dynamic loading messages, supporting streaming, different interaction type (image, audio, text, toolcall, toolresult...) +- [ ] Add compatibility in persistant data storage and GhJson to more Grasshopper native data types (colors, points, vectors, lines, plane, circle...) diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs new file mode 100644 index 00000000..9aee1640 --- /dev/null +++ b/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs @@ -0,0 +1,157 @@ +/* + * 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. + */ + +/* + * TestBadgesOneComponent: Grasshopper test component to visually verify the + * inline badge rendering. Displays a single sample badge above the component + * using the shared ComponentBadgesAttributes with an override that contributes + * one additional badge and hover label. + */ + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Grasshopper.Kernel; +using SmartHopper.Core.ComponentBase; + +namespace SmartHopper.Components.Test.Badges +{ + /// + /// Test component that renders exactly one badge via custom attributes. + /// + public class TestBadgesOneComponent : AIStatefulAsyncComponentBase + { + /// + public override Guid ComponentGuid => new Guid("2B5E5F5A-6F4D-4C0F-8D99-0E9A04BB33A1"); + + /// + protected override Bitmap Icon => null; + + /// + public override GH_Exposure Exposure => GH_Exposure.quinary; + + /// + /// Initializes a new instance of the class. + /// + public TestBadgesOneComponent() + : base("Test Badges: One", "TBadges1", + "Renders a single sample badge above the component for visual verification.", + "SmartHopper", "Testing") + { + } + + /// + protected override void RegisterOutputParams(GH_OutputParamManager pManager) + { + // No custom outputs; base adds Metrics + base.RegisterOutputParams(pManager); + } + + /// + public override void CreateAttributes() + { + this.m_attributes = new OneBadgeAttributes(this); + } + + /// + /// No-op: badge test component defines no additional inputs. + /// + /// Input param manager. + protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) + { + // Intentionally empty + } + + /// + /// No-op: badge test component defines no additional outputs. + /// + /// Output param manager. + protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) + { + // Intentionally empty + } + + /// + /// Creates a minimal no-op worker since the component exists only to test inline badge rendering. + /// + /// Unused progress reporter. + /// A worker that performs no computation. + protected override AsyncWorkerBase CreateWorker(Action progressReporter) + { + return new NoopWorker(this, AddRuntimeMessage); + } + + /// + /// Minimal worker for badge test components. It gathers no input, performs no work, + /// and sets only a lightweight status message so the async pipeline completes. + /// + private sealed class NoopWorker : AsyncWorkerBase + { + /// + /// Initializes a new instance of the class. + /// + /// Component instance. + /// Delegate to add runtime messages. + public NoopWorker(GH_Component parent, Action addRuntimeMessage) + : base(parent, addRuntimeMessage) + { + } + + /// + public override void GatherInput(IGH_DataAccess DA, out int dataCount) + { + dataCount = 0; + } + + /// + public override Task DoWorkAsync(CancellationToken token) + { + return Task.CompletedTask; + } + + /// + public override void SetOutput(IGH_DataAccess DA, out string message) + { + message = "Ready"; + } + } + + /// + /// Custom attributes contributing one additional badge. + /// + private class OneBadgeAttributes : ComponentBadgesAttributes + { + private static void DrawSampleBadge(Graphics g, float x, float y) + { + var size = 16f; + using (var bg = new SolidBrush(Color.FromArgb(52, 152, 219))) // blue + using (var pen = new Pen(Color.White, 1.5f)) + { + var rect = new RectangleF(x, y, size, size); + g.FillEllipse(bg, rect); + g.DrawEllipse(pen, rect); + + // white dot + g.FillEllipse(Brushes.White, rect.X + size * 0.45f, rect.Y + size * 0.45f, size * 0.1f, size * 0.1f); + } + } + + public OneBadgeAttributes(AIProviderComponentBase owner) : base(owner) { } + + /// + protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges() + { + yield return (DrawSampleBadge, "Sample badge 1"); + } + } + } +} diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs new file mode 100644 index 00000000..d1e289b2 --- /dev/null +++ b/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs @@ -0,0 +1,155 @@ +/* + * 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. + */ + +/* + * TestBadgesThreeComponent: Verifies three inline badges rendering and hover labels. + */ + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Grasshopper.Kernel; +using SmartHopper.Core.ComponentBase; + +namespace SmartHopper.Components.Test.Badges +{ + /// + /// Test component that renders three sample badges via custom attributes. + /// + public class TestBadgesThreeComponent : AIStatefulAsyncComponentBase + { + /// + public override Guid ComponentGuid => new Guid("5D9A39A7-9F1B-4F2E-9D6A-9B9D3F93A0B7"); + + /// + protected override Bitmap Icon => null; + + /// + public override GH_Exposure Exposure => GH_Exposure.quinary; + + public TestBadgesThreeComponent() + : base("Test Badges: Three", "TBadges3", + "Renders three sample badges above the component for visual verification.", + "SmartHopper", "Testing") + { + } + + public override void CreateAttributes() + { + this.m_attributes = new ThreeBadgesAttributes(this); + } + + /// + /// No-op: badge test component defines no additional inputs. + /// + /// Input param manager. + protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) + { + // Intentionally empty + } + + /// + /// No-op: badge test component defines no additional outputs. + /// + /// Output param manager. + protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) + { + // Intentionally empty + } + + /// + /// Creates a minimal no-op worker since the component exists only to test inline badge rendering. + /// + /// Unused progress reporter. + /// A worker that performs no computation. + protected override AsyncWorkerBase CreateWorker(Action progressReporter) + { + return new NoopWorker(this, AddRuntimeMessage); + } + + /// + /// Minimal worker for badge test components. It gathers no input, performs no work, + /// and sets only a lightweight status message so the async pipeline completes. + /// + private sealed class NoopWorker : AsyncWorkerBase + { + /// + /// Initializes a new instance of the class. + /// + /// Component instance. + /// Delegate to add runtime messages. + public NoopWorker(GH_Component parent, Action addRuntimeMessage) + : base(parent, addRuntimeMessage) + { + } + + /// + public override void GatherInput(IGH_DataAccess DA, out int dataCount) + { + dataCount = 0; + } + + /// + public override Task DoWorkAsync(CancellationToken token) + { + return Task.CompletedTask; + } + + /// + public override void SetOutput(IGH_DataAccess DA, out string message) + { + message = "Ready"; + } + } + + private class ThreeBadgesAttributes : ComponentBadgesAttributes + { + private const float S = 16f; + + private static void DrawBlue(Graphics g, float x, float y) + { + using var bg = new SolidBrush(Color.FromArgb(52, 152, 219)); + using var pen = new Pen(Color.White, 1.5f); + var rect = new RectangleF(x, y, S, S); + g.FillEllipse(bg, rect); + g.DrawEllipse(pen, rect); + } + + private static void DrawPurple(Graphics g, float x, float y) + { + using var bg = new SolidBrush(Color.FromArgb(155, 89, 182)); + using var pen = new Pen(Color.White, 1.5f); + var rect = new RectangleF(x, y, S, S); + g.FillEllipse(bg, rect); + g.DrawEllipse(pen, rect); + } + + private static void DrawTeal(Graphics g, float x, float y) + { + using var bg = new SolidBrush(Color.FromArgb(26, 188, 156)); + using var pen = new Pen(Color.White, 1.5f); + var rect = new RectangleF(x, y, S, S); + g.FillEllipse(bg, rect); + g.DrawEllipse(pen, rect); + } + + public ThreeBadgesAttributes(AIProviderComponentBase owner) : base(owner) { } + + protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges() + { + yield return (DrawBlue, "Sample badge 1"); + yield return (DrawPurple, "Sample badge 2"); + yield return (DrawTeal, "Sample badge 3"); + } + } + } +} diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs new file mode 100644 index 00000000..04bdcee2 --- /dev/null +++ b/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs @@ -0,0 +1,145 @@ +/* + * 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. + */ + +/* + * TestBadgesTwoComponent: Verifies two inline badges rendering and hover labels. + */ + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; +using Grasshopper.Kernel; +using SmartHopper.Core.ComponentBase; + +namespace SmartHopper.Components.Test.Badges +{ + /// + /// Test component that renders two sample badges via custom attributes. + /// + public class TestBadgesTwoComponent : AIStatefulAsyncComponentBase + { + /// + public override Guid ComponentGuid => new Guid("A1A8E7D1-5F40-4F8C-8D8C-2E7DE55F6B22"); + + /// + protected override Bitmap Icon => null; + + /// + public override GH_Exposure Exposure => GH_Exposure.quinary; + + public TestBadgesTwoComponent() + : base("Test Badges: Two", "TBadges2", + "Renders two sample badges above the component for visual verification.", + "SmartHopper", "Testing") + { + } + + public override void CreateAttributes() + { + this.m_attributes = new TwoBadgesAttributes(this); + } + + /// + /// No-op: badge test component defines no additional inputs. + /// + /// Input param manager. + protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) + { + // Intentionally empty + } + + /// + /// No-op: badge test component defines no additional outputs. + /// + /// Output param manager. + protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) + { + // Intentionally empty + } + + /// + /// Creates a minimal no-op worker since the component exists only to test inline badge rendering. + /// + /// Unused progress reporter. + /// A worker that performs no computation. + protected override AsyncWorkerBase CreateWorker(Action progressReporter) + { + return new NoopWorker(this, AddRuntimeMessage); + } + + /// + /// Minimal worker for badge test components. It gathers no input, performs no work, + /// and sets only a lightweight status message so the async pipeline completes. + /// + private sealed class NoopWorker : AsyncWorkerBase + { + /// + /// Initializes a new instance of the class. + /// + /// Component instance. + /// Delegate to add runtime messages. + public NoopWorker(GH_Component parent, Action addRuntimeMessage) + : base(parent, addRuntimeMessage) + { + } + + /// + public override void GatherInput(IGH_DataAccess DA, out int dataCount) + { + dataCount = 0; + } + + /// + public override Task DoWorkAsync(CancellationToken token) + { + return Task.CompletedTask; + } + + /// + public override void SetOutput(IGH_DataAccess DA, out string message) + { + message = "Ready"; + } + } + + private class TwoBadgesAttributes : ComponentBadgesAttributes + { + private const float S = 16f; + + private static void DrawBlueBadge(Graphics g, float x, float y) + { + using var bg = new SolidBrush(Color.FromArgb(52, 152, 219)); + using var pen = new Pen(Color.White, 1.5f); + var rect = new RectangleF(x, y, S, S); + g.FillEllipse(bg, rect); + g.DrawEllipse(pen, rect); + } + + private static void DrawPurpleBadge(Graphics g, float x, float y) + { + using var bg = new SolidBrush(Color.FromArgb(155, 89, 182)); + using var pen = new Pen(Color.White, 1.5f); + var rect = new RectangleF(x, y, S, S); + g.FillEllipse(bg, rect); + g.DrawEllipse(pen, rect); + } + + public TwoBadgesAttributes(AIProviderComponentBase owner) : base(owner) { } + + protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges() + { + yield return (DrawBlueBadge, "Sample badge 1"); + yield return (DrawPurpleBadge, "Sample badge 2"); + } + } + } +} diff --git a/src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs similarity index 52% rename from src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs rename to src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs index b4466013..7562a0b7 100644 --- a/src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs +++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentAttributes.cs @@ -8,12 +8,14 @@ * version 3 of the License, or (at your option) any later version. */ +using System; using System.Drawing; using Grasshopper.GUI; using Grasshopper.GUI.Canvas; using Grasshopper.Kernel.Attributes; using SmartHopper.Infrastructure.AIProviders; using SmartHopper.Infrastructure.Settings; +using Timer = System.Timers.Timer; namespace SmartHopper.Core.ComponentBase { @@ -21,19 +23,28 @@ namespace SmartHopper.Core.ComponentBase /// Custom attributes for AI components that displays the provider logo /// as a badge on the component. /// - public class AIComponentAttributes : GH_ComponentAttributes + public class AIProviderComponentAttributes : GH_ComponentAttributes { private readonly AIProviderComponentBase owner; private const int BADGESIZE = 16; // Size of the provider logo badge private const float MINZOOMTHRESHOLD = 0.5f; // Minimum zoom level to show the badge private const int PROVIDERSTRIPHEIGHT = 20; // Height of the provider strip + // Hover state for inline provider label + private RectangleF providerIconRect = RectangleF.Empty; + private bool hoverProviderIcon = false; + + // Timer-based auto-hide for inline label (disappears after 5s even if still hovered) + // Purpose: avoid sticky labels when the cursor remains stationary. + private Timer? providerLabelTimer; + private bool providerLabelAutoHidden = false; + /// - /// Initializes a new instance of the class. - /// Creates a new instance of AIComponentAttributes. + /// Initializes a new instance of the class. + /// Creates a new instance of AIProviderComponentAttributes. /// /// The AI component that owns these attributes. - public AIComponentAttributes(AIProviderComponentBase owner) + public AIProviderComponentAttributes(AIProviderComponentBase owner) : base(owner) { this.owner = owner; @@ -53,6 +64,10 @@ protected override void Layout() bounds.Height += PROVIDERSTRIPHEIGHT; this.Bounds = bounds; } + + // Reset hover state on layout + this.providerIconRect = RectangleF.Empty; + this.hoverProviderIcon = false; } /// @@ -106,12 +121,87 @@ protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasCha bounds.Bottom - PROVIDERSTRIPHEIGHT + ((PROVIDERSTRIPHEIGHT - BADGESIZE) / 2), BADGESIZE, BADGESIZE); + this.providerIconRect = iconRect; // Draw the provider icon using GH methods if (providerIcon != null) { GH_GraphicsUtil.RenderIcon(graphics, iconRect, providerIcon); } + + // Draw inline label for provider when hovered and not auto-hidden (rendered after icon) + if (this.hoverProviderIcon && !this.providerLabelAutoHidden && this.providerIconRect.Width > 0 && canvas.Viewport.Zoom >= MINZOOMTHRESHOLD) + { + var label = $"Using {actualProviderName} provider"; + InlineLabelRenderer.DrawInlineLabel(graphics, this.providerIconRect, label); + } + } + } + + /// + /// Track mouse hover over the provider icon to trigger inline label rendering. + /// + public override GH_ObjectResponse RespondToMouseMove(GH_Canvas sender, GH_CanvasMouseEvent e) + { + bool prev = this.hoverProviderIcon; + + if (sender?.Viewport.Zoom < MINZOOMTHRESHOLD) + { + this.hoverProviderIcon = false; + } + else + { + var pt = e.CanvasLocation; + this.hoverProviderIcon = !this.providerIconRect.IsEmpty && this.providerIconRect.Contains(pt); + } + + if (prev != this.hoverProviderIcon) + { + // Start/stop 5s auto-hide timer based on hover transitions + if (this.hoverProviderIcon) + { + this.providerLabelAutoHidden = false; + StartProviderLabelTimer(); + } + else + { + StopProviderLabelTimer(); + this.providerLabelAutoHidden = false; // reset for next hover + } + + this.owner.OnDisplayExpired(false); + } + + return base.RespondToMouseMove(sender, e); + } + + /// + /// Starts a one-shot 5s timer to auto-hide the inline label and request a repaint. + /// + private void StartProviderLabelTimer() + { + StopProviderLabelTimer(); + this.providerLabelTimer = new Timer(5000) { AutoReset = false }; + this.providerLabelTimer.Elapsed += (_, __) => + { + // Mark as auto-hidden and request display refresh + this.providerLabelAutoHidden = true; + try { this.owner?.OnDisplayExpired(false); } catch { /* ignore */ } + StopProviderLabelTimer(); + }; + this.providerLabelTimer.Start(); + } + + /// + /// Stops and disposes the provider label timer if active. + /// + private void StopProviderLabelTimer() + { + if (this.providerLabelTimer != null) + { + try { this.providerLabelTimer.Stop(); } catch { /* ignore */ } + try { this.providerLabelTimer.Dispose(); } catch { /* ignore */ } + this.providerLabelTimer = null; } } } diff --git a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs index e643bdfc..0f43b055 100644 --- a/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AIProviderComponentBase.cs @@ -165,7 +165,7 @@ public string GetActualAIProviderName() /// public override void CreateAttributes() { - this.m_attributes = new AIComponentAttributes(this); + this.m_attributes = new AIProviderComponentAttributes(this); } /// diff --git a/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs b/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs index 398ca6a4..53b6f2bb 100644 --- a/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs @@ -27,6 +27,7 @@ using SmartHopper.Infrastructure.AICall; using SmartHopper.Infrastructure.AIModels; using SmartHopper.Infrastructure.AITools; +using SmartHopper.Infrastructure.Settings; namespace SmartHopper.Core.ComponentBase { @@ -47,6 +48,13 @@ public abstract class AIStatefulAsyncComponentBase : AIProviderComponentBase /// private AIMetrics responseMetrics; + /// + /// Cached badge flags (to prevent recomputation during Render/panning). + /// + private bool badgeVerified; + private bool badgeDeprecated; + private bool badgeCacheValid; + /// /// Initializes a new instance of the class. /// Creates a new instance of the AI-powered stateful asynchronous component. @@ -112,6 +120,9 @@ protected override void SolveInstance(IGH_DataAccess DA) DA.GetData("Model", ref model); this.SetModel(model); + // Update badge cache using current inputs before executing the solution + this.UpdateBadgeCache(); + base.SolveInstance(DA); } @@ -335,6 +346,107 @@ protected override void BeforeSolveInstance() protected override void OnSolveInstancePostSolve(IGH_DataAccess DA) { this.SetMetricsOutput(DA); + + // Update badge cache again after solving, so last metrics model is considered + this.UpdateBadgeCache(); + } + + #endregion + + #region UI + + /// + /// Creates the custom attributes for this component, enabling provider and model badges. + /// Uses to render provider icon (via base) and model state badges. + /// + public override void CreateAttributes() + { + this.m_attributes = new ComponentBadgesAttributes(this); + } + + /// + /// Updates the cached badge flags based on the most relevant model and provider. + /// Priority for model: last metrics model, then configured/default model. + /// + internal void UpdateBadgeCache() + { + try + { + // Resolve provider + string providerName = this.GetActualAIProviderName(); + if (providerName == AIProviderComponentBase.DEFAULT_PROVIDER) + { + providerName = SmartHopperSettings.Instance.DefaultAIProvider; + } + + // Resolve model to badge + string modelForBadges = this.GetLastMetricsModel(); + if (string.IsNullOrWhiteSpace(modelForBadges)) + { + modelForBadges = this.GetModelToDisplay(); + } + + if (string.IsNullOrWhiteSpace(providerName) || string.IsNullOrWhiteSpace(modelForBadges)) + { + this.badgeVerified = false; + this.badgeDeprecated = false; + this.badgeCacheValid = false; + return; + } + + var caps = ModelManager.Instance.GetCapabilities(providerName, modelForBadges); + if (caps == null) + { + this.badgeVerified = false; + this.badgeDeprecated = false; + this.badgeCacheValid = false; + return; + } + + this.badgeVerified = caps.Verified; + this.badgeDeprecated = caps.Deprecated; + this.badgeCacheValid = true; + } + catch + { + // On any failure, mark cache invalid to avoid rendering + this.badgeVerified = false; + this.badgeDeprecated = false; + this.badgeCacheValid = false; + } + } + + /// + /// Tries to get the cached badge flags without recomputation. + /// + /// True if model is verified. + /// True if model is deprecated. + /// True if cache is valid; otherwise false. + internal bool TryGetCachedBadgeFlags(out bool verified, out bool deprecated) + { + verified = this.badgeVerified; + deprecated = this.badgeDeprecated; + return this.badgeCacheValid; + } + + /// + /// Returns the last used model recorded in the component's persisted metrics, if any. + /// Used by UI attributes to reflect the actual model previously executed. + /// + /// Last used model name, or empty if not available. + internal string GetLastMetricsModel() + { + return this.responseMetrics != null ? (this.responseMetrics.Model ?? string.Empty) : string.Empty; + } + + /// + /// Returns the configured/input model or the provider's default model for display + /// when the component has never run. + /// + /// Configured/default model name, or empty. + internal string GetModelToDisplay() + { + return this.GetModel() ?? string.Empty; } #endregion diff --git a/src/SmartHopper.Core/ComponentBase/ComponentBadgesAttributes.cs b/src/SmartHopper.Core/ComponentBase/ComponentBadgesAttributes.cs new file mode 100644 index 00000000..00ad903a --- /dev/null +++ b/src/SmartHopper.Core/ComponentBase/ComponentBadgesAttributes.cs @@ -0,0 +1,304 @@ +/* + * 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. + */ + +/* + * ComponentBadgesAttributes: Custom Grasshopper component attributes that render + * AI model status badges (Verified/Deprecated) as floating circles above the component. + * + * Purpose: Extend component UI to show model state directly on the component. + * - Uses last used model from metrics when available; otherwise falls back to the + * configured (input/default) model. + * - Queries ModelManager for AIModelCapabilities to determine Verified/Deprecated flags. + * - Designed to be extensible for future badges (e.g., automatic model replacement). + */ + +using System; +using System.Collections.Generic; +using System.Drawing; +using Grasshopper.GUI; +using Grasshopper.GUI.Canvas; +using Timer = System.Timers.Timer; + +namespace SmartHopper.Core.ComponentBase +{ + /// + /// Custom attributes for AI components that display model badges (verified/deprecated) + /// as floating circles centered above the component (not overlapping the body). + /// + public class ComponentBadgesAttributes : AIProviderComponentAttributes + { + private readonly AIProviderComponentBase owner; + + // Layout constants + private const int BADGE_SIZE = 16; // Size of badges + private const float MIN_ZOOM_THRESHOLD = 0.5f; // Minimum zoom to render badges + private const int BADGE_GAP = 6; // Gap between badges + private const int FLOAT_OFFSET = -10; // Vertical offset above component + + // Hover/interaction state for inline labels (generalized) + private readonly List badgeRects = new List(); + private readonly List badgeLabels = new List(); + private int hoverBadgeIndex = -1; + + // Timer-based auto-hide for inline badge labels (disappears after 5s even if still hovered) + // Purpose: avoid sticky labels when the cursor remains stationary over a badge. + private Timer? badgeLabelTimer; + private bool badgeLabelAutoHidden = false; + + /// + /// Creates a new instance of . + /// + /// The AI component that owns these attributes. + public ComponentBadgesAttributes(AIProviderComponentBase owner) + : base(owner) + { + this.owner = owner; + } + + /// + /// Keep default component bounds; badges are drawn floating above. + /// + protected override void Layout() + { + base.Layout(); + + // Ensure the attribute bounds include the floating badges region above the component + // so that Grasshopper dispatches mouse events when hovering the badges. + var bounds = this.Bounds; + float extendTop = FLOAT_OFFSET + BADGE_SIZE; + bounds.Y -= extendTop; + bounds.Height += extendTop; + this.Bounds = bounds; + + // Reset hover state when layout changes + this.badgeRects.Clear(); + this.badgeLabels.Clear(); + this.hoverBadgeIndex = -1; + } + + /// + /// Renders model state badges as floating circles above the component. + /// Uses cached flags from the owner to avoid heavy lookups during panning. + /// + protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasChannel channel) + { + // First render base visuals (including provider icon at the bottom strip) + base.Render(canvas, graphics, channel); + + if (channel != GH_CanvasChannel.Objects) + { + return; + } + + if (canvas.Viewport.Zoom < MIN_ZOOM_THRESHOLD) + { + return; + } + + if (this.owner is not AIStatefulAsyncComponentBase stateful) + { + return; + } + + if (!stateful.TryGetCachedBadgeFlags(out bool showVerified, out bool showDeprecated)) + { + return; + } + + // Collect badges (built-in + extension point) + var items = new List<(System.Action draw, string label)>(); + if (showVerified) + { + items.Add((DrawVerifiedBadge, "Using a verified model")); + } + if (showDeprecated) + { + items.Add((DrawDeprecatedBadge, "Using a deprecated model")); + } + foreach (var extra in this.GetAdditionalBadges()) + { + items.Add(extra); + } + + if (items.Count == 0) + { + this.badgeRects.Clear(); + this.badgeLabels.Clear(); + this.hoverBadgeIndex = -1; + return; + } + + var bounds = this.Bounds; + + int count = items.Count; + float totalWidth = count * BADGE_SIZE + (count - 1) * BADGE_GAP; + float originX = bounds.Left + (bounds.Width - totalWidth) / 2f; + float y = bounds.Top - FLOAT_OFFSET - BADGE_SIZE; + + // Reset rects before drawing + this.badgeRects.Clear(); + this.badgeLabels.Clear(); + + float x = originX; + for (int i = 0; i < items.Count; i++) + { + var item = items[i]; + item.draw(graphics, x, y); + this.badgeRects.Add(new RectangleF(x, y, BADGE_SIZE, BADGE_SIZE)); + this.badgeLabels.Add(item.label); + x += BADGE_SIZE + BADGE_GAP; + } + + // Draw inline labels last so they appear on top + if (this.hoverBadgeIndex >= 0 && this.hoverBadgeIndex < this.badgeRects.Count && !this.badgeLabelAutoHidden) + { + var rect = this.badgeRects[this.hoverBadgeIndex]; + var text = this.badgeLabels[this.hoverBadgeIndex]; + InlineLabelRenderer.DrawInlineLabel(graphics, rect, text); + } + } + + // Model resolution moved to owner cache to avoid work during Render. + + /// + /// Draw a simple green check-circle for Verified. + /// + private static void DrawVerifiedBadge(Graphics g, float x, float y) + { + using (var bg = new SolidBrush(Color.FromArgb(32, 152, 72))) // green + using (var pen = new Pen(Color.White, 1.5f)) + { + var rect = new RectangleF(x, y, BADGE_SIZE, BADGE_SIZE); + g.FillEllipse(bg, rect); + + // check mark + var p1 = new PointF(x + BADGE_SIZE * 0.28f, y + BADGE_SIZE * 0.55f); + var p2 = new PointF(x + BADGE_SIZE * 0.45f, y + BADGE_SIZE * 0.72f); + var p3 = new PointF(x + BADGE_SIZE * 0.75f, y + BADGE_SIZE * 0.30f); + g.DrawLines(pen, new[] { p1, p2, p3 }); + } + } + + /// + /// Draw a simple orange warning triangle for Deprecated. + /// + private static void DrawDeprecatedBadge(Graphics g, float x, float y) + { + using (var bg = new SolidBrush(Color.FromArgb(230, 126, 34))) // orange + using (var pen = new Pen(Color.White, 1.5f)) + { + var cx = x + BADGE_SIZE / 2f; + var cy = y + BADGE_SIZE / 2f; + var r = BADGE_SIZE * 0.45f; + + // Triangle points + var p1 = new PointF(cx, cy - r); + var p2 = new PointF(cx - r * 0.866f, cy + r * 0.5f); + var p3 = new PointF(cx + r * 0.866f, cy + r * 0.5f); + + var path = new System.Drawing.Drawing2D.GraphicsPath(); + path.AddPolygon(new[] { p1, p2, p3 }); + g.FillPath(bg, path); + + // Exclamation mark + var lineTop = new PointF(cx, cy - r * 0.2f); + var lineBottom = new PointF(cx, cy + r * 0.3f); + g.DrawLine(pen, lineTop, lineBottom); + g.DrawEllipse(pen, cx - 1.5f, cy + r * 0.4f, 3f, 3f); + } + } + + /// + /// Extension point to allow derived attributes to contribute additional badges. + /// Each badge provides a draw function and the hover label text. + /// + /// Sequence of additional badge descriptors. + protected virtual IEnumerable<(System.Action draw, string label)> GetAdditionalBadges() + { + yield break; + } + + /// + /// Track mouse hover over badges to trigger inline label rendering. + /// + public override Grasshopper.GUI.Canvas.GH_ObjectResponse RespondToMouseMove(GH_Canvas sender, GH_CanvasMouseEvent e) + { + int prevIndex = this.hoverBadgeIndex; + + if (sender?.Viewport.Zoom < MIN_ZOOM_THRESHOLD) + { + this.hoverBadgeIndex = -1; + } + else + { + var pt = e.CanvasLocation; + int newIndex = -1; + for (int i = 0; i < this.badgeRects.Count; i++) + { + if (this.badgeRects[i].Contains(pt)) + { + newIndex = i; + break; + } + } + + this.hoverBadgeIndex = newIndex; + } + + if (prevIndex != this.hoverBadgeIndex) + { + // Start/stop 5s auto-hide timer based on hover transitions + if (this.hoverBadgeIndex >= 0) + { + this.badgeLabelAutoHidden = false; + StartBadgeLabelTimer(); + } + else + { + StopBadgeLabelTimer(); + this.badgeLabelAutoHidden = false; // reset for next hover + } + + this.owner.OnDisplayExpired(false); + } + + return base.RespondToMouseMove(sender, e); + } + + /// + /// Starts a one-shot 5s timer to auto-hide the inline badge label and request a repaint. + /// + private void StartBadgeLabelTimer() + { + StopBadgeLabelTimer(); + this.badgeLabelTimer = new Timer(5000) { AutoReset = false }; + this.badgeLabelTimer.Elapsed += (_, __) => + { + this.badgeLabelAutoHidden = true; + try { this.owner?.OnDisplayExpired(false); } catch { /* ignore */ } + StopBadgeLabelTimer(); + }; + this.badgeLabelTimer.Start(); + } + + /// + /// Stops and disposes the badge label timer if active. + /// + private void StopBadgeLabelTimer() + { + if (this.badgeLabelTimer != null) + { + try { this.badgeLabelTimer.Stop(); } catch { /* ignore */ } + try { this.badgeLabelTimer.Dispose(); } catch { /* ignore */ } + this.badgeLabelTimer = null; + } + } + } +} diff --git a/src/SmartHopper.Core/ComponentBase/InlineLabelRenderer.cs b/src/SmartHopper.Core/ComponentBase/InlineLabelRenderer.cs new file mode 100644 index 00000000..2b9fb213 --- /dev/null +++ b/src/SmartHopper.Core/ComponentBase/InlineLabelRenderer.cs @@ -0,0 +1,58 @@ +/* + * 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. + */ + +/* + * InlineLabelRenderer: Utility to render small inline tooltip-like labels anchored + * to UI rectangles on the Grasshopper canvas. + * Purpose: Centralize inline label drawing for provider icons and badges to avoid + * code duplication and ensure consistent styling. + */ + +using System.Drawing; +using Grasshopper.Kernel; + +namespace SmartHopper.Core.ComponentBase +{ + /// + /// Provides helper methods to draw compact inline labels anchored to a rectangle. + /// + internal static class InlineLabelRenderer + { + /// + /// Draws an inline tooltip-like label above the given anchor rectangle. + /// The label uses GH small font, dark background and light border/text. + /// + /// Target graphics. + /// Anchor rectangle the label should relate to. + /// Text to display inside the label. + public static void DrawInlineLabel(Graphics g, RectangleF anchor, string text) + { + var font = GH_FontServer.Small; + var size = g.MeasureString(text, font); + var padding = 4f; + var width = size.Width + padding * 2f; + var height = size.Height + padding * 1.5f; + var x = anchor.Left + (anchor.Width - width) / 2f; + var y = anchor.Top - height - 4f; // small gap below + + var rect = new RectangleF(x, y, width, height); + using (var bg = new SolidBrush(Color.FromArgb(240, 255, 255, 255))) + using (var pen = new Pen(Color.FromArgb(255, 255, 255, 255), 1f)) + using (var fg = new SolidBrush(Color.Black)) + { + g.FillRectangle(bg, rect); + g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height); + var tx = rect.X + padding; + var ty = rect.Y + (rect.Height - size.Height) / 2f; + g.DrawString(text, font, fg, new PointF(tx, ty)); + } + } + } +} diff --git a/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs b/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs index 7f507cda..26656fe3 100644 --- a/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/SelectingComponentBase.cs @@ -12,6 +12,7 @@ * SelectingComponentBase - base class for components with a "Select Components" button. */ +using System; using System.Collections.Generic; using System.Drawing; using System.Linq; @@ -21,6 +22,7 @@ using Grasshopper.GUI.Canvas; using Grasshopper.Kernel; using Grasshopper.Kernel.Attributes; +using Timer = System.Timers.Timer; namespace SmartHopper.Core.ComponentBase { @@ -108,6 +110,11 @@ public class SelectingComponentAttributes : GH_ComponentAttributes private bool isHovering; private bool isClicking; + // Timer-based auto-hide of the visual highlight for selected objects. + // Purpose: ensure the dashed highlight disappears after 5s even if the cursor stays hovered. + private Timer? selectDisplayTimer; + private bool selectAutoHidden = false; + public SelectingComponentAttributes(SelectingComponentBase owner) : base(owner) { @@ -145,7 +152,7 @@ protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasCha var ty = this.buttonBounds.Y + ((this.buttonBounds.Height - size.Height) / 2); graphics.DrawString(text, font, (this.isHovering || this.isClicking) ? Brushes.Black : Brushes.White, new PointF(tx, ty)); - if (this.isHovering && this.owner.SelectedObjects.Count > 0) + if (this.isHovering && !this.selectAutoHidden && this.owner.SelectedObjects.Count > 0) { using (var pen = new Pen(Color.DodgerBlue, 2f)) { @@ -181,7 +188,20 @@ public override GH_ObjectResponse RespondToMouseMove(GH_Canvas sender, GH_Canvas this.isHovering = this.buttonBounds.Contains((int)e.CanvasLocation.X, (int)e.CanvasLocation.Y); if (was != this.isHovering) { - this.owner.ExpireSolution(true); + // Start/stop 5s auto-hide timer based on hover transitions + if (this.isHovering) + { + this.selectAutoHidden = false; + StartSelectDisplayTimer(); + } + else + { + StopSelectDisplayTimer(); + this.selectAutoHidden = false; // reset for next hover + } + + // Use display invalidation for hover-only visual changes + this.owner.OnDisplayExpired(false); } return base.RespondToMouseMove(sender, e); @@ -197,5 +217,34 @@ public override GH_ObjectResponse RespondToMouseUp(GH_Canvas sender, GH_CanvasMo return base.RespondToMouseUp(sender, e); } + + /// + /// Starts a one-shot 5s timer to auto-hide the selection highlight and request a repaint. + /// + private void StartSelectDisplayTimer() + { + StopSelectDisplayTimer(); + this.selectDisplayTimer = new Timer(5000) { AutoReset = false }; + this.selectDisplayTimer.Elapsed += (_, __) => + { + this.selectAutoHidden = true; + try { this.owner?.OnDisplayExpired(false); } catch { /* ignore */ } + StopSelectDisplayTimer(); + }; + this.selectDisplayTimer.Start(); + } + + /// + /// Stops and disposes the selection display timer if active. + /// + private void StopSelectDisplayTimer() + { + if (this.selectDisplayTimer != null) + { + try { this.selectDisplayTimer.Stop(); } catch { /* ignore */ } + try { this.selectDisplayTimer.Dispose(); } catch { /* ignore */ } + this.selectDisplayTimer = null; + } + } } }