diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 61c0267375..21ef5ced5c 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -836,17 +836,18 @@ export function DiscordIcon(props: SVGProps) { export function LinkedInIcon(props: SVGProps) { return ( - - - - - + + ) } @@ -3833,3 +3834,32 @@ export function ApifyIcon(props: SVGProps) { ) } + +interface StatusDotIconProps extends SVGProps { + status: 'operational' | 'degraded' | 'outage' | 'maintenance' | 'loading' | 'error' +} + +export function StatusDotIcon({ status, className, ...props }: StatusDotIconProps) { + const colors = { + operational: '#10B981', + degraded: '#F59E0B', + outage: '#EF4444', + maintenance: '#3B82F6', + loading: '#9CA3AF', + error: '#9CA3AF', + } + + return ( + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 5a31d3c47d..a1e8348d74 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -68,6 +68,7 @@ import { ResendIcon, S3Icon, SalesforceIcon, + SearchIcon, SendgridIcon, SentryIcon, SerperIcon, @@ -128,6 +129,7 @@ export const blockTypeToIconMap: Record = { serper: SerperIcon, sentry: SentryIcon, sendgrid: SendgridIcon, + search: SearchIcon, salesforce: SalesforceIcon, s3: S3Icon, resend: ResendIcon, diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index a49297bad1..9c0469c808 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -63,6 +63,7 @@ "resend", "s3", "salesforce", + "search", "sendgrid", "sentry", "serper", diff --git a/apps/docs/content/docs/en/tools/search.mdx b/apps/docs/content/docs/en/tools/search.mdx new file mode 100644 index 0000000000..795956a565 --- /dev/null +++ b/apps/docs/content/docs/en/tools/search.mdx @@ -0,0 +1,59 @@ +--- +title: Search +description: Search the web ($0.01 per search) +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +The **Search** tool lets you search the web from within your Sim workflows using state-of-the-art search engines. Use it to pull in the latest information, news, facts, and web content directly into your agents, automations, or conversations. + +- **General web search**: Find up-to-date information from the internet to supplement your workflows. +- **Automated queries**: Let agents or program logic submit search queries and handle the results automatically. +- **Structured results**: Returns the most relevant web results, including title, link, snippet, and date for each result. + +> **Note:** Each search costs **$0.01** per query. + +This tool is ideal for any workflow where your agents need access to live web data or must reference current events, perform research, or fetch supplemental content. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Search the web using the Search tool. Each search costs $0.01 per query. + + + +## Tools + +### `search_tool` + +Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | The search query | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `results` | json | Search results | +| `query` | string | The search query | +| `totalResults` | number | Total number of results | +| `source` | string | Search source \(exa\) | +| `cost` | json | Cost information \($0.01\) | + + + +## Notes + +- Category: `tools` +- Type: `search` diff --git a/apps/sim/app/api/tools/search/route.ts b/apps/sim/app/api/tools/search/route.ts new file mode 100644 index 0000000000..f3a748e2b9 --- /dev/null +++ b/apps/sim/app/api/tools/search/route.ts @@ -0,0 +1,130 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkHybridAuth } from '@/lib/auth/hybrid' +import { SEARCH_TOOL_COST } from '@/lib/billing/constants' +import { env } from '@/lib/env' +import { createLogger } from '@/lib/logs/console/logger' +import { executeTool } from '@/tools' + +const logger = createLogger('search') + +const SearchRequestSchema = z.object({ + query: z.string().min(1), +}) + +export const maxDuration = 60 +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID() + + try { + const { searchParams: urlParams } = new URL(request.url) + const workflowId = urlParams.get('workflowId') || undefined + + const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) + + if (!authResult.success || !authResult.userId) { + const errorMessage = workflowId ? 'Workflow not found' : authResult.error || 'Unauthorized' + const statusCode = workflowId ? 404 : 401 + return NextResponse.json({ success: false, error: errorMessage }, { status: statusCode }) + } + + const userId = authResult.userId + + logger.info(`[${requestId}] Authenticated search request via ${authResult.authType}`, { + userId, + }) + + const body = await request.json() + const validated = SearchRequestSchema.parse(body) + + if (!env.EXA_API_KEY) { + logger.error(`[${requestId}] EXA_API_KEY not configured`) + return NextResponse.json( + { success: false, error: 'Search service not configured' }, + { status: 503 } + ) + } + + logger.info(`[${requestId}] Executing search`, { + userId, + query: validated.query, + }) + + const result = await executeTool('exa_search', { + query: validated.query, + type: 'auto', + useAutoprompt: true, + text: true, + apiKey: env.EXA_API_KEY, + }) + + if (!result.success) { + logger.error(`[${requestId}] Search failed`, { + userId, + error: result.error, + }) + return NextResponse.json( + { + success: false, + error: result.error || 'Search failed', + }, + { status: 500 } + ) + } + + const results = (result.output.results || []).map((r: any, index: number) => ({ + title: r.title || '', + link: r.url || '', + snippet: r.text || '', + date: r.publishedDate || undefined, + position: index + 1, + })) + + const cost = { + input: 0, + output: 0, + total: SEARCH_TOOL_COST, + tokens: { + prompt: 0, + completion: 0, + total: 0, + }, + model: 'search-exa', + pricing: { + input: 0, + cachedInput: 0, + output: 0, + updatedAt: new Date().toISOString(), + }, + } + + logger.info(`[${requestId}] Search completed`, { + userId, + resultCount: results.length, + cost: cost.total, + }) + + return NextResponse.json({ + results, + query: validated.query, + totalResults: results.length, + source: 'exa', + cost, + }) + } catch (error: any) { + logger.error(`[${requestId}] Search failed`, { + error: error.message, + stack: error.stack, + }) + + return NextResponse.json( + { + success: false, + error: error.message || 'Search failed', + }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 672f19fd5e..cbed4a68a8 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -11,6 +11,45 @@ import { mockTriggerDevSdk, } from '@/app/api/__test-utils__/utils' +const { + hasProcessedMessageMock, + markMessageAsProcessedMock, + closeRedisConnectionMock, + acquireLockMock, + generateRequestHashMock, + validateSlackSignatureMock, + handleWhatsAppVerificationMock, + handleSlackChallengeMock, + processWhatsAppDeduplicationMock, + processGenericDeduplicationMock, + fetchAndProcessAirtablePayloadsMock, + processWebhookMock, + executeMock, +} = vi.hoisted(() => ({ + hasProcessedMessageMock: vi.fn().mockResolvedValue(false), + markMessageAsProcessedMock: vi.fn().mockResolvedValue(true), + closeRedisConnectionMock: vi.fn().mockResolvedValue(undefined), + acquireLockMock: vi.fn().mockResolvedValue(true), + generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'), + validateSlackSignatureMock: vi.fn().mockResolvedValue(true), + handleWhatsAppVerificationMock: vi.fn().mockResolvedValue(null), + handleSlackChallengeMock: vi.fn().mockReturnValue(null), + processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null), + processGenericDeduplicationMock: vi.fn().mockResolvedValue(null), + fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined), + processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })), + executeMock: vi.fn().mockResolvedValue({ + success: true, + output: { response: 'Webhook execution success' }, + logs: [], + metadata: { + duration: 100, + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + }, + }), +})) + vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }), @@ -32,31 +71,6 @@ vi.mock('@/background/logs-webhook-delivery', () => ({ logsWebhookDelivery: {}, })) -const hasProcessedMessageMock = vi.fn().mockResolvedValue(false) -const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true) -const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined) -const acquireLockMock = vi.fn().mockResolvedValue(true) -const generateRequestHashMock = vi.fn().mockResolvedValue('test-hash-123') -const validateSlackSignatureMock = vi.fn().mockResolvedValue(true) -const handleWhatsAppVerificationMock = vi.fn().mockResolvedValue(null) -const handleSlackChallengeMock = vi.fn().mockReturnValue(null) -const processWhatsAppDeduplicationMock = vi.fn().mockResolvedValue(null) -const processGenericDeduplicationMock = vi.fn().mockResolvedValue(null) -const fetchAndProcessAirtablePayloadsMock = vi.fn().mockResolvedValue(undefined) -const processWebhookMock = vi - .fn() - .mockResolvedValue(new Response('Webhook processed', { status: 200 })) -const executeMock = vi.fn().mockResolvedValue({ - success: true, - output: { response: 'Webhook execution success' }, - logs: [], - metadata: { - duration: 100, - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - }, -}) - vi.mock('@/lib/redis', () => ({ hasProcessedMessage: hasProcessedMessageMock, markMessageAsProcessed: markMessageAsProcessedMock, @@ -76,9 +90,6 @@ vi.mock('@/lib/webhooks/utils', () => ({ vi.mock('@/app/api/webhooks/utils', () => ({ generateRequestHash: generateRequestHashMock, -})) - -vi.mock('@/app/api/webhooks/utils', () => ({ validateSlackSignature: validateSlackSignatureMock, })) @@ -117,7 +128,47 @@ vi.mock('@/lib/logs/execution/logging-session', () => ({ })), })) -process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test' +vi.mock('@/lib/workspaces/utils', async () => { + const actual = await vi.importActual('@/lib/workspaces/utils') + return { + ...(actual as Record), + getWorkspaceBilledAccountUserId: vi + .fn() + .mockImplementation(async (workspaceId: string | null | undefined) => + workspaceId ? 'test-user-id' : null + ), + } +}) + +vi.mock('@/services/queue', () => ({ + RateLimiter: vi.fn().mockImplementation(() => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 10, + resetAt: new Date(), + }), + })), + RateLimitError: class RateLimitError extends Error { + constructor( + message: string, + public statusCode = 429 + ) { + super(message) + this.name = 'RateLimitError' + } + }, +})) + +vi.mock('@/lib/workflows/db-helpers', () => ({ + loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + isFromNormalizedTables: true, + }), + blockExistsInDeployment: vi.fn().mockResolvedValue(true), +})) vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: vi.fn().mockReturnValue({}), @@ -125,9 +176,12 @@ vi.mock('drizzle-orm/postgres-js', () => ({ vi.mock('postgres', () => vi.fn().mockReturnValue({})) +process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test' + +import { POST } from '@/app/api/webhooks/trigger/[path]/route' + describe('Webhook Trigger API Route', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() globalMockData.webhooks.length = 0 @@ -143,48 +197,6 @@ describe('Webhook Trigger API Route', () => { workspaceId: 'test-workspace-id', }) - vi.doMock('@/lib/workspaces/utils', async () => { - const actual = await vi.importActual('@/lib/workspaces/utils') - return { - ...(actual as Record), - getWorkspaceBilledAccountUserId: vi - .fn() - .mockImplementation(async (workspaceId: string | null | undefined) => - workspaceId ? 'test-user-id' : null - ), - } - }) - - vi.doMock('@/services/queue', () => ({ - RateLimiter: vi.fn().mockImplementation(() => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 10, - resetAt: new Date(), - }), - })), - RateLimitError: class RateLimitError extends Error { - constructor( - message: string, - public statusCode = 429 - ) { - super(message) - this.name = 'RateLimitError' - } - }, - })) - - vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isFromNormalizedTables: true, - }), - blockExistsInDeployment: vi.fn().mockResolvedValue(true), - })) - hasProcessedMessageMock.mockResolvedValue(false) markMessageAsProcessedMock.mockResolvedValue(true) acquireLockMock.mockResolvedValue(true) @@ -208,8 +220,6 @@ describe('Webhook Trigger API Route', () => { const params = Promise.resolve({ path: 'non-existent-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') - const response = await POST(req, { params }) expect(response.status).toBe(404) @@ -239,7 +249,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -273,7 +282,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'bearer.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -305,7 +313,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'custom.header.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -347,7 +354,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'case.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -389,7 +395,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'custom.case.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(200) @@ -413,7 +418,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(401) @@ -442,7 +446,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(401) @@ -463,7 +466,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'no.auth.test' }) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(401) @@ -492,7 +494,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(401) @@ -521,7 +522,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(401) @@ -547,7 +547,6 @@ describe('Webhook Trigger API Route', () => { const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers) const params = Promise.resolve({ path: 'test-path' }) - const { POST } = await import('@/app/api/webhooks/trigger/[path]/route') const response = await POST(req, { params }) expect(response.status).toBe(401) diff --git a/apps/sim/blocks/blocks/search.ts b/apps/sim/blocks/blocks/search.ts new file mode 100644 index 0000000000..9ec8dbe482 --- /dev/null +++ b/apps/sim/blocks/blocks/search.ts @@ -0,0 +1,38 @@ +import { SearchIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' + +export const SearchBlock: BlockConfig = { + type: 'search', + name: 'Search', + description: 'Search the web ($0.01 per search)', + longDescription: 'Search the web using the Search tool. Each search costs $0.01 per query.', + bgColor: '#3B82F6', + icon: SearchIcon, + category: 'tools', + docsLink: 'https://docs.sim.ai/tools/search', + subBlocks: [ + { + id: 'query', + title: 'Search Query', + type: 'long-input', + placeholder: 'Enter your search query...', + required: true, + }, + ], + tools: { + access: ['search_tool'], + config: { + tool: () => 'search_tool', + }, + }, + inputs: { + query: { type: 'string', description: 'Search query' }, + }, + outputs: { + results: { type: 'json', description: 'Search results' }, + query: { type: 'string', description: 'The search query' }, + totalResults: { type: 'number', description: 'Total number of results' }, + source: { type: 'string', description: 'Search source (exa)' }, + cost: { type: 'json', description: 'Cost information ($0.01)' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 994f3402cd..93e5885d6d 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -77,12 +77,12 @@ import { RouterBlock } from '@/blocks/blocks/router' import { S3Block } from '@/blocks/blocks/s3' import { SalesforceBlock } from '@/blocks/blocks/salesforce' import { ScheduleBlock } from '@/blocks/blocks/schedule' +import { SearchBlock } from '@/blocks/blocks/search' import { SendGridBlock } from '@/blocks/blocks/sendgrid' import { SentryBlock } from '@/blocks/blocks/sentry' import { SerperBlock } from '@/blocks/blocks/serper' import { SharepointBlock } from '@/blocks/blocks/sharepoint' import { SlackBlock } from '@/blocks/blocks/slack' -import { SmtpBlock } from '@/blocks/blocks/smtp' import { StagehandBlock } from '@/blocks/blocks/stagehand' import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent' import { StartTriggerBlock } from '@/blocks/blocks/start_trigger' @@ -95,7 +95,6 @@ import { TelegramBlock } from '@/blocks/blocks/telegram' import { ThinkingBlock } from '@/blocks/blocks/thinking' import { TranslateBlock } from '@/blocks/blocks/translate' import { TrelloBlock } from '@/blocks/blocks/trello' -import { TtsBlock } from '@/blocks/blocks/tts' import { TwilioSMSBlock } from '@/blocks/blocks/twilio' import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice' import { TypeformBlock } from '@/blocks/blocks/typeform' @@ -120,13 +119,15 @@ import type { BlockConfig } from '@/blocks/types' export const registry: Record = { agent: AgentBlock, airtable: AirtableBlock, + api: ApiBlock, + api_trigger: ApiTriggerBlock, apify: ApifyBlock, apollo: ApolloBlock, - api: ApiBlock, arxiv: ArxivBlock, asana: AsanaBlock, browser_use: BrowserUseBlock, calendly: CalendlyBlock, + chat_trigger: ChatTriggerBlock, clay: ClayBlock, condition: ConditionBlock, confluence: ConfluenceBlock, @@ -134,8 +135,8 @@ export const registry: Record = { elevenlabs: ElevenLabsBlock, evaluator: EvaluatorBlock, exa: ExaBlock, - firecrawl: FirecrawlBlock, file: FileBlock, + firecrawl: FirecrawlBlock, function: FunctionBlock, generic_webhook: GenericWebhookBlock, github: GitHubBlock, @@ -154,6 +155,8 @@ export const registry: Record = { hunter: HunterBlock, image_generator: ImageGeneratorBlock, incidentio: IncidentioBlock, + input_trigger: InputTriggerBlock, + intercom: IntercomBlock, jina: JinaBlock, jira: JiraBlock, knowledge: KnowledgeBlock, @@ -161,9 +164,11 @@ export const registry: Record = { linkedin: LinkedInBlock, linkup: LinkupBlock, mailchimp: MailchimpBlock, + mailgun: MailgunBlock, + manual_trigger: ManualTriggerBlock, mcp: McpBlock, mem0: Mem0Block, - zep: ZepBlock, + memory: MemoryBlock, microsoft_excel: MicrosoftExcelBlock, microsoft_planner: MicrosoftPlannerBlock, microsoft_teams: MicrosoftTeamsBlock, @@ -173,44 +178,35 @@ export const registry: Record = { neo4j: Neo4jBlock, note: NoteBlock, notion: NotionBlock, + onedrive: OneDriveBlock, openai: OpenAIBlock, outlook: OutlookBlock, - onedrive: OneDriveBlock, parallel_ai: ParallelBlock, perplexity: PerplexityBlock, - posthog: PostHogBlock, pinecone: PineconeBlock, pipedrive: PipedriveBlock, postgresql: PostgreSQLBlock, + posthog: PostHogBlock, pylon: PylonBlock, qdrant: QdrantBlock, - resend: ResendBlock, - sendgrid: SendGridBlock, - mailgun: MailgunBlock, - smtp: SmtpBlock, - memory: MemoryBlock, reddit: RedditBlock, + resend: ResendBlock, response: ResponseBlock, router: RouterBlock, - schedule: ScheduleBlock, s3: S3Block, salesforce: SalesforceBlock, + schedule: ScheduleBlock, + search: SearchBlock, + sendgrid: SendGridBlock, sentry: SentryBlock, - intercom: IntercomBlock, - zendesk: ZendeskBlock, serper: SerperBlock, sharepoint: SharepointBlock, + slack: SlackBlock, stagehand: StagehandBlock, stagehand_agent: StagehandAgentBlock, - slack: SlackBlock, starter: StarterBlock, - stt: SttBlock, - tts: TtsBlock, start_trigger: StartTriggerBlock, - input_trigger: InputTriggerBlock, - chat_trigger: ChatTriggerBlock, - manual_trigger: ManualTriggerBlock, - api_trigger: ApiTriggerBlock, + stt: SttBlock, stripe: StripeBlock, supabase: SupabaseBlock, tavily: TavilyBlock, @@ -234,6 +230,8 @@ export const registry: Record = { workflow_input: WorkflowInputBlock, x: XBlock, youtube: YouTubeBlock, + zep: ZepBlock, + zendesk: ZendeskBlock, } export const getBlock = (type: string): BlockConfig | undefined => registry[type] diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index 4b85fdd467..e1174a2af7 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -318,48 +318,49 @@ describe('GenericBlockHandler', () => { }) }) - it.concurrent('should not process cost info for non-knowledge tools', async () => { - // Set up non-knowledge tool - mockBlock.config.tool = 'some_other_tool' - mockTool.id = 'some_other_tool' + it.concurrent( + 'should process cost info for all tools (universal cost extraction)', + async () => { + mockBlock.config.tool = 'some_other_tool' + mockTool.id = 'some_other_tool' + + mockGetTool.mockImplementation((toolId) => { + if (toolId === 'some_other_tool') { + return mockTool + } + return undefined + }) - mockGetTool.mockImplementation((toolId) => { - if (toolId === 'some_other_tool') { - return mockTool + const inputs = { param: 'value' } + const mockToolResponse = { + success: true, + output: { + result: 'success', + cost: { + input: 0.001, + output: 0.002, + total: 0.003, + tokens: { prompt: 100, completion: 50, total: 150 }, + model: 'some-model', + }, + }, } - return undefined - }) - const inputs = { param: 'value' } - const mockToolResponse = { - success: true, - output: { + mockExecuteTool.mockResolvedValue(mockToolResponse) + + const result = await handler.execute(mockContext, mockBlock, inputs) + + expect(result).toEqual({ result: 'success', cost: { input: 0.001, output: 0.002, total: 0.003, - tokens: { prompt: 100, completion: 50, total: 150 }, - model: 'some-model', }, - }, - } - - mockExecuteTool.mockResolvedValue(mockToolResponse) - - const result = await handler.execute(mockContext, mockBlock, inputs) - - // Should return original output without cost transformation - expect(result).toEqual({ - result: 'success', - cost: { - input: 0.001, - output: 0.002, - total: 0.003, tokens: { prompt: 100, completion: 50, total: 150 }, model: 'some-model', - }, - }) - }) + }) + } + ) }) }) diff --git a/apps/sim/executor/handlers/generic/generic-handler.ts b/apps/sim/executor/handlers/generic/generic-handler.ts index a564192b4d..c875ab1402 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.ts @@ -104,7 +104,7 @@ export class GenericBlockHandler implements BlockHandler { const output = result.output let cost = null - if (block.config.tool?.startsWith('knowledge_') && output?.cost) { + if (output?.cost) { cost = output.cost } diff --git a/apps/sim/lib/billing/constants.ts b/apps/sim/lib/billing/constants.ts index 31eb2622b3..fb9d9b41f5 100644 --- a/apps/sim/lib/billing/constants.ts +++ b/apps/sim/lib/billing/constants.ts @@ -20,6 +20,11 @@ export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200 */ export const BASE_EXECUTION_CHARGE = 0.001 +/** + * Fixed cost for search tool invocation (in dollars) + */ +export const SEARCH_TOOL_COST = 0.01 + /** * Default threshold (in dollars) for incremental overage billing * When unbilled overage reaches this amount, an invoice item is created diff --git a/apps/sim/lib/workflows/db-helpers.test.ts b/apps/sim/lib/workflows/db-helpers.test.ts index d218cf15b6..c3f876a1ef 100644 --- a/apps/sim/lib/workflows/db-helpers.test.ts +++ b/apps/sim/lib/workflows/db-helpers.test.ts @@ -9,47 +9,51 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkflowState } from '@/stores/workflows/workflow/types' -const mockDb = { - select: vi.fn(), - insert: vi.fn(), - delete: vi.fn(), - transaction: vi.fn(), -} - -const mockWorkflowBlocks = { - workflowId: 'workflowId', - id: 'id', - type: 'type', - name: 'name', - positionX: 'positionX', - positionY: 'positionY', - enabled: 'enabled', - horizontalHandles: 'horizontalHandles', - height: 'height', - subBlocks: 'subBlocks', - outputs: 'outputs', - data: 'data', - parentId: 'parentId', - extent: 'extent', -} +const { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } = vi.hoisted(() => { + const mockDb = { + select: vi.fn(), + insert: vi.fn(), + delete: vi.fn(), + transaction: vi.fn(), + } + + const mockWorkflowBlocks = { + workflowId: 'workflowId', + id: 'id', + type: 'type', + name: 'name', + positionX: 'positionX', + positionY: 'positionY', + enabled: 'enabled', + horizontalHandles: 'horizontalHandles', + height: 'height', + subBlocks: 'subBlocks', + outputs: 'outputs', + data: 'data', + parentId: 'parentId', + extent: 'extent', + } + + const mockWorkflowEdges = { + workflowId: 'workflowId', + id: 'id', + sourceBlockId: 'sourceBlockId', + targetBlockId: 'targetBlockId', + sourceHandle: 'sourceHandle', + targetHandle: 'targetHandle', + } -const mockWorkflowEdges = { - workflowId: 'workflowId', - id: 'id', - sourceBlockId: 'sourceBlockId', - targetBlockId: 'targetBlockId', - sourceHandle: 'sourceHandle', - targetHandle: 'targetHandle', -} + const mockWorkflowSubflows = { + workflowId: 'workflowId', + id: 'id', + type: 'type', + config: 'config', + } -const mockWorkflowSubflows = { - workflowId: 'workflowId', - id: 'id', - type: 'type', - config: 'config', -} + return { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } +}) -vi.doMock('@sim/db', () => ({ +vi.mock('@sim/db', () => ({ db: mockDb, workflowBlocks: mockWorkflowBlocks, workflowEdges: mockWorkflowEdges, @@ -64,9 +68,11 @@ vi.doMock('@sim/db', () => ({ createdBy: 'createdBy', deployedBy: 'deployedBy', }, + workflow: {}, + webhook: {}, })) -vi.doMock('drizzle-orm', () => ({ +vi.mock('drizzle-orm', () => ({ eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), and: vi.fn((...conditions) => ({ type: 'and', conditions })), desc: vi.fn((field) => ({ field, type: 'desc' })), @@ -78,7 +84,7 @@ vi.doMock('drizzle-orm', () => ({ })), })) -vi.doMock('@/lib/logs/console/logger', () => ({ +vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), @@ -87,6 +93,8 @@ vi.doMock('@/lib/logs/console/logger', () => ({ })), })) +import * as dbHelpers from '@/lib/workflows/db-helpers' + const mockWorkflowId = 'test-workflow-123' const mockBlocksFromDb = [ @@ -306,11 +314,8 @@ const mockWorkflowState: WorkflowState = { } describe('Database Helpers', () => { - let dbHelpers: typeof import('@/lib/workflows/db-helpers') - - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks() - dbHelpers = await import('@/lib/workflows/db-helpers') }) afterEach(() => { @@ -341,6 +346,7 @@ describe('Database Helpers', () => { })) const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId) + expect(result).toBeDefined() expect(result?.isFromNormalizedTables).toBe(true) expect(result?.blocks).toBeDefined() diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 2a0c55ab99..a1a5671802 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -117,7 +117,13 @@ import { exaSearchTool, } from '@/tools/exa' import { fileParseTool } from '@/tools/file' -import { crawlTool, extractTool, mapTool, scrapeTool, searchTool } from '@/tools/firecrawl' +import { + crawlTool, + extractTool, + searchTool as firecrawlSearchTool, + mapTool, + scrapeTool, +} from '@/tools/firecrawl' import { functionExecuteTool } from '@/tools/function' import { githubAddAssigneesTool, @@ -768,6 +774,7 @@ import { salesforceUpdateOpportunityTool, salesforceUpdateTaskTool, } from '@/tools/salesforce' +import { searchTool } from '@/tools/search' import { sendGridAddContactsToListTool, sendGridAddContactTool, @@ -1047,7 +1054,7 @@ export const tools: Record = { vision_tool: visionTool, file_parser: fileParseTool, firecrawl_scrape: scrapeTool, - firecrawl_search: searchTool, + firecrawl_search: firecrawlSearchTool, firecrawl_crawl: crawlTool, firecrawl_map: mapTool, firecrawl_extract: extractTool, @@ -1455,6 +1462,7 @@ export const tools: Record = { knowledge_search: knowledgeSearchTool, knowledge_upload_chunk: knowledgeUploadChunkTool, knowledge_create_document: knowledgeCreateDocumentTool, + search_tool: searchTool, elevenlabs_tts: elevenLabsTtsTool, stt_whisper: whisperSttTool, stt_deepgram: deepgramSttTool, diff --git a/apps/sim/tools/search/index.ts b/apps/sim/tools/search/index.ts new file mode 100644 index 0000000000..2ebfba46b3 --- /dev/null +++ b/apps/sim/tools/search/index.ts @@ -0,0 +1,2 @@ +export { searchTool } from './tool' +export type { SearchParams, SearchResponse } from './types' diff --git a/apps/sim/tools/search/tool.ts b/apps/sim/tools/search/tool.ts new file mode 100644 index 0000000000..4f6f9e3a54 --- /dev/null +++ b/apps/sim/tools/search/tool.ts @@ -0,0 +1,41 @@ +import type { ToolConfig } from '@/tools/types' +import type { SearchParams, SearchResponse } from './types' + +export const searchTool: ToolConfig = { + id: 'search_tool', + name: 'Web Search', + description: + 'Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result.', + version: '1.0.0', + + params: { + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The search query', + }, + }, + + request: { + url: () => '/api/tools/search', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + query: params.query, + }), + }, + + transformResponse: async (response) => { + if (!response.ok) { + throw new Error(`Search failed: ${response.statusText}`) + } + const data = await response.json() + return { + success: true, + output: data, + } + }, +} diff --git a/apps/sim/tools/search/types.ts b/apps/sim/tools/search/types.ts new file mode 100644 index 0000000000..73bfe038cf --- /dev/null +++ b/apps/sim/tools/search/types.ts @@ -0,0 +1,37 @@ +import type { ToolResponse } from '@/tools/types' + +export interface SearchParams { + query: string +} + +export interface SearchResponse extends ToolResponse { + output: { + results: Array<{ + title: string + link: string + snippet: string + date?: string + position: number + }> + query: string + totalResults: number + source: 'exa' + cost: { + input: number + output: number + total: number + tokens: { + prompt: number + completion: number + total: number + } + model: string + pricing?: { + input: number + cachedInput: number + output: number + updatedAt: string + } + } + } +}