diff --git a/app/api/sandbox/route.ts b/app/api/sandbox/route.ts index f1fb599..dbf0efc 100644 --- a/app/api/sandbox/route.ts +++ b/app/api/sandbox/route.ts @@ -4,6 +4,7 @@ import { codeBlock } from 'common-tags'; import { textModel } from '@/lib/ai'; import { URL_CONTEXT_FALLBACK_CONFIDENCE } from '@/lib/autopilot'; import { generateEmbedding, serializeEmbedding } from '@/lib/embeddings'; +import { searchWeb } from '@/lib/exa-search'; import { parseLLMJSON } from '@/lib/parse-llm-json'; import { createServerClient } from '@/lib/supabase/server'; @@ -61,11 +62,13 @@ async function runGroundedAnswerAgent({ subject, question, retrievedContext, + webContext, tonePolicy, }: { subject: string; question: string; retrievedContext: string; + webContext?: string; tonePolicy?: string | null; }) { const systemPrompt = codeBlock` @@ -102,6 +105,8 @@ async function runGroundedAnswerAgent({ Customer question: ${question} + ${webContext ? `Web search results:\n${webContext}\n` : ''} + Retrieved knowledge base sections: ${retrievedContext} @@ -147,6 +152,18 @@ export async function POST(request: Request) { const tonePolicy = org?.tone_policy ?? null; const questionText = `${subject}\n${question}`; + /* ---------------------- WEB SEARCH + VECTOR RETRIEVAL ------------------ */ + + const embeddingPromise = + datasources && datasources.length > 0 + ? generateEmbedding(questionText) + : Promise.resolve([] as number[]); + + const [webContext, embeddingResult] = await Promise.all([ + searchWeb(questionText), + embeddingPromise, + ]); + /* -------------------------- VECTOR RETRIEVAL --------------------------- */ const matchedSections: Array<{ @@ -158,17 +175,14 @@ export async function POST(request: Request) { similarity: number; }> = []; - if (datasources && datasources.length > 0) { - const embedding = await generateEmbedding(questionText); - if (embedding.length > 0) { - const { data } = await supabase.rpc('match_sections', { - embedding: serializeEmbedding(embedding), - match_threshold: 0.1, - p_organization_id: orgId, - match_count: 8, - }); - if (data) matchedSections.push(...data); - } + if (embeddingResult.length > 0) { + const { data } = await supabase.rpc('match_sections', { + embedding: serializeEmbedding(embeddingResult), + match_threshold: 0.1, + p_organization_id: orgId, + match_count: 8, + }); + if (data) matchedSections.push(...data); } // Expand context by fetching immediate neighbors (position ± 1) of each @@ -232,7 +246,7 @@ export async function POST(request: Request) { datasourceUrl: datasources?.find((d) => d.id === s.datasource_id)?.url, })); - if (!retrievedContext.trim()) { + if (!retrievedContext.trim() && !webContext.trim()) { return new Response( JSON.stringify({ html: '

No relevant information found in the knowledge base for this question.

', @@ -252,6 +266,7 @@ export async function POST(request: Request) { subject, question, retrievedContext, + webContext, tonePolicy, }); diff --git a/app/api/webhooks/reply/route.ts b/app/api/webhooks/reply/route.ts index 500c653..776cf7a 100644 --- a/app/api/webhooks/reply/route.ts +++ b/app/api/webhooks/reply/route.ts @@ -7,6 +7,7 @@ import { textModel } from '@/lib/ai'; import { URL_CONTEXT_FALLBACK_CONFIDENCE } from '@/lib/autopilot'; import { cleanBody } from '@/lib/cleanBody'; import { generateEmbedding, serializeEmbedding } from '@/lib/embeddings'; +import { searchWeb } from '@/lib/exa-search'; import { parseLLMJSON } from '@/lib/parse-llm-json'; import { createServiceClient } from '@/lib/supabase/service'; @@ -225,6 +226,7 @@ async function runGroundedAnswerAgent({ question, retrievedContext, apiContext, + webContext, conversationHistory, tonePolicy, }: { @@ -232,6 +234,7 @@ async function runGroundedAnswerAgent({ question: string; retrievedContext: string; apiContext?: string; + webContext?: string; conversationHistory?: string; tonePolicy?: string | null; }) { @@ -273,6 +276,8 @@ async function runGroundedAnswerAgent({ ${apiContext ? `Live API data:\n${apiContext}\n` : ''} + ${webContext ? `Web search results:\n${webContext}\n` : ''} + Retrieved knowledge base sections: ${retrievedContext} @@ -347,11 +352,14 @@ export async function POST(request: Request) { /* --------------------------- MCP TOOL GATHERING ------------------------- */ - const apiContext = await gatherContextViaMcp( - thread?.subject ?? '', - record.cleaned_body, - mcpServers ?? [] - ); + const [apiContext, webContext] = await Promise.all([ + gatherContextViaMcp( + thread?.subject ?? '', + record.cleaned_body, + mcpServers ?? [] + ), + searchWeb(questionText), + ]); /* -------------------------- VECTOR RETRIEVAL --------------------------- */ @@ -376,6 +384,7 @@ export async function POST(request: Request) { question: record.cleaned_body, retrievedContext, apiContext, + webContext, conversationHistory, tonePolicy: org?.tone_policy, }); diff --git a/app/org/[slug]/dashboard/page.tsx b/app/org/[slug]/dashboard/page.tsx index cf1320c..eeac0ad 100644 --- a/app/org/[slug]/dashboard/page.tsx +++ b/app/org/[slug]/dashboard/page.tsx @@ -58,6 +58,7 @@ export default async function DashboardPage({ params }: Props) { initialTonePolicy={org.tone_policy ?? null} initialMcpServers={mcpServers ?? []} workflowsCount={workflows?.length ?? 0} + webSearchEnabled={!!process.env.EXA_API_KEY} /> ); } diff --git a/app/org/[slug]/page.tsx b/app/org/[slug]/page.tsx index e647d31..cb11ad0 100644 --- a/app/org/[slug]/page.tsx +++ b/app/org/[slug]/page.tsx @@ -58,6 +58,7 @@ export default async function OrgPage({ params }: Props) { initialTonePolicy={org.tone_policy ?? null} initialMcpServers={mcpServers ?? []} workflowsCount={workflows?.length ?? 0} + webSearchEnabled={!!process.env.EXA_API_KEY} /> ); } diff --git a/components/organization/WelcomeDashboard.tsx b/components/organization/WelcomeDashboard.tsx index 7745ed7..701ed8b 100644 --- a/components/organization/WelcomeDashboard.tsx +++ b/components/organization/WelcomeDashboard.tsx @@ -54,6 +54,7 @@ interface Props { initialTonePolicy: string | null; initialMcpServers: Tables<'mcp_server'>[]; workflowsCount: number; + webSearchEnabled: boolean; } // ─── Stat chip ──────────────────────────────────────────────────────────────── @@ -211,6 +212,7 @@ export function WelcomeDashboard({ initialTonePolicy, initialMcpServers, workflowsCount, + webSearchEnabled, }: Props) { const { copied, copyToClipboard } = useCopyToClipboard(); const [, setAddDataSource] = useAddDataSource(); @@ -318,6 +320,15 @@ export function WelcomeDashboard({ +
+ + + 🌐 Web Search {webSearchEnabled ? 'On' : 'Off'} + +
diff --git a/env.example b/env.example index 04f9f4b..087144c 100644 --- a/env.example +++ b/env.example @@ -4,3 +4,4 @@ SUPABASE_SERVICE_KEY= NEXT_PUBLIC_BASE_URL=http://localhost:3000 RESEND_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY= +EXA_API_KEY= diff --git a/lib/exa-search.ts b/lib/exa-search.ts new file mode 100644 index 0000000..9def6bd --- /dev/null +++ b/lib/exa-search.ts @@ -0,0 +1,48 @@ +import Exa from 'exa-js'; + +/** Maximum number of Exa search results to include as context. */ +const EXA_MAX_RESULTS = 3; + +/** Maximum characters to include from each result's text. */ +const EXA_MAX_TEXT_CHARS = 1500; + +function formatSearchResult( + r: { title?: string | null; url?: string | null; text?: string | null }, + index: number +): string { + const title = r.title ? `[${index + 1}] ${r.title}` : `[${index + 1}]`; + const url = r.url ? `Source: ${r.url}` : ''; + const text = r.text?.trim() ?? ''; + return [title, url, text].filter(Boolean).join('\n'); +} + +/** + * Perform an Exa neural web search and return a plain-text summary of the + * top results suitable for inclusion in the grounded answer agent prompt. + * + * Returns an empty string when EXA_API_KEY is not set or the search fails. + */ +export async function searchWeb(query: string): Promise { + const apiKey = process.env.EXA_API_KEY; + if (!apiKey) return ''; + + try { + const exa = new Exa(apiKey); + + const result = await exa.searchAndContents(query, { + numResults: EXA_MAX_RESULTS, + type: 'neural', + useAutoprompt: true, + text: { maxCharacters: EXA_MAX_TEXT_CHARS }, + }); + + if (!result.results?.length) return ''; + + return result.results + .map((r, i) => formatSearchResult(r, i)) + .join('\n\n---\n\n'); + } catch (err) { + console.error('[exa-search] Web search failed:', err); + return ''; + } +} diff --git a/package.json b/package.json index aba9610..16d5561 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "clsx": "^2.1.1", "common-tags": "^1.8.2", "date-fns": "^3.6.0", + "exa-js": "^2.7.0", "geist": "^1.7.0", "isomorphic-dompurify": "3.0.0-rc.2", "jotai": "^2.18.0",