chat-agent-toolkit 1.2.32 → 1.2.34

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.
Files changed (67) hide show
  1. package/dist/research-agent.cjs.js +1 -1
  2. package/dist/research-agent.cjs.js.map +1 -1
  3. package/dist/research-agent.es.js +1 -1
  4. package/dist/research-agent.es.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/config/config-manager.ts +295 -295
  7. package/src/config/config-types.ts +65 -65
  8. package/src/config/environment-variables.ts +16 -16
  9. package/src/config/index.ts +26 -26
  10. package/src/config/language-models-database.ts +1434 -1434
  11. package/src/config/mcp-server-registry.ts +24 -24
  12. package/src/config/model-registry.ts +271 -271
  13. package/src/config/model-tester.ts +211 -211
  14. package/src/config/model-utils.ts +193 -193
  15. package/src/config/provider-ui-config.ts +196 -196
  16. package/src/connectors/open-connector.json +130 -130
  17. package/src/index.ts +26 -26
  18. package/src/memory/ARCHITECTURE.md +302 -302
  19. package/src/memory/README.md +224 -224
  20. package/src/memory/agent-memory-manager.ts +416 -416
  21. package/src/memory/example.ts +343 -343
  22. package/src/memory/index.ts +47 -47
  23. package/src/memory/mastra-integration.ts +604 -604
  24. package/src/memory/storage/drizzle-storage.ts +236 -236
  25. package/src/memory/storage/in-memory-storage.ts +551 -551
  26. package/src/memory/storage/storage-interface.ts +68 -68
  27. package/src/memory/types.ts +125 -125
  28. package/src/models/types.ts +4 -4
  29. package/src/provider-logos/01-ai.png +0 -0
  30. package/src/provider-logos/anthropic.png +0 -0
  31. package/src/provider-logos/aws-bedrock.png +0 -0
  32. package/src/provider-logos/aws-sagemaker.png +0 -0
  33. package/src/provider-logos/azure-openai-service.png +0 -0
  34. package/src/provider-logos/chatglm.png +0 -0
  35. package/src/provider-logos/cohere.png +0 -0
  36. package/src/provider-logos/gemini.png +0 -0
  37. package/src/provider-logos/groqcloud.png +0 -0
  38. package/src/provider-logos/huggingface.png +0 -0
  39. package/src/provider-logos/jina.png +0 -0
  40. package/src/provider-logos/localai.png +0 -0
  41. package/src/provider-logos/mistral-ai.png +0 -0
  42. package/src/provider-logos/moonshot-ai.png +0 -0
  43. package/src/provider-logos/ollama.png +0 -0
  44. package/src/provider-logos/openai.png +0 -0
  45. package/src/provider-logos/openllm.png +0 -0
  46. package/src/provider-logos/openrouter.png +0 -0
  47. package/src/provider-logos/replicate.png +0 -0
  48. package/src/provider-logos/together-ai.png +0 -0
  49. package/src/provider-logos/tongyi.png +0 -0
  50. package/src/provider-logos/xorbits-inference.png +0 -0
  51. package/src/provider-logos/zhipu-ai.png +0 -0
  52. package/src/tools/index.ts +13 -13
  53. package/src/tools/open-connector-mastra.ts +270 -270
  54. package/src/tools/open-connector-mcp.ts +170 -170
  55. package/src/tools/qwksearch-api-tools.ts +326 -326
  56. package/src/tools/search/doc-utils.ts +139 -139
  57. package/src/tools/search/document.ts +47 -47
  58. package/src/tools/search/index.ts +14 -14
  59. package/src/tools/search/link-summarizer.ts +112 -112
  60. package/src/tools/search/meta-search-types.ts +62 -62
  61. package/src/tools/search/metaSearchAgent.ts +465 -465
  62. package/src/tools/search/search-handlers.ts +97 -97
  63. package/src/tools/search/suggestionGeneratorAgent.ts +56 -56
  64. package/src/types.d.ts +137 -137
  65. package/src/utils/index.ts +2 -2
  66. package/src/utils/markdown-to-html.ts +53 -53
  67. package/src/utils/outputParser.ts +73 -73
@@ -1,465 +1,465 @@
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
- type R2CredentialsInput,
39
- } from "./doc-utils";
40
- import { groupAndSummarizeDocs } from "./link-summarizer";
41
-
42
- export type { MetaSearchAgentType, Config } from "./meta-search-types";
43
-
44
- const waitWithTimeout = async <T>(
45
- promise: Promise<T>,
46
- timeoutMs: number,
47
- ): Promise<T | undefined> => {
48
- return Promise.race([
49
- promise,
50
- new Promise<undefined>((resolve) => {
51
- setTimeout(() => resolve(undefined), timeoutMs);
52
- }),
53
- ]);
54
- };
55
-
56
- /**
57
- * Substitutes `{key}` placeholders in a prompt template with the provided
58
- * values, leaving unknown placeholders untouched.
59
- */
60
- const interpolatePrompt = (
61
- template: string,
62
- vars: Record<string, string>,
63
- ): string => {
64
- return Object.entries(vars).reduce(
65
- (result, [key, value]) => result.split(`{${key}}`).join(value),
66
- template,
67
- );
68
- };
69
-
70
- class MetaSearchAgent implements MetaSearchAgentType {
71
- private config: Config;
72
-
73
- constructor(config: Config) {
74
- this.config = config;
75
- }
76
-
77
- /**
78
- * Rephrases the user's query into a standalone search question (and any
79
- * URLs to summarize), runs the web search, and returns the documents to
80
- * use as answer context.
81
- */
82
- private async retrieveSearchDocs(
83
- llm: LanguageModel,
84
- chatHistory: string,
85
- query: string,
86
- category: string = "general",
87
- sourceExtractionEnabled = false,
88
- thinkingTimeLimit = 0,
89
- emitter?: EventEmitter,
90
- ): Promise<{ query: string; docs: Document[] }> {
91
- const { text: retrieverOutput } = await generateText({
92
- model: llm,
93
- temperature: 0,
94
- system: this.config.queryGeneratorPrompt,
95
- messages: [
96
- ...this.config.queryGeneratorFewShots.map(([role, content]) => ({
97
- role,
98
- content,
99
- })),
100
- {
101
- role: "user",
102
- content: `
103
- <conversation>
104
- ${chatHistory}
105
- </conversation>
106
-
107
- <query>
108
- ${query}
109
- </query>
110
- `,
111
- },
112
- ],
113
- });
114
-
115
- const linksOutputParser = new LineListOutputParser({ key: "links" });
116
- const questionOutputParser = new LineOutputParser({ key: "question" });
117
-
118
- const links = await linksOutputParser.parse(retrieverOutput);
119
- let question =
120
- (await questionOutputParser.parse(retrieverOutput)) ?? retrieverOutput;
121
-
122
- if (question === "not_needed") {
123
- question = "latest information";
124
- }
125
-
126
- if (links.length > 0 && this.config.getDocumentsFromLinks) {
127
- if (question.length === 0) question = "summarize";
128
-
129
- const linkDocs = await this.config.getDocumentsFromLinks({ links });
130
- const docs = await groupAndSummarizeDocs(llm, linkDocs, question);
131
-
132
- return { query: question, docs };
133
- }
134
-
135
- question = question.replace(/<think>.*?<\/think>/g, "");
136
- if (!question || question.trim().length === 0) {
137
- question = "latest information";
138
- }
139
-
140
- // Validate and sanitize the query before sending to search APIs
141
- // If the LLM returned a long response instead of a concise query, extract the first sentence
142
- // or use the original user query as fallback
143
- question = question.trim();
144
- if (question.length > 500 || question.split(/[.!?]\s+/).length > 5) {
145
- // Query is too long or contains too many sentences - likely the LLM returned a full response
146
- console.warn(`[MetaSearchAgent] Query is too long (${question.length} chars), truncating or using fallback`);
147
-
148
- // Try to extract first sentence as the query
149
- const firstSentence = question.split(/[.!?]\s+/)[0].trim();
150
- if (firstSentence.length > 0 && firstSentence.length < 200) {
151
- question = firstSentence;
152
- } else {
153
- // Fallback to original user query
154
- question = query.slice(0, 200);
155
- }
156
- }
157
-
158
- // Emit "searching" progress event so the client can show live status
159
- const categoryLabel = this.config.activeEngines.length > 0
160
- ? this.config.activeEngines.slice(0, 2).join(", ")
161
- : "Web";
162
- const emitSearching = (status: SearchingEvent["status"], query: string, cat?: string) => {
163
- emitter?.emit("data", JSON.stringify({
164
- type: "searching",
165
- data: { query, category: cat ?? categoryLabel, status } satisfies SearchingEvent,
166
- }));
167
- };
168
-
169
- emitSearching("running", question);
170
-
171
- let res: { results: any[]; suggestions: string[] } = { results: [], suggestions: [] };
172
-
173
- // If no search functions provided, return empty results
174
- if (!this.config.searchSearxng && !this.config.searchTavily) {
175
- res = { results: [], suggestions: [] };
176
- } else {
177
- const runSearxng = async () => {
178
- try {
179
- const result = await this.config.searchSearxng!(question, {
180
- language: "en",
181
- engines: this.config.activeEngines,
182
- categories: [category],
183
- });
184
- // Ensure result has the expected structure
185
- return result && typeof result === 'object' && 'results' in result
186
- ? result
187
- : { results: [], suggestions: [] };
188
- } catch (error) {
189
- console.error("[MetaSearchAgent] SearXNG search failed:", error);
190
- return { results: [], suggestions: [] };
191
- }
192
- };
193
-
194
- const isTavilyConfigured = this.config.isTavilyConfigured?.() ?? false;
195
-
196
- if (
197
- isTavilyConfigured &&
198
- this.config.activeEngines.length === 0 &&
199
- category === "general"
200
- ) {
201
- try {
202
- const tavilyResult = await this.config.searchTavily!(question, { searchDepth: "basic", maxResults: 10 });
203
- res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
204
- ? tavilyResult
205
- : { results: [], suggestions: [] };
206
- } catch (error) {
207
- console.error("Tavily search failed, falling back to SearXNG:", error);
208
- res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
209
- }
210
- } else {
211
- if (isTavilyConfigured && this.config.searchTavily) {
212
- try {
213
- res = await Promise.race([
214
- runSearxng(),
215
- new Promise<{ results: any[]; suggestions: string[] }>((_, reject) =>
216
- setTimeout(() => reject(new Error("Timeout")), 10000)
217
- )
218
- ]);
219
- } catch (err: any) {
220
- if (err.message === "Timeout") {
221
- console.warn("[MetaSearchAgent] SearXNG search did not respond in 10 seconds, falling back to Tavily.");
222
- try {
223
- const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
224
- res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
225
- ? tavilyResult
226
- : { results: [], suggestions: [] };
227
- } catch (tavilyErr) {
228
- console.error("[MetaSearchAgent] Tavily fallback also failed, awaiting SearXNG directly:", tavilyErr);
229
- res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
230
- }
231
- } else {
232
- console.error("[MetaSearchAgent] SearXNG search failed, falling back to Tavily:", err);
233
- try {
234
- const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
235
- res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
236
- ? tavilyResult
237
- : { results: [], suggestions: [] };
238
- } catch (tavilyErr) {
239
- console.error("[MetaSearchAgent] Tavily fallback also failed:", tavilyErr);
240
- res = { results: [], suggestions: [] };
241
- }
242
- }
243
- }
244
- } else {
245
- res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
246
- }
247
- }
248
- }
249
-
250
- let documents: Document[] = (res?.results ?? []).map((result) => ({
251
- pageContent:
252
- result.content ||
253
- (this.config.activeEngines.includes("youtube") ? result.title : ""),
254
- metadata: {
255
- title: result.title,
256
- url: result.url,
257
- source: result.source,
258
- ...(result.img_src && { img_src: result.img_src }),
259
- },
260
- }));
261
-
262
- if (documents.length === 0) {
263
- documents = buildFallbackDocs(question);
264
- }
265
-
266
- emitSearching("done", question);
267
-
268
- // Determine extraction budget from thinkingTimeLimit (seconds).
269
- // thinkingTimeLimit === 0 means unlimited; use server config.
270
- let scrapeCount: number;
271
- let perSourceTimeout: number;
272
-
273
- if (thinkingTimeLimit > 0) {
274
- // Spread the time budget across 3 sources
275
- scrapeCount = 3;
276
- perSourceTimeout = Math.max(2, Math.floor(thinkingTimeLimit / scrapeCount));
277
- } else if (sourceExtractionEnabled) {
278
- scrapeCount = 3;
279
- // Default to 5 seconds if no config function available
280
- perSourceTimeout = 5;
281
- } else {
282
- scrapeCount = 0;
283
- perSourceTimeout = 0;
284
- }
285
-
286
- if (scrapeCount > 0) {
287
- const docsToScrape = documents.slice(0, scrapeCount);
288
- emitSearching("running", `Extracting top ${docsToScrape.length} sources`, "extract");
289
-
290
- const extractionTasks = this.config.scrapeURL ? docsToScrape.map(async (doc, idx) => {
291
- const url = doc.metadata?.url;
292
- if (!url || !this.config.scrapeURL) return;
293
- try {
294
- const result = await waitWithTimeout(
295
- this.config.scrapeURL(url, { timeout: perSourceTimeout }),
296
- perSourceTimeout * 1000 + 1500,
297
- );
298
- if (typeof result === "string" && result.length > 100) {
299
- const text = htmlToText(result)
300
- .replace(/(\r\n|\n|\r)/gm, " ")
301
- .replace(/\s+/g, " ")
302
- .trim()
303
- .slice(0, 5000);
304
- if (text.length > 100) {
305
- documents[idx].pageContent = text;
306
- }
307
- }
308
- } catch {
309
- // Keep original snippet on scraping failure or timeout
310
- }
311
- }) : [];
312
-
313
- await Promise.allSettled(extractionTasks);
314
- emitSearching("done", `Extracting top ${docsToScrape.length} sources`, "extract");
315
- }
316
-
317
- return { query: question, docs: documents };
318
- }
319
-
320
- /**
321
- * Runs the full search-and-answer pipeline, emitting "sources",
322
- * "response", "searching", "end", and "error" events on the emitter.
323
- */
324
- private async runPipeline(
325
- emitter: EventEmitter,
326
- message: string,
327
- history: ChatTurnMessage[],
328
- llm: LanguageModel,
329
- optimizationMode: "speed" | "balanced" | "quality",
330
- fileIds: string[],
331
- systemInstructions: string,
332
- category: string,
333
- sourceExtractionEnabled: boolean,
334
- thinkingTimeLimit: number,
335
- ): Promise<void> {
336
- try {
337
- let docs: Document[] | null = null;
338
- let query = message;
339
-
340
- if (this.config.searchWeb) {
341
- const result = await this.retrieveSearchDocs(
342
- llm,
343
- formatChatHistoryAsString(history),
344
- message,
345
- category,
346
- sourceExtractionEnabled,
347
- thinkingTimeLimit,
348
- emitter,
349
- );
350
- query = result.query;
351
- docs = result.docs;
352
- }
353
-
354
- const r2Credentials: R2CredentialsInput | undefined = process.env.R2_ACCOUNT_ID
355
- ? {
356
- accountId: process.env.R2_ACCOUNT_ID,
357
- accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
358
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
359
- bucket: process.env.R2_UPLOADS_BUCKET || "qwksearch-uploads",
360
- }
361
- : undefined;
362
-
363
- const sortedDocs = await rerankDocs(
364
- query,
365
- docs ?? [],
366
- fileIds,
367
- optimizationMode,
368
- r2Credentials,
369
- );
370
-
371
- const sources = normalizeSourcesOutput(sortedDocs, message);
372
- console.log("[MetaSearchAgent] emitting sources:", sources.length);
373
- emitter.emit("data", JSON.stringify({ type: "sources", data: sources }));
374
-
375
- const systemPrompt = interpolatePrompt(this.config.responsePrompt, {
376
- systemInstructions,
377
- context: processDocs(sortedDocs),
378
- date: new Date().toISOString(),
379
- });
380
-
381
- const result = streamText({
382
- model: llm,
383
- temperature: 0.7,
384
- system: systemPrompt,
385
- messages: [...history, { role: "user", content: message }],
386
- });
387
-
388
- let responseChunkCount = 0;
389
- for await (const chunk of result.textStream) {
390
- responseChunkCount += 1;
391
- emitter.emit("data", JSON.stringify({ type: "response", data: chunk }));
392
- }
393
- console.log("[MetaSearchAgent] response stream ended, chunks:", responseChunkCount);
394
-
395
- if (responseChunkCount === 0) {
396
- const fallbackUrls = sources
397
- .map((doc) => doc.metadata?.url)
398
- .filter((url): url is string => typeof url === "string")
399
- .slice(0, 5);
400
-
401
- const fallbackMessage = [
402
- "I couldn't generate a full answer, but I ran a web search and found these source URLs:",
403
- ...fallbackUrls.map((url) => `- ${url}`),
404
- ].join("\n");
405
-
406
- emitter.emit("data", JSON.stringify({ type: "response", data: fallbackMessage }));
407
- }
408
-
409
- emitter.emit("end");
410
- } catch (err) {
411
- const errMessage = err instanceof Error ? err.message : String(err);
412
- console.error("[MetaSearchAgent] caught error from AI SDK stream:", errMessage, err);
413
- let userMessage = errMessage;
414
- if (errMessage.includes("404") || errMessage.toLowerCase().includes("not found")) {
415
- userMessage =
416
- "The selected AI model was not found at the provider. Please go to Settings → Model Providers and select a different model.";
417
- } else if (errMessage.includes("410")) {
418
- userMessage =
419
- "The selected AI model is no longer available (deprecated by the provider). Please go to Settings → Model Providers and select a different model.";
420
- } else if (errMessage.includes("401") || errMessage.includes("authentication")) {
421
- userMessage =
422
- "Authentication failed with the AI provider. Please check your API key in Settings.";
423
- } else if (errMessage.includes("429") || errMessage.includes("rate limit")) {
424
- userMessage =
425
- "Rate limit reached for the AI provider. Please wait a moment and try again.";
426
- }
427
- emitter.emit("error", JSON.stringify({ data: userMessage }));
428
- }
429
- }
430
-
431
- async searchAndAnswer(
432
- message: string,
433
- history: ChatTurnMessage[],
434
- llm: LanguageModel,
435
- optimizationMode: "speed" | "balanced" | "quality",
436
- fileIds: string[],
437
- systemInstructions: string,
438
- category: string = "general",
439
- sourceExtractionEnabled = false,
440
- thinkingTimeLimit = 0,
441
- ) {
442
- const emitter = new EventEmitter();
443
-
444
- // Start on the next macrotask so callers can attach their event
445
- // listeners before the first "sources"/"response" event can fire.
446
- setTimeout(() => {
447
- this.runPipeline(
448
- emitter,
449
- message,
450
- history,
451
- llm,
452
- optimizationMode,
453
- fileIds,
454
- systemInstructions,
455
- category,
456
- sourceExtractionEnabled,
457
- thinkingTimeLimit,
458
- );
459
- }, 0);
460
-
461
- return emitter;
462
- }
463
- }
464
-
465
- 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
+ /** 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
+ type R2CredentialsInput,
39
+ } from "./doc-utils";
40
+ import { groupAndSummarizeDocs } from "./link-summarizer";
41
+
42
+ export type { MetaSearchAgentType, Config } from "./meta-search-types";
43
+
44
+ const waitWithTimeout = async <T>(
45
+ promise: Promise<T>,
46
+ timeoutMs: number,
47
+ ): Promise<T | undefined> => {
48
+ return Promise.race([
49
+ promise,
50
+ new Promise<undefined>((resolve) => {
51
+ setTimeout(() => resolve(undefined), timeoutMs);
52
+ }),
53
+ ]);
54
+ };
55
+
56
+ /**
57
+ * Substitutes `{key}` placeholders in a prompt template with the provided
58
+ * values, leaving unknown placeholders untouched.
59
+ */
60
+ const interpolatePrompt = (
61
+ template: string,
62
+ vars: Record<string, string>,
63
+ ): string => {
64
+ return Object.entries(vars).reduce(
65
+ (result, [key, value]) => result.split(`{${key}}`).join(value),
66
+ template,
67
+ );
68
+ };
69
+
70
+ class MetaSearchAgent implements MetaSearchAgentType {
71
+ private config: Config;
72
+
73
+ constructor(config: Config) {
74
+ this.config = config;
75
+ }
76
+
77
+ /**
78
+ * Rephrases the user's query into a standalone search question (and any
79
+ * URLs to summarize), runs the web search, and returns the documents to
80
+ * use as answer context.
81
+ */
82
+ private async retrieveSearchDocs(
83
+ llm: LanguageModel,
84
+ chatHistory: string,
85
+ query: string,
86
+ category: string = "general",
87
+ sourceExtractionEnabled = false,
88
+ thinkingTimeLimit = 0,
89
+ emitter?: EventEmitter,
90
+ ): Promise<{ query: string; docs: Document[] }> {
91
+ const { text: retrieverOutput } = await generateText({
92
+ model: llm,
93
+ temperature: 0,
94
+ system: this.config.queryGeneratorPrompt,
95
+ messages: [
96
+ ...this.config.queryGeneratorFewShots.map(([role, content]) => ({
97
+ role,
98
+ content,
99
+ })),
100
+ {
101
+ role: "user",
102
+ content: `
103
+ <conversation>
104
+ ${chatHistory}
105
+ </conversation>
106
+
107
+ <query>
108
+ ${query}
109
+ </query>
110
+ `,
111
+ },
112
+ ],
113
+ });
114
+
115
+ const linksOutputParser = new LineListOutputParser({ key: "links" });
116
+ const questionOutputParser = new LineOutputParser({ key: "question" });
117
+
118
+ const links = await linksOutputParser.parse(retrieverOutput);
119
+ let question =
120
+ (await questionOutputParser.parse(retrieverOutput)) ?? retrieverOutput;
121
+
122
+ if (question === "not_needed") {
123
+ question = "latest information";
124
+ }
125
+
126
+ if (links.length > 0 && this.config.getDocumentsFromLinks) {
127
+ if (question.length === 0) question = "summarize";
128
+
129
+ const linkDocs = await this.config.getDocumentsFromLinks({ links });
130
+ const docs = await groupAndSummarizeDocs(llm, linkDocs, question);
131
+
132
+ return { query: question, docs };
133
+ }
134
+
135
+ question = question.replace(/<think>.*?<\/think>/g, "");
136
+ if (!question || question.trim().length === 0) {
137
+ question = "latest information";
138
+ }
139
+
140
+ // Validate and sanitize the query before sending to search APIs
141
+ // If the LLM returned a long response instead of a concise query, extract the first sentence
142
+ // or use the original user query as fallback
143
+ question = question.trim();
144
+ if (question.length > 500 || question.split(/[.!?]\s+/).length > 5) {
145
+ // Query is too long or contains too many sentences - likely the LLM returned a full response
146
+ console.warn(`[MetaSearchAgent] Query is too long (${question.length} chars), truncating or using fallback`);
147
+
148
+ // Try to extract first sentence as the query
149
+ const firstSentence = question.split(/[.!?]\s+/)[0].trim();
150
+ if (firstSentence.length > 0 && firstSentence.length < 200) {
151
+ question = firstSentence;
152
+ } else {
153
+ // Fallback to original user query
154
+ question = query.slice(0, 200);
155
+ }
156
+ }
157
+
158
+ // Emit "searching" progress event so the client can show live status
159
+ const categoryLabel = this.config.activeEngines.length > 0
160
+ ? this.config.activeEngines.slice(0, 2).join(", ")
161
+ : "Web";
162
+ const emitSearching = (status: SearchingEvent["status"], query: string, cat?: string) => {
163
+ emitter?.emit("data", JSON.stringify({
164
+ type: "searching",
165
+ data: { query, category: cat ?? categoryLabel, status } satisfies SearchingEvent,
166
+ }));
167
+ };
168
+
169
+ emitSearching("running", question);
170
+
171
+ let res: { results: any[]; suggestions: string[] } = { results: [], suggestions: [] };
172
+
173
+ // If no search functions provided, return empty results
174
+ if (!this.config.searchSearxng && !this.config.searchTavily) {
175
+ res = { results: [], suggestions: [] };
176
+ } else {
177
+ const runSearxng = async () => {
178
+ try {
179
+ const result = await this.config.searchSearxng!(question, {
180
+ language: "en",
181
+ engines: this.config.activeEngines,
182
+ categories: [category],
183
+ });
184
+ // Ensure result has the expected structure
185
+ return result && typeof result === 'object' && 'results' in result
186
+ ? result
187
+ : { results: [], suggestions: [] };
188
+ } catch (error) {
189
+ console.error("[MetaSearchAgent] SearXNG search failed:", error);
190
+ return { results: [], suggestions: [] };
191
+ }
192
+ };
193
+
194
+ const isTavilyConfigured = this.config.isTavilyConfigured?.() ?? false;
195
+
196
+ if (
197
+ isTavilyConfigured &&
198
+ this.config.activeEngines.length === 0 &&
199
+ category === "general"
200
+ ) {
201
+ try {
202
+ const tavilyResult = await this.config.searchTavily!(question, { searchDepth: "basic", maxResults: 10 });
203
+ res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
204
+ ? tavilyResult
205
+ : { results: [], suggestions: [] };
206
+ } catch (error) {
207
+ console.error("Tavily search failed, falling back to SearXNG:", error);
208
+ res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
209
+ }
210
+ } else {
211
+ if (isTavilyConfigured && this.config.searchTavily) {
212
+ try {
213
+ res = await Promise.race([
214
+ runSearxng(),
215
+ new Promise<{ results: any[]; suggestions: string[] }>((_, reject) =>
216
+ setTimeout(() => reject(new Error("Timeout")), 10000)
217
+ )
218
+ ]);
219
+ } catch (err: any) {
220
+ if (err.message === "Timeout") {
221
+ console.warn("[MetaSearchAgent] SearXNG search did not respond in 10 seconds, falling back to Tavily.");
222
+ try {
223
+ const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
224
+ res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
225
+ ? tavilyResult
226
+ : { results: [], suggestions: [] };
227
+ } catch (tavilyErr) {
228
+ console.error("[MetaSearchAgent] Tavily fallback also failed, awaiting SearXNG directly:", tavilyErr);
229
+ res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
230
+ }
231
+ } else {
232
+ console.error("[MetaSearchAgent] SearXNG search failed, falling back to Tavily:", err);
233
+ try {
234
+ const tavilyResult = await this.config.searchTavily(question, { searchDepth: "basic", maxResults: 10 });
235
+ res = tavilyResult && typeof tavilyResult === 'object' && 'results' in tavilyResult
236
+ ? tavilyResult
237
+ : { results: [], suggestions: [] };
238
+ } catch (tavilyErr) {
239
+ console.error("[MetaSearchAgent] Tavily fallback also failed:", tavilyErr);
240
+ res = { results: [], suggestions: [] };
241
+ }
242
+ }
243
+ }
244
+ } else {
245
+ res = this.config.searchSearxng ? await runSearxng() : { results: [], suggestions: [] };
246
+ }
247
+ }
248
+ }
249
+
250
+ let documents: Document[] = (res?.results ?? []).map((result) => ({
251
+ pageContent:
252
+ result.content ||
253
+ (this.config.activeEngines.includes("youtube") ? result.title : ""),
254
+ metadata: {
255
+ title: result.title,
256
+ url: result.url,
257
+ source: result.source,
258
+ ...(result.img_src && { img_src: result.img_src }),
259
+ },
260
+ }));
261
+
262
+ if (documents.length === 0) {
263
+ documents = buildFallbackDocs(question);
264
+ }
265
+
266
+ emitSearching("done", question);
267
+
268
+ // Determine extraction budget from thinkingTimeLimit (seconds).
269
+ // thinkingTimeLimit === 0 means unlimited; use server config.
270
+ let scrapeCount: number;
271
+ let perSourceTimeout: number;
272
+
273
+ if (thinkingTimeLimit > 0) {
274
+ // Spread the time budget across 3 sources
275
+ scrapeCount = 3;
276
+ perSourceTimeout = Math.max(2, Math.floor(thinkingTimeLimit / scrapeCount));
277
+ } else if (sourceExtractionEnabled) {
278
+ scrapeCount = 3;
279
+ // Default to 5 seconds if no config function available
280
+ perSourceTimeout = 5;
281
+ } else {
282
+ scrapeCount = 0;
283
+ perSourceTimeout = 0;
284
+ }
285
+
286
+ if (scrapeCount > 0) {
287
+ const docsToScrape = documents.slice(0, scrapeCount);
288
+ emitSearching("running", `Extracting top ${docsToScrape.length} sources`, "extract");
289
+
290
+ const extractionTasks = this.config.scrapeURL ? docsToScrape.map(async (doc, idx) => {
291
+ const url = doc.metadata?.url;
292
+ if (!url || !this.config.scrapeURL) return;
293
+ try {
294
+ const result = await waitWithTimeout(
295
+ this.config.scrapeURL(url, { timeout: perSourceTimeout }),
296
+ perSourceTimeout * 1000 + 1500,
297
+ );
298
+ if (typeof result === "string" && result.length > 100) {
299
+ const text = htmlToText(result)
300
+ .replace(/(\r\n|\n|\r)/gm, " ")
301
+ .replace(/\s+/g, " ")
302
+ .trim()
303
+ .slice(0, 5000);
304
+ if (text.length > 100) {
305
+ documents[idx].pageContent = text;
306
+ }
307
+ }
308
+ } catch {
309
+ // Keep original snippet on scraping failure or timeout
310
+ }
311
+ }) : [];
312
+
313
+ await Promise.allSettled(extractionTasks);
314
+ emitSearching("done", `Extracting top ${docsToScrape.length} sources`, "extract");
315
+ }
316
+
317
+ return { query: question, docs: documents };
318
+ }
319
+
320
+ /**
321
+ * Runs the full search-and-answer pipeline, emitting "sources",
322
+ * "response", "searching", "end", and "error" events on the emitter.
323
+ */
324
+ private async runPipeline(
325
+ emitter: EventEmitter,
326
+ message: string,
327
+ history: ChatTurnMessage[],
328
+ llm: LanguageModel,
329
+ optimizationMode: "speed" | "balanced" | "quality",
330
+ fileIds: string[],
331
+ systemInstructions: string,
332
+ category: string,
333
+ sourceExtractionEnabled: boolean,
334
+ thinkingTimeLimit: number,
335
+ ): Promise<void> {
336
+ try {
337
+ let docs: Document[] | null = null;
338
+ let query = message;
339
+
340
+ if (this.config.searchWeb) {
341
+ const result = await this.retrieveSearchDocs(
342
+ llm,
343
+ formatChatHistoryAsString(history),
344
+ message,
345
+ category,
346
+ sourceExtractionEnabled,
347
+ thinkingTimeLimit,
348
+ emitter,
349
+ );
350
+ query = result.query;
351
+ docs = result.docs;
352
+ }
353
+
354
+ const r2Credentials: R2CredentialsInput | undefined = process.env.R2_ACCOUNT_ID
355
+ ? {
356
+ accountId: process.env.R2_ACCOUNT_ID,
357
+ accessKeyId: process.env.R2_ACCESS_KEY_ID || "",
358
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || "",
359
+ bucket: process.env.R2_UPLOADS_BUCKET || "qwksearch-uploads",
360
+ }
361
+ : undefined;
362
+
363
+ const sortedDocs = await rerankDocs(
364
+ query,
365
+ docs ?? [],
366
+ fileIds,
367
+ optimizationMode,
368
+ r2Credentials,
369
+ );
370
+
371
+ const sources = normalizeSourcesOutput(sortedDocs, message);
372
+ console.log("[MetaSearchAgent] emitting sources:", sources.length);
373
+ emitter.emit("data", JSON.stringify({ type: "sources", data: sources }));
374
+
375
+ const systemPrompt = interpolatePrompt(this.config.responsePrompt, {
376
+ systemInstructions,
377
+ context: processDocs(sortedDocs),
378
+ date: new Date().toISOString(),
379
+ });
380
+
381
+ const result = streamText({
382
+ model: llm,
383
+ temperature: 0.7,
384
+ system: systemPrompt,
385
+ messages: [...history, { role: "user", content: message }],
386
+ });
387
+
388
+ let responseChunkCount = 0;
389
+ for await (const chunk of result.textStream) {
390
+ responseChunkCount += 1;
391
+ emitter.emit("data", JSON.stringify({ type: "response", data: chunk }));
392
+ }
393
+ console.log("[MetaSearchAgent] response stream ended, chunks:", responseChunkCount);
394
+
395
+ if (responseChunkCount === 0) {
396
+ const fallbackUrls = sources
397
+ .map((doc) => doc.metadata?.url)
398
+ .filter((url): url is string => typeof url === "string")
399
+ .slice(0, 5);
400
+
401
+ const fallbackMessage = [
402
+ "I couldn't generate a full answer, but I ran a web search and found these source URLs:",
403
+ ...fallbackUrls.map((url) => `- ${url}`),
404
+ ].join("\n");
405
+
406
+ emitter.emit("data", JSON.stringify({ type: "response", data: fallbackMessage }));
407
+ }
408
+
409
+ emitter.emit("end");
410
+ } catch (err) {
411
+ const errMessage = err instanceof Error ? err.message : String(err);
412
+ console.error("[MetaSearchAgent] caught error from AI SDK stream:", errMessage, err);
413
+ let userMessage = errMessage;
414
+ if (errMessage.includes("404") || errMessage.toLowerCase().includes("not found")) {
415
+ userMessage =
416
+ "The selected AI model was not found at the provider. Please go to Settings → Model Providers and select a different model.";
417
+ } else if (errMessage.includes("410")) {
418
+ userMessage =
419
+ "The selected AI model is no longer available (deprecated by the provider). Please go to Settings → Model Providers and select a different model.";
420
+ } else if (errMessage.includes("401") || errMessage.includes("authentication")) {
421
+ userMessage =
422
+ "Authentication failed with the AI provider. Please check your API key in Settings.";
423
+ } else if (errMessage.includes("429") || errMessage.includes("rate limit")) {
424
+ userMessage =
425
+ "Rate limit reached for the AI provider. Please wait a moment and try again.";
426
+ }
427
+ emitter.emit("error", JSON.stringify({ data: userMessage }));
428
+ }
429
+ }
430
+
431
+ async searchAndAnswer(
432
+ message: string,
433
+ history: ChatTurnMessage[],
434
+ llm: LanguageModel,
435
+ optimizationMode: "speed" | "balanced" | "quality",
436
+ fileIds: string[],
437
+ systemInstructions: string,
438
+ category: string = "general",
439
+ sourceExtractionEnabled = false,
440
+ thinkingTimeLimit = 0,
441
+ ) {
442
+ const emitter = new EventEmitter();
443
+
444
+ // Start on the next macrotask so callers can attach their event
445
+ // listeners before the first "sources"/"response" event can fire.
446
+ setTimeout(() => {
447
+ this.runPipeline(
448
+ emitter,
449
+ message,
450
+ history,
451
+ llm,
452
+ optimizationMode,
453
+ fileIds,
454
+ systemInstructions,
455
+ category,
456
+ sourceExtractionEnabled,
457
+ thinkingTimeLimit,
458
+ );
459
+ }, 0);
460
+
461
+ return emitter;
462
+ }
463
+ }
464
+
465
+ export default MetaSearchAgent;