mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-29 16:58:11 -05:00
Compare commits
10 Commits
feat/ee
...
feat/tools
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b74e723e4 | ||
|
|
2b026ded16 | ||
|
|
dca0758054 | ||
|
|
ae17c90bdf | ||
|
|
1256a15266 | ||
|
|
0b2b7ed9c8 | ||
|
|
0d8d9fb238 | ||
|
|
e0f1e66f4f | ||
|
|
20bb7cdec6 | ||
|
|
1469e9c66c |
25
README.md
25
README.md
@@ -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)
|
||||
|
||||
@@ -5113,3 +5113,21 @@ export function PulseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimilarwebIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
height='24'
|
||||
width='24'
|
||||
>
|
||||
<path
|
||||
d='M22.099 5.781c-1.283 -2 -3.14 -3.67 -5.27 -4.52l-0.63 -0.213a7.433 7.433 0 0 0 -2.15 -0.331c-2.307 0.01 -4.175 1.92 -4.175 4.275a4.3 4.3 0 0 0 0.867 2.602l-0.26 -0.342c0.124 0.186 0.26 0.37 0.417 0.556 0.663 0.802 1.604 1.635 2.822 2.58 2.999 2.32 4.943 4.378 5.104 6.93 0.038 0.344 0.062 0.696 0.062 1.051 0 1.297 -0.283 2.67 -0.764 3.635h0.005s-0.207 0.377 -0.077 0.487c0.066 0.057 0.21 0.1 0.46 -0.053a12.104 12.104 0 0 0 3.4 -3.33 12.111 12.111 0 0 0 2.088 -6.635 12.098 12.098 0 0 0 -1.9 -6.692zm-9.096 8.718 -1.878 -1.55c-3.934 -2.87 -5.98 -5.966 -4.859 -9.783a8.73 8.73 0 0 1 0.37 -1.016v-0.004s0.278 -0.583 -0.327 -0.295a12.067 12.067 0 0 0 -6.292 9.975 12.11 12.11 0 0 0 2.053 7.421 9.394 9.394 0 0 0 2.154 2.168H4.22c4.148 3.053 7.706 1.446 7.706 1.446h0.003a4.847 4.847 0 0 0 2.962 -4.492 4.855 4.855 0 0 0 -1.889 -3.87z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
ServiceNowIcon,
|
||||
SftpIcon,
|
||||
ShopifyIcon,
|
||||
SimilarwebIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
SQSIcon,
|
||||
@@ -228,6 +229,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
sftp: SftpIcon,
|
||||
sharepoint: MicrosoftSharepointIcon,
|
||||
shopify: ShopifyIcon,
|
||||
similarweb: SimilarwebIcon,
|
||||
slack: SlackIcon,
|
||||
smtp: SmtpIcon,
|
||||
sqs: SQSIcon,
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"sftp",
|
||||
"sharepoint",
|
||||
"shopify",
|
||||
"similarweb",
|
||||
"slack",
|
||||
"smtp",
|
||||
"sqs",
|
||||
|
||||
183
apps/docs/content/docs/en/tools/similarweb.mdx
Normal file
183
apps/docs/content/docs/en/tools/similarweb.mdx
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Similarweb
|
||||
description: Website traffic and analytics data
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="similarweb"
|
||||
color="#000922"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Similarweb](https://www.similarweb.com/) is a leading platform for web analytics that provides in-depth traffic and engagement data for millions of websites. Similarweb gives you insights into website visits, traffic sources, audience behavior, and competitive benchmarks.
|
||||
|
||||
With Similarweb in Sim, your agents can:
|
||||
|
||||
- **Analyze website traffic**: Retrieve key metrics such as monthly visits, average duration, bounce rates, and top countries.
|
||||
- **Understand audience engagement**: Gain insights into how users interact with websites, including pages per visit and engagement duration.
|
||||
- **Track rankings and performance**: Access global, country, and category ranks to benchmark sites against competitors.
|
||||
- **Discover traffic sources**: Break down traffic by channels like direct, search, social, referrals, and more.
|
||||
|
||||
Use Sim's Similarweb integration to automate the monitoring of competitors, track your site’s performance, or surface actionable market research—all integrated directly into your workflows and automations. Empower your agents to access and utilize reliable web analytics data easily and programmatically.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access comprehensive website analytics including traffic estimates, engagement metrics, rankings, and traffic sources using the Similarweb API.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `similarweb_website_overview`
|
||||
|
||||
Get comprehensive website analytics including traffic, rankings, engagement, and traffic sources
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | SimilarWeb API key |
|
||||
| `domain` | string | Yes | Website domain to analyze \(without www or protocol\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `siteName` | string | Website name |
|
||||
| `description` | string | Website description |
|
||||
| `globalRank` | number | Global traffic rank |
|
||||
| `countryRank` | number | Country traffic rank |
|
||||
| `categoryRank` | number | Category traffic rank |
|
||||
| `category` | string | Website category |
|
||||
| `monthlyVisits` | number | Estimated monthly visits |
|
||||
| `engagementVisitDuration` | number | Average visit duration in seconds |
|
||||
| `engagementPagesPerVisit` | number | Average pages per visit |
|
||||
| `engagementBounceRate` | number | Bounce rate \(0-1\) |
|
||||
| `topCountries` | array | Top countries by traffic share |
|
||||
| ↳ `country` | string | Country code |
|
||||
| ↳ `share` | number | Traffic share \(0-1\) |
|
||||
| `trafficSources` | json | Traffic source breakdown |
|
||||
| ↳ `direct` | number | Direct traffic share |
|
||||
| ↳ `referrals` | number | Referral traffic share |
|
||||
| ↳ `search` | number | Search traffic share |
|
||||
| ↳ `social` | number | Social traffic share |
|
||||
| ↳ `mail` | number | Email traffic share |
|
||||
| ↳ `paidReferrals` | number | Paid referral traffic share |
|
||||
|
||||
### `similarweb_traffic_visits`
|
||||
|
||||
Get total website visits over time (desktop and mobile combined)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | SimilarWeb API key |
|
||||
| `domain` | string | Yes | Website domain to analyze \(without www or protocol\) |
|
||||
| `country` | string | Yes | 2-letter ISO country code or "world" for worldwide data |
|
||||
| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly |
|
||||
| `startDate` | string | No | Start date in YYYY-MM format |
|
||||
| `endDate` | string | No | End date in YYYY-MM format |
|
||||
| `mainDomainOnly` | boolean | No | Exclude subdomains from results |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `domain` | string | Analyzed domain |
|
||||
| `country` | string | Country filter applied |
|
||||
| `granularity` | string | Data granularity |
|
||||
| `lastUpdated` | string | Data last updated timestamp |
|
||||
| `visits` | array | Visit data over time |
|
||||
| ↳ `date` | string | Date \(YYYY-MM-DD\) |
|
||||
| ↳ `visits` | number | Number of visits |
|
||||
|
||||
### `similarweb_bounce_rate`
|
||||
|
||||
Get website bounce rate over time (desktop and mobile combined)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | SimilarWeb API key |
|
||||
| `domain` | string | Yes | Website domain to analyze \(without www or protocol\) |
|
||||
| `country` | string | Yes | 2-letter ISO country code or "world" for worldwide data |
|
||||
| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly |
|
||||
| `startDate` | string | No | Start date in YYYY-MM format |
|
||||
| `endDate` | string | No | End date in YYYY-MM format |
|
||||
| `mainDomainOnly` | boolean | No | Exclude subdomains from results |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `domain` | string | Analyzed domain |
|
||||
| `country` | string | Country filter applied |
|
||||
| `granularity` | string | Data granularity |
|
||||
| `lastUpdated` | string | Data last updated timestamp |
|
||||
| `bounceRate` | array | Bounce rate data over time |
|
||||
| ↳ `date` | string | Date \(YYYY-MM-DD\) |
|
||||
| ↳ `bounceRate` | number | Bounce rate \(0-1\) |
|
||||
|
||||
### `similarweb_pages_per_visit`
|
||||
|
||||
Get average pages per visit over time (desktop and mobile combined)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | SimilarWeb API key |
|
||||
| `domain` | string | Yes | Website domain to analyze \(without www or protocol\) |
|
||||
| `country` | string | Yes | 2-letter ISO country code or "world" for worldwide data |
|
||||
| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly |
|
||||
| `startDate` | string | No | Start date in YYYY-MM format |
|
||||
| `endDate` | string | No | End date in YYYY-MM format |
|
||||
| `mainDomainOnly` | boolean | No | Exclude subdomains from results |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `domain` | string | Analyzed domain |
|
||||
| `country` | string | Country filter applied |
|
||||
| `granularity` | string | Data granularity |
|
||||
| `lastUpdated` | string | Data last updated timestamp |
|
||||
| `pagesPerVisit` | array | Pages per visit data over time |
|
||||
| ↳ `date` | string | Date \(YYYY-MM-DD\) |
|
||||
| ↳ `pagesPerVisit` | number | Average pages per visit |
|
||||
|
||||
### `similarweb_visit_duration`
|
||||
|
||||
Get average desktop visit duration over time (in seconds)
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `apiKey` | string | Yes | SimilarWeb API key |
|
||||
| `domain` | string | Yes | Website domain to analyze \(without www or protocol\) |
|
||||
| `country` | string | Yes | 2-letter ISO country code or "world" for worldwide data |
|
||||
| `granularity` | string | Yes | Data granularity: daily, weekly, or monthly |
|
||||
| `startDate` | string | No | Start date in YYYY-MM format |
|
||||
| `endDate` | string | No | End date in YYYY-MM format |
|
||||
| `mainDomainOnly` | boolean | No | Exclude subdomains from results |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `domain` | string | Analyzed domain |
|
||||
| `country` | string | Country filter applied |
|
||||
| `granularity` | string | Data granularity |
|
||||
| `lastUpdated` | string | Data last updated timestamp |
|
||||
| `averageVisitDuration` | array | Desktop visit duration data over time |
|
||||
| ↳ `date` | string | Date \(YYYY-MM-DD\) |
|
||||
| ↳ `durationSeconds` | number | Average visit duration in seconds |
|
||||
|
||||
|
||||
@@ -26,78 +26,15 @@ 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 categories, get channel information, get all videos from a channel, get channel playlists, get playlist items, and get video comments.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `youtube_search`
|
||||
|
||||
Search for videos on YouTube using the YouTube Data API. Supports advanced filtering by channel, date range, duration, category, quality, captions, and more.
|
||||
|
||||
#### 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" \(<4 min\), "medium" \(4-20 min\), "long" \(>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 |
|
||||
| `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 |
|
||||
|
||||
### `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 +51,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 +86,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 +173,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" \(<4 min\), "medium" \(4-20 min\), "long" \(>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 +239,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\) |
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || {},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)]'>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
200
apps/sim/blocks/blocks/similarweb.ts
Normal file
200
apps/sim/blocks/blocks/similarweb.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { SimilarwebIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
|
||||
export const SimilarwebBlock: BlockConfig = {
|
||||
type: 'similarweb',
|
||||
name: 'Similarweb',
|
||||
description: 'Website traffic and analytics data',
|
||||
longDescription:
|
||||
'Access comprehensive website analytics including traffic estimates, engagement metrics, rankings, and traffic sources using the Similarweb API.',
|
||||
docsLink: 'https://developers.similarweb.com/docs/similarweb-web-traffic-api',
|
||||
category: 'tools',
|
||||
bgColor: '#000922',
|
||||
icon: SimilarwebIcon,
|
||||
authMode: AuthMode.ApiKey,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Website Overview', id: 'similarweb_website_overview' },
|
||||
{ label: 'Traffic Visits', id: 'similarweb_traffic_visits' },
|
||||
{ label: 'Bounce Rate', id: 'similarweb_bounce_rate' },
|
||||
{ label: 'Pages Per Visit', id: 'similarweb_pages_per_visit' },
|
||||
{ label: 'Visit Duration (Desktop)', id: 'similarweb_visit_duration' },
|
||||
],
|
||||
value: () => 'similarweb_website_overview',
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'example.com',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
title: 'Country',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Worldwide', id: 'world' },
|
||||
{ label: 'United States', id: 'us' },
|
||||
{ label: 'United Kingdom', id: 'gb' },
|
||||
{ label: 'Germany', id: 'de' },
|
||||
{ label: 'France', id: 'fr' },
|
||||
{ label: 'Spain', id: 'es' },
|
||||
{ label: 'Italy', id: 'it' },
|
||||
{ label: 'Canada', id: 'ca' },
|
||||
{ label: 'Australia', id: 'au' },
|
||||
{ label: 'Japan', id: 'jp' },
|
||||
{ label: 'Brazil', id: 'br' },
|
||||
{ label: 'India', id: 'in' },
|
||||
{ label: 'Netherlands', id: 'nl' },
|
||||
{ label: 'Poland', id: 'pl' },
|
||||
{ label: 'Russia', id: 'ru' },
|
||||
{ label: 'Mexico', id: 'mx' },
|
||||
{ label: 'South Korea', id: 'kr' },
|
||||
{ label: 'China', id: 'cn' },
|
||||
],
|
||||
value: () => 'world',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'similarweb_website_overview',
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'granularity',
|
||||
title: 'Granularity',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Monthly', id: 'monthly' },
|
||||
{ label: 'Weekly', id: 'weekly' },
|
||||
{ label: 'Daily', id: 'daily' },
|
||||
],
|
||||
value: () => 'monthly',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'similarweb_website_overview',
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'startDate',
|
||||
title: 'Start Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM (e.g., 2024-01)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'similarweb_website_overview',
|
||||
not: true,
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a date in YYYY-MM format based on the user's description.
|
||||
Examples:
|
||||
- "this month" -> Current month in YYYY-MM format
|
||||
- "last month" -> Previous month in YYYY-MM format
|
||||
- "3 months ago" -> Date 3 months ago in YYYY-MM format
|
||||
- "beginning of year" -> January of current year (e.g., 2024-01)
|
||||
|
||||
Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Describe the start date (e.g., "3 months ago", "last month")...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'endDate',
|
||||
title: 'End Date',
|
||||
type: 'short-input',
|
||||
placeholder: 'YYYY-MM (e.g., 2024-12)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'similarweb_website_overview',
|
||||
not: true,
|
||||
},
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a date in YYYY-MM format based on the user's description.
|
||||
Examples:
|
||||
- "this month" -> Current month in YYYY-MM format
|
||||
- "last month" -> Previous month in YYYY-MM format
|
||||
- "now" -> Current month in YYYY-MM format
|
||||
|
||||
Return ONLY the date string in YYYY-MM format - no explanations, no quotes, no extra text.`,
|
||||
placeholder: 'Describe the end date (e.g., "this month", "now")...',
|
||||
generationType: 'timestamp',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'mainDomainOnly',
|
||||
title: 'Main Domain Only',
|
||||
type: 'switch',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'similarweb_website_overview',
|
||||
not: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apiKey',
|
||||
title: 'API Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Similarweb API key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'similarweb_website_overview',
|
||||
'similarweb_traffic_visits',
|
||||
'similarweb_bounce_rate',
|
||||
'similarweb_pages_per_visit',
|
||||
'similarweb_visit_duration',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => params.operation,
|
||||
},
|
||||
},
|
||||
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
domain: { type: 'string', description: 'Website domain to analyze' },
|
||||
apiKey: { type: 'string', description: 'Similarweb API key' },
|
||||
country: { type: 'string', description: '2-letter ISO country code or "world"' },
|
||||
granularity: { type: 'string', description: 'Data granularity (daily, weekly, monthly)' },
|
||||
startDate: { type: 'string', description: 'Start date in YYYY-MM format' },
|
||||
endDate: { type: 'string', description: 'End date in YYYY-MM format' },
|
||||
mainDomainOnly: { type: 'boolean', description: 'Exclude subdomains from results' },
|
||||
},
|
||||
|
||||
outputs: {
|
||||
// Website Overview outputs
|
||||
siteName: { type: 'string', description: 'Website name' },
|
||||
description: { type: 'string', description: 'Website description' },
|
||||
globalRank: { type: 'number', description: 'Global traffic rank' },
|
||||
countryRank: { type: 'number', description: 'Country traffic rank' },
|
||||
categoryRank: { type: 'number', description: 'Category traffic rank' },
|
||||
category: { type: 'string', description: 'Website category' },
|
||||
monthlyVisits: { type: 'number', description: 'Estimated monthly visits' },
|
||||
engagementVisitDuration: { type: 'number', description: 'Average visit duration (seconds)' },
|
||||
engagementPagesPerVisit: { type: 'number', description: 'Average pages per visit' },
|
||||
engagementBounceRate: { type: 'number', description: 'Bounce rate (0-1)' },
|
||||
topCountries: { type: 'json', description: 'Top countries by traffic share' },
|
||||
trafficSources: { type: 'json', description: 'Traffic source breakdown' },
|
||||
// Time series outputs
|
||||
domain: { type: 'string', description: 'Analyzed domain' },
|
||||
country: { type: 'string', description: 'Country filter applied' },
|
||||
granularity: { type: 'string', description: 'Data granularity' },
|
||||
lastUpdated: { type: 'string', description: 'Data last updated timestamp' },
|
||||
visits: { type: 'json', description: 'Visit data over time' },
|
||||
bounceRate: { type: 'json', description: 'Bounce rate data over time' },
|
||||
pagesPerVisit: { type: 'json', description: 'Pages per visit data over time' },
|
||||
averageVisitDuration: { type: 'json', description: 'Desktop visit duration data over time' },
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ import { ServiceNowBlock } from '@/blocks/blocks/servicenow'
|
||||
import { SftpBlock } from '@/blocks/blocks/sftp'
|
||||
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
|
||||
import { ShopifyBlock } from '@/blocks/blocks/shopify'
|
||||
import { SimilarwebBlock } from '@/blocks/blocks/similarweb'
|
||||
import { SlackBlock } from '@/blocks/blocks/slack'
|
||||
import { SmtpBlock } from '@/blocks/blocks/smtp'
|
||||
import { SpotifyBlock } from '@/blocks/blocks/spotify'
|
||||
@@ -280,6 +281,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
sftp: SftpBlock,
|
||||
sharepoint: SharepointBlock,
|
||||
shopify: ShopifyBlock,
|
||||
similarweb: SimilarwebBlock,
|
||||
slack: SlackBlock,
|
||||
smtp: SmtpBlock,
|
||||
spotify: SpotifyBlock,
|
||||
|
||||
@@ -5113,3 +5113,21 @@ export function PulseIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SimilarwebIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
height='24'
|
||||
width='24'
|
||||
>
|
||||
<path
|
||||
d='M22.099 5.781c-1.283 -2 -3.14 -3.67 -5.27 -4.52l-0.63 -0.213a7.433 7.433 0 0 0 -2.15 -0.331c-2.307 0.01 -4.175 1.92 -4.175 4.275a4.3 4.3 0 0 0 0.867 2.602l-0.26 -0.342c0.124 0.186 0.26 0.37 0.417 0.556 0.663 0.802 1.604 1.635 2.822 2.58 2.999 2.32 4.943 4.378 5.104 6.93 0.038 0.344 0.062 0.696 0.062 1.051 0 1.297 -0.283 2.67 -0.764 3.635h0.005s-0.207 0.377 -0.077 0.487c0.066 0.057 0.21 0.1 0.46 -0.053a12.104 12.104 0 0 0 3.4 -3.33 12.111 12.111 0 0 0 2.088 -6.635 12.098 12.098 0 0 0 -1.9 -6.692zm-9.096 8.718 -1.878 -1.55c-3.934 -2.87 -5.98 -5.966 -4.859 -9.783a8.73 8.73 0 0 1 0.37 -1.016v-0.004s0.278 -0.583 -0.327 -0.295a12.067 12.067 0 0 0 -6.292 9.975 12.11 12.11 0 0 0 2.053 7.421 9.394 9.394 0 0 0 2.154 2.168H4.22c4.148 3.053 7.706 1.446 7.706 1.446h0.003a4.847 4.847 0 0 0 2.962 -4.492 4.855 4.855 0 0 0 -1.889 -3.87z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1342,6 +1342,13 @@ import {
|
||||
shopifyUpdateOrderTool,
|
||||
shopifyUpdateProductTool,
|
||||
} from '@/tools/shopify'
|
||||
import {
|
||||
similarwebBounceRateTool,
|
||||
similarwebPagesPerVisitTool,
|
||||
similarwebTrafficVisitsTool,
|
||||
similarwebVisitDurationTool,
|
||||
similarwebWebsiteOverviewTool,
|
||||
} from '@/tools/similarweb'
|
||||
import {
|
||||
slackAddReactionTool,
|
||||
slackCanvasTool,
|
||||
@@ -1648,6 +1655,8 @@ import {
|
||||
youtubeCommentsTool,
|
||||
youtubePlaylistItemsTool,
|
||||
youtubeSearchTool,
|
||||
youtubeTrendingTool,
|
||||
youtubeVideoCategoriesTool,
|
||||
youtubeVideoDetailsTool,
|
||||
} from '@/tools/youtube'
|
||||
import {
|
||||
@@ -1934,6 +1943,11 @@ export const tools: Record<string, ToolConfig> = {
|
||||
github_latest_commit: githubLatestCommitTool,
|
||||
github_latest_commit_v2: githubLatestCommitV2Tool,
|
||||
serper_search: serperSearchTool,
|
||||
similarweb_website_overview: similarwebWebsiteOverviewTool,
|
||||
similarweb_traffic_visits: similarwebTrafficVisitsTool,
|
||||
similarweb_bounce_rate: similarwebBounceRateTool,
|
||||
similarweb_pages_per_visit: similarwebPagesPerVisitTool,
|
||||
similarweb_visit_duration: similarwebVisitDurationTool,
|
||||
servicenow_create_record: servicenowCreateRecordTool,
|
||||
servicenow_read_record: servicenowReadRecordTool,
|
||||
servicenow_update_record: servicenowUpdateRecordTool,
|
||||
@@ -1982,13 +1996,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,
|
||||
|
||||
139
apps/sim/tools/similarweb/bounce_rate.ts
Normal file
139
apps/sim/tools/similarweb/bounce_rate.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { SimilarwebBounceRateParams, SimilarwebBounceRateResponse } from './types'
|
||||
|
||||
export const similarwebBounceRateTool: ToolConfig<
|
||||
SimilarwebBounceRateParams,
|
||||
SimilarwebBounceRateResponse
|
||||
> = {
|
||||
id: 'similarweb_bounce_rate',
|
||||
name: 'SimilarWeb Bounce Rate',
|
||||
description: 'Get website bounce rate over time (desktop and mobile combined)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SimilarWeb API key',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Website domain to analyze (without www or protocol)',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: '2-letter ISO country code or "world" for worldwide data',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Data granularity: daily, weekly, or monthly',
|
||||
},
|
||||
startDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start date in YYYY-MM format',
|
||||
},
|
||||
endDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End date in YYYY-MM format',
|
||||
},
|
||||
mainDomainOnly: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Exclude subdomains from results',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const domain = params.domain
|
||||
?.trim()
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, '')
|
||||
.replace(/\/$/, '')
|
||||
const url = new URL(
|
||||
`https://api.similarweb.com/v1/website/${domain}/total-traffic-and-engagement/bounce-rate`
|
||||
)
|
||||
url.searchParams.set('api_key', params.apiKey?.trim())
|
||||
url.searchParams.set('country', params.country?.trim() ?? 'world')
|
||||
url.searchParams.set('granularity', params.granularity ?? 'monthly')
|
||||
url.searchParams.set('format', 'json')
|
||||
if (params.startDate) url.searchParams.set('start_date', params.startDate)
|
||||
if (params.endDate) url.searchParams.set('end_date', params.endDate)
|
||||
if (params.mainDomainOnly !== undefined)
|
||||
url.searchParams.set('main_domain_only', String(params.mainDomainOnly))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to get bounce rate')
|
||||
}
|
||||
|
||||
const meta = data.meta ?? {}
|
||||
const request = meta.request ?? {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
domain: request.domain ?? null,
|
||||
country: request.country ?? null,
|
||||
granularity: request.granularity ?? null,
|
||||
lastUpdated: meta.last_updated ?? null,
|
||||
bounceRate:
|
||||
data.bounce_rate?.map((b: { date: string; bounce_rate: number }) => ({
|
||||
date: b.date,
|
||||
bounceRate: b.bounce_rate,
|
||||
})) ?? [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'Analyzed domain',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
description: 'Country filter applied',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
description: 'Data granularity',
|
||||
},
|
||||
lastUpdated: {
|
||||
type: 'string',
|
||||
description: 'Data last updated timestamp',
|
||||
optional: true,
|
||||
},
|
||||
bounceRate: {
|
||||
type: 'array',
|
||||
description: 'Bounce rate data over time',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: { type: 'string', description: 'Date (YYYY-MM-DD)' },
|
||||
bounceRate: { type: 'number', description: 'Bounce rate (0-1)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
6
apps/sim/tools/similarweb/index.ts
Normal file
6
apps/sim/tools/similarweb/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { similarwebBounceRateTool } from './bounce_rate'
|
||||
export { similarwebPagesPerVisitTool } from './pages_per_visit'
|
||||
export { similarwebTrafficVisitsTool } from './traffic_visits'
|
||||
export * from './types'
|
||||
export { similarwebVisitDurationTool } from './visit_duration'
|
||||
export { similarwebWebsiteOverviewTool } from './website_overview'
|
||||
139
apps/sim/tools/similarweb/pages_per_visit.ts
Normal file
139
apps/sim/tools/similarweb/pages_per_visit.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { SimilarwebPagesPerVisitParams, SimilarwebPagesPerVisitResponse } from './types'
|
||||
|
||||
export const similarwebPagesPerVisitTool: ToolConfig<
|
||||
SimilarwebPagesPerVisitParams,
|
||||
SimilarwebPagesPerVisitResponse
|
||||
> = {
|
||||
id: 'similarweb_pages_per_visit',
|
||||
name: 'SimilarWeb Pages Per Visit',
|
||||
description: 'Get average pages per visit over time (desktop and mobile combined)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SimilarWeb API key',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Website domain to analyze (without www or protocol)',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: '2-letter ISO country code or "world" for worldwide data',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Data granularity: daily, weekly, or monthly',
|
||||
},
|
||||
startDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start date in YYYY-MM format',
|
||||
},
|
||||
endDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End date in YYYY-MM format',
|
||||
},
|
||||
mainDomainOnly: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Exclude subdomains from results',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const domain = params.domain
|
||||
?.trim()
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, '')
|
||||
.replace(/\/$/, '')
|
||||
const url = new URL(
|
||||
`https://api.similarweb.com/v1/website/${domain}/total-traffic-and-engagement/pages-per-visit`
|
||||
)
|
||||
url.searchParams.set('api_key', params.apiKey?.trim())
|
||||
url.searchParams.set('country', params.country?.trim() ?? 'world')
|
||||
url.searchParams.set('granularity', params.granularity ?? 'monthly')
|
||||
url.searchParams.set('format', 'json')
|
||||
if (params.startDate) url.searchParams.set('start_date', params.startDate)
|
||||
if (params.endDate) url.searchParams.set('end_date', params.endDate)
|
||||
if (params.mainDomainOnly !== undefined)
|
||||
url.searchParams.set('main_domain_only', String(params.mainDomainOnly))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to get pages per visit')
|
||||
}
|
||||
|
||||
const meta = data.meta ?? {}
|
||||
const request = meta.request ?? {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
domain: request.domain ?? null,
|
||||
country: request.country ?? null,
|
||||
granularity: request.granularity ?? null,
|
||||
lastUpdated: meta.last_updated ?? null,
|
||||
pagesPerVisit:
|
||||
data.pages_per_visit?.map((p: { date: string; pages_per_visit: number }) => ({
|
||||
date: p.date,
|
||||
pagesPerVisit: p.pages_per_visit,
|
||||
})) ?? [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'Analyzed domain',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
description: 'Country filter applied',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
description: 'Data granularity',
|
||||
},
|
||||
lastUpdated: {
|
||||
type: 'string',
|
||||
description: 'Data last updated timestamp',
|
||||
optional: true,
|
||||
},
|
||||
pagesPerVisit: {
|
||||
type: 'array',
|
||||
description: 'Pages per visit data over time',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: { type: 'string', description: 'Date (YYYY-MM-DD)' },
|
||||
pagesPerVisit: { type: 'number', description: 'Average pages per visit' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
139
apps/sim/tools/similarweb/traffic_visits.ts
Normal file
139
apps/sim/tools/similarweb/traffic_visits.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { SimilarwebTrafficVisitsParams, SimilarwebTrafficVisitsResponse } from './types'
|
||||
|
||||
export const similarwebTrafficVisitsTool: ToolConfig<
|
||||
SimilarwebTrafficVisitsParams,
|
||||
SimilarwebTrafficVisitsResponse
|
||||
> = {
|
||||
id: 'similarweb_traffic_visits',
|
||||
name: 'SimilarWeb Traffic Visits',
|
||||
description: 'Get total website visits over time (desktop and mobile combined)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SimilarWeb API key',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Website domain to analyze (without www or protocol)',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: '2-letter ISO country code or "world" for worldwide data',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Data granularity: daily, weekly, or monthly',
|
||||
},
|
||||
startDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start date in YYYY-MM format',
|
||||
},
|
||||
endDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End date in YYYY-MM format',
|
||||
},
|
||||
mainDomainOnly: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Exclude subdomains from results',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const domain = params.domain
|
||||
?.trim()
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, '')
|
||||
.replace(/\/$/, '')
|
||||
const url = new URL(
|
||||
`https://api.similarweb.com/v1/website/${domain}/total-traffic-and-engagement/visits`
|
||||
)
|
||||
url.searchParams.set('api_key', params.apiKey?.trim())
|
||||
url.searchParams.set('country', params.country?.trim() ?? 'world')
|
||||
url.searchParams.set('granularity', params.granularity ?? 'monthly')
|
||||
url.searchParams.set('format', 'json')
|
||||
if (params.startDate) url.searchParams.set('start_date', params.startDate)
|
||||
if (params.endDate) url.searchParams.set('end_date', params.endDate)
|
||||
if (params.mainDomainOnly !== undefined)
|
||||
url.searchParams.set('main_domain_only', String(params.mainDomainOnly))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to get traffic visits')
|
||||
}
|
||||
|
||||
const meta = data.meta ?? {}
|
||||
const request = meta.request ?? {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
domain: request.domain ?? null,
|
||||
country: request.country ?? null,
|
||||
granularity: request.granularity ?? null,
|
||||
lastUpdated: meta.last_updated ?? null,
|
||||
visits:
|
||||
data.visits?.map((v: { date: string; visits: number }) => ({
|
||||
date: v.date,
|
||||
visits: v.visits,
|
||||
})) ?? [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'Analyzed domain',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
description: 'Country filter applied',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
description: 'Data granularity',
|
||||
},
|
||||
lastUpdated: {
|
||||
type: 'string',
|
||||
description: 'Data last updated timestamp',
|
||||
optional: true,
|
||||
},
|
||||
visits: {
|
||||
type: 'array',
|
||||
description: 'Visit data over time',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: { type: 'string', description: 'Date (YYYY-MM-DD)' },
|
||||
visits: { type: 'number', description: 'Number of visits' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
139
apps/sim/tools/similarweb/types.ts
Normal file
139
apps/sim/tools/similarweb/types.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Common parameters for all SimilarWeb API endpoints
|
||||
*/
|
||||
export interface SimilarwebBaseParams {
|
||||
apiKey: string
|
||||
domain: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for time-series endpoints (visits, bounce rate, etc.)
|
||||
*/
|
||||
export interface SimilarwebTimeSeriesParams extends SimilarwebBaseParams {
|
||||
country: string
|
||||
granularity: 'daily' | 'weekly' | 'monthly'
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
mainDomainOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Website Overview (API Lite) parameters
|
||||
*/
|
||||
export interface SimilarwebWebsiteOverviewParams extends SimilarwebBaseParams {}
|
||||
|
||||
/**
|
||||
* Website Overview response
|
||||
*/
|
||||
export interface SimilarwebWebsiteOverviewResponse extends ToolResponse {
|
||||
output: {
|
||||
siteName: string
|
||||
description: string | null
|
||||
globalRank: number | null
|
||||
countryRank: number | null
|
||||
categoryRank: number | null
|
||||
category: string | null
|
||||
monthlyVisits: number | null
|
||||
engagementVisitDuration: number | null
|
||||
engagementPagesPerVisit: number | null
|
||||
engagementBounceRate: number | null
|
||||
topCountries: Array<{
|
||||
country: string
|
||||
share: number
|
||||
}>
|
||||
trafficSources: {
|
||||
direct: number | null
|
||||
referrals: number | null
|
||||
search: number | null
|
||||
social: number | null
|
||||
mail: number | null
|
||||
paidReferrals: number | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traffic Visits parameters
|
||||
*/
|
||||
export interface SimilarwebTrafficVisitsParams extends SimilarwebTimeSeriesParams {}
|
||||
|
||||
/**
|
||||
* Traffic Visits response
|
||||
*/
|
||||
export interface SimilarwebTrafficVisitsResponse extends ToolResponse {
|
||||
output: {
|
||||
domain: string
|
||||
country: string
|
||||
granularity: string
|
||||
lastUpdated: string | null
|
||||
visits: Array<{
|
||||
date: string
|
||||
visits: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounce Rate parameters
|
||||
*/
|
||||
export interface SimilarwebBounceRateParams extends SimilarwebTimeSeriesParams {}
|
||||
|
||||
/**
|
||||
* Bounce Rate response
|
||||
*/
|
||||
export interface SimilarwebBounceRateResponse extends ToolResponse {
|
||||
output: {
|
||||
domain: string
|
||||
country: string
|
||||
granularity: string
|
||||
lastUpdated: string | null
|
||||
bounceRate: Array<{
|
||||
date: string
|
||||
bounceRate: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages Per Visit parameters
|
||||
*/
|
||||
export interface SimilarwebPagesPerVisitParams extends SimilarwebTimeSeriesParams {}
|
||||
|
||||
/**
|
||||
* Pages Per Visit response
|
||||
*/
|
||||
export interface SimilarwebPagesPerVisitResponse extends ToolResponse {
|
||||
output: {
|
||||
domain: string
|
||||
country: string
|
||||
granularity: string
|
||||
lastUpdated: string | null
|
||||
pagesPerVisit: Array<{
|
||||
date: string
|
||||
pagesPerVisit: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Average Visit Duration parameters
|
||||
*/
|
||||
export interface SimilarwebVisitDurationParams extends SimilarwebTimeSeriesParams {}
|
||||
|
||||
/**
|
||||
* Average Visit Duration response
|
||||
*/
|
||||
export interface SimilarwebVisitDurationResponse extends ToolResponse {
|
||||
output: {
|
||||
domain: string
|
||||
country: string
|
||||
granularity: string
|
||||
lastUpdated: string | null
|
||||
averageVisitDuration: Array<{
|
||||
date: string
|
||||
durationSeconds: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
141
apps/sim/tools/similarweb/visit_duration.ts
Normal file
141
apps/sim/tools/similarweb/visit_duration.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { SimilarwebVisitDurationParams, SimilarwebVisitDurationResponse } from './types'
|
||||
|
||||
export const similarwebVisitDurationTool: ToolConfig<
|
||||
SimilarwebVisitDurationParams,
|
||||
SimilarwebVisitDurationResponse
|
||||
> = {
|
||||
id: 'similarweb_visit_duration',
|
||||
name: 'SimilarWeb Visit Duration',
|
||||
description: 'Get average desktop visit duration over time (in seconds)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SimilarWeb API key',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Website domain to analyze (without www or protocol)',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: '2-letter ISO country code or "world" for worldwide data',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Data granularity: daily, weekly, or monthly',
|
||||
},
|
||||
startDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start date in YYYY-MM format',
|
||||
},
|
||||
endDate: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End date in YYYY-MM format',
|
||||
},
|
||||
mainDomainOnly: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Exclude subdomains from results',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const domain = params.domain
|
||||
?.trim()
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, '')
|
||||
.replace(/\/$/, '')
|
||||
const url = new URL(
|
||||
`https://api.similarweb.com/v1/website/${domain}/traffic-and-engagement/average-visit-duration`
|
||||
)
|
||||
url.searchParams.set('api_key', params.apiKey?.trim())
|
||||
url.searchParams.set('country', params.country?.trim() ?? 'world')
|
||||
url.searchParams.set('granularity', params.granularity ?? 'monthly')
|
||||
url.searchParams.set('format', 'json')
|
||||
if (params.startDate) url.searchParams.set('start_date', params.startDate)
|
||||
if (params.endDate) url.searchParams.set('end_date', params.endDate)
|
||||
if (params.mainDomainOnly !== undefined)
|
||||
url.searchParams.set('main_domain_only', String(params.mainDomainOnly))
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to get visit duration')
|
||||
}
|
||||
|
||||
const meta = data.meta ?? {}
|
||||
const request = meta.request ?? {}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
domain: request.domain ?? null,
|
||||
country: request.country ?? null,
|
||||
granularity: request.granularity ?? null,
|
||||
lastUpdated: meta.last_updated ?? null,
|
||||
averageVisitDuration:
|
||||
data.average_visit_duration?.map(
|
||||
(d: { date: string; average_visit_duration: number }) => ({
|
||||
date: d.date,
|
||||
durationSeconds: d.average_visit_duration,
|
||||
})
|
||||
) ?? [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'Analyzed domain',
|
||||
},
|
||||
country: {
|
||||
type: 'string',
|
||||
description: 'Country filter applied',
|
||||
},
|
||||
granularity: {
|
||||
type: 'string',
|
||||
description: 'Data granularity',
|
||||
},
|
||||
lastUpdated: {
|
||||
type: 'string',
|
||||
description: 'Data last updated timestamp',
|
||||
optional: true,
|
||||
},
|
||||
averageVisitDuration: {
|
||||
type: 'array',
|
||||
description: 'Desktop visit duration data over time',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date: { type: 'string', description: 'Date (YYYY-MM-DD)' },
|
||||
durationSeconds: { type: 'number', description: 'Average visit duration in seconds' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
196
apps/sim/tools/similarweb/website_overview.ts
Normal file
196
apps/sim/tools/similarweb/website_overview.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { SimilarwebWebsiteOverviewParams, SimilarwebWebsiteOverviewResponse } from './types'
|
||||
|
||||
export const similarwebWebsiteOverviewTool: ToolConfig<
|
||||
SimilarwebWebsiteOverviewParams,
|
||||
SimilarwebWebsiteOverviewResponse
|
||||
> = {
|
||||
id: 'similarweb_website_overview',
|
||||
name: 'SimilarWeb Website Overview',
|
||||
description:
|
||||
'Get comprehensive website analytics including traffic, rankings, engagement, and traffic sources',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'SimilarWeb API key',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Website domain to analyze (without www or protocol)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const domain = params.domain
|
||||
?.trim()
|
||||
.replace(/^(https?:\/\/)?(www\.)?/, '')
|
||||
.replace(/\/$/, '')
|
||||
const url = new URL(`https://api.similarweb.com/v1/website/${domain}/general-data/all`)
|
||||
url.searchParams.set('api_key', params.apiKey?.trim())
|
||||
url.searchParams.set('format', 'json')
|
||||
return url.toString()
|
||||
},
|
||||
method: 'GET',
|
||||
headers: () => ({
|
||||
Accept: 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error?.message || data.message || 'Failed to get website overview')
|
||||
}
|
||||
|
||||
const topCountriesRaw = data.TopCountryShares ?? data.top_country_shares ?? []
|
||||
const topCountries = topCountriesRaw.map(
|
||||
(c: {
|
||||
Country?: number
|
||||
CountryCode?: string
|
||||
Value?: number
|
||||
country?: number
|
||||
country_code?: string
|
||||
value?: number
|
||||
}) => ({
|
||||
country: c.CountryCode ?? c.country_code ?? String(c.Country ?? c.country ?? ''),
|
||||
share: c.Value ?? c.value ?? 0,
|
||||
})
|
||||
)
|
||||
|
||||
const sources = data.TrafficSources ?? data.traffic_sources ?? {}
|
||||
|
||||
const engagements = data.Engagements ?? data.engagements ?? data.engagments ?? {}
|
||||
|
||||
const getGlobalRank = () => {
|
||||
if (data.GlobalRank?.Rank !== undefined) return data.GlobalRank.Rank
|
||||
if (data.global_rank?.rank !== undefined) return data.global_rank.rank
|
||||
if (typeof data.GlobalRank === 'number') return data.GlobalRank
|
||||
if (typeof data.global_rank === 'number') return data.global_rank
|
||||
return null
|
||||
}
|
||||
|
||||
const getCountryRank = () => {
|
||||
if (data.CountryRank?.Rank !== undefined) return data.CountryRank.Rank
|
||||
if (data.country_rank?.rank !== undefined) return data.country_rank.rank
|
||||
if (typeof data.CountryRank === 'number') return data.CountryRank
|
||||
if (typeof data.country_rank === 'number') return data.country_rank
|
||||
return null
|
||||
}
|
||||
|
||||
const getCategoryRank = () => {
|
||||
if (data.CategoryRank?.Rank !== undefined) return data.CategoryRank.Rank
|
||||
if (data.category_rank?.rank !== undefined) return data.category_rank.rank
|
||||
if (typeof data.CategoryRank === 'number') return data.CategoryRank
|
||||
if (typeof data.category_rank === 'number') return data.category_rank
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
siteName: data.SiteName ?? data.site_name ?? null,
|
||||
description: data.Description ?? data.description ?? null,
|
||||
globalRank: getGlobalRank(),
|
||||
countryRank: getCountryRank(),
|
||||
categoryRank: getCategoryRank(),
|
||||
category: data.Category ?? data.category ?? null,
|
||||
monthlyVisits: engagements.Visits ?? engagements.visits ?? null,
|
||||
engagementVisitDuration: engagements.TimeOnSite ?? engagements.time_on_site ?? null,
|
||||
engagementPagesPerVisit: engagements.PagePerVisit ?? engagements.page_per_visit ?? null,
|
||||
engagementBounceRate: engagements.BounceRate ?? engagements.bounce_rate ?? null,
|
||||
topCountries,
|
||||
trafficSources: {
|
||||
direct: sources.Direct ?? sources.direct ?? null,
|
||||
referrals: sources.Referrals ?? sources.referrals ?? null,
|
||||
search: sources.Search ?? sources.search ?? null,
|
||||
social: sources.Social ?? sources.social ?? null,
|
||||
mail: sources.Mail ?? sources.mail ?? null,
|
||||
paidReferrals: sources['Paid Referrals'] ?? sources.paid_referrals ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
siteName: {
|
||||
type: 'string',
|
||||
description: 'Website name',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Website description',
|
||||
optional: true,
|
||||
},
|
||||
globalRank: {
|
||||
type: 'number',
|
||||
description: 'Global traffic rank',
|
||||
optional: true,
|
||||
},
|
||||
countryRank: {
|
||||
type: 'number',
|
||||
description: 'Country traffic rank',
|
||||
optional: true,
|
||||
},
|
||||
categoryRank: {
|
||||
type: 'number',
|
||||
description: 'Category traffic rank',
|
||||
optional: true,
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Website category',
|
||||
optional: true,
|
||||
},
|
||||
monthlyVisits: {
|
||||
type: 'number',
|
||||
description: 'Estimated monthly visits',
|
||||
optional: true,
|
||||
},
|
||||
engagementVisitDuration: {
|
||||
type: 'number',
|
||||
description: 'Average visit duration in seconds',
|
||||
optional: true,
|
||||
},
|
||||
engagementPagesPerVisit: {
|
||||
type: 'number',
|
||||
description: 'Average pages per visit',
|
||||
optional: true,
|
||||
},
|
||||
engagementBounceRate: {
|
||||
type: 'number',
|
||||
description: 'Bounce rate (0-1)',
|
||||
optional: true,
|
||||
},
|
||||
topCountries: {
|
||||
type: 'array',
|
||||
description: 'Top countries by traffic share',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
country: { type: 'string', description: 'Country code' },
|
||||
share: { type: 'number', description: 'Traffic share (0-1)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
trafficSources: {
|
||||
type: 'json',
|
||||
description: 'Traffic source breakdown',
|
||||
properties: {
|
||||
direct: { type: 'number', description: 'Direct traffic share' },
|
||||
referrals: { type: 'number', description: 'Referral traffic share' },
|
||||
search: { type: 'number', description: 'Search traffic share' },
|
||||
social: { type: 'number', description: 'Social traffic share' },
|
||||
mail: { type: 'number', description: 'Email traffic share' },
|
||||
paidReferrals: { type: 'number', description: 'Paid referral traffic share' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 += `®ionCode=${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"',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
139
apps/sim/tools/youtube/trending.ts
Normal file
139
apps/sim/tools/youtube/trending.ts
Normal 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 += `®ionCode=${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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
108
apps/sim/tools/youtube/video_categories.ts
Normal file
108
apps/sim/tools/youtube/video_categories.ts
Normal 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 += `®ionCode=${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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
8
packages/db/migrations/0149_next_cerise.sql
Normal file
8
packages/db/migrations/0149_next_cerise.sql
Normal 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;
|
||||
10347
packages/db/migrations/meta/0149_snapshot.json
Normal file
10347
packages/db/migrations/meta/0149_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1037,6 +1037,13 @@
|
||||
"when": 1769626313827,
|
||||
"tag": "0148_aberrant_venom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 149,
|
||||
"version": "7",
|
||||
"when": 1769656977701,
|
||||
"tag": "0149_next_cerise",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user