Merge 4040dc8f59 into da71eaebd2
This commit is contained in:
commit
13d60f6a9a
@ -17,7 +17,7 @@ import {
|
|||||||
writeCache,
|
writeCache,
|
||||||
} from "./web-shared.js";
|
} from "./web-shared.js";
|
||||||
|
|
||||||
const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
|
const SEARCH_PROVIDERS = ["brave", "perplexity", "searxng"] as const;
|
||||||
const DEFAULT_SEARCH_COUNT = 5;
|
const DEFAULT_SEARCH_COUNT = 5;
|
||||||
const MAX_SEARCH_COUNT = 10;
|
const MAX_SEARCH_COUNT = 10;
|
||||||
|
|
||||||
@ -90,6 +90,25 @@ type PerplexityConfig = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SearxngConfig = {
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearxngSearchResult = {
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
engines?: string[];
|
||||||
|
score?: number;
|
||||||
|
publishedDate?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearxngSearchResponse = {
|
||||||
|
query?: string;
|
||||||
|
number_of_results?: number;
|
||||||
|
results?: SearxngSearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
|
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
|
||||||
|
|
||||||
type PerplexitySearchResponse = {
|
type PerplexitySearchResponse = {
|
||||||
@ -131,6 +150,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
|
|||||||
docs: "https://docs.openclaw.ai/tools/web",
|
docs: "https://docs.openclaw.ai/tools/web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (provider === "searxng") {
|
||||||
|
return {
|
||||||
|
error: "missing_searxng_base_url",
|
||||||
|
message:
|
||||||
|
"web_search (searxng) needs a base URL. Configure tools.web.search.searxng.baseUrl with your SearXNG instance URL.",
|
||||||
|
docs: "https://docs.molt.bot/tools/web",
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
error: "missing_brave_api_key",
|
error: "missing_brave_api_key",
|
||||||
message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
||||||
@ -144,6 +171,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
|||||||
? search.provider.trim().toLowerCase()
|
? search.provider.trim().toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (raw === "perplexity") return "perplexity";
|
if (raw === "perplexity") return "perplexity";
|
||||||
|
if (raw === "searxng") return "searxng";
|
||||||
if (raw === "brave") return "brave";
|
if (raw === "brave") return "brave";
|
||||||
return "brave";
|
return "brave";
|
||||||
}
|
}
|
||||||
@ -155,6 +183,22 @@ function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
|
|||||||
return perplexity as PerplexityConfig;
|
return perplexity as PerplexityConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSearxngConfig(search?: WebSearchConfig): SearxngConfig {
|
||||||
|
if (!search || typeof search !== "object") return {};
|
||||||
|
const searxng = "searxng" in search ? search.searxng : undefined;
|
||||||
|
if (!searxng || typeof searxng !== "object") return {};
|
||||||
|
return searxng as SearxngConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSearxngBaseUrl(searxng?: SearxngConfig): string | undefined {
|
||||||
|
const fromConfig =
|
||||||
|
searxng && "baseUrl" in searxng && typeof searxng.baseUrl === "string"
|
||||||
|
? searxng.baseUrl.trim()
|
||||||
|
: "";
|
||||||
|
const fromEnv = (process.env.SEARXNG_BASE_URL ?? "").trim();
|
||||||
|
return fromConfig || fromEnv || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
|
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
source: PerplexityApiKeySource;
|
source: PerplexityApiKeySource;
|
||||||
@ -306,6 +350,62 @@ async function runPerplexitySearch(params: {
|
|||||||
return { content, citations };
|
return { content, citations };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runSearxngSearch(params: {
|
||||||
|
query: string;
|
||||||
|
baseUrl: string;
|
||||||
|
count: number;
|
||||||
|
timeoutSeconds: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<{
|
||||||
|
results: Array<{
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
engines: string[];
|
||||||
|
score: number;
|
||||||
|
published?: string;
|
||||||
|
siteName?: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const url = new URL("/search", params.baseUrl.replace(/\/$/, ""));
|
||||||
|
url.searchParams.set("q", params.query);
|
||||||
|
url.searchParams.set("format", "json");
|
||||||
|
if (params.language) {
|
||||||
|
url.searchParams.set("language", params.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await readResponseText(res);
|
||||||
|
throw new Error(`SearXNG API error (${res.status}): ${detail || res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as SearxngSearchResponse;
|
||||||
|
const results = Array.isArray(data.results) ? data.results : [];
|
||||||
|
|
||||||
|
// Sort by score descending and take top N results
|
||||||
|
const sorted = results.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, params.count);
|
||||||
|
|
||||||
|
const mapped = sorted.map((entry) => ({
|
||||||
|
title: entry.title ?? "",
|
||||||
|
url: entry.url ?? "",
|
||||||
|
description: entry.content ?? "",
|
||||||
|
engines: entry.engines ?? [],
|
||||||
|
score: entry.score ?? 0,
|
||||||
|
published: entry.publishedDate ?? undefined,
|
||||||
|
siteName: resolveSiteName(entry.url ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { results: mapped };
|
||||||
|
}
|
||||||
|
|
||||||
async function runWebSearch(params: {
|
async function runWebSearch(params: {
|
||||||
query: string;
|
query: string;
|
||||||
count: number;
|
count: number;
|
||||||
@ -319,11 +419,14 @@ async function runWebSearch(params: {
|
|||||||
freshness?: string;
|
freshness?: string;
|
||||||
perplexityBaseUrl?: string;
|
perplexityBaseUrl?: string;
|
||||||
perplexityModel?: string;
|
perplexityModel?: string;
|
||||||
|
searxngBaseUrl?: string;
|
||||||
}): Promise<Record<string, unknown>> {
|
}): Promise<Record<string, unknown>> {
|
||||||
const cacheKey = normalizeCacheKey(
|
const cacheKey = normalizeCacheKey(
|
||||||
params.provider === "brave"
|
params.provider === "brave"
|
||||||
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
|
? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}`
|
||||||
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
: params.provider === "searxng"
|
||||||
|
? `${params.provider}:${params.searxngBaseUrl}:${params.query}:${params.count}:${params.search_lang || "default"}`
|
||||||
|
: `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
|
||||||
);
|
);
|
||||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||||
if (cached) return { ...cached.value, cached: true };
|
if (cached) return { ...cached.value, cached: true };
|
||||||
@ -351,6 +454,31 @@ async function runWebSearch(params: {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.provider === "searxng") {
|
||||||
|
if (!params.searxngBaseUrl) {
|
||||||
|
throw new Error("SearXNG base URL is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await runSearxngSearch({
|
||||||
|
query: params.query,
|
||||||
|
baseUrl: params.searxngBaseUrl,
|
||||||
|
count: params.count,
|
||||||
|
timeoutSeconds: params.timeoutSeconds,
|
||||||
|
language: params.search_lang,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
query: params.query,
|
||||||
|
provider: params.provider,
|
||||||
|
baseUrl: params.searxngBaseUrl,
|
||||||
|
count: results.length,
|
||||||
|
tookMs: Date.now() - start,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
if (params.provider !== "brave") {
|
if (params.provider !== "brave") {
|
||||||
throw new Error("Unsupported web search provider.");
|
throw new Error("Unsupported web search provider.");
|
||||||
}
|
}
|
||||||
@ -415,11 +543,14 @@ export function createWebSearchTool(options?: {
|
|||||||
|
|
||||||
const provider = resolveSearchProvider(search);
|
const provider = resolveSearchProvider(search);
|
||||||
const perplexityConfig = resolvePerplexityConfig(search);
|
const perplexityConfig = resolvePerplexityConfig(search);
|
||||||
|
const searxngConfig = resolveSearxngConfig(search);
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
provider === "perplexity"
|
provider === "perplexity"
|
||||||
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
|
? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
|
||||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
|
: provider === "searxng"
|
||||||
|
? "Search the web using a self-hosted SearXNG instance. Aggregates results from multiple search engines. Returns titles, URLs, descriptions, and source engines."
|
||||||
|
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: "Web Search",
|
label: "Web Search",
|
||||||
@ -429,12 +560,25 @@ export function createWebSearchTool(options?: {
|
|||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const perplexityAuth =
|
const perplexityAuth =
|
||||||
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
|
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
|
||||||
const apiKey =
|
const searxngBaseUrl =
|
||||||
provider === "perplexity" ? perplexityAuth?.apiKey : resolveSearchApiKey(search);
|
provider === "searxng" ? resolveSearxngBaseUrl(searxngConfig) : undefined;
|
||||||
|
|
||||||
if (!apiKey) {
|
// For SearXNG, we don't need an API key, just a base URL
|
||||||
|
const apiKey =
|
||||||
|
provider === "perplexity"
|
||||||
|
? perplexityAuth?.apiKey
|
||||||
|
: provider === "searxng"
|
||||||
|
? "unused" // SearXNG doesn't need an API key
|
||||||
|
: resolveSearchApiKey(search);
|
||||||
|
|
||||||
|
if (provider === "searxng" && !searxngBaseUrl) {
|
||||||
return jsonResult(missingSearchKeyPayload(provider));
|
return jsonResult(missingSearchKeyPayload(provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!apiKey && provider !== "searxng") {
|
||||||
|
return jsonResult(missingSearchKeyPayload(provider));
|
||||||
|
}
|
||||||
|
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const query = readStringParam(params, "query", { required: true });
|
const query = readStringParam(params, "query", { required: true });
|
||||||
const count =
|
const count =
|
||||||
@ -462,7 +606,7 @@ export function createWebSearchTool(options?: {
|
|||||||
const result = await runWebSearch({
|
const result = await runWebSearch({
|
||||||
query,
|
query,
|
||||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||||
apiKey,
|
apiKey: apiKey ?? "",
|
||||||
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
|
||||||
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
|
||||||
provider,
|
provider,
|
||||||
@ -476,6 +620,7 @@ export function createWebSearchTool(options?: {
|
|||||||
perplexityAuth?.apiKey,
|
perplexityAuth?.apiKey,
|
||||||
),
|
),
|
||||||
perplexityModel: resolvePerplexityModel(perplexityConfig),
|
perplexityModel: resolvePerplexityModel(perplexityConfig),
|
||||||
|
searxngBaseUrl,
|
||||||
});
|
});
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
},
|
},
|
||||||
@ -486,4 +631,5 @@ export const __testing = {
|
|||||||
inferPerplexityBaseUrlFromApiKey,
|
inferPerplexityBaseUrlFromApiKey,
|
||||||
resolvePerplexityBaseUrl,
|
resolvePerplexityBaseUrl,
|
||||||
normalizeFreshness,
|
normalizeFreshness,
|
||||||
|
resolveSearxngBaseUrl,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -435,7 +435,7 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
'Text suffix for cross-context markers (supports "{channel}").',
|
'Text suffix for cross-context markers (supports "{channel}").',
|
||||||
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
|
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
|
||||||
"tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).",
|
"tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).",
|
||||||
"tools.web.search.provider": 'Search provider ("brave" or "perplexity").',
|
"tools.web.search.provider": 'Search provider ("brave", "perplexity", or "searxng").',
|
||||||
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
||||||
"tools.web.search.maxResults": "Default number of results to return (1-10).",
|
"tools.web.search.maxResults": "Default number of results to return (1-10).",
|
||||||
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
|
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
|
||||||
@ -446,6 +446,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
|
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
|
||||||
"tools.web.search.perplexity.model":
|
"tools.web.search.perplexity.model":
|
||||||
'Perplexity model override (default: "perplexity/sonar-pro").',
|
'Perplexity model override (default: "perplexity/sonar-pro").',
|
||||||
|
"tools.web.search.searxng.baseUrl":
|
||||||
|
"SearXNG instance base URL (e.g. https://search.example.com). Can also be set via SEARXNG_BASE_URL env var.",
|
||||||
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
|
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
|
||||||
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
||||||
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
|
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
|
||||||
|
|||||||
@ -165,7 +165,9 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
|||||||
export const ToolsWebSearchSchema = z
|
export const ToolsWebSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(),
|
provider: z
|
||||||
|
.union([z.literal("brave"), z.literal("perplexity"), z.literal("searxng")])
|
||||||
|
.optional(),
|
||||||
apiKey: z.string().optional(),
|
apiKey: z.string().optional(),
|
||||||
maxResults: z.number().int().positive().optional(),
|
maxResults: z.number().int().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
@ -178,6 +180,12 @@ export const ToolsWebSearchSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
searxng: z
|
||||||
|
.object({
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user