Compare commits

...

6 Commits

Author SHA1 Message Date
waleed
2b24a47308 updated docs 2026-01-31 00:55:39 -08:00
waleed
0e7b8430e9 feat(tools): added google maps and DSPy 2026-01-31 00:51:22 -08:00
Vikhyath Mondreti
cf2f1abcaf fix(executor): condition inside parallel (#3094)
* fix(executor): condition inside parallel

* remove comments
2026-01-30 18:47:39 -08:00
Waleed
4109feecf6 feat(invitations): added invitations query hook, migrated all tool files to use absolute imports (#3092)
* feat(invitations): added invitations query hook, migrated all tool files to use absolute imports

* ack PR comments

* remove dead import

* remove unused hook
2026-01-30 18:39:23 -08:00
Waleed
37d5e01f5f fix(mcp): increase timeout from 1m to 10m (#3093) 2026-01-30 17:51:05 -08:00
Vikhyath Mondreti
2d799b3272 fix(billing): plan should be detected from stripe subscription object (#3090)
* fix(billing): plan should be detected from stripe subscription object

* fix typing
2026-01-30 17:01:16 -08:00
316 changed files with 6660 additions and 1539 deletions

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ import {
DiscordIcon,
DocumentIcon,
DropboxIcon,
DsPyIcon,
DuckDuckGoIcon,
DynamoDBIcon,
ElasticsearchIcon,
@@ -41,6 +42,7 @@ import {
GoogleFormsIcon,
GoogleGroupsIcon,
GoogleIcon,
GoogleMapsIcon,
GoogleSheetsIcon,
GoogleSlidesIcon,
GoogleVaultIcon,
@@ -153,6 +155,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
datadog: DatadogIcon,
discord: DiscordIcon,
dropbox: DropboxIcon,
dspy: DsPyIcon,
duckduckgo: DuckDuckGoIcon,
dynamodb: DynamoDBIcon,
elasticsearch: ElasticsearchIcon,
@@ -169,6 +172,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
google_drive: GoogleDriveIcon,
google_forms: GoogleFormsIcon,
google_groups: GoogleGroupsIcon,
google_maps: GoogleMapsIcon,
google_search: GoogleIcon,
google_sheets_v2: GoogleSheetsIcon,
google_slides: GoogleSlidesIcon,

View File

@@ -1,5 +1,5 @@
---
title: CalCom
title: Cal Com
description: Manage Cal.com bookings, event types, schedules, and availability
---

View File

@@ -0,0 +1,112 @@
---
title: DSPy
description: Run predictions using self-hosted DSPy programs
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="dspy"
color="#1E293B"
/>
{/* MANUAL-CONTENT-START:intro */}
[DSPy](https://github.com/stanford-oval/dspy) is an open-source framework for programming—rather than prompting—language models. DSPy enables you to build interpretable and modular LLM-powered agents using Python functions, structured modules, and declarative signatures, making it easy to compose, debug, and reliably deploy language model applications.
With DSPy in Sim, you can:
- **Run custom predictions**: Connect your self-hosted DSPy server and invoke prediction endpoints for a variety of natural language tasks.
- **Chain of Thought and ReAct reasoning**: Leverage advanced DSPy modules for step-by-step reasoning, multi-turn dialogs, and action-observation loops.
- **Integrate with your workflows**: Automate LLM predictions and reasoning as part of any Sim automation or agent routine.
- **Provide custom endpoints and context**: Flexibly call your own DSPy-powered APIs with custom authentication, endpoints, input fields, and context.
These features let your Sim agents access modular, interpretable LLM-based programs for tasks like question answering, document analysis, decision support, and more—where you remain in control of the model, data, and logic.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming—not prompting—language models.
## Tools
### `dspy_predict`
Run a prediction using a self-hosted DSPy program endpoint
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Base URL of the DSPy server \(e.g., https://your-dspy-server.com\) |
| `apiKey` | string | No | API key for authentication \(if required by your server\) |
| `endpoint` | string | No | API endpoint path \(defaults to /predict\) |
| `input` | string | Yes | The input text to send to the DSPy program |
| `inputField` | string | No | Name of the input field expected by the DSPy program \(defaults to "text"\) |
| `context` | string | No | Additional context to provide to the DSPy program |
| `additionalInputs` | json | No | Additional key-value pairs to include in the request body |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `answer` | string | The main output/answer from the DSPy program |
| `reasoning` | string | The reasoning or rationale behind the answer \(if available\) |
| `status` | string | Response status from the DSPy server \(success or error\) |
| `rawOutput` | json | The complete raw output from the DSPy program \(result.toDict\(\)\) |
### `dspy_chain_of_thought`
Run a Chain of Thought prediction using a self-hosted DSPy ChainOfThought program endpoint
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Base URL of the DSPy server \(e.g., https://your-dspy-server.com\) |
| `apiKey` | string | No | API key for authentication \(if required by your server\) |
| `endpoint` | string | No | API endpoint path \(defaults to /predict\) |
| `question` | string | Yes | The question to answer using chain of thought reasoning |
| `context` | string | No | Additional context to provide for answering the question |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `answer` | string | The answer generated through chain of thought reasoning |
| `reasoning` | string | The step-by-step reasoning that led to the answer |
| `status` | string | Response status from the DSPy server \(success or error\) |
| `rawOutput` | json | The complete raw output from the DSPy program \(result.toDict\(\)\) |
### `dspy_react`
Run a ReAct agent using a self-hosted DSPy ReAct program endpoint for multi-step reasoning and action
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `baseUrl` | string | Yes | Base URL of the DSPy server \(e.g., https://your-dspy-server.com\) |
| `apiKey` | string | No | API key for authentication \(if required by your server\) |
| `endpoint` | string | No | API endpoint path \(defaults to /predict\) |
| `task` | string | Yes | The task or question for the ReAct agent to work on |
| `context` | string | No | Additional context to provide for the task |
| `maxIterations` | number | No | Maximum number of reasoning iterations \(defaults to server setting\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `answer` | string | The final answer or result from the ReAct agent |
| `reasoning` | string | The overall reasoning summary from the agent |
| `trajectory` | array | The step-by-step trajectory of thoughts, actions, and observations |
| ↳ `thought` | string | The reasoning thought at this step |
| ↳ `toolName` | string | The name of the tool/action called |
| ↳ `toolArgs` | json | Arguments passed to the tool |
| ↳ `observation` | string | The observation/result from the tool execution |
| `status` | string | Response status from the DSPy server \(success or error\) |
| `rawOutput` | json | The complete raw output from the DSPy program \(result.toDict\(\)\) |

View File

@@ -10,6 +10,23 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
color="#E8F0FE"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Groups](https://groups.google.com) is part of Google Workspace, providing email-based group communication, collaboration, and access control for teams and organizations. Google Groups lets you create mailing lists, manage membership, and control permissions for both internal and external users.
This page explains how you can use Sim to automate the management of Google Groups in your workflows. With Sim, agents can create and configure groups, add or remove members, update group settings, and keep directory lists up-to-date automatically—ideal for onboarding workflows, syncing IT systems, or dynamically managing project teams.
With Google Groups, you can:
- **Centralize communications**: Create team or project mailing lists for group conversations
- **Manage group membership**: Add, remove, or update members with granular roles (owner, manager, member)
- **Control access**: Manage who can view, post, or join; set permissions for public/private visibility
- **Collaborate across teams**: Streamline communication and document sharing via group-based access
- **Automate IT tasks**: Use Sim to keep group memberships current as teams change
In Sim, the Google Groups integration gives your agents API-driven control to automate common administrative tasks. Connect directly to your Google Workspace domain to add users to groups, manage lists, audit group settings, and ensure your organizations access controls are always up-to-date—without manual overhead.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Connect to Google Workspace to create, update, and manage groups and their members using the Admin SDK Directory API.

View File

@@ -0,0 +1,450 @@
---
title: Google Maps
description: Geocoding, directions, places, and distance calculations
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_maps"
color="#E0E0E0"
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Maps](https://maps.google.com) is a comprehensive platform offering a wide array of APIs for mapping, geocoding, routing, places, environment data, and more. Through Sim, your agents can leverage key Google Maps Platform APIs to automate a variety of location-based workflows.
**The following Google Maps APIs are included in this integration:**
- **Geocoding API:** Convert addresses into latitude/longitude coordinates and perform reverse geocoding.
- **Directions API:** Calculate driving, walking, cycling, or transit directions and routes between locations.
- **Distance Matrix API:** Compute travel distances and times for multiple origin and destination combinations.
- **Places API:** Search for places (businesses, landmarks, establishments) by name, type, or proximity.
- **Place Details API:** Retrieve detailed information for a specific place, such as address, ratings, hours, and contact info.
- **Elevation API:** Obtain elevation data (height above sea level) for any set of locations globally.
- **Time Zone API:** Look up time zone information for any geographic location.
- **Air Quality API:** Fetch real-time air quality data for specific coordinates.
With these APIs, your Sim agents can automate location lookup and enrichment, plan optimal routes and deliveries, estimate times and distances, analyze place data, enrich records with geographic context, get environmental conditions, and more—all without manual work or external tools.
If you need capabilities beyond what's listed here or want to request support for additional Google Maps APIs, let us know!
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Google Maps Platform APIs into your workflow. Supports geocoding addresses to coordinates, reverse geocoding, getting directions between locations, calculating distance matrices, searching for places, retrieving place details, elevation data, and timezone information.
## Tools
### `google_maps_air_quality`
Get current air quality data for a location
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key with Air Quality API enabled |
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `languageCode` | string | No | Language code for the response \(e.g., "en", "es"\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `dateTime` | string | Timestamp of the air quality data |
| `regionCode` | string | Region code for the location |
| `indexes` | array | Array of air quality indexes |
| ↳ `code` | string | Index code \(e.g., "uaqi", "usa_epa"\) |
| ↳ `displayName` | string | Display name of the index |
| ↳ `aqi` | number | Air quality index value |
| ↳ `aqiDisplay` | string | Formatted AQI display string |
| ↳ `color` | object | RGB color for the AQI level |
| ↳ `category` | string | Category description \(e.g., "Good", "Moderate"\) |
| ↳ `dominantPollutant` | string | The dominant pollutant |
| `pollutants` | array | Array of pollutant concentrations |
| ↳ `code` | string | Pollutant code \(e.g., "pm25", "o3"\) |
| ↳ `displayName` | string | Display name |
| ↳ `fullName` | string | Full pollutant name |
| ↳ `concentration` | object | Concentration info |
| ↳ `value` | number | Concentration value |
| ↳ `units` | string | Units \(e.g., "PARTS_PER_BILLION"\) |
| ↳ `additionalInfo` | object | Additional info about sources and effects |
| `healthRecommendations` | object | Health recommendations for different populations |
### `google_maps_directions`
Get directions and route information between two locations
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `origin` | string | Yes | Starting location \(address or lat,lng\) |
| `destination` | string | Yes | Destination location \(address or lat,lng\) |
| `mode` | string | No | Travel mode: driving, walking, bicycling, or transit |
| `avoid` | string | No | Features to avoid: tolls, highways, or ferries |
| `waypoints` | json | No | Array of intermediate waypoints |
| `units` | string | No | Unit system: metric or imperial |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `routes` | array | All available routes |
| ↳ `summary` | string | Route summary \(main road names\) |
| ↳ `legs` | array | Route legs \(segments between waypoints\) |
| ↳ `overviewPolyline` | string | Encoded polyline for the entire route |
| ↳ `warnings` | array | Route warnings |
| ↳ `waypointOrder` | array | Optimized waypoint order \(if requested\) |
| `distanceText` | string | Total distance as human-readable text \(e.g., "5.2 km"\) |
| `distanceMeters` | number | Total distance in meters |
| `durationText` | string | Total duration as human-readable text \(e.g., "15 mins"\) |
| `durationSeconds` | number | Total duration in seconds |
| `startAddress` | string | Resolved starting address |
| `endAddress` | string | Resolved ending address |
| `steps` | array | Turn-by-turn navigation instructions |
| ↳ `instruction` | string | Navigation instruction \(HTML stripped\) |
| ↳ `distanceText` | string | Step distance as text |
| ↳ `distanceMeters` | number | Step distance in meters |
| ↳ `durationText` | string | Step duration as text |
| ↳ `durationSeconds` | number | Step duration in seconds |
| ↳ `startLocation` | object | Step start coordinates |
| ↳ `endLocation` | object | Step end coordinates |
| ↳ `travelMode` | string | Travel mode for this step |
| ↳ `maneuver` | string | Maneuver type \(turn-left, etc.\) |
| `polyline` | string | Encoded polyline for the primary route |
### `google_maps_distance_matrix`
Calculate travel distance and time between multiple origins and destinations
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `origin` | string | Yes | Origin location \(address or lat,lng\) |
| `destinations` | json | Yes | Array of destination locations |
| `mode` | string | No | Travel mode: driving, walking, bicycling, or transit |
| `avoid` | string | No | Features to avoid: tolls, highways, or ferries |
| `units` | string | No | Unit system: metric or imperial |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `originAddresses` | array | Resolved origin addresses |
| `destinationAddresses` | array | Resolved destination addresses |
| `rows` | array | Distance matrix rows \(one per origin\) |
| ↳ `elements` | array | Elements \(one per destination\) |
| ↳ `distanceText` | string | Distance as text \(e.g., "5.2 km"\) |
| ↳ `distanceMeters` | number | Distance in meters |
| ↳ `durationText` | string | Duration as text \(e.g., "15 mins"\) |
| ↳ `durationSeconds` | number | Duration in seconds |
| ↳ `durationInTrafficText` | string | Duration in traffic as text |
| ↳ `durationInTrafficSeconds` | number | Duration in traffic in seconds |
| ↳ `status` | string | Element status \(OK, NOT_FOUND, ZERO_RESULTS\) |
### `google_maps_elevation`
Get elevation data for a location
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `elevation` | number | Elevation in meters above sea level \(negative for below\) |
| `lat` | number | Latitude of the elevation sample |
| `lng` | number | Longitude of the elevation sample |
| `resolution` | number | Maximum distance between data points \(meters\) from which elevation was interpolated |
### `google_maps_geocode`
Convert an address into geographic coordinates (latitude and longitude)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `address` | string | Yes | The address to geocode |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formattedAddress` | string | The formatted address string |
| `lat` | number | Latitude coordinate |
| `lng` | number | Longitude coordinate |
| `location` | json | Location object with lat and lng |
| `placeId` | string | Google Place ID for this location |
| `addressComponents` | array | Detailed address components |
| ↳ `longName` | string | Full name of the component |
| ↳ `shortName` | string | Abbreviated name |
| ↳ `types` | array | Component types |
| `locationType` | string | Location accuracy type \(ROOFTOP, RANGE_INTERPOLATED, etc.\) |
### `google_maps_geolocate`
Geolocate a device using WiFi access points, cell towers, or IP address
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key with Geolocation API enabled |
| `homeMobileCountryCode` | number | No | Home mobile country code \(MCC\) |
| `homeMobileNetworkCode` | number | No | Home mobile network code \(MNC\) |
| `radioType` | string | No | Radio type: lte, gsm, cdma, wcdma, or nr |
| `carrier` | string | No | Carrier name |
| `considerIp` | boolean | No | Whether to use IP address for geolocation \(default: true\) |
| `cellTowers` | array | No | Array of cell tower objects with cellId, locationAreaCode, mobileCountryCode, mobileNetworkCode |
| `wifiAccessPoints` | array | No | Array of WiFi access point objects with macAddress \(required\), signalStrength, etc. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `lat` | number | Latitude coordinate |
| `lng` | number | Longitude coordinate |
| `accuracy` | number | Accuracy radius in meters |
### `google_maps_place_details`
Get detailed information about a specific place
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `placeId` | string | Yes | Google Place ID |
| `fields` | string | No | Comma-separated list of fields to return |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `placeId` | string | Google Place ID |
| `name` | string | Place name |
| `formattedAddress` | string | Formatted street address |
| `lat` | number | Latitude coordinate |
| `lng` | number | Longitude coordinate |
| `types` | array | Place types \(e.g., restaurant, cafe\) |
| `rating` | number | Average rating \(1.0 to 5.0\) |
| `userRatingsTotal` | number | Total number of user ratings |
| `priceLevel` | number | Price level \(0=Free, 1=Inexpensive, 2=Moderate, 3=Expensive, 4=Very Expensive\) |
| `website` | string | Place website URL |
| `phoneNumber` | string | Local formatted phone number |
| `internationalPhoneNumber` | string | International formatted phone number |
| `openNow` | boolean | Whether the place is currently open |
| `weekdayText` | array | Opening hours formatted by day of week |
| `reviews` | array | User reviews \(up to 5 most relevant\) |
| ↳ `authorName` | string | Reviewer name |
| ↳ `authorUrl` | string | Reviewer profile URL |
| ↳ `profilePhotoUrl` | string | Reviewer photo URL |
| ↳ `rating` | number | Rating given \(1-5\) |
| ↳ `text` | string | Review text |
| ↳ `time` | number | Review timestamp \(Unix epoch\) |
| ↳ `relativeTimeDescription` | string | Relative time \(e.g., "a month ago"\) |
| `photos` | array | Place photos |
| ↳ `photoReference` | string | Photo reference for Place Photos API |
| ↳ `height` | number | Photo height in pixels |
| ↳ `width` | number | Photo width in pixels |
| ↳ `htmlAttributions` | array | Required attributions |
| `url` | string | Google Maps URL for the place |
| `utcOffset` | number | UTC offset in minutes |
| `vicinity` | string | Simplified address \(neighborhood/street\) |
| `businessStatus` | string | Business status \(OPERATIONAL, CLOSED_TEMPORARILY, CLOSED_PERMANENTLY\) |
### `google_maps_places_search`
Search for places using a text query
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `query` | string | Yes | Search query \(e.g., "restaurants in Times Square"\) |
| `location` | json | No | Location to bias results towards \(\{lat, lng\}\) |
| `radius` | number | No | Search radius in meters |
| `type` | string | No | Place type filter \(e.g., restaurant, cafe, hotel\) |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
| `region` | string | No | Region bias as a ccTLD code \(e.g., us, uk\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `places` | array | List of places found |
| ↳ `placeId` | string | Google Place ID |
| ↳ `name` | string | Place name |
| ↳ `formattedAddress` | string | Formatted address |
| ↳ `lat` | number | Latitude |
| ↳ `lng` | number | Longitude |
| ↳ `types` | array | Place types |
| ↳ `rating` | number | Average rating \(1-5\) |
| ↳ `userRatingsTotal` | number | Number of ratings |
| ↳ `priceLevel` | number | Price level \(0-4\) |
| ↳ `openNow` | boolean | Whether currently open |
| ↳ `photoReference` | string | Photo reference for Photos API |
| ↳ `businessStatus` | string | Business status |
| `nextPageToken` | string | Token for fetching the next page of results |
### `google_maps_reverse_geocode`
Convert geographic coordinates (latitude and longitude) into a human-readable address
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `language` | string | No | Language code for results \(e.g., en, es, fr\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formattedAddress` | string | The formatted address string |
| `placeId` | string | Google Place ID for this location |
| `addressComponents` | array | Detailed address components |
| ↳ `longName` | string | Full name of the component |
| ↳ `shortName` | string | Abbreviated name |
| ↳ `types` | array | Component types |
| `types` | array | Address types \(e.g., street_address, route\) |
### `google_maps_snap_to_roads`
Snap GPS coordinates to the nearest road segment
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key with Roads API enabled |
| `path` | string | Yes | Pipe-separated list of lat,lng coordinates \(e.g., "60.170880,24.942795\|60.170879,24.942796"\) |
| `interpolate` | boolean | No | Whether to interpolate additional points along the road |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `snappedPoints` | array | Array of snapped points on roads |
| ↳ `location` | object | Snapped location coordinates |
| ↳ `lat` | number | Latitude |
| ↳ `lng` | number | Longitude |
| ↳ `originalIndex` | number | Index in the original path \(if not interpolated\) |
| ↳ `placeId` | string | Place ID for this road segment |
| `warningMessage` | string | Warning message if any \(e.g., if points could not be snapped\) |
### `google_maps_speed_limits`
Get speed limits for road segments
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key with Roads API enabled |
| `path` | string | No | Pipe-separated list of lat,lng coordinates \(e.g., "60.170880,24.942795\|60.170879,24.942796"\) |
| `placeIds` | array | No | Array of Place IDs for road segments \(alternative to path\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `speedLimits` | array | Array of speed limits for road segments |
| ↳ `placeId` | string | Place ID for the road segment |
| ↳ `speedLimit` | number | Speed limit value |
| ↳ `units` | string | Speed limit units \(KPH or MPH\) |
| `snappedPoints` | array | Array of snapped points corresponding to the speed limits |
| ↳ `location` | object | Snapped location coordinates |
| ↳ `lat` | number | Latitude |
| ↳ `lng` | number | Longitude |
| ↳ `originalIndex` | number | Index in the original path |
| ↳ `placeId` | string | Place ID for this road segment |
### `google_maps_timezone`
Get timezone information for a location
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key |
| `lat` | number | Yes | Latitude coordinate |
| `lng` | number | Yes | Longitude coordinate |
| `timestamp` | number | No | Unix timestamp to determine DST offset \(defaults to current time\) |
| `language` | string | No | Language code for timezone name \(e.g., en, es, fr\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `timeZoneId` | string | IANA timezone ID \(e.g., "America/New_York", "Europe/London"\) |
| `timeZoneName` | string | Localized timezone name \(e.g., "Eastern Daylight Time"\) |
| `rawOffset` | number | UTC offset in seconds \(without DST\) |
| `dstOffset` | number | Daylight Saving Time offset in seconds \(0 if not in DST\) |
| `totalOffsetSeconds` | number | Total UTC offset in seconds \(rawOffset + dstOffset\) |
| `totalOffsetHours` | number | Total UTC offset in hours \(e.g., -5 for EST, -4 for EDT\) |
### `google_maps_validate_address`
Validate and standardize a postal address
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Google Maps API key with Address Validation API enabled |
| `address` | string | Yes | The address to validate \(as a single string\) |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code \(e.g., "US", "CA"\) |
| `locality` | string | No | City or locality name |
| `enableUspsCass` | boolean | No | Enable USPS CASS validation for US addresses |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `formattedAddress` | string | The standardized formatted address |
| `lat` | number | Latitude coordinate |
| `lng` | number | Longitude coordinate |
| `placeId` | string | Google Place ID for this address |
| `addressComplete` | boolean | Whether the address is complete and deliverable |
| `hasUnconfirmedComponents` | boolean | Whether some address components could not be confirmed |
| `hasInferredComponents` | boolean | Whether some components were inferred \(not in input\) |
| `hasReplacedComponents` | boolean | Whether some components were replaced with canonical values |
| `validationGranularity` | string | Granularity of validation \(PREMISE, SUB_PREMISE, ROUTE, etc.\) |
| `geocodeGranularity` | string | Granularity of the geocode result |
| `addressComponents` | array | Detailed address components |
| ↳ `longName` | string | Full name of the component |
| ↳ `shortName` | string | Abbreviated name |
| ↳ `types` | array | Component types |
| `missingComponentTypes` | array | Types of address components that are missing |
| `unconfirmedComponentTypes` | array | Types of components that could not be confirmed |
| `unresolvedTokens` | array | Input tokens that could not be resolved |

View File

@@ -19,6 +19,7 @@
"datadog",
"discord",
"dropbox",
"dspy",
"duckduckgo",
"dynamodb",
"elasticsearch",
@@ -35,6 +36,7 @@
"google_drive",
"google_forms",
"google_groups",
"google_maps",
"google_search",
"google_sheets",
"google_slides",

View File

@@ -61,12 +61,12 @@ Get comprehensive website analytics including traffic, rankings, engagement, and
| ↳ `country` | string | Country code |
| ↳ `share` | number | Traffic share \(0-1\) |
| `trafficSources` | json | Traffic source breakdown |
| ↳ `direct` | number | Direct traffic share |
| ↳ `referrals` | number | Referral traffic share |
| ↳ `search` | number | Search traffic share |
| ↳ `social` | number | Social traffic share |
| ↳ `mail` | number | Email traffic share |
| ↳ `paidReferrals` | number | Paid referral traffic share |
| ↳ `direct` | number | Direct traffic share |
| ↳ `referrals` | number | Referral traffic share |
| ↳ `search` | number | Search traffic share |
| ↳ `social` | number | Social traffic share |
| ↳ `mail` | number | Email traffic share |
| ↳ `paidReferrals` | number | Paid referral traffic share |
### `similarweb_traffic_visits`

View File

@@ -217,7 +217,7 @@ Search for videos on YouTube using the YouTube Data API. Supports advanced filte
| ↳ `channelId` | string | Channel ID that uploaded the video |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `liveBroadcastContent` | string | Live broadcast status: |
| ↳ `liveBroadcastContent` | string | Live broadcast status: "none", "live", or "upcoming" |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
@@ -271,7 +271,7 @@ Get a list of video categories available on YouTube. Use this to discover valid
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of video categories available in the specified region |
| ↳ `categoryId` | string | Category ID to use in search/trending filters \(e.g., |
| ↳ `categoryId` | string | Category ID to use in search/trending filters \(e.g., "10" for Music\) |
| ↳ `title` | string | Human-readable category name |
| ↳ `assignable` | boolean | Whether videos can be tagged with this category |
| `totalResults` | number | Total number of categories available |
@@ -297,7 +297,7 @@ Get detailed information about a specific YouTube video including statistics, co
| `channelId` | string | Channel ID |
| `channelTitle` | string | Channel name |
| `publishedAt` | string | Published date and time |
| `duration` | string | Video duration in ISO 8601 format \(e.g., |
| `duration` | string | Video duration in ISO 8601 format \(e.g., "PT4M13S" for 4 min 13 sec\) |
| `viewCount` | number | Number of views |
| `likeCount` | number | Number of likes |
| `commentCount` | number | Number of comments |
@@ -305,11 +305,11 @@ Get detailed information about a specific YouTube video including statistics, co
| `thumbnail` | string | Video thumbnail URL |
| `tags` | array | Video tags |
| `categoryId` | string | YouTube video category ID |
| `definition` | string | Video definition: |
| `caption` | string | Whether captions are available: |
| `definition` | string | Video definition: "hd" or "sd" |
| `caption` | string | Whether captions are available: "true" or "false" |
| `licensedContent` | boolean | Whether the video is licensed content |
| `privacyStatus` | string | Video privacy status: |
| `liveBroadcastContent` | string | Live broadcast status: |
| `privacyStatus` | string | Video privacy status: "public", "private", or "unlisted" |
| `liveBroadcastContent` | string | Live broadcast status: "live", "upcoming", or "none" |
| `defaultLanguage` | string | Default language of the video metadata |
| `defaultAudioLanguage` | string | Default audio language of the video |
| `isLiveContent` | boolean | Whether this video is or was a live stream |

View File

@@ -264,7 +264,7 @@ async function handleToolsCall(
method: 'POST',
headers,
body: JSON.stringify({ input: params.arguments || {}, triggerType: 'mcp' }),
signal: AbortSignal.timeout(300000), // 5 minute timeout
signal: AbortSignal.timeout(600000), // 10 minute timeout
})
const executeResult = await response.json()

View File

@@ -32,8 +32,7 @@ import {
useTestNotification,
useUpdateNotification,
} from '@/hooks/queries/notifications'
import { useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { useSlackAccounts } from '@/hooks/use-slack-accounts'
import { useConnectedAccounts, useConnectOAuthService } from '@/hooks/queries/oauth-connections'
import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types'
import { SlackChannelSelector } from './components/slack-channel-selector'
import { WorkflowSelector } from './components/workflow-selector'
@@ -167,7 +166,8 @@ export function NotificationSettings({
const deleteNotification = useDeleteNotification()
const testNotification = useTestNotification()
const { accounts: slackAccounts, isLoading: isLoadingSlackAccounts } = useSlackAccounts()
const { data: slackAccounts = [], isLoading: isLoadingSlackAccounts } =
useConnectedAccounts('slack')
const connectSlack = useConnectOAuthService()
useEffect(() => {
@@ -530,7 +530,7 @@ export function NotificationSettings({
message:
result.data?.error || (result.data?.success ? 'Test sent successfully' : 'Test failed'),
})
} catch (error) {
} catch (_error) {
setTestStatus({ id, success: false, message: 'Failed to send test' })
}
}

View File

@@ -1,32 +1,28 @@
'use client'
import type React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import {
useWorkspacePermissions,
useWorkspacePermissionsQuery,
type WorkspacePermissions,
} from '@/hooks/use-workspace-permissions'
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useUserPermissions, type WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useNotificationStore } from '@/stores/notifications'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
const logger = createLogger('WorkspacePermissionsProvider')
interface WorkspacePermissionsContextType {
// Raw workspace permissions data
workspacePermissions: WorkspacePermissions | null
permissionsLoading: boolean
permissionsError: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetchPermissions: () => Promise<void>
// Computed user permissions (connection-aware)
userPermissions: WorkspaceUserPermissions & { isOfflineMode?: boolean }
// Connection state management
setOfflineMode: (isOffline: boolean) => void
}
const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextType>({
@@ -43,7 +39,6 @@ const WorkspacePermissionsContext = createContext<WorkspacePermissionsContextTyp
isLoading: false,
error: null,
},
setOfflineMode: () => {},
})
interface WorkspacePermissionsProviderProps {
@@ -51,35 +46,20 @@ interface WorkspacePermissionsProviderProps {
}
/**
* Provider that manages workspace permissions and user access
* Also provides connection-aware permissions that enforce read-only mode when offline
* Provides workspace permissions and connection-aware user access throughout the app.
* Enforces read-only mode when offline to prevent data loss.
*/
export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsProviderProps) {
const params = useParams()
const workspaceId = params?.workspaceId as string
const queryClient = useQueryClient()
// Manage offline mode state locally
const [isOfflineMode, setIsOfflineMode] = useState(false)
// Track whether we've already surfaced an offline notification to avoid duplicates
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
// Get operation error state directly from the store (avoid full useCollaborativeWorkflow subscription)
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
const addNotification = useNotificationStore((state) => state.addNotification)
// Set offline mode when there are operation errors
useEffect(() => {
if (hasOperationError) {
setIsOfflineMode(true)
}
}, [hasOperationError])
const isOfflineMode = hasOperationError
/**
* Surface a global notification when entering offline mode.
* Uses the shared notifications system instead of bespoke UI in individual components.
*/
useEffect(() => {
if (!isOfflineMode || hasShownOfflineNotification) {
return
@@ -89,7 +69,6 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
addNotification({
level: 'error',
message: 'Connection unavailable',
// Global notification (no workflowId) so it is visible regardless of the active workflow
action: {
type: 'refresh',
message: '',
@@ -101,40 +80,44 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
}
}, [addNotification, hasShownOfflineNotification, isOfflineMode])
// Fetch workspace permissions and loading state
const {
permissions: workspacePermissions,
loading: permissionsLoading,
error: permissionsError,
updatePermissions,
refetch: refetchPermissions,
} = useWorkspacePermissions(workspaceId)
data: workspacePermissions,
isLoading: permissionsLoading,
error: permissionsErrorObj,
refetch,
} = useWorkspacePermissionsQuery(workspaceId)
const permissionsError = permissionsErrorObj?.message ?? null
const updatePermissions = useCallback(
(newPermissions: WorkspacePermissions) => {
if (!workspaceId) return
queryClient.setQueryData(workspaceKeys.permissions(workspaceId), newPermissions)
},
[workspaceId, queryClient]
)
const refetchPermissions = useCallback(async () => {
await refetch()
}, [refetch])
// Get base user permissions from workspace permissions
const baseUserPermissions = useUserPermissions(
workspacePermissions,
workspacePermissions ?? null,
permissionsLoading,
permissionsError
)
// Note: Connection-based error detection removed - only rely on operation timeouts
// The 5-second operation timeout system will handle all error cases
// Create connection-aware permissions that override user permissions when offline
const userPermissions = useMemo((): WorkspaceUserPermissions & { isOfflineMode?: boolean } => {
if (isOfflineMode) {
// In offline mode, force read-only permissions regardless of actual user permissions
return {
...baseUserPermissions,
canEdit: false,
canAdmin: false,
// Keep canRead true so users can still view content
canRead: baseUserPermissions.canRead,
isOfflineMode: true,
}
}
// When online, use normal permissions
return {
...baseUserPermissions,
isOfflineMode: false,
@@ -143,13 +126,12 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
const contextValue = useMemo(
() => ({
workspacePermissions,
workspacePermissions: workspacePermissions ?? null,
permissionsLoading,
permissionsError,
updatePermissions,
refetchPermissions,
userPermissions,
setOfflineMode: setIsOfflineMode,
}),
[
workspacePermissions,
@@ -169,8 +151,8 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
}
/**
* Hook to access workspace permissions and data from context
* This provides both raw workspace permissions and computed user permissions
* Accesses workspace permissions data and operations from context.
* Must be used within a WorkspacePermissionsProvider.
*/
export function useWorkspacePermissionsContext(): WorkspacePermissionsContextType {
const context = useContext(WorkspacePermissionsContext)
@@ -183,8 +165,8 @@ export function useWorkspacePermissionsContext(): WorkspacePermissionsContextTyp
}
/**
* Hook to access user permissions from context
* This replaces individual useUserPermissions calls and includes connection-aware permissions
* Accesses the current user's computed permissions including offline mode status.
* Convenience hook that extracts userPermissions from the context.
*/
export function useUserPermissionsContext(): WorkspaceUserPermissions & {
isOfflineMode?: boolean

View File

@@ -21,14 +21,13 @@ import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
useDeleteWorkspaceFile,
useStorageInfo,
useUploadWorkspaceFile,
useWorkspaceFiles,
} from '@/hooks/queries/workspace-files'
import { useUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
const logger = createLogger('FileUploadsSettings')
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
@@ -94,9 +93,7 @@ export function Files() {
const fileInputRef = useRef<HTMLInputElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { permissions: workspacePermissions, loading: permissionsLoading } =
useWorkspacePermissions(workspaceId)
const userPermissions = useUserPermissions(workspacePermissions, permissionsLoading)
const { userPermissions, permissionsLoading } = useWorkspacePermissionsContext()
const handleUploadClick = () => {
fileInputRef.current?.click()

View File

@@ -1,23 +0,0 @@
import React from 'react'
import { Skeleton } from '@/components/ui/skeleton'
export const PermissionsTableSkeleton = React.memo(() => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>
</div>
))
PermissionsTableSkeleton.displayName = 'PermissionsTableSkeleton'

View File

@@ -1,20 +1,39 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, RotateCw, X } from 'lucide-react'
import { Badge, Button, Tooltip } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import { useSession } from '@/lib/auth/auth-client'
import type { PermissionType } from '@/lib/workspaces/permissions/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions'
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
import { PermissionSelector } from './permission-selector'
import { PermissionsTableSkeleton } from './permissions-table-skeleton'
import type { UserPermissions } from './types'
const PermissionsTableSkeleton = () => (
<div className='scrollbar-hide max-h-[300px] overflow-y-auto'>
<div className='flex items-center justify-between gap-[8px] py-[8px]'>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[14px] w-40 rounded-[4px]' />
</div>
</div>
<div className='flex flex-shrink-0 items-center'>
<div className='inline-flex gap-[2px]'>
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
<Skeleton className='h-[28px] w-[44px] rounded-[5px]' />
</div>
</div>
</div>
</div>
)
export interface PermissionsTableProps {
userPermissions: UserPermissions[]
onPermissionChange: (userId: string, permissionType: PermissionType) => void
onRemoveMember?: (userId: string, email: string) => void
onRemoveInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string, email: string) => void
onResendInvitation?: (invitationId: string) => void
disabled?: boolean
existingUserPermissionChanges: Record<string, Partial<UserPermissions>>
isSaving?: boolean
@@ -143,7 +162,6 @@ export const PermissionsTable = ({
<div>
{allUsers.map((user) => {
const isCurrentUser = user.isCurrentUser === true
const isExistingUser = filteredExistingUsers.some((eu) => eu.email === user.email)
const isPendingInvitation = user.isPendingInvitation === true
const userIdentifier = user.userId || user.email
const originalPermission = workspacePermissions?.users?.find(
@@ -205,7 +223,7 @@ export const PermissionsTable = ({
<span className='inline-flex'>
<Button
variant='ghost'
onClick={() => onResendInvitation(user.invitationId!, user.email)}
onClick={() => onResendInvitation(user.invitationId!)}
disabled={
disabled ||
isSaving ||

View File

@@ -1,5 +1,4 @@
export { PermissionSelector } from './components/permission-selector'
export { PermissionsTable } from './components/permissions-table'
export { PermissionsTableSkeleton } from './components/permissions-table-skeleton'
export type { PermissionType, UserPermissions } from './components/types'
export { InviteModal } from './invite-modal'

View File

@@ -19,7 +19,14 @@ import { useSession } from '@/lib/auth/auth-client'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { PermissionsTable } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/components/permissions-table'
import { API_ENDPOINTS } from '@/stores/constants'
import {
useBatchSendWorkspaceInvitations,
useCancelWorkspaceInvitation,
usePendingInvitations,
useRemoveWorkspaceMember,
useResendWorkspaceInvitation,
useUpdateWorkspacePermissions,
} from '@/hooks/queries/invitations'
import type { PermissionType, UserPermissions } from './components/types'
const logger = createLogger('InviteModal')
@@ -30,40 +37,25 @@ interface InviteModalProps {
workspaceName?: string
}
interface PendingInvitation {
id: string
workspaceId: string
email: string
permissions: PermissionType
status: string
createdAt: string
}
export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalProps) {
const formRef = useRef<HTMLFormElement>(null)
const [emailItems, setEmailItems] = useState<TagItem[]>([])
const [userPermissions, setUserPermissions] = useState<UserPermissions[]>([])
const [pendingInvitations, setPendingInvitations] = useState<UserPermissions[]>([])
const [isPendingInvitationsLoading, setIsPendingInvitationsLoading] = useState(false)
const [existingUserPermissionChanges, setExistingUserPermissionChanges] = useState<
Record<string, Partial<UserPermissions>>
>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const cooldownIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map())
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [memberToRemove, setMemberToRemove] = useState<{ userId: string; email: string } | null>(
null
)
const [isRemovingMember, setIsRemovingMember] = useState(false)
const [invitationToRemove, setInvitationToRemove] = useState<{
invitationId: string
email: string
} | null>(null)
const [isRemovingInvitation, setIsRemovingInvitation] = useState(false)
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const [resendCooldowns, setResendCooldowns] = useState<Record<string, number>>({})
const [resentInvitationIds, setResentInvitationIds] = useState<Record<string, boolean>>({})
const [resendingInvitationIds, setResendingInvitationIds] = useState<Record<string, boolean>>({})
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -72,50 +64,26 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
workspacePermissions,
permissionsLoading,
updatePermissions,
refetchPermissions,
userPermissions: userPerms,
} = useWorkspacePermissionsContext()
const { data: pendingInvitations = [], isLoading: isPendingInvitationsLoading } =
usePendingInvitations(open ? workspaceId : undefined)
const batchSendInvitations = useBatchSendWorkspaceInvitations()
const cancelInvitation = useCancelWorkspaceInvitation()
const resendInvitation = useResendWorkspaceInvitation()
const removeMember = useRemoveWorkspaceMember()
const updatePermissionsMutation = useUpdateWorkspacePermissions()
const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0
const validEmails = emailItems.filter((item) => item.isValid).map((item) => item.value)
const hasNewInvites = validEmails.length > 0
const fetchPendingInvitations = useCallback(async () => {
if (!workspaceId) return
setIsPendingInvitationsLoading(true)
try {
const response = await fetch('/api/workspaces/invitations')
if (response.ok) {
const data = await response.json()
const workspacePendingInvitations =
data.invitations
?.filter(
(inv: PendingInvitation) =>
inv.status === 'pending' && inv.workspaceId === workspaceId
)
.map((inv: PendingInvitation) => ({
email: inv.email,
permissionType: inv.permissions,
isPendingInvitation: true,
invitationId: inv.id,
})) || []
setPendingInvitations(workspacePendingInvitations)
}
} catch (error) {
logger.error('Error fetching pending invitations:', error)
} finally {
setIsPendingInvitationsLoading(false)
}
}, [workspaceId])
useEffect(() => {
if (open && workspaceId) {
fetchPendingInvitations()
refetchPermissions()
}
}, [open, workspaceId, fetchPendingInvitations, refetchPermissions])
const isSubmitting = batchSendInvitations.isPending
const isSaving = updatePermissionsMutation.isPending
const isRemovingMember = removeMember.isPending
const isRemovingInvitation = cancelInvitation.isPending
useEffect(() => {
if (open) {
@@ -180,16 +148,12 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[emailItems, pendingInvitations, workspacePermissions?.users, session?.user?.email]
)
const removeEmailItem = useCallback(
(_value: string, index: number, isValid?: boolean) => {
const itemToRemove = emailItems[index]
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid ?? itemToRemove?.isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== itemToRemove?.value))
}
},
[emailItems]
)
const removeEmailItem = useCallback((value: string, index: number, isValid?: boolean) => {
setEmailItems((prev) => prev.filter((_, i) => i !== index))
if (isValid) {
setUserPermissions((prev) => prev.filter((user) => user.email !== value))
}
}, [])
const fileInputOptions: FileInputOptions = useMemo(
() => ({
@@ -198,7 +162,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
extractValues: (text: string) => {
const emailRegex = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g
const matches = text.match(emailRegex) || []
return [...new Set(matches.map((e) => e.toLowerCase()))]
const uniqueEmails = [...new Set(matches.map((e) => e.toLowerCase()))]
return uniqueEmails.filter((email) => quickValidateEmail(email).isValid)
},
tooltip: 'Upload emails',
}),
@@ -230,53 +195,38 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
[workspacePermissions?.users]
)
const handleSaveChanges = useCallback(async () => {
const handleSaveChanges = useCallback(() => {
if (!userPerms.canAdmin || !hasPendingChanges || !workspaceId) return
setIsSaving(true)
setErrorMessage(null)
try {
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
userId,
permissions: changes.permissionType || 'read',
}))
const updates = Object.entries(existingUserPermissionChanges).map(([userId, changes]) => ({
userId,
permissions: (changes.permissionType || 'read') as 'admin' | 'write' | 'read',
}))
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(workspaceId), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
updatePermissionsMutation.mutate(
{ workspaceId, updates },
{
onSuccess: (data) => {
if (data.users && data.total !== undefined) {
updatePermissions({ users: data.users, total: data.total })
}
setExistingUserPermissionChanges({})
},
onError: (error) => {
logger.error('Error saving permission changes:', error)
setErrorMessage(error.message || 'Failed to save permission changes. Please try again.')
},
body: JSON.stringify({ updates }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to update permissions')
}
if (data.users && data.total !== undefined) {
updatePermissions({ users: data.users, total: data.total })
}
setExistingUserPermissionChanges({})
} catch (error) {
logger.error('Error saving permission changes:', error)
const errorMsg =
error instanceof Error
? error.message
: 'Failed to save permission changes. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsSaving(false)
}
)
}, [
userPerms.canAdmin,
hasPendingChanges,
workspaceId,
existingUserPermissionChanges,
updatePermissions,
updatePermissionsMutation,
])
const handleRestoreChanges = useCallback(() => {
@@ -289,62 +239,57 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setMemberToRemove({ userId, email })
}, [])
const handleRemoveMemberConfirm = useCallback(async () => {
const handleRemoveMemberConfirm = useCallback(() => {
if (!memberToRemove || !workspaceId || !userPerms.canAdmin) return
setIsRemovingMember(true)
setErrorMessage(null)
try {
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
const userRecord = workspacePermissions?.users?.find(
(user) => user.userId === memberToRemove.userId
)
if (!userRecord) {
throw new Error('User is not a member of this workspace')
}
const response = await fetch(`/api/workspaces/members/${memberToRemove.userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to remove member')
}
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
)
updatePermissions({
users: updatedUsers,
total: workspacePermissions.total - 1,
})
}
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
return updated
})
} catch (error) {
logger.error('Error removing member:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to remove member. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsRemovingMember(false)
if (!userRecord) {
setErrorMessage('User is not a member of this workspace')
setMemberToRemove(null)
return
}
}, [memberToRemove, workspaceId, userPerms.canAdmin, workspacePermissions, updatePermissions])
removeMember.mutate(
{ userId: memberToRemove.userId, workspaceId },
{
onSuccess: () => {
if (workspacePermissions) {
const updatedUsers = workspacePermissions.users.filter(
(user) => user.userId !== memberToRemove.userId
)
updatePermissions({
users: updatedUsers,
total: workspacePermissions.total - 1,
})
}
setExistingUserPermissionChanges((prev) => {
const updated = { ...prev }
delete updated[memberToRemove.userId]
return updated
})
setMemberToRemove(null)
},
onError: (error) => {
logger.error('Error removing member:', error)
setErrorMessage(error.message || 'Failed to remove member. Please try again.')
setMemberToRemove(null)
},
}
)
}, [
memberToRemove,
workspaceId,
userPerms.canAdmin,
workspacePermissions,
updatePermissions,
removeMember,
])
const handleRemoveMemberCancel = useCallback(() => {
setMemberToRemove(null)
@@ -354,120 +299,101 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
setInvitationToRemove({ invitationId, email })
}, [])
const handleRemoveInvitationConfirm = useCallback(async () => {
const handleRemoveInvitationConfirm = useCallback(() => {
if (!invitationToRemove || !workspaceId || !userPerms.canAdmin) return
setIsRemovingInvitation(true)
setErrorMessage(null)
try {
const response = await fetch(
`/api/workspaces/invitations/${invitationToRemove.invitationId}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
}
)
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to cancel invitation')
cancelInvitation.mutate(
{ invitationId: invitationToRemove.invitationId, workspaceId },
{
onSuccess: () => {
setInvitationToRemove(null)
},
onError: (error) => {
logger.error('Error cancelling invitation:', error)
setErrorMessage(error.message || 'Failed to cancel invitation. Please try again.')
setInvitationToRemove(null)
},
}
setPendingInvitations((prev) =>
prev.filter((inv) => inv.invitationId !== invitationToRemove.invitationId)
)
} catch (error) {
logger.error('Error cancelling invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to cancel invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setIsRemovingInvitation(false)
setInvitationToRemove(null)
}
}, [invitationToRemove, workspaceId, userPerms.canAdmin])
)
}, [invitationToRemove, workspaceId, userPerms.canAdmin, cancelInvitation])
const handleRemoveInvitationCancel = useCallback(() => {
setInvitationToRemove(null)
}, [])
const handleResendInvitation = useCallback(
async (invitationId: string, email: string) => {
(invitationId: string) => {
if (!workspaceId || !userPerms.canAdmin) return
const secondsLeft = resendCooldowns[invitationId]
if (secondsLeft && secondsLeft > 0) return
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
if (resendingInvitationIds[invitationId]) return
setErrorMessage(null)
setResendingInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
try {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Failed to resend invitation')
}
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
} catch (error) {
logger.error('Error resending invitation:', error)
const errorMsg =
error instanceof Error ? error.message : 'Failed to resend invitation. Please try again.'
setErrorMessage(errorMsg)
} finally {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
if (existingInterval) {
clearInterval(existingInterval)
}
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
resendInvitation.mutate(
{ invitationId, workspaceId },
{
onSuccess: () => {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
cooldownIntervalsRef.current.delete(invitationId)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
})
setResentInvitationIds((prev) => ({ ...prev, [invitationId]: true }))
setTimeout(() => {
setResentInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
}, 4000)
cooldownIntervalsRef.current.set(invitationId, interval)
}
setResendCooldowns((prev) => ({ ...prev, [invitationId]: 60 }))
const existingInterval = cooldownIntervalsRef.current.get(invitationId)
if (existingInterval) {
clearInterval(existingInterval)
}
const interval = setInterval(() => {
setResendCooldowns((prev) => {
const current = prev[invitationId]
if (current === undefined) return prev
if (current <= 1) {
const next = { ...prev }
delete next[invitationId]
clearInterval(interval)
cooldownIntervalsRef.current.delete(invitationId)
return next
}
return { ...prev, [invitationId]: current - 1 }
})
}, 1000)
cooldownIntervalsRef.current.set(invitationId, interval)
},
onError: (error) => {
setResendingInvitationIds((prev) => {
const next = { ...prev }
delete next[invitationId]
return next
})
logger.error('Error resending invitation:', error)
setErrorMessage(error.message || 'Failed to resend invitation. Please try again.')
},
}
)
},
[workspaceId, userPerms.canAdmin, resendCooldowns]
[workspaceId, userPerms.canAdmin, resendCooldowns, resendingInvitationIds, resendInvitation]
)
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
(e: React.FormEvent) => {
e.preventDefault()
setErrorMessage(null)
@@ -476,122 +402,65 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
return
}
setIsSubmitting(true)
try {
const failedInvites: string[] = []
const results = await Promise.all(
validEmails.map(async (email) => {
try {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
const response = await fetch('/api/workspaces/invitations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId,
email: email,
role: 'member',
permission: permissionType,
}),
})
const data = await response.json()
if (!response.ok) {
failedInvites.push(email)
if (data.error) {
setErrorMessage(data.error)
}
return false
}
return true
} catch {
failedInvites.push(email)
return false
}
})
)
const successCount = results.filter(Boolean).length
const successfulEmails = validEmails.filter((_, index) => results[index])
if (successCount > 0) {
if (successfulEmails.length > 0) {
const newPendingInvitations: UserPermissions[] = successfulEmails.map((email) => {
const userPermission = userPermissions.find((up) => up.email === email)
const permissionType = userPermission?.permissionType || 'read'
return {
email,
permissionType,
isPendingInvitation: true,
}
})
setPendingInvitations((prev) => {
const existingEmails = new Set(prev.map((inv) => inv.email))
const merged = [...prev]
newPendingInvitations.forEach((inv) => {
if (!existingEmails.has(inv.email)) {
merged.push(inv)
}
})
return merged
})
}
fetchPendingInvitations()
if (failedInvites.length > 0) {
setEmailItems(failedInvites.map((email) => ({ value: email, isValid: true })))
setUserPermissions((prev) => prev.filter((user) => failedInvites.includes(user.email)))
} else {
setEmailItems([])
setUserPermissions([])
}
const invitations = validEmails.map((email) => {
const userPermission = userPermissions.find((up) => up.email === email)
return {
email,
permission: (userPermission?.permissionType || 'read') as 'admin' | 'write' | 'read',
}
} catch (err) {
logger.error('Error inviting members:', err)
const errorMessage =
err instanceof Error ? err.message : 'An unexpected error occurred. Please try again.'
setErrorMessage(errorMessage)
} finally {
setIsSubmitting(false)
}
})
batchSendInvitations.mutate(
{ workspaceId, invitations },
{
onSuccess: (result) => {
if (result.failed.length > 0) {
setEmailItems(result.failed.map((f) => ({ value: f.email, isValid: true })))
setUserPermissions((prev) =>
prev.filter((user) => result.failed.some((f) => f.email === user.email))
)
setErrorMessage(result.failed[0].error)
} else {
setEmailItems([])
setUserPermissions([])
}
},
onError: (error) => {
logger.error('Error inviting members:', error)
setErrorMessage(error.message || 'An unexpected error occurred. Please try again.')
},
}
)
},
[validEmails, workspaceId, userPermissions, fetchPendingInvitations]
[validEmails, workspaceId, userPermissions, batchSendInvitations]
)
const resetState = useCallback(() => {
setEmailItems([])
setUserPermissions([])
setPendingInvitations([])
setIsPendingInvitationsLoading(false)
setExistingUserPermissionChanges({})
setIsSubmitting(false)
setIsSaving(false)
setErrorMessage(null)
setMemberToRemove(null)
setIsRemovingMember(false)
setInvitationToRemove(null)
setIsRemovingInvitation(false)
setResendCooldowns({})
setResentInvitationIds({})
setResendingInvitationIds({})
cooldownIntervalsRef.current.forEach((interval) => clearInterval(interval))
cooldownIntervalsRef.current.clear()
}, [])
const pendingInvitationsForTable: UserPermissions[] = useMemo(
() =>
pendingInvitations.map((inv) => ({
email: inv.email,
permissionType: inv.permissionType,
isPendingInvitation: true,
invitationId: inv.invitationId,
})),
[pendingInvitations]
)
return (
<Modal
open={open}
@@ -681,7 +550,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
isSaving={isSaving}
workspacePermissions={workspacePermissions}
permissionsLoading={permissionsLoading}
pendingInvitations={pendingInvitations}
pendingInvitations={pendingInvitationsForTable}
isPendingInvitationsLoading={isPendingInvitationsLoading}
resendingInvitationIds={resendingInvitationIds}
resentInvitationIds={resentInvitationIds}
@@ -691,26 +560,29 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
</ModalBody>
<ModalFooter className='justify-between'>
{hasPendingChanges && userPerms.canAdmin && (
<div className='flex gap-[8px]'>
<Button
type='button'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
>
Restore Changes
</Button>
<Button
type='button'
variant='tertiary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
)}
<div
className={`flex gap-[8px] ${hasPendingChanges && userPerms.canAdmin ? '' : 'pointer-events-none invisible'}`}
aria-hidden={!(hasPendingChanges && userPerms.canAdmin)}
>
<Button
type='button'
variant='default'
disabled={isSaving || isSubmitting}
onClick={handleRestoreChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
>
Restore Changes
</Button>
<Button
type='button'
variant='tertiary'
disabled={isSaving || isSubmitting}
onClick={handleSaveChanges}
tabIndex={hasPendingChanges && userPerms.canAdmin ? 0 : -1}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
<Button
type='button'

View File

@@ -14,4 +14,4 @@ export {
export { useSidebarResize } from './use-sidebar-resize'
export { useWorkflowOperations } from './use-workflow-operations'
export { useWorkflowSelection } from './use-workflow-selection'
export { useWorkspaceManagement } from './use-workspace-management'
export { useWorkspaceManagement, type Workspace } from './use-workspace-management'

View File

@@ -1,31 +1,33 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { usePathname, useRouter } from 'next/navigation'
import { generateWorkspaceName } from '@/lib/workspaces/naming'
import { useLeaveWorkspace } from '@/hooks/queries/invitations'
import {
useCreateWorkspace,
useDeleteWorkspace,
useUpdateWorkspaceName,
useWorkspacesQuery,
type Workspace,
workspaceKeys,
} from '@/hooks/queries/workspace'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('useWorkspaceManagement')
interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
interface UseWorkspaceManagementProps {
workspaceId: string
sessionUserId?: string
}
/**
* Custom hook to manage workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Manages workspace operations including fetching, switching, creating, deleting, and leaving workspaces.
* Handles workspace validation and URL synchronization.
*
* @param props - Configuration object containing workspaceId and sessionUserId
* @returns Workspace management state and operations
* @param props.workspaceId - The current workspace ID from the URL
* @param props.sessionUserId - The current user's session ID
* @returns Workspace state and operations
*/
export function useWorkspaceManagement({
workspaceId,
@@ -33,140 +35,68 @@ export function useWorkspaceManagement({
}: UseWorkspaceManagementProps) {
const router = useRouter()
const pathname = usePathname()
const queryClient = useQueryClient()
const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace)
// Workspace management state
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
const [activeWorkspace, setActiveWorkspace] = useState<Workspace | null>(null)
const [isWorkspacesLoading, setIsWorkspacesLoading] = useState(true)
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const {
data: workspaces = [],
isLoading: isWorkspacesLoading,
refetch: refetchWorkspaces,
} = useWorkspacesQuery(Boolean(sessionUserId))
const leaveWorkspaceMutation = useLeaveWorkspace()
const createWorkspaceMutation = useCreateWorkspace()
const deleteWorkspaceMutation = useDeleteWorkspace()
const updateWorkspaceNameMutation = useUpdateWorkspaceName()
// Refs to avoid dependency issues
const workspaceIdRef = useRef<string>(workspaceId)
const routerRef = useRef<ReturnType<typeof useRouter>>(router)
const pathnameRef = useRef<string | null>(pathname || null)
const activeWorkspaceRef = useRef<Workspace | null>(null)
const isInitializedRef = useRef<boolean>(false)
const hasValidatedRef = useRef<boolean>(false)
// Update refs when values change
workspaceIdRef.current = workspaceId
routerRef.current = router
pathnameRef.current = pathname || null
const activeWorkspace = useMemo(() => {
if (!workspaces.length) return null
return workspaces.find((w) => w.id === workspaceId) ?? null
}, [workspaces, workspaceId])
const activeWorkspaceRef = useRef<Workspace | null>(activeWorkspace)
activeWorkspaceRef.current = activeWorkspace
/**
* Refresh workspace list without validation logic - used for non-current workspace operations
*/
const refreshWorkspaceList = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Only update activeWorkspace if it still exists in the fetched workspaces
// Use functional update to avoid dependency on activeWorkspace
setActiveWorkspace((currentActive) => {
if (!currentActive) {
return currentActive
}
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentActive.id
)
if (matchingWorkspace) {
return matchingWorkspace
}
// Active workspace was deleted, clear it
logger.warn(`Active workspace ${currentActive.id} no longer exists`)
return null
})
}
} catch (err) {
logger.error('Error refreshing workspace list:', err)
} finally {
setIsWorkspacesLoading(false)
useEffect(() => {
if (isWorkspacesLoading || hasValidatedRef.current || !workspaces.length) {
return
}
}, [])
const currentWorkspaceId = workspaceIdRef.current
const matchingWorkspace = workspaces.find((w) => w.id === currentWorkspaceId)
if (!matchingWorkspace) {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
const fallbackWorkspace = workspaces[0]
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
routerRef.current?.push(`/workspace/${fallbackWorkspace.id}/w`)
}
hasValidatedRef.current = true
}, [workspaces, isWorkspacesLoading])
const refreshWorkspaceList = useCallback(async () => {
await queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
}, [queryClient])
const fetchWorkspaces = useCallback(async () => {
setIsWorkspacesLoading(true)
try {
const response = await fetch('/api/workspaces')
const data = await response.json()
hasValidatedRef.current = false
await refetchWorkspaces()
}, [refetchWorkspaces])
if (data.workspaces && Array.isArray(data.workspaces)) {
const fetchedWorkspaces = data.workspaces as Workspace[]
setWorkspaces(fetchedWorkspaces)
// Handle active workspace selection with URL validation using refs
const currentWorkspaceId = workspaceIdRef.current
const currentRouter = routerRef.current
if (currentWorkspaceId) {
const matchingWorkspace = fetchedWorkspaces.find(
(workspace) => workspace.id === currentWorkspaceId
)
if (matchingWorkspace) {
setActiveWorkspace(matchingWorkspace)
} else {
logger.warn(`Workspace ${currentWorkspaceId} not found in user's workspaces`)
// Fallback to first workspace if current not found
if (fetchedWorkspaces.length > 0) {
const fallbackWorkspace = fetchedWorkspaces[0]
setActiveWorkspace(fallbackWorkspace)
// Update URL to match the fallback workspace
logger.info(`Redirecting to fallback workspace: ${fallbackWorkspace.id}`)
currentRouter?.push(`/workspace/${fallbackWorkspace.id}/w`)
} else {
logger.error('No workspaces available for user')
}
}
}
}
} catch (err) {
logger.error('Error fetching workspaces:', err)
} finally {
setIsWorkspacesLoading(false)
}
}, [])
/**
* Update workspace name both in API and local state
*/
const updateWorkspaceName = useCallback(
async (workspaceId: string, newName: string): Promise<boolean> => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
// Update local state immediately after successful API call
// Only update activeWorkspace if it's the one being renamed
setActiveWorkspace((prev) =>
prev && prev.id === workspaceId ? { ...prev, name: newName.trim() } : prev
)
setWorkspaces((prev) =>
prev.map((workspace) =>
workspace.id === workspaceId ? { ...workspace, name: newName.trim() } : workspace
)
)
await updateWorkspaceNameMutation.mutateAsync({ workspaceId, name: newName })
logger.info('Successfully updated workspace name to:', newName.trim())
return true
} catch (error) {
@@ -174,21 +104,18 @@ export function useWorkspaceManagement({
return false
}
},
[]
[updateWorkspaceNameMutation]
)
const switchWorkspace = useCallback(
async (workspace: Workspace) => {
// If already on this workspace, return
if (activeWorkspaceRef.current?.id === workspace.id) {
return
}
try {
// Switch workspace and update URL
await switchToWorkspace(workspace.id)
const currentPath = pathnameRef.current || ''
// Preserve templates route if user is on templates or template detail
const templateDetailMatch = currentPath.match(/^\/workspace\/[^/]+\/templates\/([^/]+)$/)
if (templateDetailMatch) {
const templateId = templateDetailMatch[1]
@@ -206,208 +133,122 @@ export function useWorkspaceManagement({
[switchToWorkspace]
)
/**
* Handle create workspace
*/
const handleCreateWorkspace = useCallback(async () => {
if (isCreatingWorkspace) {
if (createWorkspaceMutation.isPending) {
logger.info('Workspace creation already in progress, ignoring request')
return
}
try {
setIsCreatingWorkspace(true)
logger.info('Creating new workspace')
// Generate workspace name using utility function
const workspaceName = await generateWorkspaceName()
logger.info(`Generated workspace name: ${workspaceName}`)
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: workspaceName,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to create workspace')
}
const data = await response.json()
const newWorkspace = data.workspace
const newWorkspace = await createWorkspaceMutation.mutateAsync({ name: workspaceName })
logger.info('Created new workspace:', newWorkspace)
// Refresh workspace list (no URL validation needed for creation)
await refreshWorkspaceList()
// Switch to the new workspace
await switchWorkspace(newWorkspace)
} catch (error) {
logger.error('Error creating workspace:', error)
} finally {
setIsCreatingWorkspace(false)
}
}, [refreshWorkspaceList, switchWorkspace, isCreatingWorkspace])
}, [createWorkspaceMutation, switchWorkspace])
/**
* Confirm delete workspace
*/
const confirmDeleteWorkspace = useCallback(
async (workspaceToDelete: Workspace, templateAction?: 'keep' | 'delete') => {
setIsDeleting(true)
try {
logger.info('Deleting workspace:', workspaceToDelete.id)
const deleteTemplates = templateAction === 'delete'
const response = await fetch(`/api/workspaces/${workspaceToDelete.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ deleteTemplates }),
await deleteWorkspaceMutation.mutateAsync({
workspaceId: workspaceToDelete.id,
deleteTemplates,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to delete workspace')
}
logger.info('Workspace deleted successfully:', workspaceToDelete.id)
// Check if we're deleting the current workspace (either active or in URL)
const isDeletingCurrentWorkspace =
workspaceIdRef.current === workspaceToDelete.id ||
activeWorkspaceRef.current?.id === workspaceToDelete.id
if (isDeletingCurrentWorkspace) {
// For current workspace deletion, use full fetchWorkspaces with URL validation
logger.info(
'Deleting current workspace - using full workspace refresh with URL validation'
)
await fetchWorkspaces()
hasValidatedRef.current = false
const { data: updatedWorkspaces } = await refetchWorkspaces()
// If we deleted the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToDelete.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToDelete.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
const remainingWorkspaces = (updatedWorkspaces || []).filter(
(w) => w.id !== workspaceToDelete.id
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
} else {
// For non-current workspace deletion, just refresh the list without URL validation
logger.info('Deleting non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error deleting workspace:', error)
} finally {
setIsDeleting(false)
}
},
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace]
[deleteWorkspaceMutation, refetchWorkspaces, switchWorkspace]
)
/**
* Handle leave workspace
*/
const handleLeaveWorkspace = useCallback(
async (workspaceToLeave: Workspace) => {
setIsLeaving(true)
if (!sessionUserId) {
logger.error('Cannot leave workspace: no session user ID')
return
}
logger.info('Leaving workspace:', workspaceToLeave.id)
try {
logger.info('Leaving workspace:', workspaceToLeave.id)
// Use the existing member removal API with current user's ID
const response = await fetch(`/api/workspaces/members/${sessionUserId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: workspaceToLeave.id,
}),
await leaveWorkspaceMutation.mutateAsync({
userId: sessionUserId,
workspaceId: workspaceToLeave.id,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to leave workspace')
}
logger.info('Left workspace successfully:', workspaceToLeave.id)
// Check if we're leaving the current workspace (either active or in URL)
const isLeavingCurrentWorkspace =
workspaceIdRef.current === workspaceToLeave.id ||
activeWorkspaceRef.current?.id === workspaceToLeave.id
if (isLeavingCurrentWorkspace) {
// For current workspace leaving, use full fetchWorkspaces with URL validation
logger.info(
'Leaving current workspace - using full workspace refresh with URL validation'
)
await fetchWorkspaces()
hasValidatedRef.current = false
const { data: updatedWorkspaces } = await refetchWorkspaces()
// If we left the active workspace, switch to the first available workspace
if (activeWorkspaceRef.current?.id === workspaceToLeave.id) {
const remainingWorkspaces = workspaces.filter((w) => w.id !== workspaceToLeave.id)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
const remainingWorkspaces = (updatedWorkspaces || []).filter(
(w) => w.id !== workspaceToLeave.id
)
if (remainingWorkspaces.length > 0) {
await switchWorkspace(remainingWorkspaces[0])
}
} else {
// For non-current workspace leaving, just refresh the list without URL validation
logger.info('Leaving non-current workspace - using simple list refresh')
await refreshWorkspaceList()
}
} catch (error) {
logger.error('Error leaving workspace:', error)
} finally {
setIsLeaving(false)
throw error
}
},
[fetchWorkspaces, refreshWorkspaceList, workspaces, switchWorkspace, sessionUserId]
[refetchWorkspaces, switchWorkspace, sessionUserId, leaveWorkspaceMutation]
)
/**
* Validate workspace exists before making API calls
*/
const isWorkspaceValid = useCallback(async (workspaceId: string) => {
try {
const response = await fetch(`/api/workspaces/${workspaceId}`)
return response.ok
} catch {
return false
}
}, [])
/**
* Initialize workspace data on mount (uses full validation with URL handling)
* fetchWorkspaces is stable (empty deps array), so it's safe to call without including it
*/
useEffect(() => {
if (sessionUserId && !isInitializedRef.current) {
isInitializedRef.current = true
fetchWorkspaces()
}
}, [sessionUserId, fetchWorkspaces])
const isWorkspaceValid = useCallback(
(targetWorkspaceId: string) => {
return workspaces.some((w) => w.id === targetWorkspaceId)
},
[workspaces]
)
return {
// State
workspaces,
activeWorkspace,
isWorkspacesLoading,
isCreatingWorkspace,
isDeleting,
isLeaving,
// Operations
isCreatingWorkspace: createWorkspaceMutation.isPending,
isDeleting: deleteWorkspaceMutation.isPending,
isLeaving: leaveWorkspaceMutation.isPending,
fetchWorkspaces,
refreshWorkspaceList,
updateWorkspaceName,
@@ -418,3 +259,5 @@ export function useWorkspaceManagement({
isWorkspaceValid,
}
}
export type { Workspace }

View File

@@ -6,7 +6,7 @@ import { getTrigger } from '@/triggers'
export const CalComBlock: BlockConfig<ToolResponse> = {
type: 'calcom',
name: 'CalCom',
name: 'Cal Com',
description: 'Manage Cal.com bookings, event types, schedules, and availability',
authMode: AuthMode.OAuth,
triggerAllowed: true,

View File

@@ -0,0 +1,175 @@
import { DsPyIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const DSPyBlock: BlockConfig = {
type: 'dspy',
name: 'DSPy',
description: 'Run predictions using self-hosted DSPy programs',
longDescription:
'Integrate with your self-hosted DSPy programs for LLM-powered predictions. Supports Predict, Chain of Thought, and ReAct agents. DSPy is the framework for programming—not prompting—language models.',
category: 'tools',
bgColor: '#1E293B',
icon: DsPyIcon,
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Predict', id: 'predict' },
{ label: 'Chain of Thought', id: 'chain_of_thought' },
{ label: 'ReAct Agent', id: 'react' },
],
value: () => 'predict',
},
{
id: 'baseUrl',
title: 'Base URL',
type: 'short-input',
placeholder: 'https://your-dspy-server.com',
required: true,
},
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'Optional API key for authentication',
},
{
id: 'endpoint',
title: 'Endpoint',
type: 'short-input',
placeholder: '/predict',
mode: 'advanced',
},
// Predict operation fields
{
id: 'input',
title: 'Input',
type: 'long-input',
placeholder: 'Enter your input text',
condition: { field: 'operation', value: 'predict' },
required: { field: 'operation', value: 'predict' },
rows: 4,
},
{
id: 'inputField',
title: 'Input Field Name',
type: 'short-input',
placeholder: 'text (defaults to "text")',
condition: { field: 'operation', value: 'predict' },
mode: 'advanced',
},
{
id: 'additionalInputs',
title: 'Additional Inputs',
type: 'long-input',
placeholder: '{"key": "value"} - JSON object with extra fields',
condition: { field: 'operation', value: 'predict' },
mode: 'advanced',
rows: 3,
},
// Chain of Thought operation fields
{
id: 'question',
title: 'Question',
type: 'long-input',
placeholder: 'Enter your question',
condition: { field: 'operation', value: 'chain_of_thought' },
required: { field: 'operation', value: 'chain_of_thought' },
rows: 4,
},
// ReAct operation fields
{
id: 'task',
title: 'Task',
type: 'long-input',
placeholder: 'Describe the task for the ReAct agent',
condition: { field: 'operation', value: 'react' },
required: { field: 'operation', value: 'react' },
rows: 4,
},
{
id: 'maxIterations',
title: 'Max Iterations',
type: 'short-input',
placeholder: 'Maximum reasoning iterations',
condition: { field: 'operation', value: 'react' },
mode: 'advanced',
},
// Common optional fields
{
id: 'context',
title: 'Context',
type: 'long-input',
placeholder: 'Additional context for the DSPy program',
mode: 'advanced',
rows: 4,
},
],
tools: {
access: ['dspy_predict', 'dspy_chain_of_thought', 'dspy_react'],
config: {
tool: (params) => `dspy_${params.operation}`,
params: (params) => {
const { operation, additionalInputs, maxIterations, ...rest } = params
let parsedAdditionalInputs: Record<string, unknown> | undefined
if (additionalInputs && typeof additionalInputs === 'string') {
try {
parsedAdditionalInputs = JSON.parse(additionalInputs)
} catch {
// Ignore parse errors
}
}
let parsedMaxIterations: number | undefined
if (maxIterations) {
const parsed = Number.parseInt(maxIterations as string, 10)
if (!Number.isNaN(parsed)) {
parsedMaxIterations = parsed
}
}
return {
...rest,
additionalInputs: parsedAdditionalInputs,
maxIterations: parsedMaxIterations,
}
},
},
},
inputs: {
operation: { type: 'string', description: 'DSPy operation to perform' },
baseUrl: { type: 'string', description: 'Base URL of the DSPy server' },
apiKey: { type: 'string', description: 'API key for authentication' },
endpoint: { type: 'string', description: 'API endpoint path' },
input: { type: 'string', description: 'Input text for Predict operation' },
inputField: { type: 'string', description: 'Name of the input field' },
context: { type: 'string', description: 'Additional context for the program' },
additionalInputs: { type: 'string', description: 'JSON object with extra fields' },
question: { type: 'string', description: 'Question for Chain of Thought' },
task: { type: 'string', description: 'Task for ReAct agent' },
maxIterations: { type: 'string', description: 'Max iterations for ReAct' },
},
outputs: {
answer: { type: 'string', description: 'The answer/output from the DSPy program' },
reasoning: { type: 'string', description: 'The reasoning or rationale behind the answer' },
trajectory: {
type: 'json',
description: 'Step-by-step trajectory for ReAct (thoughts, actions, observations)',
},
status: { type: 'string', description: 'Response status from the DSPy server' },
rawOutput: { type: 'json', description: 'Complete raw output from the DSPy program' },
},
}

View File

@@ -0,0 +1,626 @@
import { GoogleMapsIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const GoogleMapsBlock: BlockConfig = {
type: 'google_maps',
name: 'Google Maps',
description: 'Geocoding, directions, places, and distance calculations',
longDescription:
'Integrate Google Maps Platform APIs into your workflow. Supports geocoding addresses to coordinates, reverse geocoding, getting directions between locations, calculating distance matrices, searching for places, retrieving place details, elevation data, and timezone information.',
docsLink: 'https://docs.sim.ai/tools/google_maps',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleMapsIcon,
subBlocks: [
// Operation selector
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [
{ label: 'Geocode Address', id: 'geocode' },
{ label: 'Reverse Geocode', id: 'reverse_geocode' },
{ label: 'Get Directions', id: 'directions' },
{ label: 'Distance Matrix', id: 'distance_matrix' },
{ label: 'Search Places', id: 'places_search' },
{ label: 'Place Details', id: 'place_details' },
{ label: 'Get Elevation', id: 'elevation' },
{ label: 'Get Timezone', id: 'timezone' },
{ label: 'Snap to Roads', id: 'snap_to_roads' },
{ label: 'Speed Limits', id: 'speed_limits' },
{ label: 'Validate Address', id: 'validate_address' },
{ label: 'Geolocate (WiFi/Cell)', id: 'geolocate' },
{ label: 'Air Quality', id: 'air_quality' },
],
value: () => 'geocode',
},
// API Key
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
password: true,
placeholder: 'Enter your Google Maps API key',
required: true,
},
// ========== Geocode ==========
{
id: 'address',
title: 'Address',
type: 'long-input',
placeholder: '1600 Amphitheatre Parkway, Mountain View, CA',
condition: { field: 'operation', value: 'geocode' },
required: { field: 'operation', value: 'geocode' },
rows: 2,
},
{
id: 'latitude',
title: 'Latitude',
type: 'short-input',
placeholder: '37.4224764',
condition: { field: 'operation', value: ['reverse_geocode', 'elevation', 'timezone'] },
required: { field: 'operation', value: ['reverse_geocode', 'elevation', 'timezone'] },
},
{
id: 'longitude',
title: 'Longitude',
type: 'short-input',
placeholder: '-122.0842499',
condition: { field: 'operation', value: ['reverse_geocode', 'elevation', 'timezone'] },
required: { field: 'operation', value: ['reverse_geocode', 'elevation', 'timezone'] },
},
{
id: 'timestamp',
title: 'Timestamp',
type: 'short-input',
placeholder: 'Unix timestamp (defaults to current time)',
condition: { field: 'operation', value: 'timezone' },
mode: 'advanced',
},
{
id: 'origin',
title: 'Origin',
type: 'short-input',
placeholder: 'Starting address or coordinates (lat,lng)',
condition: { field: 'operation', value: ['directions', 'distance_matrix'] },
required: { field: 'operation', value: ['directions', 'distance_matrix'] },
},
{
id: 'destination',
title: 'Destination',
type: 'short-input',
placeholder: 'Destination address or coordinates (lat,lng)',
condition: { field: 'operation', value: 'directions' },
required: { field: 'operation', value: 'directions' },
},
{
id: 'destinations',
title: 'Destinations',
type: 'long-input',
placeholder: 'Destination addresses separated by | (e.g., New York, NY|Boston, MA)',
condition: { field: 'operation', value: 'distance_matrix' },
required: { field: 'operation', value: 'distance_matrix' },
rows: 3,
},
{
id: 'mode',
title: 'Travel Mode',
type: 'dropdown',
options: [
{ label: 'Driving', id: 'driving' },
{ label: 'Walking', id: 'walking' },
{ label: 'Bicycling', id: 'bicycling' },
{ label: 'Transit', id: 'transit' },
],
value: () => 'driving',
condition: { field: 'operation', value: ['directions', 'distance_matrix'] },
},
{
id: 'avoid',
title: 'Avoid',
type: 'dropdown',
options: [
{ label: 'None', id: '' },
{ label: 'Tolls', id: 'tolls' },
{ label: 'Highways', id: 'highways' },
{ label: 'Ferries', id: 'ferries' },
],
condition: { field: 'operation', value: ['directions', 'distance_matrix'] },
mode: 'advanced',
},
{
id: 'waypoints',
title: 'Waypoints',
type: 'long-input',
placeholder: 'Optional stops separated by | (e.g., Stop 1|Stop 2)',
condition: { field: 'operation', value: 'directions' },
rows: 2,
mode: 'advanced',
},
{
id: 'units',
title: 'Units',
type: 'dropdown',
options: [
{ label: 'Metric (km)', id: 'metric' },
{ label: 'Imperial (miles)', id: 'imperial' },
],
value: () => 'metric',
condition: { field: 'operation', value: ['directions', 'distance_matrix'] },
mode: 'advanced',
},
{
id: 'query',
title: 'Search Query',
type: 'short-input',
placeholder: 'restaurants near Times Square',
condition: { field: 'operation', value: 'places_search' },
required: { field: 'operation', value: 'places_search' },
},
{
id: 'locationBias',
title: 'Location Bias',
type: 'short-input',
placeholder: 'lat,lng to bias results (e.g., 40.7580,-73.9855)',
condition: { field: 'operation', value: 'places_search' },
mode: 'advanced',
},
{
id: 'radius',
title: 'Radius (meters)',
type: 'short-input',
placeholder: 'Search radius in meters (e.g., 5000)',
condition: { field: 'operation', value: 'places_search' },
mode: 'advanced',
},
{
id: 'placeType',
title: 'Place Type',
type: 'dropdown',
options: [
{ label: 'Any', id: '' },
{ label: 'Restaurant', id: 'restaurant' },
{ label: 'Cafe', id: 'cafe' },
{ label: 'Bar', id: 'bar' },
{ label: 'Hotel', id: 'lodging' },
{ label: 'Gas Station', id: 'gas_station' },
{ label: 'Hospital', id: 'hospital' },
{ label: 'Pharmacy', id: 'pharmacy' },
{ label: 'Bank', id: 'bank' },
{ label: 'ATM', id: 'atm' },
{ label: 'Grocery Store', id: 'supermarket' },
{ label: 'Shopping Mall', id: 'shopping_mall' },
{ label: 'Gym', id: 'gym' },
{ label: 'Park', id: 'park' },
{ label: 'Museum', id: 'museum' },
{ label: 'Movie Theater', id: 'movie_theater' },
{ label: 'Airport', id: 'airport' },
{ label: 'Train Station', id: 'train_station' },
{ label: 'Bus Station', id: 'bus_station' },
{ label: 'Parking', id: 'parking' },
],
condition: { field: 'operation', value: 'places_search' },
mode: 'advanced',
},
{
id: 'placeId',
title: 'Place ID',
type: 'short-input',
placeholder: 'Google Place ID (e.g., ChIJN1t_tDeuEmsRUsoyG83frY4)',
condition: { field: 'operation', value: 'place_details' },
required: { field: 'operation', value: 'place_details' },
},
{
id: 'fields',
title: 'Fields',
type: 'short-input',
placeholder: 'name,formatted_address,rating,opening_hours',
condition: { field: 'operation', value: 'place_details' },
mode: 'advanced',
},
{
id: 'path',
title: 'Path',
type: 'long-input',
placeholder: 'Pipe-separated lat,lng pairs (e.g., 60.170880,24.942795|60.170879,24.942796)',
condition: { field: 'operation', value: ['snap_to_roads', 'speed_limits'] },
required: { field: 'operation', value: 'snap_to_roads' },
rows: 3,
},
{
id: 'interpolate',
title: 'Interpolate',
type: 'switch',
condition: { field: 'operation', value: 'snap_to_roads' },
mode: 'advanced',
},
{
id: 'placeIds',
title: 'Place IDs',
type: 'long-input',
placeholder: 'Pipe-separated Place IDs (alternative to path)',
condition: { field: 'operation', value: 'speed_limits' },
rows: 2,
mode: 'advanced',
},
{
id: 'addressToValidate',
title: 'Address',
type: 'long-input',
placeholder: '1600 Amphitheatre Parkway, Mountain View, CA 94043',
condition: { field: 'operation', value: 'validate_address' },
required: { field: 'operation', value: 'validate_address' },
rows: 2,
},
{
id: 'regionCode',
title: 'Region Code',
type: 'short-input',
placeholder: 'ISO country code (e.g., US, CA, GB)',
condition: { field: 'operation', value: 'validate_address' },
mode: 'advanced',
},
{
id: 'locality',
title: 'Locality',
type: 'short-input',
placeholder: 'City name (optional hint)',
condition: { field: 'operation', value: 'validate_address' },
mode: 'advanced',
},
{
id: 'enableUspsCass',
title: 'Enable USPS CASS',
type: 'switch',
condition: { field: 'operation', value: 'validate_address' },
mode: 'advanced',
},
{
id: 'considerIp',
title: 'Use IP Address',
type: 'switch',
condition: { field: 'operation', value: 'geolocate' },
},
{
id: 'radioType',
title: 'Radio Type',
type: 'dropdown',
options: [
{ label: 'None', id: '' },
{ label: 'LTE', id: 'lte' },
{ label: 'GSM', id: 'gsm' },
{ label: 'CDMA', id: 'cdma' },
{ label: 'WCDMA', id: 'wcdma' },
{ label: '5G NR', id: 'nr' },
],
condition: { field: 'operation', value: 'geolocate' },
mode: 'advanced',
},
{
id: 'carrier',
title: 'Carrier',
type: 'short-input',
placeholder: 'Carrier name',
condition: { field: 'operation', value: 'geolocate' },
mode: 'advanced',
},
{
id: 'wifiAccessPoints',
title: 'WiFi Access Points',
type: 'long-input',
placeholder: 'JSON array of WiFi APs: [{"macAddress": "..."}]',
condition: { field: 'operation', value: 'geolocate' },
rows: 3,
mode: 'advanced',
},
{
id: 'cellTowers',
title: 'Cell Towers',
type: 'long-input',
placeholder: 'JSON array of cell towers: [{"cellId": ..., "locationAreaCode": ...}]',
condition: { field: 'operation', value: 'geolocate' },
rows: 3,
mode: 'advanced',
},
{
id: 'aqLatitude',
title: 'Latitude',
type: 'short-input',
placeholder: '37.4224764',
condition: { field: 'operation', value: 'air_quality' },
required: { field: 'operation', value: 'air_quality' },
},
{
id: 'aqLongitude',
title: 'Longitude',
type: 'short-input',
placeholder: '-122.0842499',
condition: { field: 'operation', value: 'air_quality' },
required: { field: 'operation', value: 'air_quality' },
},
{
id: 'languageCode',
title: 'Language Code',
type: 'short-input',
placeholder: 'Language code (e.g., en, es)',
condition: { field: 'operation', value: 'air_quality' },
mode: 'advanced',
},
{
id: 'language',
title: 'Language',
type: 'short-input',
placeholder: 'Language code (e.g., en, es, fr, de)',
mode: 'advanced',
},
{
id: 'region',
title: 'Region Bias',
type: 'short-input',
placeholder: 'Country code (e.g., us, uk, de)',
mode: 'advanced',
condition: { field: 'operation', value: ['geocode', 'places_search'] },
},
],
tools: {
access: [
'google_maps_air_quality',
'google_maps_directions',
'google_maps_distance_matrix',
'google_maps_elevation',
'google_maps_geocode',
'google_maps_geolocate',
'google_maps_place_details',
'google_maps_places_search',
'google_maps_reverse_geocode',
'google_maps_snap_to_roads',
'google_maps_speed_limits',
'google_maps_timezone',
'google_maps_validate_address',
],
config: {
tool: (params) => `google_maps_${params.operation}`,
params: (params) => {
const { operation, locationBias, ...rest } = params
let location: { lat: number; lng: number } | undefined
if (locationBias && typeof locationBias === 'string' && locationBias.includes(',')) {
const [lat, lng] = locationBias.split(',').map((s) => Number.parseFloat(s.trim()))
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
location = { lat, lng }
}
}
let lat: number | undefined
let lng: number | undefined
if (params.latitude) {
lat = Number.parseFloat(params.latitude)
}
if (params.longitude) {
lng = Number.parseFloat(params.longitude)
}
if (params.aqLatitude) {
lat = Number.parseFloat(params.aqLatitude)
}
if (params.aqLongitude) {
lng = Number.parseFloat(params.aqLongitude)
}
let timestamp: number | undefined
if (params.timestamp) {
timestamp = Number.parseInt(params.timestamp, 10)
}
let destinations: string[] | undefined
if (params.destinations && typeof params.destinations === 'string') {
destinations = params.destinations.split('|').map((d: string) => d.trim())
}
let waypoints: string[] | undefined
if (params.waypoints && typeof params.waypoints === 'string') {
waypoints = params.waypoints
.split('|')
.map((w: string) => w.trim())
.filter(Boolean)
}
let radius: number | undefined
if (params.radius) {
radius = Number.parseInt(params.radius, 10)
}
let placeIds: string[] | undefined
if (params.placeIds && typeof params.placeIds === 'string') {
placeIds = params.placeIds
.split('|')
.map((p: string) => p.trim())
.filter(Boolean)
}
let wifiAccessPoints: unknown[] | undefined
if (params.wifiAccessPoints && typeof params.wifiAccessPoints === 'string') {
try {
wifiAccessPoints = JSON.parse(params.wifiAccessPoints)
} catch {
// Ignore parse errors
}
}
let cellTowers: unknown[] | undefined
if (params.cellTowers && typeof params.cellTowers === 'string') {
try {
cellTowers = JSON.parse(params.cellTowers)
} catch {
// Ignore parse errors
}
}
const address = params.addressToValidate || params.address
// Parse boolean switches (can come as string or boolean from form)
let interpolate: boolean | undefined
if (params.interpolate !== undefined) {
interpolate = params.interpolate === 'true' || params.interpolate === true
}
let enableUspsCass: boolean | undefined
if (params.enableUspsCass !== undefined) {
enableUspsCass = params.enableUspsCass === 'true' || params.enableUspsCass === true
}
let considerIp: boolean | undefined
if (params.considerIp !== undefined) {
considerIp = params.considerIp === 'true' || params.considerIp === true
}
return {
...rest,
address,
location,
lat,
lng,
timestamp,
destinations,
waypoints,
radius,
placeIds,
wifiAccessPoints,
cellTowers,
interpolate,
enableUspsCass,
considerIp,
type: params.placeType || undefined,
avoid: params.avoid || undefined,
radioType: params.radioType || undefined,
}
},
},
},
inputs: {
operation: { type: 'string', description: 'Operation to perform' },
apiKey: { type: 'string', description: 'Google Maps API key' },
address: { type: 'string', description: 'Address to geocode' },
latitude: { type: 'string', description: 'Latitude coordinate' },
longitude: { type: 'string', description: 'Longitude coordinate' },
timestamp: { type: 'string', description: 'Unix timestamp for timezone' },
origin: { type: 'string', description: 'Starting location' },
destination: { type: 'string', description: 'Destination location' },
destinations: { type: 'string', description: 'Multiple destinations (pipe-separated)' },
mode: { type: 'string', description: 'Travel mode' },
avoid: { type: 'string', description: 'Features to avoid' },
waypoints: { type: 'string', description: 'Waypoints (pipe-separated)' },
query: { type: 'string', description: 'Places search query' },
locationBias: { type: 'string', description: 'Location bias for search' },
radius: { type: 'string', description: 'Search radius in meters' },
placeType: { type: 'string', description: 'Place type filter' },
placeId: { type: 'string', description: 'Google Place ID' },
fields: { type: 'string', description: 'Fields to retrieve' },
units: { type: 'string', description: 'Unit system' },
language: { type: 'string', description: 'Response language' },
region: { type: 'string', description: 'Region bias' },
path: { type: 'string', description: 'Pipe-separated lat,lng coordinates' },
interpolate: { type: 'boolean', description: 'Interpolate points along road' },
placeIds: { type: 'string', description: 'Pipe-separated Place IDs for speed limits' },
addressToValidate: { type: 'string', description: 'Address to validate' },
regionCode: { type: 'string', description: 'ISO country code for address' },
locality: { type: 'string', description: 'City name hint' },
enableUspsCass: { type: 'boolean', description: 'Enable USPS CASS validation' },
considerIp: { type: 'boolean', description: 'Use IP for geolocation' },
radioType: { type: 'string', description: 'Radio type (lte, gsm, etc.)' },
carrier: { type: 'string', description: 'Carrier name' },
wifiAccessPoints: { type: 'string', description: 'WiFi access points JSON' },
cellTowers: { type: 'string', description: 'Cell towers JSON' },
aqLatitude: { type: 'string', description: 'Latitude for air quality' },
aqLongitude: { type: 'string', description: 'Longitude for air quality' },
languageCode: { type: 'string', description: 'Language code for air quality' },
},
outputs: {
formattedAddress: { type: 'string', description: 'Formatted address string' },
lat: { type: 'number', description: 'Latitude coordinate' },
lng: { type: 'number', description: 'Longitude coordinate' },
placeId: { type: 'string', description: 'Google Place ID' },
addressComponents: { type: 'json', description: 'Detailed address components' },
locationType: { type: 'string', description: 'Location accuracy type' },
types: { type: 'json', description: 'Address or place types' },
routes: { type: 'json', description: 'Available routes' },
distanceText: { type: 'string', description: 'Distance as text (e.g., "5.2 km")' },
distanceMeters: { type: 'number', description: 'Distance in meters' },
durationText: { type: 'string', description: 'Duration as text (e.g., "15 mins")' },
durationSeconds: { type: 'number', description: 'Duration in seconds' },
startAddress: { type: 'string', description: 'Starting address' },
endAddress: { type: 'string', description: 'Ending address' },
steps: { type: 'json', description: 'Turn-by-turn directions' },
polyline: { type: 'string', description: 'Encoded polyline for the route' },
rows: { type: 'json', description: 'Distance matrix rows' },
originAddresses: { type: 'json', description: 'Resolved origin addresses' },
destinationAddresses: { type: 'json', description: 'Resolved destination addresses' },
places: { type: 'json', description: 'List of places found' },
nextPageToken: { type: 'string', description: 'Token for next page of results' },
name: { type: 'string', description: 'Place name' },
rating: { type: 'number', description: 'Place rating (1-5)' },
userRatingsTotal: { type: 'number', description: 'Number of user ratings' },
priceLevel: { type: 'number', description: 'Price level (0-4)' },
website: { type: 'string', description: 'Place website' },
phoneNumber: { type: 'string', description: 'Place phone number' },
internationalPhoneNumber: { type: 'string', description: 'International phone number' },
openNow: { type: 'boolean', description: 'Whether place is currently open' },
weekdayText: { type: 'json', description: 'Opening hours by day' },
reviews: { type: 'json', description: 'Place reviews' },
photos: { type: 'json', description: 'Place photos' },
url: { type: 'string', description: 'Google Maps URL for the place' },
vicinity: { type: 'string', description: 'Simplified address' },
elevation: { type: 'number', description: 'Elevation in meters' },
resolution: { type: 'number', description: 'Data resolution in meters' },
timeZoneId: { type: 'string', description: 'Timezone ID (e.g., America/New_York)' },
timeZoneName: { type: 'string', description: 'Timezone display name' },
rawOffset: { type: 'number', description: 'UTC offset in seconds (without DST)' },
dstOffset: { type: 'number', description: 'DST offset in seconds' },
snappedPoints: { type: 'json', description: 'Snapped road coordinates' },
warningMessage: { type: 'string', description: 'Warning message if any' },
speedLimits: { type: 'json', description: 'Speed limits for road segments' },
addressComplete: { type: 'boolean', description: 'Whether address is complete' },
hasUnconfirmedComponents: { type: 'boolean', description: 'Has unconfirmed components' },
hasInferredComponents: { type: 'boolean', description: 'Has inferred components' },
hasReplacedComponents: { type: 'boolean', description: 'Has replaced components' },
validationGranularity: { type: 'string', description: 'Validation granularity level' },
geocodeGranularity: { type: 'string', description: 'Geocode granularity level' },
missingComponentTypes: { type: 'json', description: 'Missing address component types' },
unconfirmedComponentTypes: { type: 'json', description: 'Unconfirmed component types' },
unresolvedTokens: { type: 'json', description: 'Unresolved input tokens' },
accuracy: { type: 'number', description: 'Location accuracy in meters' },
dateTime: { type: 'string', description: 'Air quality data timestamp' },
regionCode: { type: 'string', description: 'Region code' },
indexes: { type: 'json', description: 'Air quality indexes' },
pollutants: { type: 'json', description: 'Pollutant concentrations' },
healthRecommendations: { type: 'json', description: 'Health recommendations' },
},
}

View File

@@ -21,6 +21,7 @@ import { CursorBlock, CursorV2Block } from '@/blocks/blocks/cursor'
import { DatadogBlock } from '@/blocks/blocks/datadog'
import { DiscordBlock } from '@/blocks/blocks/discord'
import { DropboxBlock } from '@/blocks/blocks/dropbox'
import { DSPyBlock } from '@/blocks/blocks/dspy'
import { DuckDuckGoBlock } from '@/blocks/blocks/duckduckgo'
import { DynamoDBBlock } from '@/blocks/blocks/dynamodb'
import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
@@ -41,6 +42,7 @@ import { GoogleDocsBlock } from '@/blocks/blocks/google_docs'
import { GoogleDriveBlock } from '@/blocks/blocks/google_drive'
import { GoogleFormsBlock } from '@/blocks/blocks/google_forms'
import { GoogleGroupsBlock } from '@/blocks/blocks/google_groups'
import { GoogleMapsBlock } from '@/blocks/blocks/google_maps'
import { GoogleSheetsBlock, GoogleSheetsV2Block } from '@/blocks/blocks/google_sheets'
import { GoogleSlidesBlock } from '@/blocks/blocks/google_slides'
import { GoogleVaultBlock } from '@/blocks/blocks/google_vault'
@@ -181,6 +183,7 @@ export const registry: Record<string, BlockConfig> = {
datadog: DatadogBlock,
discord: DiscordBlock,
dropbox: DropboxBlock,
dspy: DSPyBlock,
duckduckgo: DuckDuckGoBlock,
dynamodb: DynamoDBBlock,
elasticsearch: ElasticsearchBlock,
@@ -204,6 +207,7 @@ export const registry: Record<string, BlockConfig> = {
google_drive: GoogleDriveBlock,
google_forms: GoogleFormsBlock,
google_groups: GoogleGroupsBlock,
google_maps: GoogleMapsBlock,
google_search: GoogleSearchBlock,
google_sheets: GoogleSheetsBlock,
google_sheets_v2: GoogleSheetsV2Block,

File diff suppressed because one or more lines are too long

View File

@@ -322,7 +322,8 @@ describe('ConditionBlockHandler', () => {
await handler.execute(mockContext, mockBlock, inputs)
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
// collectBlockData is now called with the current node ID for parallel branch context
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext, mockBlock.id)
})
it('should handle function_execute tool failure', async () => {
@@ -620,4 +621,248 @@ describe('ConditionBlockHandler', () => {
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
})
})
describe('Parallel branch handling', () => {
it('should resolve connections and block data correctly when inside a parallel branch', async () => {
// Simulate a condition block inside a parallel branch
// Virtual block ID uses subscript notation: blockId₍branchIndex₎
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍0₎', // Virtual ID for branch 0
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
// Source block also has a virtual ID in the same branch
const sourceBlockVirtualId = 'agent-block-1₍0₎'
// Set up workflow with connections using BASE block IDs (as they are in the workflow definition)
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
// Connections use base IDs, not virtual IDs
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
// Block states use virtual IDs (as outputs are stored per-branch)
const parallelBlockStates = new Map<string, BlockState>([
[
sourceBlockVirtualId,
{ output: { response: 'hello from branch 0', success: true }, executed: true },
],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.response === "hello from branch 0"' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// The condition should evaluate to true because:
// 1. Connection lookup uses base ID 'cond-block-1' (extracted from 'cond-block-1₍0₎')
// 2. Source block output is found at virtual ID 'agent-block-1₍0₎' (same branch)
// 3. The evaluation context contains { response: 'hello from branch 0' }
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
expect((result as any).selectedPath).toEqual({
blockId: 'target-block-1',
blockType: 'api',
blockTitle: 'Target',
})
})
it('should find correct source block output in parallel branch context', async () => {
// Test that when multiple branches exist, the correct branch output is used
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍1₎', // Virtual ID for branch 1
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-block-1',
metadata: { id: 'api', name: 'Target' },
position: { x: 200, y: 0 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-block-1', sourceHandle: 'condition-cond1' },
],
loops: [],
parallels: [],
}
// Multiple branches have executed - each has different output
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 10 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 25 }, executed: true }], // Branch 1 has value 25
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }],
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
// Condition checks if value > 20 - should be true for branch 1 (value=25)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// Should evaluate using branch 1's data (value=25), not branch 0 (value=10) or branch 2 (value=5)
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('cond1')
})
it('should fall back to else when condition is false in parallel branch', async () => {
const parallelConditionBlock: SerializedBlock = {
id: 'cond-block-1₍2₎', // Virtual ID for branch 2
metadata: { id: 'condition', name: 'Condition' },
position: { x: 0, y: 0 },
config: {},
}
const parallelWorkflow: SerializedWorkflow = {
blocks: [
{
id: 'agent-block-1',
metadata: { id: 'agent', name: 'Agent' },
position: { x: 0, y: 0 },
config: {},
},
{
id: 'cond-block-1',
metadata: { id: 'condition', name: 'Condition' },
position: { x: 100, y: 0 },
config: {},
},
{
id: 'target-true',
metadata: { id: 'api', name: 'True Path' },
position: { x: 200, y: 0 },
config: {},
},
{
id: 'target-false',
metadata: { id: 'api', name: 'False Path' },
position: { x: 200, y: 100 },
config: {},
},
],
connections: [
{ source: 'agent-block-1', target: 'cond-block-1' },
{ source: 'cond-block-1', target: 'target-true', sourceHandle: 'condition-cond1' },
{ source: 'cond-block-1', target: 'target-false', sourceHandle: 'condition-else1' },
],
loops: [],
parallels: [],
}
const parallelBlockStates = new Map<string, BlockState>([
['agent-block-1₍0₎', { output: { value: 100 }, executed: true }],
['agent-block-1₍1₎', { output: { value: 50 }, executed: true }],
['agent-block-1₍2₎', { output: { value: 5 }, executed: true }], // Branch 2 has value 5
])
const parallelContext: ExecutionContext = {
workflowId: 'test-workflow-id',
workspaceId: 'test-workspace-id',
workflow: parallelWorkflow,
blockStates: parallelBlockStates,
blockLogs: [],
completedBlocks: new Set(),
decisions: {
router: new Map(),
condition: new Map(),
},
environmentVariables: {},
workflowVariables: {},
}
// Condition checks if value > 20 - should be false for branch 2 (value=5)
const conditions = [
{ id: 'cond1', title: 'if', value: 'context.value > 20' },
{ id: 'else1', title: 'else', value: '' },
]
const inputs = { conditions: JSON.stringify(conditions) }
const result = await handler.execute(parallelContext, parallelConditionBlock, inputs)
// Should fall back to else path because branch 2's value (5) is not > 20
expect((result as any).conditionResult).toBe(true)
expect((result as any).selectedOption).toBe('else1')
expect((result as any).selectedPath.blockId).toBe('target-false')
})
})
})

View File

@@ -3,6 +3,12 @@ import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
import {
buildBranchNodeId,
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -18,7 +24,8 @@ const CONDITION_TIMEOUT_MS = 5000
export async function evaluateConditionExpression(
ctx: ExecutionContext,
conditionExpression: string,
providedEvalContext?: Record<string, any>
providedEvalContext?: Record<string, any>,
currentNodeId?: string
): Promise<boolean> {
const evalContext = providedEvalContext || {}
@@ -26,7 +33,7 @@ export async function evaluateConditionExpression(
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx, currentNodeId)
const result = await executeTool(
'function_execute',
@@ -83,7 +90,19 @@ export class ConditionBlockHandler implements BlockHandler {
): Promise<BlockOutput> {
const conditions = this.parseConditions(inputs.conditions)
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
const baseBlockId = extractBaseBlockId(block.id)
const branchIndex = isBranchNodeId(block.id) ? extractBranchIndex(block.id) : null
const sourceConnection = ctx.workflow?.connections.find((conn) => conn.target === baseBlockId)
let sourceBlockId = sourceConnection?.source
if (sourceBlockId && branchIndex !== null) {
const virtualSourceId = buildBranchNodeId(sourceBlockId, branchIndex)
if (ctx.blockStates.has(virtualSourceId)) {
sourceBlockId = virtualSourceId
}
}
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
const rawSourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
@@ -91,13 +110,16 @@ export class ConditionBlockHandler implements BlockHandler {
// thinking this block is pausing (it was already resumed by the HITL block)
const sourceOutput = this.filterPauseMetadata(rawSourceOutput)
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
const outgoingConnections = ctx.workflow?.connections.filter(
(conn) => conn.source === baseBlockId
)
const { selectedConnection, selectedCondition } = await this.evaluateConditions(
conditions,
outgoingConnections || [],
evalContext,
ctx
ctx,
block.id
)
if (!selectedConnection || !selectedCondition) {
@@ -170,7 +192,8 @@ export class ConditionBlockHandler implements BlockHandler {
conditions: Array<{ id: string; title: string; value: string }>,
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
evalContext: Record<string, any>,
ctx: ExecutionContext
ctx: ExecutionContext,
currentNodeId?: string
): Promise<{
selectedConnection: { target: string; sourceHandle?: string } | null
selectedCondition: { id: string; title: string; value: string } | null
@@ -189,7 +212,8 @@ export class ConditionBlockHandler implements BlockHandler {
const conditionMet = await evaluateConditionExpression(
ctx,
conditionValueString,
evalContext
evalContext,
currentNodeId
)
if (conditionMet) {

View File

@@ -2,6 +2,11 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
import type { ExecutionContext } from '@/executor/types'
import type { OutputSchema } from '@/executor/utils/block-reference'
import {
extractBaseBlockId,
extractBranchIndex,
isBranchNodeId,
} from '@/executor/utils/subflow-utils'
import type { SerializedBlock } from '@/serializer/types'
import type { ToolConfig } from '@/tools/types'
import { getTool } from '@/tools/utils'
@@ -86,14 +91,30 @@ export function getBlockSchema(
return undefined
}
export function collectBlockData(ctx: ExecutionContext): BlockDataCollection {
export function collectBlockData(
ctx: ExecutionContext,
currentNodeId?: string
): BlockDataCollection {
const blockData: Record<string, unknown> = {}
const blockNameMapping: Record<string, string> = {}
const blockOutputSchemas: Record<string, OutputSchema> = {}
const branchIndex =
currentNodeId && isBranchNodeId(currentNodeId) ? extractBranchIndex(currentNodeId) : null
for (const [id, state] of ctx.blockStates.entries()) {
if (state.output !== undefined) {
blockData[id] = state.output
if (branchIndex !== null && isBranchNodeId(id)) {
const stateBranchIndex = extractBranchIndex(id)
if (stateBranchIndex === branchIndex) {
const baseId = extractBaseBlockId(id)
if (blockData[baseId] === undefined) {
blockData[baseId] = state.output
}
}
}
}
}

View File

@@ -0,0 +1,309 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { workspaceKeys } from './workspace'
/**
* Query key factory for invitation-related queries.
* Provides hierarchical cache keys for workspace invitations.
*/
export const invitationKeys = {
all: ['invitations'] as const,
lists: () => [...invitationKeys.all, 'list'] as const,
list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const,
}
/** Raw invitation data from the API. */
export interface PendingInvitation {
id: string
workspaceId: string
email: string
permissions: 'admin' | 'write' | 'read'
status: string
createdAt: string
}
/** Normalized invitation for display in the UI. */
export interface WorkspaceInvitation {
email: string
permissionType: 'admin' | 'write' | 'read'
isPendingInvitation: boolean
invitationId?: string
}
async function fetchPendingInvitations(workspaceId: string): Promise<WorkspaceInvitation[]> {
const response = await fetch('/api/workspaces/invitations')
if (!response.ok) {
throw new Error('Failed to fetch pending invitations')
}
const data = await response.json()
return (
data.invitations
?.filter(
(inv: PendingInvitation) => inv.status === 'pending' && inv.workspaceId === workspaceId
)
.map((inv: PendingInvitation) => ({
email: inv.email,
permissionType: inv.permissions,
isPendingInvitation: true,
invitationId: inv.id,
})) || []
)
}
/**
* Fetches pending invitations for a workspace.
* @param workspaceId - The workspace ID to fetch invitations for
*/
export function usePendingInvitations(workspaceId: string | undefined) {
return useQuery({
queryKey: invitationKeys.list(workspaceId ?? ''),
queryFn: () => fetchPendingInvitations(workspaceId as string),
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface BatchSendInvitationsParams {
workspaceId: string
invitations: Array<{ email: string; permission: 'admin' | 'write' | 'read' }>
}
interface BatchInvitationResult {
successful: string[]
failed: Array<{ email: string; error: string }>
}
/**
* Sends multiple workspace invitations in parallel.
* Returns results for each invitation indicating success or failure.
*/
export function useBatchSendWorkspaceInvitations() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workspaceId,
invitations,
}: BatchSendInvitationsParams): Promise<BatchInvitationResult> => {
const results = await Promise.allSettled(
invitations.map(async ({ email, permission }) => {
const response = await fetch('/api/workspaces/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspaceId,
email,
role: 'member',
permission,
}),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to send invitation')
}
return { email, data: await response.json() }
})
)
const successful: string[] = []
const failed: Array<{ email: string; error: string }> = []
results.forEach((result, index) => {
const email = invitations[index].email
if (result.status === 'fulfilled') {
successful.push(email)
} else {
failed.push({ email, error: result.reason?.message || 'Unknown error' })
}
})
return { successful, failed }
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface CancelInvitationParams {
invitationId: string
workspaceId: string
}
/**
* Cancels a pending workspace invitation.
* Invalidates the invitation list cache on success.
*/
export function useCancelWorkspaceInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ invitationId }: CancelInvitationParams) => {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to cancel invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface ResendInvitationParams {
invitationId: string
workspaceId: string
}
/**
* Resends a pending workspace invitation email.
* Invalidates the invitation list cache on success.
*/
export function useResendWorkspaceInvitation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ invitationId }: ResendInvitationParams) => {
const response = await fetch(`/api/workspaces/invitations/${invitationId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to resend invitation')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: invitationKeys.list(variables.workspaceId),
})
},
})
}
interface RemoveMemberParams {
userId: string
workspaceId: string
}
/**
* Removes a member from a workspace.
* Invalidates the workspace permissions cache on success.
*/
export function useRemoveWorkspaceMember() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, workspaceId }: RemoveMemberParams) => {
const response = await fetch(`/api/workspaces/members/${userId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to remove member')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
},
})
}
interface LeaveWorkspaceParams {
userId: string
workspaceId: string
}
/**
* Allows the current user to leave a workspace.
* Invalidates both permissions and workspace list caches on success.
*/
export function useLeaveWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ userId, workspaceId }: LeaveWorkspaceParams) => {
const response = await fetch(`/api/workspaces/members/${userId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to leave workspace')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
queryClient.invalidateQueries({
queryKey: workspaceKeys.all,
})
},
})
}
interface UpdatePermissionsParams {
workspaceId: string
updates: Array<{ userId: string; permissions: 'admin' | 'write' | 'read' }>
}
/**
* Updates permissions for one or more workspace members.
* Invalidates the workspace permissions cache on success.
*/
export function useUpdateWorkspacePermissions() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, updates }: UpdatePermissionsParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update permissions')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.permissions(variables.workspaceId),
})
},
})
}

View File

@@ -6,27 +6,32 @@ import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsQuery')
/**
* Query key factories for OAuth connections
* Query key factory for OAuth connection queries.
* Provides hierarchical cache keys for connections and provider-specific accounts.
*/
export const oauthConnectionsKeys = {
all: ['oauthConnections'] as const,
connections: () => [...oauthConnectionsKeys.all, 'connections'] as const,
accounts: (provider: string) => [...oauthConnectionsKeys.all, 'accounts', provider] as const,
}
/**
* Service info type - extends OAuthServiceConfig with connection status and the service key
*/
/** OAuth service with connection status and linked accounts. */
export interface ServiceInfo extends OAuthServiceConfig {
/** The service key from OAUTH_PROVIDERS (e.g., 'gmail', 'google-drive') */
id: string
isConnected: boolean
lastConnected?: string
accounts?: { id: string; name: string }[]
}
/**
* Define available services from standardized OAuth providers
*/
/** OAuth connection data returned from the API. */
interface OAuthConnectionResponse {
provider: string
baseProvider?: string
accounts?: { id: string; name: string }[]
lastConnected?: string
scopes?: string[]
}
function defineServices(): ServiceInfo[] {
const servicesList: ServiceInfo[] = []
@@ -44,9 +49,6 @@ function defineServices(): ServiceInfo[] {
return servicesList
}
/**
* Fetch OAuth connections and merge with service definitions
*/
async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
try {
const serviceDefinitions = defineServices()
@@ -65,7 +67,9 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
const connections = data.connections || []
const updatedServices = serviceDefinitions.map((service) => {
const connection = connections.find((conn: any) => conn.provider === service.providerId)
const connection = connections.find(
(conn: OAuthConnectionResponse) => conn.provider === service.providerId
)
if (connection) {
return {
@@ -76,13 +80,14 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
}
const connectionWithScopes = connections.find((conn: any) => {
const connectionWithScopes = connections.find((conn: OAuthConnectionResponse) => {
if (!conn.baseProvider || !service.providerId.startsWith(conn.baseProvider)) {
return false
}
if (conn.scopes && service.scopes) {
return service.scopes.every((scope) => conn.scopes.includes(scope))
const connScopes = conn.scopes
return service.scopes.every((scope) => connScopes.includes(scope))
}
return false
@@ -108,26 +113,28 @@ async function fetchOAuthConnections(): Promise<ServiceInfo[]> {
}
/**
* Hook to fetch OAuth connections
* Fetches all OAuth service connections with their status.
* Returns service definitions merged with connection data.
*/
export function useOAuthConnections() {
return useQuery({
queryKey: oauthConnectionsKeys.connections(),
queryFn: fetchOAuthConnections,
staleTime: 30 * 1000, // 30 seconds - connections don't change often
retry: false, // Don't retry on 404
placeholderData: keepPreviousData, // Show cached data immediately
staleTime: 30 * 1000,
retry: false,
placeholderData: keepPreviousData,
})
}
/**
* Connect OAuth service mutation
*/
interface ConnectServiceParams {
providerId: string
callbackURL: string
}
/**
* Initiates OAuth connection flow for a service.
* Redirects the user to the provider's authorization page.
*/
export function useConnectOAuthService() {
const queryClient = useQueryClient()
@@ -138,7 +145,6 @@ export function useConnectOAuthService() {
return { success: true }
}
// Shopify requires a custom OAuth flow with shop domain input
if (providerId === 'shopify') {
const returnUrl = encodeURIComponent(callbackURL)
window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
@@ -161,9 +167,6 @@ export function useConnectOAuthService() {
})
}
/**
* Disconnect OAuth service mutation
*/
interface DisconnectServiceParams {
provider: string
providerId: string
@@ -171,6 +174,10 @@ interface DisconnectServiceParams {
accountId: string
}
/**
* Disconnects an OAuth service account.
* Performs optimistic update and rolls back on failure.
*/
export function useDisconnectOAuthService() {
const queryClient = useQueryClient()
@@ -230,3 +237,38 @@ export function useDisconnectOAuthService() {
},
})
}
/** Connected OAuth account for a specific provider. */
export interface ConnectedAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
async function fetchConnectedAccounts(provider: string): Promise<ConnectedAccount[]> {
const response = await fetch(`/api/auth/accounts?provider=${provider}`)
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || `Failed to load ${provider} accounts`)
}
const data = await response.json()
return data.accounts || []
}
/**
* Fetches connected accounts for a specific OAuth provider.
* @param provider - The provider ID (e.g., 'slack', 'google')
* @param options - Query options including enabled flag
*/
export function useConnectedAccounts(provider: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: oauthConnectionsKeys.accounts(provider),
queryFn: () => fetchConnectedAccounts(provider),
enabled: options?.enabled ?? true,
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -1,10 +1,13 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
/**
* Query key factories for workspace-related queries
* Query key factory for workspace-related queries.
* Provides hierarchical cache keys for workspaces, settings, and permissions.
*/
export const workspaceKeys = {
all: ['workspace'] as const,
lists: () => [...workspaceKeys.all, 'list'] as const,
list: () => [...workspaceKeys.lists(), 'user'] as const,
details: () => [...workspaceKeys.all, 'detail'] as const,
detail: (id: string) => [...workspaceKeys.details(), id] as const,
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
@@ -13,9 +16,186 @@ export const workspaceKeys = {
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
}
/** Represents a workspace in the user's workspace list. */
export interface Workspace {
id: string
name: string
ownerId: string
role?: string
membershipId?: string
permissions?: 'admin' | 'write' | 'read' | null
}
async function fetchWorkspaces(): Promise<Workspace[]> {
const response = await fetch('/api/workspaces')
if (!response.ok) {
throw new Error('Failed to fetch workspaces')
}
const data = await response.json()
return data.workspaces || []
}
/**
* Fetch workspace settings
* Fetches the current user's workspaces.
* @param enabled - Whether the query should execute (defaults to true)
*/
export function useWorkspacesQuery(enabled = true) {
return useQuery({
queryKey: workspaceKeys.list(),
queryFn: fetchWorkspaces,
enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
interface CreateWorkspaceParams {
name: string
}
/**
* Creates a new workspace.
* Automatically invalidates the workspace list cache on success.
*/
export function useCreateWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name }: CreateWorkspaceParams) => {
const response = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create workspace')
}
const data = await response.json()
return data.workspace as Workspace
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
},
})
}
interface DeleteWorkspaceParams {
workspaceId: string
deleteTemplates?: boolean
}
/**
* Deletes a workspace.
* Automatically invalidates the workspace list cache on success.
*/
export function useDeleteWorkspace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, deleteTemplates = false }: DeleteWorkspaceParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deleteTemplates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to delete workspace')
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
},
})
}
interface UpdateWorkspaceNameParams {
workspaceId: string
name: string
}
/**
* Updates a workspace's name.
* Invalidates both the workspace list and the specific workspace detail cache.
*/
export function useUpdateWorkspaceName() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, name }: UpdateWorkspaceNameParams) => {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update workspace name')
}
return response.json()
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: workspaceKeys.lists() })
queryClient.invalidateQueries({ queryKey: workspaceKeys.detail(variables.workspaceId) })
},
})
}
/** Represents a user with permissions in a workspace. */
export interface WorkspaceUser {
userId: string
email: string
name: string | null
image: string | null
permissionType: 'admin' | 'write' | 'read'
}
/** Workspace permissions data containing all users and their access levels. */
export interface WorkspacePermissions {
users: WorkspaceUser[]
total: number
}
async function fetchWorkspacePermissions(workspaceId: string): Promise<WorkspacePermissions> {
const response = await fetch(`/api/workspaces/${workspaceId}/permissions`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Workspace not found or access denied')
}
if (response.status === 401) {
throw new Error('Authentication required')
}
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
}
return response.json()
}
/**
* Fetches permissions for a specific workspace.
* @param workspaceId - The workspace ID to fetch permissions for
*/
export function useWorkspacePermissionsQuery(workspaceId: string | null | undefined) {
return useQuery({
queryKey: workspaceKeys.permissions(workspaceId ?? ''),
queryFn: () => fetchWorkspacePermissions(workspaceId as string),
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
async function fetchWorkspaceSettings(workspaceId: string) {
const [settingsResponse, permissionsResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}`),
@@ -38,7 +218,8 @@ async function fetchWorkspaceSettings(workspaceId: string) {
}
/**
* Hook to fetch workspace settings
* Fetches workspace settings including permissions.
* @param workspaceId - The workspace ID to fetch settings for
*/
export function useWorkspaceSettings(workspaceId: string) {
return useQuery({
@@ -50,15 +231,16 @@ export function useWorkspaceSettings(workspaceId: string) {
})
}
/**
* Update workspace settings mutation
*/
interface UpdateWorkspaceSettingsParams {
workspaceId: string
billedAccountUserId?: string
billingAccountUserEmail?: string
}
/**
* Updates workspace settings (e.g., billing configuration).
* Invalidates the workspace settings cache on success.
*/
export function useUpdateWorkspaceSettings() {
const queryClient = useQueryClient()
@@ -85,9 +267,7 @@ export function useUpdateWorkspaceSettings() {
})
}
/**
* Workspace type returned by admin workspaces query
*/
/** Workspace with admin access metadata. */
export interface AdminWorkspace {
id: string
name: string
@@ -96,9 +276,6 @@ export interface AdminWorkspace {
canInvite: boolean
}
/**
* Fetch workspaces where user has admin access
*/
async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWorkspace[]> {
if (!userId) {
return []
@@ -121,7 +298,7 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
}
const permissionData = await permissionResponse.json()
return { workspace, permissionData }
} catch (error) {
} catch (_error) {
return null
}
}
@@ -161,14 +338,15 @@ async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWo
}
/**
* Hook to fetch workspaces where user has admin access
* Fetches workspaces where the user has admin access.
* @param userId - The user ID to check admin access for
*/
export function useAdminWorkspaces(userId: string | undefined) {
return useQuery({
queryKey: workspaceKeys.adminList(userId),
queryFn: () => fetchAdminWorkspaces(userId),
enabled: Boolean(userId),
staleTime: 60 * 1000, // Cache for 60 seconds
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
}

View File

@@ -1,52 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
interface SlackAccount {
id: string
accountId: string
providerId: string
displayName?: string
}
interface UseSlackAccountsResult {
accounts: SlackAccount[]
isLoading: boolean
error: string | null
refetch: () => Promise<void>
}
/**
* Fetches and manages connected Slack accounts for the current user.
* @returns Object containing accounts array, loading state, error state, and refetch function
*/
export function useSlackAccounts(): UseSlackAccountsResult {
const [accounts, setAccounts] = useState<SlackAccount[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchAccounts = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch('/api/auth/accounts?provider=slack')
if (response.ok) {
const data = await response.json()
setAccounts(data.accounts || [])
} else {
const data = await response.json().catch(() => ({}))
setError(data.error || 'Failed to load Slack accounts')
setAccounts([])
}
} catch {
setError('Failed to load Slack accounts')
setAccounts([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchAccounts()
}, [])
return { accounts, isLoading, error, refetch: fetchAccounts }
}

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react'
import { createLogger } from '@sim/logger'
import { useSession } from '@/lib/auth/auth-client'
import type { PermissionType, WorkspacePermissions } from '@/hooks/use-workspace-permissions'
import type { WorkspacePermissions } from '@/hooks/queries/workspace'
export type PermissionType = 'admin' | 'write' | 'read'
const logger = createLogger('useUserPermissions')

View File

@@ -1,107 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import type { permissionTypeEnum } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { API_ENDPOINTS } from '@/stores/constants'
const logger = createLogger('useWorkspacePermissions')
export type PermissionType = (typeof permissionTypeEnum.enumValues)[number]
export interface WorkspaceUser {
userId: string
email: string
name: string | null
image: string | null
permissionType: PermissionType
}
export interface WorkspacePermissions {
users: WorkspaceUser[]
total: number
}
interface UseWorkspacePermissionsReturn {
permissions: WorkspacePermissions | null
loading: boolean
error: string | null
updatePermissions: (newPermissions: WorkspacePermissions) => void
refetch: () => Promise<void>
}
/**
* Custom hook to fetch and manage workspace permissions
*
* @param workspaceId - The workspace ID to fetch permissions for
* @returns Object containing permissions data, loading state, error state, and refetch function
*/
export function useWorkspacePermissions(workspaceId: string | null): UseWorkspacePermissionsReturn {
const [permissions, setPermissions] = useState<WorkspacePermissions | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchPermissions = async (id: string): Promise<void> => {
try {
setLoading(true)
setError(null)
const response = await fetch(API_ENDPOINTS.WORKSPACE_PERMISSIONS(id))
if (!response.ok) {
if (response.status === 404) {
throw new Error('Workspace not found or access denied')
}
if (response.status === 401) {
throw new Error('Authentication required')
}
throw new Error(`Failed to fetch permissions: ${response.statusText}`)
}
const data: WorkspacePermissions = await response.json()
setPermissions(data)
logger.info('Workspace permissions loaded', {
workspaceId: id,
userCount: data.total,
users: data.users.map((u) => ({ email: u.email, permissions: u.permissionType })),
})
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
setError(errorMessage)
logger.error('Failed to fetch workspace permissions', {
workspaceId: id,
error: errorMessage,
})
} finally {
setLoading(false)
}
}
const updatePermissions = useCallback((newPermissions: WorkspacePermissions): void => {
setPermissions(newPermissions)
}, [])
useEffect(() => {
if (workspaceId) {
fetchPermissions(workspaceId)
} else {
// Clear state if no workspace ID
setPermissions(null)
setError(null)
setLoading(false)
}
}, [workspaceId])
const refetch = useCallback(async () => {
if (workspaceId) {
await fetchPermissions(workspaceId)
}
}, [workspaceId])
return {
permissions,
loading,
error,
updatePermissions,
refetch,
}
}

View File

@@ -30,7 +30,7 @@ import {
ensureOrganizationForTeamSubscription,
syncSubscriptionUsageLimits,
} from '@/lib/billing/organization'
import { getPlans } from '@/lib/billing/plans'
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
@@ -2641,29 +2641,42 @@ export const auth = betterAuth({
}
},
onSubscriptionComplete: async ({
stripeSubscription,
subscription,
}: {
event: Stripe.Event
stripeSubscription: Stripe.Subscription
subscription: any
}) => {
const { priceId, planFromStripe, isTeamPlan } =
resolvePlanFromStripeSubscription(stripeSubscription)
logger.info('[onSubscriptionComplete] Subscription created', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
priceId,
status: subscription.status,
})
const subscriptionForOrgCreation = isTeamPlan
? { ...subscription, plan: 'team' }
: subscription
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
} catch (orgError) {
logger.error(
'[onSubscriptionComplete] Failed to ensure organization for team subscription',
{
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
error: orgError instanceof Error ? orgError.message : String(orgError),
stack: orgError instanceof Error ? orgError.stack : undefined,
}
@@ -2684,22 +2697,67 @@ export const auth = betterAuth({
event: Stripe.Event
subscription: any
}) => {
const stripeSubscription = event.data.object as Stripe.Subscription
const { priceId, planFromStripe, isTeamPlan } =
resolvePlanFromStripeSubscription(stripeSubscription)
if (priceId && !planFromStripe) {
logger.warn(
'[onSubscriptionUpdate] Could not determine plan from Stripe price ID',
{
subscriptionId: subscription.id,
priceId,
dbPlan: subscription.plan,
}
)
}
const isUpgradeToTeam =
isTeamPlan &&
subscription.plan !== 'team' &&
!subscription.referenceId.startsWith('org_')
const effectivePlanForTeamFeatures = planFromStripe ?? subscription.plan
logger.info('[onSubscriptionUpdate] Subscription updated', {
subscriptionId: subscription.id,
status: subscription.status,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
referenceId: subscription.referenceId,
})
const subscriptionForOrgCreation = isUpgradeToTeam
? { ...subscription, plan: 'team' }
: subscription
let resolvedSubscription = subscription
try {
resolvedSubscription = await ensureOrganizationForTeamSubscription(subscription)
resolvedSubscription = await ensureOrganizationForTeamSubscription(
subscriptionForOrgCreation
)
if (isUpgradeToTeam) {
logger.info(
'[onSubscriptionUpdate] Detected Pro -> Team upgrade, ensured organization creation',
{
subscriptionId: subscription.id,
originalPlan: subscription.plan,
newPlan: planFromStripe,
resolvedReferenceId: resolvedSubscription.referenceId,
}
)
}
} catch (orgError) {
logger.error(
'[onSubscriptionUpdate] Failed to ensure organization for team subscription',
{
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
plan: subscription.plan,
dbPlan: subscription.plan,
planFromStripe,
isUpgradeToTeam,
error: orgError instanceof Error ? orgError.message : String(orgError),
stack: orgError instanceof Error ? orgError.stack : undefined,
}
@@ -2717,9 +2775,8 @@ export const auth = betterAuth({
})
}
if (resolvedSubscription.plan === 'team') {
if (effectivePlanForTeamFeatures === 'team') {
try {
const stripeSubscription = event.data.object as Stripe.Subscription
const quantity = stripeSubscription.items?.data?.[0]?.quantity || 1
const result = await syncSeatsFromStripeQuantity(

View File

@@ -1,3 +1,4 @@
import type Stripe from 'stripe'
import {
getFreeTierLimit,
getProTierLimit,
@@ -56,6 +57,13 @@ export function getPlanByName(planName: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.name === planName)
}
/**
* Get a specific plan by Stripe price ID
*/
export function getPlanByPriceId(priceId: string): BillingPlan | undefined {
return getPlans().find((plan) => plan.priceId === priceId)
}
/**
* Get plan limits for a given plan name
*/
@@ -63,3 +71,26 @@ export function getPlanLimits(planName: string): number {
const plan = getPlanByName(planName)
return plan?.limits.cost ?? getFreeTierLimit()
}
export interface StripePlanResolution {
priceId: string | undefined
planFromStripe: string | null
isTeamPlan: boolean
}
/**
* Resolve plan information from a Stripe subscription object.
* Used to get the authoritative plan from Stripe rather than relying on DB state.
*/
export function resolvePlanFromStripeSubscription(
stripeSubscription: Stripe.Subscription
): StripePlanResolution {
const priceId = stripeSubscription?.items?.data?.[0]?.price?.id
const plan = priceId ? getPlanByPriceId(priceId) : undefined
return {
priceId,
planFromStripe: plan?.name ?? null,
isTeamPlan: plan?.name === 'team',
}
}

View File

@@ -34,7 +34,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 60000,
CLIENT_TIMEOUT: 600000,
MAX_RETRIES: 3,
RECONNECT_DELAY: 1000,
} as const

View File

@@ -81,8 +81,8 @@ describe('generateMcpServerId', () => {
})
describe('MCP_CONSTANTS', () => {
it.concurrent('has correct execution timeout', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(60000)
it.concurrent('has correct execution timeout (10 minutes)', () => {
expect(MCP_CONSTANTS.EXECUTION_TIMEOUT).toBe(600000)
})
it.concurrent('has correct cache timeout (5 minutes)', () => {
@@ -107,8 +107,8 @@ describe('MCP_CONSTANTS', () => {
})
describe('MCP_CLIENT_CONSTANTS', () => {
it.concurrent('has correct client timeout', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(60000)
it.concurrent('has correct client timeout (10 minutes)', () => {
expect(MCP_CLIENT_CONSTANTS.CLIENT_TIMEOUT).toBe(600000)
})
it.concurrent('has correct auto refresh interval (5 minutes)', () => {

View File

@@ -6,7 +6,7 @@ import { isMcpTool, MCP } from '@/executor/constants'
* MCP-specific constants
*/
export const MCP_CONSTANTS = {
EXECUTION_TIMEOUT: 60000,
EXECUTION_TIMEOUT: 600000,
CACHE_TIMEOUT: 5 * 60 * 1000,
DEFAULT_RETRIES: 3,
DEFAULT_CONNECTION_TIMEOUT: 30000,
@@ -49,7 +49,7 @@ export function sanitizeHeaders(
* Client-safe MCP constants
*/
export const MCP_CLIENT_CONSTANTS = {
CLIENT_TIMEOUT: 60000,
CLIENT_TIMEOUT: 600000,
AUTO_REFRESH_INTERVAL: 5 * 60 * 1000,
} as const

View File

@@ -1,6 +1,6 @@
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ACancelTaskParams, A2ACancelTaskResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aCancelTaskTool: ToolConfig<A2ACancelTaskParams, A2ACancelTaskResponse> = {
id: 'a2a_cancel_task',

View File

@@ -1,6 +1,9 @@
import type {
A2ADeletePushNotificationParams,
A2ADeletePushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ADeletePushNotificationParams, A2ADeletePushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aDeletePushNotificationTool: ToolConfig<
A2ADeletePushNotificationParams,

View File

@@ -1,6 +1,6 @@
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetAgentCardParams, A2AGetAgentCardResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetAgentCardTool: ToolConfig<A2AGetAgentCardParams, A2AGetAgentCardResponse> = {
id: 'a2a_get_agent_card',

View File

@@ -1,6 +1,9 @@
import type {
A2AGetPushNotificationParams,
A2AGetPushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetPushNotificationParams, A2AGetPushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetPushNotificationTool: ToolConfig<
A2AGetPushNotificationParams,

View File

@@ -1,6 +1,6 @@
import type { A2AGetTaskParams, A2AGetTaskResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AGetTaskParams, A2AGetTaskResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aGetTaskTool: ToolConfig<A2AGetTaskParams, A2AGetTaskResponse> = {
id: 'a2a_get_task',

View File

@@ -1,6 +1,6 @@
import type { A2AResubscribeParams, A2AResubscribeResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2AResubscribeParams, A2AResubscribeResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aResubscribeTool: ToolConfig<A2AResubscribeParams, A2AResubscribeResponse> = {
id: 'a2a_resubscribe',

View File

@@ -1,6 +1,6 @@
import type { A2ASendMessageParams, A2ASendMessageResponse } from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ASendMessageParams, A2ASendMessageResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aSendMessageTool: ToolConfig<A2ASendMessageParams, A2ASendMessageResponse> = {
id: 'a2a_send_message',

View File

@@ -1,6 +1,9 @@
import type {
A2ASetPushNotificationParams,
A2ASetPushNotificationResponse,
} from '@/tools/a2a/types'
import { A2A_OUTPUT_PROPERTIES } from '@/tools/a2a/types'
import type { ToolConfig } from '@/tools/types'
import type { A2ASetPushNotificationParams, A2ASetPushNotificationResponse } from './types'
import { A2A_OUTPUT_PROPERTIES } from './types'
export const a2aSetPushNotificationTool: ToolConfig<
A2ASetPushNotificationParams,

View File

@@ -1,5 +1,5 @@
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
import type { RunActorParams, RunActorResult } from './types'
const POLL_INTERVAL_MS = 5000 // 5 seconds between polls
const MAX_POLL_TIME_MS = 300000 // 5 minutes maximum polling time

View File

@@ -1,5 +1,5 @@
import type { RunActorParams, RunActorResult } from '@/tools/apify/types'
import type { ToolConfig } from '@/tools/types'
import type { RunActorParams, RunActorResult } from './types'
export const apifyRunActorSyncTool: ToolConfig<RunActorParams, RunActorResult> = {
id: 'apify_run_actor_sync',

View File

@@ -0,0 +1,109 @@
import type { DSPyChainOfThoughtParams, DSPyChainOfThoughtResponse } from '@/tools/dspy/types'
import type { ToolConfig } from '@/tools/types'
export const chainOfThoughtTool: ToolConfig<DSPyChainOfThoughtParams, DSPyChainOfThoughtResponse> =
{
id: 'dspy_chain_of_thought',
name: 'DSPy Chain of Thought',
description:
'Run a Chain of Thought prediction using a self-hosted DSPy ChainOfThought program endpoint',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Base URL of the DSPy server (e.g., https://your-dspy-server.com)',
},
apiKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'API key for authentication (if required by your server)',
},
endpoint: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'API endpoint path (defaults to /predict)',
},
question: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The question to answer using chain of thought reasoning',
},
context: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Additional context to provide for answering the question',
},
},
request: {
method: 'POST',
url: (params) => {
const baseUrl = params.baseUrl.replace(/\/$/, '')
const endpoint = params.endpoint || '/predict'
return `${baseUrl}${endpoint}`
},
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
body: (params) => {
const body: Record<string, unknown> = {
text: params.question,
}
if (params.context) {
body.context = params.context
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const status = data.status ?? 'success'
const outputData = data.data ?? data
return {
success: true,
output: {
answer: outputData.answer ?? outputData.output ?? outputData.response ?? '',
reasoning: outputData.reasoning ?? outputData.rationale ?? outputData.thought ?? '',
status,
rawOutput: outputData,
},
}
},
outputs: {
answer: {
type: 'string',
description: 'The answer generated through chain of thought reasoning',
},
reasoning: {
type: 'string',
description: 'The step-by-step reasoning that led to the answer',
},
status: {
type: 'string',
description: 'Response status from the DSPy server (success or error)',
},
rawOutput: {
type: 'json',
description: 'The complete raw output from the DSPy program (result.toDict())',
},
},
}

View File

@@ -0,0 +1,4 @@
export { chainOfThoughtTool } from '@/tools/dspy/chain-of-thought'
export { predictTool } from '@/tools/dspy/predict'
export { reactTool } from '@/tools/dspy/react'
export * from '@/tools/dspy/types'

View File

@@ -0,0 +1,125 @@
import type { DSPyPredictParams, DSPyPredictResponse } from '@/tools/dspy/types'
import type { ToolConfig } from '@/tools/types'
export const predictTool: ToolConfig<DSPyPredictParams, DSPyPredictResponse> = {
id: 'dspy_predict',
name: 'DSPy Predict',
description: 'Run a prediction using a self-hosted DSPy program endpoint',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Base URL of the DSPy server (e.g., https://your-dspy-server.com)',
},
apiKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'API key for authentication (if required by your server)',
},
endpoint: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'API endpoint path (defaults to /predict)',
},
input: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The input text to send to the DSPy program',
},
inputField: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Name of the input field expected by the DSPy program (defaults to "text")',
},
context: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Additional context to provide to the DSPy program',
},
additionalInputs: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Additional key-value pairs to include in the request body',
},
},
request: {
method: 'POST',
url: (params) => {
const baseUrl = params.baseUrl.replace(/\/$/, '')
const endpoint = params.endpoint || '/predict'
return `${baseUrl}${endpoint}`
},
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
body: (params) => {
const inputField = params.inputField || 'text'
const body: Record<string, unknown> = {
[inputField]: params.input,
}
if (params.context) {
body.context = params.context
}
if (params.additionalInputs) {
Object.assign(body, params.additionalInputs)
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const status = data.status ?? 'success'
const outputData = data.data ?? data
return {
success: true,
output: {
answer: outputData.answer ?? outputData.output ?? outputData.response ?? '',
reasoning: outputData.reasoning ?? outputData.rationale ?? null,
status,
rawOutput: outputData,
},
}
},
outputs: {
answer: {
type: 'string',
description: 'The main output/answer from the DSPy program',
},
reasoning: {
type: 'string',
description: 'The reasoning or rationale behind the answer (if available)',
optional: true,
},
status: {
type: 'string',
description: 'Response status from the DSPy server (success or error)',
},
rawOutput: {
type: 'json',
description: 'The complete raw output from the DSPy program (result.toDict())',
},
},
}

View File

@@ -0,0 +1,156 @@
import type { DSPyReActParams, DSPyReActResponse } from '@/tools/dspy/types'
import { parseTrajectory } from '@/tools/dspy/utils'
import type { ToolConfig } from '@/tools/types'
export const reactTool: ToolConfig<DSPyReActParams, DSPyReActResponse> = {
id: 'dspy_react',
name: 'DSPy ReAct',
description:
'Run a ReAct agent using a self-hosted DSPy ReAct program endpoint for multi-step reasoning and action',
version: '1.0.0',
params: {
baseUrl: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Base URL of the DSPy server (e.g., https://your-dspy-server.com)',
},
apiKey: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'API key for authentication (if required by your server)',
},
endpoint: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'API endpoint path (defaults to /predict)',
},
task: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The task or question for the ReAct agent to work on',
},
context: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Additional context to provide for the task',
},
maxIterations: {
type: 'number',
required: false,
visibility: 'user-only',
description: 'Maximum number of reasoning iterations (defaults to server setting)',
},
},
request: {
method: 'POST',
url: (params) => {
const baseUrl = params.baseUrl.replace(/\/$/, '')
const endpoint = params.endpoint || '/predict'
return `${baseUrl}${endpoint}`
},
headers: (params) => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (params.apiKey) {
headers.Authorization = `Bearer ${params.apiKey}`
}
return headers
},
body: (params) => {
const body: Record<string, unknown> = {
text: params.task,
}
if (params.context) {
body.context = params.context
}
if (params.maxIterations !== undefined) {
body.max_iters = params.maxIterations
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
const status = data.status ?? 'success'
const outputData = data.data ?? data
const rawTrajectory = outputData.trajectory ?? {}
const trajectory = Array.isArray(rawTrajectory)
? rawTrajectory.map((step: Record<string, unknown>) => ({
thought: (step.thought as string) ?? (step.reasoning as string) ?? '',
toolName: (step.tool_name as string) ?? (step.selected_fn as string) ?? '',
toolArgs:
(step.tool_args as Record<string, unknown>) ??
(step.args as Record<string, unknown>) ??
{},
observation: step.observation !== undefined ? String(step.observation) : null,
}))
: parseTrajectory(rawTrajectory)
return {
success: true,
output: {
answer:
outputData.answer ??
outputData.process_result ??
outputData.output ??
outputData.response ??
'',
reasoning: outputData.reasoning ?? null,
trajectory,
status,
rawOutput: outputData,
},
}
},
outputs: {
answer: {
type: 'string',
description: 'The final answer or result from the ReAct agent',
},
reasoning: {
type: 'string',
description: 'The overall reasoning summary from the agent',
optional: true,
},
trajectory: {
type: 'array',
description: 'The step-by-step trajectory of thoughts, actions, and observations',
items: {
type: 'object',
properties: {
thought: { type: 'string', description: 'The reasoning thought at this step' },
toolName: { type: 'string', description: 'The name of the tool/action called' },
toolArgs: { type: 'json', description: 'Arguments passed to the tool' },
observation: {
type: 'string',
description: 'The observation/result from the tool execution',
optional: true,
},
},
},
},
status: {
type: 'string',
description: 'Response status from the DSPy server (success or error)',
},
rawOutput: {
type: 'json',
description: 'The complete raw output from the DSPy program (result.toDict())',
},
},
}

View File

@@ -0,0 +1,84 @@
import type { ToolResponse } from '@/tools/types'
/**
* Parameters for running a DSPy prediction
*/
export interface DSPyPredictParams {
baseUrl: string
apiKey?: string
endpoint?: string
input: string
inputField?: string
context?: string
additionalInputs?: Record<string, unknown>
}
/**
* Response from a DSPy prediction
*/
export interface DSPyPredictResponse extends ToolResponse {
output: {
answer: string
reasoning: string | null
status: string
rawOutput: Record<string, unknown>
}
}
/**
* Parameters for running a DSPy Chain of Thought prediction
*/
export interface DSPyChainOfThoughtParams {
baseUrl: string
apiKey?: string
endpoint?: string
question: string
context?: string
}
/**
* Response from a DSPy Chain of Thought prediction
*/
export interface DSPyChainOfThoughtResponse extends ToolResponse {
output: {
answer: string
reasoning: string
status: string
rawOutput: Record<string, unknown>
}
}
/**
* Parameters for running a DSPy ReAct agent
*/
export interface DSPyReActParams {
baseUrl: string
apiKey?: string
endpoint?: string
task: string
context?: string
maxIterations?: number
}
/**
* ReAct trajectory step structure (matches DSPy output format)
*/
export interface DSPyTrajectoryStep {
thought: string
toolName: string
toolArgs: Record<string, unknown>
observation: string | null
}
/**
* Response from a DSPy ReAct agent
*/
export interface DSPyReActResponse extends ToolResponse {
output: {
answer: string
reasoning: string | null
trajectory: DSPyTrajectoryStep[]
status: string
rawOutput: Record<string, unknown>
}
}

View File

@@ -0,0 +1,28 @@
import type { DSPyTrajectoryStep } from '@/tools/dspy/types'
/**
* Parse DSPy ReAct trajectory format into structured steps
* DSPy trajectory format: { thought_0, tool_name_0, tool_args_0, observation_0, thought_1, ... }
*/
export function parseTrajectory(trajectory: Record<string, unknown>): DSPyTrajectoryStep[] {
const steps: DSPyTrajectoryStep[] = []
let idx = 0
while (
trajectory[`thought_${idx}`] !== undefined ||
trajectory[`tool_name_${idx}`] !== undefined
) {
steps.push({
thought: (trajectory[`thought_${idx}`] as string) ?? '',
toolName: (trajectory[`tool_name_${idx}`] as string) ?? '',
toolArgs: (trajectory[`tool_args_${idx}`] as Record<string, unknown>) ?? {},
observation:
trajectory[`observation_${idx}`] !== undefined
? String(trajectory[`observation_${idx}`])
: null,
})
idx++
}
return steps
}

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsAddAliasParams,
GoogleGroupsAddAliasResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse } from './types'
export const addAliasTool: ToolConfig<GoogleGroupsAddAliasParams, GoogleGroupsAddAliasResponse> = {
id: 'google_groups_add_alias',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsAddMemberParams, GoogleGroupsResponse } from './types'
export const addMemberTool: ToolConfig<GoogleGroupsAddMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_add_member',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsCreateParams, GoogleGroupsResponse } from './types'
export const createGroupTool: ToolConfig<GoogleGroupsCreateParams, GoogleGroupsResponse> = {
id: 'google_groups_create_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsDeleteParams, GoogleGroupsResponse } from './types'
export const deleteGroupTool: ToolConfig<GoogleGroupsDeleteParams, GoogleGroupsResponse> = {
id: 'google_groups_delete_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetParams, GoogleGroupsResponse } from './types'
export const getGroupTool: ToolConfig<GoogleGroupsGetParams, GoogleGroupsResponse> = {
id: 'google_groups_get_group',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetMemberParams, GoogleGroupsResponse } from './types'
export const getMemberTool: ToolConfig<GoogleGroupsGetMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_get_member',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsGetSettingsParams,
GoogleGroupsGetSettingsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsGetSettingsParams, GoogleGroupsGetSettingsResponse } from './types'
export const getSettingsTool: ToolConfig<
GoogleGroupsGetSettingsParams,

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsHasMemberParams, GoogleGroupsResponse } from './types'
export const hasMemberTool: ToolConfig<GoogleGroupsHasMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_has_member',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsListAliasesParams,
GoogleGroupsListAliasesResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListAliasesParams, GoogleGroupsListAliasesResponse } from './types'
export const listAliasesTool: ToolConfig<
GoogleGroupsListAliasesParams,

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsListParams, GoogleGroupsResponse } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListParams, GoogleGroupsResponse } from './types'
export const listGroupsTool: ToolConfig<GoogleGroupsListParams, GoogleGroupsResponse> = {
id: 'google_groups_list_groups',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsListMembersParams,
GoogleGroupsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsListMembersParams, GoogleGroupsResponse } from './types'
export const listMembersTool: ToolConfig<GoogleGroupsListMembersParams, GoogleGroupsResponse> = {
id: 'google_groups_list_members',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsRemoveAliasParams,
GoogleGroupsRemoveAliasResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsRemoveAliasParams, GoogleGroupsRemoveAliasResponse } from './types'
export const removeAliasTool: ToolConfig<
GoogleGroupsRemoveAliasParams,

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsRemoveMemberParams,
GoogleGroupsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsRemoveMemberParams, GoogleGroupsResponse } from './types'
export const removeMemberTool: ToolConfig<GoogleGroupsRemoveMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_remove_member',

View File

@@ -1,5 +1,5 @@
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsResponse, GoogleGroupsUpdateParams } from './types'
export const updateGroupTool: ToolConfig<GoogleGroupsUpdateParams, GoogleGroupsResponse> = {
id: 'google_groups_update_group',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsResponse,
GoogleGroupsUpdateMemberParams,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsResponse, GoogleGroupsUpdateMemberParams } from './types'
export const updateMemberTool: ToolConfig<GoogleGroupsUpdateMemberParams, GoogleGroupsResponse> = {
id: 'google_groups_update_member',

View File

@@ -1,5 +1,8 @@
import type {
GoogleGroupsUpdateSettingsParams,
GoogleGroupsUpdateSettingsResponse,
} from '@/tools/google_groups/types'
import type { ToolConfig } from '@/tools/types'
import type { GoogleGroupsUpdateSettingsParams, GoogleGroupsUpdateSettingsResponse } from './types'
export const updateSettingsTool: ToolConfig<
GoogleGroupsUpdateSettingsParams,

View File

@@ -0,0 +1,223 @@
import type {
GoogleMapsAirQualityParams,
GoogleMapsAirQualityResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsAirQualityTool: ToolConfig<
GoogleMapsAirQualityParams,
GoogleMapsAirQualityResponse
> = {
id: 'google_maps_air_quality',
name: 'Google Maps Air Quality',
description: 'Get current air quality data for a location',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key with Air Quality API enabled',
},
lat: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Longitude coordinate',
},
languageCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for the response (e.g., "en", "es")',
},
},
request: {
url: (params) => {
return `https://airquality.googleapis.com/v1/currentConditions:lookup?key=${params.apiKey.trim()}`
},
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: {
location: { latitude: number; longitude: number }
extraComputations: string[]
languageCode?: string
} = {
location: {
latitude: params.lat,
longitude: params.lng,
},
extraComputations: [
'HEALTH_RECOMMENDATIONS',
'DOMINANT_POLLUTANT_CONCENTRATION',
'POLLUTANT_CONCENTRATION',
'LOCAL_AQI',
'POLLUTANT_ADDITIONAL_INFO',
],
}
if (params.languageCode) {
body.languageCode = params.languageCode.trim()
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.error) {
throw new Error(`Air Quality failed: ${data.error.message || 'Unknown error'}`)
}
const indexes = (data.indexes || []).map(
(index: {
code: string
displayName: string
aqi: number
aqiDisplay: string
color: { red?: number; green?: number; blue?: number }
category: string
dominantPollutant: string
}) => ({
code: index.code,
displayName: index.displayName,
aqi: index.aqi,
aqiDisplay: index.aqiDisplay,
color: {
red: index.color?.red || 0,
green: index.color?.green || 0,
blue: index.color?.blue || 0,
},
category: index.category,
dominantPollutant: index.dominantPollutant,
})
)
const pollutants = (data.pollutants || []).map(
(pollutant: {
code: string
displayName: string
fullName: string
concentration: { value: number; units: string }
additionalInfo?: { sources: string; effects: string }
}) => ({
code: pollutant.code,
displayName: pollutant.displayName,
fullName: pollutant.fullName,
concentration: {
value: pollutant.concentration?.value || 0,
units: pollutant.concentration?.units || '',
},
additionalInfo: pollutant.additionalInfo
? {
sources: pollutant.additionalInfo.sources,
effects: pollutant.additionalInfo.effects,
}
: undefined,
})
)
const healthRecs = data.healthRecommendations
const healthRecommendations = healthRecs
? {
generalPopulation: healthRecs.generalPopulation || '',
elderly: healthRecs.elderly || '',
lungDiseasePopulation: healthRecs.lungDiseasePopulation || '',
heartDiseasePopulation: healthRecs.heartDiseasePopulation || '',
athletes: healthRecs.athletes || '',
pregnantWomen: healthRecs.pregnantWomen || '',
children: healthRecs.children || '',
}
: null
return {
success: true,
output: {
dateTime: data.dateTime || '',
regionCode: data.regionCode || '',
indexes,
pollutants,
healthRecommendations,
},
}
},
outputs: {
dateTime: {
type: 'string',
description: 'Timestamp of the air quality data',
},
regionCode: {
type: 'string',
description: 'Region code for the location',
},
indexes: {
type: 'array',
description: 'Array of air quality indexes',
items: {
type: 'object',
properties: {
code: { type: 'string', description: 'Index code (e.g., "uaqi", "usa_epa")' },
displayName: { type: 'string', description: 'Display name of the index' },
aqi: { type: 'number', description: 'Air quality index value' },
aqiDisplay: { type: 'string', description: 'Formatted AQI display string' },
color: {
type: 'object',
description: 'RGB color for the AQI level',
properties: {
red: { type: 'number' },
green: { type: 'number' },
blue: { type: 'number' },
},
},
category: {
type: 'string',
description: 'Category description (e.g., "Good", "Moderate")',
},
dominantPollutant: { type: 'string', description: 'The dominant pollutant' },
},
},
},
pollutants: {
type: 'array',
description: 'Array of pollutant concentrations',
items: {
type: 'object',
properties: {
code: { type: 'string', description: 'Pollutant code (e.g., "pm25", "o3")' },
displayName: { type: 'string', description: 'Display name' },
fullName: { type: 'string', description: 'Full pollutant name' },
concentration: {
type: 'object',
description: 'Concentration info',
properties: {
value: { type: 'number', description: 'Concentration value' },
units: { type: 'string', description: 'Units (e.g., "PARTS_PER_BILLION")' },
},
},
additionalInfo: {
type: 'object',
description: 'Additional info about sources and effects',
},
},
},
},
healthRecommendations: {
type: 'object',
description: 'Health recommendations for different populations',
},
},
}

View File

@@ -0,0 +1,259 @@
import type {
GoogleMapsDirectionsParams,
GoogleMapsDirectionsResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsDirectionsTool: ToolConfig<
GoogleMapsDirectionsParams,
GoogleMapsDirectionsResponse
> = {
id: 'google_maps_directions',
name: 'Google Maps Directions',
description: 'Get directions and route information between two locations',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
origin: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Starting location (address or lat,lng)',
},
destination: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Destination location (address or lat,lng)',
},
mode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Travel mode: driving, walking, bicycling, or transit',
},
avoid: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Features to avoid: tolls, highways, or ferries',
},
waypoints: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Array of intermediate waypoints',
},
units: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Unit system: metric or imperial',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for results (e.g., en, es, fr)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/directions/json')
url.searchParams.set('origin', params.origin.trim())
url.searchParams.set('destination', params.destination.trim())
url.searchParams.set('key', params.apiKey.trim())
if (params.mode) {
url.searchParams.set('mode', params.mode)
}
if (params.avoid) {
url.searchParams.set('avoid', params.avoid)
}
if (params.waypoints && params.waypoints.length > 0) {
url.searchParams.set('waypoints', params.waypoints.join('|'))
}
if (params.units) {
url.searchParams.set('units', params.units)
}
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Directions request failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const routes = data.routes.map(
(route: {
summary: string
legs: Array<{
start_address: string
end_address: string
start_location: { lat: number; lng: number }
end_location: { lat: number; lng: number }
distance: { text: string; value: number }
duration: { text: string; value: number }
steps: Array<{
html_instructions: string
distance: { text: string; value: number }
duration: { text: string; value: number }
start_location: { lat: number; lng: number }
end_location: { lat: number; lng: number }
travel_mode: string
maneuver?: string
}>
}>
overview_polyline: { points: string }
warnings: string[]
waypoint_order: number[]
}) => ({
summary: route.summary,
legs: route.legs.map((leg) => ({
startAddress: leg.start_address,
endAddress: leg.end_address,
startLocation: {
lat: leg.start_location.lat,
lng: leg.start_location.lng,
},
endLocation: {
lat: leg.end_location.lat,
lng: leg.end_location.lng,
},
distanceText: leg.distance.text,
distanceMeters: leg.distance.value,
durationText: leg.duration.text,
durationSeconds: leg.duration.value,
steps: leg.steps.map((step) => ({
instruction: step.html_instructions.replace(/<[^>]*>/g, ''),
distanceText: step.distance.text,
distanceMeters: step.distance.value,
durationText: step.duration.text,
durationSeconds: step.duration.value,
startLocation: {
lat: step.start_location.lat,
lng: step.start_location.lng,
},
endLocation: {
lat: step.end_location.lat,
lng: step.end_location.lng,
},
travelMode: step.travel_mode,
maneuver: step.maneuver ?? null,
})),
})),
overviewPolyline: route.overview_polyline.points,
warnings: route.warnings ?? [],
waypointOrder: route.waypoint_order ?? [],
})
)
const primaryRoute = routes[0]
const primaryLeg = primaryRoute?.legs[0]
return {
success: true,
output: {
routes,
distanceText: primaryLeg?.distanceText ?? '',
distanceMeters: primaryLeg?.distanceMeters ?? 0,
durationText: primaryLeg?.durationText ?? '',
durationSeconds: primaryLeg?.durationSeconds ?? 0,
startAddress: primaryLeg?.startAddress ?? '',
endAddress: primaryLeg?.endAddress ?? '',
steps: primaryLeg?.steps ?? [],
polyline: primaryRoute?.overviewPolyline ?? '',
},
}
},
outputs: {
routes: {
type: 'array',
description: 'All available routes',
items: {
type: 'object',
properties: {
summary: { type: 'string', description: 'Route summary (main road names)' },
legs: { type: 'array', description: 'Route legs (segments between waypoints)' },
overviewPolyline: {
type: 'string',
description: 'Encoded polyline for the entire route',
},
warnings: { type: 'array', description: 'Route warnings' },
waypointOrder: { type: 'array', description: 'Optimized waypoint order (if requested)' },
},
},
},
distanceText: {
type: 'string',
description: 'Total distance as human-readable text (e.g., "5.2 km")',
},
distanceMeters: {
type: 'number',
description: 'Total distance in meters',
},
durationText: {
type: 'string',
description: 'Total duration as human-readable text (e.g., "15 mins")',
},
durationSeconds: {
type: 'number',
description: 'Total duration in seconds',
},
startAddress: {
type: 'string',
description: 'Resolved starting address',
},
endAddress: {
type: 'string',
description: 'Resolved ending address',
},
steps: {
type: 'array',
description: 'Turn-by-turn navigation instructions',
items: {
type: 'object',
properties: {
instruction: { type: 'string', description: 'Navigation instruction (HTML stripped)' },
distanceText: { type: 'string', description: 'Step distance as text' },
distanceMeters: { type: 'number', description: 'Step distance in meters' },
durationText: { type: 'string', description: 'Step duration as text' },
durationSeconds: { type: 'number', description: 'Step duration in seconds' },
startLocation: { type: 'object', description: 'Step start coordinates' },
endLocation: { type: 'object', description: 'Step end coordinates' },
travelMode: { type: 'string', description: 'Travel mode for this step' },
maneuver: {
type: 'string',
description: 'Maneuver type (turn-left, etc.)',
optional: true,
},
},
},
},
polyline: {
type: 'string',
description: 'Encoded polyline for the primary route',
},
},
}

View File

@@ -0,0 +1,181 @@
import type {
GoogleMapsDistanceMatrixParams,
GoogleMapsDistanceMatrixResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsDistanceMatrixTool: ToolConfig<
GoogleMapsDistanceMatrixParams,
GoogleMapsDistanceMatrixResponse
> = {
id: 'google_maps_distance_matrix',
name: 'Google Maps Distance Matrix',
description: 'Calculate travel distance and time between multiple origins and destinations',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
origin: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Origin location (address or lat,lng)',
},
destinations: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description: 'Array of destination locations',
},
mode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Travel mode: driving, walking, bicycling, or transit',
},
avoid: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Features to avoid: tolls, highways, or ferries',
},
units: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Unit system: metric or imperial',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for results (e.g., en, es, fr)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/distancematrix/json')
url.searchParams.set('origins', params.origin.trim())
url.searchParams.set('destinations', params.destinations.join('|'))
url.searchParams.set('key', params.apiKey.trim())
if (params.mode) {
url.searchParams.set('mode', params.mode)
}
if (params.avoid) {
url.searchParams.set('avoid', params.avoid)
}
if (params.units) {
url.searchParams.set('units', params.units)
}
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Distance matrix request failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const rows = data.rows.map(
(row: {
elements: Array<{
distance?: { text: string; value: number }
duration?: { text: string; value: number }
duration_in_traffic?: { text: string; value: number }
status: string
}>
}) => ({
elements: row.elements.map((element) => ({
distanceText: element.distance?.text ?? 'N/A',
distanceMeters: element.distance?.value ?? 0,
durationText: element.duration?.text ?? 'N/A',
durationSeconds: element.duration?.value ?? 0,
durationInTrafficText: element.duration_in_traffic?.text ?? null,
durationInTrafficSeconds: element.duration_in_traffic?.value ?? null,
status: element.status,
})),
})
)
return {
success: true,
output: {
originAddresses: data.origin_addresses ?? [],
destinationAddresses: data.destination_addresses ?? [],
rows,
},
}
},
outputs: {
originAddresses: {
type: 'array',
description: 'Resolved origin addresses',
items: {
type: 'string',
},
},
destinationAddresses: {
type: 'array',
description: 'Resolved destination addresses',
items: {
type: 'string',
},
},
rows: {
type: 'array',
description: 'Distance matrix rows (one per origin)',
items: {
type: 'object',
properties: {
elements: {
type: 'array',
description: 'Elements (one per destination)',
items: {
type: 'object',
properties: {
distanceText: { type: 'string', description: 'Distance as text (e.g., "5.2 km")' },
distanceMeters: { type: 'number', description: 'Distance in meters' },
durationText: { type: 'string', description: 'Duration as text (e.g., "15 mins")' },
durationSeconds: { type: 'number', description: 'Duration in seconds' },
durationInTrafficText: {
type: 'string',
description: 'Duration in traffic as text',
optional: true,
},
durationInTrafficSeconds: {
type: 'number',
description: 'Duration in traffic in seconds',
optional: true,
},
status: {
type: 'string',
description: 'Element status (OK, NOT_FOUND, ZERO_RESULTS)',
},
},
},
},
},
},
},
},
}

View File

@@ -0,0 +1,91 @@
import type {
GoogleMapsElevationParams,
GoogleMapsElevationResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsElevationTool: ToolConfig<
GoogleMapsElevationParams,
GoogleMapsElevationResponse
> = {
id: 'google_maps_elevation',
name: 'Google Maps Elevation',
description: 'Get elevation data for a location',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
lat: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Longitude coordinate',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/elevation/json')
url.searchParams.set('locations', `${params.lat},${params.lng}`)
url.searchParams.set('key', params.apiKey.trim())
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Elevation request failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const result = data.results[0]
return {
success: true,
output: {
elevation: result.elevation,
lat: result.location.lat,
lng: result.location.lng,
resolution: result.resolution,
},
}
},
outputs: {
elevation: {
type: 'number',
description: 'Elevation in meters above sea level (negative for below)',
},
lat: {
type: 'number',
description: 'Latitude of the elevation sample',
},
lng: {
type: 'number',
description: 'Longitude of the elevation sample',
},
resolution: {
type: 'number',
description:
'Maximum distance between data points (meters) from which elevation was interpolated',
},
},
}

View File

@@ -0,0 +1,130 @@
import type { GoogleMapsGeocodeParams, GoogleMapsGeocodeResponse } from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsGeocodeTool: ToolConfig<GoogleMapsGeocodeParams, GoogleMapsGeocodeResponse> =
{
id: 'google_maps_geocode',
name: 'Google Maps Geocode',
description: 'Convert an address into geographic coordinates (latitude and longitude)',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
address: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The address to geocode',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for results (e.g., en, es, fr)',
},
region: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Region bias as a ccTLD code (e.g., us, uk)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/geocode/json')
url.searchParams.set('address', params.address.trim())
url.searchParams.set('key', params.apiKey.trim())
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
if (params.region) {
url.searchParams.set('region', params.region.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Geocoding failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const result = data.results[0]
const location = result.geometry.location
return {
success: true,
output: {
formattedAddress: result.formatted_address,
lat: location.lat,
lng: location.lng,
location: {
lat: location.lat,
lng: location.lng,
},
placeId: result.place_id,
addressComponents: (result.address_components || []).map(
(comp: { long_name: string; short_name: string; types: string[] }) => ({
longName: comp.long_name,
shortName: comp.short_name,
types: comp.types,
})
),
locationType: result.geometry.location_type,
},
}
},
outputs: {
formattedAddress: {
type: 'string',
description: 'The formatted address string',
},
lat: {
type: 'number',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
description: 'Longitude coordinate',
},
location: {
type: 'json',
description: 'Location object with lat and lng',
},
placeId: {
type: 'string',
description: 'Google Place ID for this location',
},
addressComponents: {
type: 'array',
description: 'Detailed address components',
items: {
type: 'object',
properties: {
longName: { type: 'string', description: 'Full name of the component' },
shortName: { type: 'string', description: 'Abbreviated name' },
types: { type: 'array', description: 'Component types' },
},
},
},
locationType: {
type: 'string',
description: 'Location accuracy type (ROOFTOP, RANGE_INTERPOLATED, etc.)',
},
},
}

View File

@@ -0,0 +1,165 @@
import type {
GoogleMapsGeolocateParams,
GoogleMapsGeolocateResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsGeolocateTool: ToolConfig<
GoogleMapsGeolocateParams,
GoogleMapsGeolocateResponse
> = {
id: 'google_maps_geolocate',
name: 'Google Maps Geolocate',
description: 'Geolocate a device using WiFi access points, cell towers, or IP address',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key with Geolocation API enabled',
},
homeMobileCountryCode: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Home mobile country code (MCC)',
},
homeMobileNetworkCode: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Home mobile network code (MNC)',
},
radioType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Radio type: lte, gsm, cdma, wcdma, or nr',
},
carrier: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Carrier name',
},
considerIp: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to use IP address for geolocation (default: true)',
},
cellTowers: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description:
'Array of cell tower objects with cellId, locationAreaCode, mobileCountryCode, mobileNetworkCode',
},
wifiAccessPoints: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description:
'Array of WiFi access point objects with macAddress (required), signalStrength, etc.',
},
},
request: {
url: (params) => {
return `https://www.googleapis.com/geolocation/v1/geolocate?key=${params.apiKey.trim()}`
},
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: {
homeMobileCountryCode?: number
homeMobileNetworkCode?: number
radioType?: string
carrier?: string
considerIp?: boolean
cellTowers?: Array<{
cellId: number
locationAreaCode: number
mobileCountryCode: number
mobileNetworkCode: number
age?: number
signalStrength?: number
timingAdvance?: number
}>
wifiAccessPoints?: Array<{
macAddress: string
signalStrength?: number
age?: number
channel?: number
signalToNoiseRatio?: number
}>
} = {}
if (params.homeMobileCountryCode !== undefined) {
body.homeMobileCountryCode = params.homeMobileCountryCode
}
if (params.homeMobileNetworkCode !== undefined) {
body.homeMobileNetworkCode = params.homeMobileNetworkCode
}
if (params.radioType) {
body.radioType = params.radioType
}
if (params.carrier) {
body.carrier = params.carrier
}
if (params.considerIp !== undefined) {
body.considerIp = params.considerIp
}
if (params.cellTowers && params.cellTowers.length > 0) {
body.cellTowers = params.cellTowers
}
if (params.wifiAccessPoints && params.wifiAccessPoints.length > 0) {
body.wifiAccessPoints = params.wifiAccessPoints
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.error) {
throw new Error(`Geolocation failed: ${data.error.message || 'Unknown error'}`)
}
return {
success: true,
output: {
lat: data.location?.lat || 0,
lng: data.location?.lng || 0,
accuracy: data.accuracy || 0,
},
}
},
outputs: {
lat: {
type: 'number',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
description: 'Longitude coordinate',
},
accuracy: {
type: 'number',
description: 'Accuracy radius in meters',
},
},
}

View File

@@ -0,0 +1,32 @@
import { googleMapsAirQualityTool } from '@/tools/google_maps/air_quality'
import { googleMapsDirectionsTool } from '@/tools/google_maps/directions'
import { googleMapsDistanceMatrixTool } from '@/tools/google_maps/distance_matrix'
import { googleMapsElevationTool } from '@/tools/google_maps/elevation'
import { googleMapsGeocodeTool } from '@/tools/google_maps/geocode'
import { googleMapsGeolocateTool } from '@/tools/google_maps/geolocate'
import { googleMapsPlaceDetailsTool } from '@/tools/google_maps/place_details'
import { googleMapsPlacesSearchTool } from '@/tools/google_maps/places_search'
import { googleMapsReverseGeocodeTool } from '@/tools/google_maps/reverse_geocode'
import { googleMapsSnapToRoadsTool } from '@/tools/google_maps/snap_to_roads'
import { googleMapsSpeedLimitsTool } from '@/tools/google_maps/speed_limits'
import { googleMapsTimezoneTool } from '@/tools/google_maps/timezone'
import { googleMapsValidateAddressTool } from '@/tools/google_maps/validate_address'
export {
googleMapsAirQualityTool,
googleMapsDirectionsTool,
googleMapsDistanceMatrixTool,
googleMapsElevationTool,
googleMapsGeocodeTool,
googleMapsGeolocateTool,
googleMapsPlaceDetailsTool,
googleMapsPlacesSearchTool,
googleMapsReverseGeocodeTool,
googleMapsSnapToRoadsTool,
googleMapsSpeedLimitsTool,
googleMapsTimezoneTool,
googleMapsValidateAddressTool,
}
// Export types
export * from './types'

View File

@@ -0,0 +1,274 @@
import type {
GoogleMapsPlaceDetailsParams,
GoogleMapsPlaceDetailsResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsPlaceDetailsTool: ToolConfig<
GoogleMapsPlaceDetailsParams,
GoogleMapsPlaceDetailsResponse
> = {
id: 'google_maps_place_details',
name: 'Google Maps Place Details',
description: 'Get detailed information about a specific place',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
placeId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Google Place ID',
},
fields: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated list of fields to return',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for results (e.g., en, es, fr)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/place/details/json')
url.searchParams.set('place_id', params.placeId.trim())
url.searchParams.set('key', params.apiKey.trim())
// Default fields if not specified - comprehensive list
const fields =
params.fields ||
'place_id,name,formatted_address,geometry,types,rating,user_ratings_total,price_level,website,formatted_phone_number,international_phone_number,opening_hours,reviews,photos,url,utc_offset,vicinity,business_status'
url.searchParams.set('fields', fields)
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Place details request failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const place = data.result
const reviews = (place.reviews || []).map(
(review: {
author_name: string
author_url?: string
profile_photo_url?: string
rating: number
text: string
time: number
relative_time_description: string
}) => ({
authorName: review.author_name,
authorUrl: review.author_url ?? null,
profilePhotoUrl: review.profile_photo_url ?? null,
rating: review.rating,
text: review.text,
time: review.time,
relativeTimeDescription: review.relative_time_description,
})
)
const photos = (place.photos || []).map(
(photo: {
photo_reference: string
height: number
width: number
html_attributions: string[]
}) => ({
photoReference: photo.photo_reference,
height: photo.height,
width: photo.width,
htmlAttributions: photo.html_attributions ?? [],
})
)
// Destructure opening hours
const openNow = place.opening_hours?.open_now ?? null
const weekdayText = place.opening_hours?.weekday_text ?? []
// Extract location
const lat = place.geometry?.location?.lat ?? null
const lng = place.geometry?.location?.lng ?? null
return {
success: true,
output: {
placeId: place.place_id,
name: place.name ?? null,
formattedAddress: place.formatted_address ?? null,
lat,
lng,
types: place.types ?? [],
rating: place.rating ?? null,
userRatingsTotal: place.user_ratings_total ?? null,
priceLevel: place.price_level ?? null,
website: place.website ?? null,
phoneNumber: place.formatted_phone_number ?? null,
internationalPhoneNumber: place.international_phone_number ?? null,
openNow,
weekdayText,
reviews,
photos,
url: place.url ?? null,
utcOffset: place.utc_offset ?? null,
vicinity: place.vicinity ?? null,
businessStatus: place.business_status ?? null,
},
}
},
outputs: {
placeId: {
type: 'string',
description: 'Google Place ID',
},
name: {
type: 'string',
description: 'Place name',
optional: true,
},
formattedAddress: {
type: 'string',
description: 'Formatted street address',
optional: true,
},
lat: {
type: 'number',
description: 'Latitude coordinate',
optional: true,
},
lng: {
type: 'number',
description: 'Longitude coordinate',
optional: true,
},
types: {
type: 'array',
description: 'Place types (e.g., restaurant, cafe)',
items: {
type: 'string',
},
},
rating: {
type: 'number',
description: 'Average rating (1.0 to 5.0)',
optional: true,
},
userRatingsTotal: {
type: 'number',
description: 'Total number of user ratings',
optional: true,
},
priceLevel: {
type: 'number',
description: 'Price level (0=Free, 1=Inexpensive, 2=Moderate, 3=Expensive, 4=Very Expensive)',
optional: true,
},
website: {
type: 'string',
description: 'Place website URL',
optional: true,
},
phoneNumber: {
type: 'string',
description: 'Local formatted phone number',
optional: true,
},
internationalPhoneNumber: {
type: 'string',
description: 'International formatted phone number',
optional: true,
},
openNow: {
type: 'boolean',
description: 'Whether the place is currently open',
optional: true,
},
weekdayText: {
type: 'array',
description: 'Opening hours formatted by day of week',
items: {
type: 'string',
},
},
reviews: {
type: 'array',
description: 'User reviews (up to 5 most relevant)',
items: {
type: 'object',
properties: {
authorName: { type: 'string', description: 'Reviewer name' },
authorUrl: { type: 'string', description: 'Reviewer profile URL', optional: true },
profilePhotoUrl: { type: 'string', description: 'Reviewer photo URL', optional: true },
rating: { type: 'number', description: 'Rating given (1-5)' },
text: { type: 'string', description: 'Review text' },
time: { type: 'number', description: 'Review timestamp (Unix epoch)' },
relativeTimeDescription: {
type: 'string',
description: 'Relative time (e.g., "a month ago")',
},
},
},
},
photos: {
type: 'array',
description: 'Place photos',
items: {
type: 'object',
properties: {
photoReference: { type: 'string', description: 'Photo reference for Place Photos API' },
height: { type: 'number', description: 'Photo height in pixels' },
width: { type: 'number', description: 'Photo width in pixels' },
htmlAttributions: { type: 'array', description: 'Required attributions' },
},
},
},
url: {
type: 'string',
description: 'Google Maps URL for the place',
optional: true,
},
utcOffset: {
type: 'number',
description: 'UTC offset in minutes',
optional: true,
},
vicinity: {
type: 'string',
description: 'Simplified address (neighborhood/street)',
optional: true,
},
businessStatus: {
type: 'string',
description: 'Business status (OPERATIONAL, CLOSED_TEMPORARILY, CLOSED_PERMANENTLY)',
optional: true,
},
},
}

View File

@@ -0,0 +1,170 @@
import type {
GoogleMapsPlacesSearchParams,
GoogleMapsPlacesSearchResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsPlacesSearchTool: ToolConfig<
GoogleMapsPlacesSearchParams,
GoogleMapsPlacesSearchResponse
> = {
id: 'google_maps_places_search',
name: 'Google Maps Places Search',
description: 'Search for places using a text query',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
query: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Search query (e.g., "restaurants in Times Square")',
},
location: {
type: 'json',
required: false,
visibility: 'user-or-llm',
description: 'Location to bias results towards ({lat, lng})',
},
radius: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Search radius in meters',
},
type: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Place type filter (e.g., restaurant, cafe, hotel)',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for results (e.g., en, es, fr)',
},
region: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Region bias as a ccTLD code (e.g., us, uk)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/place/textsearch/json')
url.searchParams.set('query', params.query.trim())
url.searchParams.set('key', params.apiKey.trim())
if (params.location) {
url.searchParams.set('location', `${params.location.lat},${params.location.lng}`)
}
if (params.radius) {
url.searchParams.set('radius', params.radius.toString())
}
if (params.type) {
url.searchParams.set('type', params.type)
}
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
if (params.region) {
url.searchParams.set('region', params.region.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK' && data.status !== 'ZERO_RESULTS') {
throw new Error(
`Places search failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const places = (data.results || []).map(
(place: {
place_id: string
name: string
formatted_address: string
geometry: { location: { lat: number; lng: number } }
types: string[]
rating?: number
user_ratings_total?: number
price_level?: number
opening_hours?: { open_now: boolean }
photos?: Array<{ photo_reference: string; height: number; width: number }>
business_status?: string
}) => ({
placeId: place.place_id,
name: place.name,
formattedAddress: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
types: place.types ?? [],
rating: place.rating ?? null,
userRatingsTotal: place.user_ratings_total ?? null,
priceLevel: place.price_level ?? null,
openNow: place.opening_hours?.open_now ?? null,
photoReference: place.photos?.[0]?.photo_reference ?? null,
businessStatus: place.business_status ?? null,
})
)
return {
success: true,
output: {
places,
nextPageToken: data.next_page_token ?? null,
},
}
},
outputs: {
places: {
type: 'array',
description: 'List of places found',
items: {
type: 'object',
properties: {
placeId: { type: 'string', description: 'Google Place ID' },
name: { type: 'string', description: 'Place name' },
formattedAddress: { type: 'string', description: 'Formatted address' },
lat: { type: 'number', description: 'Latitude' },
lng: { type: 'number', description: 'Longitude' },
types: { type: 'array', description: 'Place types' },
rating: { type: 'number', description: 'Average rating (1-5)', optional: true },
userRatingsTotal: { type: 'number', description: 'Number of ratings', optional: true },
priceLevel: { type: 'number', description: 'Price level (0-4)', optional: true },
openNow: { type: 'boolean', description: 'Whether currently open', optional: true },
photoReference: {
type: 'string',
description: 'Photo reference for Photos API',
optional: true,
},
businessStatus: { type: 'string', description: 'Business status', optional: true },
},
},
},
nextPageToken: {
type: 'string',
description: 'Token for fetching the next page of results',
optional: true,
},
},
}

View File

@@ -0,0 +1,117 @@
import type {
GoogleMapsReverseGeocodeParams,
GoogleMapsReverseGeocodeResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsReverseGeocodeTool: ToolConfig<
GoogleMapsReverseGeocodeParams,
GoogleMapsReverseGeocodeResponse
> = {
id: 'google_maps_reverse_geocode',
name: 'Google Maps Reverse Geocode',
description:
'Convert geographic coordinates (latitude and longitude) into a human-readable address',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
lat: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Longitude coordinate',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for results (e.g., en, es, fr)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/geocode/json')
url.searchParams.set('latlng', `${params.lat},${params.lng}`)
url.searchParams.set('key', params.apiKey.trim())
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Reverse geocoding failed: ${data.status} - ${data.error_message || 'Unknown error'}`
)
}
const result = data.results[0]
return {
success: true,
output: {
formattedAddress: result.formatted_address,
placeId: result.place_id,
addressComponents: (result.address_components || []).map(
(comp: { long_name: string; short_name: string; types: string[] }) => ({
longName: comp.long_name,
shortName: comp.short_name,
types: comp.types,
})
),
types: result.types || [],
},
}
},
outputs: {
formattedAddress: {
type: 'string',
description: 'The formatted address string',
},
placeId: {
type: 'string',
description: 'Google Place ID for this location',
},
addressComponents: {
type: 'array',
description: 'Detailed address components',
items: {
type: 'object',
properties: {
longName: { type: 'string', description: 'Full name of the component' },
shortName: { type: 'string', description: 'Abbreviated name' },
types: { type: 'array', description: 'Component types' },
},
},
},
types: {
type: 'array',
description: 'Address types (e.g., street_address, route)',
items: {
type: 'string',
},
},
},
}

View File

@@ -0,0 +1,113 @@
import type {
GoogleMapsSnapToRoadsParams,
GoogleMapsSnapToRoadsResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsSnapToRoadsTool: ToolConfig<
GoogleMapsSnapToRoadsParams,
GoogleMapsSnapToRoadsResponse
> = {
id: 'google_maps_snap_to_roads',
name: 'Google Maps Snap to Roads',
description: 'Snap GPS coordinates to the nearest road segment',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key with Roads API enabled',
},
path: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description:
'Pipe-separated list of lat,lng coordinates (e.g., "60.170880,24.942795|60.170879,24.942796")',
},
interpolate: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to interpolate additional points along the road',
},
},
request: {
url: (params) => {
const url = new URL('https://roads.googleapis.com/v1/snapToRoads')
url.searchParams.set('path', params.path.trim())
url.searchParams.set('key', params.apiKey.trim())
if (params.interpolate !== undefined) {
url.searchParams.set('interpolate', String(params.interpolate))
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.error) {
throw new Error(`Snap to Roads failed: ${data.error.message || 'Unknown error'}`)
}
const snappedPoints = (data.snappedPoints || []).map(
(point: {
location: { latitude: number; longitude: number }
originalIndex?: number
placeId: string
}) => ({
location: {
lat: point.location.latitude,
lng: point.location.longitude,
},
originalIndex: point.originalIndex,
placeId: point.placeId,
})
)
return {
success: true,
output: {
snappedPoints,
warningMessage: data.warningMessage || null,
},
}
},
outputs: {
snappedPoints: {
type: 'array',
description: 'Array of snapped points on roads',
items: {
type: 'object',
properties: {
location: {
type: 'object',
description: 'Snapped location coordinates',
properties: {
lat: { type: 'number', description: 'Latitude' },
lng: { type: 'number', description: 'Longitude' },
},
},
originalIndex: {
type: 'number',
description: 'Index in the original path (if not interpolated)',
},
placeId: { type: 'string', description: 'Place ID for this road segment' },
},
},
},
warningMessage: {
type: 'string',
description: 'Warning message if any (e.g., if points could not be snapped)',
},
},
}

View File

@@ -0,0 +1,133 @@
import type {
GoogleMapsSpeedLimitsParams,
GoogleMapsSpeedLimitsResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsSpeedLimitsTool: ToolConfig<
GoogleMapsSpeedLimitsParams,
GoogleMapsSpeedLimitsResponse
> = {
id: 'google_maps_speed_limits',
name: 'Google Maps Speed Limits',
description: 'Get speed limits for road segments',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key with Roads API enabled',
},
path: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Pipe-separated list of lat,lng coordinates (e.g., "60.170880,24.942795|60.170879,24.942796")',
},
placeIds: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description: 'Array of Place IDs for road segments (alternative to path)',
},
},
request: {
url: (params) => {
const url = new URL('https://roads.googleapis.com/v1/speedLimits')
url.searchParams.set('key', params.apiKey.trim())
if (params.path) {
url.searchParams.set('path', params.path.trim())
}
if (params.placeIds && params.placeIds.length > 0) {
for (const placeId of params.placeIds) {
url.searchParams.append('placeId', placeId.trim())
}
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.error) {
throw new Error(`Speed Limits failed: ${data.error.message || 'Unknown error'}`)
}
const speedLimits = (data.speedLimits || []).map(
(limit: { placeId: string; speedLimit: number; units: 'KPH' | 'MPH' }) => ({
placeId: limit.placeId,
speedLimit: limit.speedLimit,
units: limit.units,
})
)
const snappedPoints = (data.snappedPoints || []).map(
(point: {
location: { latitude: number; longitude: number }
originalIndex?: number
placeId: string
}) => ({
location: {
lat: point.location.latitude,
lng: point.location.longitude,
},
originalIndex: point.originalIndex,
placeId: point.placeId,
})
)
return {
success: true,
output: {
speedLimits,
snappedPoints,
},
}
},
outputs: {
speedLimits: {
type: 'array',
description: 'Array of speed limits for road segments',
items: {
type: 'object',
properties: {
placeId: { type: 'string', description: 'Place ID for the road segment' },
speedLimit: { type: 'number', description: 'Speed limit value' },
units: { type: 'string', description: 'Speed limit units (KPH or MPH)' },
},
},
},
snappedPoints: {
type: 'array',
description: 'Array of snapped points corresponding to the speed limits',
items: {
type: 'object',
properties: {
location: {
type: 'object',
description: 'Snapped location coordinates',
properties: {
lat: { type: 'number', description: 'Latitude' },
lng: { type: 'number', description: 'Longitude' },
},
},
originalIndex: { type: 'number', description: 'Index in the original path' },
placeId: { type: 'string', description: 'Place ID for this road segment' },
},
},
},
},
}

View File

@@ -0,0 +1,120 @@
import type {
GoogleMapsTimezoneParams,
GoogleMapsTimezoneResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsTimezoneTool: ToolConfig<
GoogleMapsTimezoneParams,
GoogleMapsTimezoneResponse
> = {
id: 'google_maps_timezone',
name: 'Google Maps Timezone',
description: 'Get timezone information for a location',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key',
},
lat: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
required: true,
visibility: 'user-or-llm',
description: 'Longitude coordinate',
},
timestamp: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Unix timestamp to determine DST offset (defaults to current time)',
},
language: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Language code for timezone name (e.g., en, es, fr)',
},
},
request: {
url: (params) => {
const url = new URL('https://maps.googleapis.com/maps/api/timezone/json')
url.searchParams.set('location', `${params.lat},${params.lng}`)
// Use provided timestamp or current time
const timestamp = params.timestamp ?? Math.floor(Date.now() / 1000)
url.searchParams.set('timestamp', timestamp.toString())
url.searchParams.set('key', params.apiKey.trim())
if (params.language) {
url.searchParams.set('language', params.language.trim())
}
return url.toString()
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.status !== 'OK') {
throw new Error(
`Timezone request failed: ${data.status} - ${data.errorMessage || 'Unknown error'}`
)
}
// Calculate total offset
const totalOffsetSeconds = data.rawOffset + data.dstOffset
const totalOffsetHours = totalOffsetSeconds / 3600
return {
success: true,
output: {
timeZoneId: data.timeZoneId,
timeZoneName: data.timeZoneName,
rawOffset: data.rawOffset,
dstOffset: data.dstOffset,
totalOffsetSeconds,
totalOffsetHours,
},
}
},
outputs: {
timeZoneId: {
type: 'string',
description: 'IANA timezone ID (e.g., "America/New_York", "Europe/London")',
},
timeZoneName: {
type: 'string',
description: 'Localized timezone name (e.g., "Eastern Daylight Time")',
},
rawOffset: {
type: 'number',
description: 'UTC offset in seconds (without DST)',
},
dstOffset: {
type: 'number',
description: 'Daylight Saving Time offset in seconds (0 if not in DST)',
},
totalOffsetSeconds: {
type: 'number',
description: 'Total UTC offset in seconds (rawOffset + dstOffset)',
},
totalOffsetHours: {
type: 'number',
description: 'Total UTC offset in hours (e.g., -5 for EST, -4 for EDT)',
},
},
}

View File

@@ -0,0 +1,481 @@
import type { ToolResponse } from '@/tools/types'
/**
* Common location type
*/
export interface LatLng {
lat: number
lng: number
}
/**
* Address component from geocoding
*/
export interface AddressComponent {
longName: string
shortName: string
types: string[]
}
/**
* Snapped point from Roads API
*/
export interface SnappedPoint {
location: LatLng
originalIndex?: number
placeId: string
}
/**
* Speed limit info from Roads API
*/
export interface SpeedLimit {
placeId: string
speedLimit: number
units: 'KPH' | 'MPH'
}
/**
* Cell tower info for geolocation
*/
export interface CellTower {
cellId: number
locationAreaCode: number
mobileCountryCode: number
mobileNetworkCode: number
age?: number
signalStrength?: number
timingAdvance?: number
}
/**
* WiFi access point info for geolocation
*/
export interface WifiAccessPoint {
macAddress: string
signalStrength?: number
age?: number
channel?: number
signalToNoiseRatio?: number
}
/**
* Air quality index info
*/
export interface AirQualityIndex {
code: string
displayName: string
aqi: number
aqiDisplay: string
color: {
red: number
green: number
blue: number
}
category: string
dominantPollutant: string
}
/**
* Pollutant concentration info
*/
export interface Pollutant {
code: string
displayName: string
fullName: string
concentration: {
value: number
units: string
}
additionalInfo?: {
sources: string
effects: string
}
}
// Geocode
// ============================================================================
export interface GoogleMapsGeocodeParams {
apiKey: string
address: string
language?: string
region?: string
}
export interface GoogleMapsGeocodeResponse extends ToolResponse {
output: {
formattedAddress: string
lat: number
lng: number
location: LatLng
placeId: string
addressComponents: AddressComponent[]
locationType: string
}
}
// ============================================================================
// Reverse Geocode
// ============================================================================
export interface GoogleMapsReverseGeocodeParams {
apiKey: string
lat: number
lng: number
language?: string
}
export interface GoogleMapsReverseGeocodeResponse extends ToolResponse {
output: {
formattedAddress: string
placeId: string
addressComponents: AddressComponent[]
types: string[]
}
}
// ============================================================================
// Directions
// ============================================================================
export interface DirectionsStep {
instruction: string
distanceText: string
distanceMeters: number
durationText: string
durationSeconds: number
startLocation: LatLng
endLocation: LatLng
travelMode: string
maneuver: string | null
}
export interface DirectionsLeg {
startAddress: string
endAddress: string
startLocation: LatLng
endLocation: LatLng
distanceText: string
distanceMeters: number
durationText: string
durationSeconds: number
steps: DirectionsStep[]
}
export interface DirectionsRoute {
summary: string
legs: DirectionsLeg[]
overviewPolyline: string
warnings: string[]
waypointOrder: number[]
}
export interface GoogleMapsDirectionsParams {
apiKey: string
origin: string
destination: string
mode?: 'driving' | 'walking' | 'bicycling' | 'transit'
avoid?: string
waypoints?: string[]
units?: 'metric' | 'imperial'
language?: string
}
export interface GoogleMapsDirectionsResponse extends ToolResponse {
output: {
routes: DirectionsRoute[]
distanceText: string
distanceMeters: number
durationText: string
durationSeconds: number
startAddress: string
endAddress: string
steps: DirectionsStep[]
polyline: string
}
}
// ============================================================================
// Distance Matrix
// ============================================================================
export interface DistanceMatrixElement {
distanceText: string
distanceMeters: number
durationText: string
durationSeconds: number
durationInTrafficText: string | null
durationInTrafficSeconds: number | null
status: string
}
export interface DistanceMatrixRow {
elements: DistanceMatrixElement[]
}
export interface GoogleMapsDistanceMatrixParams {
apiKey: string
origin: string
destinations: string[]
mode?: 'driving' | 'walking' | 'bicycling' | 'transit'
avoid?: string
units?: 'metric' | 'imperial'
language?: string
}
export interface GoogleMapsDistanceMatrixResponse extends ToolResponse {
output: {
originAddresses: string[]
destinationAddresses: string[]
rows: DistanceMatrixRow[]
}
}
// ============================================================================
// Places Search
// ============================================================================
export interface PlaceResult {
placeId: string
name: string
formattedAddress: string
lat: number
lng: number
types: string[]
rating: number | null
userRatingsTotal: number | null
priceLevel: number | null
openNow: boolean | null
photoReference: string | null
businessStatus: string | null
}
export interface GoogleMapsPlacesSearchParams {
apiKey: string
query: string
location?: LatLng
radius?: number
type?: string
language?: string
region?: string
}
export interface GoogleMapsPlacesSearchResponse extends ToolResponse {
output: {
places: PlaceResult[]
nextPageToken: string | null
}
}
// ============================================================================
// Place Details
// ============================================================================
export interface PlaceReview {
authorName: string
authorUrl: string | null
profilePhotoUrl: string | null
rating: number
text: string
time: number
relativeTimeDescription: string
}
export interface PlacePhoto {
photoReference: string
height: number
width: number
htmlAttributions: string[]
}
export interface GoogleMapsPlaceDetailsParams {
apiKey: string
placeId: string
fields?: string
language?: string
}
export interface GoogleMapsPlaceDetailsResponse extends ToolResponse {
output: {
placeId: string
name: string | null
formattedAddress: string | null
lat: number | null
lng: number | null
types: string[]
rating: number | null
userRatingsTotal: number | null
priceLevel: number | null
website: string | null
phoneNumber: string | null
internationalPhoneNumber: string | null
openNow: boolean | null
weekdayText: string[]
reviews: PlaceReview[]
photos: PlacePhoto[]
url: string | null
utcOffset: number | null
vicinity: string | null
businessStatus: string | null
}
}
// ============================================================================
// Elevation
// ============================================================================
export interface GoogleMapsElevationParams {
apiKey: string
lat: number
lng: number
}
export interface GoogleMapsElevationResponse extends ToolResponse {
output: {
elevation: number
lat: number
lng: number
resolution: number
}
}
// ============================================================================
// Timezone
// ============================================================================
export interface GoogleMapsTimezoneParams {
apiKey: string
lat: number
lng: number
timestamp?: number
language?: string
}
export interface GoogleMapsTimezoneResponse extends ToolResponse {
output: {
timeZoneId: string
timeZoneName: string
rawOffset: number
dstOffset: number
totalOffsetSeconds: number
totalOffsetHours: number
}
}
// ============================================================================
// Snap to Roads
// ============================================================================
export interface GoogleMapsSnapToRoadsParams {
apiKey: string
path: string
interpolate?: boolean
}
export interface GoogleMapsSnapToRoadsResponse extends ToolResponse {
output: {
snappedPoints: SnappedPoint[]
warningMessage: string | null
}
}
// ============================================================================
// Speed Limits
// ============================================================================
export interface GoogleMapsSpeedLimitsParams {
apiKey: string
path?: string
placeIds?: string[]
}
export interface GoogleMapsSpeedLimitsResponse extends ToolResponse {
output: {
speedLimits: SpeedLimit[]
snappedPoints: SnappedPoint[]
}
}
// ============================================================================
// Validate Address
// ============================================================================
export interface GoogleMapsValidateAddressParams {
apiKey: string
address: string
regionCode?: string
locality?: string
enableUspsCass?: boolean
}
export interface GoogleMapsValidateAddressResponse extends ToolResponse {
output: {
formattedAddress: string
lat: number
lng: number
placeId: string
addressComplete: boolean
hasUnconfirmedComponents: boolean
hasInferredComponents: boolean
hasReplacedComponents: boolean
validationGranularity: string
geocodeGranularity: string
addressComponents: AddressComponent[]
missingComponentTypes: string[]
unconfirmedComponentTypes: string[]
unresolvedTokens: string[]
}
}
// ============================================================================
// Geolocate
// ============================================================================
export interface GoogleMapsGeolocateParams {
apiKey: string
homeMobileCountryCode?: number
homeMobileNetworkCode?: number
radioType?: 'lte' | 'gsm' | 'cdma' | 'wcdma' | 'nr'
carrier?: string
considerIp?: boolean
cellTowers?: CellTower[]
wifiAccessPoints?: WifiAccessPoint[]
}
export interface GoogleMapsGeolocateResponse extends ToolResponse {
output: {
lat: number
lng: number
accuracy: number
}
}
// ============================================================================
// Air Quality
// ============================================================================
export interface GoogleMapsAirQualityParams {
apiKey: string
lat: number
lng: number
languageCode?: string
}
export interface GoogleMapsAirQualityResponse extends ToolResponse {
output: {
dateTime: string
regionCode: string
indexes: AirQualityIndex[]
pollutants: Pollutant[]
healthRecommendations: {
generalPopulation: string
elderly: string
lungDiseasePopulation: string
heartDiseasePopulation: string
athletes: string
pregnantWomen: string
children: string
} | null
}
}

View File

@@ -0,0 +1,194 @@
import type {
GoogleMapsValidateAddressParams,
GoogleMapsValidateAddressResponse,
} from '@/tools/google_maps/types'
import type { ToolConfig } from '@/tools/types'
export const googleMapsValidateAddressTool: ToolConfig<
GoogleMapsValidateAddressParams,
GoogleMapsValidateAddressResponse
> = {
id: 'google_maps_validate_address',
name: 'Google Maps Validate Address',
description: 'Validate and standardize a postal address',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Google Maps API key with Address Validation API enabled',
},
address: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The address to validate (as a single string)',
},
regionCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'ISO 3166-1 alpha-2 country code (e.g., "US", "CA")',
},
locality: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'City or locality name',
},
enableUspsCass: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Enable USPS CASS validation for US addresses',
},
},
request: {
url: (params) => {
return `https://addressvalidation.googleapis.com/v1:validateAddress?key=${params.apiKey.trim()}`
},
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => {
const body: {
address: { addressLines: string[]; regionCode?: string; locality?: string }
enableUspsCass?: boolean
} = {
address: {
addressLines: [params.address.trim()],
},
}
if (params.regionCode) {
body.address.regionCode = params.regionCode.trim()
}
if (params.locality) {
body.address.locality = params.locality.trim()
}
if (params.enableUspsCass !== undefined) {
body.enableUspsCass = params.enableUspsCass
}
return body
},
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (data.error) {
throw new Error(`Address Validation failed: ${data.error.message || 'Unknown error'}`)
}
const result = data.result
const verdict = result?.verdict || {}
const address = result?.address || {}
const geocode = result?.geocode || {}
const addressComponents = (address.addressComponents || []).map(
(comp: {
componentName: { text: string; languageCode?: string }
componentType: string
confirmationLevel: string
}) => ({
longName: comp.componentName?.text || '',
shortName: comp.componentName?.text || '',
types: [comp.componentType],
})
)
return {
success: true,
output: {
formattedAddress: address.formattedAddress || '',
lat: geocode.location?.latitude || 0,
lng: geocode.location?.longitude || 0,
placeId: geocode.placeId || '',
addressComplete: verdict.addressComplete || false,
hasUnconfirmedComponents: verdict.hasUnconfirmedComponents || false,
hasInferredComponents: verdict.hasInferredComponents || false,
hasReplacedComponents: verdict.hasReplacedComponents || false,
validationGranularity: verdict.validationGranularity || '',
geocodeGranularity: verdict.geocodeGranularity || '',
addressComponents,
missingComponentTypes: address.missingComponentTypes || [],
unconfirmedComponentTypes: address.unconfirmedComponentTypes || [],
unresolvedTokens: address.unresolvedTokens || [],
},
}
},
outputs: {
formattedAddress: {
type: 'string',
description: 'The standardized formatted address',
},
lat: {
type: 'number',
description: 'Latitude coordinate',
},
lng: {
type: 'number',
description: 'Longitude coordinate',
},
placeId: {
type: 'string',
description: 'Google Place ID for this address',
},
addressComplete: {
type: 'boolean',
description: 'Whether the address is complete and deliverable',
},
hasUnconfirmedComponents: {
type: 'boolean',
description: 'Whether some address components could not be confirmed',
},
hasInferredComponents: {
type: 'boolean',
description: 'Whether some components were inferred (not in input)',
},
hasReplacedComponents: {
type: 'boolean',
description: 'Whether some components were replaced with canonical values',
},
validationGranularity: {
type: 'string',
description: 'Granularity of validation (PREMISE, SUB_PREMISE, ROUTE, etc.)',
},
geocodeGranularity: {
type: 'string',
description: 'Granularity of the geocode result',
},
addressComponents: {
type: 'array',
description: 'Detailed address components',
items: {
type: 'object',
properties: {
longName: { type: 'string', description: 'Full name of the component' },
shortName: { type: 'string', description: 'Abbreviated name' },
types: { type: 'array', description: 'Component types' },
},
},
},
missingComponentTypes: {
type: 'array',
description: 'Types of address components that are missing',
},
unconfirmedComponentTypes: {
type: 'array',
description: 'Types of components that could not be confirmed',
},
unresolvedTokens: {
type: 'array',
description: 'Input tokens that could not be resolved',
},
},
}

View File

@@ -1,7 +1,7 @@
import { createHmac } from 'crypto'
import { v4 as uuidv4 } from 'uuid'
import type { RequestResponse, WebhookRequestParams } from '@/tools/http/types'
import type { ToolConfig } from '@/tools/types'
import type { RequestResponse, WebhookRequestParams } from './types'
/**
* Generates HMAC-SHA256 signature for webhook payload

View File

@@ -1,8 +1,8 @@
import type { ToolConfig } from '@/tools/types'
import type {
IncidentioIncidentStatusesListParams,
IncidentioIncidentStatusesListResponse,
} from './types'
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
export const incidentStatusesListTool: ToolConfig<
IncidentioIncidentStatusesListParams,

View File

@@ -1,8 +1,8 @@
import type { ToolConfig } from '@/tools/types'
import type {
IncidentioIncidentTypesListParams,
IncidentioIncidentTypesListResponse,
} from './types'
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
export const incidentTypesListTool: ToolConfig<
IncidentioIncidentTypesListParams,

View File

@@ -1,5 +1,8 @@
import type {
IncidentioSeveritiesListParams,
IncidentioSeveritiesListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioSeveritiesListParams, IncidentioSeveritiesListResponse } from './types'
export const severitiesListTool: ToolConfig<
IncidentioSeveritiesListParams,

View File

@@ -1,5 +1,8 @@
import type {
IncidentioUsersListParams,
IncidentioUsersListResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioUsersListParams, IncidentioUsersListResponse } from './types'
export const usersListTool: ToolConfig<IncidentioUsersListParams, IncidentioUsersListResponse> = {
id: 'incidentio_users_list',

View File

@@ -1,5 +1,8 @@
import type {
IncidentioUsersShowParams,
IncidentioUsersShowResponse,
} from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { IncidentioUsersShowParams, IncidentioUsersShowResponse } from './types'
export const usersShowTool: ToolConfig<IncidentioUsersShowParams, IncidentioUsersShowResponse> = {
id: 'incidentio_users_show',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsCreateParams, WorkflowsCreateResponse } from './types'
export const workflowsCreateTool: ToolConfig<WorkflowsCreateParams, WorkflowsCreateResponse> = {
id: 'incidentio_workflows_create',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsDeleteParams, WorkflowsDeleteResponse } from './types'
export const workflowsDeleteTool: ToolConfig<WorkflowsDeleteParams, WorkflowsDeleteResponse> = {
id: 'incidentio_workflows_delete',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsListParams, WorkflowsListResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsListParams, WorkflowsListResponse } from './types'
export const workflowsListTool: ToolConfig<WorkflowsListParams, WorkflowsListResponse> = {
id: 'incidentio_workflows_list',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsShowParams, WorkflowsShowResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsShowParams, WorkflowsShowResponse } from './types'
export const workflowsShowTool: ToolConfig<WorkflowsShowParams, WorkflowsShowResponse> = {
id: 'incidentio_workflows_show',

View File

@@ -1,5 +1,5 @@
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from '@/tools/incidentio/types'
import type { ToolConfig } from '@/tools/types'
import type { WorkflowsUpdateParams, WorkflowsUpdateResponse } from './types'
export const workflowsUpdateTool: ToolConfig<WorkflowsUpdateParams, WorkflowsUpdateResponse> = {
id: 'incidentio_workflows_update',

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomGetCompany')
export interface IntercomGetCompanyParams {
accessToken: string
companyId: string

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomGetConversation')
export interface IntercomGetConversationParams {
accessToken: string
conversationId: string

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListCompanies')
export interface IntercomListCompaniesParams {
accessToken: string
per_page?: number

View File

@@ -1,9 +1,6 @@
import { createLogger } from '@sim/logger'
import { buildIntercomUrl, handleIntercomError } from '@/tools/intercom/types'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('IntercomListContacts')
export interface IntercomListContactsParams {
accessToken: string
per_page?: number

Some files were not shown because too many files have changed in this diff Show More