@retrivora-ai/rag-engine 1.0.3 → 1.0.5
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/DocumentChunker-Dh9TvmGG.d.mts +45 -0
- package/dist/DocumentChunker-Dh9TvmGG.d.ts +45 -0
- package/dist/{RagConfig-DRJO4hGU.d.mts → RagConfig-BEmz4hVP.d.mts} +58 -1
- package/dist/{RagConfig-DRJO4hGU.d.ts → RagConfig-BEmz4hVP.d.ts} +58 -1
- package/dist/{chunk-3JR3SAWX.mjs → chunk-MWL4AGQI.mjs} +216 -108
- package/dist/handlers/index.d.mts +2 -2
- package/dist/handlers/index.d.ts +2 -2
- package/dist/handlers/index.js +218 -110
- package/dist/handlers/index.mjs +11 -3
- package/dist/index-CSfaCeux.d.ts +206 -0
- package/dist/index-DFSy0CG6.d.mts +206 -0
- package/dist/index.d.mts +3 -4
- package/dist/index.d.ts +3 -4
- package/dist/index.js +103 -89
- package/dist/index.mjs +91 -95
- package/dist/server.d.mts +45 -97
- package/dist/server.d.ts +45 -97
- package/dist/server.js +298 -225
- package/dist/server.mjs +78 -167
- package/package.json +12 -10
- package/src/components/ChatWindow.tsx +2 -2
- package/src/components/ConfigProvider.tsx +2 -2
- package/src/components/MessageBubble.tsx +2 -2
- package/src/components/SourceCard.tsx +1 -1
- package/src/components/ThemeToggle.tsx +1 -1
- package/src/config/ConfigBuilder.ts +86 -211
- package/src/config/uiConstants.ts +23 -0
- package/src/core/ConfigResolver.ts +57 -29
- package/src/core/LangChainAgent.ts +73 -40
- package/src/core/Pipeline.ts +63 -7
- package/src/core/ProviderRegistry.ts +2 -2
- package/src/core/QueryProcessor.ts +9 -8
- package/src/handlers/index.ts +138 -49
- package/src/hooks/useRagChat.ts +71 -32
- package/src/rag/EntityExtractor.ts +40 -13
- package/src/server.ts +12 -2
- package/dist/DocumentChunker-C-sCZPhi.d.mts +0 -102
- package/dist/DocumentChunker-C-sCZPhi.d.ts +0 -102
- package/dist/index-B2mutkgp.d.ts +0 -116
- package/dist/index-Bjy0es5a.d.mts +0 -116
|
@@ -3,76 +3,109 @@ import { RagConfig } from "../config/RagConfig";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* LangChainAgent — Orchestration layer that turns the RAG pipeline into an Agent.
|
|
6
|
-
*
|
|
7
|
-
* Uses dynamic imports to
|
|
6
|
+
*
|
|
7
|
+
* Uses dynamic imports to keep LangChain an optional peer dependency.
|
|
8
|
+
* Requires `langchain` (v1.x) and `@langchain/core` to be installed.
|
|
9
|
+
*
|
|
10
|
+
* LangChain v1.x migration notes:
|
|
11
|
+
* - `AgentExecutor` and `createOpenAIFunctionsAgent` are removed.
|
|
12
|
+
* - The replacement is `createAgent` from the `langchain` root package,
|
|
13
|
+
* which returns a `ReactAgent` (LangGraph-backed ReAct agent).
|
|
14
|
+
* - Invocation uses `{ messages: [...] }` instead of `{ input, chat_history }`.
|
|
8
15
|
*/
|
|
9
16
|
export class LangChainAgent {
|
|
10
|
-
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
private agent?: any;
|
|
11
19
|
|
|
12
20
|
constructor(private pipeline: Pipeline, private config: RagConfig) {}
|
|
13
21
|
|
|
14
22
|
/**
|
|
15
|
-
* Initializes the agent with the RAG pipeline as a
|
|
16
|
-
* Dynamically imports
|
|
23
|
+
* Initializes the LangChain ReAct agent with the RAG pipeline as a tool.
|
|
24
|
+
* Dynamically imports langchain so the package doesn't crash if it isn't installed.
|
|
17
25
|
*/
|
|
18
26
|
async initialize(chatModel: unknown) {
|
|
19
27
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
const [{ DynamicTool }, { HumanMessage }, { createAgent }] = await Promise.all([
|
|
29
|
+
import('@langchain/core/tools'),
|
|
30
|
+
import('@langchain/core/messages'),
|
|
31
|
+
import('langchain'),
|
|
32
|
+
]);
|
|
24
33
|
|
|
25
34
|
// 1. Define the RAG Search Tool
|
|
26
35
|
const searchTool = new DynamicTool({
|
|
27
|
-
name:
|
|
28
|
-
description:
|
|
36
|
+
name: 'document_search',
|
|
37
|
+
description:
|
|
38
|
+
'Use this tool to search through the knowledge base and document repository. Input should be a specific search query.',
|
|
29
39
|
func: async (query: string) => {
|
|
30
40
|
const response = await this.pipeline.ask(query);
|
|
31
|
-
return `Search Results:\n${response.reply}\n\nSources Used: ${JSON.stringify(
|
|
41
|
+
return `Search Results:\n${response.reply}\n\nSources Used: ${JSON.stringify(
|
|
42
|
+
response.sources.map((s) => s.id)
|
|
43
|
+
)}`;
|
|
32
44
|
},
|
|
33
45
|
});
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
[
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// 3. Create the Agent
|
|
46
|
-
const agent = await createOpenAIFunctionsAgent({
|
|
47
|
-
llm: chatModel,
|
|
48
|
-
tools,
|
|
49
|
-
prompt,
|
|
47
|
+
// 2. Create the ReAct agent (langchain v1.x API)
|
|
48
|
+
// - `model` replaces the old `llm` parameter
|
|
49
|
+
// - `createAgent` replaces `createOpenAIFunctionsAgent` + `AgentExecutor`
|
|
50
|
+
this.agent = createAgent({
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
model: chatModel as any,
|
|
53
|
+
tools: [searchTool],
|
|
54
|
+
systemPrompt: this.config.llm.systemPrompt ||
|
|
55
|
+
'You are a helpful AI assistant with access to a document search tool.',
|
|
50
56
|
});
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
tools,
|
|
55
|
-
});
|
|
58
|
+
// Suppress unused import warning — HumanMessage is used in run()
|
|
59
|
+
void HumanMessage;
|
|
56
60
|
} catch (error) {
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
const isMissing =
|
|
62
|
+
error instanceof Error && error.message.includes('Cannot find module');
|
|
63
|
+
const hint = isMissing
|
|
64
|
+
? " Make sure 'langchain' and '@langchain/core' are installed:\n npm install langchain @langchain/core"
|
|
65
|
+
: '';
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[LangChainAgent] Failed to initialize.${hint}\n${
|
|
68
|
+
error instanceof Error ? error.message : String(error)
|
|
69
|
+
}`
|
|
70
|
+
);
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
/**
|
|
63
75
|
* Run the agentic flow.
|
|
76
|
+
*
|
|
77
|
+
* LangChain v1.x: invoke takes `{ messages: [...] }` instead of `{ input, chat_history }`.
|
|
78
|
+
* The agent returns `{ messages: [...] }` — the last message is the final answer.
|
|
64
79
|
*/
|
|
65
80
|
async run(input: string, chatHistory: unknown[] = []) {
|
|
66
|
-
if (!this.
|
|
67
|
-
throw new Error(
|
|
81
|
+
if (!this.agent) {
|
|
82
|
+
throw new Error('[LangChainAgent] Agent not initialized. Call initialize() first.');
|
|
68
83
|
}
|
|
69
84
|
|
|
70
|
-
//
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
// Dynamically import HumanMessage to construct the message list
|
|
86
|
+
const { HumanMessage, AIMessage } = await import('@langchain/core/messages');
|
|
87
|
+
|
|
88
|
+
const historyMessages = (chatHistory as Array<{ role: string; content: string }>).map((m) =>
|
|
89
|
+
m.role === 'user' ? new HumanMessage(m.content) : new AIMessage(m.content)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const response = await this.agent.invoke({
|
|
93
|
+
messages: [...historyMessages, new HumanMessage(input)],
|
|
74
94
|
});
|
|
75
95
|
|
|
76
|
-
|
|
96
|
+
// Extract the text from the last message in the response
|
|
97
|
+
const lastMessage = response?.messages?.at(-1);
|
|
98
|
+
if (lastMessage && typeof lastMessage.content === 'string') {
|
|
99
|
+
return lastMessage.content;
|
|
100
|
+
}
|
|
101
|
+
// Fallback for structured content arrays
|
|
102
|
+
if (Array.isArray(lastMessage?.content)) {
|
|
103
|
+
return lastMessage.content
|
|
104
|
+
.filter((c: { type: string }) => c.type === 'text')
|
|
105
|
+
.map((c: { text: string }) => c.text)
|
|
106
|
+
.join('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return String(response?.output ?? response ?? '');
|
|
77
110
|
}
|
|
78
111
|
}
|
package/src/core/Pipeline.ts
CHANGED
|
@@ -13,6 +13,52 @@ import { EmbeddingStrategyResolver } from '../config/EmbeddingStrategy';
|
|
|
13
13
|
import { QueryProcessor } from './QueryProcessor';
|
|
14
14
|
import { IngestDocument, ChatResponse, VectorMatch, GraphSearchResult, RetrievalResult, UpsertDocument } from '../types';
|
|
15
15
|
|
|
16
|
+
// ─── LRU Embedding Cache ───────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A simple size-capped LRU cache for embedding vectors.
|
|
20
|
+
* Prevents the unbounded `Map` from consuming infinite memory under production load.
|
|
21
|
+
*/
|
|
22
|
+
class LRUEmbeddingCache {
|
|
23
|
+
private readonly cache = new Map<string, number[]>();
|
|
24
|
+
private readonly maxSize: number;
|
|
25
|
+
|
|
26
|
+
constructor(maxSize = 500) {
|
|
27
|
+
this.maxSize = maxSize;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get(key: string): number[] | undefined {
|
|
31
|
+
const value = this.cache.get(key);
|
|
32
|
+
if (value !== undefined) {
|
|
33
|
+
// Promote to most-recently-used position
|
|
34
|
+
this.cache.delete(key);
|
|
35
|
+
this.cache.set(key, value);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
set(key: string, value: number[]): void {
|
|
41
|
+
if (this.cache.has(key)) {
|
|
42
|
+
this.cache.delete(key);
|
|
43
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
44
|
+
// Evict the oldest (first) entry
|
|
45
|
+
const oldestKey = this.cache.keys().next().value;
|
|
46
|
+
if (oldestKey !== undefined) this.cache.delete(oldestKey);
|
|
47
|
+
}
|
|
48
|
+
this.cache.set(key, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
clear(): void {
|
|
52
|
+
this.cache.clear();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get size(): number {
|
|
56
|
+
return this.cache.size;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Pipeline ─────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
16
62
|
/**
|
|
17
63
|
* Pipeline — orchestrates the RAG flow: Embed → Search → Augment → Generate.
|
|
18
64
|
*
|
|
@@ -22,6 +68,7 @@ import { IngestDocument, ChatResponse, VectorMatch, GraphSearchResult, Retrieval
|
|
|
22
68
|
* - Smart embedding strategy (integrated / separate / external)
|
|
23
69
|
* - Error recovery for transient failures
|
|
24
70
|
* - Multi-tenancy support via namespacing
|
|
71
|
+
* - LRU-bounded embedding cache (max 500 entries, prevents memory leaks)
|
|
25
72
|
*/
|
|
26
73
|
export class Pipeline {
|
|
27
74
|
private vectorDB!: BaseVectorProvider;
|
|
@@ -33,7 +80,8 @@ export class Pipeline {
|
|
|
33
80
|
private entityExtractor?: EntityExtractor;
|
|
34
81
|
private reranker: Reranker;
|
|
35
82
|
private agent?: LangChainAgent;
|
|
36
|
-
|
|
83
|
+
/** LRU-bounded cache: avoids re-embedding identical queries within the same process. */
|
|
84
|
+
private embeddingCache = new LRUEmbeddingCache(500);
|
|
37
85
|
private initialised = false;
|
|
38
86
|
|
|
39
87
|
constructor(private config: RagConfig) {
|
|
@@ -144,6 +192,7 @@ export class Pipeline {
|
|
|
144
192
|
|
|
145
193
|
/**
|
|
146
194
|
* Step 2: Generate embeddings for chunks with retry logic.
|
|
195
|
+
* Uses batchEmbed when available for efficiency; falls back to sequential embedding.
|
|
147
196
|
*/
|
|
148
197
|
private async processEmbeddings(chunks: Chunk[]): Promise<number[][]> {
|
|
149
198
|
const embedBatchOptions: BatchOptions = {
|
|
@@ -158,8 +207,12 @@ export class Pipeline {
|
|
|
158
207
|
embedBatchOptions
|
|
159
208
|
);
|
|
160
209
|
|
|
210
|
+
// mapWithRetry filters out undefined on failure; guard against mismatch explicitly
|
|
161
211
|
if (vectors.length !== chunks.length) {
|
|
162
|
-
throw new Error(
|
|
212
|
+
throw new Error(
|
|
213
|
+
`[Pipeline] Embedding mismatch: got ${vectors.length} vectors for ${chunks.length} chunks. ` +
|
|
214
|
+
`Check embedding provider logs for individual chunk failures.`
|
|
215
|
+
);
|
|
163
216
|
}
|
|
164
217
|
|
|
165
218
|
return vectors;
|
|
@@ -313,12 +366,12 @@ export class Pipeline {
|
|
|
313
366
|
|
|
314
367
|
/**
|
|
315
368
|
* Universal retrieval method combining all enabled providers.
|
|
369
|
+
* Uses an LRU-bounded embedding cache to avoid re-embedding the same query.
|
|
316
370
|
*/
|
|
317
371
|
async retrieve(query: string, options: { namespace?: string; topK?: number; filter?: Record<string, unknown> }): Promise<RetrievalResult> {
|
|
318
372
|
const ns = options.namespace ?? this.config.projectId;
|
|
319
373
|
const topK = options.topK ?? 5;
|
|
320
374
|
|
|
321
|
-
// Performance Optimization: Cache query embeddings to avoid redundant LLM calls
|
|
322
375
|
const cacheKey = `${ns}::${query}`;
|
|
323
376
|
let queryVector = this.embeddingCache.get(cacheKey);
|
|
324
377
|
|
|
@@ -354,10 +407,13 @@ New Question: ${question}
|
|
|
354
407
|
|
|
355
408
|
Optimized Search Query:`;
|
|
356
409
|
|
|
357
|
-
const rewrite = await this.llmProvider.chat(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
410
|
+
const rewrite = await this.llmProvider.chat(
|
|
411
|
+
[
|
|
412
|
+
{ role: 'system', content: 'You are an assistant that optimizes search queries for RAG systems.' },
|
|
413
|
+
{ role: 'user', content: prompt }
|
|
414
|
+
],
|
|
415
|
+
''
|
|
416
|
+
);
|
|
361
417
|
|
|
362
418
|
return rewrite.trim() || question;
|
|
363
419
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BaseVectorProvider } from '../providers/vectordb/BaseVectorProvider';
|
|
2
|
-
import { BaseGraphProvider } from '
|
|
2
|
+
import { BaseGraphProvider } from '../providers/graphdb/BaseGraphProvider';
|
|
3
3
|
import { VectorDBConfig, GraphDBConfig, LLMConfig, EmbeddingConfig } from '../config/RagConfig';
|
|
4
4
|
import { LLMFactory } from '../llm/LLMFactory';
|
|
5
5
|
import { ILLMProvider } from '../llm/ILLMProvider';
|
|
@@ -127,7 +127,7 @@ export class ProviderRegistry {
|
|
|
127
127
|
|
|
128
128
|
switch (provider) {
|
|
129
129
|
case 'simple': {
|
|
130
|
-
const { SimpleGraphProvider } = await import('
|
|
130
|
+
const { SimpleGraphProvider } = await import('../providers/graphdb/SimpleGraphProvider');
|
|
131
131
|
return new SimpleGraphProvider(config);
|
|
132
132
|
}
|
|
133
133
|
default:
|
|
@@ -137,7 +137,7 @@ export class QueryProcessor {
|
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
// 6. Capitalized proper nouns
|
|
140
|
+
// 6. Capitalized proper nouns — added here so buildQueryFilter can reuse them
|
|
141
141
|
for (const match of question.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\b/g)) {
|
|
142
142
|
addHint(match[0]);
|
|
143
143
|
}
|
|
@@ -147,6 +147,10 @@ export class QueryProcessor {
|
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
149
|
* Constructs a QueryFilter object from extracted hints.
|
|
150
|
+
*
|
|
151
|
+
* Note: proper nouns are extracted inside `extractQueryFieldHints` and surfaced
|
|
152
|
+
* as field-less hints; `buildQueryFilter` adds them to `keywords` without
|
|
153
|
+
* re-running the proper noun regex to avoid duplication.
|
|
150
154
|
*/
|
|
151
155
|
static buildQueryFilter(question: string, hints: QueryFieldHint[]): QueryFilter {
|
|
152
156
|
const filter: QueryFilter = { metadata: {}, keywords: [], queryText: question };
|
|
@@ -155,16 +159,13 @@ export class QueryProcessor {
|
|
|
155
159
|
if (hint.field) {
|
|
156
160
|
filter.metadata![hint.field] = hint.value;
|
|
157
161
|
} else {
|
|
158
|
-
|
|
162
|
+
// hint.value already includes proper nouns from extractQueryFieldHints
|
|
163
|
+
if (!filter.keywords!.includes(hint.value)) {
|
|
164
|
+
filter.keywords!.push(hint.value);
|
|
165
|
+
}
|
|
159
166
|
}
|
|
160
167
|
}
|
|
161
168
|
|
|
162
|
-
// Capture proper-noun sequences as keywords
|
|
163
|
-
for (const match of question.matchAll(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,3}\b/g)) {
|
|
164
|
-
const term = this.normalizeHintValue(match[0]);
|
|
165
|
-
if (term && !filter.keywords!.includes(term)) filter.keywords!.push(term);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
169
|
if (Object.keys(filter.metadata || {}).length === 0) delete filter.metadata;
|
|
169
170
|
if (filter.keywords && filter.keywords.length === 0) delete filter.keywords;
|
|
170
171
|
|
package/src/handlers/index.ts
CHANGED
|
@@ -4,11 +4,60 @@ import { RagConfig } from '../config/RagConfig';
|
|
|
4
4
|
import { ChatMessage } from '../llm/ILLMProvider';
|
|
5
5
|
import { DocumentParser } from '../utils/DocumentParser';
|
|
6
6
|
|
|
7
|
+
// ─── SSE helpers ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encode a payload as a Server-Sent Events frame.
|
|
11
|
+
* data: <json>\n\n
|
|
12
|
+
*/
|
|
13
|
+
function sseFrame(payload: unknown): string {
|
|
14
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Encode a plain text chunk as an SSE frame. */
|
|
18
|
+
function sseTextFrame(text: string): string {
|
|
19
|
+
return `data: ${JSON.stringify({ type: 'text', text })}\n\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Encode the retrieval metadata as an SSE frame. */
|
|
23
|
+
function sseMetaFrame(meta: unknown): string {
|
|
24
|
+
return `data: ${JSON.stringify({ type: 'metadata', ...((meta as object) ?? {}) })}\n\n`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Encode a stream error as an SSE frame. */
|
|
28
|
+
function sseErrorFrame(message: string): string {
|
|
29
|
+
return `data: ${JSON.stringify({ type: 'error', error: message })}\n\n`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** SSE response headers. */
|
|
33
|
+
const SSE_HEADERS: Record<string, string> = {
|
|
34
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
35
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
36
|
+
Connection: 'keep-alive',
|
|
37
|
+
'X-Accel-Buffering': 'no', // Disable Nginx buffering for streaming
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ─── Handler Factories ────────────────────────────────────────────────────────
|
|
41
|
+
|
|
7
42
|
/**
|
|
8
|
-
* createChatHandler — factory that returns a Next.js App Router POST handler
|
|
43
|
+
* createChatHandler — factory that returns a Next.js App Router POST handler.
|
|
44
|
+
*
|
|
45
|
+
* Accepts either a full/partial `RagConfig` **or** a pre-built `VectorPlugin`
|
|
46
|
+
* instance (preferred for singleton/multi-tenant setups).
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // Option A — pass config (plugin created once at module load time)
|
|
50
|
+
* export const POST = createChatHandler(getRagConfig());
|
|
51
|
+
*
|
|
52
|
+
* // Option B — pass a pre-built plugin (most flexible)
|
|
53
|
+
* const plugin = new VectorPlugin(getRagConfig());
|
|
54
|
+
* export const POST = createChatHandler(plugin);
|
|
9
55
|
*/
|
|
10
|
-
export function createChatHandler(
|
|
11
|
-
const plugin =
|
|
56
|
+
export function createChatHandler(configOrPlugin?: Partial<RagConfig> | VectorPlugin) {
|
|
57
|
+
const plugin =
|
|
58
|
+
configOrPlugin instanceof VectorPlugin
|
|
59
|
+
? configOrPlugin
|
|
60
|
+
: new VectorPlugin(configOrPlugin);
|
|
12
61
|
|
|
13
62
|
return async function POST(req: NextRequest) {
|
|
14
63
|
try {
|
|
@@ -33,63 +82,94 @@ export function createChatHandler(config?: Partial<RagConfig>) {
|
|
|
33
82
|
}
|
|
34
83
|
|
|
35
84
|
/**
|
|
36
|
-
* createStreamHandler — factory
|
|
85
|
+
* createStreamHandler — factory for a streaming SSE Next.js App Router POST handler.
|
|
86
|
+
*
|
|
87
|
+
* Streams the assistant response as Server-Sent Events (SSE). Each frame is a
|
|
88
|
+
* JSON object with a `type` discriminant:
|
|
89
|
+
* - `{ type: "text", text: "..." }` — incremental text chunk
|
|
90
|
+
* - `{ type: "metadata", sources: [...] }` — retrieval metadata (last frame)
|
|
91
|
+
* - `{ type: "error", error: "..." }` — stream-level error
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* // src/app/api/chat/route.ts
|
|
95
|
+
* export const POST = createStreamHandler(getRagConfig());
|
|
96
|
+
*
|
|
97
|
+
* // Client-side parsing in useRagChat.ts:
|
|
98
|
+
* // const lines = chunk.split('\n');
|
|
99
|
+
* // for (const line of lines) {
|
|
100
|
+
* // if (line.startsWith('data: ')) {
|
|
101
|
+
* // const frame = JSON.parse(line.slice(6));
|
|
102
|
+
* // if (frame.type === 'text') { ... }
|
|
103
|
+
* // }
|
|
104
|
+
* // }
|
|
37
105
|
*/
|
|
38
|
-
export function createStreamHandler(
|
|
39
|
-
const plugin =
|
|
106
|
+
export function createStreamHandler(configOrPlugin?: Partial<RagConfig> | VectorPlugin) {
|
|
107
|
+
const plugin =
|
|
108
|
+
configOrPlugin instanceof VectorPlugin
|
|
109
|
+
? configOrPlugin
|
|
110
|
+
: new VectorPlugin(configOrPlugin);
|
|
40
111
|
|
|
41
112
|
return async function POST(req: NextRequest) {
|
|
113
|
+
// Parse body first so we can return a normal JSON 400 before opening the stream
|
|
114
|
+
let body: { message?: string; history?: ChatMessage[]; namespace?: string };
|
|
42
115
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
async start(controller) {
|
|
49
|
-
try {
|
|
50
|
-
const pipelineStream = plugin.chatStream(message, history, namespace);
|
|
51
|
-
|
|
52
|
-
for await (const chunk of pipelineStream) {
|
|
53
|
-
if (typeof chunk === 'string') {
|
|
54
|
-
controller.enqueue(encoder.encode(chunk));
|
|
55
|
-
} else {
|
|
56
|
-
// Yield metadata/sources at the end
|
|
57
|
-
controller.enqueue(encoder.encode(`\n\n__METADATA__${JSON.stringify(chunk)}`));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
controller.close();
|
|
61
|
-
} catch (streamError) {
|
|
62
|
-
console.error('[createStreamHandler] Stream processing error:', streamError);
|
|
63
|
-
const errorMessage = streamError instanceof Error ? streamError.message : String(streamError);
|
|
64
|
-
controller.enqueue(encoder.encode(`\n\n__ERROR__${JSON.stringify({ error: errorMessage })}`));
|
|
65
|
-
controller.close();
|
|
66
|
-
}
|
|
67
|
-
},
|
|
116
|
+
body = await req.json();
|
|
117
|
+
} catch {
|
|
118
|
+
return new Response(JSON.stringify({ error: 'Invalid JSON body' }), {
|
|
119
|
+
status: 400,
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
68
121
|
});
|
|
122
|
+
}
|
|
69
123
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
} catch (err) {
|
|
78
|
-
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
79
|
-
return new Response(JSON.stringify({ error: message }), {
|
|
80
|
-
status: 500,
|
|
124
|
+
const { message, history = [], namespace } = body;
|
|
125
|
+
|
|
126
|
+
if (!message?.trim()) {
|
|
127
|
+
return new Response(JSON.stringify({ error: 'message is required' }), {
|
|
128
|
+
status: 400,
|
|
81
129
|
headers: { 'Content-Type': 'application/json' },
|
|
82
130
|
});
|
|
83
131
|
}
|
|
132
|
+
|
|
133
|
+
const encoder = new TextEncoder();
|
|
134
|
+
|
|
135
|
+
const stream = new ReadableStream({
|
|
136
|
+
async start(controller) {
|
|
137
|
+
const enqueue = (text: string) => controller.enqueue(encoder.encode(text));
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const pipelineStream = plugin.chatStream(message, history, namespace);
|
|
141
|
+
|
|
142
|
+
for await (const chunk of pipelineStream) {
|
|
143
|
+
if (typeof chunk === 'string') {
|
|
144
|
+
enqueue(sseTextFrame(chunk));
|
|
145
|
+
} else {
|
|
146
|
+
// Retrieval metadata object — always the final frame
|
|
147
|
+
enqueue(sseMetaFrame(chunk));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (streamError) {
|
|
151
|
+
const errorMessage =
|
|
152
|
+
streamError instanceof Error ? streamError.message : String(streamError);
|
|
153
|
+
console.error('[createStreamHandler] Stream error:', streamError);
|
|
154
|
+
enqueue(sseErrorFrame(errorMessage));
|
|
155
|
+
} finally {
|
|
156
|
+
controller.close();
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return new Response(stream, { headers: SSE_HEADERS });
|
|
84
162
|
};
|
|
85
163
|
}
|
|
86
164
|
|
|
87
|
-
|
|
88
165
|
/**
|
|
89
166
|
* createIngestHandler — factory for the document ingestion endpoint.
|
|
90
167
|
*/
|
|
91
|
-
export function createIngestHandler(
|
|
92
|
-
const plugin =
|
|
168
|
+
export function createIngestHandler(configOrPlugin?: Partial<RagConfig> | VectorPlugin) {
|
|
169
|
+
const plugin =
|
|
170
|
+
configOrPlugin instanceof VectorPlugin
|
|
171
|
+
? configOrPlugin
|
|
172
|
+
: new VectorPlugin(configOrPlugin);
|
|
93
173
|
|
|
94
174
|
return async function POST(req: NextRequest) {
|
|
95
175
|
try {
|
|
@@ -115,8 +195,11 @@ export function createIngestHandler(config?: Partial<RagConfig>) {
|
|
|
115
195
|
/**
|
|
116
196
|
* createHealthHandler — factory for the health-check endpoint.
|
|
117
197
|
*/
|
|
118
|
-
export function createHealthHandler(
|
|
119
|
-
const plugin =
|
|
198
|
+
export function createHealthHandler(configOrPlugin?: Partial<RagConfig> | VectorPlugin) {
|
|
199
|
+
const plugin =
|
|
200
|
+
configOrPlugin instanceof VectorPlugin
|
|
201
|
+
? configOrPlugin
|
|
202
|
+
: new VectorPlugin(configOrPlugin);
|
|
120
203
|
|
|
121
204
|
return async function GET() {
|
|
122
205
|
try {
|
|
@@ -137,8 +220,11 @@ export function createHealthHandler(config?: Partial<RagConfig>) {
|
|
|
137
220
|
/**
|
|
138
221
|
* createUploadHandler — factory for the file upload ingestion endpoint.
|
|
139
222
|
*/
|
|
140
|
-
export function createUploadHandler(
|
|
141
|
-
const plugin =
|
|
223
|
+
export function createUploadHandler(configOrPlugin?: Partial<RagConfig> | VectorPlugin) {
|
|
224
|
+
const plugin =
|
|
225
|
+
configOrPlugin instanceof VectorPlugin
|
|
226
|
+
? configOrPlugin
|
|
227
|
+
: new VectorPlugin(configOrPlugin);
|
|
142
228
|
|
|
143
229
|
return async function POST(req: NextRequest) {
|
|
144
230
|
try {
|
|
@@ -174,3 +260,6 @@ export function createUploadHandler(config?: Partial<RagConfig>) {
|
|
|
174
260
|
}
|
|
175
261
|
};
|
|
176
262
|
}
|
|
263
|
+
|
|
264
|
+
// Re-export SSE helper so host apps can use it in custom handlers
|
|
265
|
+
export { sseFrame, sseTextFrame, sseMetaFrame, sseErrorFrame };
|