Skip to content
Merged
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
25 changes: 24 additions & 1 deletion src/components/settings/sections/web-search-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ import {
import { SEARXNG_CATEGORY_OPTIONS, SERPAPI_ENGINE_OPTIONS, resolveSearchConfig } from "@/lib/web-search"

const SEARCH_PROVIDERS = [
{
id: "ollama",
label: "Ollama (Local)",
hint: "Free web search via your local Ollama instance — no API key required",
urlPlaceholder: "http://localhost:11434",
needsApiKey: false,
isLocal: true,
},
{
id: "tavily",
label: "Tavily",
Expand Down Expand Up @@ -82,6 +90,8 @@ export function WebSearchSection() {
const isActive = resolvedConfig.provider === provider.id
const hasConfig = provider.id === "searxng"
? !!override?.searXngUrl
: provider.id === "ollama"
? !!override?.ollamaUrl
: !!override?.apiKey
const isExpanded = !!expanded[provider.id]
return (
Expand Down Expand Up @@ -149,7 +159,20 @@ export function WebSearchSection() {

{isExpanded && (
<div className="space-y-4 border-t bg-background/50 px-4 py-3">
{provider.needsApiKey ? (
{"isLocal" in provider && provider.isLocal ? (
// Ollama: URL field without API key
<div className="space-y-2">
<Label>{t("settings.sections.webSearch.instanceUrl")}</Label>
<Input
value={override?.ollamaUrl ?? resolvedConfig.ollamaUrl ?? "http://localhost:11434"}
onChange={(e) => updateProvider("ollama", { ollamaUrl: e.target.value })}
placeholder={provider.urlPlaceholder}
/>
<p className="text-xs text-muted-foreground">
{t("settings.sections.webSearch.ollamaHint")}
</p>
</div>
) : provider.needsApiKey ? (
<div className="space-y-2">
<Label>{t("settings.apiKey")}</Label>
<Input
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@
"activeBadge": "Active",
"savedBadge": "Saved",
"instanceUrl": "Instance URL",
"ollamaHint": "Uses your local Ollama's built-in web search. Make sure Ollama is running and a web-search-capable model is available.",
"searxngJsonHint": "The instance must allow JSON search responses (`format=json`).",
"searchCategories": "Search categories",
"searxngCategoriesHint": "Categories are sent as the SearXNG `categories` parameter.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@
"activeBadge": "活跃",
"savedBadge": "已保存",
"instanceUrl": "实例 URL",
"ollamaHint": "使用本地 Ollama 内置的网页搜索功能。请确保 Ollama 正在运行,并且有支持网页搜索的模型可用。",
"searxngJsonHint": "实例必须允许 JSON 搜索响应(`format=json`)。",
"searchCategories": "搜索分类",
"searxngCategoriesHint": "分类会作为 SearXNG 的 `categories` 参数发送。",
Expand Down
84 changes: 82 additions & 2 deletions src/lib/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const SEARXNG_CATEGORY_OPTIONS: { value: SearXngCategory; label: string;

export function resolveSearchConfig(config: SearchApiConfig): SearchApiConfig {
const providerConfigs: SearchProviderConfigs = config.providerConfigs ?? {
...(config.provider !== "none" && config.apiKey
...(config.provider !== "none" && config.provider !== "ollama" && config.apiKey
? {
[config.provider]: {
apiKey: config.apiKey,
Expand All @@ -59,6 +59,13 @@ export function resolveSearchConfig(config: SearchApiConfig): SearchApiConfig {
},
}
: {}),
...(config.provider === "ollama" && config.ollamaUrl
? {
ollama: {
ollamaUrl: config.ollamaUrl,
},
}
: {}),
}

const activeProvider = config.provider as SearchProvider
Expand All @@ -70,6 +77,7 @@ export function resolveSearchConfig(config: SearchApiConfig): SearchApiConfig {
serpApiEngine: config.serpApiEngine ?? providerConfigs.serpapi?.serpApiEngine ?? "google",
searXngUrl: config.searXngUrl ?? providerConfigs.searxng?.searXngUrl ?? "",
searXngCategories: config.searXngCategories ?? providerConfigs.searxng?.searXngCategories ?? ["general"],
ollamaUrl: config.ollamaUrl ?? providerConfigs.ollama?.ollamaUrl ?? "http://localhost:11434",
providerConfigs,
}
}
Expand All @@ -82,6 +90,7 @@ export function resolveSearchConfig(config: SearchApiConfig): SearchApiConfig {
serpApiEngine: activeOverride?.serpApiEngine ?? config.serpApiEngine ?? "google",
searXngUrl: activeOverride?.searXngUrl ?? config.searXngUrl ?? "",
searXngCategories: activeOverride?.searXngCategories ?? config.searXngCategories ?? ["general"],
ollamaUrl: activeOverride?.ollamaUrl ?? config.ollamaUrl ?? "http://localhost:11434",
providerConfigs,
}
}
Expand All @@ -92,6 +101,9 @@ export function hasConfiguredSearchProvider(config: SearchApiConfig): boolean {
if (resolved.provider === "searxng") {
return Boolean(resolved.searXngUrl?.trim())
}
if (resolved.provider === "ollama") {
return true // Ollama uses default localhost URL if not configured
}
return Boolean(resolved.apiKey?.trim())
}

Expand All @@ -105,7 +117,7 @@ export async function webSearch(
throw new Error("Web search not configured. Select a search provider in Settings.")
}
if ((resolved.provider === "tavily" || resolved.provider === "serpapi") && !resolved.apiKey) {
throw new Error("Web search not configured. Add a Tavily or SerpApi API key in Settings.")
throw new Error("Web search not configured. Add a Tavily or SerpApi API key in Settings, or select a different provider.")
}
if (resolved.provider === "searxng" && !resolved.searXngUrl?.trim()) {
throw new Error("Web search not configured. Add a SearXNG instance URL in Settings.")
Expand All @@ -118,6 +130,8 @@ export async function webSearch(
return serpApiSearch(query, resolved.apiKey, maxResults, resolved.serpApiEngine ?? "google")
case "searxng":
return searXngSearch(query, resolved.searXngUrl ?? "", maxResults, resolved.searXngCategories ?? ["general"])
case "ollama":
return ollamaSearch(query, resolved.ollamaUrl ?? "http://localhost:11434", maxResults)
default:
throw new Error(`Unknown search provider: ${resolved.provider}`)
}
Expand Down Expand Up @@ -341,3 +355,69 @@ function normalizeSerpApiResult(item: unknown): WebSearchResult {
source: hostnameFromUrl(url) || r.source || r.displayed_link || "",
}
}

interface OllamaSearchResponse {
results?: Array<{
title?: string
url?: string
content?: string
}>
error?: string
}

async function ollamaSearch(
query: string,
ollamaUrl: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const baseUrl = ollamaUrl.replace(/\/+$/, "")
const searchUrl = `${baseUrl}/api/experimental/web_search`

const httpFetch = await getHttpFetch()
let response: Response
try {
response = await httpFetch(searchUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
max_results: maxResults,
}),
})
} catch (err) {
if (isFetchNetworkError(err)) {
throw new Error(
`Network error reaching Ollama at ${baseUrl}. Make sure Ollama is running and web search is enabled.`,
)
}
throw err
}

if (!response.ok) {
if (response.status === 401) {
throw new Error(
"Ollama requires authentication. Run `ollama signin` in your terminal to authenticate.",
)
}
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(`Ollama web search failed (${response.status}): ${errorText}`)
}

const data = (await response.json()) as OllamaSearchResponse

if (data.error) {
throw new Error(`Ollama web search error: ${data.error}`)
}

return (data.results ?? [])
.slice(0, maxResults)
.map((r) => {
const url = r.url ?? ""
return {
title: r.title ?? "Untitled",
url,
snippet: r.content ?? "",
source: hostnameFromUrl(url),
}
})
}
4 changes: 3 additions & 1 deletion src/stores/wiki-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface LlmConfig {
reasoning?: ReasoningConfig
}

export type SearchProvider = "tavily" | "serpapi" | "searxng" | "none"
export type SearchProvider = "tavily" | "serpapi" | "searxng" | "ollama" | "none"
export type SerpApiEngine =
| "google"
| "google_news"
Expand Down Expand Up @@ -57,6 +57,7 @@ export interface SearchProviderOverride {
serpApiEngine?: SerpApiEngine
searXngUrl?: string
searXngCategories?: SearXngCategory[]
ollamaUrl?: string
}

export type SearchProviderConfigs = Partial<Record<Exclude<SearchProvider, "none">, SearchProviderOverride>>
Expand All @@ -67,6 +68,7 @@ interface SearchApiConfig {
serpApiEngine?: SerpApiEngine
searXngUrl?: string
searXngCategories?: SearXngCategory[]
ollamaUrl?: string
providerConfigs?: SearchProviderConfigs
}

Expand Down
Loading