Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
# Critical repository files
/.gitignore @marc-romu
/.gitattributes @marc-romu
/.windsurfrules @marc-romu

# Critical solution files
/*.sln @marc-romu
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/block-dev-release-to-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Block Dev Release to Main

on:
pull_request:
branches: [ main ]
paths:
- 'Solution.props'

permissions:
contents: read
pull-requests: read

jobs:
check-dev-release:
name: 🚫 Block Dev Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4
with:
ref: ${{ github.event.pull_request.head.sha }}

- name: Check for Dev Release
id: check-dev
run: |
VERSION=$(grep -oP '(?<=<SolutionVersion>)[^<]+' Solution.props)
echo "Version found: $VERSION"

if [[ $VERSION == *"-dev"* ]]; then
echo "::error::Development release versions (-dev) cannot be merged into main branch"
echo "IS_DEV_RELEASE=true" >> $GITHUB_ENV
exit 1
else
echo "IS_DEV_RELEASE=false" >> $GITHUB_ENV
fi
3 changes: 2 additions & 1 deletion .github/workflows/pull-request-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
run: |
VERSION=$(grep -oP '(?<=<SolutionVersion>)[^<]+' Solution.props)
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[0-9]+)?)?$ ]]; then
echo "Invalid version format in Solution.props. Must follow semantic versioning."
echo "Invalid version format in Solution.props ($VERSION). Must follow semantic versioning."
exit 1
fi

Expand Down Expand Up @@ -60,6 +60,7 @@ jobs:
PR_TITLE="${{ github.event.pull_request.title }}"
if [[ ! $PR_TITLE =~ ^(feat|fix|docs|style|refactor|test|chore)(\([a-z]+\))?:.+ ]]; then
echo "Error: Pull request title must follow conventional commits format"
echo "Current title: $PR_TITLE"
echo "Example formats:"
echo " feat: add new feature"
echo " fix(component): resolve specific issue"
Expand Down
3 changes: 2 additions & 1 deletion .windsurfrules
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
- All components should inherit from ComponentBase or a derived class.
- Components should be named with the pattern [Category][Action][Type]Component (e.g., AITextGenerateComponent).
- Consider using AIStatefulAsyncComponentBase since it already implements most of the required structure to manage AI methods, states and async operations.
8. When asked for a PR title or description, follow the rules in @.github/PULL_REQUEST_TEMPLATE.md

When the user asks to add, change, deprecate, remove, fix, and/or security edit changes in the code, execute also the following tasks:
When the user asks to add, change, deprecate, remove, fix, or security edit changes in the code, execute also the following tasks:
1. Only on the first edit, check if the version defined in @Solution.props is a dev version in format X.X.X-dev.YYMMDD. If so, update the date to today.
2. If relevant, mention the edit in the @CHANGELOG.md file under the "Unreleased" section. Do not modify other parts of the file.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added ChatDialog class using Eto.Forms for a modern chat UI experience.
- Added ChatUtils class with helper methods for AI chat interactions.
- Added RunOnlyOnInputChanges property to StatefulAsyncComponentBase to control component execution behavior.
- Added "Default" option in the AI provider selection menu to use the provider specified in SmartHopper settings.
- Added default provider selection in the settings dialog to set the global default AI provider.

### Changed

- Modified AIChatComponent to always run when the Run parameter is true, regardless of input changes.
- Improved ChatDialog UI with a modern chat-like interface featuring message bubbles, better layout, and visual styling.
- Enhanced message bubbles to properly wrap text and resize dynamically with the window size.
- Added a "Supported Data Types" section to README.md documenting currently supported and planned Grasshopper-native types.
- Changed AI components to use the default provider from SmartHopper settings when "Default" is selected.
- Updated component icon display to show the actual provider icon when "Default" is selected.

## [0.1.1-alpha] - 2025-03-03

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# SmartHopper - AI-Powered Grasshopper3D Plugin

[![Version](https://img.shields.io/badge/version-0%2E1%2E2--dev%2E250303-yellow)](https://github.com/architects-toolkit/SmartHopper/releases)
[![Version](https://img.shields.io/badge/version-0%2E1%2E2--dev%2E250308-yellow)](https://github.com/architects-toolkit/SmartHopper/releases)
[![Status](https://img.shields.io/badge/status-Unstable%20development-yellow)](https://github.com/architects-toolkit/SmartHopper/releases)
[![Grasshopper](https://img.shields.io/badge/plugin_for-Grasshopper3D-darkgreen?logo=rhinoceros)](https://www.rhino3d.com/)
[![MistralAI](https://img.shields.io/badge/AI--powered-MistralAI-orange)](https://mistral.ai/)
Expand Down
29 changes: 28 additions & 1 deletion src/SmartHopper.Config/Configuration/SmartHopperSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,18 @@ public class SmartHopperSettings

public Dictionary<string, Dictionary<string, object>> ProviderSettings { get; set; }
public int DebounceTime { get; set; }

/// <summary>
/// The default AI provider to use when not explicitly specified in components.
/// If not set or the provider doesn't exist, the first available provider will be used.
/// </summary>
public string DefaultAIProvider { get; set; }

public SmartHopperSettings()
{
ProviderSettings = new Dictionary<string, Dictionary<string, object>>();
DebounceTime = 1000;
DefaultAIProvider = string.Empty;
}

// Use a constant key and IV for encryption (these could be moved to secure configuration)
Expand Down Expand Up @@ -208,7 +215,8 @@ public void Save()
var settingsToSave = new SmartHopperSettings
{
ProviderSettings = EncryptSensitiveSettings(ProviderSettings),
DebounceTime = DebounceTime
DebounceTime = DebounceTime,
DefaultAIProvider = DefaultAIProvider
};

var json = JsonConvert.SerializeObject(settingsToSave, Formatting.Indented);
Expand Down Expand Up @@ -240,6 +248,25 @@ public static IEnumerable<IAIProvider> DiscoverProviders()
}
}

/// <summary>
/// Gets the default AI provider from settings, or the first available provider if not set.
/// </summary>
/// <returns>The default AI provider name</returns>
public string GetDefaultAIProvider()
{
var providers = DiscoverProviders().ToList();

// If the DefaultAIProvider is set and exists in the available providers, use it
if (!string.IsNullOrEmpty(DefaultAIProvider) &&
providers.Any(p => p.Name == DefaultAIProvider))
{
return DefaultAIProvider;
}

// Otherwise, return the first available provider or empty string if none
return providers.Any() ? providers.First().Name : string.Empty;
}

/// <summary>
/// Gets the icon for the specified AI provider
/// </summary>
Expand Down
10 changes: 9 additions & 1 deletion src/SmartHopper.Core/ComponentBase/AIComponentAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,16 @@ protected override void Render(GH_Canvas canvas, Graphics graphics, GH_CanvasCha
if (string.IsNullOrEmpty(_owner._aiProvider) || canvas.Viewport.Zoom < MIN_ZOOM_THRESHOLD)
return;

// Get the actual provider name (resolving Default to the actual provider)
string actualProviderName = _owner._aiProvider;
if (_owner._aiProvider == AIStatefulAsyncComponentBase.DEFAULT_PROVIDER)
{
var settings = SmartHopperSettings.Load();
actualProviderName = settings.GetDefaultAIProvider();
}

// Get the provider icon
var providerIcon = SmartHopperSettings.GetProviderIcon(_owner._aiProvider);
var providerIcon = SmartHopperSettings.GetProviderIcon(actualProviderName);
if (providerIcon == null)
return;

Expand Down
135 changes: 131 additions & 4 deletions src/SmartHopper.Core/ComponentBase/AIStatefulAsyncComponentBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ namespace SmartHopper.Core.ComponentBase
/// </summary>
public abstract class AIStatefulAsyncComponentBase : StatefulAsyncComponentBase
{
/// <summary>
/// Special value used to indicate that the default provider from settings should be used.
/// </summary>
public const string DEFAULT_PROVIDER = "Default";

/// <summary>
/// The model to use for AI processing. Set up from the component's inputs.
/// </summary>
Expand Down Expand Up @@ -65,7 +70,8 @@ protected AIStatefulAsyncComponentBase(
string subCategory)
: base(name, nickname, description, category, subCategory)
{
_aiProvider = MistralAI._name; // Default to MistralAI
// Set the default provider option
_aiProvider = DEFAULT_PROVIDER;
}

#region PARAMS
Expand Down Expand Up @@ -133,6 +139,33 @@ protected override void AppendAdditionalComponentMenuItems(ToolStripDropDown men
var providersMenu = new ToolStripMenuItem("Select AI Provider");
menu.Items.Add(providersMenu);

// Add the Default option first
var defaultItem = new ToolStripMenuItem(DEFAULT_PROVIDER)
{
Checked = _aiProvider == DEFAULT_PROVIDER,
CheckOnClick = true,
Tag = DEFAULT_PROVIDER
};

defaultItem.Click += (s, e) =>
{
var menuItem = s as ToolStripMenuItem;
if (menuItem != null)
{
// Uncheck all other items
foreach (ToolStripMenuItem otherItem in providersMenu.DropDownItems)
{
if (otherItem != menuItem)
otherItem.Checked = false;
}

_aiProvider = DEFAULT_PROVIDER;
ExpireSolution(true);
}
};

providersMenu.DropDownItems.Add(defaultItem);

// Get all available providers
var providers = SmartHopperSettings.DiscoverProviders();
foreach (var provider in providers)
Expand Down Expand Up @@ -219,7 +252,9 @@ protected async Task<AIResponse> GetResponse(List<KeyValuePair<string, string>>
{
try
{
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [GetResponse] Using Provider: {_aiProvider}");
// Get the actual provider name to use
string actualProvider = GetActualProviderName();
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [GetResponse] Using Provider: {actualProvider} (Selected: {_aiProvider})");

Debug.WriteLine("[AIStatefulAsyncComponentBase] Number of messages: " + messages.Count);

Expand All @@ -229,7 +264,7 @@ protected async Task<AIResponse> GetResponse(List<KeyValuePair<string, string>>
}

var response = await AIUtils.GetResponse(
_aiProvider,
actualProvider,
model: GetModel(),
messages,
endpoint: GetEndpoint());
Expand Down Expand Up @@ -312,6 +347,9 @@ protected void SetMetricsOutput(IGH_DataAccess DA, int initialBranches = 0)
return;
}

// Get the actual provider name
string actualProvider = GetActualProviderName();

// Aggregate metrics
int totalInTokens = _responseMetrics.Sum(r => r.InTokens);
int totalOutTokens = _responseMetrics.Sum(r => r.OutTokens);
Expand All @@ -323,7 +361,7 @@ protected void SetMetricsOutput(IGH_DataAccess DA, int initialBranches = 0)

// Create JSON object with metrics
var metricsJson = new JObject(
new JProperty("ai_provider", _aiProvider),
new JProperty("ai_provider", actualProvider),
new JProperty("ai_model", usedModels),
new JProperty("tokens_input", totalInTokens),
new JProperty("tokens_output", totalOutTokens),
Expand Down Expand Up @@ -398,5 +436,94 @@ protected static GH_Structure<GH_String> ConvertToGHString(GH_Structure<IGH_Goo>
}

#endregion

#region PERSISTENCE

/// <summary>
/// Writes the component's persistent data to the Grasshopper file.
/// </summary>
/// <param name="writer">The writer to use for serialization</param>
/// <returns>True if the write operation succeeds, false if it fails or an exception occurs</returns>
public override bool Write(GH_IO.Serialization.GH_IWriter writer)
{
if (!base.Write(writer))
return false;

try
{
// Store the selected AI provider
writer.SetString("AIProvider", _aiProvider);
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [Write] Stored AI provider: {_aiProvider}");

return true;
}
catch (Exception ex)
{
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [Write] Exception: {ex.Message}");
return false;
}
}

/// <summary>
/// Reads the component's persistent data from the Grasshopper file.
/// </summary>
/// <param name="reader">The reader to use for deserialization</param>
/// <returns>True if the read operation succeeds, false if it fails, required data is missing, or an exception occurs</returns>
public override bool Read(GH_IO.Serialization.GH_IReader reader)
{
if (!base.Read(reader))
return false;

try
{
// Read the stored AI provider if available
if (reader.ItemExists("AIProvider"))
{
string storedProvider = reader.GetString("AIProvider");
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [Read] Read stored AI provider: {storedProvider}");

// Check if the provider exists in the available providers
var providers = SmartHopperSettings.DiscoverProviders();
if (providers.Any(p => p.Name == storedProvider))
{
_aiProvider = storedProvider;
_previousSelectedProvider = storedProvider;
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [Read] Restored AI provider: {_aiProvider}");
}
else
{
// If the provider doesn't exist, use the first available provider
_aiProvider = providers.Any() ? providers.First().Name : MistralAI._name;
_previousSelectedProvider = _aiProvider;
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [Read] Provider not found, using default: {_aiProvider}");
}
}

return true;
}
catch (Exception ex)
{
Debug.WriteLine($"[AIStatefulAsyncComponentBase] [Read] Exception: {ex.Message}");
return false;
}
}

#endregion

/// <summary>
/// Gets the actual provider name to use for AI processing.
/// If the selected provider is "Default", returns the default provider from settings.
/// </summary>
/// <returns>The actual provider name to use</returns>
protected string GetActualProviderName()
{
if (_aiProvider == DEFAULT_PROVIDER)
{
var settings = SmartHopperSettings.Load();
return settings.GetDefaultAIProvider();
}

return _aiProvider;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,7 @@ protected virtual void RestorePersistentOutputs(IGH_DataAccess DA)
/// </summary>
/// <param name="writer">The writer to use for serialization</param>
/// <returns>True if the write operation succeeds, false if it fails or an exception occurs</returns>
public sealed override bool Write(GH_IWriter writer)
public override bool Write(GH_IWriter writer)
{
if (!base.Write(writer))
return false;
Expand Down Expand Up @@ -782,7 +782,7 @@ public sealed override bool Write(GH_IWriter writer)
/// </summary>
/// <param name="reader">The reader to use for deserialization</param>
/// <returns>True if the read operation succeeds, false if it fails, required data is missing, or an exception occurs</returns>
public sealed override bool Read(GH_IReader reader)
public override bool Read(GH_IReader reader)
{
if (!base.Read(reader))
return false;
Expand Down
Loading