Skip to content

Commit ddd1669

Browse files
committed
feat: Implement SearchXNG as alternative search backend
- Add SearchXNG search function in search tool - Update types for SearchXNG results - Add configuration options in README for SearchXNG setup
1 parent f68855d commit ddd1669

8 files changed

+2650
-27
lines changed

.env.local.example

+7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ LOCAL_REDIS_URL=redis://localhost:6379 # or redis://redis:6379 if you're using d
1515
UPSTASH_REDIS_REST_URL=[YOUR_UPSTASH_REDIS_REST_URL]
1616
UPSTASH_REDIS_REST_TOKEN=[YOUR_UPSTASH_REDIS_REST_TOKEN]
1717

18+
SEARCHXNG_API_URL=http://localhost:8080 # Replace with your local SearchXNG API URL or docker http://searchxng:8080
19+
SEARCH_API=searchxng # use searchxng, tavily or exa
20+
SEARXNG_SECRET="" # generate a secret key e.g. openssl rand -base64 32
21+
SEARXNG_PORT=8080 # default port
22+
SEARXNG_BIND_ADDRESS=0.0.0.0 # default address
23+
SEARXNG_IMAGE_PROXY=true # enable image proxy
24+
SEARXNG_LIMITER=false # can be enabled to limit the number of requests per IP address
1825

1926
# Optional
2027
# The settings below can be used optionally as needed.

README.md

+47-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ An AI-powered search engine with a generative UI.
3737
- App framework: [Next.js](https://nextjs.org/)
3838
- Text streaming / Generative UI: [Vercel AI SDK](https://sdk.vercel.ai/docs)
3939
- Generative Model: [OpenAI](https://openai.com/)
40-
- Search API: [Tavily AI](https://tavily.com/) / [Serper](https://serper.dev)
40+
- Search API: [Tavily AI](https://tavily.com/) / [Serper](https://serper.dev) / [SearchXNG](https://docs.searxng.org/)
4141
- Reader API: [Jina AI](https://jina.ai/)
4242
- Serverless Database: [Upstash](https://upstash.com/)
4343
- Component library: [shadcn/ui](https://ui.shadcn.com/)
@@ -146,6 +146,52 @@ If you want to use Morphic as a search engine in your browser, follow these step
146146

147147
This will allow you to use Morphic as your default search engine in the browser.
148148

149+
### Using SearchXNG as an Alternative Search Backend
150+
151+
Morphic now supports SearchXNG as an alternative search backend. To use SearchXNG:
152+
153+
1. Ensure you have Docker and Docker Compose installed on your system.
154+
2. In your `.env.local` file, set the following variables:
155+
156+
- SEARCHXNG_API_URL=http://redis:8080
157+
- SEARXNG_SECRET=your_secret_key_here
158+
- SEARXNG_PORT=8080
159+
- SEARXNG_IMAGE_PROXY=true
160+
- SEARCH_API=searchxng
161+
- SEARXNG_LIMITER=false # can be enabled to limit the number of requests per IP
162+
- SEARCH_API=searchxng
163+
164+
3. Two configuration files are provided in the root directory:
165+
- `searxng-settings.yml`: This file contains the main configuration for SearchXNG, including engine settings and server options.
166+
- `searxng-limiter.toml`: This file configures the rate limiting and bot detection features of SearchXNG.
167+
168+
4. Run `docker-compose up` to start the Morphic stack with SearchXNG included.
169+
5. SearchXNG will be available at `http://localhost:8080` and Morphic will use it as the search backend.
170+
171+
#### Customizing SearchXNG
172+
173+
* You can modify `searxng-settings.yml` to enable/disable specific search engines, change UI settings, or adjust server options.
174+
* The `searxng-limiter.toml` file allows you to configure rate limiting and bot detection. This is useful if you're exposing SearchXNG directly to the internet.
175+
* If you prefer not to use external configuration files, you can set these options using environment variables in the `docker-compose.yml` file or directly in the SearchXNG container.
176+
177+
#### Advanced Configuration
178+
179+
* To disable the limiter entirely, set `LIMITER=false` in the SearchXNG service environment variables.
180+
* For production use, consider adjusting the `SEARXNG_SECRET_KEY` to a secure, randomly generated value.
181+
* The `SEARXNG_IMAGE_PROXY` option allows SearchXNG to proxy image results, enhancing privacy. Set to `true` to enable this feature.
182+
183+
#### Troubleshooting
184+
185+
* If you encounter issues with specific search engines (e.g., Wikidata), you can disable them in `searxng-settings.yml`:
186+
187+
```yaml
188+
engines:
189+
- name: wikidata
190+
disabled: true
191+
```
192+
193+
* refer to https://docs.searxng.org/admin/settings/settings.html#settings-yml
194+
149195
## ✅ Verified models
150196
151197
### List of models applicable to all:

bun.lockb

0 Bytes
Binary file not shown.

docker-compose.yaml

+13-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ services:
1212
- "3000:3000" # Maps port 3000 on the host to port 3000 in the container.
1313
depends_on:
1414
- redis
15+
- searchxng
1516

1617
redis:
1718
image: redis:alpine
@@ -21,5 +22,16 @@ services:
2122
- redis_data:/data
2223
command: redis-server --appendonly yes
2324

25+
searchxng:
26+
image: searxng/searxng
27+
ports:
28+
- "${SEARXNG_PORT:-8080}:8080"
29+
env_file: .env.local # can remove if you want to use env variables or in settings.yml
30+
volumes:
31+
- ./searxng-limiter.toml:/etc/searxng/limiter.toml
32+
- ./searxng-settings.yml:/etc/searxng/settings.yml
33+
- searchxng_data:/data
34+
2435
volumes:
25-
redis_data:
36+
redis_data:
37+
searchxng_data:

lib/agents/tools/search.tsx

+111-25
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { searchSchema } from '@/lib/schema/search'
55
import { SearchSection } from '@/components/search-section'
66
import { ToolProps } from '.'
77
import { sanitizeUrl } from '@/lib/utils'
8-
import { SearchResults } from '@/lib/types'
8+
import { SearchResults, SearchResultItem, SearchXNGResponse, SearchXNGResult } from '@/lib/types'
99

1010
export const searchTool = ({ uiStream, fullResponse }: ToolProps) =>
1111
tool({
@@ -28,36 +28,35 @@ export const searchTool = ({ uiStream, fullResponse }: ToolProps) =>
2828
/>
2929
)
3030

31-
// Tavily API requires a minimum of 5 characters in the query
31+
// Ensure minimum query length for all APIs
3232
const filledQuery =
3333
query.length < 5 ? query + ' '.repeat(5 - query.length) : query
34-
let searchResult
35-
const searchAPI: 'tavily' | 'exa' = 'tavily'
34+
let searchResult: SearchResults
35+
const searchAPI = (process.env.SEARCH_API as 'tavily' | 'exa' | 'searchxng') || 'tavily'
36+
console.log(`Using search API: ${searchAPI}`)
37+
3638
try {
37-
searchResult =
39+
searchResult = await (
3840
searchAPI === 'tavily'
39-
? await tavilySearch(
40-
filledQuery,
41-
max_results,
42-
search_depth,
43-
include_domains,
44-
exclude_domains
45-
)
46-
: await exaSearch(query)
41+
? tavilySearch
42+
: searchAPI === 'exa'
43+
? exaSearch
44+
: searchXNGSearch
45+
)(filledQuery, max_results, search_depth, include_domains, exclude_domains)
4746
} catch (error) {
4847
console.error('Search API error:', error)
4948
hasError = true
49+
searchResult = { results: [], query: filledQuery, images: [], number_of_results: 0 }
5050
}
5151

5252
if (hasError) {
53-
fullResponse = `An error occurred while searching for "${query}.`
53+
fullResponse = `An error occurred while searching for "${filledQuery}".`
5454
uiStream.update(null)
5555
streamResults.done()
5656
return searchResult
5757
}
5858

5959
streamResults.done(JSON.stringify(searchResult))
60-
6160
return searchResult
6261
}
6362
})
@@ -68,8 +67,12 @@ async function tavilySearch(
6867
searchDepth: 'basic' | 'advanced' = 'basic',
6968
includeDomains: string[] = [],
7069
excludeDomains: string[] = []
71-
): Promise<any> {
70+
): Promise<SearchResults> {
7271
const apiKey = process.env.TAVILY_API_KEY
72+
if (!apiKey) {
73+
throw new Error('TAVILY_API_KEY is not set in the environment variables')
74+
}
75+
7376
const response = await fetch('https://api.tavily.com/search', {
7477
method: 'POST',
7578
headers: {
@@ -78,7 +81,7 @@ async function tavilySearch(
7881
body: JSON.stringify({
7982
api_key: apiKey,
8083
query,
81-
max_results: maxResults < 5 ? 5 : maxResults,
84+
max_results: Math.max(maxResults, 5),
8285
search_depth: searchDepth,
8386
include_images: true,
8487
include_answers: true,
@@ -88,31 +91,114 @@ async function tavilySearch(
8891
})
8992

9093
if (!response.ok) {
91-
throw new Error(`Error: ${response.status}`)
94+
throw new Error(`Tavily API error: ${response.status} ${response.statusText}`)
9295
}
9396

94-
// sanitize the image urls
9597
const data = await response.json()
96-
const sanitizedData: SearchResults = {
98+
return {
9799
...data,
98-
images: data.images.map((url: any) => sanitizeUrl(url))
100+
images: data.images.map((url: string) => sanitizeUrl(url))
99101
}
100-
101-
return sanitizedData
102102
}
103103

104104
async function exaSearch(
105105
query: string,
106106
maxResults: number = 10,
107+
_searchDepth: string,
107108
includeDomains: string[] = [],
108109
excludeDomains: string[] = []
109-
): Promise<any> {
110+
): Promise<SearchResults> {
110111
const apiKey = process.env.EXA_API_KEY
112+
if (!apiKey) {
113+
throw new Error('EXA_API_KEY is not set in the environment variables')
114+
}
115+
111116
const exa = new Exa(apiKey)
112-
return exa.searchAndContents(query, {
117+
const exaResults = await exa.searchAndContents(query, {
113118
highlights: true,
114119
numResults: maxResults,
115120
includeDomains,
116121
excludeDomains
117122
})
123+
124+
return {
125+
results: exaResults.results.map((result: any) => ({
126+
title: result.title,
127+
url: result.url,
128+
content: result.highlight || result.text
129+
})),
130+
query,
131+
images: [],
132+
number_of_results: exaResults.results.length
133+
}
118134
}
135+
136+
async function searchXNGSearch(
137+
query: string,
138+
maxResults: number = 10,
139+
_searchDepth: string,
140+
includeDomains: string[] = [],
141+
excludeDomains: string[] = []
142+
): Promise<SearchResults> {
143+
const apiUrl = process.env.SEARCHXNG_API_URL
144+
if (!apiUrl) {
145+
throw new Error('SEARCHXNG_API_URL is not set in the environment variables')
146+
}
147+
148+
try {
149+
// Construct the URL with query parameters
150+
const url = new URL(`${apiUrl}/search`)
151+
url.searchParams.append('q', query)
152+
url.searchParams.append('format', 'json')
153+
url.searchParams.append('max_results', maxResults.toString())
154+
// Enable both general and image results
155+
url.searchParams.append('categories', 'general,images')
156+
// Add domain filters if specified
157+
if (includeDomains.length > 0) {
158+
url.searchParams.append('include_domains', includeDomains.join(','))
159+
}
160+
if (excludeDomains.length > 0) {
161+
url.searchParams.append('exclude_domains', excludeDomains.join(','))
162+
}
163+
// Fetch results from SearchXNG
164+
const response = await fetch(url.toString(), {
165+
method: 'GET',
166+
headers: {
167+
'Accept': 'application/json'
168+
}
169+
})
170+
171+
if (!response.ok) {
172+
const errorText = await response.text()
173+
console.error(`SearchXNG API error (${response.status}):`, errorText)
174+
throw new Error(`SearchXNG API error: ${response.status} ${response.statusText} - ${errorText}`)
175+
}
176+
177+
const data: SearchXNGResponse = await response.json()
178+
//console.log('SearchXNG API response:', JSON.stringify(data, null, 2))
179+
180+
// Separate general results and image results
181+
const generalResults = data.results.filter(result => !result.img_src)
182+
const imageResults = data.results.filter(result => result.img_src)
183+
184+
// Format the results to match the expected SearchResults structure
185+
return {
186+
results: generalResults.map((result: SearchXNGResult): SearchResultItem => ({
187+
title: result.title,
188+
url: result.url,
189+
content: result.content
190+
})),
191+
query: data.query,
192+
images: imageResults.map(result => {
193+
const imgSrc = result.img_src || '';
194+
// If image_proxy is disabled, img_src should always be a full URL
195+
// If it's enabled, it might be a relative URL
196+
return imgSrc.startsWith('http') ? imgSrc : `${apiUrl}${imgSrc}`
197+
}).filter(Boolean), // Remove any empty strings
198+
number_of_results: data.number_of_results
199+
}
200+
} catch (error) {
201+
console.error('SearchXNG API error:', error)
202+
throw error
203+
}
204+
}

lib/types/index.ts

+14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type SearchResults = {
22
images: string[]
33
results: SearchResultItem[]
44
query: string
5+
number_of_results?: number
56
}
67

78
export type ExaSearchResults = {
@@ -70,3 +71,16 @@ export type AIMessage = {
7071
| 'followup'
7172
| 'end'
7273
}
74+
75+
export interface SearchXNGResult {
76+
title: string;
77+
url: string;
78+
content: string;
79+
img_src?: string;
80+
}
81+
82+
export interface SearchXNGResponse {
83+
query: string;
84+
number_of_results: number;
85+
results: SearchXNGResult[];
86+
}

searxng-limiter.toml

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
#https://docs.searxng.org/admin/searx.limiter.html

0 commit comments

Comments
 (0)