diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 9605212f6e2..dfffe6eebe9 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2346,6 +2346,17 @@ "mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.", "mcp_endpoint_title": "Endpoint URL", "mcp_endpoint_description": "Add this URL to your AI assistant's MCP configuration", + "web_search_title": "Web Search Engine", + "web_search_description": "Choose which search engine the AI agent uses for web searches. Provider default uses the built-in search of each LLM provider (Anthropic, OpenAI, Google). Tavily and SearXNG work with all providers including Ollama.", + "web_search_engine": "Search engine", + "web_search_engine_description": "Select the search engine to use for AI web searches", + "web_search_provider_default": "Provider default (built-in)", + "tavily_api_key": "Tavily API key", + "tavily_api_key_description": "Get a free API key at tavily.com (1,000 searches/month free)", + "searxng_url": "SearXNG instance URL", + "searxng_url_description": "URL of your self-hosted SearXNG instance", + "search_timeout": "Search timeout (seconds)", + "search_timeout_description": "Maximum time to wait for web search results before timing out", "tools": { "search_notes": "Search notes", "get_note": "Get note", diff --git a/apps/client/src/widgets/type_widgets/options/llm.tsx b/apps/client/src/widgets/type_widgets/options/llm.tsx index 9da9f50ca62..922abed7977 100644 --- a/apps/client/src/widgets/type_widgets/options/llm.tsx +++ b/apps/client/src/widgets/type_widgets/options/llm.tsx @@ -23,6 +23,7 @@ export default function LlmSettings() { return ( <> + ); @@ -86,6 +87,66 @@ function getMcpEndpointUrl() { return `${window.location.protocol}//localhost:${port}/mcp`; } +function WebSearchSettings() { + const [searchEngine, setSearchEngine] = useTriliumOption("llmWebSearchEngine"); + const [tavilyApiKey, setTavilyApiKey] = useTriliumOption("llmTavilyApiKey"); + const [searxngUrl, setSearxngUrl] = useTriliumOption("llmSearxngUrl"); + const [searchTimeout, setSearchTimeout] = useTriliumOption("llmSearchTimeout"); + + return ( + +

{t("llm.web_search_description")}

+ + + + + + {searchEngine === "tavily" && ( + + setTavilyApiKey((e.target as HTMLInputElement).value)} + placeholder="tvly-..." + /> + + )} + + {searchEngine === "searxng" && ( + + setSearxngUrl((e.target as HTMLInputElement).value)} + placeholder="http://localhost:8888" + /> + + )} + + + setSearchTimeout((e.target as HTMLInputElement).value)} + /> + +
+ ); +} + function McpSettings() { const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled"); const endpointUrl = useMemo(() => getMcpEndpointUrl(), []); diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index 9e21dcb7b73..d171ad392d6 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -108,6 +108,10 @@ const ALLOWED_OPTIONS = new Set([ // LLM options "llmProviders", "mcpEnabled", + "llmWebSearchEngine", + "llmTavilyApiKey", + "llmSearxngUrl", + "llmSearchTimeout", // OCR options "ocrAutoProcessImages", "ocrMinConfidence" diff --git a/apps/server/src/services/llm/providers/base_provider.ts b/apps/server/src/services/llm/providers/base_provider.ts index 5000b4fcbfd..d852309cb77 100644 --- a/apps/server/src/services/llm/providers/base_provider.ts +++ b/apps/server/src/services/llm/providers/base_provider.ts @@ -13,6 +13,8 @@ import { getSkillsSummary } from "../skills/index.js"; import { getNoteMeta,SYSTEM_PROMPT_LIMITS } from "../tools/helpers.js"; import { allToolRegistries } from "../tools/index.js"; import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js"; +import { addTavilySearchTool, addSearxngSearchTool } from "../web_search_tools.js"; +import optionService from "../../options.js"; const DEFAULT_MAX_TOKENS = 8096; const TITLE_MAX_TOKENS = 30; @@ -157,7 +159,28 @@ export abstract class BaseProvider implements LlmProvider { const tools: ToolSet = {}; if (config.enableWebSearch) { - this.addWebSearchTool(tools); + const searchEngine = optionService.getOptionOrNull("llmWebSearchEngine") || "provider"; + const timeoutSec = parseInt(optionService.getOptionOrNull("llmSearchTimeout") || "15", 10); + const timeoutMs = (timeoutSec > 0 ? timeoutSec : 15) * 1000; + + let customToolAdded = false; + if (searchEngine === "tavily") { + const apiKey = optionService.getOptionOrNull("llmTavilyApiKey"); + if (apiKey) { + addTavilySearchTool(tools, apiKey, timeoutMs); + customToolAdded = true; + } + } else if (searchEngine === "searxng") { + const instanceUrl = optionService.getOptionOrNull("llmSearxngUrl"); + if (instanceUrl) { + addSearxngSearchTool(tools, instanceUrl, timeoutMs); + customToolAdded = true; + } + } + + if (!customToolAdded) { + this.addWebSearchTool(tools); + } } if (config.enableNoteTools) { diff --git a/apps/server/src/services/llm/web_search_tools.ts b/apps/server/src/services/llm/web_search_tools.ts new file mode 100644 index 00000000000..37204aad777 --- /dev/null +++ b/apps/server/src/services/llm/web_search_tools.ts @@ -0,0 +1,130 @@ +/** + * Custom web search tools for LLM chat. + * Provides Tavily and SearXNG search as alternatives to provider-built-in web search. + */ + +import { tool } from "ai"; +import type { ToolSet } from "ai"; +import { z } from "zod"; + +import log from "../log.js"; + +const MAX_RESULTS = 5; +const DEFAULT_TIMEOUT_MS = 15_000; + +/** Create an AbortSignal that times out after the given milliseconds. */ +function timeoutSignal(ms: number): AbortSignal { + return AbortSignal.timeout(ms); +} + +interface TavilyResult { + title: string; + url: string; + content: string; +} + +interface SearxngResult { + title: string; + url: string; + content: string; +} + +/** + * Add a Tavily web search tool to the tool set. + * Tavily is an AI-optimized search API that returns clean, relevant results. + * Free tier: 1000 queries/month at https://tavily.com + */ +export function addTavilySearchTool(tools: ToolSet, apiKey: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): void { + tools.web_search = tool({ + description: "Search the web for current information using Tavily. Use this when the user asks about recent events, real-time data, or anything requiring up-to-date web information.", + inputSchema: z.object({ + query: z.string().describe("The search query") + }), + execute: async ({ query }) => { + try { + const response = await fetch("https://api.tavily.com/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + api_key: apiKey, + query, + max_results: MAX_RESULTS, + include_answer: true + }), + signal: timeoutSignal(timeoutMs) + }); + + if (!response.ok) { + const errorText = await response.text(); + log.error(`Tavily search failed: ${response.status} ${errorText}`); + return { error: `Search failed: ${response.status}` }; + } + + const data = await response.json() as { answer?: string; results: TavilyResult[] }; + + return { + answer: data.answer || undefined, + results: data.results.map((r: TavilyResult) => ({ + title: r.title, + url: r.url, + snippet: r.content + })) + }; + } catch (e) { + log.error(`Tavily search error: ${e}`); + return { error: `Search failed: ${e instanceof Error ? e.message : String(e)}` }; + } + } + }); +} + +/** + * Add a SearXNG web search tool to the tool set. + * SearXNG is a self-hosted metasearch engine that aggregates results from multiple sources. + * No API key required — just a running SearXNG instance URL. + */ +export function addSearxngSearchTool(tools: ToolSet, instanceUrl: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): void { + // Normalize the URL (remove trailing slash) + const baseUrl = instanceUrl.replace(/\/+$/, ""); + + tools.web_search = tool({ + description: "Search the web for current information using SearXNG. Use this when the user asks about recent events, real-time data, or anything requiring up-to-date web information.", + inputSchema: z.object({ + query: z.string().describe("The search query") + }), + execute: async ({ query }) => { + try { + const params = new URLSearchParams({ + q: query, + format: "json", + categories: "general", + language: "auto" + }); + + const response = await fetch(`${baseUrl}/search?${params}`, { + headers: { "Accept": "application/json" }, + signal: timeoutSignal(timeoutMs) + }); + + if (!response.ok) { + const errorText = await response.text(); + log.error(`SearXNG search failed: ${response.status} ${errorText}`); + return { error: `Search failed: ${response.status}` }; + } + + const data = await response.json() as { results: SearxngResult[] }; + + return { + results: (data.results || []).slice(0, MAX_RESULTS).map((r: SearxngResult) => ({ + title: r.title, + url: r.url, + snippet: r.content + })) + }; + } catch (e) { + log.error(`SearXNG search error: ${e}`); + return { error: `Search failed: ${e instanceof Error ? e.message : String(e)}` }; + } + } + }); +} diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index 92912222d09..3d965422bc3 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -214,6 +214,10 @@ const defaultOptions: DefaultOption[] = [ // AI / LLM { name: "llmProviders", value: "[]", isSynced: true }, { name: "mcpEnabled", value: "false", isSynced: false }, + { name: "llmWebSearchEngine", value: "provider", isSynced: true }, + { name: "llmTavilyApiKey", value: "", isSynced: true }, + { name: "llmSearxngUrl", value: "", isSynced: true }, + { name: "llmSearchTimeout", value: "15", isSynced: true }, // OCR options { name: "ocrAutoProcessImages", value: "false", isSynced: true }, diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 590e3436dc6..a2f368235e2 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -146,6 +146,14 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions