11import { createOpenRouter } from '@openrouter/ai-sdk-provider' ;
22import { convertToModelMessages , streamText , tool , type UIMessage } from 'ai' ;
33import { z } from 'zod' ;
4- import { source } from '@/lib/source' ;
5- import { Document , type DocumentData } from 'flexsearch' ;
6- import apiEndpoints from '@/lib/generated/api-endpoints.json' ;
7-
8- interface CustomDocument extends DocumentData {
9- url : string ;
10- title : string ;
11- description : string ;
12- content : string ;
4+ import { liteClient } from 'algoliasearch/lite' ;
5+
6+ const algolia = liteClient (
7+ process . env . NEXT_PUBLIC_ALGOLIA_APP_ID ! ,
8+ process . env . NEXT_PUBLIC_ALGOLIA_SEARCH_KEY ! ,
9+ ) ;
10+
11+ const STOP_WORDS = new Set ( [
12+ 'a' , 'an' , 'the' , 'is' , 'are' , 'was' , 'were' , 'be' , 'been' , 'being' , 'have' , 'has' , 'had' ,
13+ 'do' , 'does' , 'did' , 'will' , 'would' , 'could' , 'should' , 'may' , 'might' , 'shall' , 'can' ,
14+ 'i' , 'you' , 'he' , 'she' , 'it' , 'we' , 'they' , 'what' , 'which' , 'who' , 'how' , 'when' , 'where' ,
15+ 'why' , 'and' , 'or' , 'but' , 'not' , 'in' , 'on' , 'at' , 'to' , 'for' , 'of' , 'with' , 'by' , 'from' ,
16+ 'this' , 'that' , 'these' , 'those' , 'my' , 'your' , 'his' , 'her' , 'its' , 'our' , 'their' ,
17+ ] ) ;
18+
19+ function buildSearchQuery ( text : string ) : string {
20+ return text
21+ . toLowerCase ( )
22+ . replace ( / [ ? ! . , ; : ] / g, '' )
23+ . split ( / \s + / )
24+ . filter ( ( w ) => w . length > 1 && ! STOP_WORDS . has ( w ) )
25+ . join ( ' ' ) ;
1326}
1427
15- const searchServer = createSearchServer ( ) ;
16-
17- async function createSearchServer ( ) {
18- const search = new Document < CustomDocument > ( {
19- document : {
20- id : 'url' ,
21- index : [ 'title' , 'description' , 'content' ] ,
22- store : true ,
23- } ,
28+ async function runSearch ( query : string , limit = 8 ) {
29+ const searchQuery = buildSearchQuery ( query ) ;
30+ const { results } = await algolia . search ( {
31+ requests : [
32+ {
33+ indexName : process . env . NEXT_PUBLIC_ALGOLIA_INDEX ! ,
34+ query : searchQuery ,
35+ hitsPerPage : limit ,
36+ } ,
37+ ] ,
2438 } ) ;
2539
26- const docs = await chunkedAll (
27- source . getPages ( ) . map ( async ( page ) => {
28- if ( ! ( 'getText' in page . data ) ) return null ;
29-
30- return {
31- title : page . data . title ,
32- description : page . data . description ,
33- url : page . url ,
34- content : await page . data . getText ( 'processed' ) ,
35- } as CustomDocument ;
36- } ) ,
37- ) ;
38-
39- for ( const doc of docs ) {
40- if ( doc ) search . add ( doc ) ;
41- }
42-
43- for ( const ep of apiEndpoints ) {
44- search . add ( ep as CustomDocument ) ;
45- }
46-
47- return search ;
48- }
49-
50- async function chunkedAll < O > ( promises : Promise < O > [ ] ) : Promise < O [ ] > {
51- const SIZE = 50 ;
52- const out : O [ ] = [ ] ;
53- for ( let i = 0 ; i < promises . length ; i += SIZE ) {
54- out . push ( ...( await Promise . all ( promises . slice ( i , i + SIZE ) ) ) ) ;
55- }
56- return out ;
57- }
58-
59- async function runSearch ( query : string , limit = 8 ) : Promise < CustomDocument [ ] > {
60- const search = await searchServer ;
61- const results = await search . searchAsync ( query , { limit, merge : true , enrich : true } ) ;
62- return ( results as any [ ] )
63- . flatMap ( ( r ) => r . result ?? [ ] )
64- . map ( ( d ) => ( {
65- ...d . doc ,
66- content : d . doc ?. content ?. slice ( 0 , 1200 ) ?? '' ,
67- } ) )
68- . filter ( ( d ) => d . url ) ;
40+ return ( ( results [ 0 ] as any ) . hits ?? [ ] ) . map ( ( hit : any ) => ( {
41+ url : hit . url ?? hit . objectID ,
42+ title : hit . title ?? '' ,
43+ section : hit . section ?? '' ,
44+ content : hit . content ?? '' ,
45+ } ) ) ;
6946}
7047
7148const openrouter = createOpenRouter ( {
@@ -86,7 +63,6 @@ export async function POST(req: Request) {
8663 const reqJson : { messages ?: UIMessage [ ] } = await req . json ( ) ;
8764 const messages = reqJson . messages ?? [ ] ;
8865
89- // Extract latest user question and search server-side — don't rely on model to call tools
9066 const lastUserText = messages
9167 . filter ( ( m ) => m . role === 'user' )
9268 . at ( - 1 )
@@ -99,11 +75,14 @@ export async function POST(req: Request) {
9975 const contextBlock =
10076 docs . length > 0
10177 ? 'Relevant documentation:\n\n' +
102- docs . map ( ( d ) => `### [${ d . title } ](${ d . url } )\n${ d . description ? d . description + '\n' : '' } ${ d . content } ` ) . join ( '\n\n---\n\n' )
103- : 'No relevant documentation found.' ;
78+ docs
79+ . map ( ( d : any ) => `### [${ d . title } ${ d . section ? ` — ${ d . section } ` : '' } ](${ d . url } )\n${ d . content } ` )
80+ . join ( '\n\n---\n\n' )
81+ : 'No relevant documentation found for this query.' ;
10482
10583 const result = streamText ( {
10684 model : openrouter . chat ( process . env . OPENROUTER_MODEL ?? 'anthropic/claude-3.5-sonnet' ) ,
85+ maxOutputTokens : 1024 ,
10786 messages : [
10887 { role : 'system' , content : `${ systemPrompt } \n\n${ contextBlock } ` } ,
10988 ...( await convertToModelMessages ( messages ) ) ,
@@ -113,15 +92,12 @@ export async function POST(req: Request) {
11392 return result . toUIMessageStreamResponse ( ) ;
11493}
11594
116- // Keep tool definition so the UI type import still works
95+ // Kept for UI type compatibility
11796const searchTool = tool ( {
118- description : 'Search the docs content and return raw JSON results.' ,
119- inputSchema : z . object ( {
120- query : z . string ( ) ,
121- limit : z . number ( ) . int ( ) . min ( 1 ) . max ( 20 ) . default ( 8 ) ,
122- } ) ,
123- async execute ( { query, limit } ) {
124- return runSearch ( query , limit ) ;
97+ description : 'Search the docs.' ,
98+ inputSchema : z . object ( { query : z . string ( ) } ) ,
99+ async execute ( { query } ) {
100+ return runSearch ( query ) ;
125101 } ,
126102} ) ;
127103
0 commit comments