feat(web-search): add SearXNG provider (#1166)
支持对接自建/公共 SearXNG 实例作为网络搜索引擎,缓解免费搜索引擎在国内 网络环境下访问受限的问题。 - types: 新增 WebSearchProviderTypeSearxng 与 BaseURL 参数字段; 类型元数据新增 RequiresBaseURL,前端可动态渲染 Instance URL 表单。 - infrastructure/web_search/searxng.go: 调用 /search?format=json,强制 utils.ValidateURLForSSRF 校验 base_url,可选 api_key 透传给反代鉴权。 - service: isValidProviderType 与参数校验接入 searxng。 - container: 注册 NewSearxngProvider 工厂。 - frontend: WebSearchSettings 表单根据 requires_base_url 渲染 Instance URL 输入框;编辑回填、free 判定同步更新。 - docker: 新增可选 searxng 服务(profile=searxng/full),附带最小化 settings.yml(启用 JSON 格式、关闭 limiter、关闭遥测), docker-compose 默认 SSRF_WHITELIST 包含 searxng 容器名。 - .env.example: 补充 SEARXNG_PORT / SEARXNG_SECRET 说明。 Closes #1166
This commit is contained in:
@@ -203,6 +203,13 @@ SYSTEM_AES_KEY=weknora-system-aes-key-32bytes!!
|
||||
# 列入者会在 URL 校验等地方绕过常规 SSRF 规则,生产环境请谨慎配置。
|
||||
# SSRF_WHITELIST=internal.service,*.corp.example,172.16.0.0/12,2001:db8::1,fd00::/8
|
||||
|
||||
# ===== SearXNG(可选自建网络搜索)=====
|
||||
# 启用:docker compose --profile searxng up -d
|
||||
# 默认对外端口 8888,容器内主机名 searxng:8080;后端通过 docker network 直接访问。
|
||||
# WeKnora 控制台中 Provider 类型选 "SearXNG",Instance URL 填 http://searxng:8080 即可。
|
||||
# SEARXNG_PORT=8888
|
||||
# SEARXNG_SECRET=please-change-me-to-a-long-random-string # openssl rand -hex 32
|
||||
|
||||
# 是否开启知识图谱构建和检索(构建阶段需调用大模型,耗时较长)
|
||||
ENABLE_GRAPH_RAG=false
|
||||
|
||||
|
||||
@@ -35,6 +35,30 @@ services:
|
||||
networks:
|
||||
- WeKnora-network-dev
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: WeKnora-searxng-dev
|
||||
ports:
|
||||
- "${SEARXNG_PORT:-8888}:8080"
|
||||
volumes:
|
||||
- ./docker/searxng/settings.yml:/etc/searxng/settings.yml:rw
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:${SEARXNG_PORT:-8888}/
|
||||
- INSTANCE_NAME=weknora-searxng-dev
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-please-change-me-to-a-long-random-string}
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- WeKnora-network-dev
|
||||
profiles:
|
||||
- searxng
|
||||
- full
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: WeKnora-minio-dev
|
||||
|
||||
+25
-1
@@ -134,7 +134,7 @@ services:
|
||||
- NEO4J_PASSWORD=${NEO4J_PASSWORD:-password}
|
||||
- TENANT_AES_KEY=${TENANT_AES_KEY:-}
|
||||
- SYSTEM_AES_KEY=${SYSTEM_AES_KEY:-}
|
||||
- SSRF_WHITELIST=${SSRF_WHITELIST:-}
|
||||
- SSRF_WHITELIST=${SSRF_WHITELIST:-searxng}
|
||||
- CONCURRENCY_POOL_SIZE=${CONCURRENCY_POOL_SIZE:-5}
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
# Crypto: 主密钥和盐值,用于 AppSecret 等敏感字段的 AES-256 加密
|
||||
@@ -238,6 +238,30 @@ services:
|
||||
networks:
|
||||
- WeKnora-network
|
||||
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: WeKnora-searxng
|
||||
ports:
|
||||
- "${SEARXNG_PORT:-8888}:8080"
|
||||
volumes:
|
||||
- ./docker/searxng/settings.yml:/etc/searxng/settings.yml:rw
|
||||
environment:
|
||||
- SEARXNG_BASE_URL=http://localhost:${SEARXNG_PORT:-8888}/
|
||||
- INSTANCE_NAME=weknora-searxng
|
||||
- SEARXNG_SECRET=${SEARXNG_SECRET:-please-change-me-to-a-long-random-string}
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- WeKnora-network
|
||||
profiles:
|
||||
- searxng
|
||||
- full
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: WeKnora-minio
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use_default_settings: true
|
||||
|
||||
general:
|
||||
instance_name: "WeKnora SearXNG"
|
||||
privacypolicy_url: false
|
||||
donation_url: false
|
||||
contact_url: false
|
||||
enable_metrics: false
|
||||
|
||||
server:
|
||||
# Override via env SEARXNG_SECRET; the entrypoint script substitutes ultrasecretkey.
|
||||
secret_key: "ultrasecretkey"
|
||||
base_url: false
|
||||
image_proxy: true
|
||||
http_protocol_version: "1.0"
|
||||
method: "GET"
|
||||
# Disabled so WeKnora's backend can call /search without IP-based throttling.
|
||||
# Re-enable and add WeKnora's egress IP to a limiter allowlist if you expose
|
||||
# this instance to the public internet.
|
||||
limiter: false
|
||||
public_instance: false
|
||||
|
||||
ui:
|
||||
static_use_hash: true
|
||||
default_locale: ""
|
||||
query_in_title: false
|
||||
infinite_scroll: false
|
||||
center_alignment: false
|
||||
theme_args:
|
||||
simple_style: auto
|
||||
search_on_category_select: true
|
||||
hotkeys: default
|
||||
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: ""
|
||||
default_lang: "auto"
|
||||
ban_time_on_fail: 5
|
||||
max_ban_time_on_fail: 120
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
|
||||
outgoing:
|
||||
request_timeout: 6.0
|
||||
max_request_timeout: 10.0
|
||||
pool_connections: 100
|
||||
pool_maxsize: 20
|
||||
enable_http2: true
|
||||
@@ -5,11 +5,12 @@ export interface WebSearchProviderEntity {
|
||||
id?: string
|
||||
tenant_id?: number
|
||||
name: string
|
||||
provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu'
|
||||
provider: 'bing' | 'google' | 'duckduckgo' | 'tavily' | 'ollama' | 'baidu' | 'searxng'
|
||||
description?: string
|
||||
parameters: {
|
||||
api_key?: string
|
||||
engine_id?: string
|
||||
base_url?: string
|
||||
proxy_url?: string
|
||||
extra_config?: Record<string, string>
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export interface WebSearchProviderTypeInfo {
|
||||
name: string
|
||||
requires_api_key: boolean
|
||||
requires_engine_id?: boolean
|
||||
requires_base_url?: boolean
|
||||
supports_proxy?: boolean
|
||||
description?: string
|
||||
docs_url?: string
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<t-input v-model="providerForm.description" :placeholder="t('webSearchSettings.providerDescPlaceholder')" />
|
||||
</t-form-item>
|
||||
|
||||
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id">
|
||||
<template v-if="selectedProviderType?.requires_api_key || selectedProviderType?.requires_engine_id || selectedProviderType?.requires_base_url">
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<div class="credentials-hint" v-if="selectedProviderType?.docs_url">
|
||||
@@ -92,6 +92,12 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<t-form-item v-if="selectedProviderType?.requires_base_url" label="Instance URL" name="parameters.base_url">
|
||||
<t-input
|
||||
v-model="providerForm.parameters.base_url"
|
||||
placeholder="https://searxng.example.com"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item v-if="selectedProviderType?.requires_api_key" :label="t('webSearchSettings.apiKeyLabel')" name="parameters.api_key">
|
||||
<t-input
|
||||
v-model="providerForm.parameters.api_key"
|
||||
@@ -177,7 +183,7 @@ const providerForm = ref<{
|
||||
name: string
|
||||
provider: string
|
||||
description: string
|
||||
parameters: { api_key?: string; engine_id?: string; proxy_url?: string }
|
||||
parameters: { api_key?: string; engine_id?: string; base_url?: string; proxy_url?: string }
|
||||
is_default: boolean
|
||||
}>({
|
||||
name: '',
|
||||
@@ -193,7 +199,7 @@ const selectedProviderType = computed(() => {
|
||||
})
|
||||
|
||||
const isProviderFree = (providerType: WebSearchProviderTypeInfo) => {
|
||||
return !providerType.requires_api_key && !providerType.requires_engine_id
|
||||
return !providerType.requires_api_key && !providerType.requires_engine_id && !providerType.requires_base_url
|
||||
}
|
||||
|
||||
const isEntityFree = (entity: WebSearchProviderEntity) => {
|
||||
@@ -246,6 +252,7 @@ const editProvider = (entity: WebSearchProviderEntity) => {
|
||||
parameters: {
|
||||
api_key: '',
|
||||
engine_id: entity.parameters?.engine_id || '',
|
||||
base_url: entity.parameters?.base_url || '',
|
||||
proxy_url: entity.parameters?.proxy_url || '',
|
||||
},
|
||||
is_default: entity.is_default || false,
|
||||
|
||||
@@ -85,7 +85,8 @@ func isValidProviderType(provider types.WebSearchProviderType) bool {
|
||||
types.WebSearchProviderTypeDuckDuckGo,
|
||||
types.WebSearchProviderTypeTavily,
|
||||
types.WebSearchProviderTypeOllama,
|
||||
types.WebSearchProviderTypeBaidu:
|
||||
types.WebSearchProviderTypeBaidu,
|
||||
types.WebSearchProviderTypeSearxng:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -120,6 +121,13 @@ func validateProviderParameters(provider types.WebSearchProviderType, params typ
|
||||
}
|
||||
case types.WebSearchProviderTypeDuckDuckGo:
|
||||
// No API key required
|
||||
case types.WebSearchProviderTypeSearxng:
|
||||
if params.BaseURL == "" {
|
||||
return fmt.Errorf("base_url is required for SearXNG provider")
|
||||
}
|
||||
if err := infra_web_search.ValidateProxyURL(params.BaseURL); err != nil {
|
||||
return fmt.Errorf("invalid SearXNG base_url: %w", err)
|
||||
}
|
||||
}
|
||||
if err := validateOptionalProxyURL(params.ProxyURL); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1252,6 +1252,7 @@ func registerWebSearchProviders(registry *infra_web_search.Registry) {
|
||||
registry.Register("tavily", infra_web_search.NewTavilyProvider)
|
||||
registry.Register("ollama", infra_web_search.NewOllamaProvider)
|
||||
registry.Register("baidu", infra_web_search.NewBaiduProvider)
|
||||
registry.Register("searxng", infra_web_search.NewSearxngProvider)
|
||||
}
|
||||
|
||||
// registerIMAdapterFactories registers adapter factories for each IM platform
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package web_search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
||||
"github.com/Tencent/WeKnora/internal/utils"
|
||||
)
|
||||
|
||||
const defaultSearxngTimeout = 15 * time.Second
|
||||
|
||||
// SearxngProvider implements web search using a self-hosted SearXNG instance.
|
||||
//
|
||||
// Unlike commercial providers, SearXNG is self-hosted, so the instance URL is
|
||||
// supplied by the tenant via WebSearchProviderParameters.BaseURL. The URL is
|
||||
// validated with utils.ValidateURLForSSRF; private/loopback hosts must be added
|
||||
// to the SSRF_WHITELIST environment variable.
|
||||
//
|
||||
// API key is optional and only required when the SearXNG instance enables it
|
||||
// (server.secret_key with limiter / json format restrictions).
|
||||
type SearxngProvider struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
apiKey string
|
||||
}
|
||||
|
||||
// NewSearxngProvider builds a SearXNG provider from tenant parameters.
|
||||
func NewSearxngProvider(params types.WebSearchProviderParameters) (interfaces.WebSearchProvider, error) {
|
||||
base := strings.TrimSpace(params.BaseURL)
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("base_url is required for SearXNG provider")
|
||||
}
|
||||
if err := utils.ValidateURLForSSRF(base); err != nil {
|
||||
return nil, fmt.Errorf("invalid SearXNG base_url: %w", err)
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid SearXNG base_url: must be an absolute http(s) URL")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("invalid SearXNG base_url scheme: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
client, err := NewSearchHTTPClient(defaultSearxngTimeout, params.ProxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SearxngProvider{
|
||||
client: client,
|
||||
baseURL: strings.TrimRight(base, "/"),
|
||||
apiKey: strings.TrimSpace(params.APIKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Name returns the provider name.
|
||||
func (p *SearxngProvider) Name() string { return "searxng" }
|
||||
|
||||
// Search performs a metasearch query against the configured SearXNG instance.
|
||||
// SearXNG must have `search.formats: [json]` enabled in settings.yml.
|
||||
func (p *SearxngProvider) Search(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
maxResults int,
|
||||
includeDate bool,
|
||||
) ([]*types.WebSearchResult, error) {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return nil, fmt.Errorf("query is empty")
|
||||
}
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("q", query)
|
||||
q.Set("format", "json")
|
||||
q.Set("safesearch", "1")
|
||||
q.Set("language", "auto")
|
||||
|
||||
reqURL := p.baseURL + "/search?" + q.Encode()
|
||||
logger.Infof(ctx, "[WebSearch][SearXNG] query=%q maxResults=%d url=%s", query, maxResults, p.baseURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "WeKnora/1.0")
|
||||
if p.apiKey != "" {
|
||||
// SearXNG does not have a built-in auth header; the api_key field is reused
|
||||
// to support reverse-proxies that gate access via Authorization.
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("searxng returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var data searxngResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode SearXNG response (ensure JSON format is enabled in settings.yml): %w", err)
|
||||
}
|
||||
|
||||
results := make([]*types.WebSearchResult, 0, maxResults)
|
||||
for _, r := range data.Results {
|
||||
if len(results) >= maxResults {
|
||||
break
|
||||
}
|
||||
if r.URL == "" || r.Title == "" {
|
||||
continue
|
||||
}
|
||||
item := &types.WebSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Snippet: r.Content,
|
||||
Source: "searxng",
|
||||
}
|
||||
if includeDate && r.PublishedDate != "" {
|
||||
if t, err := time.Parse(time.RFC3339, r.PublishedDate); err == nil {
|
||||
item.PublishedAt = &t
|
||||
}
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
logger.Infof(ctx, "[WebSearch][SearXNG] returned %d results", len(results))
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type searxngResponse struct {
|
||||
Query string `json:"query"`
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
PublishedDate string `json:"publishedDate,omitempty"`
|
||||
} `json:"results"`
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
WebSearchProviderTypeTavily WebSearchProviderType = "tavily"
|
||||
WebSearchProviderTypeOllama WebSearchProviderType = "ollama"
|
||||
WebSearchProviderTypeBaidu WebSearchProviderType = "baidu"
|
||||
WebSearchProviderTypeSearxng WebSearchProviderType = "searxng"
|
||||
)
|
||||
|
||||
// WebSearchProviderEntity represents a configured web search provider instance for a tenant.
|
||||
@@ -71,6 +72,9 @@ type WebSearchProviderParameters struct {
|
||||
APIKey string `yaml:"api_key" json:"api_key,omitempty"`
|
||||
// Google Custom Search Engine ID (only for Google provider)
|
||||
EngineID string `yaml:"engine_id" json:"engine_id,omitempty"`
|
||||
// Base URL for self-hosted search engines (e.g. SearXNG instance URL).
|
||||
// Validated with utils.ValidateURLForSSRF; private hosts must be added to SSRF_WHITELIST.
|
||||
BaseURL string `yaml:"base_url" json:"base_url,omitempty"`
|
||||
// Optional HTTP/HTTPS proxy URL for outbound search requests (e.g. http://host:port); validated with utils.ValidateURLForSSRF.
|
||||
// Does not replace the search API endpoint; only tunnels traffic to the official APIs.
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
@@ -121,6 +125,8 @@ type WebSearchProviderTypeInfo struct {
|
||||
RequiresAPIKey bool `json:"requires_api_key"`
|
||||
// Whether the provider requires an engine ID (e.g., Google CSE)
|
||||
RequiresEngineID bool `json:"requires_engine_id"`
|
||||
// Whether the provider requires a user-supplied base URL (e.g., self-hosted SearXNG instance)
|
||||
RequiresBaseURL bool `json:"requires_base_url"`
|
||||
// Whether optional proxy_url in parameters is honored for outbound requests
|
||||
SupportsProxy bool `json:"supports_proxy"`
|
||||
// Description
|
||||
@@ -172,6 +178,15 @@ func GetWebSearchProviderTypes() []WebSearchProviderTypeInfo {
|
||||
Description: "Ollama Cloud web search (requires Ollama API key)",
|
||||
DocsURL: "https://docs.ollama.com/capabilities/web-search",
|
||||
},
|
||||
{
|
||||
ID: "searxng",
|
||||
Name: "SearXNG",
|
||||
RequiresAPIKey: false,
|
||||
RequiresBaseURL: true,
|
||||
SupportsProxy: true,
|
||||
Description: "Self-hosted SearXNG metasearch instance (provide instance URL; private hosts must be SSRF-whitelisted)",
|
||||
DocsURL: "https://docs.searxng.org/",
|
||||
},
|
||||
{
|
||||
ID: "baidu",
|
||||
Name: "Baidu",
|
||||
|
||||
Reference in New Issue
Block a user