Compare commits

...

11 Commits

Author SHA1 Message Date
Siddharth Ganesan
86c3b82339 Add anvanced mode to messages 2026-01-29 13:19:48 -08:00
Siddharth Ganesan
d44c75f486 Add toggle, haven't tested 2026-01-29 13:17:27 -08:00
Siddharth Ganesan
2b026ded16 fix(copilot): hosted api key validation + credential validation (#3000)
* Fix

* Fix greptile

* Fix validation

* Fix comments

* Lint

* Fix

* remove passed in workspace id ref

* Fix comments

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
2026-01-29 10:48:59 -08:00
Siddharth Ganesan
dca0758054 fix(executor): conditional deactivation for loops/parallels (#3069)
* Fix deactivation

* Remove comments
2026-01-29 10:43:30 -08:00
Waleed
ae17c90bdf chore(readme): update readme.md (#3066) 2026-01-28 23:51:34 -08:00
Waleed
1256a15266 fix(posthog): move session recording proxy to middleware for large payload support (#3065)
Next.js rewrites can strip request bodies for large payloads (1MB+),
causing 400 errors from CloudFront. PostHog session recordings require
up to 64MB per message. Moving the proxy to middleware ensures proper
body passthrough.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:49:57 -08:00
Waleed
0b2b7ed9c8 fix(oauth): use createElement for icon components to fix React hooks error (#3064) 2026-01-28 23:37:00 -08:00
Vikhyath Mondreti
0d8d9fb238 fix(type): logs workspace delivery (#3063) 2026-01-28 21:54:20 -08:00
Vikhyath Mondreti
e0f1e66f4f feat(child-workflows): nested execution snapshots (#3059)
* feat(child-workflows): nested execution snapshots

* cleanup typing

* address bugbot comments and fix tests

* do not cascade delete logs/snapshots

* fix few more inconsitencies

* fix external logs route

* add fallback color
2026-01-28 19:40:52 -08:00
Emir Karabeg
20bb7cdec6 improvement(preview): include current workflow badge in breadcrumb in workflow snapshot (#3062)
* feat(preview): add workflow context badge for nested navigation

Adds a badge next to the Back button when viewing nested workflows
to help users identify which workflow they are currently viewing.
This is especially helpful when navigating deeply into nested
workflow blocks.

Changes:
- Added workflowName field to WorkflowStackEntry interface
- Capture workflow name from metadata when drilling down
- Display workflow name badge next to Back button

Co-authored-by: emir <emir@simstudio.ai>

* added workflow name and desc to metadata for workflow preview

* added copy and search icon in code in preview editor

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: waleed <walif6@gmail.com>
2026-01-28 19:33:19 -08:00
Waleed
1469e9c66c feat(youtube): add captions, trending, and video categories tools with enhanced API coverage (#3060)
* feat(youtube): add captions, trending, and video categories tools with enhanced API coverage

* fix(youtube): remove captions tool (requires OAuth), fix tinybird defaults, encode pageToken
2026-01-28 19:08:33 -08:00
63 changed files with 12915 additions and 617 deletions

View File

@@ -172,31 +172,6 @@ Key environment variables for self-hosted deployments. See [`.env.example`](apps
| `API_ENCRYPTION_KEY` | Yes | Encrypts API keys (`openssl rand -hex 32`) |
| `COPILOT_API_KEY` | No | API key from sim.ai for Copilot features |
## Troubleshooting
### Ollama models not showing in dropdown (Docker)
If you're running Ollama on your host machine and Sim in Docker, change `OLLAMA_URL` from `localhost` to `host.docker.internal`:
```bash
OLLAMA_URL=http://host.docker.internal:11434 docker compose -f docker-compose.prod.yml up -d
```
See [Using an External Ollama Instance](#using-an-external-ollama-instance) for details.
### Database connection issues
Ensure PostgreSQL has the pgvector extension installed. When using Docker, wait for the database to be healthy before running migrations.
### Port conflicts
If ports 3000, 3002, or 5432 are in use, configure alternatives:
```bash
# Custom ports
NEXT_PUBLIC_APP_URL=http://localhost:3100 POSTGRES_PORT=5433 docker compose up -d
```
## Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router)

View File

@@ -26,78 +26,41 @@ In Sim, the YouTube integration enables your agents to programmatically search a
## Usage Instructions
Integrate YouTube into the workflow. Can search for videos, get video details, get channel information, get all videos from a channel, get channel playlists, get playlist items, find related videos, and get video comments.
Integrate YouTube into the workflow. Can search for videos, get trending videos, get video details, get video captions, get video categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments.
## Tools
### `youtube_search`
### `youtube_captions`
Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more.
List available caption tracks (subtitles/transcripts) for a YouTube video. Returns information about each caption including language, type, and whether it is auto-generated.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Search query for YouTube videos |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `apiKey` | string | Yes | YouTube API Key |
| `channelId` | string | No | Filter results to a specific YouTube channel ID |
| `publishedAfter` | string | No | Only return videos published after this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `publishedBefore` | string | No | Only return videos published before this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `videoDuration` | string | No | Filter by video length: "short" \(&lt;4 min\), "medium" \(4-20 min\), "long" \(&gt;20 min\), "any" |
| `order` | string | No | Sort results by: "date", "rating", "relevance" \(default\), "title", "videoCount", "viewCount" |
| `videoCategoryId` | string | No | Filter by YouTube category ID \(e.g., "10" for Music, "20" for Gaming\) |
| `videoDefinition` | string | No | Filter by video quality: "high" \(HD\), "standard", "any" |
| `videoCaption` | string | No | Filter by caption availability: "closedCaption" \(has captions\), "none" \(no captions\), "any" |
| `regionCode` | string | No | Return results relevant to a specific region \(ISO 3166-1 alpha-2 country code, e.g., "US", "GB"\) |
| `relevanceLanguage` | string | No | Return results most relevant to a language \(ISO 639-1 code, e.g., "en", "es"\) |
| `safeSearch` | string | No | Content filtering level: "moderate" \(default\), "none", "strict" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of YouTube videos matching the search query |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_video_details`
Get detailed information about a specific YouTube video.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `videoId` | string | Yes | YouTube video ID to get captions for |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `videoId` | string | YouTube video ID |
| `title` | string | Video title |
| `description` | string | Video description |
| `channelId` | string | Channel ID |
| `channelTitle` | string | Channel name |
| `publishedAt` | string | Published date and time |
| `duration` | string | Video duration in ISO 8601 format |
| `viewCount` | number | Number of views |
| `likeCount` | number | Number of likes |
| `commentCount` | number | Number of comments |
| `thumbnail` | string | Video thumbnail URL |
| `tags` | array | Video tags |
| `items` | array | Array of available caption tracks for the video |
| ↳ `captionId` | string | Caption track ID |
| ↳ `language` | string | Language code of the caption \(e.g., |
| ↳ `name` | string | Name/label of the caption track |
| ↳ `trackKind` | string | Type of caption track: |
| ↳ `lastUpdated` | string | When the caption was last updated |
| ↳ `isCC` | boolean | Whether this is a closed caption track |
| ↳ `isAutoSynced` | boolean | Whether the caption timing was automatically synced |
| ↳ `audioTrackType` | string | Type of audio track this caption is for |
| `totalResults` | number | Total number of caption tracks available |
### `youtube_channel_info`
Get detailed information about a YouTube channel.
Get detailed information about a YouTube channel including statistics, branding, and content details.
#### Input
@@ -114,43 +77,20 @@ Get detailed information about a YouTube channel.
| `channelId` | string | YouTube channel ID |
| `title` | string | Channel name |
| `description` | string | Channel description |
| `subscriberCount` | number | Number of subscribers |
| `videoCount` | number | Number of videos |
| `subscriberCount` | number | Number of subscribers \(0 if hidden\) |
| `videoCount` | number | Number of public videos |
| `viewCount` | number | Total channel views |
| `publishedAt` | string | Channel creation date |
| `thumbnail` | string | Channel thumbnail URL |
| `customUrl` | string | Channel custom URL |
### `youtube_channel_videos`
Get all videos from a specific YouTube channel, with sorting options.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `channelId` | string | Yes | YouTube channel ID to get videos from |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Sort order: "date" \(newest first\), "rating", "relevance", "title", "viewCount" |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of videos from the channel |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Video publish date |
| `totalResults` | number | Total number of videos in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
| `thumbnail` | string | Channel thumbnail/avatar URL |
| `customUrl` | string | Channel custom URL \(handle\) |
| `country` | string | Country the channel is associated with |
| `uploadsPlaylistId` | string | Playlist ID containing all channel uploads \(use with playlist_items\) |
| `bannerImageUrl` | string | Channel banner image URL |
| `hiddenSubscriberCount` | boolean | Whether the subscriber count is hidden |
### `youtube_channel_playlists`
Get all playlists from a specific YouTube channel.
Get all public playlists from a specific YouTube channel.
#### Input
@@ -172,19 +112,80 @@ Get all playlists from a specific YouTube channel.
| ↳ `thumbnail` | string | Playlist thumbnail URL |
| ↳ `itemCount` | number | Number of videos in playlist |
| ↳ `publishedAt` | string | Playlist creation date |
| ↳ `channelTitle` | string | Channel name |
| `totalResults` | number | Total number of playlists in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_playlist_items`
### `youtube_channel_videos`
Get videos from a YouTube playlist.
Search for videos from a specific YouTube channel with sorting options. For complete channel video list, use channel_info to get uploadsPlaylistId, then use playlist_items.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `playlistId` | string | Yes | YouTube playlist ID |
| `maxResults` | number | No | Maximum number of videos to return |
| `channelId` | string | Yes | YouTube channel ID to get videos from |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `order` | string | No | Sort order: "date" \(newest first, default\), "rating", "relevance", "title", "viewCount" |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of videos from the channel |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `channelTitle` | string | Channel name |
| `totalResults` | number | Total number of videos in the channel |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_comments`
Get top-level comments from a YouTube video with author details and engagement.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `maxResults` | number | No | Maximum number of comments to return \(1-100\) |
| `order` | string | No | Order of comments: "time" \(newest first\) or "relevance" \(most relevant first\) |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of top-level comments from the video |
| ↳ `commentId` | string | Comment ID |
| ↳ `authorDisplayName` | string | Comment author display name |
| ↳ `authorChannelUrl` | string | Comment author channel URL |
| ↳ `authorProfileImageUrl` | string | Comment author profile image URL |
| ↳ `textDisplay` | string | Comment text \(HTML formatted\) |
| ↳ `textOriginal` | string | Comment text \(plain text\) |
| ↳ `likeCount` | number | Number of likes on the comment |
| ↳ `publishedAt` | string | When the comment was posted |
| ↳ `updatedAt` | string | When the comment was last edited |
| ↳ `replyCount` | number | Number of replies to this comment |
| `totalResults` | number | Total number of comment threads available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_playlist_items`
Get videos from a YouTube playlist. Can be used with a channel uploads playlist to get all channel videos.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `playlistId` | string | Yes | YouTube playlist ID. Use uploadsPlaylistId from channel_info to get all channel videos. |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
@@ -198,22 +199,65 @@ Get videos from a YouTube playlist.
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `publishedAt` | string | Date added to playlist |
| ↳ `channelTitle` | string | Channel name |
| ↳ `position` | number | Position in playlist |
| ↳ `channelTitle` | string | Playlist owner channel name |
| ↳ `position` | number | Position in playlist \(0-indexed\) |
| ↳ `videoOwnerChannelId` | string | Channel ID of the video owner |
| ↳ `videoOwnerChannelTitle` | string | Channel name of the video owner |
| `totalResults` | number | Total number of items in playlist |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_comments`
### `youtube_search`
Get comments from a YouTube video.
Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, live streams, and more.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `maxResults` | number | No | Maximum number of comments to return |
| `order` | string | No | Order of comments: time or relevance |
| `query` | string | Yes | Search query for YouTube videos |
| `maxResults` | number | No | Maximum number of videos to return \(1-50\) |
| `pageToken` | string | No | Page token for pagination \(use nextPageToken from previous response\) |
| `apiKey` | string | Yes | YouTube API Key |
| `channelId` | string | No | Filter results to a specific YouTube channel ID |
| `publishedAfter` | string | No | Only return videos published after this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `publishedBefore` | string | No | Only return videos published before this date \(RFC 3339 format: "2024-01-01T00:00:00Z"\) |
| `videoDuration` | string | No | Filter by video length: "short" \(&lt;4 min\), "medium" \(4-20 min\), "long" \(&gt;20 min\), "any" |
| `order` | string | No | Sort results by: "date", "rating", "relevance" \(default\), "title", "videoCount", "viewCount" |
| `videoCategoryId` | string | No | Filter by YouTube category ID \(e.g., "10" for Music, "20" for Gaming\). Use video_categories to list IDs. |
| `videoDefinition` | string | No | Filter by video quality: "high" \(HD\), "standard", "any" |
| `videoCaption` | string | No | Filter by caption availability: "closedCaption" \(has captions\), "none" \(no captions\), "any" |
| `eventType` | string | No | Filter by live broadcast status: "live" \(currently live\), "upcoming" \(scheduled\), "completed" \(past streams\) |
| `regionCode` | string | No | Return results relevant to a specific region \(ISO 3166-1 alpha-2 country code, e.g., "US", "GB"\) |
| `relevanceLanguage` | string | No | Return results most relevant to a language \(ISO 639-1 code, e.g., "en", "es"\) |
| `safeSearch` | string | No | Content filtering level: "moderate" \(default\), "none", "strict" |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of YouTube videos matching the search query |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `channelId` | string | Channel ID that uploaded the video |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `liveBroadcastContent` | string | Live broadcast status: |
| `totalResults` | number | Total number of search results available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_trending`
Get the most popular/trending videos on YouTube. Can filter by region and video category.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code to get trending videos for \(e.g., "US", "GB", "JP"\). Defaults to US. |
| `videoCategoryId` | string | No | Filter by video category ID \(e.g., "10" for Music, "20" for Gaming, "17" for Sports\) |
| `maxResults` | number | No | Maximum number of trending videos to return \(1-50\) |
| `pageToken` | string | No | Page token for pagination |
| `apiKey` | string | Yes | YouTube API Key |
@@ -221,17 +265,84 @@ Get comments from a YouTube video.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `items` | array | Array of comments from the video |
| ↳ `commentId` | string | Comment ID |
| ↳ `authorDisplayName` | string | Comment author name |
| ↳ `authorChannelUrl` | string | Comment author channel URL |
| ↳ `textDisplay` | string | Comment text \(HTML formatted\) |
| ↳ `textOriginal` | string | Comment text \(plain text\) |
| `items` | array | Array of trending videos |
| ↳ `videoId` | string | YouTube video ID |
| ↳ `title` | string | Video title |
| ↳ `description` | string | Video description |
| ↳ `thumbnail` | string | Video thumbnail URL |
| ↳ `channelId` | string | Channel ID |
| ↳ `channelTitle` | string | Channel name |
| ↳ `publishedAt` | string | Video publish date |
| ↳ `viewCount` | number | Number of views |
| ↳ `likeCount` | number | Number of likes |
| ↳ `publishedAt` | string | Comment publish date |
| ↳ `updatedAt` | string | Comment last updated date |
| ↳ `replyCount` | number | Number of replies |
| `totalResults` | number | Total number of comments |
| ↳ `commentCount` | number | Number of comments |
| ↳ `duration` | string | Video duration in ISO 8601 format |
| `totalResults` | number | Total number of trending videos available |
| `nextPageToken` | string | Token for accessing the next page of results |
### `youtube_video_categories`
Get a list of video categories available on YouTube. Use this to discover valid category IDs for filtering search and trending results.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `regionCode` | string | No | ISO 3166-1 alpha-2 country code to get categories for \(e.g., "US", "GB", "JP"\). Defaults to US. |
| `hl` | string | No | Language for category titles \(e.g., "en", "es", "fr"\). Defaults to English. |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| 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., |
| ↳ `title` | string | Human-readable category name |
| ↳ `assignable` | boolean | Whether videos can be tagged with this category |
| `totalResults` | number | Total number of categories available |
### `youtube_video_details`
Get detailed information about a specific YouTube video including statistics, content details, live streaming info, and metadata.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `videoId` | string | Yes | YouTube video ID |
| `apiKey` | string | Yes | YouTube API Key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `videoId` | string | YouTube video ID |
| `title` | string | Video title |
| `description` | string | Video description |
| `channelId` | string | Channel ID |
| `channelTitle` | string | Channel name |
| `publishedAt` | string | Published date and time |
| `duration` | string | Video duration in ISO 8601 format \(e.g., |
| `viewCount` | number | Number of views |
| `likeCount` | number | Number of likes |
| `commentCount` | number | Number of comments |
| `favoriteCount` | number | Number of times added to favorites |
| `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: |
| `licensedContent` | boolean | Whether the video is licensed content |
| `privacyStatus` | string | Video privacy status: |
| `liveBroadcastContent` | string | Live broadcast status: |
| `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 |
| `scheduledStartTime` | string | Scheduled start time for upcoming live streams \(ISO 8601\) |
| `actualStartTime` | string | When the live stream actually started \(ISO 8601\) |
| `actualEndTime` | string | When the live stream ended \(ISO 8601\) |
| `concurrentViewers` | number | Current number of viewers \(only for active live streams\) |
| `activeLiveChatId` | string | Live chat ID for the stream \(only for active live streams\) |

View File

@@ -56,7 +56,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
deploymentVersionName: workflowDeploymentVersion.name,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(
workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
@@ -65,7 +65,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, userId)
)
)
@@ -77,17 +77,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const workflowSummary = {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
color: log.workflowColor,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
}
const workflowSummary = log.workflowId
? {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
color: log.workflowColor,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
}
: null
const response = {
id: log.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { subscription, user, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { subscription, user, workflowExecutionLogs, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -40,17 +40,17 @@ export async function GET(request: NextRequest) {
const freeUserIds = freeUsers.map((u) => u.userId)
const workflowsQuery = await db
.select({ id: workflow.id })
.from(workflow)
.where(inArray(workflow.userId, freeUserIds))
const workspacesQuery = await db
.select({ id: workspace.id })
.from(workspace)
.where(inArray(workspace.billedAccountUserId, freeUserIds))
if (workflowsQuery.length === 0) {
logger.info('No workflows found for free users')
return NextResponse.json({ message: 'No workflows found for cleanup' })
if (workspacesQuery.length === 0) {
logger.info('No workspaces found for free users')
return NextResponse.json({ message: 'No workspaces found for cleanup' })
}
const workflowIds = workflowsQuery.map((w) => w.id)
const workspaceIds = workspacesQuery.map((w) => w.id)
const results = {
enhancedLogs: {
@@ -77,7 +77,7 @@ export async function GET(request: NextRequest) {
let batchesProcessed = 0
let hasMoreLogs = true
logger.info(`Starting enhanced logs cleanup for ${workflowIds.length} workflows`)
logger.info(`Starting enhanced logs cleanup for ${workspaceIds.length} workspaces`)
while (hasMoreLogs && batchesProcessed < MAX_BATCHES) {
const oldEnhancedLogs = await db
@@ -99,7 +99,7 @@ export async function GET(request: NextRequest) {
.from(workflowExecutionLogs)
.where(
and(
inArray(workflowExecutionLogs.workflowId, workflowIds),
inArray(workflowExecutionLogs.workspaceId, workspaceIds),
lt(workflowExecutionLogs.createdAt, retentionDate)
)
)
@@ -127,7 +127,7 @@ export async function GET(request: NextRequest) {
customKey: enhancedLogKey,
metadata: {
logId: String(log.id),
workflowId: String(log.workflowId),
workflowId: String(log.workflowId ?? ''),
executionId: String(log.executionId),
logType: 'enhanced',
archivedAt: new Date().toISOString(),

View File

@@ -6,10 +6,11 @@ import {
workflowExecutionSnapshots,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
const logger = createLogger('LogsByExecutionIdAPI')
@@ -48,14 +49,15 @@ export async function GET(
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.entityId, workflowExecutionLogs.workspaceId),
eq(permissions.userId, authenticatedUserId)
)
)
@@ -78,10 +80,42 @@ export async function GET(
return NextResponse.json({ error: 'Workflow state snapshot not found' }, { status: 404 })
}
const executionData = workflowLog.executionData as WorkflowExecutionLog['executionData']
const traceSpans = (executionData?.traceSpans as TraceSpan[]) || []
const childSnapshotIds = new Set<string>()
const collectSnapshotIds = (spans: TraceSpan[]) => {
spans.forEach((span) => {
const snapshotId = span.childWorkflowSnapshotId
if (typeof snapshotId === 'string') {
childSnapshotIds.add(snapshotId)
}
if (span.children?.length) {
collectSnapshotIds(span.children)
}
})
}
if (traceSpans.length > 0) {
collectSnapshotIds(traceSpans)
}
const childWorkflowSnapshots =
childSnapshotIds.size > 0
? await db
.select()
.from(workflowExecutionSnapshots)
.where(inArray(workflowExecutionSnapshots.id, Array.from(childSnapshotIds)))
: []
const childSnapshotMap = childWorkflowSnapshots.reduce<Record<string, unknown>>((acc, snap) => {
acc[snap.id] = snap.stateData
return acc
}, {})
const response = {
executionId,
workflowId: workflowLog.workflowId,
workflowState: snapshot.stateData,
childWorkflowSnapshots: childSnapshotMap,
executionMetadata: {
trigger: workflowLog.trigger,
startedAt: workflowLog.startedAt.toISOString(),

View File

@@ -1,7 +1,7 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -41,7 +41,7 @@ export async function GET(request: NextRequest) {
totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData,
workflowName: workflow.name,
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
}
const workspaceCondition = eq(workflowExecutionLogs.workspaceId, params.workspaceId)
@@ -74,7 +74,7 @@ export async function GET(request: NextRequest) {
const rows = await db
.select(selectColumns)
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(

View File

@@ -116,7 +116,7 @@ export async function GET(request: NextRequest) {
workflowDeploymentVersion,
eq(workflowDeploymentVersion.id, workflowExecutionLogs.deploymentVersionId)
)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
@@ -190,7 +190,7 @@ export async function GET(request: NextRequest) {
pausedExecutions,
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
@@ -314,17 +314,19 @@ export async function GET(request: NextRequest) {
} catch {}
}
const workflowSummary = {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
color: log.workflowColor,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
}
const workflowSummary = log.workflowId
? {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
color: log.workflowColor,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
}
: null
return {
id: log.id,

View File

@@ -72,7 +72,7 @@ export async function GET(request: NextRequest) {
maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
@@ -103,8 +103,8 @@ export async function GET(request: NextRequest) {
const statsQuery = await db
.select({
workflowId: workflowExecutionLogs.workflowId,
workflowName: workflow.name,
workflowId: sql<string>`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
workflowName: sql<string>`COALESCE(${workflow.name}, 'Deleted Workflow')`,
segmentIndex:
sql<number>`FLOOR(EXTRACT(EPOCH FROM (${workflowExecutionLogs.startedAt} - ${startTimeIso}::timestamp)) * 1000 / ${segmentMs})`.as(
'segment_index'
@@ -120,7 +120,7 @@ export async function GET(request: NextRequest) {
),
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
@@ -130,7 +130,11 @@ export async function GET(request: NextRequest) {
)
)
.where(whereCondition)
.groupBy(workflowExecutionLogs.workflowId, workflow.name, sql`segment_index`)
.groupBy(
sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`,
sql`COALESCE(${workflow.name}, 'Deleted Workflow')`,
sql`segment_index`
)
const workflowMap = new Map<
string,

View File

@@ -133,9 +133,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const finalWorkflowData = {
...workflowData,
state: {
// Default values for expected properties
deploymentStatuses: {},
// Data from normalized tables
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
@@ -143,8 +141,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
metadata: {
name: workflowData.name,
description: workflowData.description,
},
},
// Include workflow variables
variables: workflowData.variables || {},
}
@@ -166,6 +167,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
metadata: {
name: workflowData.name,
description: workflowData.description,
},
},
variables: workflowData.variables || {},
}

View File

@@ -215,6 +215,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
for (const log of logs) {
if (!log.workflowId) continue // Skip logs for deleted workflows
const idx = Math.min(
segments - 1,
Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs))

View File

@@ -1,5 +1,9 @@
import { memo } from 'react'
import { cn } from '@/lib/core/utils/cn'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { StatusBar, type StatusBarSegment } from '..'
@@ -61,22 +65,32 @@ export function WorkflowsList({
<div>
{filteredExecutions.map((workflow, idx) => {
const isSelected = expandedWorkflowId === workflow.workflowId
const isDeletedWorkflow = workflow.workflowName === DELETED_WORKFLOW_LABEL
const workflowColor = isDeletedWorkflow
? DELETED_WORKFLOW_COLOR
: workflows[workflow.workflowId]?.color || '#64748b'
const canToggle = !isDeletedWorkflow
return (
<div
key={workflow.workflowId}
className={cn(
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
'flex h-[44px] items-center gap-[16px] px-[24px] hover:bg-[var(--surface-3)] dark:hover:bg-[var(--surface-4)]',
canToggle ? 'cursor-pointer' : 'cursor-default',
isSelected && 'bg-[var(--surface-3)] dark:bg-[var(--surface-4)]'
)}
onClick={() => onToggleWorkflow(workflow.workflowId)}
onClick={() => {
if (canToggle) {
onToggleWorkflow(workflow.workflowId)
}
}}
>
{/* Workflow name with color */}
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
backgroundColor: workflowColor,
}}
/>
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>

View File

@@ -80,6 +80,9 @@ export function ExecutionSnapshot({
}, [executionId, closeMenu])
const workflowState = data?.workflowState as WorkflowState | undefined
const childWorkflowSnapshots = data?.childWorkflowSnapshots as
| Record<string, WorkflowState>
| undefined
const renderContent = () => {
if (isLoading) {
@@ -148,6 +151,7 @@ export function ExecutionSnapshot({
key={executionId}
workflowState={workflowState}
traceSpans={traceSpans}
childWorkflowSnapshots={childWorkflowSnapshots}
className={className}
height={height}
width={width}

View File

@@ -26,6 +26,8 @@ import {
} from '@/app/workspace/[workspaceId]/logs/components'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate,
getDisplayStatus,
StatusBadge,
@@ -386,22 +388,25 @@ export const LogDetails = memo(function LogDetails({
</div>
{/* Workflow Card */}
{log.workflow && (
<div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</div>
<div className='flex min-w-0 items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
/>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow.name}
</span>
</div>
<div className='flex w-0 min-w-0 flex-1 flex-col gap-[8px]'>
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow
</div>
)}
<div className='flex min-w-0 items-center gap-[8px]'>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{
backgroundColor:
log.workflow?.color ||
(!log.workflowId ? DELETED_WORKFLOW_COLOR : undefined),
}}
/>
<span className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-secondary)]'>
{log.workflow?.name ||
(!log.workflowId ? DELETED_WORKFLOW_LABEL : 'Unknown')}
</span>
</div>
</div>
</div>
{/* Execution ID */}

View File

@@ -7,6 +7,8 @@ import { List, type RowComponentProps, useListRef } from 'react-window'
import { Badge, buttonVariants } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
DELETED_WORKFLOW_COLOR,
DELETED_WORKFLOW_LABEL,
formatDate,
formatDuration,
getDisplayStatus,
@@ -33,6 +35,11 @@ interface LogRowProps {
const LogRow = memo(
function LogRow({ log, isSelected, onClick, onContextMenu, selectedRowRef }: LogRowProps) {
const formattedDate = useMemo(() => formatDate(log.createdAt), [log.createdAt])
const isDeletedWorkflow = !log.workflow?.id && !log.workflowId
const workflowName = isDeletedWorkflow
? DELETED_WORKFLOW_LABEL
: log.workflow?.name || 'Unknown'
const workflowColor = isDeletedWorkflow ? DELETED_WORKFLOW_COLOR : log.workflow?.color
const handleClick = useCallback(() => onClick(log), [onClick, log])
@@ -78,10 +85,15 @@ const LogRow = memo(
>
<div
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
style={{ backgroundColor: log.workflow?.color }}
style={{ backgroundColor: workflowColor }}
/>
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
{log.workflow?.name || 'Unknown'}
<span
className={cn(
'min-w-0 truncate font-medium text-[12px]',
isDeletedWorkflow ? 'text-[var(--text-tertiary)]' : 'text-[var(--text-primary)]'
)}
>
{workflowName}
</span>
</div>

View File

@@ -27,6 +27,9 @@ export const LOG_COLUMN_ORDER: readonly LogColumnKey[] = [
'duration',
] as const
export const DELETED_WORKFLOW_LABEL = 'Deleted Workflow'
export const DELETED_WORKFLOW_COLOR = 'var(--text-tertiary)'
export type LogStatus = 'error' | 'pending' | 'running' | 'info' | 'cancelled'
/**

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ExternalLink, Users } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
@@ -203,7 +203,7 @@ export function CredentialSelector({
if (!baseProviderConfig) {
return <ExternalLink className='h-3 w-3' />
}
return baseProviderConfig.icon({ className: 'h-3 w-3' })
return createElement(baseProviderConfig.icon, { className: 'h-3 w-3' })
}, [])
const getProviderName = useCallback((providerName: OAuthProvider) => {

View File

@@ -8,11 +8,19 @@ import {
useState,
} from 'react'
import { isEqual } from 'lodash'
import { ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
import { ArrowLeftRight, ChevronDown, ChevronsUpDown, ChevronUp, Plus } from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import { cn } from '@/lib/core/utils/cn'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { FileUpload } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
@@ -27,13 +35,13 @@ const MAX_TEXTAREA_HEIGHT_PX = 320
/** Pattern to match complete message objects in JSON */
const COMPLETE_MESSAGE_PATTERN =
/"role"\s*:\s*"(system|user|assistant)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
/"role"\s*:\s*"(system|user|assistant|media)"[^}]*"content"\s*:\s*"((?:[^"\\]|\\.)*)"/g
/** Pattern to match incomplete content at end of buffer */
const INCOMPLETE_CONTENT_PATTERN = /"content"\s*:\s*"((?:[^"\\]|\\.)*)$/
/** Pattern to match role before content */
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*$/
const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant|media)"[^{]*$/
/**
* Unescapes JSON string content
@@ -41,41 +49,40 @@ const ROLE_BEFORE_CONTENT_PATTERN = /"role"\s*:\s*"(system|user|assistant)"[^{]*
const unescapeContent = (str: string): string =>
str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
/**
* Media content for multimodal messages
*/
interface MediaContent {
data: string
mimeType?: string
fileName?: string
}
/**
* Interface for individual message in the messages array
*/
interface Message {
role: 'system' | 'user' | 'assistant'
role: 'system' | 'user' | 'assistant' | 'media'
content: string
media?: MediaContent
}
/**
* Props for the MessagesInput component
*/
interface MessagesInputProps {
/** Unique identifier for the block */
blockId: string
/** Unique identifier for the sub-block */
subBlockId: string
/** Configuration object for the sub-block */
config: SubBlockConfig
/** Whether component is in preview mode */
isPreview?: boolean
/** Value to display in preview mode */
previewValue?: Message[] | null
/** Whether the input is disabled */
disabled?: boolean
/** Ref to expose wand control handlers to parent */
wandControlRef?: React.MutableRefObject<WandControlHandlers | null>
}
/**
* MessagesInput component for managing LLM message history
*
* @remarks
* - Manages an array of messages with role and content
* - Each message can be edited, removed, or reordered
* - Stores data in LLM-compatible format: [{ role, content }]
*/
export function MessagesInput({
blockId,
@@ -90,6 +97,10 @@ export function MessagesInput({
const [localMessages, setLocalMessages] = useState<Message[]>([{ role: 'user', content: '' }])
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
const [openPopoverIndex, setOpenPopoverIndex] = useState<number | null>(null)
// Local media mode state - basic = FileUpload, advanced = URL/base64 textarea
const [mediaMode, setMediaMode] = useState<'basic' | 'advanced'>('basic')
const subBlockInput = useSubBlockInput({
blockId,
subBlockId,
@@ -98,43 +109,38 @@ export function MessagesInput({
disabled,
})
/**
* Gets the current messages as JSON string for wand context
*/
const getMessagesJson = useCallback((): string => {
if (localMessages.length === 0) return ''
// Filter out empty messages for cleaner context
const nonEmptyMessages = localMessages.filter((m) => m.content.trim() !== '')
if (nonEmptyMessages.length === 0) return ''
return JSON.stringify(nonEmptyMessages, null, 2)
}, [localMessages])
/**
* Streaming buffer for accumulating JSON content
*/
const streamBufferRef = useRef<string>('')
/**
* Parses and validates messages from JSON content
*/
const parseMessages = useCallback((content: string): Message[] | null => {
try {
const parsed = JSON.parse(content)
if (Array.isArray(parsed)) {
const validMessages: Message[] = parsed
.filter(
(m): m is { role: string; content: string } =>
(m): m is { role: string; content: string; media?: MediaContent } =>
typeof m === 'object' &&
m !== null &&
typeof m.role === 'string' &&
typeof m.content === 'string'
)
.map((m) => ({
role: (['system', 'user', 'assistant'].includes(m.role)
? m.role
: 'user') as Message['role'],
content: m.content,
}))
.map((m) => {
const role = ['system', 'user', 'assistant', 'media'].includes(m.role) ? m.role : 'user'
const message: Message = {
role: role as Message['role'],
content: m.content,
}
if (m.media) {
message.media = m.media
}
return message
})
return validMessages.length > 0 ? validMessages : null
}
} catch {
@@ -143,26 +149,19 @@ export function MessagesInput({
return null
}, [])
/**
* Extracts messages from streaming JSON buffer
* Uses simple pattern matching for efficiency
*/
const extractStreamingMessages = useCallback(
(buffer: string): Message[] => {
// Try complete JSON parse first
const complete = parseMessages(buffer)
if (complete) return complete
const result: Message[] = []
// Reset regex lastIndex for global pattern
COMPLETE_MESSAGE_PATTERN.lastIndex = 0
let match
while ((match = COMPLETE_MESSAGE_PATTERN.exec(buffer)) !== null) {
result.push({ role: match[1] as Message['role'], content: unescapeContent(match[2]) })
}
// Check for incomplete message at end (content still streaming)
const lastContentIdx = buffer.lastIndexOf('"content"')
if (lastContentIdx !== -1) {
const tail = buffer.slice(lastContentIdx)
@@ -172,7 +171,6 @@ export function MessagesInput({
const roleMatch = head.match(ROLE_BEFORE_CONTENT_PATTERN)
if (roleMatch) {
const content = unescapeContent(incomplete[1])
// Only add if not duplicate of last complete message
if (result.length === 0 || result[result.length - 1].content !== content) {
result.push({ role: roleMatch[1] as Message['role'], content })
}
@@ -185,9 +183,6 @@ export function MessagesInput({
[parseMessages]
)
/**
* Wand hook for AI-assisted content generation
*/
const wandHook = useWand({
wandConfig: config.wandConfig,
currentValue: getMessagesJson(),
@@ -208,7 +203,6 @@ export function MessagesInput({
setLocalMessages(validMessages)
setMessages(validMessages)
} else {
// Fallback: treat as raw system prompt
const trimmed = content.trim()
if (trimmed) {
const fallback: Message[] = [{ role: 'system', content: trimmed }]
@@ -219,9 +213,6 @@ export function MessagesInput({
},
})
/**
* Expose wand control handlers to parent via ref
*/
useImperativeHandle(
wandControlRef,
() => ({
@@ -249,9 +240,6 @@ export function MessagesInput({
}
}, [isPreview, previewValue, messages])
/**
* Gets the current messages array
*/
const currentMessages = useMemo<Message[]>(() => {
if (isPreview && previewValue && Array.isArray(previewValue)) {
return previewValue
@@ -269,9 +257,6 @@ export function MessagesInput({
startHeight: number
} | null>(null)
/**
* Updates a specific message's content
*/
const updateMessageContent = useCallback(
(index: number, content: string) => {
if (isPreview || disabled) return
@@ -287,17 +272,26 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled]
)
/**
* Updates a specific message's role
*/
const updateMessageRole = useCallback(
(index: number, role: 'system' | 'user' | 'assistant') => {
(index: number, role: 'system' | 'user' | 'assistant' | 'media') => {
if (isPreview || disabled) return
const updatedMessages = [...localMessages]
updatedMessages[index] = {
...updatedMessages[index],
role,
if (role === 'media') {
updatedMessages[index] = {
...updatedMessages[index],
role,
content: updatedMessages[index].content || '',
media: updatedMessages[index].media || {
data: '',
},
}
} else {
const { media: _, ...rest } = updatedMessages[index]
updatedMessages[index] = {
...rest,
role,
}
}
setLocalMessages(updatedMessages)
setMessages(updatedMessages)
@@ -305,9 +299,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled]
)
/**
* Adds a message after the specified index
*/
const addMessageAfter = useCallback(
(index: number) => {
if (isPreview || disabled) return
@@ -320,9 +311,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled]
)
/**
* Deletes a message at the specified index
*/
const deleteMessage = useCallback(
(index: number) => {
if (isPreview || disabled) return
@@ -335,9 +323,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled]
)
/**
* Moves a message up in the list
*/
const moveMessageUp = useCallback(
(index: number) => {
if (isPreview || disabled || index === 0) return
@@ -352,9 +337,6 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled]
)
/**
* Moves a message down in the list
*/
const moveMessageDown = useCallback(
(index: number) => {
if (isPreview || disabled || index === localMessages.length - 1) return
@@ -369,18 +351,11 @@ export function MessagesInput({
[localMessages, setMessages, isPreview, disabled]
)
/**
* Capitalizes the first letter of the role
*/
const formatRole = (role: string): string => {
return role.charAt(0).toUpperCase() + role.slice(1)
}
/**
* Handles header click to focus the textarea
*/
const handleHeaderClick = useCallback((index: number, e: React.MouseEvent) => {
// Don't focus if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-radix-popper-content-wrapper]')) {
return
@@ -570,50 +545,52 @@ export function MessagesInput({
className='flex cursor-pointer items-center justify-between px-[8px] pt-[6px]'
onClick={(e) => handleHeaderClick(index, e)}
>
<Popover
open={openPopoverIndex === index}
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
>
<PopoverTrigger asChild>
<button
type='button'
disabled={isPreview || disabled}
className={cn(
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
(isPreview || disabled) &&
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
)}
onClick={(e) => e.stopPropagation()}
aria-label='Select message role'
>
{formatRole(message.role)}
{!isPreview && !disabled && (
<ChevronDown
className={cn(
'h-3 w-3 flex-shrink-0 transition-transform duration-100',
openPopoverIndex === index && 'rotate-180'
)}
/>
)}
</button>
</PopoverTrigger>
<PopoverContent minWidth={140} align='start'>
<div className='flex flex-col gap-[2px]'>
{(['system', 'user', 'assistant'] as const).map((role) => (
<PopoverItem
key={role}
active={message.role === role}
onClick={() => {
updateMessageRole(index, role)
setOpenPopoverIndex(null)
}}
>
<span>{formatRole(role)}</span>
</PopoverItem>
))}
</div>
</PopoverContent>
</Popover>
<div className='flex items-center'>
<Popover
open={openPopoverIndex === index}
onOpenChange={(open) => setOpenPopoverIndex(open ? index : null)}
>
<PopoverTrigger asChild>
<button
type='button'
disabled={isPreview || disabled}
className={cn(
'group -ml-1.5 -my-1 flex items-center gap-1 rounded px-1.5 py-1 font-medium text-[13px] text-[var(--text-primary)] leading-none transition-colors hover:bg-[var(--surface-5)] hover:text-[var(--text-secondary)]',
(isPreview || disabled) &&
'cursor-default hover:bg-transparent hover:text-[var(--text-primary)]'
)}
onClick={(e) => e.stopPropagation()}
aria-label='Select message role'
>
{formatRole(message.role)}
{!isPreview && !disabled && (
<ChevronDown
className={cn(
'h-3 w-3 flex-shrink-0 transition-transform duration-100',
openPopoverIndex === index && 'rotate-180'
)}
/>
)}
</button>
</PopoverTrigger>
<PopoverContent minWidth={140} align='start'>
<div className='flex flex-col gap-[2px]'>
{(['system', 'user', 'assistant', 'media'] as const).map((role) => (
<PopoverItem
key={role}
active={message.role === role}
onClick={() => {
updateMessageRole(index, role)
setOpenPopoverIndex(null)
}}
>
<span>{formatRole(role)}</span>
</PopoverItem>
))}
</div>
</PopoverContent>
</Popover>
</div>
{!isPreview && !disabled && (
<div className='flex items-center'>
@@ -657,6 +634,43 @@ export function MessagesInput({
</Button>
</>
)}
{/* Mode toggle for media messages */}
{message.role === 'media' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
setMediaMode((m) => (m === 'basic' ? 'advanced' : 'basic'))
}}
disabled={disabled}
className='-my-1 -mr-1 h-6 w-6 p-0'
aria-label={
mediaMode === 'advanced'
? 'Switch to file upload'
: 'Switch to URL/text input'
}
>
<ArrowLeftRight
className={cn(
'h-3 w-3',
mediaMode === 'advanced'
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
)}
/>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{mediaMode === 'advanced'
? 'Switch to file upload'
: 'Switch to URL/text input'}
</p>
</Tooltip.Content>
</Tooltip.Root>
)}
<Button
variant='ghost'
onClick={(e: React.MouseEvent) => {
@@ -673,98 +687,138 @@ export function MessagesInput({
)}
</div>
{/* Content Input with overlay for variable highlighting */}
<div className='relative w-full overflow-hidden'>
<textarea
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
placeholder='Enter message content...'
value={message.content}
onChange={fieldHandlers.onChange}
onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault()
const direction = e.shiftKey ? -1 : 1
const nextIndex = index + direction
if (nextIndex >= 0 && nextIndex < currentMessages.length) {
const nextFieldId = `message-${nextIndex}`
const nextTextarea = textareaRefs.current[nextFieldId]
if (nextTextarea) {
nextTextarea.focus()
nextTextarea.selectionStart = nextTextarea.value.length
nextTextarea.selectionEnd = nextTextarea.value.length
{/* Content Input - different for media vs text messages */}
{message.role === 'media' ? (
<div className='relative w-full px-[8px] py-[8px]'>
{mediaMode === 'basic' ? (
<FileUpload
blockId={blockId}
subBlockId={`${subBlockId}-media-${index}`}
acceptedTypes='image/*,audio/*,video/*,application/pdf,.doc,.docx,.txt'
multiple={false}
isPreview={isPreview}
disabled={disabled}
/>
) : (
<textarea
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
className='relative z-[2] m-0 box-border h-auto min-h-[60px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words rounded-[4px] border border-[var(--border-1)] bg-transparent px-[8px] py-[6px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] outline-none transition-colors [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] hover:border-[var(--border-2)] focus:border-[var(--border-2)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
placeholder='Enter URL or paste base64 data...'
value={message.media?.data || ''}
onChange={(e) => {
const updatedMessages = [...localMessages]
if (updatedMessages[index].role === 'media') {
updatedMessages[index] = {
...updatedMessages[index],
content: e.target.value.substring(0, 50),
media: {
...updatedMessages[index].media,
data: e.target.value,
},
}
}
}
return
}
fieldHandlers.onKeyDown(e)
}}
onDrop={fieldHandlers.onDrop}
onDragOver={fieldHandlers.onDragOver}
onFocus={fieldHandlers.onFocus}
onScroll={(e) => {
const overlay = overlayRefs.current[fieldId]
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
disabled={isPreview || disabled}
/>
<div
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
>
{formatDisplayText(message.content, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
{message.content.endsWith('\n') && '\u200B'}
setLocalMessages(updatedMessages)
setMessages(updatedMessages)
}}
disabled={isPreview || disabled}
/>
)}
</div>
{/* Env var dropdown for this message */}
<EnvVarDropdown
visible={fieldState.showEnvVars && !isPreview && !disabled}
onSelect={handleEnvSelect}
searchTerm={fieldState.searchTerm}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
workspaceId={subBlockInput.workspaceId}
maxHeight='192px'
inputRef={textareaRefObject}
/>
{/* Tag dropdown for this message */}
<TagDropdown
visible={fieldState.showTags && !isPreview && !disabled}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={textareaRefObject}
/>
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()
) : (
<div className='relative w-full overflow-hidden'>
<textarea
ref={(el) => {
textareaRefs.current[fieldId] = el
}}
className='relative z-[2] m-0 box-border h-auto min-h-[80px] w-full resize-none overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-sm text-transparent leading-[1.5] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus:outline-none focus-visible:outline-none disabled:cursor-not-allowed [&::-webkit-scrollbar]:hidden'
placeholder='Enter message content...'
value={message.content}
onChange={fieldHandlers.onChange}
onKeyDown={(e) => {
if (e.key === 'Tab' && !isPreview && !disabled) {
e.preventDefault()
const direction = e.shiftKey ? -1 : 1
const nextIndex = index + direction
if (nextIndex >= 0 && nextIndex < currentMessages.length) {
const nextFieldId = `message-${nextIndex}`
const nextTextarea = textareaRefs.current[nextFieldId]
if (nextTextarea) {
nextTextarea.focus()
nextTextarea.selectionStart = nextTextarea.value.length
nextTextarea.selectionEnd = nextTextarea.value.length
}
}
return
}
fieldHandlers.onKeyDown(e)
}}
onDrop={fieldHandlers.onDrop}
onDragOver={fieldHandlers.onDragOver}
onFocus={fieldHandlers.onFocus}
onScroll={(e) => {
const overlay = overlayRefs.current[fieldId]
if (overlay) {
overlay.scrollTop = e.currentTarget.scrollTop
overlay.scrollLeft = e.currentTarget.scrollLeft
}
}}
disabled={isPreview || disabled}
/>
<div
ref={(el) => {
overlayRefs.current[fieldId] = el
}}
className='pointer-events-none absolute top-0 left-0 z-[1] m-0 box-border w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words border-none bg-transparent px-[8px] py-[8px] font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.5] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
{formatDisplayText(message.content, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
{message.content.endsWith('\n') && '\u200B'}
</div>
)}
</div>
{/* Env var dropdown for this message */}
<EnvVarDropdown
visible={fieldState.showEnvVars && !isPreview && !disabled}
onSelect={handleEnvSelect}
searchTerm={fieldState.searchTerm}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
workspaceId={subBlockInput.workspaceId}
maxHeight='192px'
inputRef={textareaRefObject}
/>
{/* Tag dropdown for this message */}
<TagDropdown
visible={fieldState.showTags && !isPreview && !disabled}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={fieldState.activeSourceBlockId}
inputValue={message.content}
cursorPosition={fieldState.cursorPosition}
onClose={() => subBlockInput.fieldHelpers.hideFieldDropdowns(fieldId)}
inputRef={textareaRefObject}
/>
{!isPreview && !disabled && (
<div
className='absolute right-1 bottom-1 z-[3] flex h-4 w-4 cursor-ns-resize items-center justify-center rounded-[4px] border border-[var(--border-1)] bg-[var(--surface-5)] dark:bg-[var(--surface-5)]'
onMouseDown={(e) => handleResizeStart(fieldId, e)}
onDragStart={(e) => {
e.preventDefault()
}}
>
<ChevronsUpDown className='h-3 w-3 text-[var(--text-muted)]' />
</div>
)}
</div>
)}
</>
)
})()}

View File

@@ -23,6 +23,7 @@ interface SelectorComboboxProps {
readOnly?: boolean
onOptionChange?: (value: string) => void
allowSearch?: boolean
missingOptionLabel?: string
}
export function SelectorCombobox({
@@ -37,6 +38,7 @@ export function SelectorCombobox({
readOnly,
onOptionChange,
allowSearch = true,
missingOptionLabel,
}: SelectorComboboxProps) {
const [storeValueRaw, setStoreValue] = useSubBlockValue<string | null | undefined>(
blockId,
@@ -60,7 +62,16 @@ export function SelectorCombobox({
detailId: activeValue,
})
const optionMap = useSelectorOptionMap(options, detailOption ?? undefined)
const selectedLabel = activeValue ? (optionMap.get(activeValue)?.label ?? activeValue) : ''
const hasMissingOption =
Boolean(activeValue) &&
Boolean(missingOptionLabel) &&
!isLoading &&
!optionMap.get(activeValue!)
const selectedLabel = activeValue
? hasMissingOption
? missingOptionLabel
: (optionMap.get(activeValue)?.label ?? activeValue)
: ''
const [inputValue, setInputValue] = useState(selectedLabel)
const previousActiveValue = useRef<string | undefined>(activeValue)

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { ExternalLink } from 'lucide-react'
import { Button, Combobox } from '@/components/emcn/components'
import {
@@ -22,7 +22,7 @@ const getProviderIcon = (providerName: OAuthProvider) => {
if (!baseProviderConfig) {
return <ExternalLink className='h-3 w-3' />
}
return baseProviderConfig.icon({ className: 'h-3 w-3' })
return createElement(baseProviderConfig.icon, { className: 'h-3 w-3' })
}
const getProviderName = (providerName: OAuthProvider) => {

View File

@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SelectorCombobox } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox'
import type { SubBlockConfig } from '@/blocks/types'
import type { SelectorContext } from '@/hooks/selectors/types'
@@ -40,6 +41,7 @@ export function WorkflowSelectorInput({
isPreview={isPreview}
previewValue={previewValue}
placeholder={subBlock.placeholder || 'Select workflow...'}
missingOptionLabel={DELETED_WORKFLOW_LABEL}
/>
)
}

View File

@@ -4,11 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Check,
ChevronDown as ChevronDownIcon,
ChevronUp,
Clipboard,
ExternalLink,
Maximize2,
RepeatIcon,
Search,
SplitIcon,
X,
} from 'lucide-react'
@@ -34,6 +37,7 @@ import {
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components'
import { PreviewContextMenu } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-context-menu'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
@@ -690,6 +694,7 @@ interface ExecutionData {
output?: unknown
status?: string
durationMs?: number
childWorkflowSnapshotId?: string
}
interface WorkflowVariable {
@@ -714,6 +719,8 @@ interface PreviewEditorProps {
parallels?: Record<string, Parallel>
/** When true, shows "Not Executed" badge if no executionData is provided */
isExecutionMode?: boolean
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Optional close handler - if not provided, no close button is shown */
onClose?: () => void
/** Callback to drill down into a nested workflow block */
@@ -739,6 +746,7 @@ function PreviewEditorContent({
loops,
parallels,
isExecutionMode = false,
childWorkflowSnapshots,
onClose,
onDrillDown,
}: PreviewEditorProps) {
@@ -768,17 +776,35 @@ function PreviewEditorContent({
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } = useWorkflowState(
childWorkflowId ?? undefined
)
const childWorkflowSnapshotId = executionData?.childWorkflowSnapshotId
const childWorkflowSnapshotState = childWorkflowSnapshotId
? childWorkflowSnapshots?.[childWorkflowSnapshotId]
: undefined
const resolvedChildWorkflowState = isExecutionMode
? childWorkflowSnapshotState
: childWorkflowState
const resolvedIsLoadingChildWorkflow = isExecutionMode ? false : isLoadingChildWorkflow
const isMissingChildWorkflow =
Boolean(childWorkflowId) && !resolvedIsLoadingChildWorkflow && !resolvedChildWorkflowState
/** Drills down into the child workflow or opens it in a new tab */
const handleExpandChildWorkflow = useCallback(() => {
if (!childWorkflowId || !childWorkflowState) return
if (!childWorkflowId) return
if (isExecutionMode && onDrillDown) {
onDrillDown(block.id, childWorkflowState)
if (!childWorkflowSnapshotState) return
onDrillDown(block.id, childWorkflowSnapshotState)
} else if (workspaceId) {
window.open(`/workspace/${workspaceId}/w/${childWorkflowId}`, '_blank', 'noopener,noreferrer')
}
}, [childWorkflowId, childWorkflowState, isExecutionMode, onDrillDown, block.id, workspaceId])
}, [
childWorkflowId,
childWorkflowSnapshotState,
isExecutionMode,
onDrillDown,
block.id,
workspaceId,
])
const contentRef = useRef<HTMLDivElement>(null)
const subBlocksRef = useRef<HTMLDivElement>(null)
@@ -813,6 +839,13 @@ function PreviewEditorContent({
} = useContextMenu()
const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false })
const [copiedSection, setCopiedSection] = useState<'input' | 'output' | null>(null)
const handleCopySection = useCallback((content: string, section: 'input' | 'output') => {
navigator.clipboard.writeText(content)
setCopiedSection(section)
setTimeout(() => setCopiedSection(null), 1500)
}, [])
const openContextMenu = useCallback(
(e: React.MouseEvent, content: string, copyOnly: boolean) => {
@@ -862,9 +895,6 @@ function PreviewEditorContent({
}
}, [contextMenuData.content])
/**
* Handles mouse down event on the resize handle to initiate resizing
*/
const handleConnectionsResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsResizing(true)
@@ -874,18 +904,12 @@ function PreviewEditorContent({
[connectionsHeight]
)
/**
* Toggle connections collapsed state
*/
const toggleConnectionsCollapsed = useCallback(() => {
setConnectionsHeight((prev) =>
prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT
)
}, [])
/**
* Sets up resize event listeners during resize operations
*/
useEffect(() => {
if (!isResizing) return
@@ -1205,7 +1229,11 @@ function PreviewEditorContent({
}
emptyMessage='No input data'
>
<div onContextMenu={handleExecutionContextMenu} ref={contentRef}>
<div
onContextMenu={handleExecutionContextMenu}
ref={contentRef}
className='relative'
>
<Code.Viewer
code={formatValueAsJson(executionData.input)}
language='json'
@@ -1215,6 +1243,49 @@ function PreviewEditorContent({
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopySection(formatValueAsJson(executionData.input), 'input')
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
{copiedSection === 'input' ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedSection === 'input' ? 'Copied' : 'Copy'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
</CollapsibleSection>
)}
@@ -1231,7 +1302,7 @@ function PreviewEditorContent({
emptyMessage='No output data'
isError={executionData.status === 'error'}
>
<div onContextMenu={handleExecutionContextMenu}>
<div onContextMenu={handleExecutionContextMenu} className='relative'>
<Code.Viewer
code={formatValueAsJson(executionData.output)}
language='json'
@@ -1244,6 +1315,49 @@ function PreviewEditorContent({
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleCopySection(formatValueAsJson(executionData.output), 'output')
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
{copiedSection === 'output' ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copiedSection === 'output' ? 'Copied' : 'Copy'}
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-4)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
</CollapsibleSection>
)}
@@ -1256,7 +1370,7 @@ function PreviewEditorContent({
Workflow Preview
</div>
<div className='relative h-[160px] overflow-hidden rounded-[4px] border border-[var(--border)]'>
{isLoadingChildWorkflow ? (
{resolvedIsLoadingChildWorkflow ? (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<div
className='h-[18px] w-[18px] animate-spin rounded-full'
@@ -1269,11 +1383,11 @@ function PreviewEditorContent({
}}
/>
</div>
) : childWorkflowState ? (
) : resolvedChildWorkflowState ? (
<>
<div className='[&_*:active]:!cursor-grabbing [&_*]:!cursor-grab [&_.react-flow__handle]:!hidden h-full w-full'>
<PreviewWorkflow
workflowState={childWorkflowState}
workflowState={resolvedChildWorkflowState}
height={160}
width='100%'
isPannable={true}
@@ -1305,7 +1419,9 @@ function PreviewEditorContent({
) : (
<div className='flex h-full items-center justify-center bg-[var(--surface-3)]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
Unable to load preview
{isMissingChildWorkflow
? DELETED_WORKFLOW_LABEL
: 'Unable to load preview'}
</span>
</div>
)}

View File

@@ -9,6 +9,7 @@ import {
isSubBlockFeatureEnabled,
isSubBlockVisibleForMode,
} from '@/lib/workflows/subblocks/visibility'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
@@ -112,7 +113,7 @@ function resolveWorkflowName(
if (!rawValue || typeof rawValue !== 'string') return null
const workflowMap = useWorkflowRegistry.getState().workflows
return workflowMap[rawValue]?.name ?? null
return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL
}
/**

View File

@@ -19,6 +19,8 @@ interface TraceSpan {
status?: string
duration?: number
children?: TraceSpan[]
childWorkflowSnapshotId?: string
childWorkflowId?: string
}
interface BlockExecutionData {
@@ -28,6 +30,7 @@ interface BlockExecutionData {
durationMs: number
/** Child trace spans for nested workflow blocks */
children?: TraceSpan[]
childWorkflowSnapshotId?: string
}
/** Represents a level in the workflow navigation stack */
@@ -35,6 +38,7 @@ interface WorkflowStackEntry {
workflowState: WorkflowState
traceSpans: TraceSpan[]
blockExecutions: Record<string, BlockExecutionData>
workflowName: string
}
/**
@@ -89,6 +93,7 @@ export function buildBlockExecutions(spans: TraceSpan[]): Record<string, BlockEx
status: span.status || 'unknown',
durationMs: span.duration || 0,
children: span.children,
childWorkflowSnapshotId: span.childWorkflowSnapshotId,
}
}
}
@@ -103,6 +108,8 @@ interface PreviewProps {
traceSpans?: TraceSpan[]
/** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
blockExecutions?: Record<string, BlockExecutionData>
/** Child workflow snapshots keyed by snapshot ID (execution mode only) */
childWorkflowSnapshots?: Record<string, WorkflowState>
/** Additional CSS class names */
className?: string
/** Height of the component */
@@ -135,6 +142,7 @@ export function Preview({
workflowState: rootWorkflowState,
traceSpans: rootTraceSpans,
blockExecutions: providedBlockExecutions,
childWorkflowSnapshots,
className,
height = '100%',
width = '100%',
@@ -144,7 +152,6 @@ export function Preview({
initialSelectedBlockId,
autoSelectLeftmost = true,
}: PreviewProps) {
/** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
const [pinnedBlockId, setPinnedBlockId] = useState<string | null>(() => {
if (initialSelectedBlockId) return initialSelectedBlockId
if (autoSelectLeftmost) {
@@ -153,17 +160,14 @@ export function Preview({
return null
})
/** Stack for nested workflow navigation. Empty means we're at the root level. */
const [workflowStack, setWorkflowStack] = useState<WorkflowStackEntry[]>([])
/** Block executions for the root level */
const rootBlockExecutions = useMemo(() => {
if (providedBlockExecutions) return providedBlockExecutions
if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
return buildBlockExecutions(rootTraceSpans)
}, [providedBlockExecutions, rootTraceSpans])
/** Current block executions - either from stack or root */
const blockExecutions = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].blockExecutions
@@ -171,7 +175,6 @@ export function Preview({
return rootBlockExecutions
}, [workflowStack, rootBlockExecutions])
/** Current workflow state - either from stack or root */
const workflowState = useMemo(() => {
if (workflowStack.length > 0) {
return workflowStack[workflowStack.length - 1].workflowState
@@ -179,41 +182,39 @@ export function Preview({
return rootWorkflowState
}, [workflowStack, rootWorkflowState])
/** Whether we're in execution mode (have trace spans/block executions) */
const isExecutionMode = useMemo(() => {
return Object.keys(blockExecutions).length > 0
}, [blockExecutions])
/** Handler to drill down into a nested workflow block */
const handleDrillDown = useCallback(
(blockId: string, childWorkflowState: WorkflowState) => {
const blockExecution = blockExecutions[blockId]
const childTraceSpans = extractChildTraceSpans(blockExecution)
const childBlockExecutions = buildBlockExecutions(childTraceSpans)
const workflowName = childWorkflowState.metadata?.name || 'Nested Workflow'
setWorkflowStack((prev) => [
...prev,
{
workflowState: childWorkflowState,
traceSpans: childTraceSpans,
blockExecutions: childBlockExecutions,
workflowName,
},
])
/** Set pinned block synchronously to avoid double fitView from sidebar resize */
const leftmostId = getLeftmostBlockId(childWorkflowState)
setPinnedBlockId(leftmostId)
},
[blockExecutions]
)
/** Handler to go back up the stack */
const handleGoBack = useCallback(() => {
setWorkflowStack((prev) => prev.slice(0, -1))
setPinnedBlockId(null)
}, [])
/** Handlers for node interactions - memoized to prevent unnecessary re-renders */
const handleNodeClick = useCallback((blockId: string) => {
setPinnedBlockId(blockId)
}, [])
@@ -232,6 +233,8 @@ export function Preview({
const isNested = workflowStack.length > 0
const currentWorkflowName = isNested ? workflowStack[workflowStack.length - 1].workflowName : null
return (
<div
style={{ height, width }}
@@ -242,20 +245,27 @@ export function Preview({
)}
>
{isNested && (
<div className='absolute top-[12px] left-[12px] z-20'>
<div className='absolute top-[12px] left-[12px] z-20 flex items-center gap-[6px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={handleGoBack}
className='flex h-[30px] items-center gap-[5px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] hover:bg-[var(--surface-4)]'
className='flex h-[28px] items-center gap-[5px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] text-[var(--text-secondary)] shadow-sm hover:bg-[var(--surface-4)] hover:text-[var(--text-primary)]'
>
<ArrowLeft className='h-[13px] w-[13px]' />
<span className='font-medium text-[13px]'>Back</span>
<ArrowLeft className='h-[12px] w-[12px]' />
<span className='font-medium text-[12px]'>Back</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='bottom'>Go back to parent workflow</Tooltip.Content>
</Tooltip.Root>
{currentWorkflowName && (
<div className='flex h-[28px] max-w-[200px] items-center rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] shadow-sm'>
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
{currentWorkflowName}
</span>
</div>
)}
</div>
)}
@@ -284,6 +294,7 @@ export function Preview({
loops={workflowState.loops}
parallels={workflowState.parallels}
isExecutionMode={isExecutionMode}
childWorkflowSnapshots={childWorkflowSnapshots}
onClose={handleEditorClose}
onDrillDown={handleDrillDown}
/>

View File

@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { createElement, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -339,9 +339,7 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
>
<div className='flex items-center gap-[12px]'>
<div className='flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'>
{typeof service.icon === 'function'
? service.icon({ className: 'h-4 w-4' })
: service.icon}
{createElement(service.icon, { className: 'h-4 w-4' })}
</div>
<div className='flex flex-col justify-center gap-[1px]'>
<span className='font-medium text-[14px]'>{service.name}</span>

View File

@@ -66,7 +66,10 @@ function generateSignature(secret: string, timestamp: number, body: string): str
async function buildPayload(
log: WorkflowExecutionLog,
subscription: typeof workspaceNotificationSubscription.$inferSelect
): Promise<NotificationPayload> {
): Promise<NotificationPayload | null> {
// Skip notifications for deleted workflows
if (!log.workflowId) return null
const workflowData = await db
.select({ name: workflowTable.name, userId: workflowTable.userId })
.from(workflowTable)
@@ -526,6 +529,13 @@ export async function executeNotificationDelivery(params: NotificationDeliveryPa
const attempts = claimed[0].attempts
const payload = await buildPayload(log, subscription)
// Skip delivery for deleted workflows
if (!payload) {
await updateDeliveryStatus(deliveryId, 'failed', 'Workflow was deleted')
logger.info(`Skipping delivery ${deliveryId} - workflow was deleted`)
return
}
let result: { success: boolean; status?: number; error?: string }
switch (notificationType) {

View File

@@ -85,7 +85,9 @@ export const AgentBlock: BlockConfig<AgentResponse> = {
id: 'messages',
title: 'Messages',
type: 'messages-input',
canonicalParamId: 'messages',
placeholder: 'Enter messages...',
mode: 'basic',
wandConfig: {
enabled: true,
maintainHistory: true,
@@ -122,6 +124,15 @@ Return ONLY the JSON array.`,
generationType: 'json-object',
},
},
{
id: 'messagesRaw',
title: 'Messages',
type: 'code',
canonicalParamId: 'messages',
placeholder: '[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]',
language: 'json',
mode: 'advanced',
},
{
id: 'model',
title: 'Model',

View File

@@ -9,7 +9,7 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
description: 'Interact with YouTube videos, channels, and playlists',
authMode: AuthMode.ApiKey,
longDescription:
'Integrate YouTube into the workflow. Can search for videos, get video details, get channel information, get all videos from a channel, get channel playlists, get playlist items, find related videos, and get video comments.',
'Integrate YouTube into the workflow. Can search for videos, get trending videos, get video details, get video categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments.',
docsLink: 'https://docs.sim.ai/tools/youtube',
category: 'tools',
bgColor: '#FF0000',
@@ -21,7 +21,9 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
type: 'dropdown',
options: [
{ label: 'Search Videos', id: 'youtube_search' },
{ label: 'Get Trending Videos', id: 'youtube_trending' },
{ label: 'Get Video Details', id: 'youtube_video_details' },
{ label: 'Get Video Categories', id: 'youtube_video_categories' },
{ label: 'Get Channel Info', id: 'youtube_channel_info' },
{ label: 'Get Channel Videos', id: 'youtube_channel_videos' },
{ label: 'Get Channel Playlists', id: 'youtube_channel_playlists' },
@@ -49,6 +51,13 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
integer: true,
condition: { field: 'operation', value: 'youtube_search' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_search' },
},
{
id: 'channelId',
title: 'Filter by Channel ID',
@@ -56,6 +65,19 @@ export const YouTubeBlock: BlockConfig<YouTubeResponse> = {
placeholder: 'Filter results to a specific channel',
condition: { field: 'operation', value: 'youtube_search' },
},
{
id: 'eventType',
title: 'Live Stream Filter',
type: 'dropdown',
options: [
{ label: 'All Videos', id: '' },
{ label: 'Currently Live', id: 'live' },
{ label: 'Upcoming Streams', id: 'upcoming' },
{ label: 'Past Streams', id: 'completed' },
],
value: () => '',
condition: { field: 'operation', value: 'youtube_search' },
},
{
id: 'publishedAfter',
title: 'Published After',
@@ -131,7 +153,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
id: 'videoCategoryId',
title: 'Category ID',
type: 'short-input',
placeholder: '10 for Music, 20 for Gaming',
placeholder: 'Use Get Video Categories to find IDs',
condition: { field: 'operation', value: 'youtube_search' },
},
{
@@ -163,7 +185,10 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
title: 'Region Code',
type: 'short-input',
placeholder: 'US, GB, JP',
condition: { field: 'operation', value: 'youtube_search' },
condition: {
field: 'operation',
value: ['youtube_search', 'youtube_trending', 'youtube_video_categories'],
},
},
{
id: 'relevanceLanguage',
@@ -184,6 +209,31 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'moderate',
condition: { field: 'operation', value: 'youtube_search' },
},
// Get Trending Videos operation inputs
{
id: 'maxResults',
title: 'Max Results',
type: 'slider',
min: 1,
max: 50,
step: 1,
integer: true,
condition: { field: 'operation', value: 'youtube_trending' },
},
{
id: 'videoCategoryId',
title: 'Category ID',
type: 'short-input',
placeholder: 'Use Get Video Categories to find IDs',
condition: { field: 'operation', value: 'youtube_trending' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_trending' },
},
// Get Video Details operation inputs
{
id: 'videoId',
@@ -193,6 +243,14 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
required: true,
condition: { field: 'operation', value: 'youtube_video_details' },
},
// Get Video Categories operation inputs
{
id: 'hl',
title: 'Language',
type: 'short-input',
placeholder: 'en, es, fr (for category names)',
condition: { field: 'operation', value: 'youtube_video_categories' },
},
// Get Channel Info operation inputs
{
id: 'channelId',
@@ -241,6 +299,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'date',
condition: { field: 'operation', value: 'youtube_channel_videos' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_channel_videos' },
},
// Get Channel Playlists operation inputs
{
id: 'channelId',
@@ -260,6 +325,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
integer: true,
condition: { field: 'operation', value: 'youtube_channel_playlists' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_channel_playlists' },
},
// Get Playlist Items operation inputs
{
id: 'playlistId',
@@ -279,6 +351,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
integer: true,
condition: { field: 'operation', value: 'youtube_playlist_items' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_playlist_items' },
},
// Get Video Comments operation inputs
{
id: 'videoId',
@@ -309,6 +388,13 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
value: () => 'relevance',
condition: { field: 'operation', value: 'youtube_comments' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token for pagination (from nextPageToken)',
condition: { field: 'operation', value: 'youtube_comments' },
},
// API Key (common to all operations)
{
id: 'apiKey',
@@ -321,13 +407,15 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
],
tools: {
access: [
'youtube_search',
'youtube_video_details',
'youtube_channel_info',
'youtube_channel_videos',
'youtube_channel_playlists',
'youtube_playlist_items',
'youtube_channel_videos',
'youtube_comments',
'youtube_playlist_items',
'youtube_search',
'youtube_trending',
'youtube_video_categories',
'youtube_video_details',
],
config: {
tool: (params) => {
@@ -339,8 +427,12 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
switch (params.operation) {
case 'youtube_search':
return 'youtube_search'
case 'youtube_trending':
return 'youtube_trending'
case 'youtube_video_details':
return 'youtube_video_details'
case 'youtube_video_categories':
return 'youtube_video_categories'
case 'youtube_channel_info':
return 'youtube_channel_info'
case 'youtube_channel_videos':
@@ -363,6 +455,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
// Search Videos
query: { type: 'string', description: 'Search query' },
maxResults: { type: 'number', description: 'Maximum number of results' },
pageToken: { type: 'string', description: 'Page token for pagination' },
// Search Filters
publishedAfter: { type: 'string', description: 'Published after date (RFC 3339)' },
publishedBefore: { type: 'string', description: 'Published before date (RFC 3339)' },
@@ -370,9 +463,11 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
videoCategoryId: { type: 'string', description: 'YouTube category ID' },
videoDefinition: { type: 'string', description: 'Video quality filter' },
videoCaption: { type: 'string', description: 'Caption availability filter' },
eventType: { type: 'string', description: 'Live stream filter (live/upcoming/completed)' },
regionCode: { type: 'string', description: 'Region code (ISO 3166-1)' },
relevanceLanguage: { type: 'string', description: 'Language code (ISO 639-1)' },
safeSearch: { type: 'string', description: 'Safe search level' },
hl: { type: 'string', description: 'Language for category names' },
// Video Details & Comments
videoId: { type: 'string', description: 'YouTube video ID' },
// Channel Info
@@ -384,7 +479,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
order: { type: 'string', description: 'Sort order' },
},
outputs: {
// Search Videos & Playlist Items
// Search Videos, Trending, Playlist Items, Captions, Categories
items: { type: 'json', description: 'List of items returned' },
totalResults: { type: 'number', description: 'Total number of results' },
nextPageToken: { type: 'string', description: 'Token for next page' },
@@ -399,11 +494,33 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
viewCount: { type: 'number', description: 'View count' },
likeCount: { type: 'number', description: 'Like count' },
commentCount: { type: 'number', description: 'Comment count' },
favoriteCount: { type: 'number', description: 'Favorite count' },
thumbnail: { type: 'string', description: 'Thumbnail URL' },
tags: { type: 'json', description: 'Video tags' },
categoryId: { type: 'string', description: 'Video category ID' },
definition: { type: 'string', description: 'Video definition (hd/sd)' },
caption: { type: 'string', description: 'Has captions (true/false)' },
licensedContent: { type: 'boolean', description: 'Is licensed content' },
privacyStatus: { type: 'string', description: 'Privacy status' },
liveBroadcastContent: { type: 'string', description: 'Live broadcast status' },
defaultLanguage: { type: 'string', description: 'Default language' },
defaultAudioLanguage: { type: 'string', description: 'Default audio language' },
// Live Streaming Details
isLiveContent: { type: 'boolean', description: 'Whether video is/was a live stream' },
scheduledStartTime: { type: 'string', description: 'Scheduled start time for live streams' },
actualStartTime: { type: 'string', description: 'Actual start time of live stream' },
actualEndTime: { type: 'string', description: 'End time of live stream' },
concurrentViewers: { type: 'number', description: 'Current viewers (live only)' },
activeLiveChatId: { type: 'string', description: 'Live chat ID' },
// Channel Info
subscriberCount: { type: 'number', description: 'Subscriber count' },
videoCount: { type: 'number', description: 'Total video count' },
customUrl: { type: 'string', description: 'Channel custom URL' },
country: { type: 'string', description: 'Channel country' },
uploadsPlaylistId: { type: 'string', description: 'Uploads playlist ID' },
bannerImageUrl: { type: 'string', description: 'Channel banner URL' },
hiddenSubscriberCount: { type: 'boolean', description: 'Is subscriber count hidden' },
// Video Categories
assignable: { type: 'boolean', description: 'Whether category can be assigned' },
},
}

View File

@@ -6,6 +6,7 @@ interface ChildWorkflowErrorOptions {
childWorkflowName: string
childTraceSpans?: TraceSpan[]
executionResult?: ExecutionResult
childWorkflowSnapshotId?: string
cause?: Error
}
@@ -16,6 +17,7 @@ export class ChildWorkflowError extends Error {
readonly childTraceSpans: TraceSpan[]
readonly childWorkflowName: string
readonly executionResult?: ExecutionResult
readonly childWorkflowSnapshotId?: string
constructor(options: ChildWorkflowErrorOptions) {
super(options.message, { cause: options.cause })
@@ -23,6 +25,7 @@ export class ChildWorkflowError extends Error {
this.childWorkflowName = options.childWorkflowName
this.childTraceSpans = options.childTraceSpans ?? []
this.executionResult = options.executionResult
this.childWorkflowSnapshotId = options.childWorkflowSnapshotId
}
static isChildWorkflowError(error: unknown): error is ChildWorkflowError {

View File

@@ -237,6 +237,9 @@ export class BlockExecutor {
if (ChildWorkflowError.isChildWorkflowError(error)) {
errorOutput.childTraceSpans = error.childTraceSpans
errorOutput.childWorkflowName = error.childWorkflowName
if (error.childWorkflowSnapshotId) {
errorOutput.childWorkflowSnapshotId = error.childWorkflowSnapshotId
}
}
this.state.setBlockOutput(node.id, errorOutput, duration)

View File

@@ -2417,4 +2417,177 @@ describe('EdgeManager', () => {
expect(successReady).toContain(targetId)
})
})
describe('Condition with loop downstream - deactivation propagation', () => {
it('should deactivate nodes after loop when condition branch containing loop is deactivated', () => {
// Scenario: condition → (if) → sentinel_start → loopBody → sentinel_end → (loop_exit) → after_loop
// → (else) → other_branch
// When condition takes "else" path, the entire if-branch including nodes after the loop should be deactivated
const conditionId = 'condition'
const sentinelStartId = 'sentinel-start'
const loopBodyId = 'loop-body'
const sentinelEndId = 'sentinel-end'
const afterLoopId = 'after-loop'
const otherBranchId = 'other-branch'
const conditionNode = createMockNode(conditionId, [
{ target: sentinelStartId, sourceHandle: 'condition-if' },
{ target: otherBranchId, sourceHandle: 'condition-else' },
])
const sentinelStartNode = createMockNode(
sentinelStartId,
[{ target: loopBodyId }],
[conditionId]
)
const loopBodyNode = createMockNode(
loopBodyId,
[{ target: sentinelEndId }],
[sentinelStartId]
)
const sentinelEndNode = createMockNode(
sentinelEndId,
[
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
{ target: afterLoopId, sourceHandle: 'loop_exit' },
],
[loopBodyId]
)
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
const otherBranchNode = createMockNode(otherBranchId, [], [conditionId])
const nodes = new Map<string, DAGNode>([
[conditionId, conditionNode],
[sentinelStartId, sentinelStartNode],
[loopBodyId, loopBodyNode],
[sentinelEndId, sentinelEndNode],
[afterLoopId, afterLoopNode],
[otherBranchId, otherBranchNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Condition selects "else" branch, deactivating the "if" branch (which contains the loop)
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
// Only otherBranch should be ready
expect(readyNodes).toContain(otherBranchId)
expect(readyNodes).not.toContain(sentinelStartId)
// afterLoop should NOT be ready - its incoming edge from sentinel_end should be deactivated
expect(readyNodes).not.toContain(afterLoopId)
// Verify that countActiveIncomingEdges returns 0 for afterLoop
// (meaning the loop_exit edge was properly deactivated)
// Note: isNodeReady returns true when all edges are deactivated (no pending deps),
// but the node won't be in readyNodes since it wasn't reached via an active path
expect(edgeManager.isNodeReady(afterLoopNode)).toBe(true) // All edges deactivated = no blocking deps
})
it('should deactivate nodes after parallel when condition branch containing parallel is deactivated', () => {
// Similar scenario with parallel instead of loop
const conditionId = 'condition'
const parallelStartId = 'parallel-start'
const parallelBodyId = 'parallel-body'
const parallelEndId = 'parallel-end'
const afterParallelId = 'after-parallel'
const otherBranchId = 'other-branch'
const conditionNode = createMockNode(conditionId, [
{ target: parallelStartId, sourceHandle: 'condition-if' },
{ target: otherBranchId, sourceHandle: 'condition-else' },
])
const parallelStartNode = createMockNode(
parallelStartId,
[{ target: parallelBodyId }],
[conditionId]
)
const parallelBodyNode = createMockNode(
parallelBodyId,
[{ target: parallelEndId }],
[parallelStartId]
)
const parallelEndNode = createMockNode(
parallelEndId,
[{ target: afterParallelId, sourceHandle: 'parallel_exit' }],
[parallelBodyId]
)
const afterParallelNode = createMockNode(afterParallelId, [], [parallelEndId])
const otherBranchNode = createMockNode(otherBranchId, [], [conditionId])
const nodes = new Map<string, DAGNode>([
[conditionId, conditionNode],
[parallelStartId, parallelStartNode],
[parallelBodyId, parallelBodyNode],
[parallelEndId, parallelEndNode],
[afterParallelId, afterParallelNode],
[otherBranchId, otherBranchNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Condition selects "else" branch
const readyNodes = edgeManager.processOutgoingEdges(conditionNode, { selectedOption: 'else' })
expect(readyNodes).toContain(otherBranchId)
expect(readyNodes).not.toContain(parallelStartId)
expect(readyNodes).not.toContain(afterParallelId)
// isNodeReady returns true when all edges are deactivated (no pending deps)
expect(edgeManager.isNodeReady(afterParallelNode)).toBe(true)
})
it('should still correctly handle normal loop exit (not deactivate when loop runs)', () => {
// When a loop actually executes and exits normally, after_loop should become ready
const sentinelStartId = 'sentinel-start'
const loopBodyId = 'loop-body'
const sentinelEndId = 'sentinel-end'
const afterLoopId = 'after-loop'
const sentinelStartNode = createMockNode(sentinelStartId, [{ target: loopBodyId }])
const loopBodyNode = createMockNode(
loopBodyId,
[{ target: sentinelEndId }],
[sentinelStartId]
)
const sentinelEndNode = createMockNode(
sentinelEndId,
[
{ target: sentinelStartId, sourceHandle: 'loop_continue' },
{ target: afterLoopId, sourceHandle: 'loop_exit' },
],
[loopBodyId]
)
const afterLoopNode = createMockNode(afterLoopId, [], [sentinelEndId])
const nodes = new Map<string, DAGNode>([
[sentinelStartId, sentinelStartNode],
[loopBodyId, loopBodyNode],
[sentinelEndId, sentinelEndNode],
[afterLoopId, afterLoopNode],
])
const dag = createMockDAG(nodes)
const edgeManager = new EdgeManager(dag)
// Simulate sentinel_end completing with loop_exit (loop is done)
const readyNodes = edgeManager.processOutgoingEdges(sentinelEndNode, {
selectedRoute: 'loop_exit',
})
// afterLoop should be ready
expect(readyNodes).toContain(afterLoopId)
})
})
})

View File

@@ -243,7 +243,7 @@ export class EdgeManager {
}
for (const [, outgoingEdge] of targetNode.outgoingEdges) {
if (!this.isControlEdge(outgoingEdge.sourceHandle)) {
if (!this.isBackwardsEdge(outgoingEdge.sourceHandle)) {
this.deactivateEdgeAndDescendants(
targetId,
outgoingEdge.target,

View File

@@ -58,7 +58,12 @@ export class AgentBlockHandler implements BlockHandler {
const providerId = getProviderFromModel(model)
const formattedTools = await this.formatTools(ctx, filteredInputs.tools || [])
const streamingConfig = this.getStreamingConfig(ctx, block)
const messages = await this.buildMessages(ctx, filteredInputs)
const rawMessages = await this.buildMessages(ctx, filteredInputs)
// Transform media messages to provider-specific format
const messages = rawMessages
? this.transformMediaMessages(rawMessages, providerId)
: undefined
const providerRequest = this.buildProviderRequest({
ctx,
@@ -815,10 +820,248 @@ export class AgentBlockHandler implements BlockHandler {
typeof msg === 'object' &&
'role' in msg &&
'content' in msg &&
['system', 'user', 'assistant'].includes(msg.role)
['system', 'user', 'assistant', 'media'].includes(msg.role)
)
}
/**
* Transforms messages with 'media' role into provider-compatible format.
* Media messages are merged with the preceding or following user message,
* or converted to a user message with multimodal content.
*/
private transformMediaMessages(messages: Message[], providerId: string): Message[] {
const result: Message[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role !== 'media') {
result.push(msg)
continue
}
// Media message - transform based on provider
const mediaContent = this.createProviderMediaContent(msg, providerId)
if (!mediaContent) {
logger.warn('Could not create media content for message', { msg })
continue
}
// Check if we should merge with the previous user message
const lastMessage = result[result.length - 1]
if (lastMessage && lastMessage.role === 'user') {
// Merge media into the previous user message's content array
const existingContent = this.ensureContentArray(lastMessage, providerId)
existingContent.push(mediaContent)
lastMessage.content = existingContent as any
} else {
// Create a new user message with the media content
result.push({
role: 'user',
content: [mediaContent] as any,
})
}
}
// Post-process: ensure all user messages have consistent content format
return result.map((msg) => {
if (msg.role === 'user' && typeof msg.content === 'string') {
// Convert string content to provider-specific text format
return {
...msg,
content: this.createTextContent(msg.content, providerId) as any,
}
}
return msg
})
}
/**
* Ensures a user message has content as an array for multimodal support
*/
private ensureContentArray(msg: Message, providerId: string): any[] {
if (Array.isArray(msg.content)) {
return msg.content
}
if (typeof msg.content === 'string' && msg.content) {
return [this.createTextContent(msg.content, providerId)]
}
return []
}
/**
* Creates provider-specific text content block
*/
private createTextContent(text: string, providerId: string): any {
switch (providerId) {
case 'google':
case 'vertex':
return { text }
case 'anthropic':
return { type: 'text', text }
default:
// OpenAI format (used by most providers)
return { type: 'text', text }
}
}
/**
* Creates provider-specific media content from a media message
*/
private createProviderMediaContent(msg: Message, providerId: string): any {
const media = msg.media
if (!media) return null
const { sourceType, data, mimeType } = media
switch (providerId) {
case 'anthropic':
return this.createAnthropicMediaContent(sourceType, data, mimeType)
case 'google':
case 'vertex':
return this.createGeminiMediaContent(sourceType, data, mimeType)
default:
// OpenAI format (used by OpenAI, Azure, xAI, Mistral, etc.)
return this.createOpenAIMediaContent(sourceType, data, mimeType)
}
}
/**
* Creates OpenAI-compatible media content
*/
private createOpenAIMediaContent(
sourceType: string,
data: string,
mimeType?: string
): any {
const isImage = mimeType?.startsWith('image/')
const isAudio = mimeType?.startsWith('audio/')
if (isImage) {
if (sourceType === 'url') {
return {
type: 'image_url',
image_url: { url: data, detail: 'auto' },
}
}
// base64 or file (already converted to base64)
return {
type: 'image_url',
image_url: { url: data, detail: 'auto' },
}
}
if (isAudio) {
const base64Data = data.includes(',') ? data.split(',')[1] : data
return {
type: 'input_audio',
input_audio: {
data: base64Data,
format: mimeType === 'audio/wav' ? 'wav' : 'mp3',
},
}
}
// For documents/files, include as URL
if (sourceType === 'url') {
return {
type: 'file',
file: { url: data },
}
}
// Base64 file - some providers may not support this directly
logger.warn('Base64 file content may not be supported by this provider')
return {
type: 'text',
text: `[File: ${mimeType || 'unknown type'}]`,
}
}
/**
* Creates Anthropic-compatible media content
*/
private createAnthropicMediaContent(
sourceType: string,
data: string,
mimeType?: string
): any {
const isImage = mimeType?.startsWith('image/')
const isPdf = mimeType === 'application/pdf'
if (isImage) {
if (sourceType === 'url') {
return {
type: 'image',
source: { type: 'url', url: data },
}
}
// base64
const base64Data = data.includes(',') ? data.split(',')[1] : data
return {
type: 'image',
source: {
type: 'base64',
media_type: mimeType || 'image/png',
data: base64Data,
},
}
}
if (isPdf) {
if (sourceType === 'url') {
return {
type: 'document',
source: { type: 'url', url: data },
}
}
const base64Data = data.includes(',') ? data.split(',')[1] : data
return {
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: base64Data,
},
}
}
// Fallback for other types
return {
type: 'text',
text: `[File: ${mimeType || 'unknown type'}]`,
}
}
/**
* Creates Google Gemini-compatible media content
*/
private createGeminiMediaContent(
sourceType: string,
data: string,
mimeType?: string
): any {
if (sourceType === 'url') {
return {
fileData: {
mimeType: mimeType || 'application/octet-stream',
fileUri: data,
},
}
}
// base64
const base64Data = data.includes(',') ? data.split(',')[1] : data
return {
inlineData: {
mimeType: mimeType || 'application/octet-stream',
data: base64Data,
},
}
}
private processMemories(memories: any): Message[] {
if (!memories) return []

View File

@@ -42,9 +42,25 @@ export interface ToolInput {
customToolId?: string
}
/**
* Media content for multimodal messages
*/
export interface MediaContent {
/** Mode: basic (file upload) or advanced (URL/base64 text input) */
mode: 'basic' | 'advanced'
/** The URL or base64 data */
data: string
/** MIME type (e.g., 'image/png', 'application/pdf', 'audio/mp3') */
mimeType?: string
/** Optional filename for file uploads */
fileName?: string
}
export interface Message {
role: 'system' | 'user' | 'assistant'
role: 'system' | 'user' | 'assistant' | 'media'
content: string
/** Media content for 'media' role messages */
media?: MediaContent
executionId?: string
function_call?: any
tool_calls?: any[]

View File

@@ -198,6 +198,7 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({
success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow',
result: { data: 'test result' },
childTraceSpans: [],
@@ -235,6 +236,7 @@ describe('WorkflowBlockHandler', () => {
expect(result).toEqual({
success: true,
childWorkflowId: 'child-id',
childWorkflowName: 'Child Workflow',
result: { nested: 'data' },
childTraceSpans: [],

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { snapshotService } from '@/lib/logs/execution/snapshot/service'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
import type { TraceSpan } from '@/lib/logs/types'
import type { BlockOutput } from '@/blocks/types'
@@ -57,6 +58,7 @@ export class WorkflowBlockHandler implements BlockHandler {
const workflowMetadata = workflows[workflowId]
let childWorkflowName = workflowMetadata?.name || workflowId
let childWorkflowSnapshotId: string | undefined
try {
const currentDepth = (ctx.workflowId?.split('_sub_').length || 1) - 1
if (currentDepth >= DEFAULTS.MAX_WORKFLOW_DEPTH) {
@@ -107,6 +109,12 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowInput = inputs.input
}
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
)
childWorkflowSnapshotId = childSnapshotResult.snapshot.id
const subExecutor = new Executor({
workflow: childWorkflow.serializedState,
workflowInput: childWorkflowInput,
@@ -139,7 +147,8 @@ export class WorkflowBlockHandler implements BlockHandler {
workflowId,
childWorkflowName,
duration,
childTraceSpans
childTraceSpans,
childWorkflowSnapshotId
)
return mappedResult
@@ -172,6 +181,7 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowName,
childTraceSpans,
executionResult,
childWorkflowSnapshotId,
cause: error instanceof Error ? error : undefined,
})
}
@@ -279,6 +289,10 @@ export class WorkflowBlockHandler implements BlockHandler {
)
const workflowVariables = (workflowData.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...workflowState,
variables: workflowVariables,
}
if (Object.keys(workflowVariables).length > 0) {
logger.info(
@@ -290,6 +304,7 @@ export class WorkflowBlockHandler implements BlockHandler {
name: workflowData.name,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: workflowState.blocks,
}
}
@@ -358,11 +373,16 @@ export class WorkflowBlockHandler implements BlockHandler {
)
const workflowVariables = (wfData?.variables as Record<string, any>) || {}
const workflowStateWithVariables = {
...deployedState,
variables: workflowVariables,
}
return {
name: wfData?.name || DEFAULTS.WORKFLOW_NAME,
serializedState: serializedWorkflow,
variables: workflowVariables,
workflowState: workflowStateWithVariables,
rawBlocks: deployedState.blocks,
}
}
@@ -504,7 +524,8 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowId: string,
childWorkflowName: string,
duration: number,
childTraceSpans?: WorkflowTraceSpan[]
childTraceSpans?: WorkflowTraceSpan[],
childWorkflowSnapshotId?: string
): BlockOutput {
const success = childResult.success !== false
const result = childResult.output || {}
@@ -515,12 +536,15 @@ export class WorkflowBlockHandler implements BlockHandler {
message: `"${childWorkflowName}" failed: ${childResult.error || 'Child workflow execution failed'}`,
childWorkflowName,
childTraceSpans: childTraceSpans || [],
childWorkflowSnapshotId,
})
}
return {
success: true,
childWorkflowName,
childWorkflowId,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
result,
childTraceSpans: childTraceSpans || [],
} as Record<string, any>

View File

@@ -210,6 +210,7 @@ export interface ExecutionSnapshotData {
executionId: string
workflowId: string
workflowState: Record<string, unknown>
childWorkflowSnapshots?: Record<string, Record<string, unknown>>
executionMetadata: {
trigger: string
startedAt: string

View File

@@ -10,6 +10,7 @@ import {
type KnowledgeBaseArgs,
} from '@/lib/copilot/tools/shared/schemas'
import { useCopilotStore } from '@/stores/panel/copilot/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
/**
* Client tool for knowledge base operations
@@ -102,7 +103,19 @@ export class KnowledgeBaseClientTool extends BaseClientTool {
const logger = createLogger('KnowledgeBaseClientTool')
try {
this.setState(ClientToolCallState.executing)
const payload: KnowledgeBaseArgs = { ...(args || { operation: 'list' }) }
// Get the workspace ID from the workflow registry hydration state
const { hydration } = useWorkflowRegistry.getState()
const workspaceId = hydration.workspaceId
// Build payload with workspace ID included in args
const payload: KnowledgeBaseArgs = {
...(args || { operation: 'list' }),
args: {
...(args?.args || {}),
workspaceId: workspaceId || undefined,
},
}
const res = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',

View File

@@ -2508,6 +2508,10 @@ async function validateWorkflowSelectorIds(
for (const subBlockConfig of blockConfig.subBlocks) {
if (!SELECTOR_TYPES.has(subBlockConfig.type)) continue
// Skip oauth-input - credentials are pre-validated before edit application
// This allows existing collaborator credentials to remain untouched
if (subBlockConfig.type === 'oauth-input') continue
const subBlockValue = blockData.subBlocks?.[subBlockConfig.id]?.value
if (!subBlockValue) continue
@@ -2573,6 +2577,295 @@ async function validateWorkflowSelectorIds(
return errors
}
/**
* Pre-validates credential and apiKey inputs in operations before they are applied.
* - Validates oauth-input (credential) IDs belong to the user
* - Filters out apiKey inputs for hosted models when isHosted is true
* - Also validates credentials and apiKeys in nestedNodes (blocks inside loop/parallel)
* Returns validation errors for any removed inputs.
*/
async function preValidateCredentialInputs(
operations: EditWorkflowOperation[],
context: { userId: string },
workflowState?: Record<string, unknown>
): Promise<{ filteredOperations: EditWorkflowOperation[]; errors: ValidationError[] }> {
const { isHosted } = await import('@/lib/core/config/feature-flags')
const { getHostedModels } = await import('@/providers/utils')
const logger = createLogger('PreValidateCredentials')
const errors: ValidationError[] = []
// Collect credential and apiKey inputs that need validation/filtering
const credentialInputs: Array<{
operationIndex: number
blockId: string
blockType: string
fieldName: string
value: string
nestedBlockId?: string
}> = []
const hostedApiKeyInputs: Array<{
operationIndex: number
blockId: string
blockType: string
model: string
nestedBlockId?: string
}> = []
const hostedModelsLower = isHosted ? new Set(getHostedModels().map((m) => m.toLowerCase())) : null
/**
* Collect credential inputs from a block's inputs based on its block config
*/
function collectCredentialInputs(
blockConfig: ReturnType<typeof getBlock>,
inputs: Record<string, unknown>,
opIndex: number,
blockId: string,
blockType: string,
nestedBlockId?: string
) {
if (!blockConfig) return
for (const subBlockConfig of blockConfig.subBlocks) {
if (subBlockConfig.type !== 'oauth-input') continue
const inputValue = inputs[subBlockConfig.id]
if (!inputValue || typeof inputValue !== 'string' || inputValue.trim() === '') continue
credentialInputs.push({
operationIndex: opIndex,
blockId,
blockType,
fieldName: subBlockConfig.id,
value: inputValue,
nestedBlockId,
})
}
}
/**
* Check if apiKey should be filtered for a block with the given model
*/
function collectHostedApiKeyInput(
inputs: Record<string, unknown>,
modelValue: string | undefined,
opIndex: number,
blockId: string,
blockType: string,
nestedBlockId?: string
) {
if (!hostedModelsLower || !inputs.apiKey) return
if (!modelValue || typeof modelValue !== 'string') return
if (hostedModelsLower.has(modelValue.toLowerCase())) {
hostedApiKeyInputs.push({
operationIndex: opIndex,
blockId,
blockType,
model: modelValue,
nestedBlockId,
})
}
}
operations.forEach((op, opIndex) => {
// Process main block inputs
if (op.params?.inputs && op.params?.type) {
const blockConfig = getBlock(op.params.type)
if (blockConfig) {
// Collect credentials from main block
collectCredentialInputs(
blockConfig,
op.params.inputs as Record<string, unknown>,
opIndex,
op.block_id,
op.params.type
)
// Check for apiKey inputs on hosted models
let modelValue = (op.params.inputs as Record<string, unknown>).model as string | undefined
// For edit operations, if model is not being changed, check existing block's model
if (
!modelValue &&
op.operation_type === 'edit' &&
(op.params.inputs as Record<string, unknown>).apiKey &&
workflowState
) {
const existingBlock = (workflowState.blocks as Record<string, unknown>)?.[op.block_id] as
| Record<string, unknown>
| undefined
const existingSubBlocks = existingBlock?.subBlocks as Record<string, unknown> | undefined
const existingModelSubBlock = existingSubBlocks?.model as
| Record<string, unknown>
| undefined
modelValue = existingModelSubBlock?.value as string | undefined
}
collectHostedApiKeyInput(
op.params.inputs as Record<string, unknown>,
modelValue,
opIndex,
op.block_id,
op.params.type
)
}
}
// Process nested nodes (blocks inside loop/parallel containers)
const nestedNodes = op.params?.nestedNodes as
| Record<string, Record<string, unknown>>
| undefined
if (nestedNodes) {
Object.entries(nestedNodes).forEach(([childId, childBlock]) => {
const childType = childBlock.type as string | undefined
const childInputs = childBlock.inputs as Record<string, unknown> | undefined
if (!childType || !childInputs) return
const childBlockConfig = getBlock(childType)
if (!childBlockConfig) return
// Collect credentials from nested block
collectCredentialInputs(
childBlockConfig,
childInputs,
opIndex,
op.block_id,
childType,
childId
)
// Check for apiKey inputs on hosted models in nested block
const modelValue = childInputs.model as string | undefined
collectHostedApiKeyInput(childInputs, modelValue, opIndex, op.block_id, childType, childId)
})
}
})
const hasCredentialsToValidate = credentialInputs.length > 0
const hasHostedApiKeysToFilter = hostedApiKeyInputs.length > 0
if (!hasCredentialsToValidate && !hasHostedApiKeysToFilter) {
return { filteredOperations: operations, errors }
}
// Deep clone operations so we can modify them
const filteredOperations = structuredClone(operations)
// Filter out apiKey inputs for hosted models and add validation errors
if (hasHostedApiKeysToFilter) {
logger.info('Filtering apiKey inputs for hosted models', { count: hostedApiKeyInputs.length })
for (const apiKeyInput of hostedApiKeyInputs) {
const op = filteredOperations[apiKeyInput.operationIndex]
// Handle nested block apiKey filtering
if (apiKeyInput.nestedBlockId) {
const nestedNodes = op.params?.nestedNodes as
| Record<string, Record<string, unknown>>
| undefined
const nestedBlock = nestedNodes?.[apiKeyInput.nestedBlockId]
const nestedInputs = nestedBlock?.inputs as Record<string, unknown> | undefined
if (nestedInputs?.apiKey) {
nestedInputs.apiKey = undefined
logger.debug('Filtered apiKey for hosted model in nested block', {
parentBlockId: apiKeyInput.blockId,
nestedBlockId: apiKeyInput.nestedBlockId,
model: apiKeyInput.model,
})
errors.push({
blockId: apiKeyInput.nestedBlockId,
blockType: apiKeyInput.blockType,
field: 'apiKey',
value: '[redacted]',
error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`,
})
}
} else if (op.params?.inputs?.apiKey) {
// Handle main block apiKey filtering
op.params.inputs.apiKey = undefined
logger.debug('Filtered apiKey for hosted model', {
blockId: apiKeyInput.blockId,
model: apiKeyInput.model,
})
errors.push({
blockId: apiKeyInput.blockId,
blockType: apiKeyInput.blockType,
field: 'apiKey',
value: '[redacted]',
error: `Cannot set API key for hosted model "${apiKeyInput.model}" - API keys are managed by the platform when using hosted models`,
})
}
}
}
// Validate credential inputs
if (hasCredentialsToValidate) {
logger.info('Pre-validating credential inputs', {
credentialCount: credentialInputs.length,
userId: context.userId,
})
const allCredentialIds = credentialInputs.map((c) => c.value)
const validationResult = await validateSelectorIds('oauth-input', allCredentialIds, context)
const invalidSet = new Set(validationResult.invalid)
if (invalidSet.size > 0) {
for (const credInput of credentialInputs) {
if (!invalidSet.has(credInput.value)) continue
const op = filteredOperations[credInput.operationIndex]
// Handle nested block credential removal
if (credInput.nestedBlockId) {
const nestedNodes = op.params?.nestedNodes as
| Record<string, Record<string, unknown>>
| undefined
const nestedBlock = nestedNodes?.[credInput.nestedBlockId]
const nestedInputs = nestedBlock?.inputs as Record<string, unknown> | undefined
if (nestedInputs?.[credInput.fieldName]) {
delete nestedInputs[credInput.fieldName]
logger.info('Removed invalid credential from nested block', {
parentBlockId: credInput.blockId,
nestedBlockId: credInput.nestedBlockId,
field: credInput.fieldName,
invalidValue: credInput.value,
})
}
} else if (op.params?.inputs?.[credInput.fieldName]) {
// Handle main block credential removal
delete op.params.inputs[credInput.fieldName]
logger.info('Removed invalid credential from operation', {
blockId: credInput.blockId,
field: credInput.fieldName,
invalidValue: credInput.value,
})
}
const warningInfo = validationResult.warning ? `. ${validationResult.warning}` : ''
const errorBlockId = credInput.nestedBlockId ?? credInput.blockId
errors.push({
blockId: errorBlockId,
blockType: credInput.blockType,
field: credInput.fieldName,
value: credInput.value,
error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`,
})
}
logger.warn('Filtered out invalid credentials', {
invalidCount: invalidSet.size,
})
}
}
return { filteredOperations, errors }
}
async function getCurrentWorkflowStateFromDb(
workflowId: string
): Promise<{ workflowState: any; subBlockValues: Record<string, Record<string, any>> }> {
@@ -2657,12 +2950,29 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
// Get permission config for the user
const permissionConfig = context?.userId ? await getUserPermissionConfig(context.userId) : null
// Pre-validate credential and apiKey inputs before applying operations
// This filters out invalid credentials and apiKeys for hosted models
let operationsToApply = operations
const credentialErrors: ValidationError[] = []
if (context?.userId) {
const { filteredOperations, errors: credErrors } = await preValidateCredentialInputs(
operations,
{ userId: context.userId },
workflowState
)
operationsToApply = filteredOperations
credentialErrors.push(...credErrors)
}
// Apply operations directly to the workflow state
const {
state: modifiedWorkflowState,
validationErrors,
skippedItems,
} = applyOperationsToWorkflowState(workflowState, operations, permissionConfig)
} = applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig)
// Add credential validation errors
validationErrors.push(...credentialErrors)
// Get workspaceId for selector validation
let workspaceId: string | undefined

View File

@@ -50,6 +50,8 @@ function prepareLogData(
export async function emitWorkflowExecutionCompleted(log: WorkflowExecutionLog): Promise<void> {
try {
if (!log.workflowId) return
const workflowData = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)

View File

@@ -293,7 +293,10 @@ export class ExecutionLogger implements IExecutionLoggerService {
}
try {
const [wf] = await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId))
// Skip workflow lookup if workflow was deleted
const wf = updatedLog.workflowId
? (await db.select().from(workflow).where(eq(workflow.id, updatedLog.workflowId)))[0]
: undefined
if (wf) {
const [usr] = await db
.select({ id: userTable.id, email: userTable.email, name: userTable.name })
@@ -461,7 +464,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
* Maintains same logic as original execution logger for billing consistency
*/
private async updateUserStats(
workflowId: string,
workflowId: string | null,
costSummary: {
totalCost: number
totalInputCost: number
@@ -494,6 +497,11 @@ export class ExecutionLogger implements IExecutionLoggerService {
return
}
if (!workflowId) {
logger.debug('Workflow was deleted, skipping user stats update')
return
}
try {
// Get the workflow record to get the userId
const [workflowRecord] = await db

View File

@@ -1,8 +1,8 @@
import { createHash } from 'crypto'
import { db } from '@sim/db'
import { workflowExecutionSnapshots } from '@sim/db/schema'
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt } from 'drizzle-orm'
import { and, eq, lt, notExists } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import type {
SnapshotService as ISnapshotService,
@@ -121,7 +121,17 @@ export class SnapshotService implements ISnapshotService {
const deletedSnapshots = await db
.delete(workflowExecutionSnapshots)
.where(lt(workflowExecutionSnapshots.createdAt, cutoffDate))
.where(
and(
lt(workflowExecutionSnapshots.createdAt, cutoffDate),
notExists(
db
.select({ id: workflowExecutionLogs.id })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.stateSnapshotId, workflowExecutionSnapshots.id))
)
)
)
.returning({ id: workflowExecutionSnapshots.id })
const deletedCount = deletedSnapshots.length

View File

@@ -112,6 +112,26 @@ export function buildTraceSpans(result: ExecutionResult): {
const duration = log.durationMs || 0
let output = log.output || {}
let childWorkflowSnapshotId: string | undefined
let childWorkflowId: string | undefined
if (output && typeof output === 'object') {
const outputRecord = output as Record<string, unknown>
childWorkflowSnapshotId =
typeof outputRecord.childWorkflowSnapshotId === 'string'
? outputRecord.childWorkflowSnapshotId
: undefined
childWorkflowId =
typeof outputRecord.childWorkflowId === 'string' ? outputRecord.childWorkflowId : undefined
if (childWorkflowSnapshotId || childWorkflowId) {
const {
childWorkflowSnapshotId: _childSnapshotId,
childWorkflowId: _childWorkflowId,
...outputRest
} = outputRecord
output = outputRest
}
}
if (log.error) {
output = {
@@ -134,6 +154,8 @@ export function buildTraceSpans(result: ExecutionResult): {
blockId: log.blockId,
input: log.input || {},
output: output,
...(childWorkflowSnapshotId ? { childWorkflowSnapshotId } : {}),
...(childWorkflowId ? { childWorkflowId } : {}),
...(log.loopId && { loopId: log.loopId }),
...(log.parallelId && { parallelId: log.parallelId }),
...(log.iterationIndex !== undefined && { iterationIndex: log.iterationIndex }),

View File

@@ -69,7 +69,7 @@ export interface ExecutionStatus {
export interface WorkflowExecutionSnapshot {
id: string
workflowId: string
workflowId: string | null
stateHash: string
stateData: WorkflowState
createdAt: string
@@ -80,7 +80,7 @@ export type WorkflowExecutionSnapshotSelect = WorkflowExecutionSnapshot
export interface WorkflowExecutionLog {
id: string
workflowId: string
workflowId: string | null
executionId: string
stateSnapshotId: string
level: 'info' | 'error'
@@ -178,6 +178,8 @@ export interface TraceSpan {
blockId?: string
input?: Record<string, unknown>
output?: Record<string, unknown>
childWorkflowSnapshotId?: string
childWorkflowId?: string
model?: string
cost?: {
input?: number

View File

@@ -325,18 +325,6 @@ const nextConfig: NextConfig = {
return redirects
},
async rewrites() {
return [
{
source: '/ingest/static/:path*',
destination: 'https://us-assets.i.posthog.com/static/:path*',
},
{
source: '/ingest/:path*',
destination: 'https://us.i.posthog.com/:path*',
},
]
},
}
export default nextConfig

View File

@@ -111,9 +111,25 @@ export interface ProviderToolConfig {
usageControl?: ToolUsageControl
}
/**
* Media content for multimodal messages
*/
export interface MediaContent {
/** Mode: basic (file upload) or advanced (URL/base64 text input) */
mode: 'basic' | 'advanced'
/** The URL or base64 data */
data: string
/** MIME type (e.g., 'image/png', 'application/pdf', 'audio/mp3') */
mimeType?: string
/** Optional filename for file uploads */
fileName?: string
}
export interface Message {
role: 'system' | 'user' | 'assistant' | 'function' | 'tool'
role: 'system' | 'user' | 'assistant' | 'function' | 'tool' | 'media'
content: string | null
/** Media content for 'media' role messages */
media?: MediaContent
name?: string
function_call?: {
name: string

View File

@@ -134,6 +134,24 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
export async function proxy(request: NextRequest) {
const url = request.nextUrl
if (url.pathname.startsWith('/ingest/')) {
const hostname = url.pathname.startsWith('/ingest/static/')
? 'us-assets.i.posthog.com'
: 'us.i.posthog.com'
const targetPath = url.pathname.replace(/^\/ingest/, '')
const targetUrl = `https://${hostname}${targetPath}${url.search}`
return NextResponse.rewrite(new URL(targetUrl), {
request: {
headers: new Headers({
...Object.fromEntries(request.headers),
host: hostname,
}),
},
})
}
const sessionCookie = getSessionCookie(request)
const hasActiveSession = isAuthDisabled || !!sessionCookie
@@ -195,6 +213,7 @@ export async function proxy(request: NextRequest) {
export const config = {
matcher: [
'/ingest/:path*', // PostHog proxy for session recording
'/', // Root path for self-hosted redirect logic
'/terms', // Whitelabel terms redirect
'/privacy', // Whitelabel privacy redirect

View File

@@ -102,7 +102,7 @@ export interface TraceSpan {
export interface WorkflowLog {
id: string
workflowId: string
workflowId: string | null
executionId?: string | null
deploymentVersion?: number | null
deploymentVersionName?: string | null

View File

@@ -1648,6 +1648,8 @@ import {
youtubeCommentsTool,
youtubePlaylistItemsTool,
youtubeSearchTool,
youtubeTrendingTool,
youtubeVideoCategoriesTool,
youtubeVideoDetailsTool,
} from '@/tools/youtube'
import {
@@ -1982,13 +1984,15 @@ export const tools: Record<string, ToolConfig> = {
typeform_create_form: typeformCreateFormTool,
typeform_update_form: typeformUpdateFormTool,
typeform_delete_form: typeformDeleteFormTool,
youtube_search: youtubeSearchTool,
youtube_video_details: youtubeVideoDetailsTool,
youtube_channel_info: youtubeChannelInfoTool,
youtube_playlist_items: youtubePlaylistItemsTool,
youtube_comments: youtubeCommentsTool,
youtube_channel_videos: youtubeChannelVideosTool,
youtube_channel_playlists: youtubeChannelPlaylistsTool,
youtube_channel_videos: youtubeChannelVideosTool,
youtube_comments: youtubeCommentsTool,
youtube_playlist_items: youtubePlaylistItemsTool,
youtube_search: youtubeSearchTool,
youtube_trending: youtubeTrendingTool,
youtube_video_categories: youtubeVideoCategoriesTool,
youtube_video_details: youtubeVideoDetailsTool,
notion_read: notionReadTool,
notion_read_database: notionReadDatabaseTool,
notion_write: notionWriteTool,

View File

@@ -7,8 +7,9 @@ export const youtubeChannelInfoTool: ToolConfig<
> = {
id: 'youtube_channel_info',
name: 'YouTube Channel Info',
description: 'Get detailed information about a YouTube channel.',
version: '1.0.0',
description:
'Get detailed information about a YouTube channel including statistics, branding, and content details.',
version: '1.1.0',
params: {
channelId: {
type: 'string',
@@ -33,11 +34,11 @@ export const youtubeChannelInfoTool: ToolConfig<
request: {
url: (params: YouTubeChannelInfoParams) => {
let url =
'https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics,contentDetails'
'https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics,contentDetails,brandingSettings'
if (params.channelId) {
url += `&id=${params.channelId}`
url += `&id=${encodeURIComponent(params.channelId)}`
} else if (params.username) {
url += `&forUsername=${params.username}`
url += `&forUsername=${encodeURIComponent(params.username)}`
}
url += `&key=${params.apiKey}`
return url
@@ -63,6 +64,11 @@ export const youtubeChannelInfoTool: ToolConfig<
viewCount: 0,
publishedAt: '',
thumbnail: '',
customUrl: null,
country: null,
uploadsPlaylistId: null,
bannerImageUrl: null,
hiddenSubscriberCount: false,
},
error: 'Channel not found',
}
@@ -72,19 +78,23 @@ export const youtubeChannelInfoTool: ToolConfig<
return {
success: true,
output: {
channelId: item.id,
title: item.snippet?.title || '',
description: item.snippet?.description || '',
channelId: item.id ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
subscriberCount: Number(item.statistics?.subscriberCount || 0),
videoCount: Number(item.statistics?.videoCount || 0),
viewCount: Number(item.statistics?.viewCount || 0),
publishedAt: item.snippet?.publishedAt || '',
publishedAt: item.snippet?.publishedAt ?? '',
thumbnail:
item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
'',
customUrl: item.snippet?.customUrl,
customUrl: item.snippet?.customUrl ?? null,
country: item.snippet?.country ?? null,
uploadsPlaylistId: item.contentDetails?.relatedPlaylists?.uploads ?? null,
bannerImageUrl: item.brandingSettings?.image?.bannerExternalUrl ?? null,
hiddenSubscriberCount: item.statistics?.hiddenSubscriberCount ?? false,
},
}
},
@@ -104,11 +114,11 @@ export const youtubeChannelInfoTool: ToolConfig<
},
subscriberCount: {
type: 'number',
description: 'Number of subscribers',
description: 'Number of subscribers (0 if hidden)',
},
videoCount: {
type: 'number',
description: 'Number of videos',
description: 'Number of public videos',
},
viewCount: {
type: 'number',
@@ -120,12 +130,31 @@ export const youtubeChannelInfoTool: ToolConfig<
},
thumbnail: {
type: 'string',
description: 'Channel thumbnail URL',
description: 'Channel thumbnail/avatar URL',
},
customUrl: {
type: 'string',
description: 'Channel custom URL',
description: 'Channel custom URL (handle)',
optional: true,
},
country: {
type: 'string',
description: 'Country the channel is associated with',
optional: true,
},
uploadsPlaylistId: {
type: 'string',
description: 'Playlist ID containing all channel uploads (use with playlist_items)',
optional: true,
},
bannerImageUrl: {
type: 'string',
description: 'Channel banner image URL',
optional: true,
},
hiddenSubscriberCount: {
type: 'boolean',
description: 'Whether the subscriber count is hidden',
},
},
}

View File

@@ -10,8 +10,8 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
> = {
id: 'youtube_channel_playlists',
name: 'YouTube Channel Playlists',
description: 'Get all playlists from a specific YouTube channel.',
version: '1.0.0',
description: 'Get all public playlists from a specific YouTube channel.',
version: '1.1.0',
params: {
channelId: {
type: 'string',
@@ -47,7 +47,7 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
)}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}`
if (params.pageToken) {
url += `&pageToken=${params.pageToken}`
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
return url
},
@@ -60,36 +60,49 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
transformResponse: async (response: Response): Promise<YouTubeChannelPlaylistsResponse> => {
const data = await response.json()
if (!data.items) {
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch channel playlists',
}
}
if (!data.items || data.items.length === 0) {
return {
success: true,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: 'No playlists found',
}
}
const items = (data.items || []).map((item: any) => ({
playlistId: item.id,
title: item.snippet?.title || '',
description: item.snippet?.description || '',
playlistId: item.id ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
thumbnail:
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.high?.url ||
'',
itemCount: item.contentDetails?.itemCount || 0,
publishedAt: item.snippet?.publishedAt || '',
itemCount: Number(item.contentDetails?.itemCount || 0),
publishedAt: item.snippet?.publishedAt ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
}))
return {
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken,
totalResults: data.pageInfo?.totalResults || items.length,
nextPageToken: data.nextPageToken ?? null,
},
}
},
@@ -107,6 +120,7 @@ export const youtubeChannelPlaylistsTool: ToolConfig<
thumbnail: { type: 'string', description: 'Playlist thumbnail URL' },
itemCount: { type: 'number', description: 'Number of videos in playlist' },
publishedAt: { type: 'string', description: 'Playlist creation date' },
channelTitle: { type: 'string', description: 'Channel name' },
},
},
},

View File

@@ -10,8 +10,9 @@ export const youtubeChannelVideosTool: ToolConfig<
> = {
id: 'youtube_channel_videos',
name: 'YouTube Channel Videos',
description: 'Get all videos from a specific YouTube channel, with sorting options.',
version: '1.0.0',
description:
'Search for videos from a specific YouTube channel with sorting options. For complete channel video list, use channel_info to get uploadsPlaylistId, then use playlist_items.',
version: '1.1.0',
params: {
channelId: {
type: 'string',
@@ -30,7 +31,8 @@ export const youtubeChannelVideosTool: ToolConfig<
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Sort order: "date" (newest first), "rating", "relevance", "title", "viewCount"',
description:
'Sort order: "date" (newest first, default), "rating", "relevance", "title", "viewCount"',
},
pageToken: {
type: 'string',
@@ -52,11 +54,9 @@ export const youtubeChannelVideosTool: ToolConfig<
params.channelId
)}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}`
if (params.order) {
url += `&order=${params.order}`
}
url += `&order=${params.order || 'date'}`
if (params.pageToken) {
url += `&pageToken=${params.pageToken}`
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
return url
},
@@ -68,23 +68,38 @@ export const youtubeChannelVideosTool: ToolConfig<
transformResponse: async (response: Response): Promise<YouTubeChannelVideosResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch channel videos',
}
}
const items = (data.items || []).map((item: any) => ({
videoId: item.id?.videoId,
title: item.snippet?.title,
description: item.snippet?.description,
videoId: item.id?.videoId ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
thumbnail:
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.high?.url ||
'',
publishedAt: item.snippet?.publishedAt || '',
publishedAt: item.snippet?.publishedAt ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
}))
return {
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken,
totalResults: data.pageInfo?.totalResults || items.length,
nextPageToken: data.nextPageToken ?? null,
},
}
},
@@ -101,6 +116,7 @@ export const youtubeChannelVideosTool: ToolConfig<
description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' },
publishedAt: { type: 'string', description: 'Video publish date' },
channelTitle: { type: 'string', description: 'Channel name' },
},
},
},

View File

@@ -4,8 +4,8 @@ import type { YouTubeCommentsParams, YouTubeCommentsResponse } from '@/tools/you
export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeCommentsResponse> = {
id: 'youtube_comments',
name: 'YouTube Video Comments',
description: 'Get comments from a YouTube video.',
version: '1.0.0',
description: 'Get top-level comments from a YouTube video with author details and engagement.',
version: '1.1.0',
params: {
videoId: {
type: 'string',
@@ -18,14 +18,14 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
required: false,
visibility: 'user-only',
default: 20,
description: 'Maximum number of comments to return',
description: 'Maximum number of comments to return (1-100)',
},
order: {
type: 'string',
required: false,
visibility: 'user-only',
visibility: 'user-or-llm',
default: 'relevance',
description: 'Order of comments: time or relevance',
description: 'Order of comments: "time" (newest first) or "relevance" (most relevant first)',
},
pageToken: {
type: 'string',
@@ -43,11 +43,11 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
request: {
url: (params: YouTubeCommentsParams) => {
let url = `https://www.googleapis.com/youtube/v3/commentThreads?part=snippet,replies&videoId=${params.videoId}&key=${params.apiKey}`
let url = `https://www.googleapis.com/youtube/v3/commentThreads?part=snippet,replies&videoId=${encodeURIComponent(params.videoId)}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 20)}`
url += `&order=${params.order || 'relevance'}`
if (params.pageToken) {
url += `&pageToken=${params.pageToken}`
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
return url
},
@@ -60,18 +60,31 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
transformResponse: async (response: Response): Promise<YouTubeCommentsResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch comments',
}
}
const items = (data.items || []).map((item: any) => {
const topLevelComment = item.snippet?.topLevelComment?.snippet
return {
commentId: item.snippet?.topLevelComment?.id || item.id,
authorDisplayName: topLevelComment?.authorDisplayName || '',
authorChannelUrl: topLevelComment?.authorChannelUrl || '',
textDisplay: topLevelComment?.textDisplay || '',
textOriginal: topLevelComment?.textOriginal || '',
likeCount: topLevelComment?.likeCount || 0,
publishedAt: topLevelComment?.publishedAt || '',
updatedAt: topLevelComment?.updatedAt || '',
replyCount: item.snippet?.totalReplyCount || 0,
commentId: item.snippet?.topLevelComment?.id ?? item.id ?? '',
authorDisplayName: topLevelComment?.authorDisplayName ?? '',
authorChannelUrl: topLevelComment?.authorChannelUrl ?? '',
authorProfileImageUrl: topLevelComment?.authorProfileImageUrl ?? '',
textDisplay: topLevelComment?.textDisplay ?? '',
textOriginal: topLevelComment?.textOriginal ?? '',
likeCount: Number(topLevelComment?.likeCount || 0),
publishedAt: topLevelComment?.publishedAt ?? '',
updatedAt: topLevelComment?.updatedAt ?? '',
replyCount: Number(item.snippet?.totalReplyCount || 0),
}
})
@@ -79,8 +92,8 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken,
totalResults: data.pageInfo?.totalResults || items.length,
nextPageToken: data.nextPageToken ?? null,
},
}
},
@@ -88,25 +101,29 @@ export const youtubeCommentsTool: ToolConfig<YouTubeCommentsParams, YouTubeComme
outputs: {
items: {
type: 'array',
description: 'Array of comments from the video',
description: 'Array of top-level comments from the video',
items: {
type: 'object',
properties: {
commentId: { type: 'string', description: 'Comment ID' },
authorDisplayName: { type: 'string', description: 'Comment author name' },
authorDisplayName: { type: 'string', description: 'Comment author display name' },
authorChannelUrl: { type: 'string', description: 'Comment author channel URL' },
authorProfileImageUrl: {
type: 'string',
description: 'Comment author profile image URL',
},
textDisplay: { type: 'string', description: 'Comment text (HTML formatted)' },
textOriginal: { type: 'string', description: 'Comment text (plain text)' },
likeCount: { type: 'number', description: 'Number of likes' },
publishedAt: { type: 'string', description: 'Comment publish date' },
updatedAt: { type: 'string', description: 'Comment last updated date' },
replyCount: { type: 'number', description: 'Number of replies', optional: true },
likeCount: { type: 'number', description: 'Number of likes on the comment' },
publishedAt: { type: 'string', description: 'When the comment was posted' },
updatedAt: { type: 'string', description: 'When the comment was last edited' },
replyCount: { type: 'number', description: 'Number of replies to this comment' },
},
},
},
totalResults: {
type: 'number',
description: 'Total number of comments',
description: 'Total number of comment threads available',
},
nextPageToken: {
type: 'string',

View File

@@ -4,6 +4,8 @@ import { youtubeChannelVideosTool } from '@/tools/youtube/channel_videos'
import { youtubeCommentsTool } from '@/tools/youtube/comments'
import { youtubePlaylistItemsTool } from '@/tools/youtube/playlist_items'
import { youtubeSearchTool } from '@/tools/youtube/search'
import { youtubeTrendingTool } from '@/tools/youtube/trending'
import { youtubeVideoCategoriesTool } from '@/tools/youtube/video_categories'
import { youtubeVideoDetailsTool } from '@/tools/youtube/video_details'
export { youtubeSearchTool }
@@ -13,3 +15,5 @@ export { youtubePlaylistItemsTool }
export { youtubeCommentsTool }
export { youtubeChannelVideosTool }
export { youtubeChannelPlaylistsTool }
export { youtubeTrendingTool }
export { youtubeVideoCategoriesTool }

View File

@@ -10,21 +10,23 @@ export const youtubePlaylistItemsTool: ToolConfig<
> = {
id: 'youtube_playlist_items',
name: 'YouTube Playlist Items',
description: 'Get videos from a YouTube playlist.',
version: '1.0.0',
description:
'Get videos from a YouTube playlist. Can be used with a channel uploads playlist to get all channel videos.',
version: '1.1.0',
params: {
playlistId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'YouTube playlist ID',
description:
'YouTube playlist ID. Use uploadsPlaylistId from channel_info to get all channel videos.',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-only',
default: 10,
description: 'Maximum number of videos to return',
description: 'Maximum number of videos to return (1-50)',
},
pageToken: {
type: 'string',
@@ -42,10 +44,10 @@ export const youtubePlaylistItemsTool: ToolConfig<
request: {
url: (params: YouTubePlaylistItemsParams) => {
let url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${params.playlistId}&key=${params.apiKey}`
let url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId=${encodeURIComponent(params.playlistId)}&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}`
if (params.pageToken) {
url += `&pageToken=${params.pageToken}`
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
return url
},
@@ -58,26 +60,40 @@ export const youtubePlaylistItemsTool: ToolConfig<
transformResponse: async (response: Response): Promise<YouTubePlaylistItemsResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch playlist items',
}
}
const items = (data.items || []).map((item: any, index: number) => ({
videoId: item.contentDetails?.videoId || item.snippet?.resourceId?.videoId,
title: item.snippet?.title || '',
description: item.snippet?.description || '',
videoId: item.contentDetails?.videoId ?? item.snippet?.resourceId?.videoId ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
thumbnail:
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.high?.url ||
'',
publishedAt: item.snippet?.publishedAt || '',
channelTitle: item.snippet?.channelTitle || '',
publishedAt: item.snippet?.publishedAt ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
position: item.snippet?.position ?? index,
videoOwnerChannelId: item.snippet?.videoOwnerChannelId ?? null,
videoOwnerChannelTitle: item.snippet?.videoOwnerChannelTitle ?? null,
}))
return {
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken,
totalResults: data.pageInfo?.totalResults || items.length,
nextPageToken: data.nextPageToken ?? null,
},
}
},
@@ -94,8 +110,18 @@ export const youtubePlaylistItemsTool: ToolConfig<
description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' },
publishedAt: { type: 'string', description: 'Date added to playlist' },
channelTitle: { type: 'string', description: 'Channel name' },
position: { type: 'number', description: 'Position in playlist' },
channelTitle: { type: 'string', description: 'Playlist owner channel name' },
position: { type: 'number', description: 'Position in playlist (0-indexed)' },
videoOwnerChannelId: {
type: 'string',
description: 'Channel ID of the video owner',
optional: true,
},
videoOwnerChannelTitle: {
type: 'string',
description: 'Channel name of the video owner',
optional: true,
},
},
},
},

View File

@@ -5,8 +5,8 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
id: 'youtube_search',
name: 'YouTube Search',
description:
'Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more.',
version: '1.0.0',
'Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, live streams, and more.',
version: '1.2.0',
params: {
query: {
type: 'string',
@@ -21,13 +21,18 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
default: 5,
description: 'Maximum number of videos to return (1-50)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination (use nextPageToken from previous response)',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'YouTube API Key',
},
// Priority 1: Essential filters
channelId: {
type: 'string',
required: false,
@@ -66,9 +71,9 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Filter by YouTube category ID (e.g., "10" for Music, "20" for Gaming)',
description:
'Filter by YouTube category ID (e.g., "10" for Music, "20" for Gaming). Use video_categories to list IDs.',
},
// Priority 2: Very useful filters
videoDefinition: {
type: 'string',
required: false,
@@ -82,6 +87,13 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
description:
'Filter by caption availability: "closedCaption" (has captions), "none" (no captions), "any"',
},
eventType: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by live broadcast status: "live" (currently live), "upcoming" (scheduled), "completed" (past streams)',
},
regionCode: {
type: 'string',
required: false,
@@ -110,7 +122,9 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
)}`
url += `&maxResults=${Number(params.maxResults || 5)}`
// Add Priority 1 filters if provided
if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
if (params.channelId) {
url += `&channelId=${encodeURIComponent(params.channelId)}`
}
@@ -129,14 +143,15 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
if (params.videoCategoryId) {
url += `&videoCategoryId=${params.videoCategoryId}`
}
// Add Priority 2 filters if provided
if (params.videoDefinition) {
url += `&videoDefinition=${params.videoDefinition}`
}
if (params.videoCaption) {
url += `&videoCaption=${params.videoCaption}`
}
if (params.eventType) {
url += `&eventType=${params.eventType}`
}
if (params.regionCode) {
url += `&regionCode=${params.regionCode}`
}
@@ -157,22 +172,39 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
transformResponse: async (response: Response): Promise<YouTubeSearchResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Search failed',
}
}
const items = (data.items || []).map((item: any) => ({
videoId: item.id?.videoId,
title: item.snippet?.title,
description: item.snippet?.description,
videoId: item.id?.videoId ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
thumbnail:
item.snippet?.thumbnails?.default?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.high?.url ||
'',
channelId: item.snippet?.channelId ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
publishedAt: item.snippet?.publishedAt ?? '',
liveBroadcastContent: item.snippet?.liveBroadcastContent ?? 'none',
}))
return {
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || 0,
nextPageToken: data.nextPageToken,
nextPageToken: data.nextPageToken ?? null,
},
}
},
@@ -188,6 +220,13 @@ export const youtubeSearchTool: ToolConfig<YouTubeSearchParams, YouTubeSearchRes
title: { type: 'string', description: 'Video title' },
description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' },
channelId: { type: 'string', description: 'Channel ID that uploaded the video' },
channelTitle: { type: 'string', description: 'Channel name' },
publishedAt: { type: 'string', description: 'Video publish date' },
liveBroadcastContent: {
type: 'string',
description: 'Live broadcast status: "none", "live", or "upcoming"',
},
},
},
},

View File

@@ -0,0 +1,139 @@
import type { ToolConfig } from '@/tools/types'
import type { YouTubeTrendingParams, YouTubeTrendingResponse } from '@/tools/youtube/types'
export const youtubeTrendingTool: ToolConfig<YouTubeTrendingParams, YouTubeTrendingResponse> = {
id: 'youtube_trending',
name: 'YouTube Trending Videos',
description:
'Get the most popular/trending videos on YouTube. Can filter by region and video category.',
version: '1.0.0',
params: {
regionCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'ISO 3166-1 alpha-2 country code to get trending videos for (e.g., "US", "GB", "JP"). Defaults to US.',
},
videoCategoryId: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by video category ID (e.g., "10" for Music, "20" for Gaming, "17" for Sports)',
},
maxResults: {
type: 'number',
required: false,
visibility: 'user-only',
default: 10,
description: 'Maximum number of trending videos to return (1-50)',
},
pageToken: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Page token for pagination',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'YouTube API Key',
},
},
request: {
url: (params: YouTubeTrendingParams) => {
let url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics,contentDetails&chart=mostPopular&key=${params.apiKey}`
url += `&maxResults=${Number(params.maxResults || 10)}`
url += `&regionCode=${params.regionCode || 'US'}`
if (params.videoCategoryId) {
url += `&videoCategoryId=${params.videoCategoryId}`
}
if (params.pageToken) {
url += `&pageToken=${encodeURIComponent(params.pageToken)}`
}
return url
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response): Promise<YouTubeTrendingResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
nextPageToken: null,
},
error: data.error.message || 'Failed to fetch trending videos',
}
}
const items = (data.items || []).map((item: any) => ({
videoId: item.id ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
thumbnail:
item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
'',
channelId: item.snippet?.channelId ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
publishedAt: item.snippet?.publishedAt ?? '',
viewCount: Number(item.statistics?.viewCount || 0),
likeCount: Number(item.statistics?.likeCount || 0),
commentCount: Number(item.statistics?.commentCount || 0),
duration: item.contentDetails?.duration ?? '',
}))
return {
success: true,
output: {
items,
totalResults: data.pageInfo?.totalResults || items.length,
nextPageToken: data.nextPageToken ?? null,
},
}
},
outputs: {
items: {
type: 'array',
description: 'Array of trending videos',
items: {
type: 'object',
properties: {
videoId: { type: 'string', description: 'YouTube video ID' },
title: { type: 'string', description: 'Video title' },
description: { type: 'string', description: 'Video description' },
thumbnail: { type: 'string', description: 'Video thumbnail URL' },
channelId: { type: 'string', description: 'Channel ID' },
channelTitle: { type: 'string', description: 'Channel name' },
publishedAt: { type: 'string', description: 'Video publish date' },
viewCount: { type: 'number', description: 'Number of views' },
likeCount: { type: 'number', description: 'Number of likes' },
commentCount: { type: 'number', description: 'Number of comments' },
duration: { type: 'string', description: 'Video duration in ISO 8601 format' },
},
},
},
totalResults: {
type: 'number',
description: 'Total number of trending videos available',
},
nextPageToken: {
type: 'string',
description: 'Token for accessing the next page of results',
optional: true,
},
},
}

View File

@@ -16,6 +16,7 @@ export interface YouTubeSearchParams {
regionCode?: string
relevanceLanguage?: string
safeSearch?: 'moderate' | 'none' | 'strict'
eventType?: 'completed' | 'live' | 'upcoming'
}
export interface YouTubeSearchResponse extends ToolResponse {
@@ -25,9 +26,13 @@ export interface YouTubeSearchResponse extends ToolResponse {
title: string
description: string
thumbnail: string
channelId: string
channelTitle: string
publishedAt: string
liveBroadcastContent: string
}>
totalResults: number
nextPageToken?: string
nextPageToken?: string | null
}
}
@@ -48,8 +53,24 @@ export interface YouTubeVideoDetailsResponse extends ToolResponse {
viewCount: number
likeCount: number
commentCount: number
favoriteCount: number
thumbnail: string
tags?: string[]
tags: string[]
categoryId: string | null
definition: string | null
caption: string | null
licensedContent: boolean | null
privacyStatus: string | null
liveBroadcastContent: string | null
defaultLanguage: string | null
defaultAudioLanguage: string | null
// Live streaming details
isLiveContent: boolean
scheduledStartTime: string | null
actualStartTime: string | null
actualEndTime: string | null
concurrentViewers: number | null
activeLiveChatId: string | null
}
}
@@ -69,7 +90,11 @@ export interface YouTubeChannelInfoResponse extends ToolResponse {
viewCount: number
publishedAt: string
thumbnail: string
customUrl?: string
customUrl: string | null
country: string | null
uploadsPlaylistId: string | null
bannerImageUrl: string | null
hiddenSubscriberCount: boolean
}
}
@@ -90,9 +115,11 @@ export interface YouTubePlaylistItemsResponse extends ToolResponse {
publishedAt: string
channelTitle: string
position: number
videoOwnerChannelId: string | null
videoOwnerChannelTitle: string | null
}>
totalResults: number
nextPageToken?: string
nextPageToken?: string | null
}
}
@@ -110,15 +137,16 @@ export interface YouTubeCommentsResponse extends ToolResponse {
commentId: string
authorDisplayName: string
authorChannelUrl: string
authorProfileImageUrl: string
textDisplay: string
textOriginal: string
likeCount: number
publishedAt: string
updatedAt: string
replyCount?: number
replyCount: number
}>
totalResults: number
nextPageToken?: string
nextPageToken?: string | null
}
}
@@ -138,9 +166,10 @@ export interface YouTubeChannelVideosResponse extends ToolResponse {
description: string
thumbnail: string
publishedAt: string
channelTitle: string
}>
totalResults: number
nextPageToken?: string
nextPageToken?: string | null
}
}
@@ -160,9 +189,55 @@ export interface YouTubeChannelPlaylistsResponse extends ToolResponse {
thumbnail: string
itemCount: number
publishedAt: string
channelTitle: string
}>
totalResults: number
nextPageToken?: string | null
}
}
export interface YouTubeTrendingParams {
apiKey: string
regionCode?: string
videoCategoryId?: string
maxResults?: number
pageToken?: string
}
export interface YouTubeTrendingResponse extends ToolResponse {
output: {
items: Array<{
videoId: string
title: string
description: string
thumbnail: string
channelId: string
channelTitle: string
publishedAt: string
viewCount: number
likeCount: number
commentCount: number
duration: string
}>
totalResults: number
nextPageToken?: string | null
}
}
export interface YouTubeVideoCategoriesParams {
apiKey: string
regionCode?: string
hl?: string
}
export interface YouTubeVideoCategoriesResponse extends ToolResponse {
output: {
items: Array<{
categoryId: string
title: string
assignable: boolean
}>
totalResults: number
nextPageToken?: string
}
}
@@ -174,3 +249,5 @@ export type YouTubeResponse =
| YouTubeCommentsResponse
| YouTubeChannelVideosResponse
| YouTubeChannelPlaylistsResponse
| YouTubeTrendingResponse
| YouTubeVideoCategoriesResponse

View File

@@ -0,0 +1,108 @@
import type { ToolConfig } from '@/tools/types'
import type {
YouTubeVideoCategoriesParams,
YouTubeVideoCategoriesResponse,
} from '@/tools/youtube/types'
export const youtubeVideoCategoriesTool: ToolConfig<
YouTubeVideoCategoriesParams,
YouTubeVideoCategoriesResponse
> = {
id: 'youtube_video_categories',
name: 'YouTube Video Categories',
description:
'Get a list of video categories available on YouTube. Use this to discover valid category IDs for filtering search and trending results.',
version: '1.0.0',
params: {
regionCode: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'ISO 3166-1 alpha-2 country code to get categories for (e.g., "US", "GB", "JP"). Defaults to US.',
},
hl: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'Language for category titles (e.g., "en", "es", "fr"). Defaults to English.',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'YouTube API Key',
},
},
request: {
url: (params: YouTubeVideoCategoriesParams) => {
let url = `https://www.googleapis.com/youtube/v3/videoCategories?part=snippet&key=${params.apiKey}`
url += `&regionCode=${params.regionCode || 'US'}`
if (params.hl) {
url += `&hl=${params.hl}`
}
return url
},
method: 'GET',
headers: () => ({
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response): Promise<YouTubeVideoCategoriesResponse> => {
const data = await response.json()
if (data.error) {
return {
success: false,
output: {
items: [],
totalResults: 0,
},
error: data.error.message || 'Failed to fetch video categories',
}
}
const items = (data.items || [])
.filter((item: any) => item.snippet?.assignable !== false)
.map((item: any) => ({
categoryId: item.id ?? '',
title: item.snippet?.title ?? '',
assignable: item.snippet?.assignable ?? false,
}))
return {
success: true,
output: {
items,
totalResults: items.length,
},
}
},
outputs: {
items: {
type: 'array',
description: 'Array of video categories available in the specified region',
items: {
type: 'object',
properties: {
categoryId: {
type: 'string',
description: 'Category ID to use in search/trending filters (e.g., "10" for Music)',
},
title: { type: 'string', description: 'Human-readable category name' },
assignable: {
type: 'boolean',
description: 'Whether videos can be tagged with this category',
},
},
},
},
totalResults: {
type: 'number',
description: 'Total number of categories available',
},
},
}

View File

@@ -7,8 +7,9 @@ export const youtubeVideoDetailsTool: ToolConfig<
> = {
id: 'youtube_video_details',
name: 'YouTube Video Details',
description: 'Get detailed information about a specific YouTube video.',
version: '1.0.0',
description:
'Get detailed information about a specific YouTube video including statistics, content details, live streaming info, and metadata.',
version: '1.2.0',
params: {
videoId: {
type: 'string',
@@ -26,7 +27,7 @@ export const youtubeVideoDetailsTool: ToolConfig<
request: {
url: (params: YouTubeVideoDetailsParams) => {
return `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics,contentDetails&id=${params.videoId}&key=${params.apiKey}`
return `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics,contentDetails,status,liveStreamingDetails&id=${encodeURIComponent(params.videoId)}&key=${params.apiKey}`
},
method: 'GET',
headers: () => ({
@@ -51,32 +52,68 @@ export const youtubeVideoDetailsTool: ToolConfig<
viewCount: 0,
likeCount: 0,
commentCount: 0,
favoriteCount: 0,
thumbnail: '',
tags: [],
categoryId: null,
definition: null,
caption: null,
licensedContent: null,
privacyStatus: null,
liveBroadcastContent: null,
defaultLanguage: null,
defaultAudioLanguage: null,
isLiveContent: false,
scheduledStartTime: null,
actualStartTime: null,
actualEndTime: null,
concurrentViewers: null,
activeLiveChatId: null,
},
error: 'Video not found',
}
}
const item = data.items[0]
const liveDetails = item.liveStreamingDetails
return {
success: true,
output: {
videoId: item.id,
title: item.snippet?.title || '',
description: item.snippet?.description || '',
channelId: item.snippet?.channelId || '',
channelTitle: item.snippet?.channelTitle || '',
publishedAt: item.snippet?.publishedAt || '',
duration: item.contentDetails?.duration || '',
videoId: item.id ?? '',
title: item.snippet?.title ?? '',
description: item.snippet?.description ?? '',
channelId: item.snippet?.channelId ?? '',
channelTitle: item.snippet?.channelTitle ?? '',
publishedAt: item.snippet?.publishedAt ?? '',
duration: item.contentDetails?.duration ?? '',
viewCount: Number(item.statistics?.viewCount || 0),
likeCount: Number(item.statistics?.likeCount || 0),
commentCount: Number(item.statistics?.commentCount || 0),
favoriteCount: Number(item.statistics?.favoriteCount || 0),
thumbnail:
item.snippet?.thumbnails?.high?.url ||
item.snippet?.thumbnails?.medium?.url ||
item.snippet?.thumbnails?.default?.url ||
'',
tags: item.snippet?.tags || [],
tags: item.snippet?.tags ?? [],
categoryId: item.snippet?.categoryId ?? null,
definition: item.contentDetails?.definition ?? null,
caption: item.contentDetails?.caption ?? null,
licensedContent: item.contentDetails?.licensedContent ?? null,
privacyStatus: item.status?.privacyStatus ?? null,
liveBroadcastContent: item.snippet?.liveBroadcastContent ?? null,
defaultLanguage: item.snippet?.defaultLanguage ?? null,
defaultAudioLanguage: item.snippet?.defaultAudioLanguage ?? null,
// Live streaming details
isLiveContent: liveDetails !== undefined,
scheduledStartTime: liveDetails?.scheduledStartTime ?? null,
actualStartTime: liveDetails?.actualStartTime ?? null,
actualEndTime: liveDetails?.actualEndTime ?? null,
concurrentViewers: liveDetails?.concurrentViewers
? Number(liveDetails.concurrentViewers)
: null,
activeLiveChatId: liveDetails?.activeLiveChatId ?? null,
},
}
},
@@ -108,7 +145,7 @@ export const youtubeVideoDetailsTool: ToolConfig<
},
duration: {
type: 'string',
description: 'Video duration in ISO 8601 format',
description: 'Video duration in ISO 8601 format (e.g., "PT4M13S" for 4 min 13 sec)',
},
viewCount: {
type: 'number',
@@ -122,6 +159,10 @@ export const youtubeVideoDetailsTool: ToolConfig<
type: 'number',
description: 'Number of comments',
},
favoriteCount: {
type: 'number',
description: 'Number of times added to favorites',
},
thumbnail: {
type: 'string',
description: 'Video thumbnail URL',
@@ -132,6 +173,74 @@ export const youtubeVideoDetailsTool: ToolConfig<
items: {
type: 'string',
},
},
categoryId: {
type: 'string',
description: 'YouTube video category ID',
optional: true,
},
definition: {
type: 'string',
description: 'Video definition: "hd" or "sd"',
optional: true,
},
caption: {
type: 'string',
description: 'Whether captions are available: "true" or "false"',
optional: true,
},
licensedContent: {
type: 'boolean',
description: 'Whether the video is licensed content',
optional: true,
},
privacyStatus: {
type: 'string',
description: 'Video privacy status: "public", "private", or "unlisted"',
optional: true,
},
liveBroadcastContent: {
type: 'string',
description: 'Live broadcast status: "live", "upcoming", or "none"',
optional: true,
},
defaultLanguage: {
type: 'string',
description: 'Default language of the video metadata',
optional: true,
},
defaultAudioLanguage: {
type: 'string',
description: 'Default audio language of the video',
optional: true,
},
isLiveContent: {
type: 'boolean',
description: 'Whether this video is or was a live stream',
},
scheduledStartTime: {
type: 'string',
description: 'Scheduled start time for upcoming live streams (ISO 8601)',
optional: true,
},
actualStartTime: {
type: 'string',
description: 'When the live stream actually started (ISO 8601)',
optional: true,
},
actualEndTime: {
type: 'string',
description: 'When the live stream ended (ISO 8601)',
optional: true,
},
concurrentViewers: {
type: 'number',
description: 'Current number of viewers (only for active live streams)',
optional: true,
},
activeLiveChatId: {
type: 'string',
description: 'Live chat ID for the stream (only for active live streams)',
optional: true,
},
},

View File

@@ -0,0 +1,8 @@
ALTER TABLE "workflow_execution_logs" DROP CONSTRAINT "workflow_execution_logs_workflow_id_workflow_id_fk";
--> statement-breakpoint
ALTER TABLE "workflow_execution_snapshots" DROP CONSTRAINT "workflow_execution_snapshots_workflow_id_workflow_id_fk";
--> statement-breakpoint
ALTER TABLE "workflow_execution_logs" ALTER COLUMN "workflow_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "workflow_execution_snapshots" ALTER COLUMN "workflow_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "workflow_execution_logs" ADD CONSTRAINT "workflow_execution_logs_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workflow_execution_snapshots" ADD CONSTRAINT "workflow_execution_snapshots_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -1037,6 +1037,13 @@
"when": 1769626313827,
"tag": "0148_aberrant_venom",
"breakpoints": true
},
{
"idx": 149,
"version": "7",
"when": 1769656977701,
"tag": "0149_next_cerise",
"breakpoints": true
}
]
}

View File

@@ -268,9 +268,7 @@ export const workflowExecutionSnapshots = pgTable(
'workflow_execution_snapshots',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
stateHash: text('state_hash').notNull(),
stateData: jsonb('state_data').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
@@ -290,9 +288,7 @@ export const workflowExecutionLogs = pgTable(
'workflow_execution_logs',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),