-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(serply): add Serply web search tool and block #6866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { Search } from '@sim/emcn/icons' | ||
| import type { BlockConfig, BlockMeta } from '@/blocks/types' | ||
| import { AuthMode, IntegrationType } from '@/blocks/types' | ||
| import type { SearchResponse } from '@/tools/serply/search' | ||
|
|
||
| export const SerplyBlock: BlockConfig<SearchResponse> = { | ||
| type: 'serply', | ||
| name: 'Serply', | ||
| description: 'Search the web using Serply', | ||
| authMode: AuthMode.ApiKey, | ||
| longDescription: 'Integrate Serply into the workflow. Can search the web.', | ||
| docsLink: 'https://docs.sim.ai/integrations/serply', | ||
| category: 'tools', | ||
| integrationType: IntegrationType.Search, | ||
| bgColor: '#4F46E5', | ||
| icon: Search, | ||
| canvasPresentation: { | ||
| defaultTitle: 'Serply', | ||
| sentences: { | ||
| default: [ | ||
| { text: 'Search Google for', field: 'query', core: true }, | ||
| { text: ', returning up to', field: 'num', after: 'results' }, | ||
| ], | ||
| }, | ||
| }, | ||
| subBlocks: [ | ||
| { | ||
| id: 'query', | ||
| title: 'Search Query', | ||
| type: 'short-input', | ||
| placeholder: 'Enter your search query...', | ||
| required: true, | ||
| }, | ||
| { | ||
| id: 'num', | ||
| title: 'Number of Results', | ||
| type: 'dropdown', | ||
| options: [ | ||
| { label: '10', id: '10' }, | ||
| { label: '20', id: '20' }, | ||
| { label: '30', id: '30' }, | ||
| { label: '50', id: '50' }, | ||
| ], | ||
| }, | ||
| { | ||
| id: 'apiKey', | ||
| title: 'API Key', | ||
| type: 'short-input', | ||
| placeholder: 'Enter your Serply API key', | ||
| password: true, | ||
| required: true, | ||
| }, | ||
| ], | ||
| tools: { | ||
| access: ['serply_search'], | ||
| }, | ||
| inputs: { | ||
| query: { type: 'string', description: 'Search query terms' }, | ||
| apiKey: { type: 'string', description: 'Serply API key' }, | ||
| num: { type: 'number', description: 'Number of results' }, | ||
| }, | ||
| outputs: { | ||
| searchResults: { type: 'json', description: 'Search results data' }, | ||
| }, | ||
| } | ||
|
|
||
| export const SerplyBlockMeta = { | ||
| tags: ['web-scraping', 'seo'], | ||
| url: 'https://serply.io', | ||
| } as const satisfies BlockMeta |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { searchTool } from '@/tools/serply/search' | ||
|
|
||
| describe('serply searchTool', () => { | ||
| it('sends the api key, accept header, and an explicit user agent', () => { | ||
| const headers = searchTool.request.headers({ query: 'sim', apiKey: 'test-key' }) | ||
|
|
||
| expect(headers['X-Api-Key']).toBe('test-key') | ||
| expect(headers.Accept).toBe('application/json') | ||
| expect(headers['User-Agent']).toBeTruthy() | ||
| }) | ||
|
|
||
| it('builds the query URL with the optional num param', () => { | ||
| const url = (searchTool.request.url as (params: any) => string)({ | ||
| query: 'sim workflows', | ||
| apiKey: 'test-key', | ||
| num: 20, | ||
| }) | ||
|
|
||
| expect(url).toBe('https://api.serply.io/v1/search/?q=sim+workflows&num=20') | ||
| }) | ||
|
|
||
| it('maps organic results into searchResults', async () => { | ||
| const response = new Response( | ||
| JSON.stringify({ | ||
| results: [ | ||
| { title: 'Sim', link: 'https://sim.ai', description: 'AI workspace' }, | ||
| { title: 'No link', description: 'dropped upstream? kept here' }, | ||
| ], | ||
| }), | ||
| { status: 200, headers: { 'Content-Type': 'application/json' } } | ||
| ) | ||
|
|
||
| const result = await searchTool.transformResponse!(response, {} as never) | ||
|
|
||
| expect(result.success).toBe(true) | ||
| expect(result.output.searchResults).toEqual([ | ||
| { title: 'Sim', link: 'https://sim.ai', snippet: 'AI workspace' }, | ||
| { title: 'No link', link: '', snippet: 'dropped upstream? kept here' }, | ||
| ]) | ||
| }) | ||
|
|
||
| it('returns an empty array when the response has no results', async () => { | ||
| const response = new Response(JSON.stringify({}), { | ||
| status: 200, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }) | ||
|
|
||
| const result = await searchTool.transformResponse!(response, {} as never) | ||
|
|
||
| expect(result.output.searchResults).toEqual([]) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import type { OutputProperty, ToolConfig, ToolResponse } from '@/tools/types' | ||
|
|
||
| export const SERPLY_SEARCH_RESULT_OUTPUT_PROPERTIES = { | ||
| title: { type: 'string', description: 'Result title' }, | ||
| link: { type: 'string', description: 'Result URL' }, | ||
| snippet: { type: 'string', description: 'Result description/snippet', optional: true }, | ||
| } as const satisfies Record<string, OutputProperty> | ||
|
|
||
| export interface SearchParams { | ||
| query: string | ||
| apiKey: string | ||
| num?: number | ||
| } | ||
|
|
||
| export interface SearchResult { | ||
| title: string | ||
| link: string | ||
| snippet?: string | ||
| } | ||
|
|
||
| export interface SearchResponse extends ToolResponse { | ||
| output: { | ||
| searchResults: SearchResult[] | ||
| } | ||
| } | ||
|
|
||
| export const searchTool: ToolConfig<SearchParams, SearchResponse> = { | ||
| id: 'serply_search', | ||
| name: 'Web Search', | ||
| description: | ||
| 'A web search tool that provides access to Google search results through the Serply SERP API. Returns organic results with titles, links, and snippets.', | ||
| version: '1.0.0', | ||
|
|
||
| params: { | ||
| query: { | ||
| type: 'string', | ||
| required: true, | ||
| visibility: 'user-or-llm', | ||
| description: 'The search query (e.g., "latest AI news", "best restaurants in NYC")', | ||
| }, | ||
| num: { | ||
| type: 'number', | ||
| required: false, | ||
| visibility: 'user-or-llm', | ||
| description: 'Number of results to return (e.g., 10, 20, 50)', | ||
| }, | ||
| apiKey: { | ||
| type: 'string', | ||
| required: true, | ||
| visibility: 'user-only', | ||
| description: 'Serply API Key', | ||
| }, | ||
| }, | ||
|
|
||
| request: { | ||
| url: (params) => { | ||
| const url = new URL('https://api.serply.io/v1/search/') | ||
| url.searchParams.set('q', params.query) | ||
| if (params.num) url.searchParams.set('num', String(Number(params.num))) | ||
| return url.toString() | ||
| }, | ||
| method: 'GET', | ||
| headers: (params) => ({ | ||
| 'X-Api-Key': params.apiKey, | ||
| Accept: 'application/json', | ||
| // Serply sits behind Cloudflare, which rejects requests without an | ||
| // explicit User-Agent, so always send one. | ||
| 'User-Agent': 'sim-serply-tool', | ||
| }), | ||
| }, | ||
|
|
||
| transformResponse: async (response: Response) => { | ||
| const data = await response.json() | ||
| const results = Array.isArray(data.results) ? data.results : [] | ||
|
|
||
| const searchResults: SearchResult[] = results.map((item: any) => ({ | ||
| title: item.title || '', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The explicit Context Used: TypeScript conventions and type safety (source) Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| link: item.link || '', | ||
| snippet: item.description || undefined, | ||
| })) | ||
|
|
||
| return { | ||
| success: true, | ||
| output: { searchResults }, | ||
| } | ||
| }, | ||
|
|
||
| outputs: { | ||
| searchResults: { | ||
| type: 'array', | ||
| description: 'Organic search results with titles, links, and snippets', | ||
| items: { type: 'object', properties: SERPLY_SEARCH_RESULT_OUTPUT_PROPERTIES }, | ||
| }, | ||
| }, | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generated tool metadata left stale
Medium Severity
serply_searchis registered in the live tool registry, butapps/sim/tools/generated/tool-ids.ts,tool-metadata.ts, andtool-outputs.tswere not regenerated. Client and serializer code resolve tools through those artifacts viahasToolId/getToolParams/getToolMetadata, so the new tool is treated as unknown andtool-metadata:checkwill fail.Additional Locations (1)
apps/sim/tools/registry.ts#L6444-L6445Reviewed by Cursor Bugbot for commit a4129fe. Configure here.