Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions apps/client/src/widgets/type_widgets/options/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default function LlmSettings() {
return (
<>
<ProviderSettings />
<WebSearchSettings />
<McpSettings />
</>
);
Expand Down Expand Up @@ -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 (
<OptionsSection title={t("llm.web_search_title")}>
<p className="form-text">{t("llm.web_search_description")}</p>

<OptionsRow name="web-search-engine" label={t("llm.web_search_engine")} description={t("llm.web_search_engine_description")}>
<select
className="form-select"
value={searchEngine || "provider"}
onChange={(e) => setSearchEngine((e.target as HTMLSelectElement).value)}
>
<option value="provider">{t("llm.web_search_provider_default")}</option>
<option value="tavily">Tavily</option>
<option value="searxng">SearXNG</option>
</select>
</OptionsRow>

{searchEngine === "tavily" && (
<OptionsRow name="tavily-api-key" label={t("llm.tavily_api_key")} description={t("llm.tavily_api_key_description")}>
<input
type="password"
className="form-control"
value={tavilyApiKey || ""}
onChange={(e) => setTavilyApiKey((e.target as HTMLInputElement).value)}
placeholder="tvly-..."
/>
</OptionsRow>
)}

{searchEngine === "searxng" && (
<OptionsRow name="searxng-url" label={t("llm.searxng_url")} description={t("llm.searxng_url_description")}>
<input
type="url"
className="form-control"
value={searxngUrl || ""}
onChange={(e) => setSearxngUrl((e.target as HTMLInputElement).value)}
placeholder="http://localhost:8888"
/>
</OptionsRow>
)}

<OptionsRow name="search-timeout" label={t("llm.search_timeout")} description={t("llm.search_timeout_description")}>
<input
type="number"
className="form-control"
min="1"
max="120"
value={searchTimeout || "15"}
onChange={(e) => setSearchTimeout((e.target as HTMLInputElement).value)}
/>
</OptionsRow>
</OptionsSection>
);
}

function McpSettings() {
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
const endpointUrl = useMemo(() => getMcpEndpointUrl(), []);
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/routes/api/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
// LLM options
"llmProviders",
"mcpEnabled",
"llmWebSearchEngine",
"llmTavilyApiKey",
"llmSearxngUrl",
"llmSearchTimeout",
// OCR options
"ocrAutoProcessImages",
"ocrMinConfidence"
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/services/llm/providers/base_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Comment thread
Kureii marked this conversation as resolved.
}

if (config.enableNoteTools) {
Expand Down
130 changes: 130 additions & 0 deletions apps/server/src/services/llm/web_search_tools.ts
Original file line number Diff line number Diff line change
@@ -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)}` };
}
}
});
}
4 changes: 4 additions & 0 deletions apps/server/src/services/options_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
8 changes: 8 additions & 0 deletions packages/commons/src/lib/options_interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
llmProviders: string;
/** Whether the MCP (Model Context Protocol) server endpoint is enabled. */
mcpEnabled: boolean;
/** Web search engine for the LLM agent: "provider" | "tavily" | "searxng" */
llmWebSearchEngine: string;
/** Tavily API key for web search */
llmTavilyApiKey: string;
/** SearXNG instance URL for web search */
llmSearxngUrl: string;
/** Timeout in seconds for web search requests */
llmSearchTimeout: string;

// OCR options
ocrEnabled: boolean;
Expand Down
Loading