From f869ae51edefea53b8aac1c8327246fadb67dd04 Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Mon, 30 Mar 2026 17:14:04 -0700 Subject: [PATCH 01/10] feat(nodes): add v0 by Vercel tool node for AI-powered UI generation Adds a new tool_v0 pipeline node that integrates with Vercel's v0 API to generate React + Tailwind CSS components from natural-language prompts. Exposes generate_ui and refine_ui as agent-invocable tools. Co-Authored-By: Claude Opus 4.6 (1M context) --- nodes/src/nodes/tool_v0/IGlobal.py | 73 ++++++ nodes/src/nodes/tool_v0/IInstance.py | 48 ++++ nodes/src/nodes/tool_v0/__init__.py | 35 +++ nodes/src/nodes/tool_v0/requirements.txt | 1 + nodes/src/nodes/tool_v0/services.json | 48 ++++ nodes/src/nodes/tool_v0/v0_driver.py | 282 +++++++++++++++++++++++ 6 files changed, 487 insertions(+) create mode 100644 nodes/src/nodes/tool_v0/IGlobal.py create mode 100644 nodes/src/nodes/tool_v0/IInstance.py create mode 100644 nodes/src/nodes/tool_v0/__init__.py create mode 100644 nodes/src/nodes/tool_v0/requirements.txt create mode 100644 nodes/src/nodes/tool_v0/services.json create mode 100644 nodes/src/nodes/tool_v0/v0_driver.py diff --git a/nodes/src/nodes/tool_v0/IGlobal.py b/nodes/src/nodes/tool_v0/IGlobal.py new file mode 100644 index 000000000..d6bb0acea --- /dev/null +++ b/nodes/src/nodes/tool_v0/IGlobal.py @@ -0,0 +1,73 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +v0 by Vercel tool node - global (shared) state. + +Reads the v0 API key from config and creates a V0Driver that implements the +ToolsBase interface for generating React UI components from text prompts. +""" + +from __future__ import annotations + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, warning + +from .v0_driver import V0Driver + + +class IGlobal(IGlobalBase): + """Global state for tool_v0.""" + + driver: V0Driver | None = None + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + + apikey = str((cfg.get('apikey') or '')).strip() + + if not apikey: + raise Exception('tool_v0: apikey is required') + + try: + self.driver = V0Driver(server_name='v0', apikey=apikey) + except Exception as e: + warning(str(e)) + raise + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + apikey = str((cfg.get('apikey') or '')).strip() + if not apikey: + warning('apikey is required') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + self.driver = None diff --git a/nodes/src/nodes/tool_v0/IInstance.py b/nodes/src/nodes/tool_v0/IInstance.py new file mode 100644 index 000000000..96a1e9985 --- /dev/null +++ b/nodes/src/nodes/tool_v0/IInstance.py @@ -0,0 +1,48 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +v0 by Vercel tool node instance. + +Delegates tool invoke operations to the V0Driver. +""" + +from __future__ import annotations + +from typing import Any + +from rocketlib import IInstanceBase + +from .IGlobal import IGlobal + + +class IInstance(IInstanceBase): + IGlobal: IGlobal + + def invoke(self, param: Any) -> Any: # noqa: ANN401 + driver = getattr(self.IGlobal, 'driver', None) + if driver is None: + raise RuntimeError('tool_v0: driver not initialized') + return driver.handle_invoke(param) diff --git a/nodes/src/nodes/tool_v0/__init__.py b/nodes/src/nodes/tool_v0/__init__.py new file mode 100644 index 000000000..d678f5ae5 --- /dev/null +++ b/nodes/src/nodes/tool_v0/__init__.py @@ -0,0 +1,35 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +from os.path import dirname, join, realpath +from depends import depends + +requirements = join(dirname(realpath(__file__)), 'requirements.txt') +depends(requirements) + +from .IGlobal import IGlobal +from .IInstance import IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_v0/requirements.txt b/nodes/src/nodes/tool_v0/requirements.txt new file mode 100644 index 000000000..f7621d172 --- /dev/null +++ b/nodes/src/nodes/tool_v0/requirements.txt @@ -0,0 +1 @@ +httpx diff --git a/nodes/src/nodes/tool_v0/services.json b/nodes/src/nodes/tool_v0/services.json new file mode 100644 index 000000000..6565839a9 --- /dev/null +++ b/nodes/src/nodes/tool_v0/services.json @@ -0,0 +1,48 @@ +{ + "title": "v0 by Vercel", + "protocol": "tool_v0://", + "classType": ["tool"], + "capabilities": ["invoke"], + "register": "filter", + "node": "python", + "path": "nodes.tool_v0", + "prefix": "v0", + "icon": "vercel.svg", + "description": [ + "Generates React UI components from natural-language prompts using Vercel's v0 API.", + "Provides generate_ui (create component) and refine_ui (iterate on existing generation)." + ], + "tile": ["Tool: v0.generate_ui"], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "v0 by Vercel", + "apikey": "" + } + } + }, + "fields": { + "tool_v0.apikey": { + "type": "string", + "title": "API Key", + "description": "Vercel v0 API key", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + } + }, + "shape": [ + { + "section": "Pipe", + "title": "v0 by Vercel", + "properties": [ + "type", + "tool_v0.apikey" + ] + } + ] +} diff --git a/nodes/src/nodes/tool_v0/v0_driver.py b/nodes/src/nodes/tool_v0/v0_driver.py new file mode 100644 index 000000000..2861db31c --- /dev/null +++ b/nodes/src/nodes/tool_v0/v0_driver.py @@ -0,0 +1,282 @@ +""" +v0 by Vercel tool-provider driver. + +Implements ``tool.query``, ``tool.validate``, and ``tool.invoke`` for +generating React UI components via Vercel's v0 generative UI API. + +v0 accepts natural-language prompts describing a desired UI and returns +production-ready React/Tailwind code. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import httpx + +from rocketlib import warning + +from ai.common.tools import ToolsBase + +# --------------------------------------------------------------------------- +# v0 API configuration +# --------------------------------------------------------------------------- + +V0_API_BASE = 'https://api.v0.dev/v1' +V0_GENERATE_ENDPOINT = f'{V0_API_BASE}/chat' +V0_REQUEST_TIMEOUT = 120 # seconds — generation can take a while + +# --------------------------------------------------------------------------- +# Static tool definitions +# --------------------------------------------------------------------------- + +GENERATE_UI_TOOL: Dict[str, Any] = { + 'name': 'generate_ui', + 'description': ('Generate a React UI component from a natural-language description. Provide a detailed prompt describing the desired UI and receive production-ready React + Tailwind CSS code.'), + 'inputSchema': { + 'type': 'object', + 'properties': { + 'prompt': { + 'type': 'string', + 'description': 'A natural-language description of the UI component to generate.', + }, + 'model': { + 'type': 'string', + 'description': 'The v0 model to use (default: "v0-1.0-md").', + 'default': 'v0-1.0-md', + }, + }, + 'required': ['prompt'], + }, + 'outputSchema': { + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'code': {'type': 'string', 'description': 'Generated React component code.'}, + 'message_id': {'type': 'string', 'description': 'v0 message ID for follow-up refinements.'}, + }, + }, +} + +REFINE_UI_TOOL: Dict[str, Any] = { + 'name': 'refine_ui', + 'description': ('Refine a previously generated UI component by providing follow-up instructions. Requires the message_id from a prior generate_ui call.'), + 'inputSchema': { + 'type': 'object', + 'properties': { + 'prompt': { + 'type': 'string', + 'description': 'Follow-up instructions describing how to change the component.', + }, + 'message_id': { + 'type': 'string', + 'description': 'The message_id returned from a previous generate_ui or refine_ui call.', + }, + 'model': { + 'type': 'string', + 'description': 'The v0 model to use (default: "v0-1.0-md").', + 'default': 'v0-1.0-md', + }, + }, + 'required': ['prompt', 'message_id'], + }, + 'outputSchema': { + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'code': {'type': 'string', 'description': 'Refined React component code.'}, + 'message_id': {'type': 'string', 'description': 'Updated message ID for further refinements.'}, + }, + }, +} + +_TOOLS_BY_BARE_NAME: Dict[str, Dict[str, Any]] = { + 'generate_ui': GENERATE_UI_TOOL, + 'refine_ui': REFINE_UI_TOOL, +} + + +class V0Driver(ToolsBase): + """Tool provider for Vercel v0 UI generation.""" + + def __init__(self, *, server_name: str, apikey: str) -> None: # noqa: D107 + self._server_name = (server_name or '').strip() or 'v0' + self._apikey = apikey + + def _bare_name(self, tool_name: str) -> str: + """Strip server prefix, accepting both bare and namespaced tool names.""" + prefix = f'{self._server_name}.' + return tool_name[len(prefix) :] if tool_name.startswith(prefix) else tool_name + + # ------------------------------------------------------------------ + # ToolsBase hooks + # ------------------------------------------------------------------ + + def _tool_query(self) -> List[ToolsBase.ToolDescriptor]: + return [{**tool, 'name': f'{self._server_name}.{tool["name"]}'} for tool in _TOOLS_BY_BARE_NAME.values()] + + def _tool_validate(self, *, tool_name: str, input_obj: Any) -> None: # noqa: ANN401 + tool = _TOOLS_BY_BARE_NAME.get(self._bare_name(tool_name)) + if tool is None: + raise ValueError(f'Unknown tool {tool_name}') + + schema = tool.get('inputSchema') or {} + required = schema.get('required', []) + if not required: + return + if not isinstance(input_obj, dict): + raise ValueError(f'Tool input must be an object; required fields={required}') + missing = [k for k in required if k not in input_obj] + if missing: + raise ValueError(f'Tool input missing required fields: {missing}') + + def _tool_invoke(self, *, tool_name: str, input_obj: Any) -> Any: # noqa: ANN401 + args = _normalize_tool_input(input_obj) + bare = self._bare_name(tool_name) + + if bare == 'generate_ui': + return self._invoke_generate(args) + elif bare == 'refine_ui': + return self._invoke_refine(args) + else: + raise ValueError(f'Unknown tool {tool_name}') + + # ------------------------------------------------------------------ + # Tool implementations + # ------------------------------------------------------------------ + + def _build_headers(self) -> Dict[str, str]: + return { + 'Authorization': f'Bearer {self._apikey}', + 'Content-Type': 'application/json', + } + + def _call_v0_api(self, messages: List[Dict[str, str]], model: str, **extra: Any) -> Dict[str, Any]: + """Send a chat-style request to the v0 API and return the parsed response.""" + payload = { + 'model': model, + 'messages': messages, + 'stream': False, + **extra, + } + + try: + with httpx.Client(timeout=V0_REQUEST_TIMEOUT) as client: + resp = client.post( + V0_GENERATE_ENDPOINT, + headers=self._build_headers(), + json=payload, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as e: + warning(f'v0 API error: {e.response.status_code} - {e.response.text}') + raise + except Exception as e: + warning(f'v0 API request failed: {e}') + raise + + @staticmethod + def _extract_code(response: Dict[str, Any]) -> tuple[str, str]: + """Extract generated code and message ID from the v0 API response.""" + message_id = '' + code = '' + + # v0 API returns a chat-completions-style response + choices = response.get('choices') or [] + if choices: + message = choices[0].get('message') or {} + code = message.get('content') or '' + message_id = response.get('id') or '' + + return code, message_id + + def _invoke_generate(self, args: Dict[str, Any]) -> Dict[str, Any]: + prompt = args.get('prompt') + if not prompt: + raise ValueError('generate_ui requires a `prompt` parameter') + + model = args.get('model') or 'v0-1.0-md' + + messages = [ + {'role': 'user', 'content': prompt}, + ] + + response = self._call_v0_api(messages, model) + code, message_id = self._extract_code(response) + + return { + 'success': True, + 'code': code, + 'message_id': message_id, + } + + def _invoke_refine(self, args: Dict[str, Any]) -> Dict[str, Any]: + prompt = args.get('prompt') + if not prompt: + raise ValueError('refine_ui requires a `prompt` parameter') + + message_id = args.get('message_id') + if not message_id: + raise ValueError('refine_ui requires a `message_id` from a prior generation') + + model = args.get('model') or 'v0-1.0-md' + + messages = [ + {'role': 'user', 'content': prompt}, + ] + + response = self._call_v0_api(messages, model, parent_message_id=message_id) + code, new_message_id = self._extract_code(response) + + return { + 'success': True, + 'code': code, + 'message_id': new_message_id or message_id, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _normalize_tool_input(input_obj: Any) -> Dict[str, Any]: + """Normalize whatever the engine/framework passes as tool input into a plain dict. + + Handles: None, dict, Pydantic model, JSON string, and nested ``input`` wrappers + that some framework paths produce. + """ + if input_obj is None: + return {} + + # Pydantic model -> dict + if hasattr(input_obj, 'model_dump') and callable(getattr(input_obj, 'model_dump')): + input_obj = input_obj.model_dump() + elif hasattr(input_obj, 'dict') and callable(getattr(input_obj, 'dict')): + input_obj = input_obj.dict() + + # JSON string -> dict + if isinstance(input_obj, str): + try: + parsed = json.loads(input_obj) + if isinstance(parsed, dict): + input_obj = parsed + except Exception: + pass + + if not isinstance(input_obj, dict): + warning(f'v0: unexpected input type {type(input_obj).__name__}: {input_obj!r}') + return {} + + # Unwrap ``{"input": {...}}`` wrappers that some framework paths leave behind + if 'input' in input_obj and isinstance(input_obj['input'], dict): + inner = input_obj['input'] + extras = {k: v for k, v in input_obj.items() if k != 'input'} + input_obj = {**inner, **extras} + + # Drop framework-injected keys that aren't tool args + input_obj.pop('security_context', None) + + return input_obj From 9551d031e213920579a6777d7e84ae02b1648473 Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Thu, 2 Apr 2026 20:18:22 -0700 Subject: [PATCH 02/10] fix(nodes): address PR #557 feedback on v0 Vercel node - Format services.json with inline comments matching repo conventions - Return success:false when _extract_code yields empty code instead of success:true with empty string - Include prior_messages in refine_ui for stateless API fallback while keeping parent_message_id for stateful servers Co-Authored-By: Claude Opus 4.6 (1M context) --- nodes/src/nodes/tool_v0/services.json | 72 +++++++++++++++++++++++++++ nodes/src/nodes/tool_v0/v0_driver.py | 34 +++++++++++-- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/nodes/src/nodes/tool_v0/services.json b/nodes/src/nodes/tool_v0/services.json index 6565839a9..6b34ac898 100644 --- a/nodes/src/nodes/tool_v0/services.json +++ b/nodes/src/nodes/tool_v0/services.json @@ -1,21 +1,82 @@ { + // + // Required: + // The displayable name of this node + // "title": "v0 by Vercel", + // + // Required: + // The protocol is the endpoint protocol + // "protocol": "tool_v0://", + // + // Required: + // Class type of the node - what it does + // "classType": ["tool"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying + // engine + // "capabilities": ["invoke"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified. If the + // type is specified, a factory is registered of that given type + // "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate - if + // not specified, the protocol will be used + // "node": "python", + // + // Optional: + // The path is the executable/script code - it is node dependent + // and is optional for most nodes + // "path": "nodes.tool_v0", + // + // Required: + // The prefix map when added/removed when converting URLs <=> paths + // "prefix": "v0", + // + // Optional: + // The icon is the icon to display in the UI for this node + // "icon": "vercel.svg", + // + // Optional: + // Description of this driver + // "description": [ "Generates React UI components from natural-language prompts using Vercel's v0 API.", "Provides generate_ui (create component) and refine_ui (iterate on existing generation)." ], + // + // Optional: + // Tile labels shown on the node in the visual builder + // "tile": ["Tool: v0.generate_ui"], + // + // Optional: + // As a pipe component, define what this pipe component takes + // and what it produces + // "lanes": {}, + // + // Optional: + // Profile section are configuration options used by the driver + // itself + // "preconfig": { + // Define the values that will be merged into any profile configuration + // specified, unless the profile is 'absolute' "default": "default", + // Defines profiles used with the "profile": key "profiles": { "default": { "title": "v0 by Vercel", @@ -23,6 +84,12 @@ } } }, + // + // Optional: + // Local fields definitions - these define fields only for the + // current service. You may specify them here, or directly + // in the shape + // "fields": { "tool_v0.apikey": { "type": "string", @@ -35,6 +102,11 @@ } } }, + // + // Required: + // Defines the fields (shape) of the service. Either source or target + // may be specified, or both, but at least one is required + // "shape": [ { "section": "Pipe", diff --git a/nodes/src/nodes/tool_v0/v0_driver.py b/nodes/src/nodes/tool_v0/v0_driver.py index 2861db31c..6f6f65ac2 100644 --- a/nodes/src/nodes/tool_v0/v0_driver.py +++ b/nodes/src/nodes/tool_v0/v0_driver.py @@ -73,6 +73,17 @@ 'type': 'string', 'description': 'The message_id returned from a previous generate_ui or refine_ui call.', }, + 'prior_messages': { + 'type': 'array', + 'description': 'Prior conversation messages (user/assistant pairs) for stateless API fallback. Include the original prompt and response so the server has full context.', + 'items': { + 'type': 'object', + 'properties': { + 'role': {'type': 'string'}, + 'content': {'type': 'string'}, + }, + }, + }, 'model': { 'type': 'string', 'description': 'The v0 model to use (default: "v0-1.0-md").', @@ -206,6 +217,12 @@ def _invoke_generate(self, args: Dict[str, Any]) -> Dict[str, Any]: response = self._call_v0_api(messages, model) code, message_id = self._extract_code(response) + if not code: + return { + 'success': False, + 'error': 'No code generated', + } + return { 'success': True, 'code': code, @@ -223,13 +240,24 @@ def _invoke_refine(self, args: Dict[str, Any]) -> Dict[str, Any]: model = args.get('model') or 'v0-1.0-md' - messages = [ - {'role': 'user', 'content': prompt}, - ] + # Build the messages array with prior history as a stateless fallback. + # The v0 /v1/chat endpoint may be stateful (server-side history keyed by + # parent_message_id) or stateless (standard OpenAI-compatible, requiring + # the full conversation in messages). We include both: the prior context + # in `messages` and `parent_message_id` as an extra parameter so the + # request works correctly regardless of the server's behaviour. + prior_messages: List[Dict[str, str]] = args.get('prior_messages') or [] + messages = [*prior_messages, {'role': 'user', 'content': prompt}] response = self._call_v0_api(messages, model, parent_message_id=message_id) code, new_message_id = self._extract_code(response) + if not code: + return { + 'success': False, + 'error': 'No code generated', + } + return { 'success': True, 'code': code, From d64caf50a25b9a3fbb4ae5457ec49f5716fe9ead Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Fri, 3 Apr 2026 11:48:08 -0700 Subject: [PATCH 03/10] fix(nodes): format services.json and add header comment for v0 node Co-Authored-By: Claude Opus 4.6 (1M context) --- nodes/src/nodes/tool_v0/services.json | 76 +++------------------------ 1 file changed, 6 insertions(+), 70 deletions(-) diff --git a/nodes/src/nodes/tool_v0/services.json b/nodes/src/nodes/tool_v0/services.json index 6b34ac898..1ff79098d 100644 --- a/nodes/src/nodes/tool_v0/services.json +++ b/nodes/src/nodes/tool_v0/services.json @@ -1,82 +1,29 @@ { // - // Required: - // The displayable name of this node + // v0 by Vercel — Generative UI tool node // - "title": "v0 by Vercel", - // - // Required: - // The protocol is the endpoint protocol + // Generates React + Tailwind CSS components from natural-language prompts + // using the Vercel v0 API. Exposes two tools: + // - generate_ui: create a component from a text description + // - refine_ui: iterate on a previous generation with follow-up instructions // + "title": "v0 by Vercel", "protocol": "tool_v0://", - // - // Required: - // Class type of the node - what it does - // "classType": ["tool"], - // - // Required: - // Capabilities are flags that change the behavior of the underlying - // engine - // "capabilities": ["invoke"], - // - // Optional: - // Register is either filter, endpoint or ignored if not specified. If the - // type is specified, a factory is registered of that given type - // "register": "filter", - // - // Optional: - // The node is the actual physical node to instantiate - if - // not specified, the protocol will be used - // "node": "python", - // - // Optional: - // The path is the executable/script code - it is node dependent - // and is optional for most nodes - // "path": "nodes.tool_v0", - // - // Required: - // The prefix map when added/removed when converting URLs <=> paths - // "prefix": "v0", - // - // Optional: - // The icon is the icon to display in the UI for this node - // "icon": "vercel.svg", - // - // Optional: - // Description of this driver - // "description": [ "Generates React UI components from natural-language prompts using Vercel's v0 API.", "Provides generate_ui (create component) and refine_ui (iterate on existing generation)." ], - // - // Optional: - // Tile labels shown on the node in the visual builder - // "tile": ["Tool: v0.generate_ui"], - // - // Optional: - // As a pipe component, define what this pipe component takes - // and what it produces - // "lanes": {}, - // - // Optional: - // Profile section are configuration options used by the driver - // itself - // "preconfig": { - // Define the values that will be merged into any profile configuration - // specified, unless the profile is 'absolute' "default": "default", - // Defines profiles used with the "profile": key "profiles": { "default": { "title": "v0 by Vercel", @@ -84,12 +31,6 @@ } } }, - // - // Optional: - // Local fields definitions - these define fields only for the - // current service. You may specify them here, or directly - // in the shape - // "fields": { "tool_v0.apikey": { "type": "string", @@ -102,11 +43,6 @@ } } }, - // - // Required: - // Defines the fields (shape) of the service. Either source or target - // may be specified, or both, but at least one is required - // "shape": [ { "section": "Pipe", From b85cbe05749a94bc471642a8bda83b58869494af Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Mon, 6 Apr 2026 11:15:34 -0700 Subject: [PATCH 04/10] refactor(nodes): migrate tool_v0 to @tool_function pattern per PR #599 Remove V0Driver(ToolsBase) and move generate_ui/refine_ui tool logic to @tool_function decorated methods on IInstance. Store apikey directly on IGlobal instead of through a driver object. Co-Authored-By: Claude Opus 4.6 (1M context) --- nodes/src/nodes/tool_v0/IGlobal.py | 19 +- nodes/src/nodes/tool_v0/IInstance.py | 232 +++++++++++++++++++- nodes/src/nodes/tool_v0/v0_driver.py | 310 --------------------------- 3 files changed, 229 insertions(+), 332 deletions(-) delete mode 100644 nodes/src/nodes/tool_v0/v0_driver.py diff --git a/nodes/src/nodes/tool_v0/IGlobal.py b/nodes/src/nodes/tool_v0/IGlobal.py index d6bb0acea..ef537c6e4 100644 --- a/nodes/src/nodes/tool_v0/IGlobal.py +++ b/nodes/src/nodes/tool_v0/IGlobal.py @@ -26,8 +26,7 @@ """ v0 by Vercel tool node - global (shared) state. -Reads the v0 API key from config and creates a V0Driver that implements the -ToolsBase interface for generating React UI components from text prompts. +Reads the v0 API key from config and stores it for IInstance tool methods. """ from __future__ import annotations @@ -35,13 +34,11 @@ from ai.common.config import Config from rocketlib import IGlobalBase, OPEN_MODE, warning -from .v0_driver import V0Driver - class IGlobal(IGlobalBase): """Global state for tool_v0.""" - driver: V0Driver | None = None + apikey: str = '' def beginGlobal(self) -> None: if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: @@ -49,17 +46,11 @@ def beginGlobal(self) -> None: cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - apikey = str((cfg.get('apikey') or '')).strip() + self.apikey = str((cfg.get('apikey') or '')).strip() - if not apikey: + if not self.apikey: raise Exception('tool_v0: apikey is required') - try: - self.driver = V0Driver(server_name='v0', apikey=apikey) - except Exception as e: - warning(str(e)) - raise - def validateConfig(self) -> None: try: cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) @@ -70,4 +61,4 @@ def validateConfig(self) -> None: warning(str(e)) def endGlobal(self) -> None: - self.driver = None + self.apikey = '' diff --git a/nodes/src/nodes/tool_v0/IInstance.py b/nodes/src/nodes/tool_v0/IInstance.py index 96a1e9985..dc1fcf43a 100644 --- a/nodes/src/nodes/tool_v0/IInstance.py +++ b/nodes/src/nodes/tool_v0/IInstance.py @@ -26,23 +26,239 @@ """ v0 by Vercel tool node instance. -Delegates tool invoke operations to the V0Driver. +Exposes ``generate_ui`` and ``refine_ui`` tools for generating React UI +components via Vercel's v0 generative UI API. """ from __future__ import annotations -from typing import Any +import json +from typing import Any, Dict, List -from rocketlib import IInstanceBase +import httpx + +from rocketlib import IInstanceBase, tool_function, warning from .IGlobal import IGlobal +# --------------------------------------------------------------------------- +# v0 API configuration +# --------------------------------------------------------------------------- + +V0_API_BASE = 'https://api.v0.dev/v1' +V0_GENERATE_ENDPOINT = f'{V0_API_BASE}/chat' +V0_REQUEST_TIMEOUT = 120 # seconds — generation can take a while + class IInstance(IInstanceBase): IGlobal: IGlobal - def invoke(self, param: Any) -> Any: # noqa: ANN401 - driver = getattr(self.IGlobal, 'driver', None) - if driver is None: - raise RuntimeError('tool_v0: driver not initialized') - return driver.handle_invoke(param) + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['prompt'], + 'properties': { + 'prompt': { + 'type': 'string', + 'description': 'A natural-language description of the UI component to generate.', + }, + 'model': { + 'type': 'string', + 'description': 'The v0 model to use (default: "v0-1.0-md").', + 'default': 'v0-1.0-md', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'code': {'type': 'string', 'description': 'Generated React component code.'}, + 'message_id': {'type': 'string', 'description': 'v0 message ID for follow-up refinements.'}, + }, + }, + description='Generate a React UI component from a natural-language description. Provide a detailed prompt describing the desired UI and receive production-ready React + Tailwind CSS code.', + ) + def generate_ui(self, args): + """Generate a React UI component from a text prompt.""" + args = _normalize_tool_input(args) + + prompt = args.get('prompt') + if not prompt: + raise ValueError('generate_ui requires a `prompt` parameter') + + model = args.get('model') or 'v0-1.0-md' + + messages = [ + {'role': 'user', 'content': prompt}, + ] + + response = self._call_v0_api(messages, model) + code, message_id = _extract_code(response) + + if not code: + return { + 'success': False, + 'error': 'No code generated', + } + + return { + 'success': True, + 'code': code, + 'message_id': message_id, + } + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['prompt', 'message_id'], + 'properties': { + 'prompt': { + 'type': 'string', + 'description': 'Follow-up instructions describing how to change the component.', + }, + 'message_id': { + 'type': 'string', + 'description': 'The message_id returned from a previous generate_ui or refine_ui call.', + }, + 'prior_messages': { + 'type': 'array', + 'description': 'Prior conversation messages (user/assistant pairs) for stateless API fallback. Include the original prompt and response so the server has full context.', + 'items': { + 'type': 'object', + 'properties': { + 'role': {'type': 'string'}, + 'content': {'type': 'string'}, + }, + }, + }, + 'model': { + 'type': 'string', + 'description': 'The v0 model to use (default: "v0-1.0-md").', + 'default': 'v0-1.0-md', + }, + }, + }, + output_schema={ + 'type': 'object', + 'properties': { + 'success': {'type': 'boolean'}, + 'code': {'type': 'string', 'description': 'Refined React component code.'}, + 'message_id': {'type': 'string', 'description': 'Updated message ID for further refinements.'}, + }, + }, + description='Refine a previously generated UI component by providing follow-up instructions. Requires the message_id from a prior generate_ui call.', + ) + def refine_ui(self, args): + """Refine a previously generated UI component.""" + args = _normalize_tool_input(args) + + prompt = args.get('prompt') + if not prompt: + raise ValueError('refine_ui requires a `prompt` parameter') + + message_id = args.get('message_id') + if not message_id: + raise ValueError('refine_ui requires a `message_id` from a prior generation') + + model = args.get('model') or 'v0-1.0-md' + + # Build the messages array with prior history as a stateless fallback. + # The v0 /v1/chat endpoint may be stateful (server-side history keyed by + # parent_message_id) or stateless (standard OpenAI-compatible, requiring + # the full conversation in messages). We include both: the prior context + # in `messages` and `parent_message_id` as an extra parameter so the + # request works correctly regardless of the server's behaviour. + prior_messages: List[Dict[str, str]] = args.get('prior_messages') or [] + messages = [*prior_messages, {'role': 'user', 'content': prompt}] + + response = self._call_v0_api(messages, model, parent_message_id=message_id) + code, new_message_id = _extract_code(response) + + if not code: + return { + 'success': False, + 'error': 'No code generated', + } + + return { + 'success': True, + 'code': code, + 'message_id': new_message_id or message_id, + } + + def _call_v0_api(self, messages: List[Dict[str, str]], model: str, **extra: Any) -> Dict[str, Any]: + """Send a chat-style request to the v0 API and return the parsed response.""" + payload = { + 'model': model, + 'messages': messages, + 'stream': False, + **extra, + } + + headers = { + 'Authorization': f'Bearer {self.IGlobal.apikey}', + 'Content-Type': 'application/json', + } + + try: + with httpx.Client(timeout=V0_REQUEST_TIMEOUT) as client: + resp = client.post( + V0_GENERATE_ENDPOINT, + headers=headers, + json=payload, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as e: + warning(f'v0 API error: {e.response.status_code} - {e.response.text}') + raise + except Exception as e: + warning(f'v0 API request failed: {e}') + raise + + +def _extract_code(response: Dict[str, Any]) -> tuple[str, str]: + """Extract generated code and message ID from the v0 API response.""" + message_id = '' + code = '' + + choices = response.get('choices') or [] + if choices: + message = choices[0].get('message') or {} + code = message.get('content') or '' + message_id = response.get('id') or '' + + return code, message_id + + +def _normalize_tool_input(input_obj: Any) -> Dict[str, Any]: + """Normalize whatever the engine/framework passes as tool input into a plain dict.""" + if input_obj is None: + return {} + + if hasattr(input_obj, 'model_dump') and callable(getattr(input_obj, 'model_dump')): + input_obj = input_obj.model_dump() + elif hasattr(input_obj, 'dict') and callable(getattr(input_obj, 'dict')): + input_obj = input_obj.dict() + + if isinstance(input_obj, str): + try: + parsed = json.loads(input_obj) + if isinstance(parsed, dict): + input_obj = parsed + except Exception: + pass + + if not isinstance(input_obj, dict): + warning(f'v0: unexpected input type {type(input_obj).__name__}: {input_obj!r}') + return {} + + if 'input' in input_obj and isinstance(input_obj['input'], dict): + inner = input_obj['input'] + extras = {k: v for k, v in input_obj.items() if k != 'input'} + input_obj = {**inner, **extras} + + input_obj.pop('security_context', None) + + return input_obj diff --git a/nodes/src/nodes/tool_v0/v0_driver.py b/nodes/src/nodes/tool_v0/v0_driver.py deleted file mode 100644 index 6f6f65ac2..000000000 --- a/nodes/src/nodes/tool_v0/v0_driver.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -v0 by Vercel tool-provider driver. - -Implements ``tool.query``, ``tool.validate``, and ``tool.invoke`` for -generating React UI components via Vercel's v0 generative UI API. - -v0 accepts natural-language prompts describing a desired UI and returns -production-ready React/Tailwind code. -""" - -from __future__ import annotations - -import json -from typing import Any, Dict, List - -import httpx - -from rocketlib import warning - -from ai.common.tools import ToolsBase - -# --------------------------------------------------------------------------- -# v0 API configuration -# --------------------------------------------------------------------------- - -V0_API_BASE = 'https://api.v0.dev/v1' -V0_GENERATE_ENDPOINT = f'{V0_API_BASE}/chat' -V0_REQUEST_TIMEOUT = 120 # seconds — generation can take a while - -# --------------------------------------------------------------------------- -# Static tool definitions -# --------------------------------------------------------------------------- - -GENERATE_UI_TOOL: Dict[str, Any] = { - 'name': 'generate_ui', - 'description': ('Generate a React UI component from a natural-language description. Provide a detailed prompt describing the desired UI and receive production-ready React + Tailwind CSS code.'), - 'inputSchema': { - 'type': 'object', - 'properties': { - 'prompt': { - 'type': 'string', - 'description': 'A natural-language description of the UI component to generate.', - }, - 'model': { - 'type': 'string', - 'description': 'The v0 model to use (default: "v0-1.0-md").', - 'default': 'v0-1.0-md', - }, - }, - 'required': ['prompt'], - }, - 'outputSchema': { - 'type': 'object', - 'properties': { - 'success': {'type': 'boolean'}, - 'code': {'type': 'string', 'description': 'Generated React component code.'}, - 'message_id': {'type': 'string', 'description': 'v0 message ID for follow-up refinements.'}, - }, - }, -} - -REFINE_UI_TOOL: Dict[str, Any] = { - 'name': 'refine_ui', - 'description': ('Refine a previously generated UI component by providing follow-up instructions. Requires the message_id from a prior generate_ui call.'), - 'inputSchema': { - 'type': 'object', - 'properties': { - 'prompt': { - 'type': 'string', - 'description': 'Follow-up instructions describing how to change the component.', - }, - 'message_id': { - 'type': 'string', - 'description': 'The message_id returned from a previous generate_ui or refine_ui call.', - }, - 'prior_messages': { - 'type': 'array', - 'description': 'Prior conversation messages (user/assistant pairs) for stateless API fallback. Include the original prompt and response so the server has full context.', - 'items': { - 'type': 'object', - 'properties': { - 'role': {'type': 'string'}, - 'content': {'type': 'string'}, - }, - }, - }, - 'model': { - 'type': 'string', - 'description': 'The v0 model to use (default: "v0-1.0-md").', - 'default': 'v0-1.0-md', - }, - }, - 'required': ['prompt', 'message_id'], - }, - 'outputSchema': { - 'type': 'object', - 'properties': { - 'success': {'type': 'boolean'}, - 'code': {'type': 'string', 'description': 'Refined React component code.'}, - 'message_id': {'type': 'string', 'description': 'Updated message ID for further refinements.'}, - }, - }, -} - -_TOOLS_BY_BARE_NAME: Dict[str, Dict[str, Any]] = { - 'generate_ui': GENERATE_UI_TOOL, - 'refine_ui': REFINE_UI_TOOL, -} - - -class V0Driver(ToolsBase): - """Tool provider for Vercel v0 UI generation.""" - - def __init__(self, *, server_name: str, apikey: str) -> None: # noqa: D107 - self._server_name = (server_name or '').strip() or 'v0' - self._apikey = apikey - - def _bare_name(self, tool_name: str) -> str: - """Strip server prefix, accepting both bare and namespaced tool names.""" - prefix = f'{self._server_name}.' - return tool_name[len(prefix) :] if tool_name.startswith(prefix) else tool_name - - # ------------------------------------------------------------------ - # ToolsBase hooks - # ------------------------------------------------------------------ - - def _tool_query(self) -> List[ToolsBase.ToolDescriptor]: - return [{**tool, 'name': f'{self._server_name}.{tool["name"]}'} for tool in _TOOLS_BY_BARE_NAME.values()] - - def _tool_validate(self, *, tool_name: str, input_obj: Any) -> None: # noqa: ANN401 - tool = _TOOLS_BY_BARE_NAME.get(self._bare_name(tool_name)) - if tool is None: - raise ValueError(f'Unknown tool {tool_name}') - - schema = tool.get('inputSchema') or {} - required = schema.get('required', []) - if not required: - return - if not isinstance(input_obj, dict): - raise ValueError(f'Tool input must be an object; required fields={required}') - missing = [k for k in required if k not in input_obj] - if missing: - raise ValueError(f'Tool input missing required fields: {missing}') - - def _tool_invoke(self, *, tool_name: str, input_obj: Any) -> Any: # noqa: ANN401 - args = _normalize_tool_input(input_obj) - bare = self._bare_name(tool_name) - - if bare == 'generate_ui': - return self._invoke_generate(args) - elif bare == 'refine_ui': - return self._invoke_refine(args) - else: - raise ValueError(f'Unknown tool {tool_name}') - - # ------------------------------------------------------------------ - # Tool implementations - # ------------------------------------------------------------------ - - def _build_headers(self) -> Dict[str, str]: - return { - 'Authorization': f'Bearer {self._apikey}', - 'Content-Type': 'application/json', - } - - def _call_v0_api(self, messages: List[Dict[str, str]], model: str, **extra: Any) -> Dict[str, Any]: - """Send a chat-style request to the v0 API and return the parsed response.""" - payload = { - 'model': model, - 'messages': messages, - 'stream': False, - **extra, - } - - try: - with httpx.Client(timeout=V0_REQUEST_TIMEOUT) as client: - resp = client.post( - V0_GENERATE_ENDPOINT, - headers=self._build_headers(), - json=payload, - ) - resp.raise_for_status() - return resp.json() - except httpx.HTTPStatusError as e: - warning(f'v0 API error: {e.response.status_code} - {e.response.text}') - raise - except Exception as e: - warning(f'v0 API request failed: {e}') - raise - - @staticmethod - def _extract_code(response: Dict[str, Any]) -> tuple[str, str]: - """Extract generated code and message ID from the v0 API response.""" - message_id = '' - code = '' - - # v0 API returns a chat-completions-style response - choices = response.get('choices') or [] - if choices: - message = choices[0].get('message') or {} - code = message.get('content') or '' - message_id = response.get('id') or '' - - return code, message_id - - def _invoke_generate(self, args: Dict[str, Any]) -> Dict[str, Any]: - prompt = args.get('prompt') - if not prompt: - raise ValueError('generate_ui requires a `prompt` parameter') - - model = args.get('model') or 'v0-1.0-md' - - messages = [ - {'role': 'user', 'content': prompt}, - ] - - response = self._call_v0_api(messages, model) - code, message_id = self._extract_code(response) - - if not code: - return { - 'success': False, - 'error': 'No code generated', - } - - return { - 'success': True, - 'code': code, - 'message_id': message_id, - } - - def _invoke_refine(self, args: Dict[str, Any]) -> Dict[str, Any]: - prompt = args.get('prompt') - if not prompt: - raise ValueError('refine_ui requires a `prompt` parameter') - - message_id = args.get('message_id') - if not message_id: - raise ValueError('refine_ui requires a `message_id` from a prior generation') - - model = args.get('model') or 'v0-1.0-md' - - # Build the messages array with prior history as a stateless fallback. - # The v0 /v1/chat endpoint may be stateful (server-side history keyed by - # parent_message_id) or stateless (standard OpenAI-compatible, requiring - # the full conversation in messages). We include both: the prior context - # in `messages` and `parent_message_id` as an extra parameter so the - # request works correctly regardless of the server's behaviour. - prior_messages: List[Dict[str, str]] = args.get('prior_messages') or [] - messages = [*prior_messages, {'role': 'user', 'content': prompt}] - - response = self._call_v0_api(messages, model, parent_message_id=message_id) - code, new_message_id = self._extract_code(response) - - if not code: - return { - 'success': False, - 'error': 'No code generated', - } - - return { - 'success': True, - 'code': code, - 'message_id': new_message_id or message_id, - } - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _normalize_tool_input(input_obj: Any) -> Dict[str, Any]: - """Normalize whatever the engine/framework passes as tool input into a plain dict. - - Handles: None, dict, Pydantic model, JSON string, and nested ``input`` wrappers - that some framework paths produce. - """ - if input_obj is None: - return {} - - # Pydantic model -> dict - if hasattr(input_obj, 'model_dump') and callable(getattr(input_obj, 'model_dump')): - input_obj = input_obj.model_dump() - elif hasattr(input_obj, 'dict') and callable(getattr(input_obj, 'dict')): - input_obj = input_obj.dict() - - # JSON string -> dict - if isinstance(input_obj, str): - try: - parsed = json.loads(input_obj) - if isinstance(parsed, dict): - input_obj = parsed - except Exception: - pass - - if not isinstance(input_obj, dict): - warning(f'v0: unexpected input type {type(input_obj).__name__}: {input_obj!r}') - return {} - - # Unwrap ``{"input": {...}}`` wrappers that some framework paths leave behind - if 'input' in input_obj and isinstance(input_obj['input'], dict): - inner = input_obj['input'] - extras = {k: v for k, v in input_obj.items() if k != 'input'} - input_obj = {**inner, **extras} - - # Drop framework-injected keys that aren't tool args - input_obj.pop('security_context', None) - - return input_obj From 5d01f85f076bcc23084c91038db132015cacac55 Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Wed, 8 Apr 2026 00:08:19 -0700 Subject: [PATCH 05/10] fix(nodes): address CodeRabbit review feedback on tool_v0 node - Remove extraneous parentheses around cfg.get() in IGlobal.py - Add defensive JSON error handling for non-JSON API responses - Update tile label to generic "Tool: v0 UI Generation" - Add 'error' field to both tool output schemas - Remove raw response body from HTTP error logging Co-Authored-By: Claude Opus 4.6 (1M context) --- nodes/src/nodes/tool_v0/IGlobal.py | 4 ++-- nodes/src/nodes/tool_v0/IInstance.py | 10 ++++++++-- nodes/src/nodes/tool_v0/services.json | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/nodes/src/nodes/tool_v0/IGlobal.py b/nodes/src/nodes/tool_v0/IGlobal.py index ef537c6e4..3800eab55 100644 --- a/nodes/src/nodes/tool_v0/IGlobal.py +++ b/nodes/src/nodes/tool_v0/IGlobal.py @@ -46,7 +46,7 @@ def beginGlobal(self) -> None: cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - self.apikey = str((cfg.get('apikey') or '')).strip() + self.apikey = str(cfg.get('apikey') or '').strip() if not self.apikey: raise Exception('tool_v0: apikey is required') @@ -54,7 +54,7 @@ def beginGlobal(self) -> None: def validateConfig(self) -> None: try: cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) - apikey = str((cfg.get('apikey') or '')).strip() + apikey = str(cfg.get('apikey') or '').strip() if not apikey: warning('apikey is required') except Exception as e: diff --git a/nodes/src/nodes/tool_v0/IInstance.py b/nodes/src/nodes/tool_v0/IInstance.py index dc1fcf43a..237e35604 100644 --- a/nodes/src/nodes/tool_v0/IInstance.py +++ b/nodes/src/nodes/tool_v0/IInstance.py @@ -75,6 +75,7 @@ class IInstance(IInstanceBase): 'success': {'type': 'boolean'}, 'code': {'type': 'string', 'description': 'Generated React component code.'}, 'message_id': {'type': 'string', 'description': 'v0 message ID for follow-up refinements.'}, + 'error': {'type': 'string', 'description': 'Error message on failure.'}, }, }, description='Generate a React UI component from a natural-language description. Provide a detailed prompt describing the desired UI and receive production-ready React + Tailwind CSS code.', @@ -145,6 +146,7 @@ def generate_ui(self, args): 'success': {'type': 'boolean'}, 'code': {'type': 'string', 'description': 'Refined React component code.'}, 'message_id': {'type': 'string', 'description': 'Updated message ID for further refinements.'}, + 'error': {'type': 'string', 'description': 'Error message on failure.'}, }, }, description='Refine a previously generated UI component by providing follow-up instructions. Requires the message_id from a prior generate_ui call.', @@ -209,9 +211,13 @@ def _call_v0_api(self, messages: List[Dict[str, str]], model: str, **extra: Any) json=payload, ) resp.raise_for_status() - return resp.json() + try: + return resp.json() + except (json.JSONDecodeError, ValueError) as exc: + warning(f'v0 API returned non-JSON response: {exc}') + raise ValueError('v0 API returned non-JSON response') from exc except httpx.HTTPStatusError as e: - warning(f'v0 API error: {e.response.status_code} - {e.response.text}') + warning(f'v0 API error: status={e.response.status_code}') raise except Exception as e: warning(f'v0 API request failed: {e}') diff --git a/nodes/src/nodes/tool_v0/services.json b/nodes/src/nodes/tool_v0/services.json index 1ff79098d..f3c3e429b 100644 --- a/nodes/src/nodes/tool_v0/services.json +++ b/nodes/src/nodes/tool_v0/services.json @@ -20,7 +20,7 @@ "Generates React UI components from natural-language prompts using Vercel's v0 API.", "Provides generate_ui (create component) and refine_ui (iterate on existing generation)." ], - "tile": ["Tool: v0.generate_ui"], + "tile": ["Tool: v0 UI Generation"], "lanes": {}, "preconfig": { "default": "default", From 0321dee0768503e2a8ae2adff8cc01932ec0cac9 Mon Sep 17 00:00:00 2001 From: Alexandru Sclearuc Date: Thu, 9 Apr 2026 15:11:36 +0300 Subject: [PATCH 06/10] fix: format JSON file --- nodes/src/nodes/tool_v0/services.json | 85 ++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/nodes/src/nodes/tool_v0/services.json b/nodes/src/nodes/tool_v0/services.json index f3c3e429b..cd2995072 100644 --- a/nodes/src/nodes/tool_v0/services.json +++ b/nodes/src/nodes/tool_v0/services.json @@ -7,23 +7,82 @@ // - generate_ui: create a component from a text description // - refine_ui: iterate on a previous generation with follow-up instructions // + + // + // Required: + // The displayable name of this node + // "title": "v0 by Vercel", + // + // Required: + // The protocol is the endpoint protocol + // "protocol": "tool_v0://", + // + // Required: + // Class type of the node - what it does + // "classType": ["tool"], - "capabilities": ["invoke"], + // + // Required: + // Capabilities are flags that change the behavior of the underlying + // engine. "experimental" marks this node as not yet production-ready + // + "capabilities": ["invoke", "experimental"], + // + // Optional: + // Register is either filter, endpoint or ignored if not specified. If the + // type is specified, a factory is registered of that given type + // "register": "filter", + // + // Optional: + // The node is the actual physical node to instantiate - if + // not specified, the protocol will be used + // "node": "python", + // + // Optional: + // The path is the executable/script code - it is node dependent + // and is optional for most nodes + // "path": "nodes.tool_v0", + // + // Required: + // The prefix map when added/removed when converting URLs <=> paths + // "prefix": "v0", + // + // Optional: + // The icon to display in the UI for this node + // "icon": "vercel.svg", - "description": [ - "Generates React UI components from natural-language prompts using Vercel's v0 API.", - "Provides generate_ui (create component) and refine_ui (iterate on existing generation)." - ], + // + // Optional: + // Description of this driver + // + "description": ["A component that connects to Vercel's v0 API to generate React + Tailwind CSS UI ", "components from natural-language prompts. Supports generate_ui (create a new component ", "from a description) and refine_ui (iterate on a previous generation with follow-up ", "instructions). Experimental: API availability and behavior may change."], + // + // Optional: + // Rendering hints to the UI which indicate which fields of + // the configuration should be used to display information + // "tile": ["Tool: v0 UI Generation"], + // + // Optional: + // As a pipe component, define what this pipe component takes + // and what it produces + // "lanes": {}, + // + // Optional: + // Profile section are configuration options used by the driver itself + // "preconfig": { + // Define the values that will be merged into any profile configuration + // specified, unless the profile is 'absolute' "default": "default", + // Defines profiles used with the "profile": key "profiles": { "default": { "title": "v0 by Vercel", @@ -31,6 +90,12 @@ } } }, + // + // Optional: + // Local field definitions - these define fields only for the + // current service. You may specify them here, or directly + // in the shape + // "fields": { "tool_v0.apikey": { "type": "string", @@ -43,14 +108,16 @@ } } }, + // + // Required: + // Defines the fields (shape) of the service. Either source or target + // may be specified, or both, but at least one is required + // "shape": [ { "section": "Pipe", "title": "v0 by Vercel", - "properties": [ - "type", - "tool_v0.apikey" - ] + "properties": ["type", "tool_v0.apikey"] } ] } From 53e1f9289d7fe97d25a0cdfc23f6abfc9e0c7425 Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Mon, 13 Apr 2026 21:10:35 -0700 Subject: [PATCH 07/10] fix(nodes): return schema-shaped errors and redact log payloads in tool_v0 Address CodeRabbit review feedback on the v0 by Vercel tool node: - generate_ui and refine_ui now wrap _call_v0_api/_extract_code in try/except and return {success: False, error: ...} on transport or parse failures, so downstream nodes can branch on failure instead of seeing raised exceptions. Argument validation also returns the same shape rather than raising ValueError. - _normalize_tool_input no longer logs the raw input repr when given an unexpected type; prompts, prior messages, and generated code are now redacted from server logs. Co-Authored-By: Claude Opus 4.6 --- nodes/src/nodes/tool_v0/IInstance.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/nodes/src/nodes/tool_v0/IInstance.py b/nodes/src/nodes/tool_v0/IInstance.py index 237e35604..c9d9a5da5 100644 --- a/nodes/src/nodes/tool_v0/IInstance.py +++ b/nodes/src/nodes/tool_v0/IInstance.py @@ -86,7 +86,7 @@ def generate_ui(self, args): prompt = args.get('prompt') if not prompt: - raise ValueError('generate_ui requires a `prompt` parameter') + return {'success': False, 'error': 'generate_ui requires a `prompt` parameter'} model = args.get('model') or 'v0-1.0-md' @@ -94,8 +94,11 @@ def generate_ui(self, args): {'role': 'user', 'content': prompt}, ] - response = self._call_v0_api(messages, model) - code, message_id = _extract_code(response) + try: + response = self._call_v0_api(messages, model) + code, message_id = _extract_code(response) + except Exception as e: + return {'success': False, 'error': f'v0 API call failed: {e}'} if not code: return { @@ -157,11 +160,11 @@ def refine_ui(self, args): prompt = args.get('prompt') if not prompt: - raise ValueError('refine_ui requires a `prompt` parameter') + return {'success': False, 'error': 'refine_ui requires a `prompt` parameter'} message_id = args.get('message_id') if not message_id: - raise ValueError('refine_ui requires a `message_id` from a prior generation') + return {'success': False, 'error': 'refine_ui requires a `message_id` from a prior generation'} model = args.get('model') or 'v0-1.0-md' @@ -174,8 +177,11 @@ def refine_ui(self, args): prior_messages: List[Dict[str, str]] = args.get('prior_messages') or [] messages = [*prior_messages, {'role': 'user', 'content': prompt}] - response = self._call_v0_api(messages, model, parent_message_id=message_id) - code, new_message_id = _extract_code(response) + try: + response = self._call_v0_api(messages, model, parent_message_id=message_id) + code, new_message_id = _extract_code(response) + except Exception as e: + return {'success': False, 'error': f'v0 API call failed: {e}'} if not code: return { @@ -257,7 +263,7 @@ def _normalize_tool_input(input_obj: Any) -> Dict[str, Any]: pass if not isinstance(input_obj, dict): - warning(f'v0: unexpected input type {type(input_obj).__name__}: {input_obj!r}') + warning(f'v0: unexpected input type {type(input_obj).__name__} (content redacted)') return {} if 'input' in input_obj and isinstance(input_obj['input'], dict): From b91b069d04a8eff446b11ed85497f871b476918d Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Fri, 15 May 2026 23:05:22 -0700 Subject: [PATCH 08/10] fix(nodes): narrow tool_v0 exception types per reviewer feedback Replace broad `except Exception` in generate_ui and refine_ui with `except (httpx.HTTPError, ValueError, json.JSONDecodeError)` so unexpected errors propagate rather than being silently swallowed. The internal _call_v0_api handler is intentionally left broad as it logs and re-raises. Co-Authored-By: Claude Sonnet 4.6 --- nodes/src/nodes/tool_v0/IInstance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nodes/src/nodes/tool_v0/IInstance.py b/nodes/src/nodes/tool_v0/IInstance.py index c9d9a5da5..2ba7f732c 100644 --- a/nodes/src/nodes/tool_v0/IInstance.py +++ b/nodes/src/nodes/tool_v0/IInstance.py @@ -97,7 +97,7 @@ def generate_ui(self, args): try: response = self._call_v0_api(messages, model) code, message_id = _extract_code(response) - except Exception as e: + except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e: return {'success': False, 'error': f'v0 API call failed: {e}'} if not code: @@ -180,7 +180,7 @@ def refine_ui(self, args): try: response = self._call_v0_api(messages, model, parent_message_id=message_id) code, new_message_id = _extract_code(response) - except Exception as e: + except (httpx.HTTPError, ValueError, json.JSONDecodeError) as e: return {'success': False, 'error': f'v0 API call failed: {e}'} if not code: From efacc63283247205ac4e77c66a135a229b771aae Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Fri, 15 May 2026 23:05:38 -0700 Subject: [PATCH 09/10] test(nodes): add unit tests for tool_v0 IInstance pure-logic helpers Covers _normalize_tool_input, _extract_code, generate_ui, and refine_ui with rocketlib stubbed via sys.modules to avoid runtime dependencies. Co-Authored-By: Claude Sonnet 4.6 --- nodes/test/test_tool_v0.py | 276 +++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 nodes/test/test_tool_v0.py diff --git a/nodes/test/test_tool_v0.py b/nodes/test/test_tool_v0.py new file mode 100644 index 000000000..12d39841c --- /dev/null +++ b/nodes/test/test_tool_v0.py @@ -0,0 +1,276 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Unit tests for tool_v0 IInstance pure-logic helpers.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# rocketlib stub — must be installed before importing the module under test +# --------------------------------------------------------------------------- + +_WARNING_CALLS: list[str] = [] + + +def _reset_warnings() -> None: + _WARNING_CALLS.clear() + + +def _stub_warning(msg: str, *_a: object, **_k: object) -> None: + _WARNING_CALLS.append(msg) + + +def _install_rocketlib_stub() -> None: + rocketlib = types.ModuleType('rocketlib') + rocketlib.IInstanceBase = object + rocketlib.IGlobalBase = object + rocketlib.tool_function = lambda *_a, **_k: lambda fn: fn + rocketlib.warning = _stub_warning + rocketlib.OPEN_MODE = SimpleNamespace(CONFIG='config') + sys.modules.setdefault('rocketlib', rocketlib) + + ai_pkg = types.ModuleType('ai') + ai_pkg.__path__ = [] + ai_common = types.ModuleType('ai.common') + ai_common.__path__ = [] + ai_config = types.ModuleType('ai.common.config') + ai_config.Config = MagicMock() + sys.modules.setdefault('ai', ai_pkg) + sys.modules.setdefault('ai.common', ai_common) + sys.modules.setdefault('ai.common.config', ai_config) + + +_install_rocketlib_stub() + +# --------------------------------------------------------------------------- +# Load the module under test via importlib so we avoid package __init__ chains +# --------------------------------------------------------------------------- + +_NODES_ROOT = Path(__file__).resolve().parent.parent / 'src' / 'nodes' +_IINSTANCE_PATH = _NODES_ROOT / 'tool_v0' / 'IInstance.py' + + +def _load_iinstance(): + # The module uses `from .IGlobal import IGlobal` so it needs a parent package. + # Register a fake package 'tool_v0' and its IGlobal sub-module in sys.modules + # so the relative import resolves without a real filesystem package. + pkg_name = 'tool_v0' + pkg_stub = types.ModuleType(pkg_name) + pkg_stub.__path__ = [str(_NODES_ROOT / 'tool_v0')] + pkg_stub.__package__ = pkg_name + sys.modules[pkg_name] = pkg_stub + + iglobal_mod = types.ModuleType(f'{pkg_name}.IGlobal') + iglobal_mod.IGlobal = type('IGlobal', (), {}) + sys.modules[f'{pkg_name}.IGlobal'] = iglobal_mod + pkg_stub.IGlobal = iglobal_mod + + spec = importlib.util.spec_from_file_location( + f'{pkg_name}.IInstance', + _IINSTANCE_PATH, + submodule_search_locations=[], + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + mod.__package__ = pkg_name + sys.modules[f'{pkg_name}.IInstance'] = mod + spec.loader.exec_module(mod) + return mod + + +_mod = _load_iinstance() +_normalize_tool_input = _mod._normalize_tool_input +_extract_code = _mod._extract_code +IInstance = _mod.IInstance + + +# --------------------------------------------------------------------------- +# Helper: build a minimal IInstance without calling __init__ +# --------------------------------------------------------------------------- + + +def _make_instance() -> IInstance: + inst = IInstance.__new__(IInstance) + iglobal = SimpleNamespace(apikey='test-key') + inst.IGlobal = iglobal + return inst + + +# ============================================================================= +# (a) _normalize_tool_input +# ============================================================================= + + +class TestNormalizeToolInput: + def setup_method(self): + _reset_warnings() + + def test_none_returns_empty_dict(self): + assert _normalize_tool_input(None) == {} + + def test_json_string_parsed_to_dict(self): + result = _normalize_tool_input(json.dumps({'prompt': 'hello'})) + assert result == {'prompt': 'hello'} + + def test_object_with_model_dump_converted(self): + obj = SimpleNamespace() + obj.model_dump = lambda: {'prompt': 'from_pydantic'} + result = _normalize_tool_input(obj) + assert result == {'prompt': 'from_pydantic'} + + def test_nested_input_key_flattened(self): + nested = {'input': {'prompt': 'hi', 'model': 'v0-1.0-md'}, 'extra': 'val'} + result = _normalize_tool_input(nested) + assert result['prompt'] == 'hi' + assert result['model'] == 'v0-1.0-md' + assert result['extra'] == 'val' + assert 'input' not in result + + def test_security_context_stripped(self): + data = {'prompt': 'test', 'security_context': {'token': 'secret'}} + result = _normalize_tool_input(data) + assert 'security_context' not in result + assert result['prompt'] == 'test' + + def test_non_dict_type_logs_type_name_only(self): + _normalize_tool_input(42) + assert len(_WARNING_CALLS) == 1 + assert 'int' in _WARNING_CALLS[0] + # Must not contain the actual value + assert '42' not in _WARNING_CALLS[0] + + def test_non_dict_list_logs_type_name_only(self): + _reset_warnings() + _normalize_tool_input(['secret_value_xyz', 'other_secret']) + assert len(_WARNING_CALLS) == 1 + assert 'list' in _WARNING_CALLS[0] + # Raw list contents must not appear in the warning + assert 'secret_value_xyz' not in _WARNING_CALLS[0] + assert 'other_secret' not in _WARNING_CALLS[0] + + def test_non_dict_returns_empty_dict(self): + result = _normalize_tool_input(12345) + assert result == {} + + +# ============================================================================= +# (b) _extract_code +# ============================================================================= + + +class TestExtractCode: + def test_empty_choices_returns_empty_strings(self): + code, msg_id = _extract_code({}) + assert code == '' + assert msg_id == '' + + def test_empty_choices_list_returns_empty_strings(self): + code, msg_id = _extract_code({'choices': []}) + assert code == '' + assert msg_id == '' + + def test_well_formed_response_extracts_code_and_id(self): + response = { + 'id': 'msg-abc123', + 'choices': [{'message': {'content': 'export default function App() {}', 'role': 'assistant'}}], + } + code, msg_id = _extract_code(response) + assert code == 'export default function App() {}' + assert msg_id == 'msg-abc123' + + def test_missing_message_field_returns_empty_strings(self): + response = { + 'id': 'msg-xyz', + 'choices': [{'finish_reason': 'stop'}], + } + code, msg_id = _extract_code(response) + assert code == '' + assert msg_id == 'msg-xyz' + + +# ============================================================================= +# (c) generate_ui +# ============================================================================= + + +class TestGenerateUi: + def test_missing_prompt_returns_error(self): + inst = _make_instance() + result = inst.generate_ui({}) + assert result['success'] is False + assert 'prompt' in result['error'] + + def test_http_status_error_returns_failure(self): + import httpx + + inst = _make_instance() + mock_response = MagicMock() + mock_response.status_code = 401 + err = httpx.HTTPStatusError('Unauthorized', request=MagicMock(), response=mock_response) + + with patch.object(inst, '_call_v0_api', side_effect=err): + result = inst.generate_ui({'prompt': 'make a button'}) + + assert result['success'] is False + assert 'error' in result + + def test_empty_choices_returns_no_code_generated(self): + inst = _make_instance() + with patch.object(inst, '_call_v0_api', return_value={'choices': [], 'id': 'x'}): + result = inst.generate_ui({'prompt': 'make a button'}) + + assert result['success'] is False + assert result['error'] == 'No code generated' + + +# ============================================================================= +# (d) refine_ui +# ============================================================================= + + +class TestRefineUi: + def test_missing_message_id_returns_error(self): + inst = _make_instance() + result = inst.refine_ui({'prompt': 'change color to blue'}) + assert result['success'] is False + assert 'message_id' in result['error'] + + def test_prior_messages_forwarded_into_call(self): + inst = _make_instance() + prior = [ + {'role': 'user', 'content': 'make a button'}, + {'role': 'assistant', 'content': 'export default function Btn() {}'}, + ] + good_response = { + 'id': 'msg-new', + 'choices': [{'message': {'content': 'export default function Btn2() {}'}}], + } + + with patch.object(inst, '_call_v0_api', return_value=good_response) as mock_call: + result = inst.refine_ui( + { + 'prompt': 'change color to blue', + 'message_id': 'msg-old', + 'prior_messages': prior, + } + ) + + assert result['success'] is True + _args, _kwargs = mock_call.call_args + messages_sent = _args[0] + # prior messages must appear at the start + assert messages_sent[:2] == prior + # new user turn must be appended + assert messages_sent[-1] == {'role': 'user', 'content': 'change color to blue'} + # parent_message_id passed as kwarg + assert _kwargs.get('parent_message_id') == 'msg-old' From 0115337a420b75c3ec48db9dc1283b7b3a6ef43e Mon Sep 17 00:00:00 2001 From: Charlie Gillet Date: Sat, 16 May 2026 02:37:07 -0700 Subject: [PATCH 10/10] test(nodes): patch tool_v0 module warning attr directly for CI environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests stubbed rocketlib via sys.modules.setdefault, but on CI the real rocketlib is already loaded into sys.modules so the stub is a no-op. The production code does `from rocketlib import warning` which binds the name at import time, so the IInstance module ends up with a reference to the engine's real logger and never appends to _WARNING_CALLS. Override _mod.warning directly after loading IInstance — patches the actual reference _normalize_tool_input uses, independent of rocketlib's state in sys.modules. Fixes test_non_dict_type_logs_type_name_only and test_non_dict_list_logs_type_name_only failing inside the engine pytest runner on Build / Ubuntu / macOS / Windows. Co-Authored-By: Claude Opus 4.7 (1M context) --- nodes/test/test_tool_v0.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nodes/test/test_tool_v0.py b/nodes/test/test_tool_v0.py index 12d39841c..d2e28d3ce 100644 --- a/nodes/test/test_tool_v0.py +++ b/nodes/test/test_tool_v0.py @@ -89,6 +89,14 @@ def _load_iinstance(): _mod = _load_iinstance() +# Force the loaded module's `warning` reference to our stub. Required because +# `from rocketlib import warning` binds the name at import time, so when the +# real rocketlib is already in sys.modules (e.g. on CI inside the engine +# pytest runner) our sys.modules stub is ignored and _mod.warning points at +# the engine's logger. Overriding the attribute on the loaded module patches +# the actual reference used by _normalize_tool_input. +_mod.warning = _stub_warning + _normalize_tool_input = _mod._normalize_tool_input _extract_code = _mod._extract_code IInstance = _mod.IInstance