Skip to content

Commit 97e64aa

Browse files
committed
Merge branch 'pr/703'
2 parents 7955d8e + 90e303f commit 97e64aa

File tree

2 files changed

+143
-19
lines changed

2 files changed

+143
-19
lines changed

docs/API/SEARCH.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ The API accepts a JSON object in the request body, where you define the focus mo
3232
"history": [
3333
["human", "Hi, how are you?"],
3434
["assistant", "I am doing well, how can I help you today?"]
35-
]
35+
],
36+
"stream": false
3637
}
3738
```
3839

@@ -71,11 +72,13 @@ The API accepts a JSON object in the request body, where you define the focus mo
7172
]
7273
```
7374

75+
- **`stream`** (boolean, optional): When set to `true`, enables streaming responses. Default is `false`.
76+
7477
### Response
7578

7679
The response from the API includes both the final message and the sources used to generate that message.
7780

78-
#### Example Response
81+
#### Standard Response (stream: false)
7982

8083
```json
8184
{
@@ -100,6 +103,28 @@ The response from the API includes both the final message and the sources used t
100103
}
101104
```
102105

106+
#### Streaming Response (stream: true)
107+
108+
When streaming is enabled, the API returns a stream of newline-delimited JSON objects. Each line contains a complete, valid JSON object. The response has Content-Type: application/json.
109+
110+
Example of streamed response objects:
111+
112+
```
113+
{"type":"init","data":"Stream connected"}
114+
{"type":"sources","data":[{"pageContent":"...","metadata":{"title":"...","url":"..."}},...]}
115+
{"type":"response","data":"Perplexica is an "}
116+
{"type":"response","data":"innovative, open-source "}
117+
{"type":"response","data":"AI-powered search engine..."}
118+
{"type":"done"}
119+
```
120+
121+
Clients should process each line as a separate JSON object. The different message types include:
122+
123+
- **`init`**: Initial connection message
124+
- **`sources`**: All sources used for the response
125+
- **`response`**: Chunks of the generated answer text
126+
- **`done`**: Indicates the stream is complete
127+
103128
### Fields in the Response
104129

105130
- **`message`** (string): The search result, generated based on the query and focus mode.

src/app/api/search/route.ts

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface ChatRequestBody {
3333
embeddingModel?: embeddingModel;
3434
query: string;
3535
history: Array<[string, string]>;
36+
stream?: boolean;
3637
}
3738

3839
export const POST = async (req: Request) => {
@@ -48,6 +49,7 @@ export const POST = async (req: Request) => {
4849

4950
body.history = body.history || [];
5051
body.optimizationMode = body.optimizationMode || 'balanced';
52+
body.stream = body.stream || false;
5153

5254
const history: BaseMessage[] = body.history.map((msg) => {
5355
return msg[0] === 'human'
@@ -125,40 +127,137 @@ export const POST = async (req: Request) => {
125127
[],
126128
);
127129

128-
return new Promise(
129-
(
130-
resolve: (value: Response) => void,
131-
reject: (value: Response) => void,
132-
) => {
133-
let message = '';
130+
if (!body.stream) {
131+
return new Promise(
132+
(
133+
resolve: (value: Response) => void,
134+
reject: (value: Response) => void,
135+
) => {
136+
let message = '';
137+
let sources: any[] = [];
138+
139+
emitter.on('data', (data: string) => {
140+
try {
141+
const parsedData = JSON.parse(data);
142+
if (parsedData.type === 'response') {
143+
message += parsedData.data;
144+
} else if (parsedData.type === 'sources') {
145+
sources = parsedData.data;
146+
}
147+
} catch (error) {
148+
reject(
149+
Response.json(
150+
{ message: 'Error parsing data' },
151+
{ status: 500 },
152+
),
153+
);
154+
}
155+
});
156+
157+
emitter.on('end', () => {
158+
resolve(Response.json({ message, sources }, { status: 200 }));
159+
});
160+
161+
emitter.on('error', (error: any) => {
162+
reject(
163+
Response.json(
164+
{ message: 'Search error', error },
165+
{ status: 500 },
166+
),
167+
);
168+
});
169+
},
170+
);
171+
}
172+
173+
const encoder = new TextEncoder();
174+
175+
const abortController = new AbortController();
176+
const { signal } = abortController;
177+
178+
const stream = new ReadableStream({
179+
start(controller) {
134180
let sources: any[] = [];
135181

136-
emitter.on('data', (data) => {
182+
controller.enqueue(
183+
encoder.encode(
184+
JSON.stringify({
185+
type: 'init',
186+
data: 'Stream connected',
187+
}) + '\n',
188+
),
189+
);
190+
191+
signal.addEventListener('abort', () => {
192+
emitter.removeAllListeners();
193+
194+
try {
195+
controller.close();
196+
} catch (error) {}
197+
});
198+
199+
emitter.on('data', (data: string) => {
200+
if (signal.aborted) return;
201+
137202
try {
138203
const parsedData = JSON.parse(data);
204+
139205
if (parsedData.type === 'response') {
140-
message += parsedData.data;
206+
controller.enqueue(
207+
encoder.encode(
208+
JSON.stringify({
209+
type: 'response',
210+
data: parsedData.data,
211+
}) + '\n',
212+
),
213+
);
141214
} else if (parsedData.type === 'sources') {
142215
sources = parsedData.data;
216+
controller.enqueue(
217+
encoder.encode(
218+
JSON.stringify({
219+
type: 'sources',
220+
data: sources,
221+
}) + '\n',
222+
),
223+
);
143224
}
144225
} catch (error) {
145-
reject(
146-
Response.json({ message: 'Error parsing data' }, { status: 500 }),
147-
);
226+
controller.error(error);
148227
}
149228
});
150229

151230
emitter.on('end', () => {
152-
resolve(Response.json({ message, sources }, { status: 200 }));
153-
});
231+
if (signal.aborted) return;
154232

155-
emitter.on('error', (error) => {
156-
reject(
157-
Response.json({ message: 'Search error', error }, { status: 500 }),
233+
controller.enqueue(
234+
encoder.encode(
235+
JSON.stringify({
236+
type: 'done',
237+
}) + '\n',
238+
),
158239
);
240+
controller.close();
241+
});
242+
243+
emitter.on('error', (error: any) => {
244+
if (signal.aborted) return;
245+
246+
controller.error(error);
159247
});
160248
},
161-
);
249+
cancel() {
250+
abortController.abort();
251+
},
252+
});
253+
254+
return new Response(stream, {
255+
headers: {
256+
'Content-Type': 'text/event-stream',
257+
'Cache-Control': 'no-cache, no-transform',
258+
Connection: 'keep-alive',
259+
},
260+
});
162261
} catch (err: any) {
163262
console.error(`Error in getting search results: ${err.message}`);
164263
return Response.json(

0 commit comments

Comments
 (0)