From 08f5130c66ed04198a5cc9087873ddd61ba68283 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 26 Feb 2026 02:27:50 +0800 Subject: [PATCH] feat(search): add Tavily search provider --- internal/mcp/providers/web/provider.go | 59 ++++++++++++++++ internal/searchproviders/service.go | 31 ++++++++- internal/searchproviders/types.go | 1 + packages/web/src/i18n/locales/en.json | 8 ++- packages/web/src/i18n/locales/zh.json | 8 ++- .../components/add-search-provider.vue | 4 +- .../components/provider-setting.vue | 8 ++- .../components/tavily-settings.vue | 69 +++++++++++++++++++ .../web/src/pages/search-providers/index.vue | 4 +- 9 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/pages/search-providers/components/tavily-settings.vue diff --git a/internal/mcp/providers/web/provider.go b/internal/mcp/providers/web/provider.go index 9786a9ce..960248fa 100644 --- a/internal/mcp/providers/web/provider.go +++ b/internal/mcp/providers/web/provider.go @@ -1,6 +1,7 @@ package web import ( + "bytes" "context" "encoding/json" "fmt" @@ -108,6 +109,8 @@ func (p *Executor) callWebSearch(ctx context.Context, providerName string, confi return p.callBingSearch(ctx, configJSON, query, count) case string(searchproviders.ProviderGoogle): return p.callGoogleSearch(ctx, configJSON, query, count) + case string(searchproviders.ProviderTavily): + return p.callTavilySearch(ctx, configJSON, query, count) default: return mcpgw.BuildToolErrorResult("unsupported search provider"), nil } @@ -302,6 +305,62 @@ func (p *Executor) callGoogleSearch(ctx context.Context, configJSON []byte, quer }), nil } +func (p *Executor) callTavilySearch(ctx context.Context, configJSON []byte, query string, count int) (map[string]any, error) { + cfg := parseConfig(configJSON) + endpoint := firstNonEmpty(stringValue(cfg["base_url"]), "https://api.tavily.com/search") + apiKey := stringValue(cfg["api_key"]) + if apiKey == "" { + return mcpgw.BuildToolErrorResult("Tavily API key is required"), nil + } + payload, _ := json.Marshal(map[string]any{ + "query": query, + "max_results": count, + }) + timeout := parseTimeout(configJSON, 15*time.Second) + client := &http.Client{Timeout: timeout} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + resp, err := client.Do(req) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return mcpgw.BuildToolErrorResult("search request failed"), nil + } + var raw struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return mcpgw.BuildToolErrorResult("invalid search response"), nil + } + results := make([]map[string]any, 0, len(raw.Results)) + for _, item := range raw.Results { + results = append(results, map[string]any{ + "title": item.Title, + "url": item.URL, + "description": item.Content, + }) + } + return mcpgw.BuildToolSuccessResult(map[string]any{ + "query": query, + "results": results, + }), nil +} + func parseTimeout(configJSON []byte, fallback time.Duration) time.Duration { cfg := parseConfig(configJSON) raw, ok := cfg["timeout_seconds"] diff --git a/internal/searchproviders/service.go b/internal/searchproviders/service.go index cb94ed63..5cc271a9 100644 --- a/internal/searchproviders/service.go +++ b/internal/searchproviders/service.go @@ -115,6 +115,34 @@ func (s *Service) ListMeta(_ context.Context) []ProviderMeta { }, }, }, + { + Provider: string(ProviderTavily), + DisplayName: "Tavily", + ConfigSchema: ProviderConfigSchema{ + Fields: map[string]ProviderFieldSchema{ + "api_key": { + Type: "secret", + Title: "API Key", + Description: "Tavily Search API key", + Required: true, + }, + "base_url": { + Type: "string", + Title: "Base URL", + Description: "Tavily API base URL", + Required: false, + Example: "https://api.tavily.com/search", + }, + "timeout_seconds": { + Type: "number", + Title: "Timeout (seconds)", + Description: "HTTP timeout in seconds", + Required: false, + Example: 15, + }, + }, + }, + }, } } @@ -245,7 +273,8 @@ func (s *Service) toGetResponse(row sqlc.SearchProvider) GetResponse { func isValidProviderName(name ProviderName) bool { switch name { - case ProviderBrave, ProviderBing, ProviderGoogle: + case ProviderBrave, ProviderBing, ProviderGoogle, + ProviderTavily: return true default: return false diff --git a/internal/searchproviders/types.go b/internal/searchproviders/types.go index 1b7e9ca6..42c14a2f 100644 --- a/internal/searchproviders/types.go +++ b/internal/searchproviders/types.go @@ -8,6 +8,7 @@ const ( ProviderBrave ProviderName = "brave" ProviderBing ProviderName = "bing" ProviderGoogle ProviderName = "google" + ProviderTavily ProviderName = "tavily" ) type ProviderConfigSchema struct { diff --git a/packages/web/src/i18n/locales/en.json b/packages/web/src/i18n/locales/en.json index 7441c45f..edc4b999 100644 --- a/packages/web/src/i18n/locales/en.json +++ b/packages/web/src/i18n/locales/en.json @@ -196,7 +196,13 @@ "searchPlaceholder": "Search providers...", "emptyTitle": "No Search Providers", "emptyDescription": "Add a search provider to configure web search", - "deleteConfirm": "Are you sure you want to delete this search provider? This action cannot be undone." + "deleteConfirm": "Are you sure you want to delete this search provider? This action cannot be undone.", + "providerNames": { + "brave": "Brave", + "bing": "Bing", + "google": "Google", + "tavily": "Tavily" + } }, "mcp": { "addTitle": "Add MCP", diff --git a/packages/web/src/i18n/locales/zh.json b/packages/web/src/i18n/locales/zh.json index 1bbd5efd..48970a05 100644 --- a/packages/web/src/i18n/locales/zh.json +++ b/packages/web/src/i18n/locales/zh.json @@ -192,7 +192,13 @@ "searchPlaceholder": "搜索提供方…", "emptyTitle": "暂无搜索提供方", "emptyDescription": "请先添加搜索提供方,才能配置搜索功能", - "deleteConfirm": "确定删除该搜索提供方?删除后无法恢复。" + "deleteConfirm": "确定删除该搜索提供方?删除后无法恢复。", + "providerNames": { + "brave": "Brave", + "bing": "Bing", + "google": "Google", + "tavily": "Tavily" + } }, "mcp": { "addTitle": "添加 MCP", diff --git a/packages/web/src/pages/search-providers/components/add-search-provider.vue b/packages/web/src/pages/search-providers/components/add-search-provider.vue index 80124013..efa84e66 100644 --- a/packages/web/src/pages/search-providers/components/add-search-provider.vue +++ b/packages/web/src/pages/search-providers/components/add-search-provider.vue @@ -71,7 +71,7 @@ :key="type" :value="type" > - {{ type }} + {{ $t(`searchProvider.providerNames.${type}`, type) }} @@ -109,7 +109,7 @@ import { useI18n } from 'vue-i18n' import FormDialogShell from '@/components/form-dialog-shell/index.vue' import { useDialogMutation } from '@/composables/useDialogMutation' -const PROVIDER_TYPES = ['brave', 'bing', 'google'] as const +const PROVIDER_TYPES = ['brave', 'bing', 'google', 'tavily'] as const const open = defineModel('open') const { t } = useI18n() diff --git a/packages/web/src/pages/search-providers/components/provider-setting.vue b/packages/web/src/pages/search-providers/components/provider-setting.vue index c552f007..cff4c93b 100644 --- a/packages/web/src/pages/search-providers/components/provider-setting.vue +++ b/packages/web/src/pages/search-providers/components/provider-setting.vue @@ -66,7 +66,7 @@ :key="type" :value="type" > - {{ type }} + {{ $t(`searchProvider.providerNames.${type}`, type) }} @@ -85,6 +85,9 @@ +
()) const curProviderId = computed(() => curProvider.value?.id) diff --git a/packages/web/src/pages/search-providers/components/tavily-settings.vue b/packages/web/src/pages/search-providers/components/tavily-settings.vue new file mode 100644 index 00000000..ce73b01f --- /dev/null +++ b/packages/web/src/pages/search-providers/components/tavily-settings.vue @@ -0,0 +1,69 @@ + + + diff --git a/packages/web/src/pages/search-providers/index.vue b/packages/web/src/pages/search-providers/index.vue index 1a3f29ae..a9162dd2 100644 --- a/packages/web/src/pages/search-providers/index.vue +++ b/packages/web/src/pages/search-providers/index.vue @@ -28,7 +28,7 @@ import ProviderSetting from './components/provider-setting.vue' import SearchProviderLogo from '@/components/search-provider-logo/index.vue' import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/index.vue' -const PROVIDER_TYPES = ['brave', 'bing', 'google'] as const +const PROVIDER_TYPES = ['brave', 'bing', 'google', 'tavily'] as const const filterProvider = ref('') const { data: providerData } = useQuery({ @@ -155,7 +155,7 @@ const openStatus = reactive({ :key="type" :value="type" > - {{ type }} + {{ $t(`searchProvider.providerNames.${type}`, type) }}