mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(search): add Tavily search provider
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -108,6 +109,8 @@ func (p *Executor) callWebSearch(ctx context.Context, providerName string, confi
|
|||||||
return p.callBingSearch(ctx, configJSON, query, count)
|
return p.callBingSearch(ctx, configJSON, query, count)
|
||||||
case string(searchproviders.ProviderGoogle):
|
case string(searchproviders.ProviderGoogle):
|
||||||
return p.callGoogleSearch(ctx, configJSON, query, count)
|
return p.callGoogleSearch(ctx, configJSON, query, count)
|
||||||
|
case string(searchproviders.ProviderTavily):
|
||||||
|
return p.callTavilySearch(ctx, configJSON, query, count)
|
||||||
default:
|
default:
|
||||||
return mcpgw.BuildToolErrorResult("unsupported search provider"), nil
|
return mcpgw.BuildToolErrorResult("unsupported search provider"), nil
|
||||||
}
|
}
|
||||||
@@ -302,6 +305,62 @@ func (p *Executor) callGoogleSearch(ctx context.Context, configJSON []byte, quer
|
|||||||
}), nil
|
}), 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 {
|
func parseTimeout(configJSON []byte, fallback time.Duration) time.Duration {
|
||||||
cfg := parseConfig(configJSON)
|
cfg := parseConfig(configJSON)
|
||||||
raw, ok := cfg["timeout_seconds"]
|
raw, ok := cfg["timeout_seconds"]
|
||||||
|
|||||||
@@ -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 {
|
func isValidProviderName(name ProviderName) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case ProviderBrave, ProviderBing, ProviderGoogle:
|
case ProviderBrave, ProviderBing, ProviderGoogle,
|
||||||
|
ProviderTavily:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const (
|
|||||||
ProviderBrave ProviderName = "brave"
|
ProviderBrave ProviderName = "brave"
|
||||||
ProviderBing ProviderName = "bing"
|
ProviderBing ProviderName = "bing"
|
||||||
ProviderGoogle ProviderName = "google"
|
ProviderGoogle ProviderName = "google"
|
||||||
|
ProviderTavily ProviderName = "tavily"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProviderConfigSchema struct {
|
type ProviderConfigSchema struct {
|
||||||
|
|||||||
@@ -196,7 +196,13 @@
|
|||||||
"searchPlaceholder": "Search providers...",
|
"searchPlaceholder": "Search providers...",
|
||||||
"emptyTitle": "No Search Providers",
|
"emptyTitle": "No Search Providers",
|
||||||
"emptyDescription": "Add a search provider to configure web search",
|
"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": {
|
"mcp": {
|
||||||
"addTitle": "Add MCP",
|
"addTitle": "Add MCP",
|
||||||
|
|||||||
@@ -192,7 +192,13 @@
|
|||||||
"searchPlaceholder": "搜索提供方…",
|
"searchPlaceholder": "搜索提供方…",
|
||||||
"emptyTitle": "暂无搜索提供方",
|
"emptyTitle": "暂无搜索提供方",
|
||||||
"emptyDescription": "请先添加搜索提供方,才能配置搜索功能",
|
"emptyDescription": "请先添加搜索提供方,才能配置搜索功能",
|
||||||
"deleteConfirm": "确定删除该搜索提供方?删除后无法恢复。"
|
"deleteConfirm": "确定删除该搜索提供方?删除后无法恢复。",
|
||||||
|
"providerNames": {
|
||||||
|
"brave": "Brave",
|
||||||
|
"bing": "Bing",
|
||||||
|
"google": "Google",
|
||||||
|
"tavily": "Tavily"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"addTitle": "添加 MCP",
|
"addTitle": "添加 MCP",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
:key="type"
|
:key="type"
|
||||||
:value="type"
|
:value="type"
|
||||||
>
|
>
|
||||||
{{ type }}
|
{{ $t(`searchProvider.providerNames.${type}`, type) }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -109,7 +109,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
|
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
|
||||||
import { useDialogMutation } from '@/composables/useDialogMutation'
|
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<boolean>('open')
|
const open = defineModel<boolean>('open')
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
:key="type"
|
:key="type"
|
||||||
:value="type"
|
:value="type"
|
||||||
>
|
>
|
||||||
{{ type }}
|
{{ $t(`searchProvider.providerNames.${type}`, type) }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -85,6 +85,9 @@
|
|||||||
<template v-else-if="form.values.provider === 'google'">
|
<template v-else-if="form.values.provider === 'google'">
|
||||||
<GoogleSettings v-model="configProxy" />
|
<GoogleSettings v-model="configProxy" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="form.values.provider === 'tavily'">
|
||||||
|
<TavilySettings v-model="configProxy" />
|
||||||
|
</template>
|
||||||
<div
|
<div
|
||||||
v-else-if="form.values.provider"
|
v-else-if="form.values.provider"
|
||||||
class="text-sm text-muted-foreground"
|
class="text-sm text-muted-foreground"
|
||||||
@@ -143,6 +146,7 @@ import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
|||||||
import BraveSettings from './brave-settings.vue'
|
import BraveSettings from './brave-settings.vue'
|
||||||
import BingSettings from './bing-settings.vue'
|
import BingSettings from './bing-settings.vue'
|
||||||
import GoogleSettings from './google-settings.vue'
|
import GoogleSettings from './google-settings.vue'
|
||||||
|
import TavilySettings from './tavily-settings.vue'
|
||||||
import SearchProviderLogo from '@/components/search-provider-logo/index.vue'
|
import SearchProviderLogo from '@/components/search-provider-logo/index.vue'
|
||||||
import { computed, inject, ref, watch } from 'vue'
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
import { toTypedSchema } from '@vee-validate/zod'
|
||||||
@@ -152,7 +156,7 @@ import { useMutation, useQueryCache } from '@pinia/colada'
|
|||||||
import { putSearchProvidersById, deleteSearchProvidersById } from '@memoh/sdk'
|
import { putSearchProvidersById, deleteSearchProvidersById } from '@memoh/sdk'
|
||||||
import type { SearchprovidersGetResponse } from '@memoh/sdk'
|
import type { SearchprovidersGetResponse } from '@memoh/sdk'
|
||||||
|
|
||||||
const PROVIDER_TYPES = ['brave', 'bing', 'google'] as const
|
const PROVIDER_TYPES = ['brave', 'bing', 'google', 'tavily'] as const
|
||||||
|
|
||||||
const curProvider = inject('curSearchProvider', ref<SearchprovidersGetResponse>())
|
const curProvider = inject('curSearchProvider', ref<SearchprovidersGetResponse>())
|
||||||
const curProviderId = computed(() => curProvider.value?.id)
|
const curProviderId = computed(() => curProvider.value?.id)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tavily-api-key">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="tavily-api-key"
|
||||||
|
v-model="localConfig.api_key"
|
||||||
|
type="password"
|
||||||
|
aria-label="API Key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tavily-base-url">Base URL</Label>
|
||||||
|
<Input
|
||||||
|
id="tavily-base-url"
|
||||||
|
v-model="localConfig.base_url"
|
||||||
|
aria-label="Base URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="tavily-timeout-seconds">Timeout (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="tavily-timeout-seconds"
|
||||||
|
v-model.number="localConfig.timeout_seconds"
|
||||||
|
type="number"
|
||||||
|
:min="1"
|
||||||
|
aria-label="Timeout (seconds)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import { Input, Label } from '@memoh/ui'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Record<string, unknown>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: Record<string, unknown>]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localConfig = reactive({
|
||||||
|
api_key: '',
|
||||||
|
base_url: 'https://api.tavily.com/search',
|
||||||
|
timeout_seconds: 15,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
localConfig.api_key = String(val?.api_key ?? '')
|
||||||
|
localConfig.base_url = String(val?.base_url ?? 'https://api.tavily.com/search')
|
||||||
|
const timeout = Number(val?.timeout_seconds ?? 15)
|
||||||
|
localConfig.timeout_seconds = Number.isFinite(timeout) && timeout > 0 ? timeout : 15
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(localConfig, () => {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
api_key: localConfig.api_key,
|
||||||
|
base_url: localConfig.base_url,
|
||||||
|
timeout_seconds: localConfig.timeout_seconds,
|
||||||
|
})
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
@@ -28,7 +28,7 @@ import ProviderSetting from './components/provider-setting.vue'
|
|||||||
import SearchProviderLogo from '@/components/search-provider-logo/index.vue'
|
import SearchProviderLogo from '@/components/search-provider-logo/index.vue'
|
||||||
import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/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 filterProvider = ref('')
|
||||||
const { data: providerData } = useQuery({
|
const { data: providerData } = useQuery({
|
||||||
@@ -155,7 +155,7 @@ const openStatus = reactive({
|
|||||||
:key="type"
|
:key="type"
|
||||||
:value="type"
|
:value="type"
|
||||||
>
|
>
|
||||||
{{ type }}
|
{{ $t(`searchProvider.providerNames.${type}`, type) }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user