From eab91d2f7f5d32788aa264ef90c71b43c2f14499 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Fri, 17 Jul 2026 18:37:37 +0800 Subject: [PATCH] feat(web-search): add Zhipu AI web search provider support Integrate Zhipu's standalone Web Search API with configurable search engine and content size options, plus settings UI, docs, and tests. --- README.md | 2 +- README_CN.md | 2 +- README_JA.md | 2 +- README_KO.md | 2 +- docs/api/web-search.md | 21 ++ docs/docs.go | 6 +- docs/swagger.json | 6 +- docs/swagger.yaml | 2 + frontend/src/api/web-search-provider.ts | 15 +- frontend/src/i18n/locales/en-US.ts | 12 + frontend/src/i18n/locales/ko-KR.ts | 12 + frontend/src/i18n/locales/ru-RU.ts | 12 + frontend/src/i18n/locales/zh-CN.ts | 12 + .../src/views/settings/WebSearchSettings.vue | 78 ++++- .../service/web_search_provider.go | 7 +- .../service/web_search_provider_test.go | 32 +++ internal/container/container.go | 1 + internal/infrastructure/web_search/zhipu.go | 271 ++++++++++++++++++ .../infrastructure/web_search/zhipu_test.go | 202 +++++++++++++ internal/types/web_search_provider.go | 64 +++++ internal/types/web_search_provider_test.go | 32 +++ 21 files changed, 778 insertions(+), 15 deletions(-) create mode 100644 internal/application/service/web_search_provider_test.go create mode 100644 internal/infrastructure/web_search/zhipu.go create mode 100644 internal/infrastructure/web_search/zhipu_test.go create mode 100644 internal/types/web_search_provider_test.go diff --git a/README.md b/README.md index 4312fc34..a826927d 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ Fully modular pipeline from document parsing, vectorization, and retrieval to LL | Object Storage | Local / MinIO / AWS S3 / Volcengine TOS / Alibaba Cloud OSS / Kingsoft Cloud KS3 / Huawei Cloud OBS; **multiple storage instances per workspace** with per-KB binding and a default instance | | IM Channels | WeCom / Feishu / Lark (Feishu International) / QQBot / Slack / Telegram / DingTalk / Mattermost / WeChat | | Website Embed | Publish agents via embed widget with domain allowlists, rate limits, and secure-mode token exchange | -| Web Search | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable | +| Web Search | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / Zhipu AI | | API Integration | Scoped API keys (capability-level grants + per-KB restriction + throttled last-used tracking) with an API integration playground; MCP OAuth and embed sessions isolated per principal | **Platform** diff --git a/README_CN.md b/README_CN.md index 53d05ebb..d8e19a96 100644 --- a/README_CN.md +++ b/README_CN.md @@ -138,7 +138,7 @@ | 对象存储 | 本地 / 腾讯云COS / 火山引擎 TOS / MinIO / AWS S3 / 阿里云 OSS / 金山云 KS3 / 华为云 OBS;支持**每空间多实例存储后端**,不同知识库可绑定不同实例并设置默认实例 | | IM 集成 | 企业微信 / 飞书 / Lark(飞书国际版)/ QQBot / Slack / Telegram / 钉钉 / Mattermost / 微信 | | 网站嵌入 | 通过嵌入 Widget 发布智能体,支持域名白名单、限流与安全模式 Token 交换 | -| 网络搜索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable | +| 网络搜索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / 智谱 AI | | API 集成 | 权限范围 API Key(能力级授权 + 按 KB 限制 + 节流的 last_used 追踪)与 API 集成调试台;MCP OAuth 与嵌入会话按 Principal 隔离 | diff --git a/README_JA.md b/README_JA.md index c39bea0e..823a7485 100644 --- a/README_JA.md +++ b/README_JA.md @@ -139,7 +139,7 @@ Feishu、Notion、Yuqueなどの外部プラットフォームからのナレッ | オブジェクトストレージ | ローカル / MinIO / AWS S3 / 火山引擎 TOS / Alibaba Cloud OSS / 金山雲 KS3 / 華為雲 OBS;**ワークスペースごとに複数のストレージインスタンス**、KB 単位のバインドとデフォルトインスタンス | | IM 統合 | WeChat Work / Feishu / Lark(Feishu 国際版)/ QQBot / Slack / Telegram / DingTalk / Mattermost / WeChat | | ウェブ埋め込み | 埋め込み Widget でエージェントを公開、ドメイン許可リスト・レート制限・セキュアモード Token 交換 | -| Web 検索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable | +| Web 検索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / Zhipu AI | | API 連携 | スコープ付き API キー(能力単位の付与 + KB 単位の制限 + 節流付き last_used 追跡)と API 連携プレイグラウンド;MCP OAuth と埋め込みセッションを Principal 単位で分離 | **プラットフォーム** diff --git a/README_KO.md b/README_KO.md index d7720e49..1e501928 100644 --- a/README_KO.md +++ b/README_KO.md @@ -148,7 +148,7 @@ Feishu, Notion, Yuque 등 외부 플랫폼에서 지식 자동 동기화를 지 | 오브젝트 스토리지 | 로컬 / MinIO / AWS S3 / Volcengine TOS / Alibaba Cloud OSS / Kingsoft Cloud KS3 / Huawei Cloud OBS; **워크스페이스당 여러 스토리지 인스턴스**, KB 단위 바인딩 및 기본 인스턴스 | | IM 통합 | WeChat Work / Feishu / Lark(Feishu 국제판) / QQBot / Slack / Telegram / DingTalk / Mattermost / WeChat | | 웹사이트 임베드 | 임베드 Widget으로 에이전트 게시, 도메인 허용 목록·속도 제한·보안 모드 Token 교환 | -| 웹 검색 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable | +| 웹 검색 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / Zhipu AI | | API 통합 | 범위 지정 API 키(능력 단위 권한 + KB 단위 제한 + 스로틀링된 last_used 추적)와 API 통합 플레이그라운드; MCP OAuth 및 임베드 세션을 Principal 단위로 격리 | **플랫폼** diff --git a/docs/api/web-search.md b/docs/api/web-search.md index e5c25fc3..6382d3ff 100644 --- a/docs/api/web-search.md +++ b/docs/api/web-search.md @@ -126,6 +126,27 @@ curl --location --request POST 'http://localhost:8080/api/v1/web-search-provider { "success": false, "error": "google api: 403 forbidden" } ``` +### 智谱 AI 配置 + +智谱使用独立的 Web Search API。`search_engine` 和 `content_size` 存放在 +`parameters.extra_config` 中;未指定时分别使用 `search_std` 和 `medium`。 + +```json +{ + "provider": "zhipu", + "parameters": { + "api_key": "your-zhipu-api-key", + "extra_config": { + "search_engine": "search_std", + "content_size": "medium" + } + } +} +``` + +`search_engine` 支持 `search_std`、`search_pro`、`search_pro_sogou` 和 +`search_pro_quark`;`content_size` 支持 `medium` 和 `high`。 + ## POST `/web-search-providers` - 创建 Provider **参数说明(请求体)**: diff --git a/docs/docs.go b/docs/docs.go index e7bced33..ba0eb6aa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -19313,7 +19313,8 @@ const docTemplate = `{ "ollama", "baidu", "searxng", - "keenable" + "keenable", + "zhipu" ], "x-enum-varnames": [ "WebSearchProviderTypeBing", @@ -19323,7 +19324,8 @@ const docTemplate = `{ "WebSearchProviderTypeOllama", "WebSearchProviderTypeBaidu", "WebSearchProviderTypeSearxng", - "WebSearchProviderTypeKeenable" + "WebSearchProviderTypeKeenable", + "WebSearchProviderTypeZhipu" ] }, "github_com_Tencent_WeKnora_internal_types.WikiConfig": { diff --git a/docs/swagger.json b/docs/swagger.json index f3b599bd..b52a6fdd 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -19306,7 +19306,8 @@ "ollama", "baidu", "searxng", - "keenable" + "keenable", + "zhipu" ], "x-enum-varnames": [ "WebSearchProviderTypeBing", @@ -19316,7 +19317,8 @@ "WebSearchProviderTypeOllama", "WebSearchProviderTypeBaidu", "WebSearchProviderTypeSearxng", - "WebSearchProviderTypeKeenable" + "WebSearchProviderTypeKeenable", + "WebSearchProviderTypeZhipu" ] }, "github_com_Tencent_WeKnora_internal_types.WikiConfig": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 2f49b47f..30ac0616 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -3815,6 +3815,7 @@ definitions: - baidu - searxng - keenable + - zhipu type: string x-enum-varnames: - WebSearchProviderTypeBing @@ -3825,6 +3826,7 @@ definitions: - WebSearchProviderTypeBaidu - WebSearchProviderTypeSearxng - WebSearchProviderTypeKeenable + - WebSearchProviderTypeZhipu github_com_Tencent_WeKnora_internal_types.WikiConfig: properties: content_instructions: diff --git a/frontend/src/api/web-search-provider.ts b/frontend/src/api/web-search-provider.ts index 0e8c3a11..74323bd9 100644 --- a/frontend/src/api/web-search-provider.ts +++ b/frontend/src/api/web-search-provider.ts @@ -5,7 +5,7 @@ export interface WebSearchProviderEntity { id?: string tenant_id?: number name: string - provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu' | 'searxng' | 'keenable' + provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu' | 'searxng' | 'keenable' | 'zhipu' description?: string parameters: { // api_key is never returned by the server in this shape; it lives behind @@ -36,6 +36,19 @@ export interface WebSearchProviderTypeInfo { supports_proxy?: boolean description?: string docs_url?: string + config_fields?: WebSearchProviderConfigField[] +} + +export interface WebSearchProviderConfigField { + key: string + label: string + label_key?: string + type: 'select' + required?: boolean + default?: string + description?: string + description_key?: string + options?: Array<{ label: string; label_key?: string; value: string }> } // Create a new web search provider diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index eb6fe834..a3adda18 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1558,6 +1558,18 @@ export default { basicInfo: "Basic Information", credentials: "Credentials", setAsDefaultDesc: "This provider will be used by default when an agent doesn't specify one", + configFields: { + searchEngine: 'Search engine', + searchEngineDesc: 'Select the Zhipu search engine and per-request price tier.', + searchStd: 'Standard · ¥0.01/request', + searchPro: 'Pro · ¥0.03/request', + searchSogou: 'Sogou · ¥0.05/request', + searchQuark: 'Quark · ¥0.05/request', + contentSize: 'Content size', + contentSizeDesc: 'Medium returns concise summaries; high returns more context.', + contentMedium: 'Medium', + contentHigh: 'High', + }, // Search behavior searchBehaviorTitle: 'Search Behavior', defaultProviderLabel: 'Default Provider', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 5ad62f25..3d181efe 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -1422,6 +1422,18 @@ export default { basicInfo: "기본 정보", credentials: "자격 증명", setAsDefaultDesc: "에이전트가 검색 엔진을 지정하지 않은 경우 이 프로바이더가 기본적으로 사용됩니다", + configFields: { + searchEngine: "검색 엔진", + searchEngineDesc: "Zhipu 검색 엔진과 요청당 요금제를 선택합니다.", + searchStd: "Standard · ¥0.01/요청", + searchPro: "Pro · ¥0.03/요청", + searchSogou: "Sogou · ¥0.05/요청", + searchQuark: "Quark · ¥0.05/요청", + contentSize: "콘텐츠 길이", + contentSizeDesc: "Medium은 간결한 요약을, High는 더 많은 컨텍스트를 반환합니다.", + contentMedium: "Medium", + contentHigh: "High", + }, searchBehaviorTitle: "검색 동작 설정", defaultProviderLabel: "기본 프로바이더", defaultProviderDescription: "자체 프로바이더를 지정하지 않은 에이전트의 기본 검색 프로바이더를 선택합니다", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 25982d4c..d70f1b5b 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1466,6 +1466,18 @@ export default { basicInfo: "Основная информация", credentials: "Учетные данные", setAsDefaultDesc: "Этот провайдер будет использоваться по умолчанию, если агент не укажет свой", + configFields: { + searchEngine: 'Поисковый движок', + searchEngineDesc: 'Выберите поисковый движок Zhipu и соответствующий тариф за запрос.', + searchStd: 'Standard · ¥0.01/запрос', + searchPro: 'Pro · ¥0.03/запрос', + searchSogou: 'Sogou · ¥0.05/запрос', + searchQuark: 'Quark · ¥0.05/запрос', + contentSize: 'Размер содержимого', + contentSizeDesc: 'Medium возвращает краткие сводки, High — больше контекста.', + contentMedium: 'Medium', + contentHigh: 'High', + }, searchBehaviorTitle: 'Поведение поиска', defaultProviderLabel: 'Провайдер по умолчанию', defaultProviderDescription: 'Выберите провайдер поиска по умолчанию для агентов, не указавших собственный.', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 21dcccd7..4bc9e513 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1431,6 +1431,18 @@ export default { basicInfo: "基础信息", credentials: "凭证信息", setAsDefaultDesc: "当智能体没有指定特定的搜索引擎时,将默认使用此配置", + configFields: { + searchEngine: "搜索引擎", + searchEngineDesc: "选择智谱搜索引擎及对应的单次调用价格档位。", + searchStd: "基础版 · ¥0.01/次", + searchPro: "高级版 · ¥0.03/次", + searchSogou: "搜狗 · ¥0.05/次", + searchQuark: "夸克 · ¥0.05/次", + contentSize: "摘要长度", + contentSizeDesc: "中等返回精简摘要;详细返回更多上下文。", + contentMedium: "中等", + contentHigh: "详细", + }, // Search behavior searchBehaviorTitle: "搜索行为配置", defaultProviderLabel: "默认搜索引擎", diff --git a/frontend/src/views/settings/WebSearchSettings.vue b/frontend/src/views/settings/WebSearchSettings.vue index 454a9d79..a866ce5f 100644 --- a/frontend/src/views/settings/WebSearchSettings.vue +++ b/frontend/src/views/settings/WebSearchSettings.vue @@ -206,7 +206,7 @@

{{ t('webSearchSettings.credentialsSection', '连接配置') }}

@@ -253,6 +253,30 @@ :placeholder="t('webSearchSettings.engineIdLabel')" /> + +
+ + + + +

+ {{ configFieldText(field.description_key, field.description) }} +

+
@@ -334,13 +358,19 @@ const providerForm = ref<{ name: string provider: string description: string - parameters: { api_key?: string; engine_id?: string; base_url?: string; proxy_url?: string } + parameters: { + api_key?: string + engine_id?: string + base_url?: string + proxy_url?: string + extra_config: Record + } is_default: boolean }>({ name: '', provider: 'duckduckgo', description: '', - parameters: {}, + parameters: { extra_config: {} }, is_default: false, }) @@ -355,6 +385,7 @@ watch( providerForm.value.parameters?.api_key, providerForm.value.parameters?.engine_id, providerForm.value.parameters?.base_url, + JSON.stringify(providerForm.value.parameters?.extra_config || {}), ], () => { lastTestOk.value = null }, ) @@ -425,6 +456,7 @@ const canTestConnection = computed(() => { if (pt.requires_api_key && !providerForm.value.parameters.api_key) return false if (pt.requires_engine_id && !providerForm.value.parameters.engine_id) return false if (pt.requires_base_url && !providerForm.value.parameters.base_url) return false + if (pt.config_fields?.some(field => field.required && !providerForm.value.parameters.extra_config?.[field.key])) return false return true }) @@ -455,9 +487,24 @@ const providerTypeLabel = (providerId: string) => { return providerTypes.value.find(p => p.id === providerId)?.name || providerId } +const configFieldText = (key: string | undefined, fallback: string) => { + return key ? t(key, fallback) : fallback +} + +const providerConfigDefaults = (providerId: string) => { + const fields = providerTypes.value.find(p => p.id === providerId)?.config_fields || [] + return Object.fromEntries( + fields + .filter(field => field.default !== undefined) + .map(field => [field.key, field.default as string]), + ) +} + // ===== Methods ===== const onProviderTypeChange = () => { - providerForm.value.parameters = {} + providerForm.value.parameters = { + extra_config: providerConfigDefaults(providerForm.value.provider), + } lastTestOk.value = null } @@ -486,7 +533,9 @@ const openAddDialog = () => { name: '', provider: providerTypes.value[0]?.id || 'duckduckgo', description: '', - parameters: {}, + parameters: { + extra_config: providerConfigDefaults(providerTypes.value[0]?.id || 'duckduckgo'), + }, is_default: providerEntities.value.length === 0 } lastTestOk.value = null @@ -506,6 +555,10 @@ const editProvider = (entity: WebSearchProviderEntity) => { engine_id: entity.parameters?.engine_id || '', base_url: entity.parameters?.base_url || '', proxy_url: entity.parameters?.proxy_url || '', + extra_config: { + ...providerConfigDefaults(entity.provider), + ...(entity.parameters?.extra_config || {}), + }, }, is_default: entity.is_default || false, } @@ -531,6 +584,13 @@ const saveProvider = async () => { base_url: providerForm.value.parameters.base_url, proxy_url: providerForm.value.parameters.proxy_url, } + const extraConfig = Object.fromEntries( + Object.entries(providerForm.value.parameters.extra_config || {}) + .filter(([, value]) => value !== ''), + ) + if (Object.keys(extraConfig).length > 0) { + paramsOut.extra_config = extraConfig + } if (!editingProvider.value && providerForm.value.parameters.api_key) { paramsOut.api_key = providerForm.value.parameters.api_key } @@ -855,6 +915,10 @@ onMounted(async () => { background: rgba(20, 158, 130, 0.12); color: #149E82; } +.provider-card--zhipu .provider-card__badge { + background: rgba(37, 99, 235, 0.12); + color: #2563EB; +} .provider-card__body { flex: 1; @@ -1133,4 +1197,8 @@ onMounted(async () => { background: rgba(20, 158, 130, 0.12); color: #149E82; } +.websearch-drawer--zhipu .setting-drawer__header-icon { + background: rgba(37, 99, 235, 0.12); + color: #2563EB; +} diff --git a/internal/application/service/web_search_provider.go b/internal/application/service/web_search_provider.go index d6580e0a..2f1d3db5 100644 --- a/internal/application/service/web_search_provider.go +++ b/internal/application/service/web_search_provider.go @@ -136,7 +136,8 @@ func isValidProviderType(provider types.WebSearchProviderType) bool { types.WebSearchProviderTypeOllama, types.WebSearchProviderTypeBaidu, types.WebSearchProviderTypeSearxng, - types.WebSearchProviderTypeKeenable: + types.WebSearchProviderTypeKeenable, + types.WebSearchProviderTypeZhipu: return true default: return false @@ -169,6 +170,10 @@ func validateProviderParameters(provider types.WebSearchProviderType, params typ if params.APIKey == "" { return fmt.Errorf("API key is required for Baidu provider") } + case types.WebSearchProviderTypeZhipu: + if err := infra_web_search.ValidateZhipuParameters(params); err != nil { + return err + } case types.WebSearchProviderTypeDuckDuckGo: // No API key required case types.WebSearchProviderTypeKeenable: diff --git a/internal/application/service/web_search_provider_test.go b/internal/application/service/web_search_provider_test.go new file mode 100644 index 00000000..4abbf631 --- /dev/null +++ b/internal/application/service/web_search_provider_test.go @@ -0,0 +1,32 @@ +package service + +import ( + "testing" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestValidateProviderParametersZhipu(t *testing.T) { + valid := types.WebSearchProviderParameters{ + APIKey: "key", + ExtraConfig: map[string]string{ + "search_engine": "search_pro", + "content_size": "high", + }, + } + if err := validateProviderParameters(types.WebSearchProviderTypeZhipu, valid); err != nil { + t.Fatalf("valid Zhipu parameters rejected: %v", err) + } + + invalid := valid + invalid.ExtraConfig = map[string]string{"search_engine": "unsupported"} + if err := validateProviderParameters(types.WebSearchProviderTypeZhipu, invalid); err == nil { + t.Fatal("invalid Zhipu search engine was accepted") + } +} + +func TestIsValidProviderTypeIncludesZhipu(t *testing.T) { + if !isValidProviderType(types.WebSearchProviderTypeZhipu) { + t.Fatal("Zhipu provider type is not accepted") + } +} diff --git a/internal/container/container.go b/internal/container/container.go index 3805d259..a30362be 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -1538,6 +1538,7 @@ func registerWebSearchProviders(registry *infra_web_search.Registry) { registry.Register("baidu", infra_web_search.NewBaiduProvider) registry.Register("searxng", infra_web_search.NewSearxngProvider) registry.Register("keenable", infra_web_search.NewKeenableProvider) + registry.Register("zhipu", infra_web_search.NewZhipuProvider) } // registerIMAdapterFactories registers adapter factories for each IM platform diff --git a/internal/infrastructure/web_search/zhipu.go b/internal/infrastructure/web_search/zhipu.go new file mode 100644 index 00000000..e4cf1785 --- /dev/null +++ b/internal/infrastructure/web_search/zhipu.go @@ -0,0 +1,271 @@ +package web_search + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + "unicode/utf8" + + "github.com/Tencent/WeKnora/internal/logger" + "github.com/Tencent/WeKnora/internal/types" + "github.com/Tencent/WeKnora/internal/types/interfaces" +) + +const ( + defaultZhipuSearchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" + defaultZhipuTimeout = 15 * time.Second + defaultZhipuResults = 10 + maxZhipuResults = 50 + maxZhipuQueryRunes = 70 + maxZhipuResponseBytes = 2 << 20 + defaultZhipuSearchEngine = "search_std" + defaultZhipuContentSize = "medium" +) + +var validZhipuSearchEngines = map[string]struct{}{ + "search_std": {}, + "search_pro": {}, + "search_pro_sogou": {}, + "search_pro_quark": {}, +} + +var validZhipuContentSizes = map[string]struct{}{ + "medium": {}, + "high": {}, +} + +// ZhipuProvider implements web search using Zhipu AI's standalone Web Search API. +type ZhipuProvider struct { + client *http.Client + baseURL string + apiKey string + searchEngine string + contentSize string +} + +// NewZhipuProvider creates a Zhipu AI web search provider from persisted parameters. +func NewZhipuProvider(params types.WebSearchProviderParameters) (interfaces.WebSearchProvider, error) { + if err := ValidateZhipuParameters(params); err != nil { + return nil, err + } + client, err := NewSearchHTTPClient(defaultZhipuTimeout, params.ProxyURL) + if err != nil { + return nil, err + } + searchEngine, contentSize := zhipuOptions(params.ExtraConfig) + return &ZhipuProvider{ + client: client, + baseURL: defaultZhipuSearchURL, + apiKey: strings.TrimSpace(params.APIKey), + searchEngine: searchEngine, + contentSize: contentSize, + }, nil +} + +// ValidateZhipuParameters validates credentials and provider-specific options. +func ValidateZhipuParameters(params types.WebSearchProviderParameters) error { + if strings.TrimSpace(params.APIKey) == "" { + return fmt.Errorf("API key is required for Zhipu provider") + } + searchEngine, contentSize := zhipuOptions(params.ExtraConfig) + if _, ok := validZhipuSearchEngines[searchEngine]; !ok { + return fmt.Errorf("invalid Zhipu search engine: %s", searchEngine) + } + if _, ok := validZhipuContentSizes[contentSize]; !ok { + return fmt.Errorf("invalid Zhipu content size: %s", contentSize) + } + return nil +} + +func zhipuOptions(extraConfig map[string]string) (searchEngine, contentSize string) { + searchEngine = defaultZhipuSearchEngine + contentSize = defaultZhipuContentSize + if value := strings.TrimSpace(extraConfig["search_engine"]); value != "" { + searchEngine = value + } + if value := strings.TrimSpace(extraConfig["content_size"]); value != "" { + contentSize = value + } + return searchEngine, contentSize +} + +// Name returns the provider name. +func (p *ZhipuProvider) Name() string { + return "zhipu" +} + +// Search performs a web search using Zhipu AI's standalone Web Search API. +func (p *ZhipuProvider) Search( + ctx context.Context, + query string, + maxResults int, + includeDate bool, +) ([]*types.WebSearchResult, error) { + preparedQuery := normalizeZhipuQuery(query) + if preparedQuery == "" { + return nil, fmt.Errorf("query is empty") + } + if utf8.RuneCountInString(strings.TrimSpace(query)) > maxZhipuQueryRunes { + logger.Infof(ctx, "[WebSearch][Zhipu] truncated query to %d characters", maxZhipuQueryRunes) + } + if maxResults <= 0 { + maxResults = defaultZhipuResults + } + if maxResults > maxZhipuResults { + maxResults = maxZhipuResults + } + + requestBody := zhipuSearchRequest{ + SearchQuery: preparedQuery, + SearchEngine: p.searchEngine, + SearchIntent: false, + Count: maxResults, + ContentSize: p.contentSize, + } + body, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal Zhipu request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create Zhipu request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("Content-Type", "application/json") + + logger.Infof(ctx, "[WebSearch][Zhipu] query=%q maxResults=%d engine=%s", preparedQuery, maxResults, p.searchEngine) + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute Zhipu request: %w", err) + } + defer resp.Body.Close() + + respBody, err := readZhipuResponseBody(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, zhipuHTTPError(resp.StatusCode, respBody) + } + + var response zhipuSearchResponse + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal Zhipu response: %w", err) + } + if response.Error.Message != "" || response.Error.Code != "" { + return nil, fmt.Errorf("Zhipu API error (%s): %s", response.Error.Code, response.Error.Message) + } + + results := make([]*types.WebSearchResult, 0, len(response.SearchResult)) + for _, item := range response.SearchResult { + if strings.TrimSpace(item.Title) == "" && strings.TrimSpace(item.Link) == "" { + continue + } + result := &types.WebSearchResult{ + Title: item.Title, + URL: item.Link, + Snippet: item.Content, + Source: "zhipu", + } + if includeDate { + if publishedAt, ok := parseZhipuDate(item.PublishDate); ok { + result.PublishedAt = &publishedAt + } + } + results = append(results, result) + if len(results) >= maxResults { + break + } + } + logger.Infof(ctx, "[WebSearch][Zhipu] returned %d results", len(results)) + return results, nil +} + +func normalizeZhipuQuery(query string) string { + query = strings.TrimSpace(query) + if utf8.RuneCountInString(query) <= maxZhipuQueryRunes { + return query + } + runes := []rune(query) + return string(runes[:maxZhipuQueryRunes]) +} + +func parseZhipuDate(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + for _, layout := range []string{ + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", + } { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + return time.Time{}, false +} + +func readZhipuResponseBody(reader io.Reader) ([]byte, error) { + body, err := io.ReadAll(io.LimitReader(reader, maxZhipuResponseBytes+1)) + if err != nil { + return nil, fmt.Errorf("failed to read Zhipu response: %w", err) + } + if len(body) > maxZhipuResponseBytes { + return nil, fmt.Errorf("Zhipu response exceeds %d bytes", maxZhipuResponseBytes) + } + return body, nil +} + +func zhipuHTTPError(statusCode int, body []byte) error { + var response zhipuSearchResponse + if err := json.Unmarshal(body, &response); err == nil && (response.Error.Code != "" || response.Error.Message != "") { + return fmt.Errorf("Zhipu API returned status %d (%s): %s", statusCode, response.Error.Code, response.Error.Message) + } + detail := strings.TrimSpace(string(body)) + if len(detail) > 4096 { + detail = detail[:4096] + } + if detail == "" { + return fmt.Errorf("Zhipu API returned status %d", statusCode) + } + return fmt.Errorf("Zhipu API returned status %d: %s", statusCode, detail) +} + +type zhipuSearchRequest struct { + SearchQuery string `json:"search_query"` + SearchEngine string `json:"search_engine"` + SearchIntent bool `json:"search_intent"` + Count int `json:"count"` + ContentSize string `json:"content_size"` +} + +type zhipuSearchResponse struct { + ID string `json:"id"` + RequestID string `json:"request_id"` + SearchResult []zhipuSearchResult `json:"search_result"` + Error zhipuError `json:"error"` +} + +type zhipuSearchResult struct { + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` + Media string `json:"media"` + Icon string `json:"icon"` + Refer string `json:"refer"` + PublishDate string `json:"publish_date"` +} + +type zhipuError struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/internal/infrastructure/web_search/zhipu_test.go b/internal/infrastructure/web_search/zhipu_test.go new file mode 100644 index 00000000..6b7ed8f9 --- /dev/null +++ b/internal/infrastructure/web_search/zhipu_test.go @@ -0,0 +1,202 @@ +package web_search + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/Tencent/WeKnora/internal/types" +) + +func TestValidateZhipuParameters(t *testing.T) { + tests := []struct { + name string + params types.WebSearchProviderParameters + wantErr bool + }{ + {name: "defaults", params: types.WebSearchProviderParameters{APIKey: "key"}}, + { + name: "custom options", + params: types.WebSearchProviderParameters{ + APIKey: "key", + ExtraConfig: map[string]string{ + "search_engine": "search_pro_sogou", + "content_size": "high", + }, + }, + }, + {name: "missing key", params: types.WebSearchProviderParameters{}, wantErr: true}, + { + name: "invalid engine", + params: types.WebSearchProviderParameters{ + APIKey: "key", + ExtraConfig: map[string]string{"search_engine": "unknown"}, + }, + wantErr: true, + }, + { + name: "invalid content size", + params: types.WebSearchProviderParameters{ + APIKey: "key", + ExtraConfig: map[string]string{"content_size": "large"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateZhipuParameters(tt.params) + if (err != nil) != tt.wantErr { + t.Fatalf("ValidateZhipuParameters() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestZhipuProviderSearch(t *testing.T) { + query := strings.Repeat("智", maxZhipuQueryRunes+1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q, want Bearer test-key", got) + } + var request zhipuSearchRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + if got := utf8.RuneCountInString(request.SearchQuery); got != maxZhipuQueryRunes { + t.Errorf("query rune count = %d, want %d", got, maxZhipuQueryRunes) + } + if request.SearchEngine != "search_pro" { + t.Errorf("search_engine = %q, want search_pro", request.SearchEngine) + } + if request.ContentSize != "high" { + t.Errorf("content_size = %q, want high", request.ContentSize) + } + if request.SearchIntent { + t.Error("search_intent = true, want false") + } + if request.Count != maxZhipuResults { + t.Errorf("count = %d, want %d", request.Count, maxZhipuResults) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "search-id", + "request_id": "request-id", + "search_result": []map[string]any{ + { + "title": "Result 1", + "link": "https://example.com/1", + "content": "Summary 1", + "publish_date": "2026-07-16", + }, + { + "title": "Result 2", + "link": "https://example.com/2", + "content": "Summary 2", + }, + }, + }) + })) + defer srv.Close() + + provider := &ZhipuProvider{ + client: srv.Client(), + baseURL: srv.URL, + apiKey: "test-key", + searchEngine: "search_pro", + contentSize: "high", + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + results, err := provider.Search(ctx, query, maxZhipuResults+1, true) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(results) != 2 { + t.Fatalf("len(results) = %d, want 2", len(results)) + } + if results[0].Snippet != "Summary 1" || results[0].Content != "" { + t.Errorf("first result content mapping = %+v", results[0]) + } + if results[0].Source != "zhipu" { + t.Errorf("source = %q, want zhipu", results[0].Source) + } + if results[0].PublishedAt == nil || results[0].PublishedAt.Format("2006-01-02") != "2026-07-16" { + t.Errorf("published_at = %v, want 2026-07-16", results[0].PublishedAt) + } +} + +func TestZhipuProviderSearchDefaults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request zhipuSearchRequest + _ = json.NewDecoder(r.Body).Decode(&request) + if request.SearchEngine != defaultZhipuSearchEngine { + t.Errorf("search_engine = %q, want %q", request.SearchEngine, defaultZhipuSearchEngine) + } + if request.ContentSize != defaultZhipuContentSize { + t.Errorf("content_size = %q, want %q", request.ContentSize, defaultZhipuContentSize) + } + if request.Count != defaultZhipuResults { + t.Errorf("count = %d, want %d", request.Count, defaultZhipuResults) + } + _, _ = w.Write([]byte(`{"search_result":[]}`)) + })) + defer srv.Close() + + provider := &ZhipuProvider{ + client: srv.Client(), + baseURL: srv.URL, + apiKey: "test-key", + searchEngine: defaultZhipuSearchEngine, + contentSize: defaultZhipuContentSize, + } + if _, err := provider.Search(context.Background(), "test", 0, false); err != nil { + t.Fatalf("Search() error = %v", err) + } +} + +func TestZhipuProviderSearchError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"code":"1302","message":"rate limited"}}`)) + })) + defer srv.Close() + + provider := &ZhipuProvider{ + client: srv.Client(), + baseURL: srv.URL, + apiKey: "test-key", + searchEngine: defaultZhipuSearchEngine, + contentSize: defaultZhipuContentSize, + } + _, err := provider.Search(context.Background(), "test", 1, false) + if err == nil || !strings.Contains(err.Error(), "rate limited") { + t.Fatalf("Search() error = %v, want rate limited error", err) + } +} + +func TestParseZhipuDate(t *testing.T) { + for _, value := range []string{ + "2026-07-16", + "2026-07-16 12:30", + "2026-07-16 12:30:45", + "2026-07-16T12:30:45Z", + } { + if _, ok := parseZhipuDate(value); !ok { + t.Errorf("parseZhipuDate(%q) failed", value) + } + } + if _, ok := parseZhipuDate("not-a-date"); ok { + t.Error("parseZhipuDate(not-a-date) unexpectedly succeeded") + } +} diff --git a/internal/types/web_search_provider.go b/internal/types/web_search_provider.go index 07bfa9ce..45a7ea3c 100644 --- a/internal/types/web_search_provider.go +++ b/internal/types/web_search_provider.go @@ -23,6 +23,7 @@ const ( WebSearchProviderTypeBaidu WebSearchProviderType = "baidu" WebSearchProviderTypeSearxng WebSearchProviderType = "searxng" WebSearchProviderTypeKeenable WebSearchProviderType = "keenable" + WebSearchProviderTypeZhipu WebSearchProviderType = "zhipu" ) // WebSearchProviderEntity represents a configured web search provider instance for a workspace. @@ -141,6 +142,29 @@ type WebSearchProviderTypeInfo struct { Description string `json:"description"` // URL to the provider's official website or documentation for obtaining credentials DocsURL string `json:"docs_url,omitempty"` + // Provider-specific non-secret configuration rendered dynamically by the frontend. + ConfigFields []WebSearchProviderConfigField `json:"config_fields,omitempty"` +} + +// WebSearchProviderConfigField describes a non-secret provider-specific form field. +// Values are persisted in WebSearchProviderParameters.ExtraConfig. +type WebSearchProviderConfigField struct { + Key string `json:"key"` + Label string `json:"label"` + LabelKey string `json:"label_key,omitempty"` + Type string `json:"type"` + Required bool `json:"required,omitempty"` + Default string `json:"default,omitempty"` + Description string `json:"description,omitempty"` + DescriptionKey string `json:"description_key,omitempty"` + Options []WebSearchProviderConfigFieldOption `json:"options,omitempty"` +} + +// WebSearchProviderConfigFieldOption describes a selectable config field value. +type WebSearchProviderConfigFieldOption struct { + Label string `json:"label"` + LabelKey string `json:"label_key,omitempty"` + Value string `json:"value"` } // GetWebSearchProviderTypes returns metadata for all supported provider types. @@ -211,5 +235,45 @@ func GetWebSearchProviderTypes() []WebSearchProviderTypeInfo { Description: "Keenable web search built for AI agents (keyless by default; an optional API key lifts the rate limit)", DocsURL: "https://keenable.ai/", }, + { + ID: "zhipu", + Name: "Zhipu AI", + RequiresAPIKey: true, + SupportsProxy: true, + Description: "Zhipu AI Web Search API (requires API key)", + DocsURL: "https://docs.bigmodel.cn/cn/guide/tools/web-search", + ConfigFields: []WebSearchProviderConfigField{ + { + Key: "search_engine", + Label: "Search engine", + LabelKey: "webSearchSettings.configFields.searchEngine", + Type: "select", + Required: true, + Default: "search_std", + Description: "Select the Zhipu search engine and per-request price tier.", + DescriptionKey: "webSearchSettings.configFields.searchEngineDesc", + Options: []WebSearchProviderConfigFieldOption{ + {Label: "Standard · ¥0.01/request", LabelKey: "webSearchSettings.configFields.searchStd", Value: "search_std"}, + {Label: "Pro · ¥0.03/request", LabelKey: "webSearchSettings.configFields.searchPro", Value: "search_pro"}, + {Label: "Sogou · ¥0.05/request", LabelKey: "webSearchSettings.configFields.searchSogou", Value: "search_pro_sogou"}, + {Label: "Quark · ¥0.05/request", LabelKey: "webSearchSettings.configFields.searchQuark", Value: "search_pro_quark"}, + }, + }, + { + Key: "content_size", + Label: "Content size", + LabelKey: "webSearchSettings.configFields.contentSize", + Type: "select", + Required: true, + Default: "medium", + Description: "Medium returns concise summaries; high returns more context.", + DescriptionKey: "webSearchSettings.configFields.contentSizeDesc", + Options: []WebSearchProviderConfigFieldOption{ + {Label: "Medium", LabelKey: "webSearchSettings.configFields.contentMedium", Value: "medium"}, + {Label: "High", LabelKey: "webSearchSettings.configFields.contentHigh", Value: "high"}, + }, + }, + }, + }, } } diff --git a/internal/types/web_search_provider_test.go b/internal/types/web_search_provider_test.go new file mode 100644 index 00000000..9a5ca711 --- /dev/null +++ b/internal/types/web_search_provider_test.go @@ -0,0 +1,32 @@ +package types + +import "testing" + +func TestGetWebSearchProviderTypesIncludesZhipuConfig(t *testing.T) { + var zhipu *WebSearchProviderTypeInfo + providerTypes := GetWebSearchProviderTypes() + for i := range providerTypes { + if providerTypes[i].ID == string(WebSearchProviderTypeZhipu) { + zhipu = &providerTypes[i] + break + } + } + if zhipu == nil { + t.Fatal("Zhipu provider metadata is missing") + } + if !zhipu.RequiresAPIKey || !zhipu.SupportsProxy { + t.Fatalf("unexpected Zhipu capability metadata: %+v", zhipu) + } + if len(zhipu.ConfigFields) != 2 { + t.Fatalf("len(ConfigFields) = %d, want 2", len(zhipu.ConfigFields)) + } + if zhipu.ConfigFields[0].Key != "search_engine" || zhipu.ConfigFields[0].Default != "search_std" { + t.Fatalf("unexpected search engine config metadata: %+v", zhipu.ConfigFields[0]) + } + if len(zhipu.ConfigFields[0].Options) != 4 { + t.Fatalf("len(search engine options) = %d, want 4", len(zhipu.ConfigFields[0].Options)) + } + if zhipu.ConfigFields[1].Key != "content_size" || zhipu.ConfigFields[1].Default != "medium" { + t.Fatalf("unexpected content size config metadata: %+v", zhipu.ConfigFields[1]) + } +}