Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2453,6 +2453,16 @@
"api_key": "API Key",
"api_key_placeholder": "Enter your API key",
"cancel": "Cancel",
"search_provider_title": "Web Search Providers",
"search_provider_description": "Configure third-party web search engines (e.g. Exa, Tavily, SearXNG). When any provider is configured, the AI agent uses it for web searches with every model. When empty, the AI agent falls back to each LLM provider's built-in web search (Anthropic, OpenAI, Google).",
"add_search_provider": "Add Search Provider",
"add_search_provider_title": "Add Search Provider",
"configured_search_providers": "Configured Search Providers",
"no_search_providers_configured": "No search providers configured. Falling back to built-in web search.",
"delete_search_provider": "Delete",
"delete_search_provider_confirmation": "Are you sure you want to delete the search provider \"{{name}}\"?",
"search_provider_type": "Search provider",
"search_provider_base_url": "Base URL",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "MCP server",
"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.",
Expand Down
98 changes: 98 additions & 0 deletions apps/client/src/widgets/type_widgets/options/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsRow, { OptionsRowWithToggle } from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
import AddSearchProviderModal, { type SearchProviderConfig, SEARCH_PROVIDER_TYPES } from "./llm/AddSearchProviderModal";

export default function LlmSettings() {
if (!isExperimentalFeatureEnabled("llm")) {
Expand All @@ -22,6 +23,7 @@ export default function LlmSettings() {
return (
<>
<ProviderSettings />
<SearchProviderSettings />
<McpSettings />
</>
);
Expand Down Expand Up @@ -80,6 +82,102 @@ function ProviderSettings() {
);
}

function SearchProviderSettings() {
const [providersJson, setProvidersJson] = useTriliumOption("searchProviders");
const providers = useMemo<SearchProviderConfig[]>(() => {
try {
return providersJson ? JSON.parse(providersJson) : [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the server-side registry, the parsed JSON should be validated as an array. If the option contains a non-array value, the useMemo hook will return a value that causes providers.filter or providers.map to crash the UI.

            const parsed = providersJson ? JSON.parse(providersJson) : [];
            return Array.isArray(parsed) ? parsed : [];

} catch {
return [];
}
}, [providersJson]);
const setProviders = useCallback((newProviders: SearchProviderConfig[]) => {
setProvidersJson(JSON.stringify(newProviders));
}, [setProvidersJson]);
const [showAddModal, setShowAddModal] = useState(false);

const handleAddProvider = useCallback((newProvider: SearchProviderConfig) => {
setProviders([...providers, newProvider]);
}, [providers, setProviders]);

const handleDeleteProvider = useCallback(async (providerId: string, providerName: string) => {
if (!(await dialog.confirm(t("llm.delete_search_provider_confirmation", { name: providerName })))) {
return;
}
setProviders(providers.filter(p => p.id !== providerId));
}, [providers, setProviders]);

return (
<OptionsSection title={t("llm.search_provider_title")}>
<p className="form-text">{t("llm.search_provider_description")}</p>

<Button
size="small"
icon="bx bx-plus"
text={t("llm.add_search_provider")}
onClick={() => setShowAddModal(true)}
/>

<hr />

<h5>{t("llm.configured_search_providers")}</h5>
<SearchProviderList
providers={providers}
onDelete={handleDeleteProvider}
/>

<AddSearchProviderModal
show={showAddModal}
onHidden={() => setShowAddModal(false)}
onSave={handleAddProvider}
/>
</OptionsSection>
);
}

interface SearchProviderListProps {
providers: SearchProviderConfig[];
onDelete: (providerId: string, providerName: string) => Promise<void>;
}

function SearchProviderList({ providers, onDelete }: SearchProviderListProps) {
if (!providers.length) {
return <div>{t("llm.no_search_providers_configured")}</div>;
}

return (
<div style={{ overflow: "auto" }}>
<table className="table table-stripped">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Typo in Bootstrap class name: table-stripped should be table-striped.

Suggested change
<table className="table table-stripped">
<table className="table table-striped">

<thead>
<tr>
<th>{t("llm.provider_name")}</th>
<th>{t("llm.provider_type")}</th>
<th>{t("llm.actions")}</th>
</tr>
</thead>
<tbody>
{providers.map((provider) => {
const providerType = SEARCH_PROVIDER_TYPES.find(p => p.id === provider.provider);
return (
<tr key={provider.id}>
<td>{provider.name}</td>
<td>{providerType?.name || provider.provider}</td>
<td>
<ActionButton
icon="bx bx-trash"
text={t("llm.delete_search_provider")}
onClick={() => onDelete(provider.id, provider.name)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

function getMcpEndpointUrl() {
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
return `${window.location.protocol}//localhost:${port}/mcp`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createPortal } from "preact/compat";
import { useRef, useState } from "preact/hooks";

import { t } from "../../../../services/i18n";
import FormGroup from "../../../react/FormGroup";
import FormSelect from "../../../react/FormSelect";
import FormTextBox from "../../../react/FormTextBox";
import Modal from "../../../react/Modal";

export interface SearchProviderConfig {
id: string;
name: string;
provider: string;
apiKey?: string;
baseUrl?: string;
}

export interface SearchProviderType {
id: string;
name: string;
/** Whether this provider requires an API key. */
requiresApiKey: boolean;
/** Whether this provider requires a base URL (e.g. self-hosted). */
requiresBaseUrl: boolean;
apiKeyPlaceholder?: string;
baseUrlPlaceholder?: string;
}

export const SEARCH_PROVIDER_TYPES: SearchProviderType[] = [
{
id: "exa",
name: "Exa",
requiresApiKey: true,
requiresBaseUrl: false,
apiKeyPlaceholder: "..."
},
{
id: "tavily",
name: "Tavily",
requiresApiKey: true,
requiresBaseUrl: false,
apiKeyPlaceholder: "tvly-..."
},
{
id: "searxng",
name: "SearXNG",
requiresApiKey: false,
requiresBaseUrl: true,
baseUrlPlaceholder: "http://localhost:8888"
}
];

interface AddSearchProviderModalProps {
show: boolean;
onHidden: () => void;
onSave: (provider: SearchProviderConfig) => void;
}

export default function AddSearchProviderModal({ show, onHidden, onSave }: AddSearchProviderModalProps) {
const [selectedProvider, setSelectedProvider] = useState(SEARCH_PROVIDER_TYPES[0].id);
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const formRef = useRef<HTMLFormElement>(null);

const providerType = SEARCH_PROVIDER_TYPES.find(p => p.id === selectedProvider) ?? SEARCH_PROVIDER_TYPES[0];
const canSubmit =
(!providerType.requiresApiKey || apiKey.trim().length > 0) &&
(!providerType.requiresBaseUrl || baseUrl.trim().length > 0);

function handleSubmit() {
if (!canSubmit) {
return;
}

const newProvider: SearchProviderConfig = {
id: `${selectedProvider}_${Date.now()}`,
name: providerType.name,
provider: selectedProvider,
...(providerType.requiresApiKey && { apiKey: apiKey.trim() }),
...(providerType.requiresBaseUrl && { baseUrl: baseUrl.trim() })
};

onSave(newProvider);
resetForm();
onHidden();
}

function resetForm() {
setSelectedProvider(SEARCH_PROVIDER_TYPES[0].id);
setApiKey("");
setBaseUrl("");
}

function handleCancel() {
resetForm();
onHidden();
}

return createPortal(
<Modal
show={show}
onHidden={handleCancel}
onSubmit={handleSubmit}
formRef={formRef}
title={t("llm.add_search_provider_title")}
className="add-search-provider-modal"
size="md"
footer={
<>
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
{t("llm.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={!canSubmit}>
{t("llm.add_search_provider")}
</button>
</>
}
>
<FormGroup name="search-provider-type" label={t("llm.search_provider_type")}>
<FormSelect
values={SEARCH_PROVIDER_TYPES}
keyProperty="id"
titleProperty="name"
currentValue={selectedProvider}
onChange={setSelectedProvider}
/>
</FormGroup>

{providerType.requiresApiKey && (
<FormGroup name="search-api-key" label={t("llm.api_key")}>
<FormTextBox
type="password"
currentValue={apiKey}
onChange={setApiKey}
placeholder={providerType.apiKeyPlaceholder ?? t("llm.api_key_placeholder")}
autoFocus
/>
</FormGroup>
)}

{providerType.requiresBaseUrl && (
<FormGroup name="search-base-url" label={t("llm.search_provider_base_url")}>
<FormTextBox
type="url"
currentValue={baseUrl}
onChange={setBaseUrl}
placeholder={providerType.baseUrlPlaceholder ?? ""}
autoFocus
/>
</FormGroup>
)}
</Modal>,
document.body
);
}
1 change: 1 addition & 0 deletions apps/server/src/routes/api/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
// LLM options
"llmProviders",
"mcpEnabled",
"searchProviders",
// OCR options
"ocrAutoProcessImages",
"ocrMinConfidence"
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/services/llm/providers/base_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { generateText, type ModelMessage, stepCountIs, streamText, type ToolSet
import yaml from "js-yaml";

import becca from "../../../becca/becca.js";
import { getFirstSearchProvider } from "../../search_providers/index.js";
import { addConfiguredSearchTool } from "../../search_providers/tool.js";
import { getSkillsSummary } from "../skills/index.js";
import { getNoteMeta,SYSTEM_PROMPT_LIMITS } from "../tools/helpers.js";
import { allToolRegistries } from "../tools/index.js";
Expand Down Expand Up @@ -157,7 +159,14 @@ export abstract class BaseProvider implements LlmProvider {
const tools: ToolSet = {};

if (config.enableWebSearch) {
this.addWebSearchTool(tools);
// Prefer a user-configured pluggable search provider (Exa/Tavily/SearXNG/…);
// otherwise fall back to each LLM provider's built-in web search.
const configuredSearch = getFirstSearchProvider();
if (configuredSearch) {
addConfiguredSearchTool(tools, configuredSearch);
} else {
this.addWebSearchTool(tools);
}
}

if (config.enableNoteTools) {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/services/options_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ const defaultOptions: DefaultOption[] = [
// AI / LLM
{ name: "llmProviders", value: "[]", isSynced: true },
{ name: "mcpEnabled", value: "false", isSynced: false },
{ name: "searchProviders", value: "[]", isSynced: true },

// OCR options
{ name: "ocrAutoProcessImages", value: "false", isSynced: true },
Expand Down
60 changes: 60 additions & 0 deletions apps/server/src/services/search_providers/base_search_provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Shared interface and types for pluggable web search providers used by the LLM agent.
*
* Each search provider implementation wraps a third-party search API and returns a
* unified {@link SearchResult} array so the LLM tool layer can remain provider-agnostic.
*/

/** Normalised search result returned by all providers. */
export interface SearchResult {
title: string;
url: string;
/** Short extract of the page (provider-chosen: highlights, summary or truncated body). */
snippet: string;
publishedDate?: string;
author?: string;
}

/** Optional search parameters understood by all providers. Providers ignore unsupported fields. */
export interface SearchOptions {
numResults?: number;
includeDomains?: string[];
excludeDomains?: string[];
/** ISO-8601 date, e.g. "2025-01-01T00:00:00.000Z" */
startPublishedDate?: string;
/** ISO-8601 date */
endPublishedDate?: string;
/** Provider-specific category hint (Exa: company, research paper, news, ...). */
category?: string;
}

/** Implemented by every concrete search provider. */
export interface SearchProvider {
/** Human-readable provider name shown to the LLM and in logs (e.g. "Exa", "Tavily"). */
name: string;
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
}

/**
* User-supplied configuration for one search-provider instance, stored as JSON in the
* {@code searchProviders} option. Shape mirrors {@code LlmProviderSetup} so the same UI
* patterns (multiple named instances, optional API key and base URL) can be reused.
*/
export interface SearchProviderSetup {
id: string;
name: string;
/** Provider type id, e.g. "exa", "tavily", "searxng". */
provider: string;
/** API key, required by providers like Exa and Tavily. */
apiKey?: string;
/** Custom endpoint, required by providers like SearXNG. */
baseUrl?: string;
}

export const DEFAULT_MAX_RESULTS = 5;
export const DEFAULT_TIMEOUT_MS = 15_000;

export abstract class BaseSearchProvider implements SearchProvider {
abstract name: string;
abstract search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
}
Loading
Loading