feat(search): add bing and google support

This commit is contained in:
Acbox
2026-02-23 15:41:47 +08:00
parent 7ada20967a
commit a440bf122b
19 changed files with 571 additions and 17 deletions
+1 -2
View File
@@ -74,8 +74,7 @@ CREATE TABLE IF NOT EXISTS search_providers (
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT search_providers_name_unique UNIQUE (name),
CONSTRAINT search_providers_provider_check CHECK (provider IN ('brave'))
CONSTRAINT search_providers_name_unique UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS models (
@@ -0,0 +1,4 @@
-- 0015_drop_search_provider_check (down)
-- Restore the original CHECK constraint limiting provider to 'brave'.
ALTER TABLE search_providers ADD CONSTRAINT search_providers_provider_check CHECK (provider IN ('brave'));
@@ -0,0 +1,5 @@
-- 0015_drop_search_provider_check
-- Remove the CHECK constraint on search_providers.provider so new providers
-- can be added without a database migration.
ALTER TABLE search_providers DROP CONSTRAINT IF EXISTS search_providers_provider_check;
+139 -1
View File
@@ -101,9 +101,19 @@ func (p *Executor) callWebSearch(ctx context.Context, providerName string, confi
count = 20
}
if strings.TrimSpace(providerName) != string(searchproviders.ProviderBrave) {
switch strings.TrimSpace(providerName) {
case string(searchproviders.ProviderBrave):
return p.callBraveSearch(ctx, configJSON, query, count)
case string(searchproviders.ProviderBing):
return p.callBingSearch(ctx, configJSON, query, count)
case string(searchproviders.ProviderGoogle):
return p.callGoogleSearch(ctx, configJSON, query, count)
default:
return mcpgw.BuildToolErrorResult("unsupported search provider"), nil
}
}
func (p *Executor) callBraveSearch(ctx context.Context, configJSON []byte, query string, count int) (map[string]any, error) {
cfg := parseConfig(configJSON)
endpoint := strings.TrimRight(firstNonEmpty(stringValue(cfg["base_url"]), "https://api.search.brave.com/res/v1/web/search"), "/")
reqURL, err := url.Parse(endpoint)
@@ -164,6 +174,134 @@ func (p *Executor) callWebSearch(ctx context.Context, providerName string, confi
}), nil
}
func (p *Executor) callBingSearch(ctx context.Context, configJSON []byte, query string, count int) (map[string]any, error) {
cfg := parseConfig(configJSON)
endpoint := strings.TrimRight(firstNonEmpty(stringValue(cfg["base_url"]), "https://api.bing.microsoft.com/v7.0/search"), "/")
reqURL, err := url.Parse(endpoint)
if err != nil {
return mcpgw.BuildToolErrorResult("invalid search provider base_url"), nil
}
params := reqURL.Query()
params.Set("q", query)
params.Set("count", fmt.Sprintf("%d", count))
reqURL.RawQuery = params.Encode()
timeout := parseTimeout(configJSON, 15*time.Second)
client := &http.Client{Timeout: timeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return mcpgw.BuildToolErrorResult(err.Error()), nil
}
req.Header.Set("Accept", "application/json")
apiKey := stringValue(cfg["api_key"])
if strings.TrimSpace(apiKey) != "" {
req.Header.Set("Ocp-Apim-Subscription-Key", strings.TrimSpace(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 {
WebPages struct {
Value []struct {
Name string `json:"name"`
URL string `json:"url"`
Snippet string `json:"snippet"`
} `json:"value"`
} `json:"webPages"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return mcpgw.BuildToolErrorResult("invalid search response"), nil
}
results := make([]map[string]any, 0, len(raw.WebPages.Value))
for _, item := range raw.WebPages.Value {
results = append(results, map[string]any{
"title": item.Name,
"url": item.URL,
"description": item.Snippet,
})
}
return mcpgw.BuildToolSuccessResult(map[string]any{
"query": query,
"results": results,
}), nil
}
func (p *Executor) callGoogleSearch(ctx context.Context, configJSON []byte, query string, count int) (map[string]any, error) {
cfg := parseConfig(configJSON)
endpoint := strings.TrimRight(firstNonEmpty(stringValue(cfg["base_url"]), "https://customsearch.googleapis.com/customsearch/v1"), "/")
reqURL, err := url.Parse(endpoint)
if err != nil {
return mcpgw.BuildToolErrorResult("invalid search provider base_url"), nil
}
cx := stringValue(cfg["cx"])
if cx == "" {
return mcpgw.BuildToolErrorResult("Google Custom Search requires cx (Search Engine ID)"), nil
}
if count > 10 {
count = 10
}
params := reqURL.Query()
params.Set("q", query)
params.Set("cx", cx)
params.Set("num", fmt.Sprintf("%d", count))
apiKey := stringValue(cfg["api_key"])
if strings.TrimSpace(apiKey) != "" {
params.Set("key", strings.TrimSpace(apiKey))
}
reqURL.RawQuery = params.Encode()
timeout := parseTimeout(configJSON, 15*time.Second)
client := &http.Client{Timeout: timeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return mcpgw.BuildToolErrorResult(err.Error()), nil
}
req.Header.Set("Accept", "application/json")
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 {
Items []struct {
Title string `json:"title"`
Link string `json:"link"`
Snippet string `json:"snippet"`
} `json:"items"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return mcpgw.BuildToolErrorResult("invalid search response"), nil
}
results := make([]map[string]any, 0, len(raw.Items))
for _, item := range raw.Items {
results = append(results, map[string]any{
"title": item.Title,
"url": item.Link,
"description": item.Snippet,
})
}
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"]
+63 -1
View File
@@ -53,6 +53,68 @@ func (s *Service) ListMeta(_ context.Context) []ProviderMeta {
},
},
},
{
Provider: string(ProviderBing),
DisplayName: "Bing",
ConfigSchema: ProviderConfigSchema{
Fields: map[string]ProviderFieldSchema{
"api_key": {
Type: "secret",
Title: "API Key",
Description: "Bing Web Search API subscription key",
Required: true,
},
"base_url": {
Type: "string",
Title: "Base URL",
Description: "Bing API base URL",
Required: false,
Example: "https://api.bing.microsoft.com/v7.0/search",
},
"timeout_seconds": {
Type: "number",
Title: "Timeout (seconds)",
Description: "HTTP timeout in seconds",
Required: false,
Example: 15,
},
},
},
},
{
Provider: string(ProviderGoogle),
DisplayName: "Google",
ConfigSchema: ProviderConfigSchema{
Fields: map[string]ProviderFieldSchema{
"api_key": {
Type: "secret",
Title: "API Key",
Description: "Google Custom Search API key",
Required: true,
},
"cx": {
Type: "string",
Title: "Search Engine ID",
Description: "Google Programmable Search Engine ID (cx)",
Required: true,
},
"base_url": {
Type: "string",
Title: "Base URL",
Description: "Google Custom Search API base URL",
Required: false,
Example: "https://customsearch.googleapis.com/customsearch/v1",
},
"timeout_seconds": {
Type: "number",
Title: "Timeout (seconds)",
Description: "HTTP timeout in seconds",
Required: false,
Example: 15,
},
},
},
},
}
}
@@ -183,7 +245,7 @@ func (s *Service) toGetResponse(row sqlc.SearchProvider) GetResponse {
func isValidProviderName(name ProviderName) bool {
switch name {
case ProviderBrave:
case ProviderBrave, ProviderBing, ProviderGoogle:
return true
default:
return false
+3 -1
View File
@@ -5,7 +5,9 @@ import "time"
type ProviderName string
const (
ProviderBrave ProviderName = "brave"
ProviderBrave ProviderName = "brave"
ProviderBing ProviderName = "bing"
ProviderGoogle ProviderName = "google"
)
type ProviderConfigSchema struct {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37 -1
View File
@@ -954,7 +954,7 @@ export type SearchprovidersProviderMeta = {
provider?: string;
};
export type SearchprovidersProviderName = 'brave';
export type SearchprovidersProviderName = 'brave' | 'bing' | 'google';
export type SearchprovidersUpdateRequest = {
config?: {
@@ -3002,6 +3002,42 @@ export type DeleteBotsByBotIdMemoryByIdResponses = {
export type DeleteBotsByBotIdMemoryByIdResponse = DeleteBotsByBotIdMemoryByIdResponses[keyof DeleteBotsByBotIdMemoryByIdResponses];
export type DeleteBotsByBotIdMessagesData = {
body?: never;
path: {
/**
* Bot ID
*/
bot_id: string;
};
query?: never;
url: '/bots/{bot_id}/messages';
};
export type DeleteBotsByBotIdMessagesErrors = {
/**
* Bad Request
*/
400: HandlersErrorResponse;
/**
* Forbidden
*/
403: HandlersErrorResponse;
/**
* Internal Server Error
*/
500: HandlersErrorResponse;
};
export type DeleteBotsByBotIdMessagesError = DeleteBotsByBotIdMessagesErrors[keyof DeleteBotsByBotIdMessagesErrors];
export type DeleteBotsByBotIdMessagesResponses = {
/**
* No Content
*/
204: unknown;
};
export type GetBotsByBotIdMessagesData = {
body?: never;
path: {
@@ -11,6 +11,8 @@
const PROVIDER_ICONS: Record<string, [string, string]> = {
brave: ['fab', 'brave'],
bing: ['fab', 'microsoft'],
google: ['fab', 'google'],
}
const DEFAULT_ICON: [string, string] = ['fas', 'globe']
+3 -1
View File
@@ -59,7 +59,7 @@ import {
faComments,
faComment,
} from '@fortawesome/free-regular-svg-icons'
import { faSlack, faBrave } from '@fortawesome/free-brands-svg-icons'
import { faSlack, faBrave, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons'
library.add(
faGear,
@@ -103,6 +103,8 @@ library.add(
faComment,
faSlack,
faBrave,
faGoogle,
faMicrosoft,
)
createApp(App)
@@ -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'] as const
const PROVIDER_TYPES = ['brave', 'bing', 'google'] as const
const open = defineModel<boolean>('open')
const { t } = useI18n()
@@ -0,0 +1,69 @@
<template>
<div class="space-y-4">
<div class="space-y-2">
<Label for="bing-api-key">API Key</Label>
<Input
id="bing-api-key"
v-model="localConfig.api_key"
type="password"
aria-label="API Key"
/>
</div>
<div class="space-y-2">
<Label for="bing-base-url">Base URL</Label>
<Input
id="bing-base-url"
v-model="localConfig.base_url"
aria-label="Base URL"
/>
</div>
<div class="space-y-2">
<Label for="bing-timeout-seconds">Timeout (seconds)</Label>
<Input
id="bing-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.bing.microsoft.com/v7.0/search',
timeout_seconds: 15,
})
watch(
() => props.modelValue,
(val) => {
localConfig.api_key = String(val?.api_key ?? '')
localConfig.base_url = String(val?.base_url ?? 'https://api.bing.microsoft.com/v7.0/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>
@@ -0,0 +1,80 @@
<template>
<div class="space-y-4">
<div class="space-y-2">
<Label for="google-api-key">API Key</Label>
<Input
id="google-api-key"
v-model="localConfig.api_key"
type="password"
aria-label="API Key"
/>
</div>
<div class="space-y-2">
<Label for="google-cx">Search Engine ID (cx)</Label>
<Input
id="google-cx"
v-model="localConfig.cx"
aria-label="Search Engine ID"
/>
</div>
<div class="space-y-2">
<Label for="google-base-url">Base URL</Label>
<Input
id="google-base-url"
v-model="localConfig.base_url"
aria-label="Base URL"
/>
</div>
<div class="space-y-2">
<Label for="google-timeout-seconds">Timeout (seconds)</Label>
<Input
id="google-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: '',
cx: '',
base_url: 'https://customsearch.googleapis.com/customsearch/v1',
timeout_seconds: 15,
})
watch(
() => props.modelValue,
(val) => {
localConfig.api_key = String(val?.api_key ?? '')
localConfig.cx = String(val?.cx ?? '')
localConfig.base_url = String(val?.base_url ?? 'https://customsearch.googleapis.com/customsearch/v1')
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,
cx: localConfig.cx,
base_url: localConfig.base_url,
timeout_seconds: localConfig.timeout_seconds,
})
}, { deep: true })
</script>
@@ -79,6 +79,12 @@
<template v-if="form.values.provider === 'brave'">
<BraveSettings v-model="configProxy" />
</template>
<template v-else-if="form.values.provider === 'bing'">
<BingSettings v-model="configProxy" />
</template>
<template v-else-if="form.values.provider === 'google'">
<GoogleSettings v-model="configProxy" />
</template>
<div
v-else-if="form.values.provider"
class="text-sm text-muted-foreground"
@@ -135,6 +141,8 @@ import {
} from '@memoh/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import BraveSettings from './brave-settings.vue'
import BingSettings from './bing-settings.vue'
import GoogleSettings from './google-settings.vue'
import SearchProviderLogo from '@/components/search-provider-logo/index.vue'
import { computed, inject, ref, watch } from 'vue'
import { toTypedSchema } from '@vee-validate/zod'
@@ -144,7 +152,7 @@ import { useMutation, useQueryCache } from '@pinia/colada'
import { putSearchProvidersById, deleteSearchProvidersById } from '@memoh/sdk'
import type { SearchprovidersGetResponse } from '@memoh/sdk'
const PROVIDER_TYPES = ['brave'] as const
const PROVIDER_TYPES = ['brave', 'bing', 'google'] as const
const curProvider = inject('curSearchProvider', ref<SearchprovidersGetResponse>())
const curProviderId = computed(() => curProvider.value?.id)
@@ -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'] as const
const PROVIDER_TYPES = ['brave', 'bing', 'google'] as const
const filterProvider = ref('')
const { data: providerData } = useQuery({
+48 -2
View File
@@ -2474,6 +2474,48 @@ const docTemplate = `{
}
}
}
},
"delete": {
"description": "Clear all persisted bot-level history messages",
"produces": [
"application/json"
],
"tags": [
"messages"
],
"summary": "Delete all bot history messages",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/schedule": {
@@ -8173,10 +8215,14 @@ const docTemplate = `{
"searchproviders.ProviderName": {
"type": "string",
"enum": [
"brave"
"brave",
"bing",
"google"
],
"x-enum-varnames": [
"ProviderBrave"
"ProviderBrave",
"ProviderBing",
"ProviderGoogle"
]
},
"searchproviders.UpdateRequest": {
+48 -2
View File
@@ -2465,6 +2465,48 @@
}
}
}
},
"delete": {
"description": "Clear all persisted bot-level history messages",
"produces": [
"application/json"
],
"tags": [
"messages"
],
"summary": "Delete all bot history messages",
"parameters": [
{
"type": "string",
"description": "Bot ID",
"name": "bot_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/bots/{bot_id}/schedule": {
@@ -8164,10 +8206,14 @@
"searchproviders.ProviderName": {
"type": "string",
"enum": [
"brave"
"brave",
"bing",
"google"
],
"x-enum-varnames": [
"ProviderBrave"
"ProviderBrave",
"ProviderBing",
"ProviderGoogle"
]
},
"searchproviders.UpdateRequest": {
+32
View File
@@ -1563,9 +1563,13 @@ definitions:
searchproviders.ProviderName:
enum:
- brave
- bing
- google
type: string
x-enum-varnames:
- ProviderBrave
- ProviderBing
- ProviderGoogle
searchproviders.UpdateRequest:
properties:
config:
@@ -3347,6 +3351,34 @@ paths:
tags:
- memory
/bots/{bot_id}/messages:
delete:
description: Clear all persisted bot-level history messages
parameters:
- description: Bot ID
in: path
name: bot_id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
"400":
description: Bad Request
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"403":
description: Forbidden
schema:
$ref: '#/definitions/handlers.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Delete all bot history messages
tags:
- messages
get:
description: List messages for a bot history with optional pagination
parameters: