chat-agent-toolkit 1.2.379 → 1.2.381

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,493 +1,518 @@
1
- /**
2
- * @fileoverview Orchestrator for complex research queries.
3
- * Manages query expansion, web search execution, and result synthesis using
4
- * LLMs through the Vercel AI SDK (generateText/streamText).
5
- */
6
- import { generateText, streamText, type LanguageModel } from "ai";
7
- import { LineOutputParser, LineListOutputParser } from "../../utils/outputParser";
8
- import type { Document } from "./document";
9
-
10
- /** Strip HTML tags and decode entities — works in Cloudflare edge runtime */
11
- function htmlToText(html: string): string {
12
- return html
13
- .replace(/<(script|style)[^>]*>[\s\S]*?<\/(script|style)>/gi, ' ')
14
- .replace(/<[^>]+>/g, ' ')
15
- .replace(/&nbsp;/g, ' ')
16
- .replace(/&amp;/g, '&')
17
- .replace(/&lt;/g, '<')
18
- .replace(/&gt;/g, '>')
19
- .replace(/&quot;/g, '"')
20
- .replace(/&#39;/g, "'")
21
- .replace(/&[a-z#][a-z0-9]+;/gi, ' ')
22
- .replace(/\s+/g, ' ')
23
- .trim();
24
- }
25
- import { formatChatHistoryAsString } from "../../utils";
26
- import EventEmitter from "events";
27
- import type {
28
- Config,
29
- ChatTurnMessage,
30
- MetaSearchAgentType,
31
- SearchingEvent,
32
- } from "./meta-search-types";
33
- import {
34
- buildFallbackDocs,
35
- rerankDocs,
36
- processDocs,
37
- normalizeSourcesOutput,
38
- loadUploadImages,
39
- type R2CredentialsInput,
40
- } from "./doc-utils";
41
- import { groupAndSummarizeDocs } from "./link-summarizer";
42
-
43
- export type { MetaSearchAgentType, Config } from "./meta-search-types";
44
-
45
- const waitWithTimeout = async <T>(
46
- promise: Promise<T>,
47
- timeoutMs: number,
48
- ): Promise<T | undefined> => {
49
- return Promise.race([
50
- promise,
51
- new Promise<undefined>((resolve) => {
52
- setTimeout(() => resolve(undefined), timeoutMs);
53
- }),
54
- ]);
55
- };
56
-
57
- /**
58
- * Substitutes `{key}` placeholders in a prompt template with the provided
59
- * values, leaving unknown placeholders untouched.
60
- */
61
- const interpolatePrompt = (
62
- template: string,
63
- vars: Record<string, string>,
64
- ): string => {
65
- return Object.entries(vars).reduce(
66
- (result, [key, value]) => result.split(`{${key}}`).join(value),
67
- template,
68
- );
69
- };
70
-
71
- class MetaSearchAgent implements MetaSearchAgentType {
72
- private config: Config;
73
-
74
- constructor(config: Config) {
75
- this.config = config;
76
- }
77
-
78
- /**
79
- * Rephrases the user's query into a standalone search question (and any
80
- * URLs to summarize), runs the web search, and returns the documents to
81
- * use as answer context.
82
- */
83
- private async retrieveSearchDocs(
84
- llm: LanguageModel,
85
- chatHistory: string,
86
- query: string,
87
- category: string = "general",
88
- sourceExtractionEnabled = false,
89
- thinkingTimeLimit = 0,
90
- emitter?: EventEmitter,
91
- queryGeneratorPromptOverride?: string,
92
- ): Promise<{ query: string; docs: Document[] }> {
93
- // A user-authored query-expansion prompt (Settings → Search Settings)
94
- // replaces the focus mode's built-in one. Blank or whitespace-only text
95
- // means "use the built-in prompt".
96
- const queryGeneratorPrompt = queryGeneratorPromptOverride?.trim()
97
- ? queryGeneratorPromptOverride
98
- : this.config.queryGeneratorPrompt;
99
-
100
- const { text: retrieverOutput } = await generateText({
101
- model: llm,
102
- temperature: 0,
103
- system: queryGeneratorPrompt,
104
- messages: [
105
- ...this.config.queryGeneratorFewShots.map(([role, content]) => ({
106
- role,
107
- content,
108
- })),
109
- {
110
- role: "user",
111
- content: `
112
- <conversation>
113
- ${chatHistory}
114
- </conversation>
115
-
116
- <query>
117
- ${query}
118
- </query>
119
- `,
120
- },
121
- ],
122
- });
123
-
124
- const linksOutputParser = new LineListOutputParser({ key: "links" });
125
- const questionOutputParser = new LineOutputParser({ key: "question" });
126
-
127
- const links = await linksOutputParser.parse(retrieverOutput);
128
- let question =
129
- (await questionOutputParser.parse(retrieverOutput)) ?? retrieverOutput;
130
-
131
- if (question === "not_needed") {
132
- question = "latest information";
133
- }
134
-
135
- if (links.length > 0 && this.config.getDocumentsFromLinks) {
136
- if (question.length === 0) question = "summarize";
137
-
138
- const linkDocs = await this.config.getDocumentsFromLinks({ links });
139
- const docs = await groupAndSummarizeDocs(llm, linkDocs, question);
140
-
141
- return { query: question, docs };
142
- }
143
-
144
- question = question.replace(/<think>.*?<\/think>/g, "");
145
- if (!question || question.trim().length === 0) {
146
- question = "latest information";
147
- }
148
-
149
- // Validate and sanitize the query before sending to search APIs
150
- // If the LLM returned a long response instead of a concise query, extract the first sentence
151
- // or use the original user query as fallback
152
- question = question.trim();
153
- if (question.length > 500 || question.split(/[.!?]\s+/).length > 5) {
154
- // Query is too long or contains too many sentences - likely the LLM returned a full response
155
- console.warn(`[MetaSearchAgent] Query is too long (${question.length} chars), truncating or using fallback`);
156
-
157
- // Try to extract first sentence as the query
158
- const firstSentence = question.split(/[.!?]\s+/)[0].trim();
159
- if (firstSentence.length > 0 && firstSentence.length < 200) {
160
- question = firstSentence;
161
- } else {
162
- // Fallback to original user query
163
- question = query.slice(0, 200);
164
- }
165
- }
166
-
167
- // Emit "searching" progress event so the client can show live status
168
- const categoryLabel = this.config.activeEngines.length > 0
169
- ? this.config.activeEngines.slice(0, 2).join(", ")
170
- : "Web";
171
- const emitSearching = (status: SearchingEvent["status"], query: string, cat?: string) => {
172
- emitter?.emit("data", JSON.stringify({
173
- type: "searching",
174
- data: { query, category: cat ?? categoryLabel, status } satisfies SearchingEvent,
175
- }));
176
- };
177
-
178
- emitSearching("running", question);
179
-
180
- let res: { results: any[]; suggestions: string[] } = { results: [], suggestions: [] };
181
-
182
- // If no search functions provided, return empty results
183
- if (!this.config.searchSearxng && !this.config.searchTavily) {
184
- res = { results: [], suggestions: [] };
185
- } else {
186
- const runSearxng = async () => {
187
- try {
188
- const result = await this.config.searchSearxng!(question, {
189
- language: "en",
190
- engines: this.config.activeEngines,
191
- categories: [category],
192
- });
193
- // Ensure result has the expected structure
194
- return result && typeof result === 'object' && 'results' in result
195
- ? result
196
- : { results: [], suggestions: [] };
197
- } catch (error) {
198
- console.error("[MetaSearchAgent] SearXNG search failed:", error);
199
- return { results: [], suggestions: [] };
200
- }
201
- };
202
-
203
- const isTavilyConfigured = this.config.isTavilyConfigured?.() ?? false;
204
-
205
- if (
206
- isTavilyConfigured &&
207
- this.config.activeEngines.length === 0 &&
208
- category === "general"
209
- ) {
210
- try {
211
- const tavilyResult = await this.config.searchTavily!(question, { searchDepth: "basic", maxResults: 10 });
212
- res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
213
- ? tavilyResult
214
- : { results: [], suggestions: [] };
215
- } catch (error) {
216
- console.error("Tavily search failed, falling back to SearXNG:", error);
217
- res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
218
- }
219
- } else {
220
- if (isTavilyConfigured && this.config.searchTavily) {
221
- try {
222
- res = await Promise.race([
223
- runSearxng(),
224
- new Promise<{ results: any[]; suggestions: string[] }>((_, reject) =>
225
- setTimeout(() => reject(new Error("Timeout")), 10000)
226
- )
227
- ]);
228
- } catch (err: any) {
229
- if (err.message === "Timeout") {
230
- console.warn("[MetaSearchAgent] SearXNG search did not respond in 10 seconds, falling back to Tavily.");
231
- try {
232
- const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
233
- res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
234
- ? tavilyResult
235
- : { results: [], suggestions: [] };
236
- } catch (tavilyErr) {
237
- console.error("[MetaSearchAgent] Tavily fallback also failed, awaiting SearXNG directly:", tavilyErr);
238
- res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
239
- }
240
- } else {
241
- console.error("[MetaSearchAgent] SearXNG search failed, falling back to Tavily:", err);
242
- try {
243
- const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
244
- res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
245
- ? tavilyResult
246
- : { results: [], suggestions: [] };
247
- } catch (tavilyErr) {
248
- console.error("[MetaSearchAgent] Tavily fallback also failed:", tavilyErr);
249
- res = { results: [], suggestions: [] };
250
- }
251
- }
252
- }
253
- } else {
254
- res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
255
- }
256
- }
257
- }
258
-
259
- let documents: Document[] = (res?.results ?? []).map((result) => ({
260
- pageContent:
261
- result.content ||
262
- (this.config.activeEngines.includes("youtube") ? result.title : ""),
263
- metadata: {
264
- title: result.title,
265
- url: result.url,
266
- source: result.source,
267
- ...(result.img_src && { img_src: result.img_src }),
268
- },
269
- }));
270
-
271
- if (documents.length === 0) {
272
- documents = buildFallbackDocs(question);
273
- }
274
-
275
- emitSearching("done", question);
276
-
277
- // Determine extraction budget from thinkingTimeLimit (seconds).
278
- // thinkingTimeLimit === 0 means unlimited; use server config.
279
- let scrapeCount: number;
280
- let perSourceTimeout: number;
281
-
282
- if (thinkingTimeLimit > 0) {
283
- // Spread the time budget across 3 sources
284
- scrapeCount = 3;
285
- perSourceTimeout = Math.max(2, Math.floor(thinkingTimeLimit / scrapeCount));
286
- } else if (sourceExtractionEnabled) {
287
- scrapeCount = 3;
288
- // Default to 5 seconds if no config function available
289
- perSourceTimeout = 5;
290
- } else {
291
- scrapeCount = 0;
292
- perSourceTimeout = 0;
293
- }
294
-
295
- if (scrapeCount > 0) {
296
- const docsToScrape = documents.slice(0, scrapeCount);
297
- emitSearching("running", `Extracting top ${docsToScrape.length} sources`, "extract");
298
-
299
- const extractionTasks = this.config.scrapeURL ? docsToScrape.map(async (doc, idx) => {
300
- const url = doc.metadata?.url;
301
- if (!url || !this.config.scrapeURL) return;
302
- try {
303
- const result = await waitWithTimeout(
304
- this.config.scrapeURL(url, { timeout: perSourceTimeout }),
305
- perSourceTimeout * 1000 + 1500,
306
- );
307
- if (typeof result === "string" && result.length > 100) {
308
- const text = htmlToText(result)
309
- .replace(/(\r\n|\n|\r)/gm, " ")
310
- .replace(/\s+/g, " ")
311
- .trim()
312
- .slice(0, 5000);
313
- if (text.length > 100) {
314
- documents[idx].pageContent = text;
315
- }
316
- }
317
- } catch {
318
- // Keep original snippet on scraping failure or timeout
319
- }
320
- }) : [];
321
-
322
- await Promise.allSettled(extractionTasks);
323
- emitSearching("done", `Extracting top ${docsToScrape.length} sources`, "extract");
324
- }
325
-
326
- return { query: question, docs: documents };
327
- }
328
-
329
- /**
330
- * Runs the full search-and-answer pipeline, emitting "sources",
331
- * "response", "searching", "end", and "error" events on the emitter.
332
- */
333
- private async runPipeline(
334
- emitter: EventEmitter,
335
- message: string,
336
- history: ChatTurnMessage[],
337
- llm: LanguageModel,
338
- optimizationMode: "speed" | "balanced" | "quality",
339
- fileIds: string[],
340
- systemInstructions: string,
341
- category: string,
342
- sourceExtractionEnabled: boolean,
343
- thinkingTimeLimit: number,
344
- queryExpansionPrompt?: string,
345
- ): Promise<void> {
346
- try {
347
- let docs: Document[] | null = null;
348
- let query = message;
349
-
350
- if (this.config.searchWeb) {
351
- const result = await this.retrieveSearchDocs(
352
- llm,
353
- formatChatHistoryAsString(history),
354
- message,
355
- category,
356
- sourceExtractionEnabled,
357
- thinkingTimeLimit,
358
- emitter,
359
- queryExpansionPrompt,
360
- );
361
- query = result.query;
362
- docs = result.docs;
363
- }
364
-
365
- const r2Credentials: R2CredentialsInput | undefined = process.env.R2_ACCOUNT_ID
366
- ? {
367
- accountId: process.env.R2_ACCOUNT_ID,
368
- accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
369
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
370
- bucket: process.env.R2_UPLOADS_BUCKET || "qwksearch-uploads",
371
- }
372
- : undefined;
373
-
374
- const sortedDocs = await rerankDocs(
375
- query,
376
- docs ?? [],
377
- fileIds,
378
- optimizationMode,
379
- r2Credentials,
380
- );
381
-
382
- const sources = normalizeSourcesOutput(sortedDocs, message);
383
- console.log("[MetaSearchAgent] emitting sources:", sources.length);
384
- emitter.emit("data", JSON.stringify({ type: "sources", data: sources }));
385
-
386
- const systemPrompt = interpolatePrompt(this.config.responsePrompt, {
387
- systemInstructions,
388
- context: processDocs(sortedDocs),
389
- date: new Date().toISOString(),
390
- });
391
-
392
- // Resolve any uploaded images so they are passed to the LLM directly as
393
- // image content parts (alongside the text query). Documents already
394
- // reach the model as text context via processDocs above.
395
- const imageAttachments = await loadUploadImages(fileIds, r2Credentials);
396
- const userContent =
397
- imageAttachments.length > 0
398
- ? [
399
- { type: "text" as const, text: message },
400
- ...imageAttachments.map((img) => ({
401
- type: "image" as const,
402
- image: img.image,
403
- })),
404
- ]
405
- : message;
406
-
407
- const result = streamText({
408
- model: llm,
409
- temperature: 0.7,
410
- system: systemPrompt,
411
- messages: [...history, { role: "user", content: userContent }],
412
- });
413
-
414
- let responseChunkCount = 0;
415
- for await (const chunk of result.textStream) {
416
- responseChunkCount += 1;
417
- emitter.emit("data", JSON.stringify({ type: "response", data: chunk }));
418
- }
419
- console.log("[MetaSearchAgent] response stream ended, chunks:", responseChunkCount);
420
-
421
- if (responseChunkCount === 0) {
422
- const fallbackUrls = sources
423
- .map((doc) => doc.metadata?.url)
424
- .filter((url): url is string => typeof url === "string")
425
- .slice(0, 5);
426
-
427
- const fallbackMessage = [
428
- "I couldn't generate a full answer, but I ran a web search and found these source URLs:",
429
- ...fallbackUrls.map((url) => `- ${url}`),
430
- ].join("\n");
431
-
432
- emitter.emit("data", JSON.stringify({ type: "response", data: fallbackMessage }));
433
- }
434
-
435
- emitter.emit("end");
436
- } catch (err) {
437
- const errMessage = err instanceof Error ? err.message : String(err);
438
- console.error("[MetaSearchAgent] caught error from AI SDK stream:", errMessage, err);
439
- let userMessage = errMessage;
440
- if (errMessage.includes("404") || errMessage.toLowerCase().includes("not found")) {
441
- userMessage =
442
- "The selected AI model was not found at the provider. Please go to Settings → Model Providers and select a different model.";
443
- } else if (errMessage.includes("410")) {
444
- userMessage =
445
- "The selected AI model is no longer available (deprecated by the provider). Please go to Settings → Model Providers and select a different model.";
446
- } else if (errMessage.includes("401") || errMessage.includes("authentication")) {
447
- userMessage =
448
- "Authentication failed with the AI provider. Please check your API key in Settings.";
449
- } else if (errMessage.includes("429") || errMessage.includes("rate limit")) {
450
- userMessage =
451
- "Rate limit reached for the AI provider. Please wait a moment and try again.";
452
- }
453
- emitter.emit("error", JSON.stringify({ data: userMessage }));
454
- }
455
- }
456
-
457
- async searchAndAnswer(
458
- message: string,
459
- history: ChatTurnMessage[],
460
- llm: LanguageModel,
461
- optimizationMode: "speed" | "balanced" | "quality",
462
- fileIds: string[],
463
- systemInstructions: string,
464
- category: string = "general",
465
- sourceExtractionEnabled = false,
466
- thinkingTimeLimit = 0,
467
- queryExpansionPrompt?: string,
468
- ) {
469
- const emitter = new EventEmitter();
470
-
471
- // Start on the next macrotask so callers can attach their event
472
- // listeners before the first "sources"/"response" event can fire.
473
- setTimeout(() => {
474
- this.runPipeline(
475
- emitter,
476
- message,
477
- history,
478
- llm,
479
- optimizationMode,
480
- fileIds,
481
- systemInstructions,
482
- category,
483
- sourceExtractionEnabled,
484
- thinkingTimeLimit,
485
- queryExpansionPrompt,
486
- );
487
- }, 0);
488
-
489
- return emitter;
490
- }
491
- }
492
-
493
- export default MetaSearchAgent;
1
+ /**
2
+ * @fileoverview Orchestrator for complex research queries.
3
+ * Manages query expansion, web search execution, and result synthesis using
4
+ * LLMs through the Vercel AI SDK (generateText/streamText).
5
+ */
6
+ import { generateText, streamText, type LanguageModel } from "ai";
7
+ import { LineOutputParser, LineListOutputParser } from "../../utils/outputParser";
8
+ import type { Document } from "./document";
9
+
10
+ /**
11
+ * Longest HTML we run through {@link htmlToText}.
12
+ *
13
+ * Each `.replace()` below allocates a fresh copy of the whole string, so an
14
+ * unbounded page (a 20MB HTML dump is not rare) becomes eight unbounded copies
15
+ * live at once — enough on its own to take a 128MB Worker isolate out. Only the
16
+ * first 5000 characters of the result are ever used, and a prefix this long is
17
+ * far more than that survives.
18
+ */
19
+ const MAX_HTML_CHARS = 500_000;
20
+
21
+ /** Strip HTML tags and decode entities — works in Cloudflare edge runtime */
22
+ function htmlToText(html: string): string {
23
+ return html
24
+ .slice(0, MAX_HTML_CHARS)
25
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/(script|style)>/gi, ' ')
26
+ .replace(/<[^>]+>/g, ' ')
27
+ .replace(/&nbsp;/g, ' ')
28
+ .replace(/&amp;/g, '&')
29
+ .replace(/&lt;/g, '<')
30
+ .replace(/&gt;/g, '>')
31
+ .replace(/&quot;/g, '"')
32
+ .replace(/&#39;/g, "'")
33
+ .replace(/&[a-z#][a-z0-9]+;/gi, ' ')
34
+ .replace(/\s+/g, ' ')
35
+ .trim();
36
+ }
37
+ import { formatChatHistoryAsString } from "../../utils";
38
+ import EventEmitter from "events";
39
+ import type {
40
+ Config,
41
+ ChatTurnMessage,
42
+ DrainableEmitter,
43
+ MetaSearchAgentType,
44
+ SearchingEvent,
45
+ } from "./meta-search-types";
46
+ import {
47
+ buildFallbackDocs,
48
+ rerankDocs,
49
+ processDocs,
50
+ normalizeSourcesOutput,
51
+ loadUploads,
52
+ selectUploadImages,
53
+ type R2CredentialsInput,
54
+ } from "./doc-utils";
55
+ import { groupAndSummarizeDocs } from "./link-summarizer";
56
+
57
+ export type { MetaSearchAgentType, Config, DrainableEmitter } from "./meta-search-types";
58
+
59
+ const waitWithTimeout = async <T>(
60
+ promise: Promise<T>,
61
+ timeoutMs: number,
62
+ ): Promise<T | undefined> => {
63
+ return Promise.race([
64
+ promise,
65
+ new Promise<undefined>((resolve) => {
66
+ setTimeout(() => resolve(undefined), timeoutMs);
67
+ }),
68
+ ]);
69
+ };
70
+
71
+ /**
72
+ * Substitutes `{key}` placeholders in a prompt template with the provided
73
+ * values, leaving unknown placeholders untouched.
74
+ */
75
+ const interpolatePrompt = (
76
+ template: string,
77
+ vars: Record<string, string>,
78
+ ): string => {
79
+ return Object.entries(vars).reduce(
80
+ (result, [key, value]) => result.split(`{${key}}`).join(value),
81
+ template,
82
+ );
83
+ };
84
+
85
+ class MetaSearchAgent implements MetaSearchAgentType {
86
+ private config: Config;
87
+
88
+ constructor(config: Config) {
89
+ this.config = config;
90
+ }
91
+
92
+ /**
93
+ * Rephrases the user's query into a standalone search question (and any
94
+ * URLs to summarize), runs the web search, and returns the documents to
95
+ * use as answer context.
96
+ */
97
+ private async retrieveSearchDocs(
98
+ llm: LanguageModel,
99
+ chatHistory: string,
100
+ query: string,
101
+ category: string = "general",
102
+ sourceExtractionEnabled = false,
103
+ thinkingTimeLimit = 0,
104
+ emitter?: EventEmitter,
105
+ queryGeneratorPromptOverride?: string,
106
+ ): Promise<{ query: string; docs: Document[] }> {
107
+ // A user-authored query-expansion prompt (Settings → Search Settings)
108
+ // replaces the focus mode's built-in one. Blank or whitespace-only text
109
+ // means "use the built-in prompt".
110
+ const queryGeneratorPrompt = queryGeneratorPromptOverride?.trim()
111
+ ? queryGeneratorPromptOverride
112
+ : this.config.queryGeneratorPrompt;
113
+
114
+ const { text: retrieverOutput } = await generateText({
115
+ model: llm,
116
+ temperature: 0,
117
+ system: queryGeneratorPrompt,
118
+ messages: [
119
+ ...this.config.queryGeneratorFewShots.map(([role, content]) => ({
120
+ role,
121
+ content,
122
+ })),
123
+ {
124
+ role: "user",
125
+ content: `
126
+ <conversation>
127
+ ${chatHistory}
128
+ </conversation>
129
+
130
+ <query>
131
+ ${query}
132
+ </query>
133
+ `,
134
+ },
135
+ ],
136
+ });
137
+
138
+ const linksOutputParser = new LineListOutputParser({ key: "links" });
139
+ const questionOutputParser = new LineOutputParser({ key: "question" });
140
+
141
+ const links = await linksOutputParser.parse(retrieverOutput);
142
+ let question =
143
+ (await questionOutputParser.parse(retrieverOutput)) ?? retrieverOutput;
144
+
145
+ if (question === "not_needed") {
146
+ question = "latest information";
147
+ }
148
+
149
+ if (links.length > 0 && this.config.getDocumentsFromLinks) {
150
+ if (question.length === 0) question = "summarize";
151
+
152
+ const linkDocs = await this.config.getDocumentsFromLinks({ links });
153
+ const docs = await groupAndSummarizeDocs(llm, linkDocs, question);
154
+
155
+ return { query: question, docs };
156
+ }
157
+
158
+ question = question.replace(/<think>.*?<\/think>/g, "");
159
+ if (!question || question.trim().length === 0) {
160
+ question = "latest information";
161
+ }
162
+
163
+ // Validate and sanitize the query before sending to search APIs
164
+ // If the LLM returned a long response instead of a concise query, extract the first sentence
165
+ // or use the original user query as fallback
166
+ question = question.trim();
167
+ if (question.length > 500 || question.split(/[.!?]\s+/).length > 5) {
168
+ // Query is too long or contains too many sentences - likely the LLM returned a full response
169
+ console.warn(`[MetaSearchAgent] Query is too long (${question.length} chars), truncating or using fallback`);
170
+
171
+ // Try to extract first sentence as the query
172
+ const firstSentence = question.split(/[.!?]\s+/)[0].trim();
173
+ if (firstSentence.length > 0 && firstSentence.length < 200) {
174
+ question = firstSentence;
175
+ } else {
176
+ // Fallback to original user query
177
+ question = query.slice(0, 200);
178
+ }
179
+ }
180
+
181
+ // Emit "searching" progress event so the client can show live status
182
+ const categoryLabel = this.config.activeEngines.length > 0
183
+ ? this.config.activeEngines.slice(0, 2).join(", ")
184
+ : "Web";
185
+ const emitSearching = (status: SearchingEvent["status"], query: string, cat?: string) => {
186
+ emitter?.emit("data", JSON.stringify({
187
+ type: "searching",
188
+ data: { query, category: cat ?? categoryLabel, status } satisfies SearchingEvent,
189
+ }));
190
+ };
191
+
192
+ emitSearching("running", question);
193
+
194
+ let res: { results: any[]; suggestions: string[] } = { results: [], suggestions: [] };
195
+
196
+ // If no search functions provided, return empty results
197
+ if (!this.config.searchSearxng && !this.config.searchTavily) {
198
+ res = { results: [], suggestions: [] };
199
+ } else {
200
+ const runSearxng = async () => {
201
+ try {
202
+ const result = await this.config.searchSearxng!(question, {
203
+ language: "en",
204
+ engines: this.config.activeEngines,
205
+ categories: [category],
206
+ });
207
+ // Ensure result has the expected structure
208
+ return result && typeof result === 'object' && 'results' in result
209
+ ? result
210
+ : { results: [], suggestions: [] };
211
+ } catch (error) {
212
+ console.error("[MetaSearchAgent] SearXNG search failed:", error);
213
+ return { results: [], suggestions: [] };
214
+ }
215
+ };
216
+
217
+ const isTavilyConfigured = this.config.isTavilyConfigured?.() ?? false;
218
+
219
+ if (
220
+ isTavilyConfigured &&
221
+ this.config.activeEngines.length === 0 &&
222
+ category === "general"
223
+ ) {
224
+ try {
225
+ const tavilyResult = await this.config.searchTavily!(question, { searchDepth: "basic", maxResults: 10 });
226
+ res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
227
+ ? tavilyResult
228
+ : { results: [], suggestions: [] };
229
+ } catch (error) {
230
+ console.error("Tavily search failed, falling back to SearXNG:", error);
231
+ res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
232
+ }
233
+ } else {
234
+ if (isTavilyConfigured && this.config.searchTavily) {
235
+ try {
236
+ res = await Promise.race([
237
+ runSearxng(),
238
+ new Promise<{ results: any[]; suggestions: string[] }>((_, reject) =>
239
+ setTimeout(() => reject(new Error("Timeout")), 10000)
240
+ )
241
+ ]);
242
+ } catch (err: any) {
243
+ if (err.message === "Timeout") {
244
+ console.warn("[MetaSearchAgent] SearXNG search did not respond in 10 seconds, falling back to Tavily.");
245
+ try {
246
+ const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
247
+ res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
248
+ ? tavilyResult
249
+ : { results: [], suggestions: [] };
250
+ } catch (tavilyErr) {
251
+ console.error("[MetaSearchAgent] Tavily fallback also failed, awaiting SearXNG directly:", tavilyErr);
252
+ res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
253
+ }
254
+ } else {
255
+ console.error("[MetaSearchAgent] SearXNG search failed, falling back to Tavily:", err);
256
+ try {
257
+ const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
258
+ res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
259
+ ? tavilyResult
260
+ : { results: [], suggestions: [] };
261
+ } catch (tavilyErr) {
262
+ console.error("[MetaSearchAgent] Tavily fallback also failed:", tavilyErr);
263
+ res = { results: [], suggestions: [] };
264
+ }
265
+ }
266
+ }
267
+ } else {
268
+ res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
269
+ }
270
+ }
271
+ }
272
+
273
+ let documents: Document[] = (res?.results ?? []).map((result) => ({
274
+ pageContent:
275
+ result.content ||
276
+ (this.config.activeEngines.includes("youtube") ? result.title : ""),
277
+ metadata: {
278
+ title: result.title,
279
+ url: result.url,
280
+ source: result.source,
281
+ ...(result.img_src && { img_src: result.img_src }),
282
+ },
283
+ }));
284
+
285
+ if (documents.length === 0) {
286
+ documents = buildFallbackDocs(question);
287
+ }
288
+
289
+ emitSearching("done", question);
290
+
291
+ // Determine extraction budget from thinkingTimeLimit (seconds).
292
+ // thinkingTimeLimit === 0 means unlimited; use server config.
293
+ let scrapeCount: number;
294
+ let perSourceTimeout: number;
295
+
296
+ if (thinkingTimeLimit > 0) {
297
+ // Spread the time budget across 3 sources
298
+ scrapeCount = 3;
299
+ perSourceTimeout = Math.max(2, Math.floor(thinkingTimeLimit / scrapeCount));
300
+ } else if (sourceExtractionEnabled) {
301
+ scrapeCount = 3;
302
+ // Default to 5 seconds if no config function available
303
+ perSourceTimeout = 5;
304
+ } else {
305
+ scrapeCount = 0;
306
+ perSourceTimeout = 0;
307
+ }
308
+
309
+ if (scrapeCount > 0) {
310
+ const docsToScrape = documents.slice(0, scrapeCount);
311
+ emitSearching("running", `Extracting top ${docsToScrape.length} sources`, "extract");
312
+
313
+ const extractionTasks = this.config.scrapeURL ? docsToScrape.map(async (doc, idx) => {
314
+ const url = doc.metadata?.url;
315
+ if (!url || !this.config.scrapeURL) return;
316
+ try {
317
+ const result = await waitWithTimeout(
318
+ this.config.scrapeURL(url, { timeout: perSourceTimeout }),
319
+ perSourceTimeout * 1000 + 1500,
320
+ );
321
+ if (typeof result === "string" && result.length > 100) {
322
+ const text = htmlToText(result)
323
+ .replace(/(\r\n|\n|\r)/gm, " ")
324
+ .replace(/\s+/g, " ")
325
+ .trim()
326
+ .slice(0, 5000);
327
+ if (text.length > 100) {
328
+ documents[idx].pageContent = text;
329
+ }
330
+ }
331
+ } catch {
332
+ // Keep original snippet on scraping failure or timeout
333
+ }
334
+ }) : [];
335
+
336
+ await Promise.allSettled(extractionTasks);
337
+ emitSearching("done", `Extracting top ${docsToScrape.length} sources`, "extract");
338
+ }
339
+
340
+ return { query: question, docs: documents };
341
+ }
342
+
343
+ /**
344
+ * Runs the full search-and-answer pipeline, emitting "sources",
345
+ * "response", "searching", "end", and "error" events on the emitter.
346
+ */
347
+ private async runPipeline(
348
+ emitter: EventEmitter,
349
+ message: string,
350
+ history: ChatTurnMessage[],
351
+ llm: LanguageModel,
352
+ optimizationMode: "speed" | "balanced" | "quality",
353
+ fileIds: string[],
354
+ systemInstructions: string,
355
+ category: string,
356
+ sourceExtractionEnabled: boolean,
357
+ thinkingTimeLimit: number,
358
+ queryExpansionPrompt?: string,
359
+ ): Promise<void> {
360
+ try {
361
+ let docs: Document[] | null = null;
362
+ let query = message;
363
+
364
+ if (this.config.searchWeb) {
365
+ const result = await this.retrieveSearchDocs(
366
+ llm,
367
+ formatChatHistoryAsString(history),
368
+ message,
369
+ category,
370
+ sourceExtractionEnabled,
371
+ thinkingTimeLimit,
372
+ emitter,
373
+ queryExpansionPrompt,
374
+ );
375
+ query = result.query;
376
+ docs = result.docs;
377
+ }
378
+
379
+ const r2Credentials: R2CredentialsInput | undefined = process.env.R2_ACCOUNT_ID
380
+ ? {
381
+ accountId: process.env.R2_ACCOUNT_ID,
382
+ accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
383
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
384
+ bucket: process.env.R2_UPLOADS_BUCKET || "qwksearch-uploads",
385
+ }
386
+ : undefined;
387
+
388
+ // Resolved once and shared below: the answer context needs the text and
389
+ // the message needs the images, and fetching each attachment twice is how
390
+ // a handful of large uploads used to reach the isolate's memory ceiling.
391
+ const uploads = await loadUploads(fileIds, r2Credentials);
392
+
393
+ const sortedDocs = await rerankDocs(
394
+ query,
395
+ docs ?? [],
396
+ fileIds,
397
+ optimizationMode,
398
+ r2Credentials,
399
+ uploads,
400
+ );
401
+
402
+ const sources = normalizeSourcesOutput(sortedDocs, message);
403
+ console.log("[MetaSearchAgent] emitting sources:", sources.length);
404
+ emitter.emit("data", JSON.stringify({ type: "sources", data: sources }));
405
+
406
+ const systemPrompt = interpolatePrompt(this.config.responsePrompt, {
407
+ systemInstructions,
408
+ context: processDocs(sortedDocs),
409
+ date: new Date().toISOString(),
410
+ });
411
+
412
+ // Uploaded images are passed to the LLM directly as image content parts
413
+ // (alongside the text query). Documents already reach the model as text
414
+ // context via processDocs above.
415
+ const imageAttachments = selectUploadImages(uploads);
416
+ const userContent =
417
+ imageAttachments.length > 0
418
+ ? [
419
+ { type: "text" as const, text: message },
420
+ ...imageAttachments.map((img) => ({
421
+ type: "image" as const,
422
+ image: img.image,
423
+ })),
424
+ ]
425
+ : message;
426
+
427
+ const result = streamText({
428
+ model: llm,
429
+ temperature: 0.7,
430
+ system: systemPrompt,
431
+ messages: [...history, { role: "user", content: userContent }],
432
+ });
433
+
434
+ let responseChunkCount = 0;
435
+ for await (const chunk of result.textStream) {
436
+ responseChunkCount += 1;
437
+ emitter.emit("data", JSON.stringify({ type: "response", data: chunk }));
438
+ // `emit` is synchronous: without this the whole model stream is pulled
439
+ // into memory at the speed of the model while the consumer writes it
440
+ // out at the speed of the client. A consumer that installs no
441
+ // `waitForDrain` keeps the old behaviour.
442
+ await (emitter as DrainableEmitter).waitForDrain?.();
443
+ }
444
+ console.log("[MetaSearchAgent] response stream ended, chunks:", responseChunkCount);
445
+
446
+ if (responseChunkCount === 0) {
447
+ const fallbackUrls = sources
448
+ .map((doc) => doc.metadata?.url)
449
+ .filter((url): url is string => typeof url === "string")
450
+ .slice(0, 5);
451
+
452
+ const fallbackMessage = [
453
+ "I couldn't generate a full answer, but I ran a web search and found these source URLs:",
454
+ ...fallbackUrls.map((url) => `- ${url}`),
455
+ ].join("\n");
456
+
457
+ emitter.emit("data", JSON.stringify({ type: "response", data: fallbackMessage }));
458
+ }
459
+
460
+ emitter.emit("end");
461
+ } catch (err) {
462
+ const errMessage = err instanceof Error ? err.message : String(err);
463
+ console.error("[MetaSearchAgent] caught error from AI SDK stream:", errMessage, err);
464
+ let userMessage = errMessage;
465
+ if (errMessage.includes("404") || errMessage.toLowerCase().includes("not found")) {
466
+ userMessage =
467
+ "The selected AI model was not found at the provider. Please go to Settings → Model Providers and select a different model.";
468
+ } else if (errMessage.includes("410")) {
469
+ userMessage =
470
+ "The selected AI model is no longer available (deprecated by the provider). Please go to Settings → Model Providers and select a different model.";
471
+ } else if (errMessage.includes("401") || errMessage.includes("authentication")) {
472
+ userMessage =
473
+ "Authentication failed with the AI provider. Please check your API key in Settings.";
474
+ } else if (errMessage.includes("429") || errMessage.includes("rate limit")) {
475
+ userMessage =
476
+ "Rate limit reached for the AI provider. Please wait a moment and try again.";
477
+ }
478
+ emitter.emit("error", JSON.stringify({ data: userMessage }));
479
+ }
480
+ }
481
+
482
+ async searchAndAnswer(
483
+ message: string,
484
+ history: ChatTurnMessage[],
485
+ llm: LanguageModel,
486
+ optimizationMode: "speed" | "balanced" | "quality",
487
+ fileIds: string[],
488
+ systemInstructions: string,
489
+ category: string = "general",
490
+ sourceExtractionEnabled = false,
491
+ thinkingTimeLimit = 0,
492
+ queryExpansionPrompt?: string,
493
+ ) {
494
+ const emitter = new EventEmitter();
495
+
496
+ // Start on the next macrotask so callers can attach their event
497
+ // listeners before the first "sources"/"response" event can fire.
498
+ setTimeout(() => {
499
+ this.runPipeline(
500
+ emitter,
501
+ message,
502
+ history,
503
+ llm,
504
+ optimizationMode,
505
+ fileIds,
506
+ systemInstructions,
507
+ category,
508
+ sourceExtractionEnabled,
509
+ thinkingTimeLimit,
510
+ queryExpansionPrompt,
511
+ );
512
+ }, 0);
513
+
514
+ return emitter;
515
+ }
516
+ }
517
+
518
+ export default MetaSearchAgent;