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.
This commit is contained in:
wizardchen
2026-07-17 18:37:37 +08:00
committed by lyingbug
parent 380e371c2e
commit eab91d2f7f
21 changed files with 778 additions and 15 deletions
+1 -1
View File
@@ -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**
+1 -1
View File
@@ -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 隔离 |
+1 -1
View File
@@ -139,7 +139,7 @@ Feishu、Notion、Yuqueなどの外部プラットフォームからのナレッ
| オブジェクトストレージ | ローカル / MinIO / AWS S3 / 火山引擎 TOS / Alibaba Cloud OSS / 金山雲 KS3 / 華為雲 OBS;**ワークスペースごとに複数のストレージインスタンス**、KB 単位のバインドとデフォルトインスタンス |
| IM 統合 | WeChat Work / Feishu / LarkFeishu 国際版)/ 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 単位で分離 |
**プラットフォーム**
+1 -1
View File
@@ -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 단위로 격리 |
**플랫폼**
+21
View File
@@ -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
**参数说明(请求体)**:
+4 -2
View File
@@ -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": {
+4 -2
View File
@@ -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": {
+2
View File
@@ -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:
+14 -1
View File
@@ -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
+12
View File
@@ -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',
+12
View File
@@ -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: "자체 프로바이더를 지정하지 않은 에이전트의 기본 검색 프로바이더를 선택합니다",
+12
View File
@@ -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: 'Выберите провайдер поиска по умолчанию для агентов, не указавших собственный.',
+12
View File
@@ -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: "默认搜索引擎",
@@ -206,7 +206,7 @@
<!-- Section 2 — 连接配置(base url / api key / engine id),仅当任意字段需要时渲染 -->
<section
v-if="selectedProviderType?.requires_api_key || selectedProviderType?.supports_optional_api_key || selectedProviderType?.requires_engine_id || selectedProviderType?.requires_base_url"
v-if="selectedProviderType?.requires_api_key || selectedProviderType?.supports_optional_api_key || selectedProviderType?.requires_engine_id || selectedProviderType?.requires_base_url || selectedProviderType?.config_fields?.length"
class="setting-drawer__section"
>
<h4 class="setting-drawer__section-title">{{ t('webSearchSettings.credentialsSection', '连接配置') }}</h4>
@@ -253,6 +253,30 @@
:placeholder="t('webSearchSettings.engineIdLabel')"
/>
</div>
<div
v-for="field in selectedProviderType?.config_fields || []"
:key="field.key"
class="form-item"
>
<label class="form-label" :class="{ required: field.required }">
{{ configFieldText(field.label_key, field.label) }}
</label>
<t-select
v-if="field.type === 'select'"
v-model="providerForm.parameters.extra_config[field.key]"
>
<t-option
v-for="option in field.options || []"
:key="option.value"
:value="option.value"
:label="configFieldText(option.label_key, option.label)"
/>
</t-select>
<p v-if="field.description" class="form-desc">
{{ configFieldText(field.description_key, field.description) }}
</p>
</div>
</section>
<!-- Section 3 — 选项(代理 / 默认) -->
@@ -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<string, string>
}
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;
}
</style>
@@ -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:
@@ -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")
}
}
+1
View File
@@ -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
+271
View File
@@ -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"`
}
@@ -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")
}
}
+64
View File
@@ -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"},
},
},
},
},
}
}
@@ -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])
}
}