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.
- package/dist/research-agent.cjs.js +1 -1
- package/dist/research-agent.cjs.js.map +1 -1
- package/dist/research-agent.es.js +1 -1
- package/dist/research-agent.es.js.map +1 -1
- package/package.json +1 -1
- package/src/config/config-manager.ts +295 -295
- package/src/config/config-types.ts +65 -65
- package/src/config/environment-variables.ts +16 -16
- package/src/config/index.ts +26 -26
- package/src/config/language-models-database.ts +1434 -1434
- package/src/config/mcp-server-registry.ts +24 -24
- package/src/config/model-registry.ts +271 -271
- package/src/config/model-tester.ts +211 -211
- package/src/config/model-utils.ts +193 -193
- package/src/config/provider-ui-config.ts +196 -196
- package/src/connectors/open-connector.json +130 -130
- package/src/index.ts +26 -26
- package/src/memory/ARCHITECTURE.md +302 -302
- package/src/memory/README.md +224 -224
- package/src/memory/agent-memory-manager.ts +416 -416
- package/src/memory/example.ts +343 -343
- package/src/memory/index.ts +47 -47
- package/src/memory/mastra-integration.ts +604 -604
- package/src/memory/storage/drizzle-storage.ts +236 -236
- package/src/memory/storage/in-memory-storage.ts +551 -551
- package/src/memory/storage/storage-interface.ts +68 -68
- package/src/memory/types.ts +125 -125
- package/src/models/types.ts +4 -4
- package/src/provider-logos/01-ai.png +0 -0
- package/src/provider-logos/anthropic.png +0 -0
- package/src/provider-logos/aws-bedrock.png +0 -0
- package/src/provider-logos/aws-sagemaker.png +0 -0
- package/src/provider-logos/azure-openai-service.png +0 -0
- package/src/provider-logos/chatglm.png +0 -0
- package/src/provider-logos/cohere.png +0 -0
- package/src/provider-logos/gemini.png +0 -0
- package/src/provider-logos/groqcloud.png +0 -0
- package/src/provider-logos/huggingface.png +0 -0
- package/src/provider-logos/jina.png +0 -0
- package/src/provider-logos/localai.png +0 -0
- package/src/provider-logos/mistral-ai.png +0 -0
- package/src/provider-logos/moonshot-ai.png +0 -0
- package/src/provider-logos/ollama.png +0 -0
- package/src/provider-logos/openai.png +0 -0
- package/src/provider-logos/openllm.png +0 -0
- package/src/provider-logos/openrouter.png +0 -0
- package/src/provider-logos/replicate.png +0 -0
- package/src/provider-logos/together-ai.png +0 -0
- package/src/provider-logos/tongyi.png +0 -0
- package/src/provider-logos/xorbits-inference.png +0 -0
- package/src/provider-logos/zhipu-ai.png +0 -0
- package/src/tools/index.ts +13 -13
- package/src/tools/open-connector-mastra.ts +270 -270
- package/src/tools/open-connector-mcp.ts +170 -170
- package/src/tools/qwksearch-api-tools.ts +326 -326
- package/src/tools/search/doc-utils.ts +139 -139
- package/src/tools/search/document.ts +47 -47
- package/src/tools/search/index.ts +14 -14
- package/src/tools/search/link-summarizer.ts +112 -112
- package/src/tools/search/meta-search-types.ts +62 -62
- package/src/tools/search/metaSearchAgent.ts +465 -465
- package/src/tools/search/search-handlers.ts +97 -97
- package/src/tools/search/suggestionGeneratorAgent.ts +56 -56
- package/src/types.d.ts +137 -137
- package/src/utils/index.ts +2 -2
- package/src/utils/markdown-to-html.ts +53 -53
- 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(/ /g, ' ')
|
|
16
|
-
.replace(/&/g, '&')
|
|
17
|
-
.replace(/</g, '<')
|
|
18
|
-
.replace(/>/g, '>')
|
|
19
|
-
.replace(/"/g, '"')
|
|
20
|
-
.replace(/'/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(/ /g, ' ')
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/'/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;
|