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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.1-alpha] - 2025-07-30

### Added

- Settings parameter to enable/disable AI generated greeting in chat.

### Fixed

- Greeting generation was using stored settings models instead of the provider's default model. To solve it, now if `AIUtils.GetResponse` doesn't get a model, it will use the provider's default model.
- Components triggered with a Boolean Toggle (permanent true value) weren't calculating when the toggle was turned to true.
- Lazy default values in `AI Provider Settings` to prevent race conditions at initialization.
- Fixed "List length in list_generate was not met for long requests" ([#277](https://github.com/architects-toolkit/SmartHopper/issues/277)

## [0.5.0-alpha] - 2025-07-29

### Added
Expand Down
3 changes: 2 additions & 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%2E5%2E0--alpha-orange)](https://github.com/architects-toolkit/SmartHopper/releases)
[![Version](https://img.shields.io/badge/version-0%2E5%2E1--alpha-orange)](https://github.com/architects-toolkit/SmartHopper/releases)
[![Status](https://img.shields.io/badge/status-Alpha-orange)](https://github.com/architects-toolkit/SmartHopper/releases)
[![Test results](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=.NET%20CI&logo=dotnet)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml)
[![Grasshopper](https://img.shields.io/badge/plugin_for-Grasshopper3D-darkgreen?logo=rhinoceros)](https://www.rhino3d.com/)
Expand Down Expand Up @@ -81,6 +81,7 @@ After installation, all SmartHopper components will be available in the Grasshop
| AI GroupTitle (AiGroupTitle)<br><sub>Group components and set a meaningful title to the group</sub> | ⚪ | - | - | - |
| AI File Context (AiFileContext)<br><sub>Set a context for the current document</sub> | ⚪ | 🟡 | 🟠 | 🟢 |
| AI Models (AiModels)<br><sub>Retrieve the list of available models for a specific provider</sub> | ⚪ | 🟡 | 🟠 | 🟢 |
| Image Viewer (ImageViewer)<br><sub>Display bitmap images on the canvas and save them to disk</sub> | ⚪ | 🟡 | 🟠 | 🟢 |
| JSON schema (JsonSchema)<br><sub>Set a JSON schema for the AI component</sub> | ⚪ | - | - | - |
| JSON object (JsonObject)<br><sub>Set a JSON object for the definition of the JSON Schema</sub> | ⚪ | - | - | - |
| JSON array (JsonArray)<br><sub>Set a JSON array for the definition of the JSON Schema</sub> | ⚪ | - | - | - |
Expand Down
2 changes: 1 addition & 1 deletion Solution.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<SolutionVersion>0.5.0-alpha</SolutionVersion>
<SolutionVersion>0.5.1-alpha</SolutionVersion>
</PropertyGroup>
</Project>
125 changes: 104 additions & 21 deletions src/SmartHopper.Core.Grasshopper/AITools/list_generate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Grasshopper.Kernel;
using Grasshopper.Kernel.Types;
Expand All @@ -35,6 +36,7 @@ public class list_generate : IAIToolProvider
/// <summary>
/// Get all tools provided by this class.
/// </summary>
/// <returns>An enumerable collection of AI tools provided by this class.</returns>
public IEnumerable<AITool> GetTools()
{
yield return new AITool(
Expand All @@ -51,12 +53,12 @@ public IEnumerable<AITool> GetTools()
""required"": [""prompt"", ""count"", ""type""]
}",
execute: this.GenerateListToolWrapper,
requiredCapabilities: AIModelCapability.TextInput | AIModelCapability.StructuredOutput
);
requiredCapabilities: AIModelCapability.TextInput | AIModelCapability.StructuredOutput);
}

/// <summary>
/// Generates a list of text items using AI, returning a JSON array of strings.
/// Uses conversational approach to ensure the target count is met.
/// </summary>
private static async Task<AIEvaluationResult<List<string>>> GenerateTextListAsync(
GH_String prompt,
Expand All @@ -65,31 +67,113 @@ private static async Task<AIEvaluationResult<List<string>>> GenerateTextListAsyn
{
try
{
var allItems = new List<string>();
var messages = new List<KeyValuePair<string, string>>
{
new("system", $"You are a list generator assistant. Generate {count} items of text based on the prompt and return ONLY the JSON array. Include no extra text or formatting. Do not wrap the output in quotes or in a code block.\n\nIMPORTANT: Each item must be a quoted string in the JSON array, even if it contains commas or special characters.\n\nOUTPUT EXAMPLES: ['item1', 'item2', 'item3'] or ['{{1,0,0}}', '{{0.707,0.707,0}}', '{{0,1,0}}']"),
new("user", prompt.Value)
new("user", prompt.Value),
};

// Call AI to generate list
var response = await getResponse(messages).ConfigureAwait(false);
const int maxIterations = 10; // Prevent infinite loops
int iteration = 0;
AIResponse? lastResponse = null;

if (response.FinishReason == "error")
while (allItems.Count < count && iteration < maxIterations)
{
iteration++;
Debug.WriteLine($"[ListTools] Iteration {iteration}: Need {count - allItems.Count} more items (have {allItems.Count}/{count})");

// Call AI to generate list
var response = await getResponse(messages).ConfigureAwait(false);
lastResponse = response;

if (response.FinishReason == "error")
{
return AIEvaluationResult<List<string>>.CreateError(
response.Response,
GH_RuntimeMessageLevel.Error,
response);
}

// Strip thinking tags from response before parsing
var cleanedResponse = AI.StripThinkTags(response.Response);

// Parse JSON array of strings
List<string> newItems;
try
{
newItems = ParsingTools.ParseStringArrayFromResponse(cleanedResponse);
}
catch (Exception parseEx)
{
Debug.WriteLine($"[ListTools] Error parsing response in iteration {iteration}: {parseEx.Message}");
Debug.WriteLine($"[ListTools] Raw response: {cleanedResponse}");

// If we have some items already, return what we have
if (allItems.Count > 0)
{
Debug.WriteLine($"[ListTools] Returning partial list with {allItems.Count} items due to parsing error");
return AIEvaluationResult<List<string>>.CreateSuccess(lastResponse, allItems);
}

// Otherwise, return the error
return AIEvaluationResult<List<string>>.CreateError(
$"Error parsing AI response: {parseEx.Message}",
GH_RuntimeMessageLevel.Error,
response);
}

Debug.WriteLine($"[ListTools] Iteration {iteration} generated {newItems.Count} items: {string.Join(", ", newItems)}");

// Add new items to our collection
foreach (var item in newItems)
{
allItems.Add(item);
}

// If we have enough items, trim to exact count and break
if (allItems.Count >= count)
{
if (allItems.Count > count)
{
allItems = allItems.GetRange(0, count);
}

Debug.WriteLine($"[ListTools] Target count {count} reached after {iteration} iterations");
break;
}

// Add the AI's response to conversation history
messages.Add(new("assistant", cleanedResponse));

// Calculate how many more items we need
int stillNeeded = count - allItems.Count;

// Create follow-up message with context
var followUpMessage = $"I need {stillNeeded} more items to complete the list. Please generate {stillNeeded} additional items that are different from the ones already provided. Current list has {allItems.Count} items: [{string.Join(", ", allItems.Select(item => $"'{item}'"))}].\n\nGenerate {stillNeeded} NEW items as a JSON array, meeting the initial user's request: {prompt}.";

messages.Add(new("user", followUpMessage));

Debug.WriteLine($"[ListTools] Requesting {stillNeeded} more items in next iteration");
}

if (allItems.Count == 0)
{
return AIEvaluationResult<List<string>>.CreateError(
response.Response,
"AI failed to generate any valid items",
GH_RuntimeMessageLevel.Error,
response);
lastResponse);
}

// Strip thinking tags from response before parsing
var cleanedResponse = AI.StripThinkTags(response.Response);

// Parse JSON array of strings
var items = ParsingTools.ParseStringArrayFromResponse(cleanedResponse);
Debug.WriteLine($"[ListTools] Generated items: {string.Join(", ", items)}");
// Final safety check: trim list if it's longer than requested
if (allItems.Count > count)
{
Debug.WriteLine($"[ListTools] Trimming final list from {allItems.Count} to {count} items");
allItems = allItems.GetRange(0, count);
}

return AIEvaluationResult<List<string>>.CreateSuccess(response, items);
Debug.WriteLine($"[ListTools] Final result: {allItems.Count} items generated: {string.Join(", ", allItems)}");
return AIEvaluationResult<List<string>>.CreateSuccess(lastResponse, allItems);
}
catch (Exception ex)
{
Expand Down Expand Up @@ -124,7 +208,7 @@ private async Task<object> GenerateListToolWrapper(JObject parameters)
return new JObject
{
["success"] = false,
["error"] = "Missing or invalid parameters: prompt, count, or type"
["error"] = "Missing or invalid parameters: prompt, count, or type",
};
}

Expand All @@ -133,7 +217,7 @@ private async Task<object> GenerateListToolWrapper(JObject parameters)
return new JObject
{
["success"] = false,
["error"] = $"Type '{type}' not supported"
["error"] = $"Type '{type}' not supported",
};
}

Expand All @@ -147,8 +231,7 @@ private async Task<object> GenerateListToolWrapper(JObject parameters)
jsonSchema: ListJsonSchema,
endpoint: endpoint,
contextProviderFilter: contextProviderFilter,
contextKeyFilter: contextKeyFilter)
).ConfigureAwait(false);
contextKeyFilter: contextKeyFilter)).ConfigureAwait(false);

// Build standardized result
return new JObject
Expand All @@ -157,7 +240,7 @@ private async Task<object> GenerateListToolWrapper(JObject parameters)
["list"] = result.Success ? JArray.FromObject(result.Result) : JValue.CreateNull(),
["count"] = result.Success ? new JValue(result.Result.Count) : new JValue(0),
["error"] = result.Success ? JValue.CreateNull() : new JValue(result.ErrorMessage),
["rawResponse"] = JToken.FromObject(result.Response)
["rawResponse"] = JToken.FromObject(result.Response),
};
}
catch (Exception ex)
Expand All @@ -166,7 +249,7 @@ private async Task<object> GenerateListToolWrapper(JObject parameters)
return new JObject
{
["success"] = false,
["error"] = $"Error: {ex.Message}"
["error"] = $"Error: {ex.Message}",
};
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ protected override void SolveInstance(IGH_DataAccess DA)
this.TransitionTo(ComponentState.NeedsRun, DA);
return;
}

this.lastDA = DA;

// Store Run parameter
Expand Down Expand Up @@ -253,18 +253,18 @@ protected override void SolveInstance(IGH_DataAccess DA)
// 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 Run parameter changed to true, restarting debounce timer with target state Waiting");
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)
if (this.RunOnlyOnInputChanges) // RunOnlyOnInputChanges default is true
{
// 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);
// Default behavior - transition to Waiting state
this.TransitionTo(ComponentState.Waiting, DA);
}
else
{
// Default behavior - transition to Waiting state
this.TransitionTo(ComponentState.Waiting, DA);
// 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);
}
}

Expand Down Expand Up @@ -378,6 +378,23 @@ private async Task ProcessTransition(ComponentState newState, IGH_DataAccess? DA
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();
Expand Down
Loading
Loading