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
7 changes: 7 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ COMM_PORT=8058
# FEISHU_WEBHOOK_PORT=9000

# ─── Search API Keys (optional, enables web_search tool) ───────────────────
# Priority: Serper > Tavily > Bing > Google > SerpAPI > Brave > Exa > Bocha > DuckDuckGo (free)
# SERPER_API_KEY=
# TAVILY_API_KEY=
# BING_SEARCH_API_KEY=
# GOOGLE_SEARCH_API_KEY=
# GOOGLE_SEARCH_CX=
# SERPAPI_API_KEY=
# BRAVE_SEARCH_API_KEY=
# EXA_API_KEY=
# BOCHA_API_KEY=

# ─── Security ──────────────────────────────────────────────────────────────
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,27 @@ async function startServer(config: ReturnType<typeof loadConfig>, values: Record
if (config.integrations?.search?.serperApiKey && !process.env['SERPER_API_KEY']) {
process.env['SERPER_API_KEY'] = config.integrations.search.serperApiKey;
}
if (config.integrations?.search?.tavilyApiKey && !process.env['TAVILY_API_KEY']) {
process.env['TAVILY_API_KEY'] = config.integrations.search.tavilyApiKey;
}
if (config.integrations?.search?.bingApiKey && !process.env['BING_SEARCH_API_KEY']) {
process.env['BING_SEARCH_API_KEY'] = config.integrations.search.bingApiKey;
}
if (config.integrations?.search?.googleSearchApiKey && !process.env['GOOGLE_SEARCH_API_KEY']) {
process.env['GOOGLE_SEARCH_API_KEY'] = config.integrations.search.googleSearchApiKey;
}
if (config.integrations?.search?.googleSearchCx && !process.env['GOOGLE_SEARCH_CX']) {
process.env['GOOGLE_SEARCH_CX'] = config.integrations.search.googleSearchCx;
}
if (config.integrations?.search?.serpApiKey && !process.env['SERPAPI_API_KEY']) {
process.env['SERPAPI_API_KEY'] = config.integrations.search.serpApiKey;
}
if (config.integrations?.search?.braveApiKey && !process.env['BRAVE_SEARCH_API_KEY']) {
process.env['BRAVE_SEARCH_API_KEY'] = config.integrations.search.braveApiKey;
}
if (config.integrations?.search?.exaApiKey && !process.env['EXA_API_KEY']) {
process.env['EXA_API_KEY'] = config.integrations.search.exaApiKey;
}
if (config.integrations?.search?.bochaApiKey && !process.env['BOCHA_API_KEY']) {
process.env['BOCHA_API_KEY'] = config.integrations.search.bochaApiKey;
}
Expand Down
182 changes: 181 additions & 1 deletion packages/core/src/tools/web-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ async function proxyFetch(url: string | URL, init?: RequestInit): Promise<Respon

/**
* Multi-backend web search tool.
* Priority: Serper (Google) > Brave Search > Bocha > DuckDuckGo Lite/HTML fallback.
* Priority: Serper > Tavily > Bing > Google > SerpAPI > Brave > Exa > Bocha > DuckDuckGo (free fallback).
* API keys are read from environment variables.
*/
export const WebSearchTool: AgentToolHandler = {
Expand Down Expand Up @@ -80,7 +80,12 @@ export const WebSearchTool: AgentToolHandler = {

const backends: Array<{ name: string; fn: typeof searchSerper }> = [
{ name: 'Serper', fn: searchSerper },
{ name: 'Tavily', fn: searchTavily },
{ name: 'Bing', fn: searchBing },
{ name: 'Google', fn: searchGoogle },
{ name: 'SerpAPI', fn: searchSerpApi },
{ name: 'Brave', fn: searchBrave },
{ name: 'Exa', fn: searchExa },
{ name: 'Bocha', fn: searchBocha },
{ name: 'DuckDuckGo', fn: searchDuckDuckGo },
];
Expand Down Expand Up @@ -152,6 +157,142 @@ async function searchSerper(query: string, maxResults: number): Promise<SearchRe
}));
}

// ── Tavily backend ────────────────────────────────────────────────────────

async function searchTavily(query: string, maxResults: number): Promise<SearchResult[]> {
const apiKey = process.env['TAVILY_API_KEY'];
if (!apiKey) throw new Error('TAVILY_API_KEY not configured');

let res: Response;
try {
res = await proxyFetch('https://api.tavily.com/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
query,
max_results: maxResults,
include_answer: false,
}),
});
} catch (err: unknown) {
throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
}

if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);

const data = (await res.json()) as {
results?: Array<{ title: string; url: string; content: string; published_date?: string }>;
};

return (data.results ?? []).slice(0, maxResults).map(r => ({
title: r.title,
url: r.url,
snippet: r.content,
date: r.published_date,
}));
}

// ── Bing Web Search backend ──────────────────────────────────────────────

async function searchBing(query: string, maxResults: number): Promise<SearchResult[]> {
const apiKey = process.env['BING_SEARCH_API_KEY'];
if (!apiKey) throw new Error('BING_SEARCH_API_KEY not configured');

const params = new URLSearchParams({ q: query, count: String(maxResults), mkt: 'en-US' });
let res: Response;
try {
res = await proxyFetch(`https://api.bing.microsoft.com/v7.0/search?${params}`, {
headers: { 'Ocp-Apim-Subscription-Key': apiKey },
});
} catch (err: unknown) {
throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
}

if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);

const data = (await res.json()) as {
webPages?: {
value?: Array<{ name: string; url: string; snippet: string; dateLastCrawled?: string }>;
};
};

return (data.webPages?.value ?? []).slice(0, maxResults).map(r => ({
title: r.name,
url: r.url,
snippet: r.snippet,
date: r.dateLastCrawled,
}));
}

// ── Google Custom Search (Programmable Search Engine) backend ─────────────

async function searchGoogle(query: string, maxResults: number): Promise<SearchResult[]> {
const apiKey = process.env['GOOGLE_SEARCH_API_KEY'];
const cx = process.env['GOOGLE_SEARCH_CX'];
if (!apiKey) throw new Error('GOOGLE_SEARCH_API_KEY not configured');
if (!cx) throw new Error('GOOGLE_SEARCH_CX not configured');

const params = new URLSearchParams({
key: apiKey,
cx,
q: query,
num: String(Math.min(maxResults, 10)),
});
let res: Response;
try {
res = await proxyFetch(`https://www.googleapis.com/customsearch/v1?${params}`);
} catch (err: unknown) {
throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
}

if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);

const data = (await res.json()) as {
items?: Array<{ title: string; link: string; snippet: string; pagemap?: { metatags?: Array<{ 'article:published_time'?: string }> } }>;
};

return (data.items ?? []).slice(0, maxResults).map(r => ({
title: r.title,
url: r.link,
snippet: r.snippet,
date: r.pagemap?.metatags?.[0]?.['article:published_time'],
}));
}

// ── SerpAPI backend ──────────────────────────────────────────────────────

async function searchSerpApi(query: string, maxResults: number): Promise<SearchResult[]> {
const apiKey = process.env['SERPAPI_API_KEY'];
if (!apiKey) throw new Error('SERPAPI_API_KEY not configured');

const params = new URLSearchParams({
api_key: apiKey,
q: query,
engine: 'google',
num: String(maxResults),
});
let res: Response;
try {
res = await proxyFetch(`https://serpapi.com/search.json?${params}`);
} catch (err: unknown) {
throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
}

if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);

const data = (await res.json()) as {
organic_results?: Array<{ title: string; link: string; snippet: string; date?: string }>;
};

return (data.organic_results ?? []).slice(0, maxResults).map(r => ({
title: r.title,
url: r.link,
snippet: r.snippet,
date: r.date,
}));
}

// ── Brave Search backend ───────────────────────────────────────────────────

async function searchBrave(query: string, maxResults: number): Promise<SearchResult[]> {
Expand Down Expand Up @@ -186,6 +327,45 @@ async function searchBrave(query: string, maxResults: number): Promise<SearchRes
}));
}

// ── Exa (AI-native search) backend ──────────────────────────────────────────

async function searchExa(query: string, maxResults: number): Promise<SearchResult[]> {
const apiKey = process.env['EXA_API_KEY'];
if (!apiKey) throw new Error('EXA_API_KEY not configured');

let res: Response;
try {
res = await proxyFetch('https://api.exa.ai/search', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
numResults: maxResults,
type: 'auto',
contents: { text: { maxCharacters: 300 } },
}),
});
} catch (err: unknown) {
throw new Error(`Network error: ${err instanceof Error ? err.message : String(err)}`);
}

if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);

const data = (await res.json()) as {
results?: Array<{ title: string; url: string; text?: string; publishedDate?: string }>;
};

return (data.results ?? []).slice(0, maxResults).map(r => ({
title: r.title,
url: r.url,
snippet: r.text ?? '',
date: r.publishedDate,
}));
}

// ── Bocha (博查) backend ────────────────────────────────────────────────────

async function searchBocha(query: string, maxResults: number): Promise<SearchResult[]> {
Expand Down
40 changes: 40 additions & 0 deletions packages/org-manager/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7306,7 +7306,12 @@ EXPLANATION_END`;
const mask = (key?: string) => key ? '***' + key.slice(-4) : '';
this.json(res, 200, {
serper: { configured: !!search.serperApiKey || !!process.env['SERPER_API_KEY'], preview: mask(search.serperApiKey || process.env['SERPER_API_KEY']) },
tavily: { configured: !!search.tavilyApiKey || !!process.env['TAVILY_API_KEY'], preview: mask(search.tavilyApiKey || process.env['TAVILY_API_KEY']) },
bing: { configured: !!search.bingApiKey || !!process.env['BING_SEARCH_API_KEY'], preview: mask(search.bingApiKey || process.env['BING_SEARCH_API_KEY']) },
google: { configured: !!(search.googleSearchApiKey && search.googleSearchCx) || !!(process.env['GOOGLE_SEARCH_API_KEY'] && process.env['GOOGLE_SEARCH_CX']), preview: mask(search.googleSearchApiKey || process.env['GOOGLE_SEARCH_API_KEY']) },
serpapi: { configured: !!search.serpApiKey || !!process.env['SERPAPI_API_KEY'], preview: mask(search.serpApiKey || process.env['SERPAPI_API_KEY']) },
brave: { configured: !!search.braveApiKey || !!process.env['BRAVE_SEARCH_API_KEY'], preview: mask(search.braveApiKey || process.env['BRAVE_SEARCH_API_KEY']) },
exa: { configured: !!search.exaApiKey || !!process.env['EXA_API_KEY'], preview: mask(search.exaApiKey || process.env['EXA_API_KEY']) },
bocha: { configured: !!search.bochaApiKey || !!process.env['BOCHA_API_KEY'], preview: mask(search.bochaApiKey || process.env['BOCHA_API_KEY']) },
});
return;
Expand All @@ -7322,11 +7327,41 @@ EXPLANATION_END`;
if (body['serperApiKey']) process.env['SERPER_API_KEY'] = body['serperApiKey'] as string;
else delete process.env['SERPER_API_KEY'];
}
if (typeof body['tavilyApiKey'] === 'string') {
updates.tavilyApiKey = body['tavilyApiKey'] || undefined;
if (body['tavilyApiKey']) process.env['TAVILY_API_KEY'] = body['tavilyApiKey'] as string;
else delete process.env['TAVILY_API_KEY'];
}
if (typeof body['bingApiKey'] === 'string') {
updates.bingApiKey = body['bingApiKey'] || undefined;
if (body['bingApiKey']) process.env['BING_SEARCH_API_KEY'] = body['bingApiKey'] as string;
else delete process.env['BING_SEARCH_API_KEY'];
}
if (typeof body['googleSearchApiKey'] === 'string') {
updates.googleSearchApiKey = body['googleSearchApiKey'] || undefined;
if (body['googleSearchApiKey']) process.env['GOOGLE_SEARCH_API_KEY'] = body['googleSearchApiKey'] as string;
else delete process.env['GOOGLE_SEARCH_API_KEY'];
}
if (typeof body['googleSearchCx'] === 'string') {
updates.googleSearchCx = body['googleSearchCx'] || undefined;
if (body['googleSearchCx']) process.env['GOOGLE_SEARCH_CX'] = body['googleSearchCx'] as string;
else delete process.env['GOOGLE_SEARCH_CX'];
}
if (typeof body['serpApiKey'] === 'string') {
updates.serpApiKey = body['serpApiKey'] || undefined;
if (body['serpApiKey']) process.env['SERPAPI_API_KEY'] = body['serpApiKey'] as string;
else delete process.env['SERPAPI_API_KEY'];
}
if (typeof body['braveApiKey'] === 'string') {
updates.braveApiKey = body['braveApiKey'] || undefined;
if (body['braveApiKey']) process.env['BRAVE_SEARCH_API_KEY'] = body['braveApiKey'] as string;
else delete process.env['BRAVE_SEARCH_API_KEY'];
}
if (typeof body['exaApiKey'] === 'string') {
updates.exaApiKey = body['exaApiKey'] || undefined;
if (body['exaApiKey']) process.env['EXA_API_KEY'] = body['exaApiKey'] as string;
else delete process.env['EXA_API_KEY'];
}
if (typeof body['bochaApiKey'] === 'string') {
updates.bochaApiKey = body['bochaApiKey'] || undefined;
if (body['bochaApiKey']) process.env['BOCHA_API_KEY'] = body['bochaApiKey'] as string;
Expand All @@ -7352,7 +7387,12 @@ EXPLANATION_END`;
const mask = (key?: string) => key ? '***' + key.slice(-4) : '';
this.json(res, 200, {
serper: { configured: !!search.serperApiKey || !!process.env['SERPER_API_KEY'], preview: mask(search.serperApiKey || process.env['SERPER_API_KEY']) },
tavily: { configured: !!search.tavilyApiKey || !!process.env['TAVILY_API_KEY'], preview: mask(search.tavilyApiKey || process.env['TAVILY_API_KEY']) },
bing: { configured: !!search.bingApiKey || !!process.env['BING_SEARCH_API_KEY'], preview: mask(search.bingApiKey || process.env['BING_SEARCH_API_KEY']) },
google: { configured: !!(search.googleSearchApiKey && search.googleSearchCx) || !!(process.env['GOOGLE_SEARCH_API_KEY'] && process.env['GOOGLE_SEARCH_CX']), preview: mask(search.googleSearchApiKey || process.env['GOOGLE_SEARCH_API_KEY']) },
serpapi: { configured: !!search.serpApiKey || !!process.env['SERPAPI_API_KEY'], preview: mask(search.serpApiKey || process.env['SERPAPI_API_KEY']) },
brave: { configured: !!search.braveApiKey || !!process.env['BRAVE_SEARCH_API_KEY'], preview: mask(search.braveApiKey || process.env['BRAVE_SEARCH_API_KEY']) },
exa: { configured: !!search.exaApiKey || !!process.env['EXA_API_KEY'], preview: mask(search.exaApiKey || process.env['EXA_API_KEY']) },
bocha: { configured: !!search.bochaApiKey || !!process.env['BOCHA_API_KEY'], preview: mask(search.bochaApiKey || process.env['BOCHA_API_KEY']) },
});
return;
Expand Down
12 changes: 11 additions & 1 deletion packages/shared/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ export interface MarkusConfig {
};
integrations?: {
feishu?: { appId?: string; appSecret?: string };
search?: { serperApiKey?: string; braveApiKey?: string; bochaApiKey?: string };
search?: {
serperApiKey?: string;
tavilyApiKey?: string;
bingApiKey?: string;
googleSearchApiKey?: string;
googleSearchCx?: string;
serpApiKey?: string;
braveApiKey?: string;
exaApiKey?: string;
bochaApiKey?: string;
};
embedding?: { apiKey?: string };
};
database?: {
Expand Down
6 changes: 3 additions & 3 deletions packages/web-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1280,9 +1280,9 @@ export const api = {
}
},
stopConcurrentBrowserTest: () => request<{ ok: boolean }>('/settings/browser/test-concurrent', { method: 'DELETE' }),
getSearch: () => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search'),
updateSearch: (keys: { serperApiKey?: string; braveApiKey?: string; bochaApiKey?: string }) =>
request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search', { method: 'POST', body: JSON.stringify(keys) }),
getSearch: () => request<{ serper: { configured: boolean; preview: string }; tavily: { configured: boolean; preview: string }; bing: { configured: boolean; preview: string }; google: { configured: boolean; preview: string }; serpapi: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; exa: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search'),
updateSearch: (keys: { serperApiKey?: string; tavilyApiKey?: string; bingApiKey?: string; googleSearchApiKey?: string; googleSearchCx?: string; serpApiKey?: string; braveApiKey?: string; exaApiKey?: string; bochaApiKey?: string }) =>
request<{ serper: { configured: boolean; preview: string }; tavily: { configured: boolean; preview: string }; bing: { configured: boolean; preview: string }; google: { configured: boolean; preview: string }; serpapi: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; exa: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search', { method: 'POST', body: JSON.stringify(keys) }),
getRemote: () => request<RemoteStatus>('/settings/remote'),
enableRemote: () => request<{ ok: boolean; status: RemoteStatus }>('/settings/remote/enable', { method: 'POST' }),
disableRemote: () => request<{ ok: boolean }>('/settings/remote/disable', { method: 'POST' }),
Expand Down
Loading
Loading