feat(search): add SearchProvider chain with keyless fallback (DuckDuckGo + SearXNG)

This commit is contained in:
matevip 2026-04-07 16:52:42 +08:00
parent afe8466fa5
commit b750306028
19 changed files with 876 additions and 83 deletions

View File

@ -25,6 +25,23 @@ services:
timeout: 5s
retries: 5
# SearXNG 搜索引擎keyless 搜索 provider零配置可用
searxng:
image: searxng/searxng:latest
container_name: mateclaw-searxng
restart: unless-stopped
environment:
- SEARXNG_BASE_URL=http://searxng:8080
volumes:
- searxng_data:/etc/searxng
ports:
- "8088:8080"
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
# MateClaw 后端服务
mateclaw-server:
build:
@ -35,6 +52,8 @@ services:
depends_on:
mysql:
condition: service_healthy
searxng:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: mysql
DB_HOST: mysql
@ -52,3 +71,4 @@ services:
volumes:
mysql_data:
server_data:
searxng_data:

View File

@ -24,6 +24,12 @@ public class SystemSettingsDTO {
private String tavilyApiKey;
private String tavilyBaseUrl;
// ===== Keyless 搜索 provider 配置 =====
/** DuckDuckGo 是否启用(默认 true作为零配置兜底 */
private Boolean duckduckgoEnabled;
/** SearXNG 实例地址(如 http://searxng:8080为空则不使用 */
private String searxngBaseUrl;
// 用于前端回显脱敏后的 API Key
private String serperApiKeyMasked;
private String tavilyApiKeyMasked;

View File

@ -24,6 +24,8 @@ public class SystemSettingService {
private static final String SERPER_BASE_URL_KEY = "serperBaseUrl";
private static final String TAVILY_API_KEY_KEY = "tavilyApiKey";
private static final String TAVILY_BASE_URL_KEY = "tavilyBaseUrl";
private static final String DUCKDUCKGO_ENABLED_KEY = "duckduckgoEnabled";
private static final String SEARXNG_BASE_URL_KEY = "searxngBaseUrl";
private final SystemSettingMapper systemSettingMapper;
@ -40,6 +42,9 @@ public class SystemSettingService {
dto.setSearchFallbackEnabled(Boolean.parseBoolean(getValue(SEARCH_FALLBACK_ENABLED_KEY, "false")));
dto.setSerperBaseUrl(getValue(SERPER_BASE_URL_KEY, "https://google.serper.dev/search"));
dto.setTavilyBaseUrl(getValue(TAVILY_BASE_URL_KEY, "https://api.tavily.com/search"));
// Keyless provider 配置
dto.setDuckduckgoEnabled(Boolean.parseBoolean(getValue(DUCKDUCKGO_ENABLED_KEY, "true")));
dto.setSearxngBaseUrl(getValue(SEARXNG_BASE_URL_KEY, ""));
// API Key 脱敏回显
dto.setSerperApiKeyMasked(maskApiKey(getValue(SERPER_API_KEY_KEY, "")));
dto.setTavilyApiKeyMasked(maskApiKey(getValue(TAVILY_API_KEY_KEY, "")));
@ -58,6 +63,8 @@ public class SystemSettingService {
dto.setSerperBaseUrl(getValue(SERPER_BASE_URL_KEY, "https://google.serper.dev/search"));
dto.setTavilyApiKey(getValue(TAVILY_API_KEY_KEY, ""));
dto.setTavilyBaseUrl(getValue(TAVILY_BASE_URL_KEY, "https://api.tavily.com/search"));
dto.setDuckduckgoEnabled(Boolean.parseBoolean(getValue(DUCKDUCKGO_ENABLED_KEY, "true")));
dto.setSearxngBaseUrl(getValue(SEARXNG_BASE_URL_KEY, ""));
return dto;
}
@ -90,6 +97,13 @@ public class SystemSettingService {
if (dto.getTavilyBaseUrl() != null) {
saveValue(TAVILY_BASE_URL_KEY, dto.getTavilyBaseUrl(), "Tavily 接口地址");
}
// Keyless provider 配置
if (dto.getDuckduckgoEnabled() != null) {
saveValue(DUCKDUCKGO_ENABLED_KEY, String.valueOf(dto.getDuckduckgoEnabled()), "DuckDuckGo 免 Key 搜索(零配置兜底)");
}
if (dto.getSearxngBaseUrl() != null) {
saveValue(SEARXNG_BASE_URL_KEY, dto.getSearxngBaseUrl(), "SearXNG 实例地址");
}
return getSettings();
}

View File

@ -1,16 +1,27 @@
package vip.mate.tool.builtin;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.search.SearchProvider;
import vip.mate.tool.search.SearchProviderRegistry;
import vip.mate.tool.search.SearchProviderRegistry.ResolvedProvider;
import vip.mate.tool.search.SearchResult;
import java.util.List;
import java.util.stream.Collectors;
/**
* 搜索服务封装 Serper / Tavily provider + fallback 逻辑
* 搜索服务通过 {@link SearchProviderRegistry} 实现 provider chain 路由与 keyless fallback
*
* <p>调度策略
* <ol>
* <li>用户配置的 primary provider key</li>
* <li>自动探测其他有 key provider</li>
* <li>Keyless fallbackDuckDuckGo / SearXNG</li>
* </ol>
*
* @author MateClaw Team
*/
@ -20,9 +31,10 @@ import vip.mate.system.service.SystemSettingService;
public class WebSearchService {
private final SystemSettingService systemSettingService;
private final SearchProviderRegistry providerRegistry;
/**
* 执行搜索根据系统设置动态选择 provider
* 执行搜索根据系统设置动态选择 provider keyless fallback
*/
public String search(String query) {
SystemSettingsDTO config = systemSettingService.getSearchSettings();
@ -31,94 +43,60 @@ public class WebSearchService {
return "搜索功能已关闭,请在系统设置中启用。";
}
String primaryProvider = config.getSearchProvider();
if (primaryProvider == null || primaryProvider.isBlank()) {
primaryProvider = "serper";
}
// 通过 provider registry 解析最佳 provider
ResolvedProvider resolved = providerRegistry.resolve(config);
log.info("搜索 provider 解析: {}", resolved != null
? resolved.provider().id() + " (source=" + resolved.source() + ")"
: "无可用 provider");
// 尝试主 provider
String result = doSearch(query, primaryProvider, config);
if (result != null) {
return result;
}
// provider 失败尝试 fallback
if (Boolean.TRUE.equals(config.getSearchFallbackEnabled())) {
String fallbackProvider = "serper".equals(primaryProvider) ? "tavily" : "serper";
log.info("主搜索提供商 {} 调用失败,回退到 {}", primaryProvider, fallbackProvider);
result = doSearch(query, fallbackProvider, config);
if (resolved != null) {
String result = tryProvider(resolved.provider(), query, config);
if (result != null) {
log.info("搜索成功 (provider={}, source={})", resolved.provider().id(), resolved.source());
return result;
}
}
return "搜索失败:所有搜索提供商均不可用,请在系统设置中检查 API Key 配置。";
// 首选 provider 失败或不可用遍历 fallback chain
if (Boolean.TRUE.equals(config.getSearchFallbackEnabled()) || resolved == null) {
for (SearchProvider p : providerRegistry.allSorted()) {
if (resolved != null && p.id().equals(resolved.provider().id())) continue;
if (!p.isAvailable(config)) continue;
String result = tryProvider(p, query, config);
if (result != null) {
log.info("搜索 fallback 成功 (provider={})", p.id());
return result;
}
}
}
return "搜索暂时不可用。建议在系统设置中配置 Serper 或 Tavily API Key 以获得更好的搜索体验。";
}
private String tryProvider(SearchProvider provider, String query, SystemSettingsDTO config) {
try {
List<SearchResult> results = provider.search(query, config);
if (results == null || results.isEmpty()) {
log.debug("Provider {} 返回空结果", provider.id());
return null;
}
return formatResults(results, provider.id());
} catch (Exception e) {
log.warn("Provider {} 搜索失败: {}", provider.id(), e.getMessage());
return null;
}
}
/**
* 调用指定 provider 执行搜索失败返回 null
* 将结构化搜索结果格式化为 Markdown LLM 消费
*/
private String doSearch(String query, String provider, SystemSettingsDTO config) {
try {
return switch (provider) {
case "serper" -> searchWithSerper(query, config);
case "tavily" -> searchWithTavily(query, config);
default -> {
log.warn("未知的搜索提供商: {}", provider);
yield null;
}
};
} catch (Exception e) {
log.error("搜索提供商 {} 调用异常: {}", provider, e.getMessage(), e);
return null;
private String formatResults(List<SearchResult> results, String providerId) {
StringBuilder sb = new StringBuilder();
sb.append("Search results (via ").append(providerId).append("):\n\n");
for (int i = 0; i < results.size(); i++) {
sb.append(i + 1).append(". ").append(results.get(i).toMarkdown()).append("\n");
}
}
private String searchWithSerper(String query, SystemSettingsDTO config) {
String apiKey = config.getSerperApiKey();
if (apiKey == null || apiKey.isBlank()) {
log.warn("Serper API Key 未配置");
return null;
}
String baseUrl = config.getSerperBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = "https://google.serper.dev/search";
}
String body = JSONUtil.toJsonStr(new JSONObject().set("q", query).set("num", 5));
String result = HttpUtil.createPost(baseUrl)
.header("X-API-KEY", apiKey)
.header("Content-Type", "application/json")
.body(body)
.timeout(15000)
.execute()
.body();
log.debug("Serper search result for '{}': {}", query, result);
return result;
}
private String searchWithTavily(String query, SystemSettingsDTO config) {
String apiKey = config.getTavilyApiKey();
if (apiKey == null || apiKey.isBlank()) {
log.warn("Tavily API Key 未配置");
return null;
}
String baseUrl = config.getTavilyBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = "https://api.tavily.com/search";
}
String body = JSONUtil.toJsonStr(new JSONObject()
.set("query", query)
.set("max_results", 5)
.set("api_key", apiKey));
String result = HttpUtil.createPost(baseUrl)
.header("Content-Type", "application/json")
.body(body)
.timeout(15000)
.execute()
.body();
log.debug("Tavily search result for '{}': {}", query, result);
return result;
return sb.toString();
}
}

View File

@ -0,0 +1,176 @@
package vip.mate.tool.search;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* DuckDuckGo 搜索提供商 无需 API Keykeyless fallback
*
* <p>通过 DuckDuckGo HTML 端点实现免 key 搜索
* 作为零配置兜底方案结果质量不如 Serper/Tavily "总比没有强"
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class DuckDuckGoSearchProvider implements SearchProvider {
private static final String SEARCH_URL = "https://html.duckduckgo.com/html/";
// 匹配 DuckDuckGo HTML 搜索结果
private static final Pattern RESULT_PATTERN = Pattern.compile(
"<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.+?)</a>",
Pattern.DOTALL);
private static final Pattern SNIPPET_PATTERN = Pattern.compile(
"<a[^>]+class=\"result__snippet\"[^>]*>(.+?)</a>",
Pattern.DOTALL);
private static final Pattern RESULT_BLOCK_PATTERN = Pattern.compile(
"<div[^>]+class=\"result results_links[^\"]*\"[^>]*>(.*?)</div>\\s*(?=<div[^>]+class=\"result |$)",
Pattern.DOTALL);
@Override
public String id() {
return "duckduckgo";
}
@Override
public String label() {
return "DuckDuckGo";
}
@Override
public boolean requiresCredential() {
return false;
}
@Override
public int autoDetectOrder() {
return 100;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
// 通过 duckduckgoEnabled 控制默认启用
return !Boolean.FALSE.equals(config.getDuckduckgoEnabled());
}
@Override
public List<SearchResult> search(String query, SystemSettingsDTO config) {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
// DuckDuckGo 在部分网络环境下可能出现 SSLHandshakeException加一次重试
String response = null;
int maxAttempts = 2;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
response = HttpUtil.createPost(SEARCH_URL)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "text/html")
.header("Accept-Language", "en-US,en;q=0.9,zh-CN;q=0.8")
.body("q=" + encoded)
.timeout(15000)
.execute()
.body();
break; // 成功则跳出
} catch (Exception e) {
log.warn("DuckDuckGo 请求失败 (attempt {}/{}): {}", attempt, maxAttempts, e.getMessage());
if (attempt == maxAttempts) {
throw e; // 最后一次仍失败则抛出 WebSearchService 捕获
}
try {
Thread.sleep(1000); // 短暂等待后重试
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("DuckDuckGo 搜索被中断", ie);
}
}
}
log.debug("DuckDuckGo search completed for '{}', response length: {}", query,
response != null ? response.length() : 0);
return parseHtmlResults(response);
}
private List<SearchResult> parseHtmlResults(String html) {
List<SearchResult> results = new ArrayList<>();
if (html == null || html.isBlank()) return results;
// 提取每个结果块
Matcher titleMatcher = RESULT_PATTERN.matcher(html);
Matcher snippetMatcher = SNIPPET_PATTERN.matcher(html);
int count = 0;
while (titleMatcher.find() && count < 5) {
String rawUrl = titleMatcher.group(1);
String rawTitle = titleMatcher.group(2);
String url = decodeUrl(rawUrl);
String title = stripHtml(rawTitle);
String snippet = "";
if (snippetMatcher.find()) {
snippet = stripHtml(snippetMatcher.group(1));
}
if (title.isBlank() && snippet.isBlank()) continue;
results.add(SearchResult.builder()
.title(title)
.url(url)
.snippet(snippet)
.source(extractDomain(url))
.providerId(id())
.build());
count++;
}
log.debug("DuckDuckGo 解析到 {} 条结果", results.size());
return results;
}
/**
* DuckDuckGo 返回的 URL 经过跳转编码 (//duckduckgo.com/l/?uddg=...)需要解码
*/
private String decodeUrl(String rawUrl) {
if (rawUrl == null) return "";
// DuckDuckGo 使用 redirect URL
if (rawUrl.contains("uddg=")) {
try {
String encoded = rawUrl.substring(rawUrl.indexOf("uddg=") + 5);
int ampIndex = encoded.indexOf('&');
if (ampIndex > 0) encoded = encoded.substring(0, ampIndex);
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
log.debug("URL 解码失败: {}", rawUrl);
}
}
return rawUrl;
}
private String stripHtml(String html) {
if (html == null) return "";
return html.replaceAll("<[^>]+>", "").replaceAll("&amp;", "&")
.replaceAll("&lt;", "<").replaceAll("&gt;", ">")
.replaceAll("&quot;", "\"").replaceAll("&#39;", "'")
.trim();
}
private String extractDomain(String url) {
try {
return URI.create(url).getHost();
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,111 @@
package vip.mate.tool.search;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* SearXNG 搜索提供商 无需 API Key但需要 base URL自部署实例
*
* <p>SearXNG 是开源的元搜索引擎可自部署Docker 部署 MateClaw 时自动包含 SearXNG 服务
* 默认 base URL {@code http://searxng:8080}
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class SearXNGSearchProvider implements SearchProvider {
@Override
public String id() {
return "searxng";
}
@Override
public String label() {
return "SearXNG";
}
@Override
public boolean requiresCredential() {
return false;
}
@Override
public int autoDetectOrder() {
// SearXNG 聚合多引擎结果质量高于 DuckDuckGoorder=100应优先
return 50;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
String baseUrl = config.getSearxngBaseUrl();
return baseUrl != null && !baseUrl.isBlank();
}
@Override
public List<SearchResult> search(String query, SystemSettingsDTO config) {
String baseUrl = config.getSearxngBaseUrl();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String url = baseUrl + "/search?q=" + encoded + "&format=json&categories=general&language=auto";
String response = HttpUtil.createGet(url)
.header("Accept", "application/json")
.timeout(15000)
.execute()
.body();
log.debug("SearXNG result for '{}': length={}", query, response != null ? response.length() : 0);
return parseResponse(response);
}
private List<SearchResult> parseResponse(String response) {
List<SearchResult> results = new ArrayList<>();
if (response == null || response.isBlank()) return results;
try {
JSONObject json = JSONUtil.parseObj(response);
JSONArray items = json.getJSONArray("results");
if (items == null) return results;
int limit = Math.min(items.size(), 5);
for (int i = 0; i < limit; i++) {
JSONObject item = items.getJSONObject(i);
String itemUrl = item.getStr("url");
results.add(SearchResult.builder()
.title(item.getStr("title"))
.url(itemUrl)
.snippet(item.getStr("content"))
.source(extractDomain(itemUrl))
.date(item.getStr("publishedDate"))
.providerId(id())
.build());
}
} catch (Exception e) {
log.warn("SearXNG 结果解析失败: {}", e.getMessage());
}
return results;
}
private String extractDomain(String url) {
try {
return URI.create(url).getHost();
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,45 @@
package vip.mate.tool.search;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.List;
/**
* 搜索提供商接口 所有搜索 provider keyless统一实现此接口
*
* @author MateClaw Team
*/
public interface SearchProvider {
/** 提供商唯一 ID如 "serper"、"tavily"、"duckduckgo"、"searxng" */
String id();
/** 显示名称 */
String label();
/** 是否需要 API Key / Credential */
boolean requiresCredential();
/**
* 自动探测排序优先级升序
* <p> key provider 用较大值300+keyless 用较小值100+
* 但实际调度时"有 credential 的 provider"优先于 keyless
* 此字段仅在同类 provider 之间排序
*/
int autoDetectOrder();
/**
* 判断该 provider 在当前配置下是否可用
* key 已配置 / keyless provider 已启用等
*/
boolean isAvailable(SystemSettingsDTO config);
/**
* 执行搜索返回结构化结果列表
*
* @param query 搜索关键词
* @param config 系统配置包含 keybaseUrl
* @return 搜索结果列表不应返回 null失败时抛异常
*/
List<SearchResult> search(String query, SystemSettingsDTO config);
}

View File

@ -0,0 +1,102 @@
package vip.mate.tool.search;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 搜索提供商注册表 收集所有 {@link SearchProvider} 实现提供优先级排序与自动探测
*
* <p>借鉴 openclaw provider auto-detect 机制
* <ol>
* <li>用户显式配置的 primary provider 直接使用</li>
* <li> autoDetectOrder 遍历优先选有 credential provider</li>
* <li>如果没有有 credential provider回退到第一个可用的 keyless provider</li>
* </ol>
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class SearchProviderRegistry {
private final List<SearchProvider> sortedProviders;
private final Map<String, SearchProvider> providerMap;
public SearchProviderRegistry(List<SearchProvider> providers) {
this.sortedProviders = providers.stream()
.sorted(Comparator.comparingInt(SearchProvider::autoDetectOrder))
.toList();
this.providerMap = providers.stream()
.collect(Collectors.toMap(SearchProvider::id, Function.identity()));
log.info("注册搜索提供商 {} 个: {}", sortedProviders.size(),
sortedProviders.stream().map(p -> p.id() + "(order=" + p.autoDetectOrder() + ")").toList());
}
/** 按 ID 获取指定 provider */
public SearchProvider getById(String id) {
return providerMap.get(id);
}
/** 获取按 autoDetectOrder 排序的全部 provider 列表 */
public List<SearchProvider> allSorted() {
return sortedProviders;
}
/**
* 根据当前配置解析应使用的 provider
*
* <p>解析策略借鉴 openclaw resolveWebSearchProviderId
* <ol>
* <li>如果用户配置了 primary provider 且该 provider 可用 选中</li>
* <li>否则按 autoDetectOrder 遍历跳过 keyless先找有 credential </li>
* <li>如果没找到 回退到第一个可用的 keyless provider</li>
* </ol>
*
* @return 选中的 provider null完全无可用 provider
*/
public ResolvedProvider resolve(SystemSettingsDTO config) {
// 1. 用户显式配置的 primary provider
String configuredId = config.getSearchProvider();
if (configuredId != null && !configuredId.isBlank()) {
SearchProvider configured = providerMap.get(configuredId);
if (configured != null && configured.isAvailable(config)) {
return new ResolvedProvider(configured, "configured");
}
}
// 2. 按优先级遍历先找有 credential
SearchProvider keylessFallback = null;
for (SearchProvider p : sortedProviders) {
if (!p.requiresCredential()) {
// 记住第一个可用的 keyless provider
if (keylessFallback == null && p.isAvailable(config)) {
keylessFallback = p;
}
continue;
}
if (p.isAvailable(config)) {
return new ResolvedProvider(p, "auto-detect");
}
}
// 3. 回退到 keyless
if (keylessFallback != null) {
return new ResolvedProvider(keylessFallback, "keyless-fallback");
}
return null;
}
/**
* 解析结果
*/
public record ResolvedProvider(SearchProvider provider, String source) {
}
}

View File

@ -0,0 +1,54 @@
package vip.mate.tool.search;
import lombok.Builder;
import lombok.Data;
/**
* 统一搜索结果结构 所有 provider 返回此格式
*
* @author MateClaw Team
*/
@Data
@Builder
public class SearchResult {
/** 结果标题 */
private String title;
/** 结果链接 */
private String url;
/** 摘要/片段 */
private String snippet;
/** 来源域名 (如 "reuters.com") */
private String source;
/** 发布时间(原始字符串,尽可能保留) */
private String date;
/** 提供该结果的 provider id */
private String providerId;
/**
* 格式化为 Markdown 用于返回给 LLM
*/
public String toMarkdown() {
StringBuilder sb = new StringBuilder();
sb.append("**").append(title != null ? title : "Untitled").append("**");
if (source != null || date != null) {
sb.append("");
if (source != null) sb.append(source);
if (source != null && date != null) sb.append(" | ");
if (date != null) sb.append(date);
}
sb.append("\n");
if (snippet != null && !snippet.isBlank()) {
sb.append(snippet).append("\n");
}
if (url != null && !url.isBlank()) {
sb.append("🔗 ").append(url).append("\n");
}
return sb.toString();
}
}

View File

@ -0,0 +1,105 @@
package vip.mate.tool.search;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
/**
* Serper (Google Search) 搜索提供商 需要 API Key
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class SerperSearchProvider implements SearchProvider {
private static final String DEFAULT_BASE_URL = "https://google.serper.dev/search";
@Override
public String id() {
return "serper";
}
@Override
public String label() {
return "Serper (Google)";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 300;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
String key = config.getSerperApiKey();
return key != null && !key.isBlank();
}
@Override
public List<SearchResult> search(String query, SystemSettingsDTO config) {
String apiKey = config.getSerperApiKey();
String baseUrl = config.getSerperBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = DEFAULT_BASE_URL;
}
String body = JSONUtil.toJsonStr(new JSONObject().set("q", query).set("num", 5));
String response = HttpUtil.createPost(baseUrl)
.header("X-API-KEY", apiKey)
.header("Content-Type", "application/json")
.body(body)
.timeout(15000)
.execute()
.body();
log.debug("Serper result for '{}': {}", query, response);
return parseResponse(response);
}
private List<SearchResult> parseResponse(String response) {
List<SearchResult> results = new ArrayList<>();
try {
JSONObject json = JSONUtil.parseObj(response);
JSONArray organic = json.getJSONArray("organic");
if (organic == null) return results;
for (int i = 0; i < organic.size(); i++) {
JSONObject item = organic.getJSONObject(i);
String url = item.getStr("link");
results.add(SearchResult.builder()
.title(item.getStr("title"))
.url(url)
.snippet(item.getStr("snippet"))
.source(extractDomain(url))
.date(item.getStr("date"))
.providerId(id())
.build());
}
} catch (Exception e) {
log.warn("Serper 结果解析失败: {}", e.getMessage());
}
return results;
}
private String extractDomain(String url) {
try {
return URI.create(url).getHost();
} catch (Exception e) {
return null;
}
}
}

View File

@ -0,0 +1,107 @@
package vip.mate.tool.search;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.system.model.SystemSettingsDTO;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
/**
* Tavily 搜索提供商 需要 API Key
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class TavilySearchProvider implements SearchProvider {
private static final String DEFAULT_BASE_URL = "https://api.tavily.com/search";
@Override
public String id() {
return "tavily";
}
@Override
public String label() {
return "Tavily";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 400;
}
@Override
public boolean isAvailable(SystemSettingsDTO config) {
String key = config.getTavilyApiKey();
return key != null && !key.isBlank();
}
@Override
public List<SearchResult> search(String query, SystemSettingsDTO config) {
String apiKey = config.getTavilyApiKey();
String baseUrl = config.getTavilyBaseUrl();
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = DEFAULT_BASE_URL;
}
String body = JSONUtil.toJsonStr(new JSONObject()
.set("query", query)
.set("max_results", 5)
.set("api_key", apiKey));
String response = HttpUtil.createPost(baseUrl)
.header("Content-Type", "application/json")
.body(body)
.timeout(15000)
.execute()
.body();
log.debug("Tavily result for '{}': {}", query, response);
return parseResponse(response);
}
private List<SearchResult> parseResponse(String response) {
List<SearchResult> results = new ArrayList<>();
try {
JSONObject json = JSONUtil.parseObj(response);
JSONArray items = json.getJSONArray("results");
if (items == null) return results;
for (int i = 0; i < items.size(); i++) {
JSONObject item = items.getJSONObject(i);
String url = item.getStr("url");
results.add(SearchResult.builder()
.title(item.getStr("title"))
.url(url)
.snippet(item.getStr("content"))
.source(extractDomain(url))
.date(item.getStr("published_date"))
.providerId(id())
.build());
}
} catch (Exception e) {
log.warn("Tavily 结果解析失败: {}", e.getMessage());
}
return results;
}
private String extractDomain(String url) {
try {
return URI.create(url).getHost();
} catch (Exception e) {
return null;
}
}
}

View File

@ -294,6 +294,14 @@ MERGE INTO mate_system_setting (id, setting_key, setting_value, description, cre
KEY (id)
VALUES (1000000011, 'tavilyBaseUrl', 'https://api.tavily.com/search', 'Tavily base URL', NOW(), NOW());
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
KEY (id)
VALUES (1000000012, 'duckduckgoEnabled', 'true', 'DuckDuckGo keyless search fallback (zero-config)', NOW(), NOW());
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
KEY (id)
VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG instance base URL (auto-configured in Docker)', NOW(), NOW());
-- Built-in tool: Date & Time
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)

View File

@ -296,6 +296,14 @@ INSERT INTO mate_system_setting (id, setting_key, setting_value, description, cr
VALUES (1000000011, 'tavilyBaseUrl', 'https://api.tavily.com/search', 'Tavily base URL', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time);
INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
VALUES (1000000012, 'duckduckgoEnabled', 'true', 'DuckDuckGo keyless search fallback (zero-config)', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time);
INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG instance base URL (auto-configured in Docker)', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time);
-- Built-in tool: Date & Time
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000001, 'DateTimeTool', 'Date & Time', 'Get current date and time information', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0)

View File

@ -296,6 +296,14 @@ INSERT INTO mate_system_setting (id, setting_key, setting_value, description, cr
VALUES (1000000011, 'tavilyBaseUrl', 'https://api.tavily.com/search', 'Tavily 接口地址', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time);
INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
VALUES (1000000012, 'duckduckgoEnabled', 'true', 'DuckDuckGo 免 Key 搜索兜底(零配置可用)', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time);
INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG 实例地址Docker 部署时自动配置)', NOW(), NOW())
ON DUPLICATE KEY UPDATE setting_key=VALUES(setting_key), setting_value=VALUES(setting_value), description=VALUES(description), update_time=VALUES(update_time);
-- 内置工具:日期时间
INSERT INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
VALUES (1000000001, 'DateTimeTool', '日期时间', '获取当前日期和时间信息', 'builtin', 'dateTimeTool', '🕐', TRUE, TRUE, NOW(), NOW(), 0)

View File

@ -300,6 +300,14 @@ MERGE INTO mate_system_setting (id, setting_key, setting_value, description, cre
KEY (id)
VALUES (1000000011, 'tavilyBaseUrl', 'https://api.tavily.com/search', 'Tavily 接口地址', NOW(), NOW());
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
KEY (id)
VALUES (1000000012, 'duckduckgoEnabled', 'true', 'DuckDuckGo 免 Key 搜索兜底(零配置可用)', NOW(), NOW());
MERGE INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
KEY (id)
VALUES (1000000013, 'searxngBaseUrl', '', 'SearXNG 实例地址Docker 部署时自动配置)', NOW(), NOW());
-- 内置工具:日期时间
MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted)
KEY (id)

View File

@ -330,6 +330,8 @@ export default {
serperBaseUrl: 'Serper Base URL',
tavilyApiKey: 'Tavily API Key',
tavilyBaseUrl: 'Tavily Base URL',
duckduckgoEnabled: 'DuckDuckGo (Keyless)',
searxngBaseUrl: 'SearXNG Base URL',
},
hints: {
provider: 'Current implementation applies DashScope model options at runtime.',
@ -343,6 +345,8 @@ export default {
serperBaseUrl: 'Usually no need to change unless using a custom proxy.',
tavilyApiKey: 'API key for Tavily search, get it from tavily.com.',
tavilyBaseUrl: 'Usually no need to change unless using a custom proxy.',
duckduckgoEnabled: 'Free keyless search fallback. No API key required. Enabled by default as a zero-config fallback.',
searxngBaseUrl: 'Self-hosted SearXNG instance URL. Auto-configured when deploying via Docker.',
},
searchTitle: 'Search Service',
searchDesc: 'Configure the built-in search tool provider and API credentials',

View File

@ -330,6 +330,8 @@ export default {
serperBaseUrl: 'Serper 接口地址',
tavilyApiKey: 'Tavily API Key',
tavilyBaseUrl: 'Tavily 接口地址',
duckduckgoEnabled: 'DuckDuckGo免 Key',
searxngBaseUrl: 'SearXNG 地址',
},
hints: {
provider: '当前版本会把 DashScope 模型参数真实应用到 Agent 调用链路。',
@ -343,6 +345,8 @@ export default {
serperBaseUrl: '通常无需修改,除非使用自定义代理地址。',
tavilyApiKey: '用于 Tavily 搜索服务,从 tavily.com 获取。',
tavilyBaseUrl: '通常无需修改,除非使用自定义代理地址。',
duckduckgoEnabled: '免费搜索兜底,无需 API Key。默认开启作为零配置下的搜索降级方案。',
searxngBaseUrl: '自部署 SearXNG 实例地址。Docker 部署时自动配置。',
},
searchTitle: '搜索服务',
searchDesc: '配置内置搜索工具的提供商与 API 凭证',

View File

@ -499,6 +499,9 @@ export interface SystemSettings {
tavilyBaseUrl: string
serperApiKeyMasked?: string
tavilyApiKeyMasked?: string
// Keyless 搜索 provider
duckduckgoEnabled: boolean
searxngBaseUrl: string
}
export interface ProviderModelInfo {

View File

@ -159,6 +159,36 @@
/>
</div>
</div>
<!-- Keyless Provider 配置 -->
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.duckduckgoEnabled') }}</div>
<div class="setting-hint">{{ t('settings.hints.duckduckgoEnabled') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="settings.duckduckgoEnabled" type="checkbox" :disabled="!settings.searchEnabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item setting-item-vertical">
<div class="setting-info">
<div class="setting-label">{{ t('settings.fields.searxngBaseUrl') }}</div>
<div class="setting-hint">{{ t('settings.hints.searxngBaseUrl') }}</div>
</div>
<div class="setting-control-full">
<input
v-model="settings.searxngBaseUrl"
type="text"
class="form-input"
placeholder="http://searxng:8080"
:disabled="!settings.searchEnabled"
/>
</div>
</div>
</div>
<div class="save-bar">
@ -193,6 +223,8 @@ const settings = reactive<SystemSettings>({
searchFallbackEnabled: false,
serperBaseUrl: 'https://google.serper.dev/search',
tavilyBaseUrl: 'https://api.tavily.com/search',
duckduckgoEnabled: true,
searxngBaseUrl: '',
})
onMounted(async () => {